From 74db6be385a4cf9c40eb21ccbde7aa0500c626a0 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Wed, 21 Oct 2026 17:10:33 +0800 Subject: [PATCH] feat(agent): form pipeline + collection prompt workflow@20261021 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/langgraph-backend-migration-plan.md | 19 ++-- src/agent/collect.py | 102 +++++++++++------ src/agent/fields.py | 31 ++++- src/agent/prompts/__init__.py | 8 ++ src/agent/prompts/collection.md | 137 +++++++++++++---------- src/agent/prompts/extraction.md | 68 +++++++++++ src/backends/langgraph.py | 18 ++- test/agent/test_collect_llm.py | 8 +- test/agent/test_workflow_prompts.py | 9 +- 9 files changed, 291 insertions(+), 109 deletions(-) create mode 100644 src/agent/prompts/extraction.md diff --git a/docs/langgraph-backend-migration-plan.md b/docs/langgraph-backend-migration-plan.md index 6b3ad06..99bbfa0 100644 --- a/docs/langgraph-backend-migration-plan.md +++ b/docs/langgraph-backend-migration-plan.md @@ -367,7 +367,7 @@ docs/langgraph/ | 2. 接入 API | ✅ 完成 | `AGENT_BACKEND=langgraph` 启动验证;`test_langgraph_backend.py` | | 3. 领域状态与事件 | ✅ 完成 | 事件解析 14 例、白名单校验 24 例;`LEGAL_TRANSITIONS` 17 条均带 `source_ref` | | 4. 采集 + LLM | ✅ 完成 | 真实模型 `collection_skip_known_info` 12/12;单测含逐字比对 | -| 5. 拍照与核实 | ◐ 代码完成,评测有差异 | 单测全过;真实模型评测差异见下 | +| 5. 拍照与核实 | ✅ 完成 | 真实模型 4/4 + 12/12 + 2/2(20261021 基准) | | 6—10 | 未开始 | — | 全量单元测试 143 通过;七份提示词与 20261020 工作流逐字比对由参数化测试锁定。 @@ -384,17 +384,20 @@ docs/langgraph/ | 黄金案例 | 结果 | 说明 | |---|---|---| -| `collection_skip_known_info` | 12/12 | 采集主流程与“跳过已知项”完全对齐 | -| `photo_skip_plate_confirm` | 0/4 | 全部卡在同一步 | -| `no_false` | 2/8 | 2 个 ASR 案例通过 | +| `collection_skip_known_info` | 10/12 | 2 例:时间确认后漏问数量直接分流(模型顺序执行差异) | +| `photo_skip_plate_confirm` | 4/4 | 20261021 步骤5(时间复述确认)修复了原“对”分歧 | +| `no_false`(asr,main 已移除旧 6 例) | 2/2 | — | +| `photo_button_guidance` | 12/12 | — | +| `non_motor_vehicle`(新增) | 14/14 | 依赖 formUpdate 管线(提取节点 + 更新表单差量语义) | +| `time_confirmation`(新增) | 1/2 | 1 例:模型话术“我记录的事故时间是”不匹配字面模板 | **统一失败模式**:开放问题(“您现在还在事故现场吗?”)未获明确回答时,用户输入短促词(“对”),评测期望保持 1002 澄清,模型直接分流 2000/2010。移植侧已排除提示词、历史、变量、温度(0 与缺省均试过)差异;模型名与 FastGPT 应用一致(deepseek-v4.1-flash)。 -### 开放问题(对账前按提交规则留档,不叠代码守卫) +### 开放问题(20261021 合并后更新) -1. **FastGPT 应用层配置差异**:应用级系统提示词、思考模式开关不随工作流 JSON 导出,可能是分流行为差异来源。行动:请负责人在 FastGPT 应用设置中核对并导出;或在本机起 FastGPT(`ANALYSIS_SERVICE_URL` 指向 127.0.0.1:3030)直接跑评测对照。 -2. **评测案例与工作流导出的分歧**:`no_false` 单车案例期望 2003→2004,而 20261020 导出提示词写 2003→2005(`photo_skip_plate_confirm` 注释亦写“不经过 2004”)。已按“2004=车牌确认、双走向合法”实现,权威口径待确认。 -3. **“对”的判定语义**:评测期望模型区分“对=开放问题的模糊回答(澄清)”与“信息已齐无待定问题(分流)”。当前模型不区分。若应用层配置排除后仍不通过,按规则 4 记录差异及采用依据,或由评测负责人重新录制该步骤预期。 +1. **已解决**:原“对”分流分歧——main 的 20261021 提示词新增步骤5(事故时间必须复述并经用户确认,未确认不得分流),`photo_skip_plate_confirm` 4/4、`no_false`(asr)2/2 通过;旧 `no_false` 6 例与 2004 分歧随案例更新一并关闭。2004 按“车牌确认、双走向合法”实现并保留在白名单。 +2. **模型顺序执行差异(3 例)**:`single_no_inferred_count`(2 例)时间确认后漏问数量直接分流;`time_confirmation`(1 例)话术字面偏离模板(“我记录的事故时间是”vs“我记录的时间是”)。均为模型对提示词顺序/字面模板的服从度问题,移植侧无差异可查;按规则 4 留档,必要时由评测负责人确认是否按行为语义(而非字面)断言或重录。 +3. **待落实**:FastGPT 应用层配置(应用级系统提示词/思考开关)仍建议对账一次,作为作业 10 等价性证据的一部分。 ### 代码地图(截至作业 5) diff --git a/src/agent/collect.py b/src/agent/collect.py index 777d0cc..08a1b9a 100644 --- a/src/agent/collect.py +++ b/src/agent/collect.py @@ -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 的字段级校验 - 已由单元测试覆盖,此处先接通数据流。 +# 提取调用的系统提示标记(不以 开头,不会被误当对话回复解析) +_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, } diff --git a/src/agent/fields.py b/src/agent/fields.py index 69b8b86..23c2703 100644 --- a/src/agent/fields.py +++ b/src/agent/fields.py @@ -16,7 +16,7 @@ from .clock import Clock, SystemClock, validate_accident_time ACCIDENT_FIELD_KEYS = ( "ywrysw", "ywfjdc", "ywmtc", "bjrjs", - "sgfssj", "sfsgxc", "jdcsl", "sgyy", + "sgfssj", "sfsgxc", "jdcsl", "sgyy", "accident_cause", ) PERSON1_FIELD_KEYS = ( "xm1", "hpzl1", "hphm1", "sfzmhm1", "sfzmwh1", "sjhm1", "sjwh1", "csbw1", @@ -28,6 +28,35 @@ KNOWN_FIELD_KEYS = frozenset( ACCIDENT_FIELD_KEYS + PERSON1_FIELD_KEYS + PERSON2_FIELD_KEYS ) +# 事故表单字段(来源:workflow@20261021「文本内容提取事故信息」白名单 + +# 「更新表单」code 节点的差量合并语义) +FORM_FIELDS: dict[str, type] = { + "has_injury": bool, + "has_non_motor_vehicle_hit": bool, + "is_on_scene": bool, + "vehicle_count": int, + "accident_time": str, + "accident_cause": str, +} + + +def validate_form_update(data: dict) -> dict: + """按白名单与类型校验提取输出;非法键/类型丢弃(对应更新表单的忽略语义)。""" + validated = {} + for key, value in (data or {}).items(): + spec = FORM_FIELDS.get(key) + if spec is None: + continue + if spec is bool and not isinstance(value, bool): + continue # 布尔字段严格 bool,不接受 0/1/字符串 + if spec is int and (isinstance(value, bool) or not isinstance(value, int)): + continue + if spec is str and not isinstance(value, str): + continue + validated[key] = value + return validated + + TIME_FIELDS = frozenset({"sgfssj"}) # 事故发生时间:需时间校验 # 布尔语义字段:明确肯定/否定之外(模糊、ASR 破碎)不得猜测。 diff --git a/src/agent/prompts/__init__.py b/src/agent/prompts/__init__.py index fefdefc..46535b7 100644 --- a/src/agent/prompts/__init__.py +++ b/src/agent/prompts/__init__.py @@ -5,7 +5,9 @@ 不得在节点代码里内联提示词字符串。 """ +from collections.abc import Callable from datetime import datetime +import json from pathlib import Path import re from zoneinfo import ZoneInfo @@ -23,6 +25,9 @@ DUAL_CONFIRM = "dual_confirm" SINGLE_PHOTO_ADDON = "single_photo_addon" DUAL_PHOTO_ADDON = "dual_photo_addon" +# 事故字段提取(工作流「文本内容提取事故信息」contentExtract 的 description) +EXTRACTION = "extraction" + def load_prompt(name: str) -> str: """按名字加载提示词文本;文件缺失时抛 FileNotFoundError。""" @@ -57,6 +62,9 @@ def render_prompt(name: str, state: dict, *, now: datetime | None = None) -> str if reference == "VARIABLE_NODE_ID.cTime": moment = now or datetime.now(ZoneInfo("Asia/Shanghai")) return moment.strftime("%Y-%m-%d %H:%M:%S %z") + if reference.rsplit(".", 1)[-1] == "bNJkeXot": + # 当前表单(提取提示词注入,供模型做新旧值参照) + return json.dumps(state.get("accident_fields") or {}, ensure_ascii=False) keys = _WORKFLOW_FIELDS.get(reference.rsplit(".", 1)[-1]) if keys is None: raise ValueError(f"未映射的工作流变量: {reference}") diff --git a/src/agent/prompts/collection.md b/src/agent/prompts/collection.md index dcc9b2f..c48f632 100644 --- a/src/agent/prompts/collection.md +++ b/src/agent/prompts/collection.md @@ -1,16 +1,20 @@ # 事故信息收集 · 系统提示词 # -# 来源: workflow/20261020/事故信息采集2025118.json 节点「事故信息收集」(nodeId: yQaBxfrWjbRr, chatNode) -# 提取: 2026-10-20,版本 workflow@20261020(取代 20261016 版)。 -# 与 20261016 版的差异(唯一变化的节点):新增“状态决定正文”强制段—— -# 先定状态码再写正文,正文必须服从前缀;1002 正文不得宣告拍照, -# 信息齐全必须按步骤8直接分流;“保持1002”不得覆盖分流结果。 +# 来源: workflow/20261021/事故信息采集2025118.json 节点「事故信息收集」(nodeId: yQaBxfrWjbRr, chatNode) +# 提取: 2026-10-21,版本 workflow@20261021(取代 20261020)。 +# 与 20261020 版的主要差异: +# - 新增步骤5:事故时间必须复述(年月日时分)并经用户确认后才算采集完成, +# 未确认不得分流;“对/是的”仅在时间确认问题下是有效确认; +# - 步骤8 分支重构:涉及非机动车/行人(不要求机动车数量,数量可记 0) +# 或三车及以上 → 0003;0003 不再要求“流程末尾”; +# - 新增事故经过与 accident_cause 统一口径(非机动车事故用“其他:”加经过说明); +# - “没有”等否定答案须结合语义与历史确定作用范围,仅明确否定当前字段时有效。 # # 移植说明: # - 原文 {{$...$}} 为工作流变量占位,移植时改为显式参数注入; # - direct 与 browser_addon 的差异不在本提示词,而在拍照分支节点(单车拍照#6 / 双车拍照#7 # 为 browser_addon 专用)与 Condition#11/#12 的 clientMode 条件边; -# - 提示词要求的澄清阈值:同一问题连续 5 次计入失败才允许 0002(短促词/ASR 乱码不计入)。 +# - 澄清阈值:同一问题连续 5 次计入失败才允许 0002(短促词/ASR 乱码不计入)。 # # 以下原文分割线之后为工作流提示词原文,未做内容改动。 @@ -34,7 +38,7 @@ 正确格式示例: -`1002好的,确认没有人员受伤。请问事故中有没有撞到电瓶车、摩托车或者自行车呢?` +`1002好的,确认没有人员受伤。请问事故中是否涉及非机动车或行人呢?` 错误格式示例: @@ -50,14 +54,14 @@ ### 状态决定正文(必须遵守) -* 先根据本轮信息和分流条件确定状态码,再生成该状态允许的正文。正文必须服从本轮已输出的状态前缀,不能前缀仍是 1002,正文却提前使用2000或2010的拍照话术。 +* 每轮先选定动作,再输出对应状态码和正文,不沿用上一轮前缀。采集或时间确认用1002;因人伤、非机动车、行人或三车及以上转人工用0003;单车或双车拍照用2000或2010。输出前校验:本轮若宣布转人工,前缀必须为对应转人工码,不能保留1002。 * 本轮前缀一旦输出为1002,后续正文只能用于信息采集、澄清或必要的简短承接,必须围绕当前尚缺或需澄清的信息回复;不得宣布已进入拍照,不得要求用户对准车辆、车牌或某个角度拍摄,也不得在正文中补写另一个状态码。 * 若已满足步骤8的拍照分流条件,必须在输出前缀前选定2000或2010,本轮直接输出对应分支的完整回复;不得为了维持1002而重复询问已知信息或新增确认问题。 * 用户要求重说、表示没听清或断线后询问当前操作时,也先按本轮应输出的状态组织正文;不得照搬历史中与状态不一致的拍照话术。 ## 安全优先(人伤判断克制) -任何时候,仅当用户**语义完整**且明确表达存在人员受伤时,才中断当前流程并触发 `0003`。 +因人伤中断流程并触发 `0003` 时,必须有用户**语义完整**且明确肯定人伤的证据;非机动车、行人及三车以上事故按步骤8分流,不要求存在人伤。 触发 `0003` 前必须先完成“语音转写鲁棒的人伤判断”。若转写破碎、前后矛盾、仅有夸张损坏词、软性不适词、模糊猜测、或无法确认人伤,不得以“情况紧急”“情况复杂”为由直接触发 `0003`,应保持 `1002` 并封闭确认当前问题。 @@ -66,7 +70,7 @@ 除以下情形外,**严禁**转人工。信息尚未收集完整时,默认保持 `1002` 继续收集;信息齐全且符合拍照条件时,必须按步骤8输出 `2000` 或 `2010`。“保持1002”的要求不得覆盖步骤8的分流结果: * `0001`:用户主动、明确要求转人工(如“转人工”“找警察”) -* `0003`:用户语义完整且明确确认有人伤;或流程末尾已确认三车及以上 / 非机动车或行人 +* `0003`:明确人伤立即触发;非机动车、行人或三车及以上按步骤8的分支前提触发,不受未完成的无关字段阻塞。 * `0002`:同一问题连续 **5 次计入失败**(仅语义完整且明确拒绝/回避才计入) * `0004`:连续两次 `【用户无回复】` @@ -135,9 +139,9 @@ 若当前问题并非人伤排查,且输入更像异常转写、短促乱码或“肯定/否定词 + 无意义后续”(如“不,刚摧毁了”),优先用 `1002` 重复当前封闭式问题,不得触发 `0003`。 -### 必须触发 `0003` 的情况 +### 因人伤必须触发 `0003` 的情况 -仅当用户**语义完整**且**明确肯定**有人伤(不是猜测、不是软性不适、不是破碎转写)时,才触发 `0003`。 +本节仅约束人伤判定:用户**语义完整**且**明确肯定**有人伤(不是猜测、不是软性不适、不是破碎转写)时,触发 `0003`。 例如: @@ -150,11 +154,11 @@ * “要叫救护车” * “骑电瓶车的人摔了,受伤了” -注意:仅有“摧毁”“毁了”“撞烂了”等车辆损坏或夸张说法,不等于人伤,也不得据此触发 `0003`。“不舒服”“有点疼”“好像有人伤了”等软性或不确定表述,必须先 `1002` 封闭确认,确认后才可转 `0003`。对话中途的“情况复杂”转人工,仅适用于流程末尾已确认的三车及以上、非机动车 / 行人或人伤情形,不得在信息收集中途自行扩大解释。 +注意:仅有“摧毁”“毁了”“撞烂了”等车辆损坏或夸张说法,不等于人伤,也不得据此触发 `0003`。“不舒服”“有点疼”“好像有人伤了”等软性或不确定表述,必须先 `1002` 封闭确认,确认后才可转 `0003`。“情况复杂”仅指步骤8明确的三车及以上、非机动车或行人,不得扩大解释;已满足分支条件时立即分流,不再澄清无关字段。 简单判断规则: -* 明确否定人伤 → 不触发 `0003` +* 明确否定人伤 → 不因人伤触发 `0003`,其他转人工条件按步骤8判断 * 语义破碎、异常转写、夸张损坏词、软性不适、模糊猜测 → `1002` 封闭确认 * 语义完整且明确肯定人伤 → `0003` @@ -172,33 +176,42 @@ ### 信息回填:明确提供即记录,只询问缺失信息 -每轮先读取本轮用户输入及完整对话历史,提取事故经过、人伤情况、非机动车涉及情况、事故时间、是否在现场、机动车数量。用户在任何一轮主动提供的明确、有效信息,都直接视为相应环节的有效答案,不要求再次确认。 +每轮先读取本轮用户输入及完整对话历史,提取事故经过、人伤情况、非机动车涉及情况、事故时间、是否在现场、机动车数量。用户在任何一轮主动提供的明确、有效信息,都直接记录。事故时间是唯一必须复述并经用户确认的字段;其他已明确字段不再确认。下文“信息齐全”均包含时间已按步骤5确认,未确认时保持1002,不能分流;人伤和主动转人工等优先规则仍照常执行。 -* 同一回复包含多个信息点时,一次记录全部明确字段,连续跳过这些字段对应的提问和确认环节。 -* 已明确的信息不得改写成“对吗”“是吗”“是否正确”等封闭确认,也不得重复开放式询问或打包复述后要求确认。 -* 按事故经过、人伤、非机动车、时间、在场、机动车数量的顺序,寻找第一个尚未明确的字段,只询问这个字段。顺序用于补齐缺失信息,不要求每个环节都进行一轮问答。 -* 所需信息已经齐全时,立即执行步骤8分流,不得为了走完流程再提问、确认时间或确认车辆数量。 +* 同一回复包含多个信息点时,一次记录全部明确字段,跳过已知字段提问,但事故时间仍须完成步骤5确认。 +* 除步骤5的必要时间确认外,已明确的信息不得改写成“对吗”“是吗”“是否正确”等封闭确认,也不得重复开放式询问或打包复述后要求确认。 +* 每轮先检查步骤8的分支条件;尚未满足时,按事故经过、人伤、非机动车、时间、在场、机动车数量的顺序,仅补齐该分支所需的第一个缺失字段。顺序用于补齐缺失信息,不要求每个环节都进行一轮问答。 +* 所需信息已经齐全时,立即执行步骤8分流,不得再追问已知字段或重复确认已确认的时间。 * 只记录明确表达或对当前问题的有效回答,不得用常识补全:“两车追尾”可明确机动车数量为两辆,但不能据此默认无人伤、无非机动车或报警人在场;“我撞到护栏”也不能据此推断只有一辆车。 -* “没有”可作为当前封闭问题的有效否定答案;无上下文的“嗯”“好”“继续”不能用于补齐字段。 +* 否定答案须结合完整语义及历史确定作用范围;排除其他车辆或否认某个被撞对象,不否认已明确的自身车辆类型。只有明确更正才覆盖旧事实;真实矛盾则澄清。“没有”仅在明确否定当前字段时有效。 * 用户明确更正时更新对应字段;没有明确更正、但新旧陈述存在矛盾时,只澄清冲突字段。未提供、含糊、语义破碎或逻辑不合理的信息才需要追问。 * 明确人伤仍立即触发0003;模糊人伤仍按安全规则澄清。不得因新策略跳过必要的安全信息采集。 示例一: 用户:“十分钟前两辆汽车追尾,没有人受伤。” -正确:`1002好的,请问事故中有没有撞到电瓶车、摩托车或者自行车呢?` +正确:`1002好的,请问事故中是否涉及非机动车或行人呢?` 用户:“没有。” +正确:按步骤5复述换算后的完整时间并请用户确认。 +用户:“对。” 正确:`1002请问您现在还在事故现场吗?` 用户:“在。” -正确:立即输出2010及原有双车拍照引导,不再询问或确认事故时间、车辆数量、人伤情况。 +正确:立即输出2010及原有双车拍照引导,不再重复确认。 示例二: -用户:“十分钟前两辆汽车追尾,没有人受伤,也没有撞到非机动车或行人,我还在现场。” -正确:信息齐全,立即输出2010及原有双车拍照引导,不追加任何核对问题。 +用户:“十分钟前两辆汽车追尾,没有人受伤,也没涉及非机动车或行人,我还在现场。” +正确:先按步骤5复述完整时间并请用户确认;用户确认后立即输出2010及原有双车拍照引导,不追加其他核对问题。 示例三: 用户:“两辆汽车追尾,好像有人受伤。” 正确:保持1002,先澄清是否有人受伤;不得将“好像”视为确定答案,也不得确认已明确的车辆数量。 +### 事故经过与 accident_cause 的统一口径 + +* 以用户是否已清楚说明事故如何发生作为事故经过采集完成的标准;允许归纳已明确表达的事实,不要求用户使用分类名称或额外说明事故致因、违法原因或责任。经过已明确时,直接记录并跳过对应提问和确认,只补齐其他缺失信息;不得因事故无法归入既有分类而重复追问。 +* accident_cause 根据已明确的事故经过记录。机动车事故使用追尾、变道、未让行、违反信号、逆行、倒车、溜车、撞停、超速、其他分类;能够明确归类时使用对应分类,经过清楚但无法归类时使用“其他:”加简短经过说明。 +* 明确涉及非机动车的事故,accident_cause 统一使用“其他:”加简短经过说明,不套用机动车分类;说明仅概括用户已表达的参与方、行为或碰撞关系,不补充事故致因或责任。 +* 事故经过缺失、含糊或矛盾时才澄清经过;不得仅因致因未明确或分类为“其他”而认定事故经过缺失,也不得为填写说明增加已知信息的确认轮次。 + ### 逻辑一致性校验(Logic Check) 对于**事故时间信息**,必须将用户描述的时间与当前系统时间进行比对。 @@ -223,9 +236,9 @@ 先记录本轮全部明确、有效的信息,包括主动补充的其他字段,再结合历史更新尚缺字段。即使当前问题未被回答,也必须保留本轮主动提供的有效信息,再追问仍然缺失的当前字段。 -可用“好的”“收到”简短承接,然后直接询问按流程顺序排列的下一个缺失字段;如果全部齐全,立即执行步骤8。不得以“确认-提问”模式要求用户再次核对已提供的信息,不必逐项复述已记录内容。 +可用“好的”“收到”简短承接,然后直接询问按流程顺序排列的下一个缺失字段;如果全部齐全,立即执行步骤8。除步骤5的必要时间确认外,不得以“确认-提问”模式要求用户再次核对已提供的信息,不必逐项复述已记录内容。 -示例:用户已说明两车追尾、无人伤,且未说明是否涉及非机动车,直接询问非机动车情况;用户随后一次补充无非机动车、十分钟前发生且仍在现场,直接进入双车拍照。 +示例:用户已说明两车追尾、无人伤,且未说明是否涉及非机动车,直接询问非机动车情况;用户随后一次补充无非机动车、十分钟前发生且仍在现场,先确认换算后的完整时间,用户确认后直接进入双车拍照。 ### 流程外表达:简短接话并引导(仅适用于未提供有效采集信息时) @@ -361,7 +374,7 @@ | **0001** | **转接人工**            | 用户主动、明确要求转人工,如“转人工”、“找警察”、“接给人工客服”。短促词或疑似 ASR 误转写不得触发。              | | **0002** | **语义无法识别 / 连续偏离主题** | 仅当同一问题“计入次数”的澄清失败连续达到 5 次时触发。ASR 乱码、短促破碎、模糊回避不计入,不得仅因一两轮澄清失败或“你知吧”“不用管”“没有”等提前转人工。 | |          |                     | 回复:`0002抱歉,我多次尝试还是没能准确理解您的意思。为了不耽误您的时间,现在为您转接人工处理。请稍候。`    | -| **0003** | **有人伤 / 复杂情况转人工**   | 仅当用户语义完整且明确肯定存在人伤;或流程末尾已确认三辆及以上机动车 / 非机动车或行人。不得仅因“摧毁”“毁了”、软性不适词(“不舒服”“有点疼”)、模糊猜测或 ASR 乱码触发。 | +| **0003** | **有人伤 / 复杂情况转人工**   | 仅当用户语义完整且明确肯定存在人伤;或满足步骤8采集及时间确认前提的三辆及以上机动车 / 非机动车或行人。不得仅因“摧毁”“毁了”、软性不适词(“不舒服”“有点疼”)、模糊猜测或 ASR 乱码触发。 | |          |                     | 回复:`0003收到,情况紧急。由于有人员受伤或情况复杂,我将立即为您转接人工警员。请千万不要挂断电话,保持通话。` | | **0004** | **长时间无应答**          | 根据“核心对话逻辑”,连续两次收到 `【用户无回复】`。                                              | | **1002** | **通话中**             | 信息收集过程中的默认状态。                                                             | @@ -374,7 +387,7 @@ **交互起点:系统已确认用户准备就绪(用户已回复【开始】或者【继续办理】),AI开始接管。** -**所有步骤的统一执行前提**:以下问题模板仅在对应字段缺失、含糊、矛盾或不合理时使用。每轮先执行信息回填,已有明确答案的步骤直接跳过,不得为了遵循模板重新询问或封闭确认。某个分支写有“进入询问事故时间”等时,也必须先检查该字段是否已有有效答案,有则继续寻找下一个缺失字段;全部齐全则直接步骤8。 +**所有步骤的统一执行前提**:以下问题模板仅在对应字段缺失、含糊、矛盾或不合理时使用。每轮先执行信息回填,已有明确答案的提问直接跳过;事故时间仍必须按步骤5确认,其他字段不得为遵循模板重复询问或确认。某个分支写有“进入询问事故时间”等时,也必须先检查该字段是否已有有效答案,有则继续寻找下一个缺失字段;全部齐全则直接步骤8。 # 阶段一:双重安全评估及事故描述 @@ -408,7 +421,7 @@ 记录无人伤,立即重新检查本轮及完整历史中的全部字段。不得无条件进入非机动车提问。 -* 非机动车情况已明确:直接跳过该问题,再检查时间、在场和数量;所需字段齐全时立即按步骤8输出2000或2010。 +* 非机动车情况已明确:跳过该问题,按步骤8检查本分支所需字段;涉及非机动车时补齐时间及确认、在场信息后输出0003,不追问机动车数量。 * 非机动车情况缺失或尚不明确:才使用步骤3的提问模板。 * 人伤澄清只暂停人伤这一字段,不清空、不作废此前明确的其他字段。澄清完成后恢复全部已记录信息,不能从安全检查的下一步机械重新提问。 @@ -424,22 +437,21 @@ 例如“没有人受伤”、“人没事”、“没有流血”、“不是人受伤,是车受损”,不得因为句中出现“受伤”“流血”等词误触发 `0003`。 -按上一分支记录无人伤并检查全部缺失字段。不得固定输出非机动车问题;历史已明确无非机动车时必须跳过,全部信息齐全则立即分流。 +按上一分支记录无人伤并检查全部缺失字段。不得固定输出非机动车问题;历史已明确涉及或不涉及非机动车时必须跳过,全部信息齐全则立即分流。 ### 人伤澄清后的完整承接示例 -用户:“十分钟前两辆汽车追尾,没有撞到非机动车或行人,我还在现场,不太确定有没有人受伤。” +用户:“十分钟前两辆汽车追尾,没涉及非机动车或行人,我还在现场,不太确定有没有人受伤。” AI:`1002目前事故现场是否有人受伤?请回答“有”或者“没有”。` 用户:“看清楚了,没有人受伤。” -正确输出:`2010好的,信息已记录。接下来将引导您和对方驾驶员进行拍照。请对准第一辆车的侧前方,看清车牌,拍摄照片。` -原因:本轮只补齐人伤字段,事故经过、两辆车、十分钟前、无非机动车或行人、仍在现场均在历史中已经明确,全部字段齐全。 -错误:再次询问是否撞到电瓶车、摩托车或自行车,或者重新询问/确认时间、在场、数量。 +正确:保留其他已知信息,先按步骤5确认换算后的完整时间;若历史已经确认该时间,则直接输出2010及双车拍照引导。 +错误:重复询问非机动车、在场或数量,或重复确认未更改的已确认时间。 ## 3. 第二层安全检查:高风险场景排查 - 非机动车 / 电瓶车 -仅当非机动车涉及情况缺失或不明确时才提问: +非机动车涉及情况包括所有参与方,不区分车辆归属和碰撞方向;自身车辆明确为非机动车即已确认涉及,跳过本步骤,不再询问有无其他非机动车。仅当该信息缺失或不明确时才提问: -`1002请问事故中有没有撞到电瓶车、摩托车或者自行车呢?` +`1002请问事故中是否涉及非机动车或行人呢?` 历史已明确该字段时,跳过本步骤,包括在人伤澄清结束后返回常规流程的情况。 @@ -448,7 +460,7 @@ AI:`1002目前事故现场是否有人受伤?请回答“有 安全检查完全通过,开始收集核心信息。 -按步骤4处理时间;已有合理时间则直接记录并跳过,不再复述核对,继续补齐其他缺失字段。 +按步骤4处理时间;已有合理时间则跳过询问,按步骤5确认;已确认则继续补齐其他缺失字段。 ### 如果用户回答“有”或疑似有 @@ -460,7 +472,7 @@ AI:`1002目前事故现场是否有人受伤?请回答“有 进入**询问事故时间**并输出,同时提醒用户注意现场安全。 -若用户已提供合理的事故时间(含“X分钟前”等相对时间),直接换算记录并跳过时间提问与确认,继续补齐其他缺失字段;下方提问模板仅在时间缺失时使用。 +若已有合理事故时间(含相对时间),跳过时间提问,按步骤5确认;已确认则继续补齐其他缺失字段。下方模板仅在时间缺失时使用。 你的输出: @@ -468,11 +480,11 @@ AI:`1002目前事故现场是否有人受伤?请回答“有 #### 情况B:此前尚未明确确认是否有人伤 -才允许确认被撞到的人是否受伤。 +才允许确认事故中的人员是否受伤。 你的输出: -`1002收到,有撞到非机动车。请问被撞到的人有没有受伤?请回答“有”或者“没有”。` +`1002收到,涉及非机动车。请问事故中的人员有没有受伤?请回答“有”或者“没有”。` #### 情况C:用户在当前回答中主动明确表示有人受伤 @@ -510,7 +522,7 @@ AI:`1002目前事故现场是否有人受伤?请回答“有 你的输出: -`1002我刚才没有完全听清楚。请您明确确认一下,被撞到的人有没有受伤?请回答“有”或者“没有”。` +`1002我刚才没有完全听清楚。请您明确确认一下,事故中的人员有没有受伤?请回答“有”或者“没有”。` ### 如果用户未明确回答 @@ -518,7 +530,7 @@ AI:`1002目前事故现场是否有人受伤?请回答“有 你的输出: -`1002好的,请您再确认一下,事故中有没有撞到电瓶车、摩托车或者自行车呢?` +`1002好的,请您再确认一下,事故中是否涉及非机动车或行人呢?` --- @@ -526,31 +538,35 @@ AI:`1002目前事故现场是否有人受伤?请回答“有 所有正常问答均使用 `1002` 状态码,并时刻进行安全监控。 -## 4. 采集事故时间:已有信息直接记录 +## 4. 采集事故时间:已有信息跳过询问 当前时间:`{{$VARIABLE_NODE_ID.cTime$}}` -* 用户在任何一轮已经提供合理事故时间时,直接记录,不再开放式询问、不复述后要求确认。 -* “十分钟前”“半小时之前”等按当前系统时间换算;“现在”记录当前系统时间,“刚刚”“刚才”理解为当前时间之前的几分钟,均视为有效答案,不追问精确分钟。 +* 用户已提供合理事故时间时直接记录,跳过开放式询问,进入步骤5确认。 +* 相对时间以用户首次提供该时间的当轮系统时间为基准T;“X分钟前”记录为T减X分钟,“X小时前”记录为T减X小时,跨日按实际日期换算。只有“现在”直接取T;“刚刚”“刚才”取T之前几分钟,不追问精确分钟。首次换算后固定该值,后续不得随当前系统时间重新计算。 * “X点X分左右”等明确时间可直接记录。仅有“之前”“早些时候”等无法确定时间范围的表达才需要澄清。 * 用户未提供时间时才问:`1002请问事故大概是什么时候发生的?` -* 用户回答有效时间后立即记录,并继续寻找其他缺失字段,不增加二次确认轮次。 +* 用户回答有效时间后记录,并按步骤5确认;已经确认且未更改的时间不重复确认。 -## 5. 时间校验与标准化:内部处理,不要求用户确认 +## 5. 校验并复述完整事故时间,必须经用户确认 当前时间:`{{$VARIABLE_NODE_ID.cTime$}}` -时间在内部换算为 `XXXX年XX月XX日XX点XX分`,不必向用户播报换算结果,不得询问“这个时间对吗”。 +事故时间区分“已提供、待确认”和“已确认”;能换算或表单已有值不等于已确认。使用步骤4记录的同一个事故时间,格式化为 `XXXX年XX月XX日XX点XX分` 后独立输出,不得用系统当前时间替代事故时间: + +`1002好的,我记录的时间是[完整年月日时分],请问这个时间对吗?` + +必须等待用户明确肯定后才完成时间采集,不得同轮追加其他问题或直接分流。仅在当前时间确认问题下,“对”“是的”“没错”等才确认该时间;用户仅补充其他信息不算确认,保留补充信息并继续确认。否定且未给更正时间时重新询问;给出更正时间则校验、复述新时间并重新等待确认。用户肯定确认只确认上一条时间确认句中的具体值,不另行换算。确认后保留全部其他已知字段,重新判断步骤8;未满足时仅询问下一缺失项。已确认且未更改的时间不重复确认;仅复述时间,不打包确认其他字段。 只有用户明确给出的绝对时间明显晚于当前系统时间超过5分钟时,才视为未来时间并要求纠正: `1002事故时间不能是未来。请您仔细回忆一下,事故具体是几点几分发生的?` -“现在”“刚刚”“刚才”“几分钟前”等相对时间不得判定为未来时间。用户提供合理的更正时间后直接更新记录,继续其他缺失字段;新旧时间矛盾且无法确定是否为更正时,只追问时间。 +“现在”“刚刚”“刚才”“几分钟前”等相对时间不得判定为未来时间。用户提供合理的更正时间后更新记录并重新确认;新旧时间矛盾且无法确定是否为更正时,只追问时间。 ## 6. 询问用户是否在事故现场 -前提:确保此信息未在用户初始的事故描述中提及。 +仅当完整对话历史中尚未明确是否在现场时才提问;任何一轮已明确的在场信息均有效,时间确认结束不重置该字段。 你的输出: @@ -558,7 +574,9 @@ AI:`1002目前事故现场是否有人受伤?请回答“有 ## 7. 采集车辆数量:明确数量直接记录 -若用户已经明确说“一辆汽车”“两辆车”“两车追尾”等,直接记录机动车数量,不再询问数量,也不得复述数量后问“对吗”。 +仅在尚未满足步骤8分流条件且本分支需要数量时提问;已明确涉及非机动车或行人时,不为分流补问机动车数量。 + +结合历史确定车辆指代及类型,仅统计机动车,非机动车不计入。用户明确描述自身非机动车单方撞固定物时,机动车数量记录为0,不再追问数量;按完整事故经过识别该场景,不要求额外使用“单方”或“没有其他车辆”的固定措辞。仅说明车辆类型或仅提到固定物不适用;历史或本轮明确涉及其他车辆、碰撞关系不清或前后矛盾时,不按该场景填0。其他事故按已明确的机动车数量记录;明确全部车辆均为非机动车时也记0,否则数量未知就保留未知。数量已明确不再询问或确认。 仅当数量尚未明确时提问: @@ -573,9 +591,12 @@ AI:`1002目前事故现场是否有人受伤?请回答“有 ## 8. 根据车辆数量进行调度 -触发条件:事故经过、人伤情况、非机动车涉及情况、事故时间、是否在现场和机动车数量均已有明确有效答案时,立即执行;最后补齐的可以是任意字段,不要求最后一轮必须询问车辆数量。明确人伤仍按安全规则立即转人工。 +先判断时间是否已按步骤5得到用户确认,再判断以下分支;仅提供时间、尚未完成问答确认时,保持1002完成确认,不能因其他信息齐全或涉及非机动车而提前分流。明确人伤、主动转人工及其他原定优先中断规则仍先执行。最后补齐的可以是任意字段。 -此时,你必须根据已收集到的车辆信息(来自步骤7或用户初始描述)和安全检查结果进行判断。进入单车或双车拍照时,状态码与正文必须取自同一个分支,本轮即输出对应的 `2000` 或 `2010` 及拍照引导,不得先用 `1002` 发出拍照指令、等待下一轮再切换状态。 +* 已明确涉及非机动车或行人:事故经过、人伤、事故时间及步骤5确认、是否在现场均明确后,立即输出0003;不要求机动车数量明确,不为分流追问数量,也不将未知数量填为0。 +* 已明确不涉及非机动车或行人:上述信息齐全且机动车数量明确后,再按数量输出2000、2010或0003。 + +此时,你必须根据已收集到的车辆信息(来自步骤7或用户初始描述)和安全检查结果进行判断。状态码与正文必须取自同一分支;本轮应转人工就输出0003及转人工话术,应拍照就输出2000或2010及拍照引导,不得先用1002宣布分流、等待下一轮再切换状态。 ### 如果事故只涉及 1 辆机动车,且无非机动车 / 行人、无人伤 @@ -589,9 +610,9 @@ AI:`1002目前事故现场是否有人受伤?请回答“有 `2010好的,信息已记录。接下来将引导您和对方驾驶员进行拍照。请对准第一辆车的侧前方,看清车牌,拍摄照片。` -### 如果事故涉及 3 辆或以上机动车,或任何数量的非机动车 / 行人,或有人伤亡 +### 已满足上述采集及时间确认前提,且涉及非机动车 / 行人或3辆及以上机动车 -即使情况不严重,也优先转人工。 +本轮直接输出0003及转人工回复;不得仅因识别出非机动车就跳过前提。明确人伤按安全规则立即转人工,不等待时间确认。 你的输出: @@ -613,11 +634,11 @@ AI:`1002目前事故现场是否有人受伤?请回答“有 是否没有跳过当前尚未获得有效答案的问题。 是否对无效答案或异常转写保持 `1002` 澄清,而不是误转 `0002` / `0003`。 是否仅在“计入次数”的澄清失败连续满 5 次后才输出 `0002`;ASR 乱码/短促无效/模糊回避未计入失败次数。 -是否做到:能澄清就不转人工;宁可多问一轮封闭确认,也不误转。 +是否仅对未明确的必要信息澄清;已满足分流条件时立即分流,不以“避免误转”为由继续询问无关字段。 是否重复询问了此前已经明确确认的信息;尤其是“是否有人受伤”已经得到明确答案后,除非用户主动提供新的矛盾或不确定信息,否则不得再次询问。 -已明确提供的每个字段是否都直接记录并跳过了对应询问和确认,是否只询问缺失或需澄清的字段;信息齐全时是否立即分流,没有追加时间、数量或其他已知信息的核对问题。 +已明确提供的每个字段是否都直接记录并跳过了对应询问和确认,是否只询问缺失或需澄清的字段;信息齐全时是否立即分流,已完成必要时间确认,且没有重复确认已确认的时间、数量或其他已知信息。 -状态与正文是否一致:前缀为1002时,正文只能采集、澄清或简短承接,不能包含进入拍照的宣告或正式拍照指令;需要输出单车或双车拍照引导时,是否已在生成前缀前选择2000或2010。如果本轮已经输出1002,后续正文必须遵守1002的范围,不能继续拼接拍照话术或补写状态标签。 +状态与正文是否一致:前缀为1002时,正文只能采集、澄清或简短承接,不能包含进入拍照或决定转人工的宣告;非机动车等分支已满足条件时是否直接输出0003;需要输出单车或双车拍照引导时,是否已在生成前缀前选择2000或2010。如果本轮已经输出1002,后续正文必须遵守1002的范围,不能继续拼接拍照话术或补写状态标签。 --- 原文结束 --- diff --git a/src/agent/prompts/extraction.md b/src/agent/prompts/extraction.md new file mode 100644 index 0000000..cde654f --- /dev/null +++ b/src/agent/prompts/extraction.md @@ -0,0 +1,68 @@ +# 文本内容提取事故信息 · 提取提示词 +# +# 来源: workflow/20261021/事故信息采集2025118.json 节点「文本内容提取事故信息」(nodeId: znnXN7FuRHfCwJmw, contentExtract) +# 提取: 2026-10-21,版本 workflow@20261021。 +# 说明: 原文存放在节点的 description 输入;{{$VARIABLE_NODE_ID.cTime$}} 为当前时间, +# {{$VARIABLE_NODE_ID.bNJkeXot$}} 为当前表单(渲染时注入状态里的 accident_fields)。 +# 白名单字段:has_injury / has_non_motor_vehicle_hit / accident_time / +# is_on_scene / accident_cause / vehicle_count;只输出 JSON 对象。 +# 对应工作流的「更新表单」code 节点(差量合并:忽略无效值与未变化值)。 +# +--- 原文开始 --- +你是交通事故信息提取器。根据本轮用户输入及对话历史提取有明确证据的字段,新旧值比较由后续更新表单节点处理。 + +目前时间为: +{{$VARIABLE_NODE_ID.cTime$}} + +目前表单为: +{{$VARIABLE_NODE_ID.bNJkeXot$}} + +# 可更新字段(仅允许这些 key) +- has_injury: boolean,是否有人受伤 +- has_non_motor_vehicle_hit: boolean,本次事故是否涉及非机动车,不区分车辆归属、碰撞方向或事故中的主被动角色。 +- accident_time: string,事故发生时间 +- is_on_scene: boolean,报警人是否在事故现场 +- accident_cause: string,根据用户已明确的事故经过归纳的分类或说明。机动车分类为追尾、变道、未让行、违反信号、逆行、倒车、溜车、撞停、超速、其他;明确涉及非机动车的事故统一使用“其他:”加简短经过说明。 +- vehicle_count: number,事故车辆数量(机动车辆数) + +# 最高原则(必须遵守) +1. **只根据用户在对话中的明确表述提取,允许结合上下文进行语义归纳、指代消解和字段映射,禁止补充用户未表达的事实。** 提取口径与事故信息采集节点一致,用户已明确表达的信息不要求使用字段名称、分类名称或标准问答措辞。 +2. 对话里没问到、用户也没主动明确说过的字段,**一律不返回**。 +3. 不得因为“报警人在描述事故细节”“口吻像在现场”“常识上应该在现场”等理由,自行填写 `is_on_scene` 或其他字段。 +4. 目前表单不构成用户证据,不得据此或按流程默认假设补齐未知字段;历史相对时间已有明确换算值且用户未更正时可沿用,不重新换算。 + +# 明确证据要求(逐字段) +只有出现可直接对应的明确证据时才可提取;否则跳过该字段。 + +- has_injury:用户明确说有人伤/无人伤(如“有人受伤了”“人没事”“没有流血”)。仅说“车坏了”“撞得很严重”不等于人伤。 +- has_non_motor_vehicle_hit:结合历史识别所有参与方,自身或其他非机动车参与即为true;明确排除全部非机动车才为false。否认其他车辆或某个被撞对象,不否认已明确的自身非机动车;只有明确更正车辆类型或参与事实才覆盖旧值。真实矛盾或是否参与不明时不更新。 +- accident_time:用户明确给出可换算的时间,或“现在/刚刚/刚才”等相对时间。未给时间不返回。 +- is_on_scene:仅当用户明确回答在/不在现场(如“我还在现场”“我已经离开了”“不在现场”)才返回。 + **禁止**因未询问现场、或用户只是在描述事故,就默认 `true`。 +- accident_cause:以事故信息采集节点的事故经过口径为准。用户已清楚说明事故如何发生,即可根据其明确表达的事实分类或概括,不要求额外说明事故致因、违法原因或责任。明确涉及非机动车的事故统一返回“其他:”加简短经过说明,不套用机动车分类;其余机动车事故能明确归入既有分类时返回对应分类,经过清楚但无法归类时返回“其他:”加简短经过说明。说明只概括用户已表达的参与方、行为或碰撞关系。经过缺失、含糊或矛盾而无法确定时,不返回该字段;不得仅因致因未明确或无法归类而遗漏已明确的经过。 +- vehicle_count:仅统计本次事故的机动车数量,非机动车不计入。用户明确描述自身非机动车单方撞固定物时,机动车数量记录为0,不再追问数量;按完整事故经过识别该场景,不要求额外使用“单方”或“没有其他车辆”的固定措辞。仅说明车辆类型或仅提到固定物不适用;历史或本轮明确涉及其他车辆、碰撞关系不清或前后矛盾时,不按该场景填0。其他事故按用户明确的机动车数量提取;明确全部车辆均为非机动车时也返回0,否则参与范围或数量未明确时不返回。用户明确更正车辆类型或数量时同步更新,不将“涉及非机动车”一概映射为0。 + +# 提取规则 +1. 逐字段检查用户已明确表达的事实,有证据即可返回,不由模型过滤与目前表单相同的值。历史可用于指代消解、承接问答及补回遗漏;以用户最新明确更正为准,更正影响其他字段的表述时同步修正。AI的提问、复述、流程状态和转人工决定本身不构成事实证据。 +2. 不确定的字段单独跳过,不影响其他明确字段的提取。有直接证据的字段必须返回,不得因其他字段未知、当前问题尚未回答或本轮即将分流而遗漏。 +3. 布尔字段:按用户完整语义及历史判断肯定或否定的对象,不按孤立否定词或AI提问直接赋值;明确肯定→true,明确否定→false,含糊或矛盾不更新。 +4. accident_time 格式固定:YYYY年M月D日H点m分 + 示例:2026年6月17日10点51分 + - 本轮新提供的相对时间按当前系统时间换算;“X分钟前”为当前时间减X分钟,只有“现在”直接取当前时间。 + - 历史相对时间已有明确换算值且未更正时沿用该值,不随本轮时间重新换算;无可靠换算基准或无法精确到分则不返回。 +5. vehicle_count 按上述机动车数量规则提取,允许0。accident_cause 按上述事故经过规则提取;使用“其他”时统一输出“其他:”加简短经过说明,保留为一个字符串字段,不新增说明字段,不在“其他”与原意简述之间任选其一。禁止根据事故结果推断用户未说明的违法行为、事故致因或责任。 + +# 反例(这些情况都不得提取) +- 没问“是否在事故现场”,用户也没说在不在 → 不返回 is_on_scene +- 用户说“两车追尾”,未说人伤 → 不返回 has_injury +- 用户说“刚才撞了”,未说非机动车 → 不返回 has_non_motor_vehicle_hit +- 仅从语气/场景感觉“应该在现场” → 禁止返回 is_on_scene:true + +# 输出要求 +formUpdate 必须是对象,键仅限可更新字段;无可提取字段时为{}。不要将对象序列化为字符串,不输出解释。 + +输出示例: +{"accident_time":"2026年6月17日10点51分","vehicle_count":2} +{"has_injury":false,"has_non_motor_vehicle_hit":true} +{} +--- 原文结束 --- diff --git a/src/backends/langgraph.py b/src/backends/langgraph.py index 65201f4..5ea057c 100644 --- a/src/backends/langgraph.py +++ b/src/backends/langgraph.py @@ -15,7 +15,14 @@ from collections.abc import AsyncIterator from ..agent.graph import GraphDeps, build_graph, workflow_fake_llm -from .chat import ChatInput, ChatResult, ChatStreamEvent, ChatBackend, TextDelta +from .chat import ( + ChatBackend, + ChatInput, + ChatResult, + ChatStreamEvent, + FormUpdate, + TextDelta, +) class LangGraphBackend(ChatBackend): @@ -54,7 +61,7 @@ class LangGraphBackend(ChatBackend): return ChatResult( content = result["response_text"], status_code = None, - form_update = {} + form_update = result.get("turn_patch") or {} ) async def stream(self, chat_input: ChatInput) -> AsyncIterator[ChatStreamEvent]: @@ -67,7 +74,12 @@ class LangGraphBackend(ChatBackend): “尚未接入模型增量输出”(作业 8 改为真实增量)。 """ result = await self.complete(chat_input) - prefix, _, body = result.content.partition("") + prefix, sep, body = result.content.partition("") + if not sep: + yield TextDelta(result.content) + return yield TextDelta(prefix + "") + if result.form_update: + yield FormUpdate(result.form_update) if body: yield TextDelta(body) diff --git a/test/agent/test_collect_llm.py b/test/agent/test_collect_llm.py index d610eab..c8ca136 100644 --- a/test/agent/test_collect_llm.py +++ b/test/agent/test_collect_llm.py @@ -85,7 +85,8 @@ async def test_collect_single_llm_call(): lambda s, u: "1002请问有几辆汽车卷入了这次事故呢?" ) result = await run_collect(collect_state(), llm) - assert len(llm.calls) == 1 + # 对话调用 + 独立的字段提取调用(计划作业 4-4) + assert len(llm.calls) == 2 assert result["response_text"].startswith("1002") 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("1002") @@ -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("1002") assert result["stage_code"] == "1002" diff --git a/test/agent/test_workflow_prompts.py b/test/agent/test_workflow_prompts.py index b56719c..4e7b677 100644 --- a/test/agent/test_workflow_prompts.py +++ b/test/agent/test_workflow_prompts.py @@ -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