Files
Hy-Memory/tests/coding/test_search_scene_routing.py
T
2026-10-16 16:26:27 +08:00

590 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
search() / async_search() 的 scene 参数 + queries-rewrite 路由测试。
覆盖三分支:
1) scene 显式 ("coding"|"chat") → 不调 LLM
2) search_mode=default + 没 scene → 固定 chat/normal,零 LLM
3) search_mode=agentic + 没 scene → 三分类 + query rewrite
外加:
- LLM 失败 / 解析失败 → fail-safe 走 chat
- scene 非法值 → ValueError
- scene=coding 但 coding.enable=False → fall back chat
- classify_and_rewrite_queries 单元测试
"""
import asyncio
import pytest
from hy_memory.coding.judge import classify_and_rewrite_queries
from tests.coding._helpers import MockLLMProvider, FailingLLMProvider
# ================================================================
# Phase 1: classify_and_rewrite_queries 单元测试(不需要真实 client)
# ================================================================
class TestClassifyAndRewrite:
def test_coding_with_rewrite(self):
async def _run():
llm = MockLLMProvider("1\n如何把 hy_memory 项目发布到私有包索引")
result = await classify_and_rewrite_queries(
["怎么发布", "用 twine 还是手动?", "我说的是发布到私有包索引"],
llm,
)
assert result["ok"] is True
assert result["is_coding"] is True
assert result["rewrite_query"] == "如何把 hy_memory 项目发布到私有包索引"
asyncio.run(_run())
def test_chat_with_rewrite(self):
async def _run():
llm = MockLLMProvider("0\n用户最喜欢什么颜色")
result = await classify_and_rewrite_queries(
["你喜欢什么颜色", "我呢"], llm,
)
assert result["ok"] is True
assert result["is_coding"] is False
assert result["rewrite_query"] == "用户最喜欢什么颜色"
asyncio.run(_run())
def test_no_rewrite_keeps_target(self):
"""LLM 返回 0/1 但没给 rewrite → 用原 target"""
async def _run():
llm = MockLLMProvider("1")
result = await classify_and_rewrite_queries(["foo"], llm)
assert result["ok"] is True
assert result["is_coding"] is True
assert result["rewrite_query"] == "foo"
asyncio.run(_run())
def test_unrecognized_head_falls_back(self):
"""LLM 输出不是 0/1 → ok=False 但仍给 rewrite"""
async def _run():
llm = MockLLMProvider("maybe\nthe rewrite")
result = await classify_and_rewrite_queries(["foo"], llm)
assert result["ok"] is False
assert result["is_coding"] is False
assert result["rewrite_query"] == "the rewrite"
asyncio.run(_run())
def test_llm_failure(self):
async def _run():
result = await classify_and_rewrite_queries(["foo"], FailingLLMProvider())
assert result["ok"] is False
assert result["is_coding"] is False
assert result["rewrite_query"] == "foo"
asyncio.run(_run())
def test_empty_queries(self):
async def _run():
llm = MockLLMProvider("1\nrewrite")
result = await classify_and_rewrite_queries([], llm)
assert result["ok"] is False
assert result["rewrite_query"] == ""
asyncio.run(_run())
def test_markdown_fence_stripped(self):
async def _run():
llm = MockLLMProvider("```\n1\nrewritten X\n```")
result = await classify_and_rewrite_queries(["x"], llm)
assert result["ok"] is True
assert result["is_coding"] is True
assert result["rewrite_query"] == "rewritten X"
asyncio.run(_run())
# ================================================================
# Phase 2: scene 参数路由 — 用最小 SimpleNamespace 模拟 client,
# 直接覆盖 async_search 入口的路由逻辑(不依赖完整 client init)
# ================================================================
#
# 此处复用现有 search_ctx fixture(含真实 store + mock embed);scene 路由
# 在 client.async_search 顶部,store 是否被实际调用由我们在 mock client 中
# 短路掉。
# ================================================================
import time
import uuid
import asyncio
from types import SimpleNamespace
from typing import List, Optional, Any, Dict
from hy_memory.client import HyMemoryClient
from hy_memory.coding.types import CodingMemory
@pytest.fixture
async def search_ctx_with_llm(tmp_data_dir, mock_embed):
"""复用 test_search_e2e 的 ctx 思路:真实 store + mock embed + 可注入 mock LLM"""
import os, uuid
from hy_memory.config import MemoryConfig
from hy_memory.coding.store import CodingMemoryStore
cfg = MemoryConfig.from_env()
cfg.history.db_path = os.path.join(tmp_data_dir, "history.db")
cfg.coding.db_path = os.path.join(tmp_data_dir, "coding_memory.db")
cfg.coding.enable = True
cfg.vector_store.provider = "chroma"
cfg.vector_store.path = os.path.join(tmp_data_dir, "chroma")
cfg.vector_store.collection_name = f"test_scene_{uuid.uuid4().hex[:8]}"
cfg.vector_store.embedding_dims = 1536
store = CodingMemoryStore(cfg, embed_service=mock_embed)
await store.initialize()
# mock client-like ns(覆盖 async_search 用到的字段)
ns = SimpleNamespace(
_config=cfg,
_embed_service=mock_embed,
_coding_store=store,
_coding_writer=object(), # 占位
_coding_llm_provider=None,
_coding_init_lock=asyncio.Lock(),
_history_store=None, # 用于 record_search
_enable_history_record=False,
_build_isolation_key=lambda u, a: f"{u}:{a}",
)
# 把 HyMemoryClient._search_coding 当 self=ns 来用
# 收 **kwargs:这里只是把 self 绑成 ns,阈值类参数原样透传,
# 以后再加新的不用跟着改
async def _search_coding_bound(target_query, **kw):
return await HyMemoryClient._search_coding(ns, target_query=target_query, **kw)
ns._search_coding = _search_coding_bound
# 短路 chat 路径需要的内部 reader:返回 fixed 空结果
async def _stub_get_coding_writer():
return ns._coding_writer
ns._get_coding_writer = _stub_get_coding_writer
# 短路 chat 路径:直接返回空 memories(因为我们这里只测路由是否走对)
async def _stub_chat_search(read_req):
return type("Resp", (), {
"memories": [], "total": 0, "elapsed_ms": 1.0,
})()
ns._reader = SimpleNamespace(read=_stub_chat_search)
# async_search 现在只是薄壳:设置 embed_override contextvar 后转发给
# _async_search_impl(真实实现)。SimpleNamespace 上必须挂这个方法,
# 否则壳转发时报 AttributeError。绑定成 bound method 形式。
ns._async_search_impl = HyMemoryClient._async_search_impl.__get__(ns, type(ns))
yield ns
await store.close()
@pytest.mark.asyncio
class TestSceneRouting:
"""async_search 的 scene/queries/query 三分支路由"""
# async_search 被 _route_to_internal_loop 装饰;测试时直接调原函数
_async_search = staticmethod(HyMemoryClient.async_search.__wrapped__)
async def _seed_coding_memory(self, ns):
await ns._coding_store.insert(CodingMemory(
experience_id="e_pub", user_id="u1", task="发布到私有包索引",
search_keys=["twine upload"], solution="...", confidence=0.9,
))
async def test_explicit_scene_coding_no_llm(self, search_ctx_with_llm):
"""scene='productivity' 显式 + 单 query → 走 coding 路径,不调 LLM"""
ns = search_ctx_with_llm
await self._seed_coding_memory(ns)
# 注入 LLM —— 如果被调,会留 calls 记录
sentinel_llm = MockLLMProvider("0\nshould not be called")
ns._coding_llm_provider = sentinel_llm
resp = await self._async_search(
ns,
query="发布到私有包索引",
scene="productivity",
user_ids=["u1"],
workspace_id=None, branch=None,
limit=10, min_score=0.0,
)
assert resp["scene"] == "productivity"
assert "facts" in resp
assert "experiences" in resp
# LLM 不应被调
assert len(sentinel_llm.calls) == 0
async def test_explicit_scene_chat_no_llm(self, search_ctx_with_llm):
"""scene='normal' 显式 + 多 queries → 走 chat 路径,不调 LLM(即使 queries 多条)"""
ns = search_ctx_with_llm
sentinel_llm = MockLLMProvider("1\nshould not be called")
ns._coding_llm_provider = sentinel_llm
# chat 路径需要 _reader.read;让它跑通
# 直接 mock async_search 结尾依赖,模拟空结果走完
# 这里只验证不进 coding 分支 + LLM 没被调
try:
resp = await self._async_search(
ns,
queries=["a", "b", "你帮我发布一下"],
scene="normal",
user_ids=["u1"],
)
# 走到 chat 分支会需要更多 client 内部依赖;我们这里用 try 兜底,
# 重点验证 LLM 没被调(短路了 LLM)
except Exception:
pass
assert len(sentinel_llm.calls) == 0 # ← 关键断言
async def test_default_single_query_no_llm(self, search_ctx_with_llm):
"""默认 light search:scene=None + 单 query 固定 normal,零 LLM。"""
ns = search_ctx_with_llm
sentinel = MockLLMProvider('{"scene":"working"}')
ns._coding_llm_provider = sentinel
try:
await self._async_search(ns, query="发布到私有包索引", user_ids=["u1"])
except Exception:
pass
assert len(sentinel.calls) == 0
async def test_default_multi_queries_keep_previous_rewrite_logic(self, search_ctx_with_llm):
ns = search_ctx_with_llm
ns._config.llm.enable_query_rewrite = True
llm = MockLLMProvider("coding\n修复 auth.py\nSINCE: NONE")
ns._coding_llm_provider = llm
seen = {}
async def _capture(target_query, **kwargs):
seen["query"] = target_query
seen.update(kwargs)
return {
"request_id": "r", "scene": "productivity",
"facts": [], "experiences": [], "elapsed_ms": 1.0,
"chain_block": "", "chain_used": [],
}
ns._search_coding = _capture
await self._async_search(
ns, queries=["auth.py 报错", "修一下"], user_ids=["u1"],
)
assert len(llm.calls) == 1
assert seen["variant"] == "coding"
assert seen["query"] == "修复 auth.py"
async def test_default_multi_queries_with_features_disabled_no_llm(self, search_ctx_with_llm):
ns = search_ctx_with_llm
ns._config.coding.enable = False
ns._config.llm.enable_query_rewrite = False
sentinel = MockLLMProvider("should not be called")
ns._coding_llm_provider = sentinel
try:
await self._async_search(
ns, queries=["上下文", "目标问题"], user_ids=["u1"],
)
except Exception:
pass
assert len(sentinel.calls) == 0
async def test_no_scene_multi_queries_calls_llm_rewrite_used(self, search_ctx_with_llm):
"""没 scene + 多 queries → 调 LLM;若 is_coding=1 + 有 rewrite,effective query = rewrite"""
ns = search_ctx_with_llm
await self._seed_coding_memory(ns)
# LLM 返回 1 + rewrite
llm = MockLLMProvider("1\n如何发布到私有包索引(改写后)")
ns._coding_llm_provider = llm
resp = await self._async_search(
ns,
queries=["发布", "用 twine?", "我说的是私有包索引"],
user_ids=["u1"],
workspace_id=None, branch=None,
limit=10, min_score=0.0,
)
assert resp["scene"] == "productivity"
assert len(llm.calls) == 1 # 调了一次 LLM
async def test_no_scene_auto_coding_uses_coding_variant(self, search_ctx_with_llm):
ns = search_ctx_with_llm
ns._coding_llm_provider = MockLLMProvider(
"coding\n修复 auth.py 的 traceback"
)
seen = {}
async def _capture(target_query, **kwargs):
seen.update(kwargs)
return {
"request_id": "r", "scene": "productivity",
"facts": [], "experiences": [], "elapsed_ms": 1.0,
"chain_block": "", "chain_used": [],
}
ns._search_coding = _capture
await self._async_search(
ns,
queries=["auth.py 报错", "帮我修掉这个 traceback"],
user_ids=["u1"],
)
assert seen["variant"] == "coding"
async def test_no_scene_multi_queries_chat_classification(self, search_ctx_with_llm):
"""没 scene + 多 queries + LLM 判 chat → 走 chat 路径"""
ns = search_ctx_with_llm
llm = MockLLMProvider("0\nrewrite for chat")
ns._coding_llm_provider = llm
try:
await self._async_search(
ns,
queries=["你喜欢", "什么颜色", "猫还是狗"],
user_ids=["u1"],
)
except Exception:
pass
# LLM 被调 1 次
assert len(llm.calls) == 1
async def test_no_scene_llm_failure_falls_back_chat(self, search_ctx_with_llm):
"""没 scene + 多 queries + LLM 失败 → fail-safe 走 chat"""
ns = search_ctx_with_llm
ns._coding_llm_provider = FailingLLMProvider()
try:
await self._async_search(
ns,
queries=["debug", "我那个错"],
user_ids=["u1"],
)
except Exception:
pass
# 没异常向上抛即可(fail-safe)
async def test_invalid_scene_raises(self, search_ctx_with_llm):
"""scene 非法值 → ValueError"""
ns = search_ctx_with_llm
with pytest.raises(ValueError, match="scene must be"):
await self._async_search(
ns,
query="x",
scene="weird",
user_ids=["u1"],
)
async def test_invalid_search_mode_raises(self, search_ctx_with_llm):
with pytest.raises(ValueError, match="search_mode must be"):
await self._async_search(
search_ctx_with_llm,
query="x", search_mode="turbo", user_ids=["u1"],
)
async def test_explicit_coding_but_disabled_falls_back(self, search_ctx_with_llm):
"""scene='productivity' 但 config.coding.enable=False → fall back chat"""
ns = search_ctx_with_llm
ns._config.coding.enable = False
sentinel_llm = MockLLMProvider("0\n")
ns._coding_llm_provider = sentinel_llm
try:
await self._async_search(
ns,
query="x",
scene="productivity",
user_ids=["u1"],
)
except Exception:
pass
# 即使 scene='productivity',coding 关闭时应该回退 chat(不会调 LLM)
assert len(sentinel_llm.calls) == 0
@pytest.mark.asyncio
class TestRequestIdPassthrough:
"""search()/async_search() 的 request_id:传入则复用,不传/空白则自生成。"""
_async_search = staticmethod(HyMemoryClient.async_search.__wrapped__)
async def _seed_coding_memory(self, ns):
await ns._coding_store.insert(CodingMemory(
experience_id="m_pub", user_id="u1", task="发布到私有包索引",
search_keys=["twine upload"], solution="...",
confidence=0.9,
))
async def test_explicit_request_id_reused(self, search_ctx_with_llm):
"""传入 request_id → 原样出现在返回结果里"""
ns = search_ctx_with_llm
await self._seed_coding_memory(ns)
resp = await self._async_search(
ns,
query="发布到私有包索引",
scene="productivity",
user_ids=["u1"],
request_id="app-预生成-req-123",
)
assert resp["request_id"] == "app-预生成-req-123"
async def test_missing_request_id_autogenerated(self, search_ctx_with_llm):
"""不传 request_id → SDK 自生成一个合法 uuid"""
ns = search_ctx_with_llm
await self._seed_coding_memory(ns)
resp = await self._async_search(
ns,
query="发布到私有包索引",
scene="productivity",
user_ids=["u1"],
)
assert resp["request_id"]
uuid.UUID(resp["request_id"]) # 不抛异常即为合法 uuid
async def test_blank_request_id_autogenerated(self, search_ctx_with_llm):
"""空白 request_id(空串 / 纯空格)→ 视为未传,自生成"""
ns = search_ctx_with_llm
await self._seed_coding_memory(ns)
for blank in ("", " "):
resp = await self._async_search(
ns,
query="发布到私有包索引",
scene="productivity",
user_ids=["u1"],
request_id=blank,
)
assert resp["request_id"]
uuid.UUID(resp["request_id"])
async def test_each_call_without_request_id_gets_distinct_id(self, search_ctx_with_llm):
"""不同调用各自生成不同的 request_id"""
ns = search_ctx_with_llm
await self._seed_coding_memory(ns)
resp1 = await self._async_search(
ns, query="发布到私有包索引", scene="productivity", user_ids=["u1"],
)
resp2 = await self._async_search(
ns, query="发布到私有包索引", scene="productivity", user_ids=["u1"],
)
assert resp1["request_id"] != resp2["request_id"]
@pytest.mark.asyncio
class TestQueryRewriteRouting:
"""search_mode=agentic 的 query rewrite 与 working memory 组合路由。"""
_async_search = staticmethod(HyMemoryClient.async_search.__wrapped__)
async def test_agentic_single_query_classifies_and_rewrites(self, search_ctx_with_llm):
ns = search_ctx_with_llm
llm = MockLLMProvider("coding\n修复 auth.py 的异常\nSINCE: NONE")
ns._coding_llm_provider = llm
seen = {}
async def _capture(target_query, **kwargs):
seen["query"] = target_query
seen.update(kwargs)
return {
"request_id": "r", "scene": "productivity",
"facts": [], "experiences": [], "elapsed_ms": 1.0,
"chain_block": "", "chain_used": [],
}
ns._search_coding = _capture
await self._async_search(
ns, query="修一下这个异常", search_mode="agentic", user_ids=["u1"],
)
assert len(llm.calls) == 1
assert seen["variant"] == "coding"
assert seen["query"] == "修复 auth.py 的异常"
async def test_agentic_without_working_memory_still_classifies_and_rewrites(self, search_ctx_with_llm):
"""agentic 不受 working-memory 开关影响:仍做三分类、rewrite 和时间解析。
chat 路径尾部依赖完整 client(_registry 等),此处只验证 LLM 被正确调用
(单 query 也走、prompt 不含判类),rewrite 解析细节由 analyze_queries 单测覆盖。
"""
ns = search_ctx_with_llm
ns._config.coding.enable = False
llm = MockLLMProvider(
"chat\nwhat important thing did the user say last week\nSINCE: 2026-06-24"
)
ns._coding_llm_provider = llm
try:
await self._async_search(
ns, query="我上周说了什么重要的事", search_mode="agentic",
user_ids=["u1"],
)
except Exception:
pass
assert len(llm.calls) == 1 # 单 query 也调了 LLM
assert "Classify the target" in llm.calls[0]["prompt"]
async def test_agentic_classifies_and_rewrites_in_one_call(self, search_ctx_with_llm):
"""agentic + working memory:一次 LLM 完成三分类、改写与时间解析。"""
ns = search_ctx_with_llm
ns._config.coding.enable = True
await TestSceneRouting()._seed_coding_memory(ns)
llm = MockLLMProvider("1\n如何发布到私有包索引\nSINCE: NONE")
ns._coding_llm_provider = llm
resp = await self._async_search(
ns, queries=["怎么发布", "我说的是私有包索引"],
search_mode="agentic",
user_ids=["u1"], workspace_id=None, branch=None,
limit=10, min_score=0.0,
)
assert len(llm.calls) == 1 # 只调一次 LLM(合并)
assert "Classify the target" in llm.calls[0]["prompt"] # 判类在同一 prompt
assert resp["scene"] == "productivity"
async def test_agentic_long_queries_still_classify_and_rewrite(self, search_ctx_with_llm):
"""agentic 使用有界 query 视图;原始 queries 超长也不关闭 rewrite。"""
ns = search_ctx_with_llm
ns._config.coding.enable = True
ns._config.llm.query_rewrite_max_chars = 20 # 故意调低
llm = MockLLMProvider("chat\n重写后的查询\nSINCE: NONE")
ns._coding_llm_provider = llm
try:
resp = await self._async_search(
ns, queries=["这是一段非常非常长的查询" * 5, "还有更多内容拼接起来超过阈值"],
search_mode="agentic",
user_ids=["u1"],
)
assert resp["rewrite"]["used"] is True
assert resp["rewrite"]["effective_query"] == "重写后的查询"
except Exception:
pass
assert len(llm.calls) == 1
async def test_agentic_long_queries_without_wm_still_use_llm(self, search_ctx_with_llm):
"""agentic 总是启用 judge/rewrite,即使 working memory 关闭且原始 query 很长。"""
ns = search_ctx_with_llm
ns._config.coding.enable = False
ns._config.llm.query_rewrite_max_chars = 10
llm = MockLLMProvider("chat\n重写后的长查询\nSINCE: NONE")
ns._coding_llm_provider = llm
try:
resp = await self._async_search(
ns, query="这是一段超过十个字符的查询内容",
search_mode="agentic", user_ids=["u1"],
)
assert resp["rewrite"]["used"] is True
except Exception:
pass
assert len(llm.calls) == 1
async def test_default_mode_no_llm(self, search_ctx_with_llm):
"""默认模式不受旧配置影响,固定 normal 且零 LLM。"""
ns = search_ctx_with_llm
ns._config.coding.enable = False
ns._config.llm.enable_query_rewrite = False
sentinel = MockLLMProvider("nope")
ns._coding_llm_provider = sentinel
try:
await self._async_search(ns, query="随便问一句", user_ids=["u1"])
except Exception:
pass
assert len(sentinel.calls) == 0