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