feat(agent): form pipeline + collection prompt workflow@20261021

- merge main: workflow/20261021 (collection prompt adds step-5 mandatory
  time readback-confirm before routing; non-motor/0003 branch rework;
  accident_cause unified schema), updated eval suites (photo_skip/
  button_guidance rewritten, no_false trimmed to asr, new
  non_motor_vehicle + time_confirmation with form/reply assertions),
  runner gains form/formMatches/replyMatches expectations
- prompts/collection.md regenerated from 20261021; extraction prompt
  archived (contentExtract description, 6-key whitelist)
- collect: extraction call per turn (extraction.md + current time/form
  injected), merge follows 更新表单 semantics (meaningful values only,
  number/string equality, diff -> turn_patch); accident_fields now uses
  external form keys
- backend: form_update flows to ChatResult and SSE formUpdate event
  (stream emits prefix, FormUpdate, body)
- fields: FORM_FIELDS whitelist with strict type validation
- verified: 155 unit tests; golden evals on real model - photo_skip 4/4,
  no_false 2/2, photo_button_guidance 12/12, non_motor_vehicle 14/14,
  collection_skip 10/12, time_confirmation 1/2; remaining 3 failures are
  model sequence/literal-template adherence, recorded in plan appendix
This commit is contained in:
Eric Wang
2026-10-21 17:10:33 +08:00
parent 4ead44ef3f
commit 74db6be385
9 changed files with 291 additions and 109 deletions
+5 -3
View File
@@ -85,7 +85,8 @@ async def test_collect_single_llm_call():
lambda s, u: "<state>1002</state>请问有几辆汽车卷入了这次事故呢?"
)
result = await run_collect(collect_state(), llm)
assert len(llm.calls) == 1
# 对话调用 + 独立的字段提取调用(计划作业 4-4)
assert len(llm.calls) == 2
assert result["response_text"].startswith("<state>1002</state>")
assert result["turn_count"] == 2
@@ -98,6 +99,7 @@ async def test_collect_routes_to_photo_when_llm_says_so():
)
result = await run_collect(collect_state(), llm)
assert result["stage_code"] == "2000"
assert len(llm.calls) == 2 # 对话 + 提取
@pytest.mark.asyncio
@@ -113,7 +115,7 @@ async def test_collect_missing_prefix_repaired_once():
llm = FakeLLMService(responder)
result = await run_collect(collect_state(), llm)
assert len(llm.calls) == 2
assert len(llm.calls) == 3 # 对话 + 修复 + 提取
assert result["response_text"].startswith("<state>1002</state>")
@@ -158,7 +160,7 @@ async def test_graph_collect_node_with_llm():
config={"configurable": {"thread_id": "s1"}},
context=GraphDeps(llm=llm),
)
assert len(llm.calls) == 1
assert len(llm.calls) == 2 # collect 对话 + 提取
assert result["response_text"].startswith("<state>1002</state>")
assert result["stage_code"] == "1002"
+6 -3
View File
@@ -10,7 +10,7 @@ from src.agent.collect import CollectReplyError, run_collect
from src.agent.llm import FakeLLMService
from src.agent.prompts import COLLECTION, load_prompt, render_prompt
WORKFLOW = Path(__file__).resolve().parents[2] / "workflow/20261020/事故信息采集2025118.json"
WORKFLOW = Path(__file__).resolve().parents[2] / "workflow/20261021/事故信息采集2025118.json"
NODES = {
"collection": "事故信息收集",
"single_photo": "单车拍照",
@@ -19,14 +19,17 @@ NODES = {
"dual_photo_addon": "双车拍照#7",
"single_confirm": "单车信息确认",
"dual_confirm": "双车信息确认",
"extraction": "文本内容提取事故信息",
}
_INPUT_KEYS = {"extraction": "description"}
@pytest.mark.parametrize(("prompt", "node_name"), NODES.items())
def test_archived_prompt_matches_workflow(prompt, node_name):
workflow = json.loads(WORKFLOW.read_text(encoding="utf-8-sig"))
node = next(n for n in workflow["nodes"] if n["name"] == node_name)
original = next(i["value"] for i in node["inputs"] if i["key"] == "systemPrompt")
input_key = _INPUT_KEYS.get(prompt, "systemPrompt")
original = next(i["value"] for i in node["inputs"] if i["key"] == input_key)
archived = load_prompt(prompt).split("--- 原文开始 ---", 1)[1].split("--- 原文结束 ---", 1)[0]
assert archived.strip() == original.strip()
@@ -54,7 +57,7 @@ async def test_short_answer_does_not_override_model_routing():
}, llm)
assert result["stage_code"] == "2000"
assert result["clarify_count"] == 0
assert len(llm.calls) == 1
assert len(llm.calls) == 2 # 对话 + 提取
@pytest.mark.asyncio