401 lines
17 KiB
Python
401 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
端到端写入路径测试。
|
||
|
||
把 judge / preproc / extractor / reconciler / store 串起来,
|
||
验证 CodingWriter 协调正确。
|
||
"""
|
||
|
||
import json
|
||
|
||
import pytest
|
||
|
||
from hy_memory.coding.extractor import CodingMemoryExtractor
|
||
from hy_memory.coding.reconciler import CodingMemoryReconciler
|
||
from hy_memory.coding.writer import CodingWriter, is_coding_segment
|
||
from hy_memory.pipelines.base import ChatMessage, ToolCall
|
||
|
||
from tests.coding._helpers import MockLLMProvider, FailingLLMProvider
|
||
|
||
|
||
def _classify_resp(is_coding):
|
||
return json.dumps({"is_coding": is_coding})
|
||
|
||
|
||
def _extract_resp(experiences, fact=None):
|
||
if isinstance(fact, list):
|
||
if len(fact) <= 1:
|
||
fact = fact[0] if fact else None
|
||
else:
|
||
# 仅供兼容性回归:旧模型可能仍返回多元素 facts 数组。
|
||
return json.dumps({"experiences": experiences, "facts": fact})
|
||
return json.dumps({"experiences": experiences, "fact": fact})
|
||
|
||
|
||
def _reconcile_resp(ops):
|
||
return json.dumps({"ops": ops})
|
||
|
||
|
||
def _coding_messages():
|
||
return [
|
||
ChatMessage(role="user", content="帮我打包发布到私有包索引"),
|
||
ChatMessage(role="assistant", content="", tool_calls=[
|
||
ToolCall(id="1", name="Read", arguments={"path": "scripts/publish_package.sh"}),
|
||
]),
|
||
ChatMessage(role="tool", content="200 lines", tool_call_id="1", tool_name="Read"),
|
||
ChatMessage(role="assistant", content="", tool_calls=[
|
||
ToolCall(id="2", name="Bash", arguments={"command": "twine upload"}),
|
||
]),
|
||
ChatMessage(role="tool", content="OK uploaded", tool_call_id="2", tool_name="Bash"),
|
||
]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
class TestIsCodingSegment:
|
||
async def test_no_tool_short_circuit(self):
|
||
msgs = [ChatMessage(role="user", content="hi"),
|
||
ChatMessage(role="assistant", content="hello")]
|
||
# 即使 LLM 返回 true,has_any_tool_message False 应短路 False
|
||
llm = MockLLMProvider('{"is_coding": true}')
|
||
result = await is_coding_segment(msgs, llm)
|
||
assert result is False
|
||
# LLM 不应被调用
|
||
assert len(llm.calls) == 0
|
||
|
||
async def test_with_tool_classify_coding(self):
|
||
llm = MockLLMProvider(_classify_resp(True))
|
||
result = await is_coding_segment(_coding_messages(), llm)
|
||
assert result is True
|
||
|
||
async def test_with_tool_classify_chat(self):
|
||
llm = MockLLMProvider(_classify_resp(False))
|
||
result = await is_coding_segment(_coding_messages(), llm)
|
||
assert result is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
class TestCodingWriterE2E:
|
||
async def test_trace_history_shared_across_working_and_coding_variants(
|
||
self, coding_config, mock_embed,
|
||
):
|
||
from hy_memory.coding.store import CodingMemoryStore
|
||
|
||
working = CodingMemoryStore(
|
||
coding_config, embed_service=mock_embed, variant="working",
|
||
)
|
||
coding = CodingMemoryStore(
|
||
coding_config, embed_service=mock_embed, variant="coding",
|
||
)
|
||
await working.initialize()
|
||
await coding.initialize()
|
||
try:
|
||
await working.append_trace(
|
||
[ChatMessage(role="user", content="先整理财务报表")],
|
||
request_id="shared-trace", user_id="u1", agent_id="a1",
|
||
)
|
||
recent = await coding.get_recent_trace_messages(
|
||
user_id="u1", agent_id="a1", max_user_turns=5,
|
||
)
|
||
assert [m.content for m in recent if m.role == "user"] == [
|
||
"先整理财务报表"
|
||
]
|
||
assert working._db_path != coding._db_path
|
||
assert working._trace_db_path == coding._trace_db_path
|
||
finally:
|
||
await coding.close()
|
||
await working.close()
|
||
|
||
async def test_trace_history_keeps_last_five_user_turns(self, coding_store):
|
||
msgs = []
|
||
for i in range(7):
|
||
msgs.extend([
|
||
ChatMessage(role="user", content=f"history-{i}"),
|
||
ChatMessage(role="assistant", content="A" * 8000),
|
||
ChatMessage(role="tool", content="T" * 5000, tool_name="Read"),
|
||
])
|
||
await coding_store.append_trace(
|
||
msgs, request_id="trace-1", user_id="u1", agent_id="a1",
|
||
)
|
||
recent = await coding_store.get_recent_trace_messages(
|
||
user_id="u1", agent_id="a1", max_user_turns=5,
|
||
)
|
||
assert [m.content for m in recent if m.role == "user"] == [
|
||
"history-2", "history-3", "history-4", "history-5", "history-6"
|
||
]
|
||
assert all("chars omitted" in m.content for m in recent if m.role == "assistant")
|
||
assert all("bytes omitted" in m.content for m in recent if m.role == "tool")
|
||
|
||
async def test_trace_history_retention_is_bounded(self, coding_store):
|
||
for i in range(105):
|
||
await coding_store.append_trace(
|
||
[ChatMessage(role="user", content=f"turn-{i}")],
|
||
request_id=f"trace-{i}", user_id="u1", agent_id="a1",
|
||
)
|
||
with coding_store._trace_lock:
|
||
count = coding_store._trace_conn.execute(
|
||
"SELECT COUNT(*) AS n FROM coding_trace WHERE user_id=? AND agent_id=?",
|
||
("u1", "a1"),
|
||
).fetchone()["n"]
|
||
assert count == 100
|
||
|
||
async def test_full_path_add(self, coding_store, mock_embed):
|
||
"""完整 e2e:含 tool 消息 → judge=True → extract → reconcile=ADD → 持久化"""
|
||
# 用同一个 LLM mock 序列响应 extract + reconcile(注意:我们这里直接调
|
||
# writer.write,不经 judge;所以 LLM 序列只需覆盖 extract + reconcile)
|
||
ext_resp = _extract_resp([{
|
||
"task": "打包发布到私有包索引",
|
||
"search_keys": ["凭证位置", "上传命令"],
|
||
"solution": "运行 scripts/publish_package.sh X.Y.Z;凭证通过环境变量提供",
|
||
"boundary_envs": "twine>=6\\nurl: packages.example.com",
|
||
"confidence": 0.9,
|
||
}])
|
||
rec_resp = _reconcile_resp([{"draft_idx": 0, "action": "ADD"}])
|
||
llm = MockLLMProvider([ext_resp, rec_resp])
|
||
|
||
ext = CodingMemoryExtractor(llm)
|
||
rec = CodingMemoryReconciler(coding_store, mock_embed, llm)
|
||
writer = CodingWriter(
|
||
store=coding_store, extractor=ext, reconciler=rec,
|
||
llm_provider=llm, embed_service=mock_embed,
|
||
)
|
||
resp = await writer.write(
|
||
_coding_messages(),
|
||
user_id="u1", agent_id="a1",
|
||
workspace_id="github.com/x/y", branch="main",
|
||
session_id="s1",
|
||
)
|
||
assert resp["success"] is True
|
||
assert resp["scene"] == "productivity"
|
||
assert len(resp["ops"]) == 1
|
||
assert resp["ops"][0]["action"] == "ADD"
|
||
assert len(resp["experience_ids"]) == 1
|
||
|
||
# 验证持久化
|
||
experience_id = resp["experience_ids"][0]
|
||
got = await coding_store.get_by_id(experience_id, user_id="u1")
|
||
assert got is not None
|
||
assert got.task == "打包发布到私有包索引"
|
||
assert got.search_keys == ["凭证位置", "上传命令"]
|
||
assert got.boundary_envs.startswith("twine>=6")
|
||
assert got.workspace_id == "github.com/x/y"
|
||
assert got.files == ["scripts/publish_package.sh"]
|
||
|
||
async def test_value_bar_returns_empty(self, coding_store, mock_embed):
|
||
"""extractor 返回 0 条 memory(价值门槛未达)"""
|
||
llm = MockLLMProvider(_extract_resp([]))
|
||
ext = CodingMemoryExtractor(llm)
|
||
rec = CodingMemoryReconciler(coding_store, mock_embed, llm)
|
||
writer = CodingWriter(
|
||
store=coding_store, extractor=ext, reconciler=rec,
|
||
llm_provider=llm, embed_service=mock_embed,
|
||
)
|
||
resp = await writer.write(
|
||
_coding_messages(), user_id="u1", workspace_id="ws/a",
|
||
)
|
||
assert resp["success"] is True
|
||
assert resp["ops"] == []
|
||
assert resp["experience_ids"] == []
|
||
|
||
async def test_no_workspace_still_writes(self, coding_store, mock_embed):
|
||
"""缺 workspace_id 不再拦稿,两条 draft 都应落库"""
|
||
ext_resp = _extract_resp([
|
||
{"task": "T1", "solution": "S", "confidence": 0.9},
|
||
{"task": "T2", "solution": "S", "confidence": 0.9},
|
||
])
|
||
rec_resp = _reconcile_resp([
|
||
{"draft_idx": 0, "action": "ADD"},
|
||
{"draft_idx": 1, "action": "ADD"},
|
||
])
|
||
llm = MockLLMProvider([ext_resp, rec_resp])
|
||
ext = CodingMemoryExtractor(llm)
|
||
rec = CodingMemoryReconciler(coding_store, mock_embed, llm)
|
||
writer = CodingWriter(
|
||
store=coding_store, extractor=ext, reconciler=rec,
|
||
llm_provider=llm, embed_service=mock_embed,
|
||
)
|
||
resp = await writer.write(
|
||
_coding_messages(), user_id="u1", workspace_id=None,
|
||
)
|
||
assert len(resp["experience_ids"]) == 2
|
||
assert sorted(await coding_store.list_user_tasks("u1")) == ["T1", "T2"]
|
||
|
||
async def test_extractor_failure(self, coding_store, mock_embed):
|
||
"""extractor LLM 失败 → 空 drafts → 空 ops"""
|
||
ext = CodingMemoryExtractor(FailingLLMProvider())
|
||
rec = CodingMemoryReconciler(coding_store, mock_embed, MockLLMProvider("{}"))
|
||
writer = CodingWriter(
|
||
store=coding_store, extractor=ext, reconciler=rec,
|
||
llm_provider=FailingLLMProvider(), embed_service=mock_embed,
|
||
)
|
||
resp = await writer.write(_coding_messages(), user_id="u1", workspace_id="ws")
|
||
assert resp["success"] is True
|
||
assert resp["ops"] == []
|
||
|
||
async def test_existing_tasks_not_shown_to_extractor(self, coding_store, mock_embed):
|
||
"""库里已有的 task 不得进抽取 prompt——抽取只读这一段,去重是 reconcile 的事"""
|
||
from hy_memory.coding.types import CodingMemory
|
||
await coding_store.insert(CodingMemory(
|
||
experience_id="e_old", user_id="u1", task="老 task", solution="S",
|
||
))
|
||
|
||
llm = MockLLMProvider([_extract_resp([]), _reconcile_resp([])])
|
||
ext = CodingMemoryExtractor(llm)
|
||
rec = CodingMemoryReconciler(coding_store, mock_embed, llm)
|
||
writer = CodingWriter(
|
||
store=coding_store, extractor=ext, reconciler=rec,
|
||
llm_provider=llm, embed_service=mock_embed,
|
||
)
|
||
await writer.write(_coding_messages(), user_id="u1", workspace_id="ws/a")
|
||
assert "老 task" not in llm.calls[0]["prompt"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
class TestDualLayerWrite:
|
||
"""双层写入 + 同批全互链(§4)"""
|
||
|
||
def _writer(self, store, embed, llm, *, verbatim_fallback_enabled=False):
|
||
return CodingWriter(
|
||
store=store,
|
||
extractor=CodingMemoryExtractor(
|
||
llm,
|
||
verbatim_fallback_enabled=verbatim_fallback_enabled,
|
||
),
|
||
reconciler=CodingMemoryReconciler(store, embed, llm),
|
||
llm_provider=llm, embed_service=embed,
|
||
)
|
||
|
||
async def test_both_layers_written_and_crosslinked(self, coding_store, mock_embed):
|
||
llm = MockLLMProvider([
|
||
_extract_resp(
|
||
[{"task": "打包发布到私有包索引", "solution": "S", "confidence": 0.9}],
|
||
[{"statement": "发布脚本在 scripts/publish_package.sh", "files": ["scripts/publish_package.sh"]}],
|
||
),
|
||
_reconcile_resp([{"draft_idx": 0, "action": "ADD"}]),
|
||
])
|
||
writer = self._writer(coding_store, mock_embed, llm)
|
||
|
||
resp = await writer.write(
|
||
_coding_messages(), user_id="u1", workspace_id="ws/a", session_id="s1",
|
||
)
|
||
|
||
assert len(resp["experience_ids"]) == 1
|
||
assert len(resp["fact_ids"]) == 1
|
||
assert resp["fact_ops_summary"] == {"insert": 1, "supersede": 0, "total": 1}
|
||
|
||
eid, fid = resp["experience_ids"][0], resp["fact_ids"][0]
|
||
exp = await coding_store.get_by_id(eid, user_id="u1")
|
||
fact = await coding_store.get_fact(fid, user_id="u1")
|
||
assert exp.source_fact_ids == [fid]
|
||
assert fact.derived_experience_ids == [eid]
|
||
assert fact.statement == "发布脚本在 scripts/publish_package.sh"
|
||
|
||
async def test_all_experiences_linked_to_unique_fact(self, coding_store, mock_embed):
|
||
"""一个 trace 的多条经验都与唯一会话 fact 双向关联。"""
|
||
llm = MockLLMProvider([
|
||
_extract_resp(
|
||
[{"task": "T1", "solution": "S"}, {"task": "T2", "solution": "S"}],
|
||
[{"statement": "事实 A"}, {"statement": "事实 B"}],
|
||
),
|
||
_reconcile_resp([
|
||
{"draft_idx": 0, "action": "ADD"},
|
||
{"draft_idx": 1, "action": "ADD"},
|
||
]),
|
||
])
|
||
writer = self._writer(coding_store, mock_embed, llm)
|
||
|
||
resp = await writer.write(_coding_messages(), user_id="u1")
|
||
eids, fids = resp["experience_ids"], resp["fact_ids"]
|
||
assert len(eids) == 2 and len(fids) == 1
|
||
|
||
for eid in eids:
|
||
exp = await coding_store.get_by_id(eid, user_id="u1")
|
||
assert exp.source_fact_ids == fids
|
||
fact = await coding_store.get_fact(fids[0], user_id="u1")
|
||
assert sorted(fact.derived_experience_ids) == sorted(eids)
|
||
|
||
async def test_skip_not_linked(self, coding_store, mock_embed):
|
||
"""SKIP 的经验没真入库,不该被连上这批事实。"""
|
||
llm = MockLLMProvider([
|
||
_extract_resp(
|
||
[{"task": "T1", "solution": "S"}],
|
||
[{"statement": "事实 A"}],
|
||
),
|
||
_reconcile_resp([{"draft_idx": 0, "action": "SKIP", "reason": "dup"}]),
|
||
])
|
||
writer = self._writer(coding_store, mock_embed, llm)
|
||
|
||
resp = await writer.write(_coding_messages(), user_id="u1")
|
||
assert resp["experience_ids"] == []
|
||
assert len(resp["fact_ids"]) == 1
|
||
fact = await coding_store.get_fact(resp["fact_ids"][0], user_id="u1")
|
||
assert fact.derived_experience_ids == []
|
||
|
||
async def test_update_keeps_old_fact_link(self, coding_store, mock_embed):
|
||
"""UPDATE 覆盖正文不留痕,但事实链取并集,旧链要保住。"""
|
||
from hy_memory.coding.types import CodingMemory
|
||
await coding_store.insert(CodingMemory(
|
||
experience_id="e1", user_id="u1", task="老 task", solution="旧 solution",
|
||
source_fact_ids=["f_before"],
|
||
))
|
||
llm = MockLLMProvider([
|
||
_extract_resp(
|
||
[{"task": "老 task", "solution": "新 solution"}],
|
||
[{"statement": "本轮新事实"}],
|
||
),
|
||
_reconcile_resp([
|
||
{"draft_idx": 0, "action": "UPDATE", "target_experience_id": "e1"},
|
||
]),
|
||
])
|
||
writer = self._writer(coding_store, mock_embed, llm)
|
||
|
||
resp = await writer.write(_coding_messages(), user_id="u1")
|
||
exp = await coding_store.get_by_id("e1", user_id="u1")
|
||
assert exp.solution == "新 solution"
|
||
assert exp.source_fact_ids == ["f_before", resp["fact_ids"][0]]
|
||
|
||
async def test_facts_only_no_experience(self, coding_store, mock_embed):
|
||
"""经验层空、事实层非空:事实照样落库,不触发经验层 reconcile。"""
|
||
llm = MockLLMProvider(_extract_resp([], [{"statement": "只有事实"}]))
|
||
writer = self._writer(coding_store, mock_embed, llm)
|
||
|
||
resp = await writer.write(_coding_messages(), user_id="u1")
|
||
assert resp["experience_ids"] == []
|
||
assert len(resp["fact_ids"]) == 1
|
||
# extract 一次;事实层库里没候选所以不调 LLM;经验层 reconcile 完全没跑
|
||
assert len(llm.calls) == 1
|
||
|
||
async def test_empty_experiences_do_not_persist_verbatim_fact_by_default(
|
||
self, coding_store, mock_embed,
|
||
):
|
||
llm = MockLLMProvider(_extract_resp([], []))
|
||
writer = self._writer(coding_store, mock_embed, llm)
|
||
|
||
resp = await writer.write(_coding_messages(), user_id="u1")
|
||
assert resp["ops"] == []
|
||
assert resp["fact_ids"] == []
|
||
assert resp["fact_ops"] == []
|
||
|
||
async def test_empty_experiences_can_persist_verbatim_fact_when_enabled(
|
||
self, coding_store, mock_embed,
|
||
):
|
||
"""显式开启时,两层都抽空仍可落一条原文事实。
|
||
|
||
端到端确认开关开启后,fallback 能穿过 reconcile 真正落库。
|
||
"""
|
||
llm = MockLLMProvider(_extract_resp([], []))
|
||
writer = self._writer(
|
||
coding_store,
|
||
mock_embed,
|
||
llm,
|
||
verbatim_fallback_enabled=True,
|
||
)
|
||
|
||
resp = await writer.write(_coding_messages(), user_id="u1")
|
||
assert resp["ops"] == [] # 经验层不兜底
|
||
assert len(resp["fact_ids"]) == 1
|
||
assert [op["action"] for op in resp["fact_ops"]] == ["INSERT"]
|
||
|
||
fact = await coding_store.get_fact(resp["fact_ids"][0], user_id="u1")
|
||
assert fact.statement == "帮我打包发布到私有包索引"
|
||
assert "twine upload" in fact.details # 轨迹原文进了 details
|