From 8a0d93307412631a6affc7d81a7d8e7460e479ef Mon Sep 17 00:00:00 2001 From: hrz <1710360675@qq.com> Date: Sat, 24 Oct 2026 08:18:40 +0000 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=E8=BF=99=E4=B8=80?= =?UTF-8?q?=E4=BE=A7=E4=B8=8D=E5=86=8D=E8=87=AA=E5=B7=B1=E5=AD=98=E5=A4=A9?= =?UTF-8?q?=E6=B0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来提示词这条路自己存了一份,插件给的失败提示被当成天气报告存了 8 小时,网络好了也出不来天气。现在这边不存了,成功和失败都交给插件那边的缓存管:成功的存 8 小时,失败的存 5 分钟。存量空条目照旧清掉,管理员配好凭据后下一次问就能出天气。 --- .../core/utils/prompt_manager.py | 63 ++++++++-------- .../core/utils/test_prompt_manager_weather.py | 72 ++++++++++++++++--- 2 files changed, 91 insertions(+), 44 deletions(-) diff --git a/main/xiaozhi-server/core/utils/prompt_manager.py b/main/xiaozhi-server/core/utils/prompt_manager.py index 2b3b9d35c..94016396b 100644 --- a/main/xiaozhi-server/core/utils/prompt_manager.py +++ b/main/xiaozhi-server/core/utils/prompt_manager.py @@ -64,8 +64,8 @@ class PromptManager: self.logger = logger or setup_logging() self.base_prompt_template = None self.last_update_time = 0 - # 天气取不到时给用户的一句话,提示词要用它占位 - self._weather_notice = "" + # 这次取到的天气内容,取不到时是一句给用户的说明 + self._weather_content = "" # 导入全局缓存管理器 from core.utils.cache.manager import cache_manager, CacheType @@ -172,17 +172,19 @@ class PromptManager: return "未知位置" def _get_weather_info(self, conn: "ConnectionHandler", location: str) -> str: - """获取天气信息""" + """获取天气信息 + +
+ 这一路不再自己存天气。插件那边存了:成功的报告 8 小时,失败的提示 + 5 分钟。这边再存一份,存的是插件给什么就存什么,失败提示会被存成 + 8 小时,网络恢复了、凭据配好了也出不来天气。 + """ try: - # 先从缓存获取 - cached_weather = self.cache_manager.get(self.CacheType.WEATHER, location) - if cached_weather: - return cached_weather - # 缓存里留着空条目说明上次没取到,留着会挡住下一次取, - # 删掉重来,开发者把凭据配好之后下一次就该出天气 + # 以前这条路过缓存,存进去的条目是空的时候,管理员把凭据配好之后 + # 还得等它过期才出天气。没人读它了,清掉 self.cache_manager.delete(self.CacheType.WEATHER, location) - # 缓存未命中,调用 async get_weather 函数 + # 调用 async get_weather 函数 # Windows ProactorEventLoop 不支持 run_coroutine_threadsafe().result() # 因此用 call_soon_threadsafe 提交任务 + threading.Event 等待结果 # 注意:Event.wait() 只阻塞当前线程池线程,不阻塞主事件循环 @@ -212,25 +214,23 @@ class PromptManager: if isinstance(result, ActionResponse): weather_report = result.result if weather_report: - self.cache_manager.set( - self.CacheType.WEATHER, location, weather_report - ) - self._weather_notice = "" + # 成功还是失败,插件那边分得很清楚:成功的存 8 小时, + # 失败的只存 5 分钟。这边原样用,不另存 + self._weather_content = weather_report return weather_report - # 凭据没配好时插件给不出报告。这种结果不写进缓存: - # 空条目留着的话,开发者配好凭据后还得等缓存过期才出天气 + # 凭据没配好时插件给不出报告 if result.action == Action.RESPONSE: - self._weather_notice = WEATHER_NOT_CONFIGURED_NOTICE + self._weather_content = WEATHER_NOT_CONFIGURED_NOTICE else: - self._weather_notice = WEATHER_UNAVAILABLE_NOTICE - return self._weather_notice - self._weather_notice = WEATHER_UNAVAILABLE_NOTICE - return self._weather_notice + self._weather_content = WEATHER_UNAVAILABLE_NOTICE + return self._weather_content + self._weather_content = WEATHER_UNAVAILABLE_NOTICE + return self._weather_content except Exception as e: self.logger.bind(tag=TAG).error(f"获取天气信息失败: {e}") - self._weather_notice = WEATHER_UNAVAILABLE_NOTICE - return self._weather_notice + self._weather_content = WEATHER_UNAVAILABLE_NOTICE + return self._weather_content def update_context_info(self, conn, client_ip: str): """同步更新上下文信息""" @@ -270,17 +270,14 @@ class PromptManager: except Exception as e: self.logger.bind(tag=TAG).error(f"更新上下文信息失败: {e}") - def _get_weather_context(self, location: str) -> str: + def _get_weather_context(self) -> str: """给提示词用的天气内容
- 缓存里没有有效报告时给一句说明,不留空。占位符空着的话,模型会自己 - 编一个天气出来。 + 天气没取到时给一句说明,不留空。占位符空着的话,模型会自己编一个 + 天气出来。 """ - cached_weather = self.cache_manager.get(self.CacheType.WEATHER, location) - if cached_weather: - return cached_weather - return self._weather_notice or WEATHER_UNAVAILABLE_NOTICE + return self._weather_content or WEATHER_UNAVAILABLE_NOTICE def build_enhanced_prompt( self, user_prompt: str, device_id: str, client_ip: str = None, *args, **kwargs @@ -295,7 +292,6 @@ class PromptManager: # 获取缓存的上下文信息 local_address = "" - weather_info = WEATHER_UNAVAILABLE_NOTICE if client_ip: # 获取位置信息(从全局缓存) @@ -303,9 +299,8 @@ class PromptManager: self.cache_manager.get(self.CacheType.LOCATION, client_ip) or "" ) - # 获取天气信息(从全局缓存) - if local_address: - weather_info = self._get_weather_context(local_address) + # 获取天气信息(取天气的那一步已经把内容放在管理器上了) + weather_info = self._get_weather_context() # 获取TTS选择的语言,默认值为中文 language = ( diff --git a/main/xiaozhi-server/tests/core/utils/test_prompt_manager_weather.py b/main/xiaozhi-server/tests/core/utils/test_prompt_manager_weather.py index 7a1ce786e..68481784c 100644 --- a/main/xiaozhi-server/tests/core/utils/test_prompt_manager_weather.py +++ b/main/xiaozhi-server/tests/core/utils/test_prompt_manager_weather.py @@ -2,7 +2,7 @@ 天气有两条路进模型:工具调用那条走插件,提示词那条走 {weather_info} 占位符。 这条用例盯的是占位符那条:凭据没配好时它得给用户一句说明,不能留空让模型 -自己编天气;取到的报告也不能被空条目挡住。 +自己编天气;取到的报告和失败提示也不能被塞进长缓存。 """ from __future__ import annotations @@ -18,6 +18,7 @@ _PROJECT_ROOT = Path(__file__).resolve().parents[3] # 提示词里不能出现这个路径,模型会照着念给终端用户 CREDENTIAL_HINT = "尚未配置和风天气 api_key,请参考 docs/weather-integration.md 申请后填入" WEATHER_REPORT = "您查询的位置是:北京\n\n当前天气: 晴\n" +FAILURE_MESSAGE = "暂时查不到 北京 的天气(城市未找到或天气服务不可用),请稍后再试" @pytest.fixture(autouse=True) @@ -67,18 +68,32 @@ class _FakeConn: self.device_id = "test-device" -def _install_fake_plugin(monkeypatch): - """把真插件换成假的:按有没有 api_key 走两种返回""" +def _install_fake_plugin(monkeypatch, responses=None): + """把真插件换成假的:按有没有 api_key 走两种返回 + +
+ responses 给了就按顺序回,回完最后一条就一直回最后一条。返回调用次数, + 用例要看插件被调了几次。 + """ import plugins_func.functions.get_weather as weather_module from plugins_func.register import Action, ActionResponse + calls = [] + async def _fake_get_weather(conn, location=None, lang="zh_CN"): - api_key = str(conn.config.get("plugins", {}).get("get_weather", {}).get("api_key") or "") + api_key = str( + conn.config.get("plugins", {}).get("get_weather", {}).get("api_key") or "" + ) + calls.append(location) if not api_key.strip(): return ActionResponse(Action.RESPONSE, None, CREDENTIAL_HINT) + if responses: + index = min(len(calls) - 1, len(responses) - 1) + return responses[index] return ActionResponse(Action.REQLLM, WEATHER_REPORT, None) monkeypatch.setattr(weather_module, "get_weather", _fake_get_weather) + return calls @pytest.fixture @@ -135,13 +150,13 @@ def test_unconfigured_weather_gives_notice_and_caches_nothing( @pytest.mark.parametrize("stale", [None, ""]) -def test_empty_cached_entry_does_not_block_weather( +def test_empty_cached_entry_is_cleared_and_weather_is_fetched_again( monkeypatch, _manager, _cache, stale ): - """缓存里留着空条目时,配好凭据后下一次就该出天气""" + """缓存里已经有空条目时,配好凭据后下一次就该出天气""" from core.utils.cache.manager import CacheType - _install_fake_plugin(monkeypatch) + calls = _install_fake_plugin(monkeypatch) loop = _FakeLoop() conn = _FakeConn(loop, api_key="not-a-real-key") _cache.set(CacheType.LOCATION, conn.client_ip, "北京") @@ -153,13 +168,49 @@ def test_empty_cached_entry_does_not_block_weather( loop.close() assert "当前天气: 晴" in prompt - assert _cache.get(CacheType.WEATHER, "北京") == WEATHER_REPORT + # 存量空条目被清掉了,没有留在缓存里 + assert _cache.get(CacheType.WEATHER, "北京") is None + assert len(calls) == 1 + + +def test_failed_weather_is_not_cached_and_is_fetched_again( + monkeypatch, _manager, _cache +): + """插件说查不到天气时,那句话不能进长缓存,下一次要重新取""" + from core.utils.cache.manager import CacheType + from plugins_func.register import Action, ActionResponse + + calls = _install_fake_plugin( + monkeypatch, + responses=[ + ActionResponse(Action.REQLLM, FAILURE_MESSAGE, None), + ActionResponse(Action.REQLLM, WEATHER_REPORT, None), + ], + ) + loop = _FakeLoop() + conn = _FakeConn(loop, api_key="not-a-real-key") + _cache.set(CacheType.LOCATION, conn.client_ip, "北京") + + try: + first_prompt = _build_prompt(_manager, conn) + # 失败提示进提示词,用户能听到「暂时查不到」 + assert "暂时查不到" in first_prompt + # 但它不能被存进 8 小时那档缓存,不然网络恢复了就出不来天气 + assert _cache.get(CacheType.WEATHER, "北京") is None + + second_prompt = _build_prompt(_manager, conn) + finally: + loop.close() + + # 下一次重新取,插件被调了两次 + assert len(calls) == 2 + assert "当前天气: 晴" in second_prompt def test_configured_weather_report_goes_to_placeholder( monkeypatch, _manager, _cache ): - """凭据齐全:报告照旧进占位符,也照旧进缓存""" + """凭据齐全:报告照旧进占位符,缓存由插件那边管""" from core.utils.cache.manager import CacheType _install_fake_plugin(monkeypatch) @@ -174,4 +225,5 @@ def test_configured_weather_report_goes_to_placeholder( assert "当前天气: 晴" in prompt assert "未配置" not in prompt - assert _cache.get(CacheType.WEATHER, "北京") == WEATHER_REPORT + # 提示词这一侧不再自己存一份,成功那份由插件按 8 小时存 + assert _cache.get(CacheType.WEATHER, "北京") is None