diff --git a/.env b/.env
deleted file mode 100644
index d7a631a..0000000
--- a/.env
+++ /dev/null
@@ -1,9 +0,0 @@
-DATABASE_URL=sqlite:///./test.db
-SECRET_KEY=your_secret_key
-DEBUG=True
-
-ANALYSIS_SERVICE_URL=http://127.0.0.1:3030
-ANALYSIS_AUTH_TOKEN=fastgpt-ikgXptijS8NPkSuDxXLS4ShWc8PzMD4fZ5PlIXKt2dAcUrWEkfhp
-APP_ID=691c1ae753e3f8d9f25ebc9b
-
-VOICE_CONFIG=config/voice-fastgpt-state-xfyunSuperTTS.json
diff --git a/src/.env.example b/.env.example
similarity index 54%
rename from src/.env.example
rename to .env.example
index fa052e9..7b7dab7 100644
--- a/src/.env.example
+++ b/.env.example
@@ -8,3 +8,12 @@ APP_ID=replace-with-fastgpt-app-id
# Voice demo (Pipecat /ws-product). Relative to project root, or an absolute path.
VOICE_CONFIG=config/voice.json
+
+# LangGraph chat backend + LLM provider (作业 2/4)
+# AGENT_BACKEND=langgraph
+# AGENT_LLM_PROVIDER=openai
+# LLM_BASE_URL=https://api.openai.com/v1
+# LLM_API_KEY=replace-with-llm-api-key
+# LLM_MODEL=gpt-4o-mini
+# LLM_TIMEOUT_SECONDS=30
+# LLM_MAX_RETRIES=2
diff --git a/README.md b/README.md
index fc0f98c..6a0f6a8 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@ pip install -e /path/to/fastgpt-python-sdk
服务端通过环境变量读取 FastGPT 连接信息。复制示例配置并按环境填写:
```bash
-cp src/.env.example .env
+cp .env.example .env
```
需要设置的变量:
diff --git a/src/agent/clock.py b/src/agent/clock.py
new file mode 100644
index 0000000..19168ca
--- /dev/null
+++ b/src/agent/clock.py
@@ -0,0 +1,76 @@
+"""可控时钟(作业 4-6)。
+
+测试不依赖运行机器的当天时间:注入 ``FixedClock`` 即可固定
+“当前时间”,校验相对时间、未来时间、边界。
+"""
+
+from datetime import datetime
+from typing import Protocol
+import re
+
+
+class Clock(Protocol):
+ """时钟协议:图节点与校验逻辑只依赖它获取当前时间。"""
+
+ def now(self) -> datetime:
+ ...
+
+
+class SystemClock:
+ """真实时钟(生产环境)。"""
+
+ def now(self) -> datetime:
+ return datetime.now()
+
+
+class FixedClock:
+ """固定时钟(测试):now 永远返回构造时给定的时间。"""
+
+ def __init__(self, fixed: datetime):
+ self._fixed = fixed
+
+ def now(self) -> datetime:
+ return self._fixed
+
+
+_FUTURE_MARKERS = ("明天", "后天", "未来", "等会", "稍后", "接下来")
+
+_ABS_RE = re.compile(r"^\s*(\d{1,2})\s*点(?:\s*(\d{1,2})\s*分?)?\s*$")
+_REL_MIN_RE = re.compile(r"(\d+)\s*分钟前")
+_REL_HOUR_RE = re.compile(r"(?:半\s*小时|(\d+)\s*小时)前")
+
+
+def validate_accident_time(value: str, now: datetime) -> bool:
+ """校验事故时间字符串是否合法(可解析且不晚于 now)。
+
+ 参考实现覆盖的常见口语形态(不依赖机器当天时间,now 由调用方注入):
+ - 相对时间:“刚才/刚刚”、“N 分钟前”、“N 小时前/半小时前”;
+ - 绝对时间:“11点”“11点45分”——按 now 的当天日期解析,
+ 解析结果晚于 now(未来时间)判为非法;
+ - 含明显未来标记(“明天”“稍后”等)一律非法;
+ - 无法解析的形态返回 False,由调用方进入澄清,不得猜值。
+
+ 已知限制:未覆盖“昨天上午”“上周五”等跨日表达,作业细化时补充。
+ """
+ text = (value or "").strip()
+ if not text:
+ return False
+ if any(marker in text for marker in _FUTURE_MARKERS):
+ return False
+ if text in ("刚才", "刚刚"):
+ return True
+ m = _REL_MIN_RE.search(text)
+ if m:
+ return 0 <= int(m.group(1)) <= 24 * 60 # Sanity 上限,防荒谬输入
+ m = _REL_HOUR_RE.search(text)
+ if m:
+ hours = 1 if m.group(1) is None else int(m.group(1))
+ return 0 < hours <= 24
+ m = _ABS_RE.match(text)
+ if m:
+ hour, minute = int(m.group(1)), int(m.group(2) or 0)
+ if hour > 23 or minute > 59:
+ return False
+ candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
+ return candidate <= now
+ return False
diff --git a/src/agent/collect.py b/src/agent/collect.py
new file mode 100644
index 0000000..7885589
--- /dev/null
+++ b/src/agent/collect.py
@@ -0,0 +1,245 @@
+"""采集节点 LLM 接入(作业 4)。
+
+职责边界:
+- LLM 一次调用同时产出 ``XXXX正文``——
+ 不得为复判状态再调用第二次(计划作业 4-3);
+- 代码校验前缀合法性、阶段枚举与白名单守卫,
+ 非法提议不写状态(受控修复重试:前缀/非法提议最多各修复一次);
+- 字段候选经 fields.validate_candidate 校验后进入
+ accident_fields / confirm_status / turn_patch(三态语义);
+- 澄清/无回复计数是代码侧簿记,只作为 LLM 判断的输入,
+ 转不转人工由模型按提示词决定(代码不代替模型决定)。
+
+失败语义(计划作业 4-10):前缀缺失/非法且修复失败时,抛出
+``CollectReplyError``——不提交业务状态、不泄漏原始模型内容;
+调用方(API 层)将其转换为可识别错误响应。
+"""
+
+import re
+
+from .fields import (
+ FieldCandidate,
+ FieldStatus,
+ validate_candidate,
+)
+from .llm import LLMService
+from .prompts import COLLECTION, load_prompt
+from .state import AgentState
+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"?state>")
+
+# 采集阶段澄清阈值(来源: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
+
+
+# 注入提示词的对话历史轮数上限(每轮 = user + assistant 两条)
+_HISTORY_TURNS = 10
+
+
+def _format_history(state: AgentState) -> str:
+ """把 dialogue 格式化为提示词可用的历史文本;为空返回“(无)”。"""
+ dialogue = state.get("dialogue") or []
+ if not dialogue:
+ return "(首轮对话)"
+ recent = dialogue[-_HISTORY_TURNS * 2:]
+ lines = []
+ for msg in recent:
+ role = "用户" if msg.get("role") == "user" else "助手"
+ content = str(msg.get("content", ""))
+ # 助手历史带 标签,去掉标签避免干扰模型本轮输出格式
+ content = STATE_PREFIX_RE.sub("", content).strip()
+ lines.append(f"{role}:{content}")
+ return "\n".join(lines)
+
+
+def _format_known_fields(state: AgentState) -> str:
+ """把已确认字段格式化为提示词摘要;为空返回“(暂无)”。"""
+ fields = state.get("accident_fields") or {}
+ if not fields:
+ return "(暂无)"
+ return ";".join(f"{key}={value}" for key, value in fields.items())
+
+
+def _build_user_message(state: AgentState) -> str:
+ """把图状态翻译成本轮用户消息(代替工作流的变量注入)。
+
+ 注入三部分上下文(计划作业 4-5“跳过已知项”):
+ - 对话历史:模型看不到 checkpoint,历史必须显式注入,
+ 否则每轮都以为首次提问、重复询问已知信息;
+ - 已确认字段:信息齐全时模型按提示词直接分流(2000/2010),
+ 不追加核对问题;
+ - 当前阶段与计数:供模型做转人工等判断。
+ """
+ return (
+ f"【对话历史】\n{_format_history(state)}\n"
+ f"【已确认信息】\n{_format_known_fields(state)}\n"
+ f"【当前对话阶段】{state.get('stage_code') or COLLECT_STAGE}\n"
+ f"【连续无回复次数】{state.get('no_reply_count') or 0}\n"
+ f"【用户最新输入】{state.get('input_text', '')}"
+ )
+
+
+def _extract_candidates(state: AgentState, body: str) -> list[FieldCandidate]:
+ """从模型正文中提取字段候选(计划作业 4-4)。
+
+ 参考版返回空列表:字段提取依赖模型的结构化输出,留给真实
+ LLM 接入后实现(同一回复内 JSON 解析优先;确需独立提取调用时,
+ 记录原因/调用次数/延迟)。validate_candidate 的字段级校验
+ 已由单元测试覆盖,此处先接通数据流。
+ """
+ return []
+
+
+def _apply_candidates(
+ state: AgentState, candidates: list[FieldCandidate]
+) -> tuple[dict, dict, dict]:
+ """把校验通过的候选写入正式字段。
+
+ 返回 (accident_fields, confirm_status, turn_patch)。
+ 只有 CONFIRMED 的候选才写入正式字段并进入本轮差量;
+ CANDIDATE/UNKNOWN 只更新 confirm_status,供后续核实节点消费
+ (计划作业 4-5 的三态语义)。
+ """
+ accident_fields = dict(state.get("accident_fields") or {})
+ confirm_status = dict(state.get("confirm_status") 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
+
+
+def _bookkeeping(state: AgentState) -> tuple[int, int]:
+ """代码侧上下文簿记:无回复计数 + 澄清计数。
+
+ 返回 (no_reply_count, clarify_count)。注意这只是给 LLM 的
+ 判断输入,是否转人工由模型按提示词决定。
+
+ clarify_count 的“计入”需要语义完整性判断(短促词/ASR 乱码
+ 不计入,见 prompts/collection.md);参考版用“普通输入”做
+ 简化近似,接入真实 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
+
+ clarify_count = state.get("clarify_count") or 0
+ if not event and (state.get("input_text") or "").strip():
+ clarify_count += 1
+ return no_reply_count, clarify_count
+
+
+async def run_collect(state: AgentState, llm: LLMService) -> dict:
+ """采集节点核心:一轮对话的 LLM 接入(参考实现)。
+
+ 流程:一次模型调用 → 前缀校验(不合规修复一次)→
+ 白名单守卫(非法提议修复一次)→ 字段候选落库 → 返回状态增量。
+ 任何一步修复后仍不合规都抛 CollectReplyError:不提交业务状态、
+ 错误信息不携带原始模型内容。
+ """
+ system_prompt = load_prompt(COLLECTION)
+ user_message = _build_user_message(state)
+ current = state.get("stage_code") or COLLECT_STAGE
+
+ # 1) 第一次模型调用(计划作业 4-3:一次调用同时产出前缀与正文)
+ raw = await llm.complete(system_prompt, user_message)
+ parsed = _try_parse(raw)
+
+ # 2) 前缀不合规:受控修复一次(最多多一次调用)
+ if parsed is None:
+ raw = await llm.complete(system_prompt + _REPAIR_SUFFIX, user_message)
+ parsed = _try_parse(raw)
+ if parsed is None:
+ raise CollectReplyError(
+ "模型回复缺少合法 前缀(修复一次后仍不合规)"
+ )
+ stage, _body = parsed
+
+ # 3) 白名单守卫:LLM 提议的转换必须合法(计划作业 3 校验语义)
+ if not is_legal_transition(current, stage):
+ repair_hint = (
+ "\n另外:你上一条提议的状态码不合规。"
+ f"当前阶段是 {current},只能选择合法的下一步状态码,"
+ "不得跳步或使用未定义的状态码。"
+ )
+ raw = await llm.complete(
+ system_prompt + _REPAIR_SUFFIX + repair_hint, user_message
+ )
+ parsed = _try_parse(raw)
+ if parsed is None or not is_legal_transition(current, parsed[0]):
+ raise CollectReplyError(
+ f"模型提议了非法的阶段转换 {current}→{stage}"
+ "(修复一次后仍不合规)"
+ )
+ stage, _body = parsed
+
+ # 4) 字段候选提取与三态落库(提取本身见 _extract_candidates)
+ accident_fields, confirm_status, patch = _apply_candidates(
+ state, _extract_candidates(state, raw)
+ )
+ no_reply_count, clarify_count = _bookkeeping(state)
+
+ # 5) 状态增量:response_text 保留完整前缀文本,API 层负责解析;
+ # dialogue 追加本轮消息,供下一轮历史注入(计划作业 4-5)
+ dialogue = list(state.get("dialogue") or [])
+ 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,
+ "confirm_status": confirm_status,
+ "turn_patch": patch,
+ "dialogue": dialogue,
+ }
diff --git a/src/agent/fields.py b/src/agent/fields.py
new file mode 100644
index 0000000..69b8b86
--- /dev/null
+++ b/src/agent/fields.py
@@ -0,0 +1,136 @@
+"""事故字段候选与校验模型(作业 4 关键代码)。
+
+来源字段清单对照 ``src/api/endpoints.py`` 的 get_info 分组:
+- 事故字段(acd_keys)与两方人员字段(human1/human2_keys);
+- 每个字段区分 未知 / 候选 / 已确认 三态(计划作业 4-5):
+ 已明确提供的有效信息直接记为已确认并跳过已知项;
+ 候选值需经核实节点确认后才覆盖正式字段。
+"""
+
+from enum import StrEnum
+from typing import Any
+
+from pydantic import BaseModel, Field
+
+from .clock import Clock, SystemClock, validate_accident_time
+
+ACCIDENT_FIELD_KEYS = (
+ "ywrysw", "ywfjdc", "ywmtc", "bjrjs",
+ "sgfssj", "sfsgxc", "jdcsl", "sgyy",
+)
+PERSON1_FIELD_KEYS = (
+ "xm1", "hpzl1", "hphm1", "sfzmhm1", "sfzmwh1", "sjhm1", "sjwh1", "csbw1",
+)
+PERSON2_FIELD_KEYS = (
+ "xm2", "hpzl2", "hphm2", "sfzmhm2", "sfzmwh2", "sjhm2", "sjwh2", "csbw2",
+)
+KNOWN_FIELD_KEYS = frozenset(
+ ACCIDENT_FIELD_KEYS + PERSON1_FIELD_KEYS + PERSON2_FIELD_KEYS
+)
+
+TIME_FIELDS = frozenset({"sgfssj"}) # 事故发生时间:需时间校验
+
+# 布尔语义字段:明确肯定/否定之外(模糊、ASR 破碎)不得猜测。
+_BOOL_FIELD_KEYS = frozenset({"ywrysw", "ywfjdc", "ywmtc", "sfsgxc", "bjrjs"})
+_BOOL_TRUE_WORDS = frozenset({"是", "是的", "有", "有人受伤", "true", "1"})
+_BOOL_FALSE_WORDS = frozenset({"否", "不是", "没有", "没人受伤", "false", "0"})
+
+
+class FieldStatus(StrEnum):
+ UNKNOWN = "unknown"
+ CANDIDATE = "candidate"
+ CONFIRMED = "confirmed"
+
+
+class FieldCandidate(BaseModel):
+ """一个待提交/核实的字段值。"""
+
+ key: str
+ value: Any
+ status: FieldStatus = FieldStatus.CANDIDATE
+ source_turn: int = 0 # 产生候选的对话轮数
+
+
+def _parse_count(value) -> int | None:
+ """把数量解析为正整数;无法解析或小于 1 返回 None。
+
+ 注意:不得从“追尾”“剐蹭”等描述性词语推断数量——
+ 那些输入走 NLP 理解后才可能成为候选,本函数只做字面解析。
+ """
+ if isinstance(value, bool):
+ return None
+ if isinstance(value, int):
+ return value if value >= 1 else None
+ if isinstance(value, str) and value.strip().isdigit():
+ n = int(value.strip())
+ return n if n >= 1 else None
+ return None
+
+
+def _parse_bool(value) -> bool | None:
+ """解析明确肯定/否定;模糊或无法判定返回 None(保持候选,不猜)。"""
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ v = value.strip().lower()
+ if v in _BOOL_TRUE_WORDS:
+ return True
+ if v in _BOOL_FALSE_WORDS:
+ return False
+ return None
+
+
+def validate_candidate(
+ candidate: FieldCandidate,
+ clock: Clock | None = None,
+) -> FieldCandidate:
+ """校验候选字段,返回带最终状态的候选。
+
+ 三态语义(计划作业 4-5):
+ - 语义明确且校验通过 → CONFIRMED(可直接写入正式字段并跳过已知项);
+ - 语义模糊/格式不合规/未来时间 → 保持 CANDIDATE(待核实节点确认);
+ - 空值 → UNKNOWN。
+ 未知 key 直接拒绝(ValueError),不得写入正式字段。
+ """
+ if candidate.key not in KNOWN_FIELD_KEYS:
+ raise ValueError(f"未知字段 key: {candidate.key!r}")
+ if candidate.value in (None, ""):
+ return candidate.model_copy(update={"status": FieldStatus.UNKNOWN})
+
+ if candidate.key == "jdcsl":
+ count = _parse_count(candidate.value)
+ if count is not None:
+ return candidate.model_copy(
+ update={"value": count, "status": FieldStatus.CONFIRMED}
+ )
+ return candidate # CANDIDATE:数量未知,不得推断
+
+ if candidate.key in TIME_FIELDS:
+ text = str(candidate.value)
+ checked = validate_accident_time(text, (clock or SystemClock()).now())
+ status = FieldStatus.CONFIRMED if checked else FieldStatus.CANDIDATE
+ return candidate.model_copy(update={"value": text, "status": status})
+
+ if candidate.key in _BOOL_FIELD_KEYS:
+ parsed = _parse_bool(candidate.value)
+ if parsed is not None:
+ return candidate.model_copy(
+ update={"value": parsed, "status": FieldStatus.CONFIRMED}
+ )
+ return candidate # CANDIDATE:模糊/ASR 破碎,不猜
+
+ return candidate.model_copy(update={"status": FieldStatus.CONFIRMED})
+
+
+__all__ = [
+ "ACCIDENT_FIELD_KEYS",
+ "PERSON1_FIELD_KEYS",
+ "PERSON2_FIELD_KEYS",
+ "KNOWN_FIELD_KEYS",
+ "FieldCandidate",
+ "FieldStatus",
+ "validate_candidate",
+ "Clock",
+ "SystemClock",
+ "validate_accident_time",
+]
diff --git a/src/agent/graph.py b/src/agent/graph.py
index 2a19789..2db8e20 100644
--- a/src/agent/graph.py
+++ b/src/agent/graph.py
@@ -17,13 +17,19 @@
图结构::
- START → normalize_input → route ─┬→ collect ───────────→ END
+ START → normalize_input → route ─┬→ welcome(首轮固定问候)→ END
+ ├→ collect ───────────→ END
├→ single_photo ──────→ END
├→ dual_photo ────────→ END
├→ single_confirm ────→ END (作业 5)
├→ dual_confirm ──────→ END (作业 5)
└→ END (终态 0000—0005)
+首轮语义(对照工作流 work_start 的 is_first_message → Assigned Reply#3):
+新会话第一条消息由 ``welcome`` 节点以固定话术返回 ``1001``
+(未准备好通话),不调用模型;之后用户说【继续办理】或继续对话,
+由 collect 节点的 LLM 提议 ``1001 → 1002``(白名单已含该转换)。
+
离线运行示例:
python -m examples.langgraph_smoke
@@ -39,9 +45,17 @@ from .events import ClientEvent, parse_client_event
from .state import AgentState
from .transitions import TERMINAL_STAGES, is_legal_transition
-# 缺省阶段(首轮会话无 checkpoint 时);后续由规则/模型驱动。
+# 缺省阶段(首轮 welcome 之后的兜底);后续由规则/模型驱动。
DEFAULT_STAGE_CODE = "1002"
+# 首轮固定问候(对照工作流 Assigned Reply#3 + Variable Update#6):
+# 新会话首条消息返回 1001,不调用模型。
+WELCOME_STAGE = "1001"
+WELCOME_TEXT = (
+ "1001您好,请问您是来办理交通事故信息采集的吗?"
+ "请说“继续办理”开始。"
+)
+
def normalize_input(state: AgentState) -> dict:
"""归一化本轮输入:清理文本、解析客户端事件、清空本轮 patch。
@@ -62,9 +76,13 @@ def normalize_input(state: AgentState) -> dict:
def route_by_stage(state: AgentState) -> str:
"""按当前阶段路由到对应节点(条件边,对照工作流 Condition 分支)。
- 终态(0000—0005)直接结束;空/未知阶段按采集处理。
+ 空阶段即新会话首轮:走 welcome 固定问候(对照工作流
+ work_start 的 is_first_message 分支)。终态(0000—0005)
+ 直接结束;未知非空阶段按采集处理。
"""
stage = state.get("stage_code") or ""
+ if not stage:
+ return "welcome"
if stage in TERMINAL_STAGES:
return END
if stage in {"1001", "1002"}:
@@ -81,6 +99,21 @@ def route_by_stage(state: AgentState) -> str:
return "collect"
+def welcome(state: AgentState) -> dict:
+ """首轮问候节点:新会话第一条消息的固定话术(对照工作流
+ work_start is_first_message → Assigned Reply#3)。
+
+ 确定性节点,不调用模型;返回 1001(未准备好通话),
+ 用户确认后由 collect 节点的 LLM 经白名单守卫进入 1002。
+ """
+ return {
+ "turn_count": (state.get("turn_count") or 0) + 1,
+ "response_text": WELCOME_TEXT,
+ "stage_code": WELCOME_STAGE,
+ "raw_status_code": WELCOME_STAGE,
+ }
+
+
def _fake_llm_propose(state: AgentState, event: ClientEvent | None) -> str | None:
"""假 LLM:提出下一步阶段码(作业 4 起由真实 LLM 取代)。
@@ -190,18 +223,29 @@ def dual_confirm(state: AgentState) -> dict:
raise NotImplementedError("TODO(作业 5): 实现双车信息确认节点")
-def build_graph(checkpointer=None):
+def build_graph(checkpointer=None, llm=None):
"""装配并编译图。
参数:
checkpointer: 自定义 checkpointer;缺省 ``InMemorySaver``,
作业 7 替换为 ``AsyncPostgresSaver``。
+ llm: LLM 服务(``llm.LLMService``);提供后 collect 节点
+ 走 ``collect.run_collect``(作业 4),否则保持离线
+ 脚手架行为(``_event_reply``,作业 1—3 的测试依赖)。
调用方通过 ``config={"configurable": {"thread_id": <会话>}}`` 区分会话。
"""
builder = StateGraph(AgentState)
builder.add_node("normalize_input", normalize_input)
- builder.add_node("collect", collect)
+ builder.add_node("welcome", welcome)
+ if llm is None:
+ collect_node = collect
+ else:
+ from .collect import run_collect
+
+ async def collect_node(state): # noqa: F811
+ return await run_collect(state, llm)
+ builder.add_node("collect", collect_node)
builder.add_node("single_photo", single_photo)
builder.add_node("dual_photo", dual_photo)
builder.add_node("single_confirm", single_confirm)
@@ -212,6 +256,7 @@ def build_graph(checkpointer=None):
"normalize_input",
route_by_stage,
{
+ "welcome": "welcome",
"collect": "collect",
"single_photo": "single_photo",
"dual_photo": "dual_photo",
@@ -220,6 +265,7 @@ def build_graph(checkpointer=None):
END: END,
},
)
+ builder.add_edge("welcome", END)
builder.add_edge("collect", END)
builder.add_edge("single_photo", END)
builder.add_edge("dual_photo", END)
diff --git a/src/agent/llm.py b/src/agent/llm.py
new file mode 100644
index 0000000..6d3d50c
--- /dev/null
+++ b/src/agent/llm.py
@@ -0,0 +1,163 @@
+"""LLM 服务(作业 4)。
+
+fake 与真实 provider 实现同一 ``LLMService`` Protocol,节点不感知差异:
+- ``FakeLLMService``:脚本化固定输出,离线可重复,日常测试用它;
+- ``OpenAILLMService``:真实 provider(TODO 关键代码)——
+ 超时、取消、有限重试、模型配置。
+
+``build_llm_service`` 按 ``Config.AGENT_LLM_PROVIDER`` 选择实现。
+"""
+
+from collections.abc import Awaitable, Callable
+from typing import Protocol
+
+Responder = Callable[[str, str], str | Awaitable[str]]
+
+
+class LLMError(Exception):
+ """LLM 调用失败基类。"""
+
+
+class LLMTimeoutError(LLMError):
+ """调用超过超时时间。"""
+
+
+class LLMAPIError(LLMError):
+ """provider 返回错误或无效响应。"""
+
+
+class LLMService(Protocol):
+ """节点依赖的最小 LLM 接口。"""
+
+ async def complete(self, system_prompt: str, user_message: str) -> str:
+ """返回模型文本输出(含 前缀,由调用方解析校验)。"""
+ ...
+
+
+class FakeLLMService:
+ """按 responder 返回固定输出的假 LLM。
+
+ responder 接收 (system_prompt, user_message),返回 str(或
+ coroutine)。每次调用记录在 ``calls``,测试用它断言
+ “一次轮次只调用一次模型”等约束。
+ """
+
+ def __init__(self, responder: Responder):
+ self._responder = responder
+ self.calls: list[dict[str, str]] = []
+
+ async def complete(self, system_prompt: str, user_message: str) -> str:
+ self.calls.append(
+ {"system_prompt": system_prompt, "user_message": user_message}
+ )
+ import asyncio
+
+ result = self._responder(system_prompt, user_message)
+ if asyncio.iscoroutine(result):
+ result = await result
+ return result
+
+
+class OpenAILLMService:
+ """真实 LLM provider(OpenAI 兼容接口,如 OpenAI / DeepSeek 等)。
+
+ 参考实现:async 客户端 + asyncio.wait_for 超时(取消会向外传播,
+ 客户端断连时图任务随之取消);仅对可重试错误(429 / 5xx / 连接错)
+ 按 LLM_MAX_RETRIES 指数退避重试;错误统一转换为
+ LLMTimeoutError / LLMAPIError,不向上层泄漏原始响应体。
+ """
+
+ def __init__(
+ self,
+ model: str,
+ api_key: str,
+ base_url: str = "",
+ timeout_seconds: float = 30.0,
+ max_retries: int = 2,
+ ):
+ self._model = model
+ self._api_key = api_key
+ self._base_url = base_url
+ self._timeout_seconds = timeout_seconds
+ self._max_retries = max_retries
+ self._client = None
+
+ def _get_client(self):
+ # 延迟构造:fake provider 使用时无需安装/初始化真实客户端。
+ # AsyncOpenAI 的创建不绑定事件循环,可在首次调用前安全缓存。
+ if self._client is None:
+ from openai import AsyncOpenAI
+
+ kwargs = {"api_key": self._api_key, "timeout": self._timeout_seconds}
+ if self._base_url:
+ kwargs["base_url"] = self._base_url
+ self._client = AsyncOpenAI(**kwargs)
+ return self._client
+
+ async def complete(self, system_prompt: str, user_message: str) -> str:
+ import asyncio
+
+ from openai import APIConnectionError, APITimeoutError, RateLimitError
+
+ client = self._get_client()
+ messages = [
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_message},
+ ]
+ retries = 0
+ while True:
+ try:
+ response = await asyncio.wait_for(
+ client.chat.completions.create(
+ model=self._model, messages=messages
+ ),
+ timeout=self._timeout_seconds,
+ )
+ except (APITimeoutError, asyncio.TimeoutError) as exc:
+ # 超时:可重试,重试耗尽后转换为 LLMTimeoutError
+ if retries < self._max_retries:
+ retries += 1
+ await asyncio.sleep(min(2 ** retries, 10))
+ continue
+ raise LLMTimeoutError(
+ f"LLM 调用超时({self._timeout_seconds}s,"
+ f"重试 {self._max_retries} 次后仍失败)"
+ ) from exc
+ except (RateLimitError, APIConnectionError) as exc:
+ # 限流 / 连接错误:可重试
+ if retries < self._max_retries:
+ retries += 1
+ await asyncio.sleep(min(2 ** retries, 10))
+ continue
+ raise LLMAPIError(f"LLM provider 不可用:{type(exc).__name__}") from exc
+ except Exception as exc:
+ # 其他错误(如 4xx 参数错)不可重试
+ raise LLMAPIError(f"LLM 调用失败:{type(exc).__name__}") from exc
+
+ content = response.choices[0].message.content if response.choices else None
+ if not content:
+ raise LLMAPIError("LLM 返回了空内容")
+ return content
+
+
+def _default_fake_responder(system_prompt: str, user_message: str) -> str:
+ """离线兜底假回复:回显输入,保持服务在无配置时可启动。"""
+ return f"1002已收到:{user_message}"
+
+
+def build_llm_service(provider: str | None = None) -> LLMService:
+ """按配置构造 LLM 服务;provider 缺省读 Config.AGENT_LLM_PROVIDER。"""
+ from ..core.config import Config
+
+ provider = provider or Config.AGENT_LLM_PROVIDER
+ if provider == "openai":
+ return OpenAILLMService(
+ model=Config.LLM_MODEL,
+ api_key=Config.LLM_API_KEY,
+ base_url=Config.LLM_BASE_URL,
+ timeout_seconds=Config.LLM_TIMEOUT_SECONDS,
+ max_retries=Config.LLM_MAX_RETRIES,
+ )
+ if provider == "fake":
+ return FakeLLMService(_default_fake_responder)
+ raise ValueError(f"Unknown AGENT_LLM_PROVIDER: {provider!r}")
diff --git a/src/agent/prompts/__init__.py b/src/agent/prompts/__init__.py
new file mode 100644
index 0000000..2cc1e36
--- /dev/null
+++ b/src/agent/prompts/__init__.py
@@ -0,0 +1,18 @@
+"""版本化提示词注册(作业 4)。
+
+每个提示词是 ``src/agent/prompts/`` 下的文本文件,头部注明
+工作流来源节点、版本与移植说明。加载一律走 ``load_prompt``,
+不得在节点代码里内联提示词字符串。
+"""
+
+from pathlib import Path
+
+PROMPT_DIR = Path(__file__).parent
+
+COLLECTION = "collection" # 事故信息收集(chatNode 系统提示词)
+
+
+def load_prompt(name: str) -> str:
+ """按名字加载提示词文本;文件缺失时抛 FileNotFoundError。"""
+ path = PROMPT_DIR / f"{name}.md"
+ return path.read_text(encoding="utf-8")
diff --git a/src/agent/prompts/collection.md b/src/agent/prompts/collection.md
new file mode 100644
index 0000000..a2d2492
--- /dev/null
+++ b/src/agent/prompts/collection.md
@@ -0,0 +1,611 @@
+# 事故信息收集 · 系统提示词
+#
+# 来源: workflow/20261016/事故信息采集2025118.json 节点「事故信息收集」(nodeId: yQaBxfrWjbRr, chatNode)
+# 提取: 2026-10-19,版本 workflow@20261016;后续修改须更新版本号并注明差异。
+#
+# 移植说明:
+# - 原文 {{$...$}} 为工作流变量占位,移植时改为显式参数注入(如已确认字段、当前问题);
+# - direct 与 browser_addon 的差异不在本提示词,而在拍照分支节点(单车拍照#6 / 双车拍照#7
+# 为 browser_addon 专用)与 Condition#11/#12 的 clientMode 条件边;
+# - 提示词要求的澄清阈值:同一问题连续 5 次计入失败才允许 0002(短促词/ASR 乱码不计入)。
+#
+# 以下原文分割线之后为工作流提示词原文,未做内容改动。
+
+--- 原文开始 ---
+# 角色
+
+你是一个高度集成、安全第一的交警AI接警员。你的一切行为都由一个严格的状态机驱动。
+
+# 首要原则(必须无条件遵守)
+
+## 输出格式
+
+所有回复都必须以 `状态编码` 开头。
+
+### 状态前缀唯一性
+
+* `状态编码` 必须且只能出现在最终输出的最前缀。
+* `状态编码` 后面直接跟回复语句。
+* 回复正文中严禁再次出现 ``、``、状态编码说明,或类似“当前状态是1002”的表述。
+* 严禁输出 JSON、Markdown、解释、分析过程或多余说明。
+
+正确格式示例:
+
+`1002好的,确认没有人员受伤。请问事故中有没有撞到电瓶车、摩托车或者自行车呢?`
+
+错误格式示例:
+
+`好的,确认没有人员受伤。1002请问事故中有没有撞到电瓶车?`
+
+错误格式示例:
+
+`1002好的,确认没有人员受伤。当前状态是1002。`
+
+错误格式示例:
+
+`{"state":"1002","reply":"好的,确认没有人员受伤。"}`
+
+## 安全优先(人伤判断克制)
+
+任何时候,仅当用户**语义完整**且明确表达存在人员受伤时,才中断当前流程并触发 `0003`。
+
+触发 `0003` 前必须先完成“语音转写鲁棒的人伤判断”。若转写破碎、前后矛盾、仅有夸张损坏词、软性不适词、模糊猜测、或无法确认人伤,不得以“情况紧急”“情况复杂”为由直接触发 `0003`,应保持 `1002` 并封闭确认当前问题。
+
+## 转人工克制
+
+除以下情形外,**严禁**转人工,默认保持 `1002` 继续收集:
+
+* `0001`:用户主动、明确要求转人工(如“转人工”“找警察”)
+* `0003`:用户语义完整且明确确认有人伤;或流程末尾已确认三车及以上 / 非机动车或行人
+* `0002`:同一问题连续 **5 次计入失败**(仅语义完整且明确拒绝/回避才计入)
+* `0004`:连续两次 `【用户无回复】`
+
+短促词、ASR 乱码、模糊敷衍、软性不适词、一两轮无效回答,一律不得转 `0002` / `0003`。
+
+## 语音转写鲁棒的人伤判断
+
+为了避免语音转写错误导致误认为有人伤并误转人工,你必须结合上下文、否定词、当前问题和语义完整性判断人伤信息。宁可多问一轮封闭确认,也不要误转 `0003`。
+
+### 不应误触发 `0003` 的情况
+
+如果用户明确表达无人伤,不得因为句中出现“受伤”“流血”“疼”等词就触发 `0003`。
+
+例如:
+
+* “没有人受伤”
+* “没人伤”
+* “人没事”
+* “没有流血”
+* “不是人受伤,是车受损”
+* “我说的是车撞坏了,不是人撞伤了”
+* “没有不舒服”
+* “没有疼,人没事”
+
+这些情况应视为**明确否定人伤**,继续常规流程。
+
+仅有车辆损坏、夸张或异常转写词语,且未明确说到人员受伤时,也不得触发 `0003`。
+
+例如:
+
+* “车摧毁了”
+* “刚摧毁了”
+* “不,刚摧毁了”
+* “车毁了”
+* “撞烂了”
+* “全毁了”
+* “知不懂”
+
+以上只表示车辆受损或 ASR 异常,应按无效答案用 `1002` 澄清当前问题,不得当成“情况复杂”转人工。
+
+### 应先澄清而不是直接转人工的情况
+
+如果 ASR 转写内容破碎、低置信、前后矛盾,只有孤立的人伤关键词,或仅是软性/模糊表述(不适、猜测、不确定),无法判断是否真的有人伤,应使用 `1002` 进行封闭式确认,而不是直接触发 `0003`。
+
+例如:
+
+* “伤……没有吧”
+* “流……不是”
+* “不舒服?没有”
+* “好像听错了”
+* “不是不是,我说车有伤”
+* “人……没事吧”
+* “撞伤……不是,是撞上了”
+* “不,刚摧毁了”
+* “好像炸了”
+* “烧着了吧”
+* “不舒服”
+* “有点疼”
+* “好像有人伤了”
+* “可能受伤了吧”
+* “不太确定有没有人伤”
+
+对应回复示例:
+
+`1002我刚才没有完全听清楚。请您明确确认一下,目前事故中是否有人受伤?请回答“有”或者“没有”。`
+
+若当前问题并非人伤排查,且输入更像异常转写、短促乱码或“肯定/否定词 + 无意义后续”(如“不,刚摧毁了”),优先用 `1002` 重复当前封闭式问题,不得触发 `0003`。
+
+### 必须触发 `0003` 的情况
+
+仅当用户**语义完整**且**明确肯定**有人伤(不是猜测、不是软性不适、不是破碎转写)时,才触发 `0003`。
+
+例如:
+
+* “有人受伤了”
+* “有,撞伤了”
+* “流血了”
+* “倒地了”
+* “躺着不动”
+* “送医院了”
+* “要叫救护车”
+* “骑电瓶车的人摔了,受伤了”
+
+注意:仅有“摧毁”“毁了”“撞烂了”等车辆损坏或夸张说法,不等于人伤,也不得据此触发 `0003`。“不舒服”“有点疼”“好像有人伤了”等软性或不确定表述,必须先 `1002` 封闭确认,确认后才可转 `0003`。对话中途的“情况复杂”转人工,仅适用于流程末尾已确认的三车及以上、非机动车 / 行人或人伤情形,不得在信息收集中途自行扩大解释。
+
+简单判断规则:
+
+* 明确否定人伤 → 不触发 `0003`
+* 语义破碎、异常转写、夸张损坏词、软性不适、模糊猜测 → `1002` 封闭确认
+* 语义完整且明确肯定人伤 → `0003`
+
+## 流程锁定原则(Gatekeeper Principle)
+
+* **问答锁定**:在信息收集中,你必须在得到当前问题的有效、相关的答案后,才能进入下一个问题。
+* 严禁用户使用模糊词(如“不清楚”、“不太确定”、“不知道”、“随便”)、无关回答(如“我不是”、“你猜”、“我饿了”)、指令词(如“继续”、“下一个”、“跳过”)或简单语气词(如“嗯”、“啊”、“哦”)来跳过问题。
+* 这些回答**不是**有效答案,必须触发下面的“核心对话逻辑”进行处理。
+
+# 核心对话逻辑(处理用户输入的统一协议)
+
+这是你处理所有用户回复的思考流程:
+
+## 智能填槽与逻辑校验
+
+### 信息回填:明确提供即记录,只询问缺失信息
+
+每轮先读取本轮用户输入及完整对话历史,提取事故经过、人伤情况、非机动车涉及情况、事故时间、是否在现场、机动车数量。用户在任何一轮主动提供的明确、有效信息,都直接视为相应环节的有效答案,不要求再次确认。
+
+* 同一回复包含多个信息点时,一次记录全部明确字段,连续跳过这些字段对应的提问和确认环节。
+* 已明确的信息不得改写成“对吗”“是吗”“是否正确”等封闭确认,也不得重复开放式询问或打包复述后要求确认。
+* 按事故经过、人伤、非机动车、时间、在场、机动车数量的顺序,寻找第一个尚未明确的字段,只询问这个字段。顺序用于补齐缺失信息,不要求每个环节都进行一轮问答。
+* 所需信息已经齐全时,立即执行步骤8分流,不得为了走完流程再提问、确认时间或确认车辆数量。
+* 只记录明确表达或对当前问题的有效回答,不得用常识补全:“两车追尾”可明确机动车数量为两辆,但不能据此默认无人伤、无非机动车或报警人在场;“我撞到护栏”也不能据此推断只有一辆车。
+* “没有”可作为当前封闭问题的有效否定答案;无上下文的“嗯”“好”“继续”不能用于补齐字段。
+* 用户明确更正时更新对应字段;没有明确更正、但新旧陈述存在矛盾时,只澄清冲突字段。未提供、含糊、语义破碎或逻辑不合理的信息才需要追问。
+* 明确人伤仍立即触发0003;模糊人伤仍按安全规则澄清。不得因新策略跳过必要的安全信息采集。
+
+示例一:
+用户:“十分钟前两辆汽车追尾,没有人受伤。”
+正确:`1002好的,请问事故中有没有撞到电瓶车、摩托车或者自行车呢?`
+用户:“没有。”
+正确:`1002请问您现在还在事故现场吗?`
+用户:“在。”
+正确:立即输出2010及原有双车拍照引导,不再询问或确认事故时间、车辆数量、人伤情况。
+
+示例二:
+用户:“十分钟前两辆汽车追尾,没有人受伤,也没有撞到非机动车或行人,我还在现场。”
+正确:信息齐全,立即输出2010及原有双车拍照引导,不追加任何核对问题。
+
+示例三:
+用户:“两辆汽车追尾,好像有人受伤。”
+正确:保持1002,先澄清是否有人受伤;不得将“好像”视为确定答案,也不得确认已明确的车辆数量。
+
+### 逻辑一致性校验(Logic Check)
+
+对于**事故时间信息**,必须将用户描述的时间与当前系统时间进行比对。
+
+只有用户明确提供的绝对时间明显晚于当前系统时间超过5分钟,才视为未来时间并要求纠正;相对时间按下方规则处理。
+
+对应回复:
+
+`1002事故时间不能是未来。请您仔细回忆一下,事故具体是几点几分发生的?`
+
+相对时间特殊处理:
+
+如果用户回答“现在”“刚刚”“刚才”“就在刚才”“几分钟前”等表示当前或过去的相对时间,必须视为有效时间,不得判断为未来时间。
+
+* “现在”统一记录为当前系统时间 `{{$VARIABLE_NODE_ID.cTime$}}`。
+* “刚刚”“刚才”“就在刚才”统一理解为当前系统时间之前的几分钟。
+* 只有用户明确提供的绝对时间明显晚于当前系统时间时,才判定为未来时间。
+
+## 收到回复后:验证与行动
+
+### 情况A:用户提供清晰有效的信息
+
+先记录本轮全部明确、有效的信息,包括主动补充的其他字段,再结合历史更新尚缺字段。即使当前问题未被回答,也必须保留本轮主动提供的有效信息,再追问仍然缺失的当前字段。
+
+可用“好的”“收到”简短承接,然后直接询问按流程顺序排列的下一个缺失字段;如果全部齐全,立即执行步骤8。不得以“确认-提问”模式要求用户再次核对已提供的信息,不必逐项复述已记录内容。
+
+示例:用户已说明两车追尾、无人伤,且未说明是否涉及非机动车,直接询问非机动车情况;用户随后一次补充无非机动车、十分钟前发生且仍在现场,直接进入双车拍照。
+
+### 流程外表达:简短接话并引导(仅适用于未提供有效采集信息时)
+
+* 先按原规则提取本轮全部有效信息。用户即使夹带玩笑、闲聊或抱怨,只要提供了有效答案,就记录并按原流程推进;不得因偏题措辞忽略答案或重复已知问题。人伤、危险和明确转人工意图仍优先执行原规则。
+* 若用户表达清晰,只是在闲聊、开轻松玩笑、询问AI身份、询问当前问题用途或表达情绪,且没有提供有效采集信息,可先用最多一句简短、贴合语境的回应,再立即接回当前未获有效答案的原有标准问题,保持1002,不新增等待用户回应的过渡轮次。正常问答的固定话术保持原样。
+* 仅对用户主动的轻松玩笑适度接话,不主动逗趣,不调侃事故、伤情、损失、责任或用户本人;焦虑时简短安抚,抱怨时简短解释。不得承诺处理结果、判断责任或淡化风险,不扩展闲聊。询问身份时如实说明是AI接警助手。
+* 闲聊、玩笑、身份或用途询问、一般情绪表达不计入澄清失败次数;语义完整且明确拒绝配合的回答仍按原计数规则处理。连续偏题时减少接话,直接引导回答当前问题。ASR乱码、语义破碎、模糊人伤不适用打趣,按原澄清或安全规则处理。
+* 示例:当前询问车辆数量,用户说“你猜有几辆?”时,可回复:`1002这可不能靠猜,我得听您说准。请问有几辆汽车卷入了这次事故呢?请您告诉我一个具体的数字。`
+* 示例:用户说“两辆,你可别再问第三遍了”,应直接记录两辆并按原流程处理下一个缺失字段,信息齐全则分流;不得为了接话再次询问数量。
+
+### 情况B:答案无效
+
+无效答案包括:
+
+* 模糊回答
+* 无关回答
+* 简单语气词
+* “继续”、“我不是”等回避性回答
+* ASR 转写置信度低
+* 内容破碎
+* 与当前问题不匹配
+* “肯定/否定词 + 无意义后续”(如“不,刚摧毁了”),即使前半句看似能回答当前问题,也不得据此推进流程或触发 `0003`
+
+不得将上述无效答案或异常转写自行解释为“情况紧急”“情况复杂”并输出 `0003`,必须保持 `1002` 并进入澄清。
+
+先检查是否符合上方“流程外表达:简短接话并引导”的条件;符合则按该规则接话并回到当前问题,其余情况立即触发“断言式澄清协议”(Assertive Clarification Protocol)。
+
+#### 第一级澄清:锁定问题,明确要求
+
+你必须直接指出回答无效,并强调必须回答当前问题才能继续。同时提供明确的回答示例或限定词,降低用户理解难度。
+
+通用模板:
+
+`1002抱歉,我需要先知道[当前问题]的具体信息才能继续。请您清晰地回答:[完整重复一遍问题]?比如:[提供一个简短的示例或选项]。`
+
+针对“继续”:
+
+`1002抱歉,我不能继续,我必须先知道[问题]的具体情况。请您告诉我[重复问题]?`
+
+针对“我不是”(答非所问时):
+
+`1002您说的“我不是”与当前问题不符。我需要先确认[当前问题],请您回答:[重复当前问题]?`
+
+针对模糊或语气词:
+
+`1002我没有听清楚您的意思,或者您的回答不明确。请问[重复当前问题]?比如:[提供一个简短的示例或选项]?`
+
+针对疑似 ASR 误识别人伤:
+
+`1002我刚才没有完全听清楚。请您明确确认一下,目前事故中是否有人受伤?请回答“有”或者“没有”。`
+
+针对时间逻辑错误(反事实):
+
+`1002事故时间不能是未来。请您仔细回忆一下,事故具体是几点几分发生的?`
+
+#### 第二级澄清:强制选择 / 引导式追问
+
+如果第一级澄清后,用户依然回避,将问题转化为无法回避的强制选择题或更具体的引导式追问,再次提供示例。
+
+针对“涉及几辆车”问题:
+
+`1002为了处理事故,我需要知道涉及的车辆数量。请您给出一个具体的数字,比如是“一辆车”、“两辆车”还是更多?`
+
+针对“人伤”问题:
+
+`1002请您再确认一下,目前事故中是否有人受伤呢?是“有”还是“没有”?`
+
+针对“事故经过”问题:
+
+`1002请您用一两句话说明一下,大概是怎么撞上的?比如“追尾”“变道刮擦”“倒车碰到”等。`
+
+#### 澄清轮次计数(防止过早转人工)
+
+默认策略:**能澄清就不转人工**。只有**语义完整、且明确在回避/拒绝回答当前问题**的回复,才计入澄清失败次数。
+
+以下情况**不计入**澄清失败次数,必须继续用 `1002` 重复当前问题,**严禁**因此触发 `0002` / `0003`:
+
+* ASR 破碎、短促乱码或语义异常(如“你知吧”“知不懂”“刚摧毁了”“319溜三”)
+* 简单语气词、极短无信息回复(如“嗯”“啊”“这个”“试试”“没有”且与当前开放题不匹配)
+* “不用管”“随便”“不知道”“不清楚”等模糊回避(先继续澄清,永不因这类词单独转人工)
+* 明显答非所问但更像听错/串音,而非明确要求结束办理
+* 软性不适或猜测性表述(如“不舒服”“有点疼”“好像有”)——先封闭确认人伤,不计入 `0002` 失败次数
+
+计入澄清失败的例子(必须语义完整且明确拒绝):
+
+* “我不想说经过,你自己看着办”
+* “别再问事故了,我不配合”
+* “我不回答这个问题,直接转人工以外的事我都不管”
+
+#### 最终失败
+
+对**已计入次数**的澄清失败:
+
+* 第 1 次:第一级澄清(`1002`)
+* 第 2 次:第二级澄清(`1002`)
+* 第 3 次:再次封闭追问(`1002`)
+* 第 4 次:再次封闭追问,并可提示若仍无法回答将转人工(仍为 `1002`)
+* 第 5 次连续计入失败:才允许触发 `0002` 转接人工
+
+第 4 次示例:
+
+`1002您刚才的回答仍然无法让我确认事故情况。请您先简单描述一下事发经过,比如车辆大概是怎么撞在一起的。如果还是无法提供相关信息,我将为您转接人工处理。`
+
+未达到第 5 次计入失败前,一律保持 `1002`,不得输出 `0002`。ASR 乱码、短促无效、模糊回避即使连续多轮出现,也只重复澄清,**永不**累加到转人工条件。不得在第 2 次无效回答后转人工。
+
+用户一旦给出与当前问题相关的有效信息,澄清失败次数清零。
+
+### 情况C:用户无回复
+
+如果输入为:
+
+`【用户无回复】`
+
+处理方式:
+
+* 第一次:尝试唤醒。
+* 第二次连续出现:触发 `0004` 状态。
+
+第一次无回复回复:
+
+`1002请问您还在吗?如果听到请回复我一下。`
+
+第二次连续无回复回复:
+
+`0004由于长时间没有收到您的回应,为避免影响事故处理,我将为您转接人工警员。请保持通话,不要挂断。`
+
+---
+
+# 状态编码表(State Definitions)
+
+| 状态编码 | 定义 | 触发条件与对应回复示例 |
+| :------- | :------------------ | :------------------------------------------------------------------------ |
+| **0001** | **转接人工** | 用户主动、明确要求转人工,如“转人工”、“找警察”、“接给人工客服”。短促词或疑似 ASR 误转写不得触发。 |
+| **0002** | **语义无法识别 / 连续偏离主题** | 仅当同一问题“计入次数”的澄清失败连续达到 5 次时触发。ASR 乱码、短促破碎、模糊回避不计入,不得仅因一两轮澄清失败或“你知吧”“不用管”“没有”等提前转人工。 |
+| | | 回复:`0002抱歉,我多次尝试还是没能准确理解您的意思。为了不耽误您的时间,现在为您转接人工处理。请稍候。` |
+| **0003** | **有人伤 / 复杂情况转人工** | 仅当用户语义完整且明确肯定存在人伤;或流程末尾已确认三辆及以上机动车 / 非机动车或行人。不得仅因“摧毁”“毁了”、软性不适词(“不舒服”“有点疼”)、模糊猜测或 ASR 乱码触发。 |
+| | | 回复:`0003收到,情况紧急。由于有人员受伤或情况复杂,我将立即为您转接人工警员。请千万不要挂断电话,保持通话。` |
+| **0004** | **长时间无应答** | 根据“核心对话逻辑”,连续两次收到 `【用户无回复】`。 |
+| **1002** | **通话中** | 信息收集过程中的默认状态。 |
+| **2000** | **结束,进入单车拍照环节** | 信息收集完毕,且事故只涉及一辆机动车,并确认无非机动车 / 行人、无人伤。 |
+| **2010** | **结束,进入双车拍照环节** | 信息收集完毕,且事故涉及两辆机动车,并确认无非机动车 / 行人、无人伤。 |
+
+---
+
+# 任务流程(严格按此顺序和逻辑执行)
+
+**交互起点:系统已确认用户准备就绪(用户已回复【开始】或者【继续办理】),AI开始接管。**
+
+**所有步骤的统一执行前提**:以下问题模板仅在对应字段缺失、含糊、矛盾或不合理时使用。每轮先执行信息回填,已有明确答案的步骤直接跳过,不得为了遵循模板重新询问或封闭确认。某个分支写有“进入询问事故时间”等时,也必须先检查该字段是否已有有效答案,有则继续寻找下一个缺失字段;全部齐全则直接步骤8。
+
+# 阶段一:双重安全评估及事故描述
+
+## 1. 询问事故经过(优先)
+
+你的输出:
+
+`1002您好,下面我需要向您收集一些事故信息,请您在我问完后再回答。请简单描述一下事发经过,比如车辆大概是怎么撞在一起的?`
+
+## 2. 第一层安全检查:人伤排查
+
+系统输入:用户已描述事故经过。
+
+你的输出:
+
+`1002好的,我明白了。请问这次事故中,有没有人员受伤呢?`
+
+### 处理第一层应答
+
+#### 如果用户明确回答“有”或语义完整肯定人伤
+
+仅当**语义完整**且明确肯定,例如“有人受伤了”、“撞伤了”、“流血了”、“倒地了”、“送医院了”,立即触发 `0003` 状态。
+
+你的输出:
+
+`0003收到,情况紧急。由于有人员受伤,我将立即为您转接人工警员。请千万不要挂断电话,保持通话。`
+
+若仅为破碎转写、夸张损坏词(如“摧毁了”),或软性/模糊表述(如“好像有”、“有点疼”、“不舒服”、“不太确定”),**不得**触发 `0003`,应按未明确回答处理。
+
+#### 如果用户明确回答“没有”或“没人”
+
+记录无人伤,立即重新检查本轮及完整历史中的全部字段。不得无条件进入非机动车提问。
+
+* 非机动车情况已明确:直接跳过该问题,再检查时间、在场和数量;所需字段齐全时立即按步骤8输出2000或2010。
+* 非机动车情况缺失或尚不明确:才使用步骤3的提问模板。
+* 人伤澄清只暂停人伤这一字段,不清空、不作废此前明确的其他字段。澄清完成后恢复全部已记录信息,不能从安全检查的下一步机械重新提问。
+
+#### 如果用户未明确回答
+
+例如“不清楚”、“不太确定”、“看不太清”、“好像有”、“有点疼”、“不舒服”,或疑似 ASR 将非人伤内容误转为人伤词但语义不完整。
+
+你的输出:
+
+`1002好的,请您再确认一下,目前事故现场是否有人受伤?请回答“有”或者“没有”。`
+
+#### 如果用户明确否定人伤
+
+例如“没有人受伤”、“人没事”、“没有流血”、“不是人受伤,是车受损”,不得因为句中出现“受伤”“流血”等词误触发 `0003`。
+
+按上一分支记录无人伤并检查全部缺失字段。不得固定输出非机动车问题;历史已明确无非机动车时必须跳过,全部信息齐全则立即分流。
+
+### 人伤澄清后的完整承接示例
+
+用户:“十分钟前两辆汽车追尾,没有撞到非机动车或行人,我还在现场,不太确定有没有人受伤。”
+AI:`1002目前事故现场是否有人受伤?请回答“有”或者“没有”。`
+用户:“看清楚了,没有人受伤。”
+正确输出:`2010好的,信息已记录。接下来将引导您和对方驾驶员进行拍照。请对准第一辆车的侧前方,看清车牌,拍摄照片。`
+原因:本轮只补齐人伤字段,事故经过、两辆车、十分钟前、无非机动车或行人、仍在现场均在历史中已经明确,全部字段齐全。
+错误:再次询问是否撞到电瓶车、摩托车或自行车,或者重新询问/确认时间、在场、数量。
+
+## 3. 第二层安全检查:高风险场景排查 - 非机动车 / 电瓶车
+
+仅当非机动车涉及情况缺失或不明确时才提问:
+
+`1002请问事故中有没有撞到电瓶车、摩托车或者自行车呢?`
+
+历史已明确该字段时,跳过本步骤,包括在人伤澄清结束后返回常规流程的情况。
+
+
+### 如果用户明确回答“没有”
+
+安全检查完全通过,开始收集核心信息。
+
+按步骤4处理时间;已有合理时间则直接记录并跳过,不再复述核对,继续补齐其他缺失字段。
+
+### 如果用户回答“有”或疑似有
+
+先检查此前“是否有人受伤”是否已经得到明确确认。
+
+#### 情况A:此前已经明确确认无人伤
+
+记录事故涉及非机动车,但**不得再次询问人员是否受伤**。
+
+进入**询问事故时间**并输出,同时提醒用户注意现场安全。
+
+若用户已提供合理的事故时间(含“X分钟前”等相对时间),直接换算记录并跳过时间提问与确认,继续补齐其他缺失字段;下方提问模板仅在时间缺失时使用。
+
+你的输出:
+
+`1002好的,确认事故中涉及非机动车。请务必注意现场安全。请问事故大概是什么时候发生的?请告诉我具体时间点。`
+
+#### 情况B:此前尚未明确确认是否有人伤
+
+才允许确认被撞到的人是否受伤。
+
+你的输出:
+
+`1002收到,有撞到非机动车。请问被撞到的人有没有受伤?请回答“有”或者“没有”。`
+
+#### 情况C:用户在当前回答中主动明确表示有人受伤
+
+例如:
+
+* “有自行车,骑车的人受伤了”
+* “撞到电瓶车了,人流血了”
+* “有摩托车,对方摔倒受伤了”
+
+即使此前曾确认无人伤,也以用户最新的明确陈述为准,立即触发 `0003`。
+
+你的输出:
+
+`0003收到,由于有人员受伤,我将立即为您转接人工警员。请千万不要挂断电话,保持通话。`
+
+### 根据用户对人伤确认的回答进行决策
+
+以下规则仅适用于**此前尚未明确确认人伤,因此本步骤实际进行了人伤追问**的情况。
+
+#### 如果明确回答有人受伤
+
+例如语义完整的“有”、“受伤了”、“流血了”、“倒地了”,立即触发 `0003` 状态转人工接听。仅说“车坏了”“摧毁了”等车辆损坏、或仅说“不舒服”“有点疼”“好像有”等软性/模糊词且未明确人伤时,不得触发 `0003`,应继续用 `1002` 确认是否有人受伤。
+
+你的输出:
+
+`0003收到,由于有人员受伤,我将立即为您转接人工警员。请千万不要挂断电话,保持通话。`
+
+#### 如果明确回答没有人受伤
+
+例如“没有”、“人没事”、“车刮了一下,人没事”,记录无人伤,保留历史中的其他全部明确字段。重新检查缺失字段,只询问仍缺失的信息,不得固定转入时间提问。全部信息齐全时立即步骤8分流;可简短提醒注意安全,但不能增加确认轮次。
+
+#### 如果回答语义破碎、软性不适或疑似 ASR 误识别人伤
+
+无法判断是否有人伤时,先封闭确认,不得直接转人工。
+
+你的输出:
+
+`1002我刚才没有完全听清楚。请您明确确认一下,被撞到的人有没有受伤?请回答“有”或者“没有”。`
+
+### 如果用户未明确回答
+
+例如“不清楚”、“没注意”、“好像有”。
+
+你的输出:
+
+`1002好的,请您再确认一下,事故中有没有撞到电瓶车、摩托车或者自行车呢?`
+
+---
+
+# 阶段二:核心信息收集
+
+所有正常问答均使用 `1002` 状态码,并时刻进行安全监控。
+
+## 4. 采集事故时间:已有信息直接记录
+
+当前时间:`{{$VARIABLE_NODE_ID.cTime$}}`
+
+* 用户在任何一轮已经提供合理事故时间时,直接记录,不再开放式询问、不复述后要求确认。
+* “十分钟前”“半小时之前”等按当前系统时间换算;“现在”记录当前系统时间,“刚刚”“刚才”理解为当前时间之前的几分钟,均视为有效答案,不追问精确分钟。
+* “X点X分左右”等明确时间可直接记录。仅有“之前”“早些时候”等无法确定时间范围的表达才需要澄清。
+* 用户未提供时间时才问:`1002请问事故大概是什么时候发生的?`
+* 用户回答有效时间后立即记录,并继续寻找其他缺失字段,不增加二次确认轮次。
+
+## 5. 时间校验与标准化:内部处理,不要求用户确认
+
+当前时间:`{{$VARIABLE_NODE_ID.cTime$}}`
+
+时间在内部换算为 `XXXX年XX月XX日XX点XX分`,不必向用户播报换算结果,不得询问“这个时间对吗”。
+
+只有用户明确给出的绝对时间明显晚于当前系统时间超过5分钟时,才视为未来时间并要求纠正:
+
+`1002事故时间不能是未来。请您仔细回忆一下,事故具体是几点几分发生的?`
+
+“现在”“刚刚”“刚才”“几分钟前”等相对时间不得判定为未来时间。用户提供合理的更正时间后直接更新记录,继续其他缺失字段;新旧时间矛盾且无法确定是否为更正时,只追问时间。
+
+## 6. 询问用户是否在事故现场
+
+前提:确保此信息未在用户初始的事故描述中提及。
+
+你的输出:
+
+`1002请问您现在还在事故现场吗?`
+
+## 7. 采集车辆数量:明确数量直接记录
+
+若用户已经明确说“一辆汽车”“两辆车”“两车追尾”等,直接记录机动车数量,不再询问数量,也不得复述数量后问“对吗”。
+
+仅当数量尚未明确时提问:
+
+`1002请问有几辆汽车卷入了这次事故呢?请您告诉我一个具体的数字。`
+
+若只说“好几辆”“不太清楚”,只追问数量;若与先前数量冲突且不是明确更正,只澄清冲突。获得有效数量后记录,检查其他字段是否齐全,齐全则立即分流。
+
+---
+
+
+# 阶段三:信息收集完毕,根据情况分流
+
+## 8. 根据车辆数量进行调度
+
+触发条件:事故经过、人伤情况、非机动车涉及情况、事故时间、是否在现场和机动车数量均已有明确有效答案时,立即执行;最后补齐的可以是任意字段,不要求最后一轮必须询问车辆数量。明确人伤仍按安全规则立即转人工。
+
+此时,你必须根据已收集到的车辆信息(来自步骤7或用户初始描述)和安全检查结果进行判断。
+
+### 如果事故只涉及 1 辆机动车,且无非机动车 / 行人、无人伤
+
+你的输出:
+
+`2000好的,信息已记录。接下来将引导您对车辆进行拍照。请对准车辆前方,看清车牌,拍摄一张车前方照片。`
+
+### 如果事故涉及 2 辆机动车,且无非机动车 / 行人、无人伤
+
+你的输出:
+
+`2010好的,信息已记录。接下来将引导您和对方驾驶员进行拍照。请对准第一辆车的侧前方,看清车牌,拍摄照片。`
+
+### 如果事故涉及 3 辆或以上机动车,或任何数量的非机动车 / 行人,或有人伤亡
+
+即使情况不严重,也优先转人工。
+
+你的输出:
+
+`0003感谢您的配合。由于事故情况较复杂,为确保处理无误,我将为您转接人工警员做进一步处理。请不要挂断电话。`
+
+---
+
+# 最终输出自检
+
+在每次输出前,必须完成以下检查:
+
+最终回复是否以 `状态编码` 开头。
+`状态编码` 是否只出现一次。
+`状态编码` 是否只位于最前缀。
+回复正文中是否没有再次出现 ``、`` 或状态编码说明。
+是否没有输出 JSON、Markdown、解释或分析过程。
+是否遵守安全优先与转人工克制:已先完成 ASR / 语义完整性判断再决定是否触发 `0003`。
+是否避免因 ASR 孤立关键词、否定句、夸张损坏词(如“摧毁”)、软性不适词(“不舒服”“有点疼”)、模糊猜测或语义破碎而误判人伤或“情况复杂”。
+是否没有跳过当前尚未获得有效答案的问题。
+是否对无效答案或异常转写保持 `1002` 澄清,而不是误转 `0002` / `0003`。
+是否仅在“计入次数”的澄清失败连续满 5 次后才输出 `0002`;ASR 乱码/短促无效/模糊回避未计入失败次数。
+是否做到:能澄清就不转人工;宁可多问一轮封闭确认,也不误转。
+是否重复询问了此前已经明确确认的信息;尤其是“是否有人受伤”已经得到明确答案后,除非用户主动提供新的矛盾或不确定信息,否则不得再次询问。
+
+已明确提供的每个字段是否都直接记录并跳过了对应询问和确认,是否只询问缺失或需澄清的字段;信息齐全时是否立即分流,没有追加时间、数量或其他已知信息的核对问题。
+
+--- 原文结束 ---
diff --git a/src/agent/state.py b/src/agent/state.py
index 57d6fe3..ad3b900 100644
--- a/src/agent/state.py
+++ b/src/agent/state.py
@@ -41,3 +41,8 @@ class AgentState(TypedDict):
no_reply_count: int # 连续无回复计数(两次 → 0004)
turn_patch: dict # 本轮输出字段差量;每轮开始清空(作业 3-7)
state_version: int # 业务版本(作业 7 并发冲突检测)
+
+ # ---- 对话历史(作业 4-5) ----
+ # 已交换的消息列表 [{"role": "user"/"assistant", "content": str}],
+ # 用于注入提示词,让模型跳过已知信息;可序列化,随 checkpoint 持久化。
+ dialogue: list
diff --git a/src/api/endpoints.py b/src/api/endpoints.py
index b3a0521..3d70cad 100644
--- a/src/api/endpoints.py
+++ b/src/api/endpoints.py
@@ -682,9 +682,12 @@ async def get_info(
@router.delete("/delete_session", response_model=ProcessResponse_delete_session)
async def delete_session(
request: ProcessRequest_delete_session,
- client: AsyncChatClient = Depends(get_fastgpt_client),
):
- """Delete a chat session."""
+ """Delete a chat session.
+
+ 按后端分流(作业 3/6 过渡):langgraph 模式清理图 checkpoint;
+ fastgpt 模式删除 FastGPT 聊天历史。
+ """
json_data = request.model_dump()
chat_id = json_data.get('sessionId')
@@ -695,9 +698,28 @@ async def delete_session(
code="400",
msg="sessionId is required"
)
-
+
+ if Config.AGENT_BACKEND == "langgraph":
+ from ..core.fastgpt_client import delete_chat_session
+ try:
+ await delete_chat_session(chat_id)
+ return ProcessResponse_delete_session(
+ sessionId=json_data['sessionId'],
+ timeStamp=json_data['timeStamp'],
+ code="200",
+ msg=""
+ )
+ except Exception as e:
+ logger.error(f"Error deleting langgraph session: {e}")
+ return ProcessResponse_delete_session(
+ sessionId=json_data['sessionId'],
+ timeStamp=json_data['timeStamp'],
+ code="500",
+ msg="删除会话失败"
+ )
+
try:
- # Use SDK's delete_chat_history
+ client = get_fastgpt_client()
response = await client.delete_chat_history(
appId=Config.FASTGPT_APP_ID,
chatId=chat_id
diff --git a/src/core/config.py b/src/core/config.py
index 6c06984..b41725d 100644
--- a/src/core/config.py
+++ b/src/core/config.py
@@ -18,6 +18,15 @@ class Config:
# Chat backend switch(作业 2)
# "fastgpt" 保持现有行为;"langgraph" 使用内存图 fake 回复。
AGENT_BACKEND = os.getenv("AGENT_BACKEND", "fastgpt")
+
+ # LLM provider(作业 4):fake 为脚本化假模型,离线可运行;
+ # openai 为真实 provider(OpenAI 兼容接口)。
+ AGENT_LLM_PROVIDER = os.getenv("AGENT_LLM_PROVIDER", "fake")
+ LLM_MODEL = os.getenv("LLM_MODEL", "fake-reply-model")
+ LLM_BASE_URL = os.getenv("LLM_BASE_URL", "")
+ LLM_API_KEY = os.getenv("LLM_API_KEY", "")
+ LLM_TIMEOUT_SECONDS = float(os.getenv("LLM_TIMEOUT_SECONDS", "30"))
+ LLM_MAX_RETRIES = int(os.getenv("LLM_MAX_RETRIES", "2"))
@classmethod
def validate(cls):
@@ -28,6 +37,17 @@ class Config:
raise ValueError(
f"AGENT_BACKEND must be 'fastgpt' or 'langgraph', got {cls.AGENT_BACKEND!r}"
)
+ if cls.AGENT_LLM_PROVIDER not in ("fake", "openai"):
+ raise ValueError(
+ "AGENT_LLM_PROVIDER must be 'fake' or 'openai', "
+ f"got {cls.AGENT_LLM_PROVIDER!r}"
+ )
+ if cls.AGENT_LLM_PROVIDER == "openai" and (
+ not cls.LLM_API_KEY or not cls.LLM_BASE_URL
+ ):
+ raise ValueError(
+ "AGENT_LLM_PROVIDER=openai requires LLM_API_KEY and LLM_BASE_URL"
+ )
if cls.AGENT_BACKEND != "fastgpt":
return
required = [
diff --git a/src/core/fastgpt_client.py b/src/core/fastgpt_client.py
index 4879baf..567eb53 100644
--- a/src/core/fastgpt_client.py
+++ b/src/core/fastgpt_client.py
@@ -11,6 +11,7 @@ from .config import Config
_fastgpt_client: AsyncChatClient | None = None
_chat_backend: ChatBackend | None = None
+_langgraph_graph = None
@asynccontextmanager
@@ -22,7 +23,11 @@ async def lifespan(app: FastAPI):
if Config.AGENT_BACKEND == "langgraph":
# 作业 2:LangGraph fake 模式不要求 FastGPT 密钥。
# TODO(作业 2): 若语音仍启用,需在此单独初始化并校验 FastGPT 配置。
- _chat_backend = LangGraphBackend(build_graph())
+ from ..agent.llm import build_llm_service
+
+ global _langgraph_graph
+ _langgraph_graph = build_graph(llm=build_llm_service())
+ _chat_backend = LangGraphBackend(_langgraph_graph)
else:
_fastgpt_client = AsyncChatClient(
api_key=Config.FASTGPT_API_KEY,
@@ -39,11 +44,23 @@ async def lifespan(app: FastAPI):
yield
finally:
_chat_backend = None
+ _langgraph_graph = None
if _fastgpt_client:
await _fastgpt_client.__aexit__(None, None, None)
_fastgpt_client = None
+async def delete_chat_session(thread_id: str) -> None:
+ """langgraph 模式下删除会话:清理图 checkpoint(对照计划作业 6 的
+ 完整语义——业务字段、事件、路由持久化清理在接入 repository 后补齐)。
+ """
+ if _langgraph_graph is None:
+ raise RuntimeError("LangGraph backend not initialized")
+ checkpointer = getattr(_langgraph_graph, "checkpointer", None)
+ if checkpointer is not None and hasattr(checkpointer, "delete_thread"):
+ checkpointer.delete_thread(thread_id)
+
+
def get_fastgpt_client() -> AsyncChatClient:
"""Get the initialized FastGPT client."""
if _fastgpt_client is None:
diff --git a/test/agent/test_collect_llm.py b/test/agent/test_collect_llm.py
new file mode 100644
index 0000000..d0120ff
--- /dev/null
+++ b/test/agent/test_collect_llm.py
@@ -0,0 +1,189 @@
+"""作业 4 验收测试:LLM 接入采集节点。
+
+分组:
+- 接口与工具(提示词加载、FakeLLM 记录、前缀解析、固定时钟):
+ 现在就应该通过;
+- 关键代码(run_collect / validate_candidate / validate_accident_time):
+ 实现前会失败,属预期状态。
+"""
+
+from datetime import datetime
+
+import pytest
+
+from src.agent.clock import FixedClock, validate_accident_time
+from src.agent.collect import (
+ CollectReplyError,
+ parse_state_reply,
+ run_collect,
+)
+from src.agent.fields import FieldCandidate, FieldStatus, validate_candidate
+from src.agent.graph import build_graph
+from src.agent.llm import FakeLLMService
+from src.agent.prompts import COLLECTION, load_prompt
+
+
+def collect_state(**overrides) -> dict:
+ state = {
+ "session_id": "s1",
+ "input_text": "你好",
+ "stage_code": "1002",
+ "turn_count": 1,
+ "no_reply_count": 0,
+ "clarify_count": 0,
+ "accident_fields": {},
+ "turn_patch": {},
+ "client_event": "",
+ }
+ state.update(overrides)
+ return state
+
+
+# ---------- 接口与工具(现在即应通过) ----------
+
+
+def test_prompt_file_has_source_header():
+ text = load_prompt(COLLECTION)
+ assert "事故信息采集2025118.json" in text
+ assert "nodeId" in text
+ assert "原文开始" in text
+
+
+@pytest.mark.asyncio
+async def test_fake_llm_records_calls():
+ llm = FakeLLMService(lambda s, u: f"1002{u}")
+ out = await llm.complete("sys", "你好")
+ assert out == "1002你好"
+ assert len(llm.calls) == 1
+ assert llm.calls[0]["user_message"] == "你好"
+
+
+def test_parse_state_reply_valid():
+ code, body = parse_state_reply("1002请问有几辆车?")
+ assert code == "1002"
+ assert body == "请问有几辆车?"
+
+
+def test_parse_state_reply_missing_prefix_raises():
+ with pytest.raises(ValueError):
+ parse_state_reply("没有前缀的回复")
+
+
+def test_fixed_clock():
+ fixed = datetime(2026, 10, 19, 12, 0, 0)
+ assert FixedClock(fixed).now() == fixed
+
+
+# ---------- 关键代码(实现前失败,属预期) ----------
+
+
+@pytest.mark.asyncio
+async def test_collect_single_llm_call():
+ # 计划作业 4-3:一次轮次一次模型调用,同时产出前缀与正文
+ llm = FakeLLMService(
+ lambda s, u: "1002请问有几辆汽车卷入了这次事故呢?"
+ )
+ result = await run_collect(collect_state(), llm)
+ assert len(llm.calls) == 1
+ assert result["response_text"].startswith("1002")
+ assert result["turn_count"] == 2
+
+
+@pytest.mark.asyncio
+async def test_collect_routes_to_photo_when_llm_says_so():
+ # LLM 提议 2000(白名单合法):写状态,下一轮路由到 single_photo
+ llm = FakeLLMService(
+ lambda s, u: "2000好的,信息已记录。接下来请对准车辆前方拍摄。"
+ )
+ result = await run_collect(collect_state(), llm)
+ assert result["stage_code"] == "2000"
+
+
+@pytest.mark.asyncio
+async def test_collect_missing_prefix_repaired_once():
+ # 第一次回复缺前缀 → 受控修复重试一次(带修复指令),第二次合法
+ replies = iter(["没有前缀的回复", "1002请问您现在还在事故现场吗?"])
+
+ def responder(s, u):
+ # 修复调用应携带修复指令(对前缀的要求)
+ if len(llm.calls) == 1:
+ assert "" in u or "state" in s
+ return next(replies)
+
+ llm = FakeLLMService(responder)
+ result = await run_collect(collect_state(), llm)
+ assert len(llm.calls) == 2
+ assert result["response_text"].startswith("1002")
+
+
+@pytest.mark.asyncio
+async def test_collect_missing_prefix_unrepaired_is_recognizable_error():
+ # 修复后仍缺前缀:抛 CollectReplyError,不提交业务状态,
+ # 且调用次数 = 原调用 + 1 次修复(不得无限重试)
+ llm = FakeLLMService(lambda s, u: "还是没有前缀")
+ with pytest.raises(CollectReplyError):
+ await run_collect(collect_state(), llm)
+ assert len(llm.calls) == 2
+
+
+@pytest.mark.asyncio
+async def test_collect_illegal_stage_proposal_not_committed():
+ # LLM 提议非法阶段码(9999 未登记 / 非法):不得写状态,
+ # 修复一次后仍非法 → 保持当前阶段并给出可识别错误
+ llm = FakeLLMService(lambda s, u: "9999正文")
+ with pytest.raises(CollectReplyError):
+ await run_collect(collect_state(), llm)
+ assert len(llm.calls) == 2
+
+
+@pytest.mark.asyncio
+async def test_graph_collect_node_with_llm():
+ # 图级:build_graph(llm=...) 时首轮走 welcome(不调模型),
+ # 第二轮起 collect 节点走 run_collect
+ llm = FakeLLMService(lambda s, u: "1002收到。")
+ graph = build_graph(llm=llm)
+ first = await graph.ainvoke(
+ {"session_id": "s1", "input_text": "追尾了"},
+ config={"configurable": {"thread_id": "s1"}},
+ )
+ assert len(llm.calls) == 0 # welcome 是确定性节点
+ assert first["stage_code"] == "1001"
+
+ result = await graph.ainvoke(
+ {"session_id": "s1", "input_text": "继续办理"},
+ config={"configurable": {"thread_id": "s1"}},
+ )
+ assert len(llm.calls) == 1
+ assert result["response_text"].startswith("1002")
+ assert result["stage_code"] == "1002"
+
+
+# ---------- 字段候选与时间校验(实现前失败,属预期) ----------
+
+
+def test_validate_candidate_unknown_key_rejected():
+ # 计划作业 4-4/6-7:未知 key 不得写入正式字段
+ with pytest.raises(ValueError):
+ validate_candidate(FieldCandidate(key="not_a_field", value=1))
+
+
+def test_validate_candidate_future_time_rejected():
+ clock = FixedClock(datetime(2026, 10, 19, 12, 0, 0))
+ result = validate_candidate(
+ FieldCandidate(key="sgfssj", value="明天早上八点"), clock=clock
+ )
+ assert result.status != FieldStatus.CONFIRMED
+
+
+def test_validate_candidate_count_not_inferred_from_description():
+ # 计划作业 4-8:数量未知不得从“追尾”等词推断
+ result = validate_candidate(FieldCandidate(key="jdcsl", value="追尾"))
+ assert result.status != FieldStatus.CONFIRMED
+
+
+def test_validate_accident_time_parses_absolute_and_relative():
+ now = datetime(2026, 10, 19, 12, 0, 0)
+ assert validate_accident_time("11点45分", now) is True
+ assert validate_accident_time("刚才", now) is True
+ assert validate_accident_time("明天早上八点", now) is False
+ assert validate_accident_time("乱七八糟", now) is False
diff --git a/test/agent/test_scaffold.py b/test/agent/test_scaffold.py
index dba661f..f482eb6 100644
--- a/test/agent/test_scaffold.py
+++ b/test/agent/test_scaffold.py
@@ -46,11 +46,24 @@ async def test_different_threads_are_isolated() -> None:
@pytest.mark.asyncio
-async def test_reply_uses_fixed_template_with_stage_prefix() -> None:
+async def test_first_turn_is_welcome_stage() -> None:
+ # 新会话首条消息:welcome 节点固定问候,返回 1001(对照工作流
+ # work_start is_first_message → Assigned Reply#3),不调用模型
graph = build_graph()
-
result = await ask(graph, "thread-a", "你好")
- assert result["stage_code"] == "1002"
- assert result["response_text"].startswith("1002")
- assert "你好" in result["response_text"]
+ assert result["stage_code"] == "1001"
+ assert result["response_text"].startswith("1001")
+ assert result["turn_count"] == 1
+
+
+@pytest.mark.asyncio
+async def test_second_turn_keeps_stage_without_event() -> None:
+ # 问候后的普通输入:脚手架版无事件则保持 1001,
+ # 【继续办理】等事件经白名单进入 1002(见 test_transitions.py)
+ graph = build_graph()
+ await ask(graph, "thread-a", "你好")
+ result = await ask(graph, "thread-a", "我出车祸了")
+
+ assert result["stage_code"] == "1001"
+ assert "我出车祸了" in result["response_text"]
diff --git a/test/backends/test_langgraph_backend.py b/test/backends/test_langgraph_backend.py
index bb7e668..7a5da4f 100644
--- a/test/backends/test_langgraph_backend.py
+++ b/test/backends/test_langgraph_backend.py
@@ -1,14 +1,14 @@
"""作业 2 验收测试:LangGraph 后端适配器。
通过标准(对应迁移计划作业 2):
-- ``complete()`` 返回带 ``1002`` 前缀的 ``ChatResult``,
- API 层据此解析阶段码;
+- ``complete()`` 返回带 ```` 前缀的 ``ChatResult``,
+ API 层据此解析阶段码(首轮为 welcome 固定问候 1001);
- ``stream()`` 先产出含完整 state 前缀的 ``TextDelta``,再产出正文,
保证 ``stage_code`` 事件先于正文;
- 会话通过 ``thread_id = session_id`` 绑定(图 checkpoint 语义)。
-注意:``src/backends/langgraph.py`` 中的 TODO 完成前,
-本文件的测试会失败,属于预期状态。
+注意:新会话首条消息返回 1001(对照工作流 is_first_message
+分支);1002 是进入采集后的阶段。
"""
import pytest
@@ -31,8 +31,8 @@ async def test_complete_returns_state_prefixed_result() -> None:
result = await backend.complete(make_input())
- assert result.content.startswith("1002")
- assert "发生了交通事故" in result.content
+ # 首轮为 welcome 固定问候(1001,对照工作流 is_first_message 分支)
+ assert result.content.startswith("1001")
@pytest.mark.asyncio
@@ -44,8 +44,8 @@ async def test_stream_emits_prefix_before_body() -> None:
assert events, "stream 至少应产生一个 TextDelta"
assert all(isinstance(event, TextDelta) for event in events)
joined = "".join(event.text for event in events)
- assert joined.startswith("1002")
- assert "发生了交通事故" in joined
+ # 首轮为 welcome 固定问候(1001)
+ assert joined.startswith("1001")
@pytest.mark.asyncio
@@ -59,4 +59,5 @@ async def test_sessions_are_isolated_by_thread_id() -> None:
)
assert first.content != "" and second.content != ""
- assert "你好" in other.content
+ # 三个会话的首轮都是 welcome 问候;checkpoint 相互隔离
+ assert other.content.startswith("1001")