Files
ZNJJ-api-server/src/agent/collect.py
T
Eric Wang e72d9c9bc0 feat(agent): align with workflow@20261022 - turn_context messages and prompt tails
- merge main (b0ff2e0..c7ca781): workflow/20261022 moves dynamic context
  out of fixed prompts - collection/extraction prompts carry no inline vars;
  reference time and current form travel in the user message <turn_context>
  block; photo/confirm data kept at prompt tails
- prompts: all 8 files regenerated from 20261022 (byte-identity tests
  point at the new export)
- collect: user message = <turn_context>(本轮参考时间) + <user_input>;
  extraction message adds 当前表单; single trusted clock per turn threads
  through reply/extraction/repair
- photo: raw input only (20261022 photo nodes use system_text, no wrapper)
- workflow fake llm: unwraps <user_input> before event parsing
- fields: accident_time normalized to extraction format (YYYY年M月D日H点m分,
  strip zero padding) - fixes form assertion mismatch with padded output
- evals/run.py: trust_env=False - bypass Windows system proxy (registry
  proxy at 192.168.0.4:7897 was intercepting localhost and returning 503)
- verified: 172 unit tests; ALL golden suites pass on real model
  (46/46): collection_skip 12/12, photo_skip 4/4, no_false 2/2,
  button_guidance 12/12, non_motor_vehicle 14/14, time_confirmation 2/2
2026-10-22 12:28:24 +08:00

