"""采集节点 LLM 接入(作业 4)。 职责边界: - LLM 一次调用同时产出 ``XXXX正文``—— 不得为复判状态再调用第二次(计划作业 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*\s*(\d{4})\s*") _STATE_TAG_RE = re.compile(r"") # 采集阶段澄清阈值(来源:prompts/collection.md) COLLECT_FAILURE_LIMIT = 5 # 受控修复指令(计划作业 4-10)。追加在系统提示词之后, # 明确要求输出格式,供模型自我修正。 _REPAIR_SUFFIX = ( "\n\n【输出修复】你上一次的输出不合规:必须且只能以" " 四位状态码 开头(如 1002)," "正文部分不得再出现 或 标签。" "请重新输出一条完全合规的回复。" ) class CollectReplyError(Exception): """采集回复不可用:格式、转换或时间依据校验且修复失败。 不携带原始模型内容,避免向上层泄漏。 """ def parse_state_reply(text: str) -> tuple[str, str]: """解析 ``XXXX`` 前缀,返回 (stage_code, 正文)。 严格模式:无有效前缀、或正文再出现 state 标签(泄漏), 都视为不合规并抛 ValueError,交由 run_collect 决定是否修复。 """ match = STATE_PREFIX_RE.match(text) if not match: raise ValueError("回复缺少有效的 前缀") 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 的 块:动态上下文经 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 "\n" + "\n".join(lines) + "\n" def _build_user_message(state: AgentState, *, now: datetime) -> str: """对齐 workflow@20261022 采集节点 userChatInput: (本轮参考时间)+ (原始输入)。 """ return ( f"{_format_turn_context(now)}\n\n" f"\n{state.get('input_text', '')}\n" ) # 采集阶段表单状态使用工作流的外部表单键(has_injury 等,见 fields.FORM_FIELDS); # 内部键(ywrysw 等)属于 get_info 分组,作业 6 再建立映射。 # 提取调用的系统提示标记(不以 开头,不会被误当对话回复解析) _EXTRACTION_MARKER = "【字段提取】" async def _extract_turn_fields( state: AgentState, llm: LLMService, *, now: datetime, time_context: dict, ) -> dict: """独立提取调用(计划作业 4-4:调用计数可审计——每轮在对话调用之外多一次)。 提示词为工作流「文本内容提取事故信息」原文(20261022 起时间/表单 经 content 的 传递);模型只输出 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"\n{state.get('input_text', '')}\n" ) 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, }