Files
Hy-Memory/tests/coding/test_fact_reconciler.py
T
0afe787c95 fix: stop authority-invalid HY retries (#78)
* fix: prevent authority-invalid HY retries

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: align coding authority with conversations

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: alvinfei <alvinfei@tencent.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 15:30:32 +08:00

906 lines
40 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 -*-
"""会话级事实 reconciler 测试:INSERT / 同任务推进 SUPERSEDE / 经验链过继"""
import json
import re
from datetime import datetime
import pytest
from hy_memory.coding.fact_reconciler import (
CHAIN_SUMMARY_PROMPT,
FACT_RECONCILE_PROMPT,
WorkingFactReconciler,
)
from hy_memory.coding.types import CodingMemory, WorkingFact, WorkingFactDraft
from tests.coding._helpers import FailingLLMProvider, MockLLMProvider
def _ops_resp(ops):
return json.dumps({"ops": ops})
# 发生 SUPERSEDE 时 reconciler 会再要一次 LLM 写演变总结,所以 mock 得备第二条
# 回复。不备的话总结那次会拿到 ops JSON 原文,测试现象会很迷惑。
_SUMMARY = "为修复 CI 构建,先定位 Node 18 错误,再升级 workflow;当前构建已通过。"
def _llm(ops, summary=_SUMMARY):
return MockLLMProvider([_ops_resp(ops), summary])
def _draft(statement, files=None):
return WorkingFactDraft(statement=statement, files=files or [])
class TestPromptEvidenceContract:
"""只验证 reconciler prompt 契约,不把它冒充为 LLM 行为证明。"""
def test_candidate_judgement_does_not_upgrade_conjecture(self):
assert "evidence-bounded text" in FACT_RECONCILE_PROMPT
assert "shared conjecture/error" in FACT_RECONCILE_PROMPT
assert "only about task continuation" in FACT_RECONCILE_PROMPT
def test_chain_summary_preserves_uncertainty_and_blockers(self):
assert "This is compression only" in CHAIN_SUMMARY_PROMPT
assert "inferred is not established" in CHAIN_SUMMARY_PROMPT
assert "unresolved stays unresolved" in CHAIN_SUMMARY_PROMPT
assert "search/permission/coverage limits" in CHAIN_SUMMARY_PROMPT
assert "Never upgrade it" in CHAIN_SUMMARY_PROMPT
assert "directly says that cause was verified" in CHAIN_SUMMARY_PROMPT
def test_chain_summary_preserves_stages_and_current_status(self):
assert "every important stage in chronological order" in CHAIN_SUMMARY_PROMPT
assert "actual action or change" in CHAIN_SUMMARY_PROMPT
assert "direct result" in CHAIN_SUMMARY_PROMPT
assert "unresolved issue or limiting condition" in CHAIN_SUMMARY_PROMPT
assert "explicit current status" in CHAIN_SUMMARY_PROMPT
assert "chains of 2-3 generations" in CHAIN_SUMMARY_PROMPT
assert "2-4 compact paragraphs" in CHAIN_SUMMARY_PROMPT
assert "Do not mechanically copy the full history" in CHAIN_SUMMARY_PROMPT
assert "Do not invent stage headings or dates" in CHAIN_SUMMARY_PROMPT
assert "earlier history was omitted" in CHAIN_SUMMARY_PROMPT
assert "important numeric values" in CHAIN_SUMMARY_PROMPT
@pytest.mark.asyncio
class TestInsert:
async def test_insert_all_drafts(self, coding_store, mock_embed):
llm = MockLLMProvider(_ops_resp([]))
rec = WorkingFactReconciler(coding_store, mock_embed, llm)
facts, ops = await rec.reconcile(
[_draft("事实 A", ["a.py"]), _draft("事实 B")],
user_id="u1", agent_id="ag1", session_id="s1", workspace_id="ws/a",
)
assert [f.statement for f in facts] == ["事实 A", "事实 B"]
assert [o.action for o in ops] == ["INSERT", "INSERT"]
stored = await coding_store.get_fact(facts[0].fact_id, user_id="u1")
assert stored is not None
assert stored.status == "active"
assert stored.files == ["a.py"]
assert stored.agent_id == "ag1"
assert stored.session_id == "s1"
assert stored.workspace_id == "ws/a"
assert stored.fact_link is None
async def test_no_llm_call_without_candidates(self, coding_store, mock_embed):
"""库里一条候选都没有就没得判,不该白花一次 LLM 调用。"""
llm = MockLLMProvider(_ops_resp([]))
rec = WorkingFactReconciler(coding_store, mock_embed, llm)
await rec.reconcile([_draft("第一条事实")], user_id="u1")
assert len(llm.calls) == 0
async def test_empty_drafts(self, coding_store, mock_embed):
llm = MockLLMProvider(_ops_resp([]))
rec = WorkingFactReconciler(coding_store, mock_embed, llm)
facts, ops = await rec.reconcile([], user_id="u1")
assert facts == []
assert ops == []
@pytest.mark.asyncio
class TestSupersede:
async def _seed_old(self, store, *, derived=None):
old = WorkingFact(
fact_id="f_old", user_id="u1",
session_id="test-session",
statement=(
"用户要修复 CI 的 npm run build;检查 .github/workflows/ci.yml 后"
"定位到 Node 18 报 ERR_OSSL_EVP_UNSUPPORTED,尚未修改。"
),
derived_experience_ids=derived or [],
)
await store.insert_fact(old)
return old
async def test_supersede_marks_old_and_links(self, coding_store, mock_embed):
await self._seed_old(coding_store)
llm = _llm([{
"draft_idx": 0,
"supersedes": ["f_old"],
"reason": "继续推进同一个 CI 构建修复",
}])
rec = WorkingFactReconciler(coding_store, mock_embed, llm)
facts, ops = await rec.reconcile(
[_draft(
"用户继续修复同一 CI 构建;把 .github/workflows/ci.yml 升到 Node 20,"
"npm run build 已通过。"
)], user_id="u1", session_id="test-session",
)
new_id = facts[0].fact_id
old = await coding_store.get_fact("f_old", user_id="u1")
assert old.status == "superseded"
assert old.fact_link == new_id
assert [o.action for o in ops] == ["SUPERSEDE", "INSERT"]
async def test_experience_chain_inherited(self, coding_store, mock_embed):
"""旧事实攒下的经验链要过继给新事实,两边都补上。"""
await coding_store.insert(CodingMemory(
experience_id="e1", user_id="u1", task="改 config", solution="S",
source_fact_ids=["f_old"],
))
await self._seed_old(coding_store, derived=["e1"])
llm = _llm([{"draft_idx": 0, "supersedes": ["f_old"]}])
rec = WorkingFactReconciler(coding_store, mock_embed, llm)
facts, _ = await rec.reconcile(
[_draft(
"用户继续修复同一 CI 构建;把 .github/workflows/ci.yml 升到 Node 20,"
"npm run build 已通过。"
)], user_id="u1", session_id="test-session",
)
new_id = facts[0].fact_id
# 正向:新事实拿到旧事实的经验链
assert facts[0].derived_experience_ids == ["e1"]
stored_new = await coding_store.get_fact(new_id, user_id="u1")
assert stored_new.derived_experience_ids == ["e1"]
# 反向:经验补上新事实,旧的也留着(链路可回溯)
exp = await coding_store.get_by_id("e1", user_id="u1")
assert exp.source_fact_ids == ["f_old", new_id]
async def test_link_chain_stays_walkable(self, coding_store, mock_embed):
"""历史全靠 fact_link 反查还原,所以连续覆盖必须一跳不断。"""
await self._seed_old(coding_store) # 第 1 代
gens = [
(
"用户继续修复同一 CI 构建;将 .github/workflows/ci.yml 升到 Node 20,"
"但 npm run build 仍受旧缓存影响。",
"f_old",
),
(
"用户继续验证同一 CI 修复;清理 npm 缓存后 npm run build 通过。",
None,
),
]
prev_id = None
for stmt, target in gens:
rec = WorkingFactReconciler(
coding_store, mock_embed,
_llm([{"draft_idx": 0, "supersedes": [target or prev_id]}]),
)
facts, _ = await rec.reconcile(
[_draft(stmt)], user_id="u1", session_id="test-session"
)
prev_id = facts[0].fact_id
lineage = await coding_store.get_fact_ancestors(prev_id, user_id="u1")
assert [f.statement for f in lineage] == [
"用户要修复 CI 的 npm run build;检查 .github/workflows/ci.yml 后"
"定位到 Node 18 报 ERR_OSSL_EVP_UNSUPPORTED,尚未修改。",
"用户继续修复同一 CI 构建;将 .github/workflows/ci.yml 升到 Node 20,"
"但 npm run build 仍受旧缓存影响。",
]
async def test_reconcile_prompt_requires_same_task_not_same_file(
self, coding_store, mock_embed
):
await self._seed_old(coding_store)
llm = _llm([])
rec = WorkingFactReconciler(coding_store, mock_embed, llm)
await rec.reconcile(
[_draft(
"用户要给 .github/workflows/ci.yml 增加发布 job;添加 twine upload,"
"配置完成但尚未发布。"
)],
user_id="u1",
session_id="test-session",
)
prompt = llm.calls[0]["prompt"]
assert "继续推进某条已有 session fact 所记录的同一个用户" in prompt
assert "只是提到了相同 file" in prompt
assert "追求不同的用户目标" in prompt
assert "INSERT 是安全默认值" in prompt
assert "ERR_OSSL_EVP_UNSUPPORTED" in prompt
assert "twine upload" in prompt
async def test_summary_empty_when_nothing_superseded(self, coding_store, mock_embed):
"""没覆盖任何东西就没有演变可讲。"""
llm = MockLLMProvider(_ops_resp([]))
rec = WorkingFactReconciler(coding_store, mock_embed, llm)
facts, _ = await rec.reconcile([_draft("全新的结论")], user_id="u1")
stored = await coding_store.get_fact(facts[0].fact_id, user_id="u1")
assert stored.fact_chain_summary == ""
async def test_old_statement_not_indexed_for_retrieval(self, coding_store, mock_embed):
"""被顶掉的说法留在库里但撤出向量库,不该重新具备召回力。"""
await self._seed_old(coding_store)
rec = WorkingFactReconciler(
coding_store, mock_embed, _llm([{"draft_idx": 0, "supersedes": ["f_old"]}]),
)
await rec.reconcile(
[_draft("config 位于 /etc/app/config.yaml")],
user_id="u1",
session_id="test-session",
)
# 拿旧说法原文去搜,只能落到新事实(靠两者本身相似),
# 且召回的 statement 一定是当前值,不是血统里那句
emb = await coding_store.embed_service.embed("config 位于 /opt/app/config.yaml")
hits = await coding_store.search_facts(emb, user_id="u1", top=5)
assert all(f.statement != "config 位于 /opt/app/config.yaml" for f, _ in hits)
async def test_ignores_id_outside_candidates(self, coding_store, mock_embed):
"""LLM 幻觉出一个不在候选里的 fact_id,不能真去动它。"""
await self._seed_old(coding_store)
llm = _llm([{"draft_idx": 0, "supersedes": ["f_hallucinated"]}])
rec = WorkingFactReconciler(coding_store, mock_embed, llm)
_, ops = await rec.reconcile(
[_draft("config 位于 /etc/app/config.yaml")], user_id="u1",
)
assert [o.action for o in ops] == ["INSERT"]
old = await coding_store.get_fact("f_old", user_id="u1")
assert old.status == "active"
async def test_near_duplicate_fact_cannot_cross_authority_scope(
self, coding_store, mock_embed
):
await coding_store.insert_fact(WorkingFact(
fact_id="f_old",
user_id="u1",
agent_id="scope:2026-10-18",
session_id="conversation-a",
workspace_id="repo-a",
statement="CI build now passes after upgrading Node",
))
llm = _llm([{"draft_idx": 0, "supersedes": ["f_old"]}])
rec = WorkingFactReconciler(coding_store, mock_embed, llm)
_, ops = await rec.reconcile(
[_draft("CI build now passes after upgrading Node")],
user_id="u1",
agent_id="scope:2026-10-19",
session_id="conversation-b",
workspace_id="repo-a",
)
old = await coding_store.get_fact("f_old", user_id="u1")
assert old is not None
assert old.status == "active"
assert [op.action for op in ops] == ["INSERT"]
assert len(llm.calls) == 0
async def test_old_fact_superseded_only_once(self, coding_store, mock_embed):
"""两条新事实都声称覆盖同一条旧事实时,只让第一条生效。
否则 fact_link 会被后写的那条覆盖,前一条的过继链就断了。
"""
await self._seed_old(coding_store)
llm = _llm([
{"draft_idx": 0, "supersedes": ["f_old"]},
{"draft_idx": 1, "supersedes": ["f_old"]},
])
rec = WorkingFactReconciler(coding_store, mock_embed, llm)
facts, ops = await rec.reconcile(
[
_draft("config 位于 /etc/app/config.yaml"),
_draft("config 位于 /srv/app/config.yaml"),
],
user_id="u1",
session_id="test-session",
)
assert len(facts) == 2
assert sum(1 for o in ops if o.action == "SUPERSEDE") == 1
old = await coding_store.get_fact("f_old", user_id="u1")
assert old.fact_link == facts[0].fact_id
@pytest.mark.asyncio
class TestChainSummary:
"""同一任务进展总结由 LLM 写,历史现取自 fact_link 链,不另存。"""
async def _seed_old(self, store):
await store.insert_fact(WorkingFact(
fact_id="f_old", user_id="u1",
session_id="test-session",
statement=(
"用户要迁移 app config;确认当前读取 /opt/app/config.yaml,"
"尚未修改配置加载逻辑。"
),
created_at=datetime(2026, 1, 1),
))
async def test_llm_summary_stored_and_used(self, coding_store, mock_embed):
await self._seed_old(coding_store)
written = (
"为迁移 app config,先确认 /opt/app/config.yaml,再切到 "
"/etc/app/config.yaml;当前加载测试通过。"
)
rec = WorkingFactReconciler(
coding_store, mock_embed,
_llm([{"draft_idx": 0, "supersedes": ["f_old"]}], summary=written),
)
facts, _ = await rec.reconcile(
[_draft(
"用户继续迁移 app config;将加载路径改为 /etc/app/config.yaml,"
"配置加载测试通过。"
)], user_id="u1", session_id="test-session",
)
stored = await coding_store.get_fact(facts[0].fact_id, user_id="u1")
assert stored.fact_chain_summary == written
async def test_prompt_carries_history_and_current(self, coding_store, mock_embed):
"""总结调用要同时看到旧 session 和当前 session,才能写任务进展。"""
await self._seed_old(coding_store)
llm = _llm([{"draft_idx": 0, "supersedes": ["f_old"]}])
rec = WorkingFactReconciler(coding_store, mock_embed, llm)
await rec.reconcile(
[_draft(
"用户继续迁移 app config;将加载路径改为 /etc/app/config.yaml,"
"配置加载测试通过。"
)], user_id="u1", session_id="test-session",
)
assert len(llm.calls) == 2, "SUPERSEDE 应触发第二次调用"
prompt = llm.calls[1]["prompt"]
assert "/opt/app/config.yaml" in prompt
assert "/etc/app/config.yaml" in prompt
assert "goal" in prompt
assert "逐阶段 progression" in prompt
assert "current status" in prompt
assert llm.calls[1]["max_tokens"] == 600
async def test_no_extra_call_without_supersede(self, coding_store, mock_embed):
"""没覆盖就没有演变可总结,不该白花一次调用。"""
await self._seed_old(coding_store)
llm = _llm([])
rec = WorkingFactReconciler(coding_store, mock_embed, llm)
await rec.reconcile(
[_draft("另一个话题的结论")],
user_id="u1",
session_id="test-session",
)
assert len(llm.calls) == 1
async def test_prompt_carries_full_lineage(self, coding_store, mock_embed):
"""第三代的总结要看到头两代,不能只看到刚被顶掉的那一条。"""
await self._seed_old(coding_store)
rec = WorkingFactReconciler(
coding_store, mock_embed, _llm([{"draft_idx": 0, "supersedes": ["f_old"]}]),
)
facts, _ = await rec.reconcile(
[_draft(
"用户继续迁移 app config;将加载路径改为 /etc/app/config.yaml,"
"配置加载测试通过。"
)], user_id="u1", session_id="test-session",
)
llm2 = _llm([{"draft_idx": 0, "supersedes": [facts[0].fact_id]}])
rec2 = WorkingFactReconciler(coding_store, mock_embed, llm2)
await rec2.reconcile(
[_draft(
"用户继续完善同一 config 迁移;改由环境变量 APP_CONFIG 指定路径,"
"回归测试通过。"
)], user_id="u1", session_id="test-session",
)
prompt = llm2.calls[1]["prompt"]
assert "/opt/app/config.yaml" in prompt, "第一代不该丢"
assert "/etc/app/config.yaml" in prompt
assert "APP_CONFIG" in prompt
assert prompt.index("/opt/") < prompt.index("/etc/"), "时间正序"
async def test_summary_failure_does_not_block_write(self, coding_store, mock_embed):
"""总结挂了,覆盖和入库照常;历史还在库里,下次覆盖会重新总结出来。"""
await self._seed_old(coding_store)
class _SummaryFails(MockLLMProvider):
async def complete(self, prompt, **kwargs):
if "ENTRIES" in prompt:
raise RuntimeError("summary boom")
return await super().complete(prompt, **kwargs)
rec = WorkingFactReconciler(
coding_store, mock_embed,
_SummaryFails(_ops_resp([{"draft_idx": 0, "supersedes": ["f_old"]}])),
)
facts, ops = await rec.reconcile(
[_draft(
"用户继续迁移 app config;将加载路径改为 /etc/app/config.yaml,"
"配置加载测试通过。"
)], user_id="u1", session_id="test-session",
)
assert sum(1 for o in ops if o.action == "SUPERSEDE") == 1
stored = await coding_store.get_fact(facts[0].fact_id, user_id="u1")
assert stored.fact_chain_summary == ""
# 关键:这一代没写成总结,但血统没断,还能查回去
lineage = await coding_store.get_fact_ancestors(stored.fact_id, user_id="u1")
assert [f.statement for f in lineage] == [
"用户要迁移 app config;确认当前读取 /opt/app/config.yaml,"
"尚未修改配置加载逻辑。"
]
async def test_summary_recovers_after_a_failed_generation(
self, coding_store, mock_embed
):
"""某一代没写成总结,下一代重走链子能把漏掉的那代补回来。"""
await self._seed_old(coding_store)
class _SummaryFails(MockLLMProvider):
async def complete(self, prompt, **kwargs):
if "ENTRIES" in prompt:
raise RuntimeError("summary boom")
return await super().complete(prompt, **kwargs)
rec = WorkingFactReconciler(
coding_store, mock_embed,
_SummaryFails(_ops_resp([{"draft_idx": 0, "supersedes": ["f_old"]}])),
)
facts, _ = await rec.reconcile(
[_draft(
"用户继续迁移 app config;将加载路径改为 /etc/app/config.yaml,"
"配置加载测试通过。"
)], user_id="u1", session_id="test-session",
)
assert facts[0].fact_chain_summary == ""
llm2 = _llm([{"draft_idx": 0, "supersedes": [facts[0].fact_id]}])
rec2 = WorkingFactReconciler(coding_store, mock_embed, llm2)
await rec2.reconcile(
[_draft(
"用户继续完善同一 config 迁移;改由环境变量 APP_CONFIG 指定路径,"
"回归测试通过。"
)], user_id="u1", session_id="test-session",
)
prompt = llm2.calls[1]["prompt"]
assert "/opt/app/config.yaml" in prompt, "上一代漏写的历史要能补回来"
async def test_deep_lineage_truncated_and_flagged(self, coding_store, mock_embed):
"""长命事实不该把整条链塞进提示词,但得说明省略了几代。"""
from hy_memory.coding.types import MAX_FACT_CHAIN_ENTRIES
prev = None
total = MAX_FACT_CHAIN_ENTRIES + 3
for i in range(total):
await coding_store.insert_fact(WorkingFact(
fact_id=f"g{i}", user_id="u1", session_id="test-session",
statement=f"第 {i} 代结论",
created_at=datetime(2026, 1, i + 1),
))
if prev:
await coding_store.supersede_fact(prev, f"g{i}")
prev = f"g{i}"
llm = _llm([{"draft_idx": 0, "supersedes": [prev]}])
rec = WorkingFactReconciler(coding_store, mock_embed, llm)
await rec.reconcile(
[_draft("最新结论")], user_id="u1", session_id="test-session"
)
prompt = llm.calls[1]["prompt"]
assert "第 0 代结论" not in prompt, "最老的几代该被截掉"
assert f"第 {total - 1} 代结论" in prompt, "最近的要留着"
assert "omitted" in prompt, "截断了就得标出来"
# 别报代数。反查本身就只走 MAX+1 步,数不出真实代数,写个数字等于骗
# 下游——第 30 代覆盖时也只会算出"1 代",而实际略掉了二十多代。
assert not re.search(r"\d+\s+earlier", prompt), "截断标注不该带代数"
async def test_no_truncation_flag_on_short_lineage(self, coding_store, mock_embed):
"""没截断就不该出现省略标注,否则 LLM 会以为还有没给它的历史。"""
await self._seed_old(coding_store)
llm = _llm([{"draft_idx": 0, "supersedes": ["f_old"]}])
rec = WorkingFactReconciler(coding_store, mock_embed, llm)
await rec.reconcile(
[_draft(
"用户继续迁移 app config;将加载路径改为 /etc/app/config.yaml,"
"配置加载测试通过。"
)], user_id="u1", session_id="test-session",
)
entries = llm.calls[1]["prompt"].split(
"ENTRIES\n═══════════════════════════════════════════════════════════════════\n",
1,
)[1]
assert "older revisions omitted" not in entries
async def test_summary_rewritten_each_generation(self, coding_store, mock_embed):
"""第二代覆盖时总结要重写,而不是留着第一代那句。"""
await self._seed_old(coding_store)
rec = WorkingFactReconciler(
coding_store, mock_embed,
_llm([{"draft_idx": 0, "supersedes": ["f_old"]}], summary="第一版总结"),
)
facts, _ = await rec.reconcile(
[_draft(
"用户继续迁移 app config;将加载路径改为 /etc/app/config.yaml,"
"配置加载测试通过。"
)], user_id="u1", session_id="test-session",
)
rec2 = WorkingFactReconciler(
coding_store, mock_embed,
_llm([{"draft_idx": 0, "supersedes": [facts[0].fact_id]}],
summary="第二版总结"),
)
facts2, _ = await rec2.reconcile(
[_draft(
"用户继续完善同一 config 迁移;改由环境变量 APP_CONFIG 指定路径,"
"回归测试通过。"
)], user_id="u1", session_id="test-session",
)
stored = await coding_store.get_fact(facts2[0].fact_id, user_id="u1")
assert stored.fact_chain_summary == "第二版总结"
async def test_disabled_skips_the_call(self, coding_store, mock_embed):
"""关掉之后不该再多花一次调用,覆盖本身照常。"""
await self._seed_old(coding_store)
llm = _llm([{"draft_idx": 0, "supersedes": ["f_old"]}])
rec = WorkingFactReconciler(
coding_store, mock_embed, llm, enable_chain_summary=False,
)
facts, ops = await rec.reconcile(
[_draft(
"用户继续迁移 app config;将加载路径改为 /etc/app/config.yaml,"
"配置加载测试通过。"
)], user_id="u1", session_id="test-session",
)
assert len(llm.calls) == 1
assert sum(1 for o in ops if o.action == "SUPERSEDE") == 1
stored = await coding_store.get_fact(facts[0].fact_id, user_id="u1")
assert stored.fact_chain_summary == ""
class TestCleanSummary:
"""模型爱给总结加装饰,进库前剥掉。"""
@pytest.mark.parametrize("raw,expected", [
("```\n一句总结。\n```", "一句总结。"),
("```markdown\n一句总结。\n```", "一句总结。"),
("总结:一句总结。", "一句总结。"),
("Summary: 一句总结。", "一句总结。"),
("“一句总结。”", "一句总结。"),
('"一句总结。"', "一句总结。"),
("一句\n总结。", "一句 总结。"),
(" 一句总结。 ", "一句总结。"),
("", ""),
("```", ""),
])
def test_strips_decoration(self, raw, expected):
assert WorkingFactReconciler._clean_summary(raw) == expected
def test_keeps_inner_quotes(self):
"""只剥最外层的成对引号,句子里引的路径名不该被削掉。"""
s = WorkingFactReconciler._clean_summary('现在读 "APP_CONFIG" 这个变量。')
assert s == '现在读 "APP_CONFIG" 这个变量。'
@pytest.mark.asyncio
class TestFailSafe:
async def test_llm_failure_still_inserts(self, coding_store, mock_embed):
"""LLM 挂了宁可多留一条过期事实,也不能因为一次调用失败丢结论。"""
await coding_store.insert_fact(WorkingFact(
fact_id="f_old", user_id="u1", session_id="test-session",
statement="config 位于 /opt/app/config.yaml",
))
rec = WorkingFactReconciler(coding_store, mock_embed, FailingLLMProvider())
facts, ops = await rec.reconcile(
[_draft("config 位于 /etc/app/config.yaml")], user_id="u1",
)
assert len(facts) == 1
assert [o.action for o in ops] == ["INSERT"]
old = await coding_store.get_fact("f_old", user_id="u1")
assert old.status == "active"
async def test_unparsable_llm_output(self, coding_store, mock_embed):
await coding_store.insert_fact(WorkingFact(
fact_id="f_old", user_id="u1", session_id="test-session",
statement="config 位于 /opt/app/config.yaml",
))
rec = WorkingFactReconciler(
coding_store, mock_embed, MockLLMProvider("不是 JSON 的一段话")
)
facts, ops = await rec.reconcile(
[_draft("config 位于 /etc/app/config.yaml")], user_id="u1",
)
assert len(facts) == 1
assert [o.action for o in ops] == ["INSERT"]
async def test_markdown_fence_parsed(self, coding_store, mock_embed):
await coding_store.insert_fact(WorkingFact(
fact_id="f_old", user_id="u1", session_id="test-session",
statement="config 位于 /opt/app/config.yaml",
))
llm = MockLLMProvider([
"```json\n" + _ops_resp([{"draft_idx": 0, "supersedes": ["f_old"]}]) + "\n```",
_SUMMARY,
])
rec = WorkingFactReconciler(coding_store, mock_embed, llm)
_, ops = await rec.reconcile(
[_draft("config 位于 /etc/app/config.yaml")],
user_id="u1",
session_id="test-session",
)
assert sum(1 for o in ops if o.action == "SUPERSEDE") == 1
@pytest.mark.asyncio
class TestSubjectGateSwitch:
"""subject 机械闸门的开关。
这个闸门是精确字符串相等,它的正确性完全取决于 subject 是不是稳定实体名。
Travel 是(人名,实测 933 组被拦对里明确误判 0 组);Progressive Search 不是
(59.2% 是 'Subquery N' 这种位置标签,两个位置标签必然不相等所以必然互拦)。
所以它必须能按数据集关掉,而不是二选一定死。
"""
async def _seed(self, store, fact_id, subject, statement):
await store.insert_fact(WorkingFact(
fact_id=fact_id, user_id="u1", session_id="test-session",
subject=subject, statement=statement,
))
async def test_gate_on_blocks_cross_subject_supersede(self, coding_store, mock_embed):
"""开启时:主体不同的候选进不了 LLM 名单,所以覆盖不可能发生。"""
await self._seed(coding_store, "f_emma", "Emma", "Emma 的三日行程已排定,预算待定。")
rec = WorkingFactReconciler(
coding_store, mock_embed,
_llm([{"draft_idx": 0, "supersedes": ["f_emma"]}]),
subject_gate=True,
)
_, ops = await rec.reconcile(
[WorkingFactDraft(subject="Bart", statement="Bart 的三日行程已排定,预算待定。")],
user_id="u1",
session_id="test-session",
)
assert [o.action for o in ops] == ["INSERT"]
assert (await coding_store.get_fact("f_emma", user_id="u1")).status == "active"
async def test_gate_off_allows_cross_subject_supersede(self, coding_store, mock_embed):
"""关闭时:候选原样交给 LLM,它判覆盖就真覆盖。"""
await self._seed(coding_store, "f_emma", "Emma", "Emma 的三日行程已排定,预算待定。")
rec = WorkingFactReconciler(
coding_store, mock_embed,
_llm([{"draft_idx": 0, "supersedes": ["f_emma"]}]),
subject_gate=False,
)
facts, ops = await rec.reconcile(
[WorkingFactDraft(subject="Bart", statement="Bart 的三日行程已排定,预算待定。")],
user_id="u1",
session_id="test-session",
)
assert [o.action for o in ops] == ["SUPERSEDE", "INSERT"]
old = await coding_store.get_fact("f_emma", user_id="u1")
assert old.status == "superseded"
assert old.fact_link == facts[0].fact_id
async def test_gate_off_skips_pre_execute_recheck(self, coding_store, mock_embed):
"""关闭时执行前那道复查也要一起跳过,否则闸门没关干净。
直接调 _execute 绕过召回,模拟"候选闸门放行了但复查还在拦"这个漏关场景。
"""
await self._seed(coding_store, "f_emma", "Emma", "Emma 的行程。")
old = await coding_store.get_fact("f_emma", user_id="u1")
rec = WorkingFactReconciler(
coding_store, mock_embed, _llm([]), subject_gate=False,
)
_, ops = await rec._execute(
[WorkingFactDraft(subject="Bart", statement="Bart 的行程。")],
[[old]],
{0: ["f_emma"]},
user_id="u1", agent_id="default_agent",
session_id="test-session", workspace_id=None,
)
assert "SUPERSEDE" in [o.action for o in ops]
async def test_empty_subject_never_blocked_either_way(self, coding_store, mock_embed):
"""有一侧 subject 为空时,开关无论怎么设都不该拦。
空不等于"主体不同",等于"这次没能定出主体"(兜底存的原文、改动前的老行)。
拿一个未知去否决一个判断是错的,该交回 LLM 按语义判。
"""
for gate in (True, False):
await self._seed(coding_store, f"f_noneed_{gate}", "", "旧的部署脚本结论。")
old = await coding_store.get_fact(f"f_noneed_{gate}", user_id="u1")
rec = WorkingFactReconciler(
coding_store, mock_embed, _llm([]), subject_gate=gate,
)
_, ops = await rec._execute(
[WorkingFactDraft(subject="deploy.sh", statement="新的部署脚本结论。")],
[[old]],
{0: [f"f_noneed_{gate}"]},
user_id="u1", agent_id="default_agent",
session_id="test-session", workspace_id=None,
)
assert "SUPERSEDE" in [o.action for o in ops], f"gate={gate} 时空 subject 被误拦"
async def test_env_var_controls_default(self, coding_store, mock_embed, monkeypatch):
"""环境变量能切默认值,显式构造参数优先于它。"""
monkeypatch.setenv("HY_FACT_SUBJECT_GATE", "0")
assert WorkingFactReconciler(coding_store, mock_embed, _llm([])).subject_gate is False
assert WorkingFactReconciler(
coding_store, mock_embed, _llm([]), subject_gate=True
).subject_gate is True
monkeypatch.setenv("HY_FACT_SUBJECT_GATE", "1")
assert WorkingFactReconciler(coding_store, mock_embed, _llm([])).subject_gate is True
@pytest.mark.asyncio
class TestDetailsInheritance:
"""被覆盖事实的 details 过继。
fact_chain_summary 是 LLM 压缩产物,实测保真度按链距衰减(一条 3 代链里 gen2
槽位保留 5/5,gen1 保留 0/2,丢的正是具体数值)。过继走复制不走生成,所以
预算内 100% 保真;且 details 不进向量,追加不改变召回打分。
"""
async def test_ancestor_details_copied_verbatim(self, coding_store, mock_embed):
await coding_store.insert_fact(WorkingFact(
fact_id="f_old", user_id="u1", subject="Emma",
session_id="test-session",
statement="Emma 的行程草案已出,预算待核。",
details="日均餐费 $32.4-$57.6\n酒店评分 3.2-3.8",
))
rec = WorkingFactReconciler(
coding_store, mock_embed,
_llm([{"draft_idx": 0, "supersedes": ["f_old"]}]),
subject_gate=False, inherit_details=True,
)
facts, _ = await rec.reconcile(
[WorkingFactDraft(subject="Emma", statement="Emma 的行程已定稿。",
details="航班 CA1234")],
user_id="u1",
session_id="test-session",
)
stored = await coding_store.get_fact(facts[0].fact_id, user_id="u1")
# 本代的 details 在,祖先的具体取值也一字不差地在
assert "航班 CA1234" in stored.details
assert "$32.4-$57.6" in stored.details
assert "3.2-3.8" in stored.details
assert "gen1" in stored.details and "Emma" in stored.details
async def test_no_exponential_growth_down_the_chain(self, coding_store, mock_embed):
"""连续覆盖三代,details 不该按代翻倍。
祖先的 details 里已经含着它自己那代过继来的块,直接追加会指数膨胀。
血统由 _collect_history 一次走全,所以每代只贡献自己那一份。
"""
await coding_store.insert_fact(WorkingFact(
fact_id="f_g1", user_id="u1", subject="T",
session_id="test-session",
statement="第一代结论。", details="V1=" + "a" * 300,
))
prev = "f_g1"
sizes = []
for gen in range(2, 5):
rec = WorkingFactReconciler(
coding_store, mock_embed,
_llm([{"draft_idx": 0, "supersedes": [prev]}]),
subject_gate=False, inherit_details=True,
)
facts, _ = await rec.reconcile(
[WorkingFactDraft(subject="T", statement=f"第{gen}代结论。",
details=f"V{gen}=" + "a" * 300)],
user_id="u1",
session_id="test-session",
)
prev = facts[0].fact_id
stored = await coding_store.get_fact(prev, user_id="u1")
sizes.append(len(stored.details))
# 每代的取值标记只出现一次,没有被重复过继
for g in range(1, gen + 1):
assert stored.details.count(f"V{g}=") == 1, (
f"第{gen}代里 V{g}= 出现 {stored.details.count(f'V{g}=')} 次,重复过继了"
)
# 线性增长:4 代最多约 4×(300+标记),指数增长会是 300×2^3 以上
assert sizes[-1] < 2200, f"details 增长过快: {sizes}"
async def test_total_budget_enforced_and_loss_reported(self, coding_store, mock_embed):
"""预算是硬的,但被丢掉的代数必须写出来,不静默截断。"""
await coding_store.insert_fact(WorkingFact(
fact_id="f_old", user_id="u1", subject="T",
session_id="test-session",
statement="旧结论。", details="X=" + "b" * 8000,
))
rec = WorkingFactReconciler(
coding_store, mock_embed,
_llm([{"draft_idx": 0, "supersedes": ["f_old"]}]),
subject_gate=False, inherit_details=True,
)
facts, _ = await rec.reconcile(
[WorkingFactDraft(subject="T", statement="新结论。", details="Y=1")],
user_id="u1",
session_id="test-session",
)
stored = await coding_store.get_fact(facts[0].fact_id, user_id="u1")
assert "…(truncated)" in stored.details
assert len(stored.details) < 6000
async def test_switch_off_keeps_details_untouched(self, coding_store, mock_embed):
await coding_store.insert_fact(WorkingFact(
fact_id="f_old", user_id="u1", subject="T",
session_id="test-session",
statement="旧结论。", details="祖先取值 Z=9",
))
rec = WorkingFactReconciler(
coding_store, mock_embed,
_llm([{"draft_idx": 0, "supersedes": ["f_old"]}]),
subject_gate=False, inherit_details=False,
)
facts, ops = await rec.reconcile(
[WorkingFactDraft(subject="T", statement="新结论。", details="Y=1")],
user_id="u1",
session_id="test-session",
)
# 覆盖照样发生,只是不过继
assert "SUPERSEDE" in [o.action for o in ops]
stored = await coding_store.get_fact(facts[0].fact_id, user_id="u1")
assert stored.details == "Y=1"
assert "Z=9" not in stored.details
async def test_inheritance_does_not_change_recall_vector(self, coding_store, mock_embed):
"""details 不进向量,所以过继多少都不该改变召回打分。
这是"追加不伤召回质量"这个论断的直接检查,不是推断。
"""
await coding_store.insert_fact(WorkingFact(
fact_id="f_old", user_id="u1", subject="T",
session_id="test-session",
statement="旧结论。", details="无关噪声 " + "q" * 2000,
))
rec = WorkingFactReconciler(
coding_store, mock_embed,
_llm([{"draft_idx": 0, "supersedes": ["f_old"]}]),
subject_gate=False, inherit_details=True,
)
facts, _ = await rec.reconcile(
[WorkingFactDraft(subject="T", statement="新结论。", details="Y=1")],
user_id="u1",
session_id="test-session",
)
new_id = facts[0].fact_id
emb = await coding_store.embed_service.embed("T 新结论。")
hits = await coding_store.search_facts(emb, user_id="u1", top=5)
got = {f.fact_id: s for f, s in hits}
assert new_id in got
# 同样的 statement+subject 但不带过继,打分应当一致
await coding_store.insert_fact(WorkingFact(
fact_id="f_clean", user_id="u1", subject="T",
statement="新结论。", details="Y=1",
))
hits2 = await coding_store.search_facts(emb, user_id="u1", top=5)
got2 = {f.fact_id: s for f, s in hits2}
assert abs(got2["f_clean"] - got2[new_id]) < 1e-6, (
f"过继改变了召回打分: clean={got2['f_clean']} inherited={got2[new_id]}"
)