297 lines
12 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.
"""采集节点 LLM 接入(作业 4)。
职责边界:
- LLM 一次调用同时产出 ``<state>XXXX</state>正文``——
不得为复判状态再调用第二次(计划作业 4-3);
- 代码校验前缀合法性、阶段枚举与白名单守卫,
非法提议不写状态(受控修复重试:前缀/非法提议最多各修复一次);
- 字段候选经 fields.validate_candidate 校验后进入
accident_fields / confirm_status / turn_patch(三态语义);
- 澄清/无回复计数是代码侧簿记,只作为 LLM 判断的输入,
转不转人工由模型按提示词决定(代码不代替模型决定)。
失败语义(计划作业 4-10):前缀缺失/非法且修复失败时,抛出
``CollectReplyError``——不提交业务状态、不泄漏原始模型内容;
调用方(API 层)将其转换为可识别错误响应。
"""
from collections.abc import Callable
from datetime import datetime
import json
import re
from .fields import (
FORM_FIELDS,
validate_form_update,
)
from .llm import LLMService
from .prompts import COLLECTION, EXTRACTION, render_prompt
from .state import AgentState
from .time_context import observation_time, time_evidence, valid_time_reply
from .transitions import COLLECT_STAGE, is_legal_transition
STATE_PREFIX_RE = re.compile(r"^\s*<state>\s*(\d{4})\s*</state>")
_STATE_TAG_RE = re.compile(r"</?state>")
# 采集阶段澄清阈值(来源:prompts/collection.md)
COLLECT_FAILURE_LIMIT = 5
# 受控修复指令(计划作业 4-10)。追加在系统提示词之后,
# 明确要求输出格式,供模型自我修正。
_REPAIR_SUFFIX = (
"\n\n【输出修复】你上一次的输出不合规:必须且只能以"
" <state>四位状态码</state> 开头(如 <state>1002</state>),"
"正文部分不得再出现 <state> 或 </state> 标签。"
"请重新输出一条完全合规的回复。"
)
class CollectReplyError(Exception):
"""采集回复不可用:格式、转换或时间依据校验且修复失败。
不携带原始模型内容,避免向上层泄漏。
"""
def parse_state_reply(text: str) -> tuple[str, str]:
"""解析 ``<state>XXXX</state>`` 前缀,返回 (stage_code, 正文)。
严格模式:无有效前缀、或正文再出现 state 标签(泄漏),
都视为不合规并抛 ValueError,交由 run_collect 决定是否修复。
"""
match = STATE_PREFIX_RE.match(text)
if not match:
raise ValueError("回复缺少有效的 <state> 前缀")
body = text[match.end():].strip()
if _STATE_TAG_RE.search(body):
raise ValueError("正文中再次出现 state 标签")
return match.group(1), body
def _try_parse(text: str) -> tuple[str, str] | None:
"""宽松入口:不合规返回 None 而不是抛错(供修复流程判断)。"""
try:
return parse_state_reply(text)
except ValueError:
return None
async def validated_reply(
llm: LLMService,
system_prompt: str,
user_message: str,
current: str,
*,
validator: Callable[[str, str], bool] = is_legal_transition,
history: list[dict[str, str]] | None = None,
reply_validator: Callable[[str, str], bool] | None = None,
) -> tuple[str, str]:
"""模型同时产出下一阶段和正文;格式与转换共用一次修复预算。
格式、阶段转换与可选的回复依据校验共用一次修复预算。不按输入长度
或关键字替模型选择动作。非法输出不写回图状态;修复仍失败则抛出
不含模型原文的错误。
"""
for attempt in range(2):
prompt = system_prompt
if attempt:
prompt += (
_REPAIR_SUFFIX
+ f"\n当前阶段为 {current}。请重新检查该阶段允许的转换"
"和当前 clientMode,不得跳步或切换到另一模式的出口。"
)
raw = await llm.complete(prompt, user_message, history=history)
parsed = _try_parse(raw)
if (parsed is not None and validator(current, parsed[0])
and (reply_validator is None or reply_validator(parsed[0], parsed[1]))):
return raw, parsed[0]
raise CollectReplyError("模型回复格式、阶段转换或时间依据不合规(一次修复后仍失败)")
# 工作流 chatNode 的 history 配置为 50;保留独立消息及其角色。
_HISTORY_MESSAGES = 50
def conversation_history(state: AgentState) -> list[dict[str, str]]:
"""历史不拼入 user 消息,不剥除历史 assistant 的状态前缀。"""
return [dict(message) for message in (state.get("dialogue") or [])[-_HISTORY_MESSAGES:]]
def context_prompt(state: AgentState, *, time_context: dict | None = None) -> str:
"""运行时上下文随 system 提示提供;业务判断仍由模型负责。"""
return "\n\n【当前会话上下文】\n" + json.dumps({
"stage_code": state.get("stage_code") or COLLECT_STAGE,
"client_mode": state.get("client_mode") or "direct",
"person_index": state.get("person_index") or 1,
"accident_fields": state.get("accident_fields") or {},
"person_fields": state.get("person_fields") or {},
"no_reply_count_before_this_turn": state.get("no_reply_count") or 0,
**({"time_evidence": time_context} if time_context is not None else {}),
}, ensure_ascii=False)
def _format_turn_context(moment: datetime, extra: dict | None = None) -> str:
"""workflow@20261022 的 <turn_context> 块:动态上下文经 user 消息传递。"""
lines = [f"本轮参考时间:{moment.strftime('%Y-%m-%d %H:%M:%S %z')}"]
for key, value in (extra or {}).items():
lines.append(f"{key}:{value}")
return "<turn_context>\n" + "\n".join(lines) + "\n</turn_context>"
def _build_user_message(state: AgentState, *, now: datetime) -> str:
"""对齐 workflow@20261022 采集节点 userChatInput:
<turn_context>(本轮参考时间)+ <user_input>(原始输入)。
"""
return (
f"{_format_turn_context(now)}\n\n"
f"<user_input>\n{state.get('input_text', '')}\n</user_input>"
)
# 采集阶段表单状态使用工作流的外部表单键(has_injury 等,见 fields.FORM_FIELDS);
# 内部键(ywrysw 等)属于 get_info 分组,作业 6 再建立映射。
# 提取调用的系统提示标记(不以 <state> 开头,不会被误当对话回复解析)
_EXTRACTION_MARKER = "【字段提取】"
async def _extract_turn_fields(
state: AgentState, llm: LLMService, *, now: datetime, time_context: dict,
) -> dict:
"""独立提取调用(计划作业 4-4:调用计数可审计——每轮在对话调用之外多一次)。
提示词为工作流「文本内容提取事故信息」原文(20261022 起时间/表单
经 content 的 <turn_context> 传递);模型只输出 JSON;
校验见 fields.validate_form_update。提取失败不阻塞对话(返回 {})。
"""
system = (
_EXTRACTION_MARKER
+ "\n按下列系统提示词执行提取,只输出 JSON 对象,不输出解释。\n\n"
+ render_prompt(EXTRACTION, state, now=now)
+ context_prompt(state, time_context=time_context)
)
message = (
f"{_format_turn_context(now, {'当前表单': json.dumps(state.get('accident_fields') or {}, ensure_ascii=False)})}\n\n"
f"<user_input>\n{state.get('input_text', '')}\n</user_input>"
)
try:
raw = await llm.complete(
system,
message,
history=conversation_history(state),
)
data = json.loads(raw.strip())
except Exception:
return {}
if not isinstance(data, dict):
return {}
if time_context["historical_reference_missing"]:
# An unanchored historical relative expression cannot support a date.
# Keep all other independently supported fields from this turn.
data.pop("accident_time", None)
return validate_form_update(data)
def _has_meaningful_value(value) -> bool:
"""对应「更新表单」的 hasMeaningfulValue:None/空串无效(0 有效)。"""
return value is not None and value != ""
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)。
"""
form = dict(state.get("accident_fields") or {})
patch: dict = {}
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]:
"""代码侧上下文簿记:无回复计数 + 澄清计数。
返回 (no_reply_count, clarify_count)。注意这只是给 LLM 的
判断输入,是否转人工由模型按提示词决定。
澄清失败次数由模型结合语义与历史判断;这里透传已有值,不把
每次普通输入都当作一次失败。
"""
event = state.get("client_event") or ""
no_reply_count = state.get("no_reply_count") or 0
if event == "no_reply":
no_reply_count += 1
elif (state.get("input_text") or "").strip():
no_reply_count = 0
return no_reply_count, state.get("clarify_count") or 0
async def run_collect(state: AgentState, llm: LLMService, *, now: datetime | None = None) -> dict:
"""采集节点核心:一轮对话的 LLM 接入(参考实现)。
流程:一次模型调用 → 前缀校验(不合规修复一次)→
白名单守卫(非法提议修复一次)→ 字段候选落库 → 返回状态增量。
任何一步修复后仍不合规都抛 CollectReplyError:不提交业务状态、
错误信息不携带原始模型内容。
"""
moment = observation_time(now)
evidence = time_evidence(state, moment)
system_prompt = render_prompt(COLLECTION, state, now=moment) + context_prompt(state, time_context=evidence)
user_message = _build_user_message(state, now=moment)
current = state.get("stage_code") or COLLECT_STAGE
raw, stage = await validated_reply(
llm, system_prompt, user_message, current, history=conversation_history(state),
reply_validator=lambda code, body: valid_time_reply(code, body, evidence),
)
# 4) 字段提取与表单差量(提取调用在对话调用之外,见 _extract_turn_fields)
extracted = await _extract_turn_fields(state, llm, now=moment, time_context=evidence)
accident_fields, patch = _merge_form(state, extracted)
no_reply_count, clarify_count = _bookkeeping(state)
# 5) 状态增量:response_text 保留完整前缀文本,API 层负责解析;
# dialogue 追加本轮消息,供下一轮历史注入(计划作业 4-5)
dialogue = list(state.get("dialogue") or [])
timestamps = dict(state.get("dialogue_timestamps") or {})
timestamps[str(len(dialogue))] = moment.isoformat()
dialogue.append({"role": "user", "content": state.get("input_text", "")})
dialogue.append({"role": "assistant", "content": raw})
return {
"turn_count": (state.get("turn_count") or 0) + 1,
"no_reply_count": no_reply_count,
"clarify_count": clarify_count,
"response_text": raw,
"stage_code": stage,
"raw_status_code": stage,
"accident_fields": accident_fields,
"turn_patch": patch,
"dialogue": dialogue,
"dialogue_timestamps": timestamps,
}