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

401 lines
17 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 -*-
"""
端到端写入路径测试。
把 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