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
+69 -33
View File
@@ -20,12 +20,11 @@ import json
import re
from .fields import (
FieldCandidate,
FieldStatus,
validate_candidate,
FORM_FIELDS,
validate_form_update,
)
from .llm import LLMService
from .prompts import COLLECTION, render_prompt
from .prompts import COLLECTION, EXTRACTION, render_prompt
from .state import AgentState
from .transitions import COLLECT_STAGE, is_legal_transition
@@ -131,37 +130,76 @@ def _build_user_message(state: AgentState) -> str:
def _extract_candidates(state: AgentState, body: str) -> list[FieldCandidate]:
"""从模型正文中提取字段候选(计划作业 4-4)。
# 采集阶段表单状态使用工作流的外部表单键(has_injury 等,见 fields.FORM_FIELDS);
# 内部键(ywrysw 等)属于 get_info 分组,作业 6 再建立映射。
参考版返回空列表:字段提取依赖模型的结构化输出,留给真实
LLM 接入后实现(同一回复内 JSON 解析优先;确需独立提取调用时,
记录原因/调用次数/延迟)。validate_candidate 的字段级校验
已由单元测试覆盖,此处先接通数据流。
# 提取调用的系统提示标记(不以 <state> 开头,不会被误当对话回复解析)
_EXTRACTION_MARKER = "【字段提取】"
async def _extract_turn_fields(state: AgentState, llm: LLMService) -> dict:
"""独立提取调用(计划作业 4-4:调用计数可审计——每轮在对话调用之外多一次)。
提示词为工作流「文本内容提取事故信息」原文(含当前时间与当前表单
注入),模型只输出 JSON;校验见 fields.validate_form_update。
提取失败不阻塞对话(返回 {})。
"""
return []
system = (
_EXTRACTION_MARKER
+ "\n按下列系统提示词执行提取,只输出 JSON 对象,不输出解释。\n\n"
+ render_prompt(EXTRACTION, state)
)
try:
raw = await llm.complete(
system,
state.get("input_text", ""),
history=conversation_history(state),
)
data = json.loads(raw.strip())
except Exception:
return {}
if not isinstance(data, dict):
return {}
return validate_form_update(data)
def _apply_candidates(
state: AgentState, candidates: list[FieldCandidate]
) -> tuple[dict, dict, dict]:
"""把校验通过的候选写入正式字段。
def _has_meaningful_value(value) -> bool:
"""对应「更新表单」的 hasMeaningfulValue:None/空串无效(0 有效)。"""
return value is not None and value != ""
返回 (accident_fields, confirm_status, turn_patch)。
只有 CONFIRMED 的候选才写入正式字段并进入本轮差量;
CANDIDATE/UNKNOWN 只更新 confirm_status,供后续核实节点消费
(计划作业 4-5 的三态语义)。
def _is_same_value(old, new) -> bool:
"""对应「更新表单」的 isSameValue:数字与数字字符串视为相等。"""
if old == new:
return True
if old is None or new is None:
return False
if old == "" or new == "":
return False
try:
return float(old) == float(new)
except (TypeError, ValueError):
return str(old).strip() == str(new).strip()
def _merge_form(state: AgentState, update: dict) -> tuple[dict, dict]:
"""按「更新表单」语义合并:白名单内、有效、有变化的字段进入差量。
返回 (合并后的完整表单, 本轮差量 turn_patch)。
"""
accident_fields = dict(state.get("accident_fields") or {})
confirm_status = dict(state.get("confirm_status") or {})
form = dict(state.get("accident_fields") or {})
patch: dict = {}
for cand in candidates:
validated = validate_candidate(cand)
confirm_status[validated.key] = validated.status
if validated.status is FieldStatus.CONFIRMED:
accident_fields[validated.key] = validated.value
patch[validated.key] = validated.value
return accident_fields, confirm_status, patch
for key in FORM_FIELDS:
if key not in update:
continue
value = update[key]
if not _has_meaningful_value(value):
continue
if _is_same_value(form.get(key), value):
continue
form[key] = value
patch[key] = value
return form, patch
def _bookkeeping(state: AgentState) -> tuple[int, int]:
@@ -199,10 +237,9 @@ async def run_collect(state: AgentState, llm: LLMService) -> dict:
)
# 4) 字段候选提取与三态落库(提取本身见 _extract_candidates)
accident_fields, confirm_status, patch = _apply_candidates(
state, _extract_candidates(state, raw)
)
# 4) 字段提取与表单差量(提取调用在对话调用之外,见 _extract_turn_fields)
extracted = await _extract_turn_fields(state, llm)
accident_fields, patch = _merge_form(state, extracted)
no_reply_count, clarify_count = _bookkeeping(state)
# 5) 状态增量:response_text 保留完整前缀文本,API 层负责解析;
@@ -218,7 +255,6 @@ async def run_collect(state: AgentState, llm: LLMService) -> dict:
"stage_code": stage,
"raw_status_code": stage,
"accident_fields": accident_fields,
"confirm_status": confirm_status,
"turn_patch": patch,
"dialogue": dialogue,
}