421 lines
18 KiB
Python
421 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Extractor 测试(双层抽取)"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from hy_memory.coding.extractor import CodingMemoryExtractor, EXTRACT_PROMPT
|
|
from hy_memory.pipelines.base import ChatMessage, ToolCall
|
|
|
|
from tests.coding._helpers import MockLLMProvider, FailingLLMProvider
|
|
|
|
|
|
def _llm_resp(experiences, fact=None):
|
|
return json.dumps({"experiences": experiences, "fact": fact})
|
|
|
|
|
|
def _legacy_llm_resp(experiences, facts=None):
|
|
return json.dumps({"experiences": experiences, "facts": facts or []})
|
|
|
|
|
|
def _msgs():
|
|
return [
|
|
ChatMessage(role="user", content="重构 auth 改成 JWT"),
|
|
ChatMessage(role="assistant", content="", tool_calls=[
|
|
ToolCall(id="1", name="Read", arguments={"path": "auth/middleware.ts"}),
|
|
]),
|
|
ChatMessage(role="tool", content="42 lines", tool_call_id="1", tool_name="Read"),
|
|
]
|
|
|
|
|
|
def _assert_verbatim_fallback(facts):
|
|
"""断言显式开启后,事实层走了原文兜底。
|
|
|
|
判据取 statement 落在用户原话、details 保住完整轨迹这两条。
|
|
"""
|
|
assert len(facts) == 1, f"expected exactly one verbatim fact, got {facts!r}"
|
|
f = facts[0]
|
|
assert f.statement == "重构 auth 改成 JWT"
|
|
assert f.subject == "" # 兜底没有主体可判,留空 → 覆盖判定退回纯语义
|
|
assert "[0] user: 重构 auth 改成 JWT" in f.details
|
|
assert "[2] tool(Read): 42 lines" in f.details
|
|
|
|
|
|
class TestFactPromptContract:
|
|
"""静态保护证据忠实度契约;不声称能保证任意 LLM 一定遵守。"""
|
|
|
|
def test_only_executed_commands_without_invented_flags(self):
|
|
assert "only actions actually executed" in EXTRACT_PROMPT
|
|
assert "NEVER complete a" in EXTRACT_PROMPT
|
|
assert "command with customary flags" in EXTRACT_PROMPT
|
|
assert '"ran Get-ChildItem -Force" → "ran Get-ChildItem"' in EXTRACT_PROMPT
|
|
|
|
def test_partial_negative_findings_keep_scope_and_limits(self):
|
|
assert "Scope every negative finding" in EXTRACT_PROMPT
|
|
assert "enumerate EVERY explicit coverage limit" in EXTRACT_PROMPT
|
|
assert "Never promote a partial search to" in EXTRACT_PROMPT
|
|
assert "Access denied" in EXTRACT_PROMPT
|
|
assert "depth limits" in EXTRACT_PROMPT
|
|
|
|
def test_inference_stays_uncertain_and_success_requires_evidence(self):
|
|
assert "inferred, and unresolved" in EXTRACT_PROMPT
|
|
|
|
assert "a cause was established" in EXTRACT_PROMPT
|
|
assert "not yet verified" in EXTRACT_PROMPT
|
|
|
|
def test_files_contract_includes_investigated_directories(self):
|
|
assert "FILE OR DIRECTORY paths" in EXTRACT_PROMPT
|
|
assert "roots such as `C:\\` and `D:\\`" in EXTRACT_PROMPT
|
|
assert "not paths merely suggested or planned" in EXTRACT_PROMPT
|
|
|
|
def test_statement_requires_specific_goal_and_ending_state(self):
|
|
assert "Start with the" in EXTRACT_PROMPT
|
|
assert "specific goal and object" in EXTRACT_PROMPT
|
|
assert "partially completed, failed, blocked, or" in EXTRACT_PROMPT
|
|
assert "still awaiting verification" in EXTRACT_PROMPT
|
|
|
|
def test_command_evidence_window_keeps_material_late_flags(self):
|
|
"""保护 prompt 输入证据窗口,不声称模型一定会把参数写进 fact。"""
|
|
command = (
|
|
'Get-ChildItem -Path "C:\\\\" -Directory -Filter "new_file_search" '
|
|
"-Recurse -Depth 3 -ErrorAction SilentlyContinue "
|
|
"| Select-Object -First 5 FullName"
|
|
)
|
|
rendered = CodingMemoryExtractor._format_conversation([
|
|
ChatMessage(role="assistant", tool_calls=[
|
|
ToolCall(id="1", name="shell", arguments={"command": command}),
|
|
]),
|
|
])
|
|
assert "-Depth 3" in rendered
|
|
assert "-ErrorAction SilentlyContinue" in rendered
|
|
|
|
def test_asking_the_user_is_not_a_reusable_experience(self):
|
|
"""澄清停滞场景的回归保护。
|
|
|
|
如果 Agent 没出计划、只反问缺失字段,
|
|
extractor 把它蒸馏成 "how to ask for departure city and travel dates"
|
|
/ "eliciting required fields for travel planning" 等经验,后续检索会继续
|
|
放大反问而不产出可解析计划。停滞被写成技能后会自我复制,所以澄清行为
|
|
必须留在 fact、不进 experiences。
|
|
"""
|
|
assert "The act of ASKING THE USER for missing information" in EXTRACT_PROMPT
|
|
assert "conversational" in EXTRACT_PROMPT
|
|
assert '"how to ask for X"' in EXTRACT_PROMPT
|
|
assert "eliciting required fields" in EXTRACT_PROMPT
|
|
# 停滞轮只写 fact,不写经验
|
|
assert 'emit "experiences": [] and write the FACT alone' in EXTRACT_PROMPT
|
|
# 判据取自 extractor 自己对会话状态的判断,不依赖任何评测集字段
|
|
assert "stalled waiting on the" in EXTRACT_PROMPT
|
|
assert "Judge substance by what the tools actually did" in EXTRACT_PROMPT
|
|
|
|
def test_mandatory_extraction_exempts_the_stall_case(self):
|
|
"""结尾那条 MANDATORY 曾是逼出假经验的直接原因。
|
|
|
|
原文是「You MUST output at least ONE experience. Do NOT return an empty
|
|
list.」——末位、无例外。反问轮没有方法可蒸馏,却被禁止返回空,模型只能
|
|
编一条。上面的禁令写在中段,会被这条结尾指令压过去,所以它必须显式让出
|
|
停滞这一种情况。默认仍是抽取:豁免只对停滞开口,不能变成偷懒的借口。
|
|
"""
|
|
assert "DEFAULT TO EXTRACTING" in EXTRACT_PROMPT
|
|
assert "The ONE exception is the stall case above" in EXTRACT_PROMPT
|
|
assert "Do not manufacture an experience" in EXTRACT_PROMPT
|
|
assert "never as a shortcut when work was actually done" in EXTRACT_PROMPT
|
|
# 旧的无条件禁令不能复活
|
|
assert "Do NOT return an empty list." not in EXTRACT_PROMPT
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestExtractor:
|
|
async def test_new_messages_all_kept_history_only_last_five_user_turns(self):
|
|
llm = MockLLMProvider(_llm_resp([], {"statement": "done"}))
|
|
ext = CodingMemoryExtractor(llm)
|
|
|
|
current = []
|
|
for i in range(10):
|
|
current.extend([
|
|
ChatMessage(role="user", content=f"new-user-{i}"),
|
|
ChatMessage(role="assistant", content="A" * 8000),
|
|
ChatMessage(role="tool", content="T" * 5000, tool_name="Read"),
|
|
])
|
|
history = []
|
|
for i in range(7):
|
|
history.extend([
|
|
ChatMessage(role="user", content=f"history-user-{i}"),
|
|
ChatMessage(role="assistant", content="history answer"),
|
|
])
|
|
|
|
await ext.extract(current, history_messages=history, user_id="u1")
|
|
prompt = llm.calls[0]["prompt"]
|
|
history_block, new_block = prompt.split(
|
|
"NEW MESSAGES (extract only from this complete write):", 1
|
|
)
|
|
|
|
assert "history-user-0" not in history_block
|
|
assert "history-user-1" not in history_block
|
|
for i in range(2, 7):
|
|
assert f"history-user-{i}" in history_block
|
|
|
|
for i in range(10):
|
|
assert f"new-user-{i}" in new_block
|
|
assert "chars omitted" in new_block
|
|
assert "bytes omitted" in new_block
|
|
|
|
async def test_normal_extract(self):
|
|
llm = MockLLMProvider(_llm_resp([
|
|
{
|
|
"task": "Auth 重构",
|
|
"search_keys": ["JWT 实现", "为什么不用 session"],
|
|
"solution": "切到 JWT,文件 auth/middleware.ts",
|
|
"boundary_envs": "node>=18",
|
|
"confidence": 0.9,
|
|
},
|
|
]))
|
|
ext = CodingMemoryExtractor(llm, verbatim_fallback_enabled=True)
|
|
drafts, facts = await ext.extract(
|
|
_msgs(), user_id="u1", agent_id="a1",
|
|
workspace_id="github.com/x/y", branch="main",
|
|
)
|
|
assert len(drafts) == 1
|
|
d = drafts[0]
|
|
assert d.task == "Auth 重构"
|
|
assert d.search_keys == ["JWT 实现", "为什么不用 session"]
|
|
assert d.solution.startswith("切到 JWT")
|
|
assert d.boundary_envs == "node>=18"
|
|
assert d.confidence == 0.9
|
|
assert d.workspace_id == "github.com/x/y"
|
|
assert d.files == ["auth/middleware.ts"]
|
|
# LLM 只给了经验、没给 fact,事实层走原文兜底
|
|
_assert_verbatim_fallback(facts)
|
|
|
|
async def test_kept_without_workspace_or_branch(self):
|
|
"""缺 workspace_id / branch 不再拒稿。
|
|
|
|
边界已不参与召回过滤,再按边界拒稿只会平白丢经验。
|
|
"""
|
|
llm = MockLLMProvider(_llm_resp([
|
|
{"task": "T1", "solution": "S", "confidence": 0.9},
|
|
{"task": "T2", "solution": "S", "confidence": 0.9},
|
|
]))
|
|
ext = CodingMemoryExtractor(llm)
|
|
drafts, _ = await ext.extract(_msgs(), user_id="u1", workspace_id=None, branch=None)
|
|
assert [d.task for d in drafts] == ["T1", "T2"]
|
|
|
|
async def test_boundary_envs_defaults_empty(self):
|
|
llm = MockLLMProvider(_llm_resp([{"task": "T", "solution": "S"}]))
|
|
ext = CodingMemoryExtractor(llm)
|
|
drafts, _ = await ext.extract(_msgs(), user_id="u1")
|
|
assert drafts[0].boundary_envs == ""
|
|
|
|
async def test_search_keys_dedup_and_truncate(self):
|
|
llm = MockLLMProvider(_llm_resp([{
|
|
"task": "T", "solution": "S",
|
|
"search_keys": ["T", "A", "A", "B", "C", "D", "E", "F"],
|
|
"confidence": 0.8,
|
|
}]))
|
|
ext = CodingMemoryExtractor(llm)
|
|
drafts, _ = await ext.extract(_msgs(), user_id="u1")
|
|
# T 与 task 同名去掉;A 重复去掉;保留前 5
|
|
assert drafts[0].search_keys == ["A", "B", "C", "D", "E"]
|
|
|
|
async def test_confidence_clamp(self):
|
|
llm = MockLLMProvider(_llm_resp([
|
|
{"task": "T1", "solution": "S", "confidence": 1.5},
|
|
{"task": "T2", "solution": "S", "confidence": -0.5},
|
|
{"task": "T3", "solution": "S", "confidence": "bad"},
|
|
]))
|
|
ext = CodingMemoryExtractor(llm)
|
|
drafts, _ = await ext.extract(_msgs(), user_id="u1")
|
|
confs = [d.confidence for d in drafts]
|
|
assert confs == [1.0, 0.0, 0.7]
|
|
|
|
async def test_drop_missing_task_or_solution(self):
|
|
llm = MockLLMProvider(_llm_resp([
|
|
{"task": "", "solution": "S"},
|
|
{"task": "T", "solution": ""},
|
|
{"task": "T2", "solution": "S"},
|
|
]))
|
|
ext = CodingMemoryExtractor(llm)
|
|
drafts, _ = await ext.extract(_msgs(), user_id="u1")
|
|
assert [d.task for d in drafts] == ["T2"]
|
|
|
|
async def test_files_auto_extracted(self):
|
|
llm = MockLLMProvider(_llm_resp([
|
|
{"task": "T", "solution": "S", "confidence": 0.8},
|
|
]))
|
|
msgs = [
|
|
ChatMessage(role="assistant", content="", tool_calls=[
|
|
ToolCall(id="1", name="Read", arguments={"path": "src/auth.py"}),
|
|
ToolCall(id="2", name="Edit", arguments={"file_path": "src/jwt.py"}),
|
|
]),
|
|
]
|
|
ext = CodingMemoryExtractor(llm)
|
|
drafts, _ = await ext.extract(msgs, user_id="u1")
|
|
assert drafts[0].files == ["src/auth.py", "src/jwt.py"]
|
|
|
|
async def test_empty_memories(self):
|
|
llm = MockLLMProvider(_llm_resp([]))
|
|
ext = CodingMemoryExtractor(llm)
|
|
drafts, facts = await ext.extract(_msgs(), user_id="u1", workspace_id="ws")
|
|
assert drafts == []
|
|
assert facts == []
|
|
|
|
async def test_empty_memories_fallback_can_be_enabled(self):
|
|
llm = MockLLMProvider(_llm_resp([]))
|
|
ext = CodingMemoryExtractor(llm, verbatim_fallback_enabled=True)
|
|
drafts, facts = await ext.extract(_msgs(), user_id="u1", workspace_id="ws")
|
|
assert drafts == []
|
|
_assert_verbatim_fallback(facts)
|
|
|
|
async def test_failsafe_on_llm_error(self):
|
|
ext = CodingMemoryExtractor(FailingLLMProvider())
|
|
drafts, facts = await ext.extract(_msgs(), user_id="u1")
|
|
assert drafts == []
|
|
assert facts == []
|
|
|
|
async def test_failsafe_on_llm_error_fallback_can_be_enabled(self):
|
|
ext = CodingMemoryExtractor(
|
|
FailingLLMProvider(), verbatim_fallback_enabled=True,
|
|
)
|
|
drafts, facts = await ext.extract(_msgs(), user_id="u1")
|
|
assert drafts == []
|
|
_assert_verbatim_fallback(facts)
|
|
|
|
async def test_markdown_fence_parse(self):
|
|
llm = MockLLMProvider(
|
|
"```json\n" + _llm_resp([
|
|
{"task": "T", "solution": "S"},
|
|
]) + "\n```"
|
|
)
|
|
ext = CodingMemoryExtractor(llm, verbatim_fallback_enabled=True)
|
|
drafts, _ = await ext.extract(_msgs(), user_id="u1")
|
|
assert len(drafts) == 1
|
|
|
|
async def test_empty_messages(self):
|
|
llm = MockLLMProvider(_llm_resp([]))
|
|
ext = CodingMemoryExtractor(llm)
|
|
drafts, facts = await ext.extract([], user_id="u1")
|
|
assert drafts == []
|
|
# 轨迹本身是空的:兜底判据是"有没有内容",无内容就该真的什么都不存。
|
|
assert facts == []
|
|
|
|
async def test_legacy_memories_key_still_parsed(self):
|
|
"""双层化之前 prompt 用的是 "memories",老模型可能还这么回。"""
|
|
llm = MockLLMProvider(json.dumps({
|
|
"memories": [{"task": "T", "solution": "S"}],
|
|
}))
|
|
ext = CodingMemoryExtractor(llm, verbatim_fallback_enabled=True)
|
|
drafts, facts = await ext.extract(_msgs(), user_id="u1")
|
|
assert [d.task for d in drafts] == ["T"]
|
|
_assert_verbatim_fallback(facts)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestFactExtraction:
|
|
async def test_single_fact_object_extracted(self):
|
|
llm = MockLLMProvider(_llm_resp(
|
|
[{"task": "T", "solution": "S"}],
|
|
{
|
|
"statement": (
|
|
"用户要定位 new_file_search;检查 src/search.py 后确认实现位于该文件,"
|
|
"并在 C: 和 D: 搜索 foo_config 但未找到。"
|
|
),
|
|
"files": ["src/search.py"],
|
|
},
|
|
))
|
|
ext = CodingMemoryExtractor(llm)
|
|
drafts, facts = await ext.extract(_msgs(), user_id="u1")
|
|
assert len(drafts) == 1
|
|
assert [f.statement for f in facts] == [
|
|
"用户要定位 new_file_search;检查 src/search.py 后确认实现位于该文件,"
|
|
"并在 C: 和 D: 搜索 foo_config 但未找到。",
|
|
]
|
|
assert facts[0].files == ["src/search.py"]
|
|
|
|
async def test_facts_only_without_experiences(self):
|
|
"""事实全抽不设门槛,所以经验空、事实非空是合法状态。"""
|
|
llm = MockLLMProvider(_llm_resp([], {"statement": "构建产物在 dist/"}))
|
|
ext = CodingMemoryExtractor(llm)
|
|
drafts, facts = await ext.extract(_msgs(), user_id="u1")
|
|
assert drafts == []
|
|
assert [f.statement for f in facts] == ["构建产物在 dist/"]
|
|
|
|
async def test_fact_null_falls_back_to_verbatim(self):
|
|
"""显式开启时,fact:null 触发原文兜底。"""
|
|
llm = MockLLMProvider(_llm_resp([], None))
|
|
ext = CodingMemoryExtractor(llm, verbatim_fallback_enabled=True)
|
|
_, facts = await ext.extract(_msgs(), user_id="u1")
|
|
_assert_verbatim_fallback(facts)
|
|
|
|
async def test_fact_missing_statement_falls_back_to_verbatim(self):
|
|
"""显式开启时,statement 缺失的 fact 触发原文兜底。"""
|
|
llm = MockLLMProvider(_llm_resp([], {"files": ["b.py"]}))
|
|
ext = CodingMemoryExtractor(llm, verbatim_fallback_enabled=True)
|
|
_, facts = await ext.extract(_msgs(), user_id="u1")
|
|
_assert_verbatim_fallback(facts)
|
|
|
|
async def test_tool_only_trace_does_not_create_fallback_by_default(self):
|
|
messages = [
|
|
ChatMessage(role="assistant", content="", tool_calls=[
|
|
ToolCall(id="1", name="exec_command", arguments={"cmd": "pwd"}),
|
|
]),
|
|
ChatMessage(
|
|
role="tool", content="/workspace", tool_call_id="1",
|
|
tool_name="exec_command",
|
|
),
|
|
]
|
|
ext = CodingMemoryExtractor(MockLLMProvider(_llm_resp([], None)))
|
|
_, facts = await ext.extract(messages, user_id="u1")
|
|
assert facts == []
|
|
|
|
async def test_tool_only_trace_fallback_can_be_enabled(self):
|
|
messages = [
|
|
ChatMessage(role="assistant", content="", tool_calls=[
|
|
ToolCall(id="1", name="exec_command", arguments={"cmd": "pwd"}),
|
|
]),
|
|
ChatMessage(
|
|
role="tool", content="/workspace", tool_call_id="1",
|
|
tool_name="exec_command",
|
|
),
|
|
]
|
|
ext = CodingMemoryExtractor(
|
|
MockLLMProvider(_llm_resp([], None)),
|
|
verbatim_fallback_enabled=True,
|
|
)
|
|
_, facts = await ext.extract(messages, user_id="u1")
|
|
assert len(facts) == 1
|
|
assert facts[0].statement.startswith(
|
|
'[0] assistant: [tools: exec_command({"cmd":"pwd"})]'
|
|
)
|
|
assert "[1] tool(exec_command): /workspace" in facts[0].details
|
|
|
|
async def test_legacy_three_facts_keeps_first_and_warns(self, caplog):
|
|
llm = MockLLMProvider(_legacy_llm_resp([], [
|
|
{"statement": "第一条会话摘要", "files": ["a.py"]},
|
|
{"statement": "第二条原子事实"},
|
|
{"statement": "第三条原子事实"},
|
|
]))
|
|
ext = CodingMemoryExtractor(llm)
|
|
with caplog.at_level("WARNING"):
|
|
_, facts = await ext.extract(_msgs(), user_id="u1")
|
|
assert [f.statement for f in facts] == ["第一条会话摘要"]
|
|
assert "legacy 'facts' array with 3 entries" in caplog.text
|
|
assert "keeping the first, dropping 2" in caplog.text
|
|
|
|
async def test_fact_files_deduped(self):
|
|
llm = MockLLMProvider(_llm_resp([],
|
|
{
|
|
"statement": "S",
|
|
"files": ["C:\\", "C:\\", " D:\\ ", "src/a.py", ""],
|
|
},
|
|
))
|
|
ext = CodingMemoryExtractor(llm)
|
|
_, facts = await ext.extract(_msgs(), user_id="u1")
|
|
assert facts[0].files == ["C:\\", "D:\\", "src/a.py"]
|
|
|
|
async def test_fact_files_non_list_tolerated(self):
|
|
llm = MockLLMProvider(_llm_resp([], {"statement": "S", "files": "a.py"}))
|
|
ext = CodingMemoryExtractor(llm)
|
|
_, facts = await ext.extract(_msgs(), user_id="u1")
|
|
assert facts[0].files == []
|