diff --git a/docs/langgraph-backend-migration-plan.md b/docs/langgraph-backend-migration-plan.md
index eabaf13..068b8c4 100644
--- a/docs/langgraph-backend-migration-plan.md
+++ b/docs/langgraph-backend-migration-plan.md
@@ -142,12 +142,12 @@ python -m pytest test/agent/test_scaffold.py -q
1. 扩充 state:内部阶段、原始状态码、待回答问题、事故字段、两方人员字段、字段确认状态、澄清计数、无回复计数、本轮 patch、响应文本和业务版本。
2. 使用 Pydantic 校验边界数据;图 state 保持可序列化,不保存 HTTP client、连接、音频和图片 base64。
3. 在 API/应用边界把 `【拍摄完成】`、`【用户无回复】`、`【客户端连续3次拍摄识别失败:原因】` 转成明确事件。图内部按事件类型处理,普通“拍好了”不能当完成事件。
-4. 建立带来源的转换表,至少包含 `0000—0005`、`1001/1002`、单车、双车和 `3001/3002`。转换条件必须读取字段和当前问题,不能只判断两个状态码。
+4. 图结构对齐工作流节点:事故信息收集、单车拍照、双车拍照、信息确认各为独立节点(对照 `workflow/20261016/事故信息采集2025118.json` 的 chatNode),节点间用条件边按 `stage_code` 路由(对照 Condition#2/#7/#9 等分支)。节点内部的小步推进(拍照步骤、分流判断)由节点内 LLM 提出目标阶段码——模型负责自然语言,代码按白名单(`transitions.LEGAL_TRANSITIONS`)校验,非法提议不写状态(受控修复重试在作业 4-10);白名单条目必须对照工作流提示词与条件边登记来源,不能只凭状态码相像填写。所有 stage_code(含拍照失败 → 0005、连续两次无回复 → 0004)都由节点内 LLM 提议,代码只负责事件解析、无回复计数等上下文簿记与合法性校验,不代替模型决定。
5. 拍照失败通知收到一次就产生 `0005`;客户端已经完成三次统计,服务端不得再累计三次。
6. 连续两次无回复产生 `0004`,中间有效回复如何重置计数必须有测试。
7. 把“本轮输出字段”在每轮开始时清空;历史事故字段继续保留,避免旧 `formUpdate` 在下一轮重复返回。
-**交付物:** `events.py`、`transitions.py`、状态模型、规则来源表、参数化转换测试。
+**交付物:** `events.py`、`transitions.py`(节点内确定性规则)、按工作流拆分的多节点图(`graph.py`)、状态模型、规则来源表、参数化转换测试。
**验收:** 无模型调用即可走完拍照事件链;在拍照中输入“继续”保持当前步骤;非法跨阶段转换不写状态;明确的转人工事件可以终止当前流程。
diff --git a/src/agent/events.py b/src/agent/events.py
new file mode 100644
index 0000000..1209216
--- /dev/null
+++ b/src/agent/events.py
@@ -0,0 +1,46 @@
+"""客户端确定性事件(作业 3)。
+
+客户端通过带标记的文本向服务端报告确定性事件。标记字符串已对照最新工作流
+``workflow/20261016/事故信息采集2025118.json``(main 分支)核实。
+
+关键规则(计划作业 3-3):普通自然语言不是事件——用户说“拍好了”不等于
+拍摄完成,只有【拍摄完成】标记才算;图内部按事件类型处理。
+"""
+
+from enum import StrEnum
+
+
+class ClientEvent(StrEnum):
+ """客户端上报的确定性事件类型。"""
+
+ SESSION_START = "session_start" # 【开始】新会话首条
+ CONTINUE = "continue" # 【继续办理】
+ PHOTO_DONE = "photo_done" # 【拍摄完成】
+ NO_REPLY = "no_reply" # 【用户无回复】
+ PHOTO_FAILED_3X = "photo_failed_3x" # 【客户端连续3次拍摄识别失败:原因】
+
+
+# 拍照失败标记前缀;原因部分由客户端填写
+# (如“图片过于模糊”“摄像头启动失败”等,见工作流中的具体形态)。
+_PHOTO_FAILED_PREFIX = "【客户端连续3次拍摄识别失败"
+
+
+def parse_client_event(text: str) -> ClientEvent | None:
+ """把客户端输入文本解析为确定性事件;普通输入返回 None。
+
+ 容忍首尾空白;拍照失败按前缀匹配以兼容不同原因文本。
+ """
+ stripped = (text or "").strip()
+ if not stripped:
+ return None
+ if stripped == "【开始】":
+ return ClientEvent.SESSION_START
+ if stripped == "【继续办理】":
+ return ClientEvent.CONTINUE
+ if stripped == "【拍摄完成】":
+ return ClientEvent.PHOTO_DONE
+ if stripped == "【用户无回复】":
+ return ClientEvent.NO_REPLY
+ if stripped.startswith(_PHOTO_FAILED_PREFIX):
+ return ClientEvent.PHOTO_FAILED_3X
+ return None
diff --git a/src/agent/graph.py b/src/agent/graph.py
index 8ca0152..2a19789 100644
--- a/src/agent/graph.py
+++ b/src/agent/graph.py
@@ -1,93 +1,228 @@
-"""LangGraph 最小脚手架(作业 1)。
+"""LangGraph 事故流程图。
-图结构:START → normalize_input → reply → END
+架构(对照 ``workflow/20261016/事故信息采集2025118.json``,作业 3 修订):
-骨架已装配好:状态、节点注册、边和编译(默认 InMemorySaver)。
-两个节点的具体逻辑留待实现(见函数体内的 TODO)。
+- 每个主要阶段是独立节点,与工作流 chatNode 一一对应:
+ ``collect``(事故信息收集)、``single_photo``(单车拍照)、
+ ``dual_photo``(双车拍照)、``single_confirm`` / ``dual_confirm``
+ (信息确认,作业 5 实现);
+- 节点间的大切换由 ``route_by_stage`` 条件边按 ``stage_code`` 路由
+ (对照工作流的 Condition#2/#7/#9 等);LLM 接入后由节点内的
+ “解析状态”代码写出 ``stage_code``,条件边只做分发,不含业务规则;
+- 节点内部的阶段推进(拍照步骤、失败/无回复处理、分流)由节点内
+ LLM 提议目标 ``stage_code``(脚手架期由 ``_fake_llm_propose``
+ 模拟),代码按 ``transitions`` 白名单校验、非法提议不写状态;
+ 代码只负责事件解析与计数等上下文簿记,不代替模型决定;
+- LLM 接入前(作业 4 之前),节点共用固定话术模板。
+
+图结构::
+
+ START → normalize_input → route ─┬→ collect ───────────→ END
+ ├→ single_photo ──────→ END
+ ├→ dual_photo ────────→ END
+ ├→ single_confirm ────→ END (作业 5)
+ ├→ dual_confirm ──────→ END (作业 5)
+ └→ END (终态 0000—0005)
离线运行示例:
- python examples/langgraph_smoke.py
+ python -m examples.langgraph_smoke
-对应测试:test/agent/test_scaffold.py
+对应测试:test/agent/test_scaffold.py、test/agent/test_events.py、
+test/agent/test_transitions.py
"""
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
+from .events import ClientEvent, parse_client_event
from .state import AgentState
+from .transitions import TERMINAL_STAGES, is_legal_transition
-# 脚手架阶段固定返回的阶段码;作业 3 起由转换表驱动。
+# 缺省阶段(首轮会话无 checkpoint 时);后续由规则/模型驱动。
DEFAULT_STAGE_CODE = "1002"
def normalize_input(state: AgentState) -> dict:
- """归一化本轮输入,为回复节点准备上下文。
+ """归一化本轮输入:清理文本、解析客户端事件、清空本轮 patch。
- TODO(作业 1): 实现该节点。建议职责:
- - 校验/清理 ``state["input_text"]``(例如去除首尾空白);
- - 返回需要写入状态的字段增量,例如
- ``{"input_text": cleaned}``;
- - 保持纯函数:不读全局状态、不做 IO。
+ - 普通输入事件为 "";带标记输入解析为 ClientEvent.value;
+ - ``turn_patch`` 每轮开始清空,避免上一轮差量残留重复发布
+ (计划作业 3-7)。
"""
cleaned = state["input_text"].strip()
- return {"input_text": cleaned}
-
-
-def reply(state: AgentState) -> dict:
- """生成固定模板回复,并把轮数加一。
-
- TODO(作业 1): 实现该节点。验收要求:
- - 每次调用使 ``turn_count`` 恰好 +1;首轮会话没有历史
- checkpoint,用 ``state.get("turn_count") or 0`` 起步;
- - ``response_text`` 使用固定模板,**包含本轮输入文本**
- (``state["input_text"]``);
- - 响应文本需带公共协议前缀,例如
- ``1002...``(``stage_code`` 用
- ``DEFAULT_STAGE_CODE``,API 层负责解析该前缀);
- - ``stage_code`` 写入 ``DEFAULT_STAGE_CODE``。
-
- 参考实现::
-
- turn_count = (state.get("turn_count") or 0) + 1
- response_text = (
- f"{DEFAULT_STAGE_CODE}"
- f"已收到:{state['input_text']}"
- )
- return {
- "turn_count": turn_count,
- "response_text": response_text,
- "stage_code": DEFAULT_STAGE_CODE,
- }
- """
- turn_count = (state.get("turn_count") or 0) + 1
- response_text = (
- f"{DEFAULT_STAGE_CODE}"
- f"已收到:{state['input_text']}"
- )
+ event = parse_client_event(cleaned)
return {
- "turn_count": turn_count,
- "response_text": response_text,
- "stage_code": DEFAULT_STAGE_CODE,
+ "input_text": cleaned,
+ "client_event": event.value if event else "",
+ "turn_patch": {},
}
+def route_by_stage(state: AgentState) -> str:
+ """按当前阶段路由到对应节点(条件边,对照工作流 Condition 分支)。
+
+ 终态(0000—0005)直接结束;空/未知阶段按采集处理。
+ """
+ stage = state.get("stage_code") or ""
+ if stage in TERMINAL_STAGES:
+ return END
+ if stage in {"1001", "1002"}:
+ return "collect"
+ if stage in {"2000", "2001", "2002", "2003", "2004", "2005"}:
+ return "single_photo"
+ if stage in {"2010", "2011", "2012", "2013", "2014", "2015",
+ "2016", "2017"}:
+ return "dual_photo"
+ if stage == "3001":
+ return "single_confirm"
+ if stage == "3002":
+ return "dual_confirm"
+ return "collect"
+
+
+def _fake_llm_propose(state: AgentState, event: ClientEvent | None) -> str | None:
+ """假 LLM:提出下一步阶段码(作业 4 起由真实 LLM 取代)。
+
+ 模拟工作流 chatNode 提示词的判断(来源见各分支注释):
+ - 会话标记且当前 1001 → 1002(work_start is_first_message);
+ - 【拍摄完成】拍照链前进一步(单车/双车拍照提示词);
+ - 拍照失败标记 → 0005(客户端已统计三次,提示词要求直接转人工);
+ - 连续第二次【用户无回复】 → 0004(no_reply_count 由代码簿记,
+ 本函数读取的是本轮计数前的历史值)。
+
+ 提议是否生效由 ``is_legal_transition`` 校验。
+ """
+ current = state.get("stage_code") or ""
+ if event in (ClientEvent.SESSION_START, ClientEvent.CONTINUE):
+ if current == "1001":
+ return "1002"
+ if event is ClientEvent.PHOTO_DONE:
+ chain = {
+ "2000": "2001", "2001": "2002", "2002": "2003",
+ "2003": "2005",
+ "2010": "2011", "2011": "2012", "2012": "2013",
+ "2013": "2014", "2014": "2015",
+ }
+ return chain.get(current)
+ if event is ClientEvent.PHOTO_FAILED_3X:
+ return "0005"
+ if event is ClientEvent.NO_REPLY and (state.get("no_reply_count") or 0) >= 1:
+ return "0004"
+ return None
+
+
+def _event_reply(state: AgentState) -> dict:
+ """事件簿记 + 阶段提议/校验 + 固定话术(作业 3 脚手架版)。
+
+ - 代码只维护上下文:``no_reply_count`` 累计(NO_REPLY)与
+ 重置(其他输入);
+ - 阶段码(含失败/无回复处理)一律由 ``_fake_llm_propose`` 提议
+ (作业 4 换真实 LLM),经白名单校验,非法提议保持当前阶段;
+ - 轮数 +1,响应带 ```` 前缀(API 层解析)。
+
+ TODO(作业 4): 真实 LLM 接入后,话术与阶段提议移入各节点;
+ TODO(作业 4-10): 对非法提议做一次受控修复重试(当前直接拒绝)。
+ """
+ raw_event = state.get("client_event") or ""
+ event = ClientEvent(raw_event) if raw_event else None
+
+ no_reply_count = state.get("no_reply_count") or 0
+ if event is ClientEvent.NO_REPLY:
+ no_reply_count += 1
+ elif (state.get("input_text") or "").strip():
+ no_reply_count = 0
+
+ current = state.get("stage_code") or DEFAULT_STAGE_CODE
+ proposed = _fake_llm_propose(state, event)
+ if proposed and is_legal_transition(current, proposed):
+ stage_code = proposed
+ else:
+ stage_code = current
+
+ response_text = (
+ f"{stage_code}"
+ f"已收到:{state['input_text']}"
+ )
+ return {
+ "turn_count": (state.get("turn_count") or 0) + 1,
+ "no_reply_count": no_reply_count,
+ "response_text": response_text,
+ "stage_code": stage_code,
+ }
+
+
+def collect(state: AgentState) -> dict:
+ """事故信息收集节点(工作流“事故信息收集” chatNode)。
+
+ TODO(作业 4): 接入 LLM——多轮采集、跳过已知信息、一次补齐分流;
+ 模型回复中的状态前缀由代码校验(解析状态,对照工作流
+ “解析状态” code 节点),不由模型自由写阶段。
+ """
+ return _event_reply(state)
+
+
+def single_photo(state: AgentState) -> dict:
+ """单车拍照节点(工作流“单车拍照” chatNode)。
+
+ TODO(作业 5): 按 2000→2001→2002→2003→2005 生成对应拍照话术;
+ 步骤推进的确定性部分已在 ``transitions`` 表覆盖。
+ """
+ return _event_reply(state)
+
+
+def dual_photo(state: AgentState) -> dict:
+ """双车拍照节点(工作流“双车拍照” chatNode)。
+
+ TODO(作业 5): 按 2010→…→2015 生成对应话术;direct 模式末次
+ 拍摄完成进入 3002(同轮询问第一方身份)。
+ """
+ return _event_reply(state)
+
+
+def single_confirm(state: AgentState) -> dict:
+ """单车信息确认节点(工作流“单车信息确认” chatNode,作业 5)。"""
+ raise NotImplementedError("TODO(作业 5): 实现单车信息确认节点")
+
+
+def dual_confirm(state: AgentState) -> dict:
+ """双车信息确认节点(工作流“双车信息确认” chatNode,作业 5)。"""
+ raise NotImplementedError("TODO(作业 5): 实现双车信息确认节点")
+
+
def build_graph(checkpointer=None):
- """装配并编译最小图。
+ """装配并编译图。
参数:
- checkpointer: 自定义 checkpointer;缺省使用 ``InMemorySaver``。
- 作业 7 会替换为 ``AsyncPostgresSaver``。
+ checkpointer: 自定义 checkpointer;缺省 ``InMemorySaver``,
+ 作业 7 替换为 ``AsyncPostgresSaver``。
- 返回:
- 编译后的图。调用方通过
- ``config={"configurable": {"thread_id": <会话 ID>}}``
- 区分会话。
+ 调用方通过 ``config={"configurable": {"thread_id": <会话>}}`` 区分会话。
"""
builder = StateGraph(AgentState)
builder.add_node("normalize_input", normalize_input)
- builder.add_node("reply", reply)
+ builder.add_node("collect", collect)
+ builder.add_node("single_photo", single_photo)
+ builder.add_node("dual_photo", dual_photo)
+ builder.add_node("single_confirm", single_confirm)
+ builder.add_node("dual_confirm", dual_confirm)
+
builder.add_edge(START, "normalize_input")
- builder.add_edge("normalize_input", "reply")
- builder.add_edge("reply", END)
+ builder.add_conditional_edges(
+ "normalize_input",
+ route_by_stage,
+ {
+ "collect": "collect",
+ "single_photo": "single_photo",
+ "dual_photo": "dual_photo",
+ "single_confirm": "single_confirm",
+ "dual_confirm": "dual_confirm",
+ END: END,
+ },
+ )
+ builder.add_edge("collect", END)
+ builder.add_edge("single_photo", END)
+ builder.add_edge("dual_photo", END)
+ builder.add_edge("single_confirm", END)
+ builder.add_edge("dual_confirm", END)
return builder.compile(checkpointer=checkpointer or InMemorySaver())
diff --git a/src/agent/state.py b/src/agent/state.py
index 89b58eb..57d6fe3 100644
--- a/src/agent/state.py
+++ b/src/agent/state.py
@@ -1,8 +1,13 @@
-"""图状态定义(作业 1)。
+"""图状态定义。
-最小状态只包含脚手架所需的五个字段;作业 3 会扩充领域状态
-(阶段、事故字段、人员字段、澄清计数、本轮 patch 等)。
-所有字段必须可序列化,不得保存 HTTP client、数据库连接等资源。
+作业 1 的五个基础字段之上,作业 3 扩充领域状态:内部阶段、原始状态码、
+待回答问题、事故字段、两方人员字段、字段确认状态、澄清计数、无回复计数、
+本轮 patch 和业务版本。
+
+约束(迁移计划作业 3-2):所有字段必须可序列化,
+不保存 HTTP client、数据库连接、音频或图片 base64。
+字段读取一律用 ``state.get(...)`` 并提供默认值——首轮会话没有
+历史 checkpoint,字段可能尚未初始化。
"""
from typing import TypedDict
@@ -11,12 +16,28 @@ from typing import TypedDict
class AgentState(TypedDict):
"""一次对话轮次在图中流动的状态。
- ``turn_count`` 由同一 ``thread_id`` 的 checkpoint 承载,
- 节点每轮只负责 +1,不负责跨会话记忆。
+ ``turn_count``、``stage_code`` 等由同一 ``thread_id`` 的
+ checkpoint 承载跨轮记忆;节点返回字段增量,LangGraph 合并写回。
"""
+ # ---- 基础(作业 1) ----
session_id: str
turn_count: int
input_text: str
response_text: str
stage_code: str
+
+ # ---- 客户端事件(作业 3) ----
+ # ClientEvent.value 或 ""(普通输入)。由 normalize_input 每轮解析。
+ client_event: str
+
+ # ---- 领域状态(作业 3,作业 4—6 逐步充实) ----
+ raw_status_code: str # 原始状态码(外部归一化前的内部码)
+ pending_question: str # 当前待回答问题 ID(如 "driver_identity")
+ accident_fields: dict # 事故字段:jdcsl/ywrysw/sgfssj/...
+ person_fields: dict # 两方人员字段:xm1/2、hphm1/2、sfzmwh1/2...
+ confirm_status: dict # 字段确认状态:unknown/candidate/confirmed
+ clarify_count: int # 澄清计数(阈值按阶段配置,作业 4-9)
+ no_reply_count: int # 连续无回复计数(两次 → 0004)
+ turn_patch: dict # 本轮输出字段差量;每轮开始清空(作业 3-7)
+ state_version: int # 业务版本(作业 7 并发冲突检测)
diff --git a/src/agent/transitions.py b/src/agent/transitions.py
new file mode 100644
index 0000000..7430d0d
--- /dev/null
+++ b/src/agent/transitions.py
@@ -0,0 +1,116 @@
+"""阶段转换校验规则(作业 3)。
+
+分工(对照 ``workflow/20261016/事故信息采集2025118.json``):
+
+- **stage_code 一律由 LLM 提议**:拍照小步(2000→2001→…)、
+ 拍照失败(→0005)、连续无回复(→0004)、分流判断——工作流中
+ 均由 chatNode 提示词驱动;迁移后由节点内 LLM 提出目标
+ stage_code(作业 4 接入,脚手架期由 ``graph._fake_llm_propose``
+ 模拟),模型负责理解自然语言与对话上下文;
+- **代码负责校验**:``LEGAL_TRANSITIONS`` 白名单校验提议的
+ source→target 是否合法;非法提议**不写状态**(保持当前阶段,
+ 受控修复重试见计划作业 4-10);
+- **代码只做上下文簿记**:客户端事件解析(``events.py``)、
+ 无回复计数等状态字段,作为 LLM 判断的输入,不代替模型决定。
+
+``LEGAL_TRANSITIONS`` 的内容是关键代码:对照工作流的节点提示词与
+条件边填写(每条 ``source_ref`` 记录来源:chatNode 提示词 /
+Condition 分支 / 评测案例),作业 10 的 rule-sources.md 依赖这些记录。
+"""""
+
+from dataclasses import dataclass
+
+# 终态:结束/转人工/失败类,只进不出。
+TERMINAL_STAGES = frozenset({"0000", "0001", "0002", "0003", "0004", "0005"})
+
+INITIAL_STAGE = "1001"
+COLLECT_STAGE = "1002"
+
+
+@dataclass(frozen=True)
+class TransitionSpec:
+ """白名单项:``source`` 阶段允许前往的 ``targets`` 集合。"""
+
+ source: str
+ targets: frozenset[str]
+ source_ref: str = ""
+
+
+# TODO(作业 3): 对照工作流节点提示词与条件边填写白名单并补 source_ref。
+# 合法转换白名单(作业 3 初稿,依据 workflow/20261016/事故信息采集2025118.json
+# 的 chatNode 提示词与条件边拟定;每条 source_ref 标注来源。
+# 保持阶段(source == target)总是合法,无需登记。
+#
+# 结构要点(从提示词核实):
+# - 事故信息收集:1002 为默认状态,可转 0001(主动转人工)/
+# 0002(同一问题连续 5 次计入失败)/0003(明确人伤)/
+# 0004(连续两次无回复)/2000(单车)/2010(双车);
+# - 单车拍照:严格顺序 2000→2001→2002→2003→2005,任意拍摄步骤可转
+# 0001(主动转人工)/0005(客户端三次失败,一次即转);
+# 2005(车损位置)→ 3001(direct 进信息确认)或 0000
+# (browser_addon 分支,见“单车拍照#6”流程终点)或 0002;
+# - 双车拍照:严格顺序 2010→…→2015,同理可转 0001/0005;
+# 2015 → 3002(direct)或 0000(browser_addon,见“双车拍照#7”);
+# - 信息确认:3001/3002 为默认状态,核实完毕 → 0000,
+# 可转 0001/0003/0004,0002 阈值为同一问题连续 4 次计入失败。
+#
+# TODO(作业 3): 逐条与工作流提示词复核,并在作业 10 汇总为 rule-sources.md。
+LEGAL_TRANSITIONS: list[TransitionSpec] = [
+ # 会话开始(work_start 的 is_first_message 条件 → Condition#9)
+ TransitionSpec("1001", frozenset({"1002"}),
+ source_ref="workflow: work_start is_first_message"),
+
+ # 事故信息收集(chatNode 提示词状态码表)
+ TransitionSpec("1002", frozenset({"0001", "0002", "0003", "0004",
+ "2000", "2010"}),
+ source_ref="workflow: 事故信息收集 提示词(状态码表)"),
+
+ # 单车拍照(严格顺序 + 全局中断)
+ TransitionSpec("2000", frozenset({"2001", "0001", "0005"}),
+ source_ref="workflow: 单车拍照 提示词(2000→2001 / 全局中断)"),
+ TransitionSpec("2001", frozenset({"2002", "0001", "0005"}),
+ source_ref="workflow: 单车拍照 提示词(2001→2002 / 全局中断)"),
+ TransitionSpec("2002", frozenset({"2003", "0001", "0005"}),
+ source_ref="workflow: 单车拍照 提示词(2002→2003 / 全局中断)"),
+ TransitionSpec("2003", frozenset({"2005", "0001", "0005"}),
+ source_ref="workflow: 单车拍照 提示词(2003→2005 / 全局中断)"),
+ TransitionSpec("2005", frozenset({"3001", "0000", "0002", "0005"}),
+ source_ref="workflow: 单车拍照 行为模式B / 单车拍照#6 流程终点"),
+
+ # 双车拍照(严格顺序 + 全局中断)
+ TransitionSpec("2010", frozenset({"2011", "0001", "0005"}),
+ source_ref="workflow: 双车拍照 提示词(2010→2011 / 全局中断)"),
+ TransitionSpec("2011", frozenset({"2012", "0001", "0005"}),
+ source_ref="workflow: 双车拍照 提示词(2011→2012 / 全局中断)"),
+ TransitionSpec("2012", frozenset({"2013", "0001", "0005"}),
+ source_ref="workflow: 双车拍照 提示词(2012→2013 / 全局中断)"),
+ TransitionSpec("2013", frozenset({"2014", "0001", "0005"}),
+ source_ref="workflow: 双车拍照 提示词(2013→2014 / 全局中断)"),
+ TransitionSpec("2014", frozenset({"2015", "0001", "0005"}),
+ source_ref="workflow: 双车拍照 提示词(2014→2015 / 全局中断)"),
+ TransitionSpec("2015", frozenset({"3002", "0000", "0002", "0005"}),
+ source_ref="workflow: 双车拍照 流程终点 / 双车拍照#7(2015→0000)"),
+
+ # 信息确认(chatNode 提示词状态码表;核实完毕 → 0000)
+ TransitionSpec("3001", frozenset({"0000", "0001", "0002", "0003", "0004"}),
+ source_ref="workflow: 单车信息确认 提示词(状态码表)"),
+ TransitionSpec("3002", frozenset({"0000", "0001", "0002", "0003", "0004"}),
+ source_ref="workflow: 双车信息确认 提示词(状态码表)"),
+]
+
+
+def is_legal_transition(source: str, target: str) -> bool:
+ """校验 source→target 是否在白名单内。
+
+ - 保持阶段(source == target)总是合法;
+ - 终态不允许任何转出;
+ - 白名单为空(未填写)时,除保持阶段外一律拒绝。
+ """
+ if source == target:
+ return True
+ if source in TERMINAL_STAGES:
+ return False
+ allowed: dict[str, set[str]] = {}
+ for spec in LEGAL_TRANSITIONS:
+ allowed.setdefault(spec.source, set()).update(spec.targets)
+ return target in allowed.get(source, set())
diff --git a/test/agent/test_events.py b/test/agent/test_events.py
new file mode 100644
index 0000000..d02ce48
--- /dev/null
+++ b/test/agent/test_events.py
@@ -0,0 +1,41 @@
+"""作业 3 事件解析测试。
+
+来源:workflow/20261016/事故信息采集2025118.json 中的客户端标记。
+关键规则:普通自然语言(如“拍好了”“继续”)不是事件。
+"""
+
+import pytest
+
+from src.agent.events import ClientEvent, parse_client_event
+
+
+@pytest.mark.parametrize(
+ ("text", "expected"),
+ [
+ ("【开始】", ClientEvent.SESSION_START),
+ ("【继续办理】", ClientEvent.CONTINUE),
+ ("【拍摄完成】", ClientEvent.PHOTO_DONE),
+ ("【用户无回复】", ClientEvent.NO_REPLY),
+ ("【客户端连续3次拍摄识别失败:图片过于模糊】", ClientEvent.PHOTO_FAILED_3X),
+ ("【客户端连续3次拍摄识别失败:摄像头启动失败】", ClientEvent.PHOTO_FAILED_3X),
+ ("【客户端连续3次拍摄识别失败:未识别到完整车牌】", ClientEvent.PHOTO_FAILED_3X),
+ (" 【拍摄完成】 ", ClientEvent.PHOTO_DONE), # 容忍首尾空白
+ ],
+)
+def test_markers_parse_to_events(text, expected):
+ assert parse_client_event(text) is expected
+
+
+@pytest.mark.parametrize(
+ "text",
+ [
+ "拍好了", # 普通“拍好了”不等于拍摄完成事件
+ "我已经拍完了",
+ "继续",
+ "你好",
+ "",
+ " ",
+ ],
+)
+def test_plain_text_is_not_an_event(text):
+ assert parse_client_event(text) is None
diff --git a/test/agent/test_transitions.py b/test/agent/test_transitions.py
new file mode 100644
index 0000000..a137035
--- /dev/null
+++ b/test/agent/test_transitions.py
@@ -0,0 +1,155 @@
+"""作业 3 转换校验与图级行为测试。
+
+分工回顾:所有 stage_code(含拍照失败 → 0005、连续无回复 → 0004)
+都由(假)LLM 提议,``transitions.is_legal_transition`` 按白名单校验;
+代码只做事件解析与无回复计数等上下文簿记。
+
+分组:
+- 校验器语义:不依赖白名单内容,现在就应该通过;
+- 提议为空/非法时保持阶段:现在就应该通过;
+- 白名单内容:``LEGAL_TRANSITIONS`` 填写完成前会失败,属预期状态。
+"""
+
+import pytest
+
+import src.agent.graph as graph_mod
+from src.agent.graph import build_graph
+from src.agent.transitions import (
+ TERMINAL_STAGES,
+ TransitionSpec,
+ is_legal_transition,
+)
+
+
+def invoke(graph, thread_id, text, **initial):
+ payload = {"session_id": thread_id, "input_text": text}
+ payload.update(initial)
+ return graph.ainvoke(
+ payload,
+ config={"configurable": {"thread_id": thread_id}},
+ )
+
+
+# ---------- 校验器语义(不依赖白名单内容) ----------
+
+
+def test_same_stage_is_always_legal():
+ assert is_legal_transition("2000", "2000") is True
+
+
+def test_unlisted_move_is_rejected():
+ # 白名单未登记的转换一律拒绝(越级跳步 / 跨阶段乱跳)
+ assert is_legal_transition("2000", "2003") is False
+ assert is_legal_transition("1002", "3001") is False
+
+
+@pytest.mark.parametrize("stage", sorted(TERMINAL_STAGES))
+def test_terminal_stage_never_transitions_out(stage):
+ monkey_whitelist = [TransitionSpec(stage, frozenset({"1002"}))]
+ import src.agent.transitions as transitions_mod
+
+ original = transitions_mod.LEGAL_TRANSITIONS
+ transitions_mod.LEGAL_TRANSITIONS = monkey_whitelist
+ try:
+ assert is_legal_transition(stage, "1002") is False
+ finally:
+ transitions_mod.LEGAL_TRANSITIONS = original
+
+
+def test_whitelist_allows_listed_target(monkeypatch):
+ import src.agent.transitions as transitions_mod
+
+ monkeypatch.setattr(
+ transitions_mod,
+ "LEGAL_TRANSITIONS",
+ [TransitionSpec("2000", frozenset({"2001"}), source_ref="test")],
+ )
+ assert is_legal_transition("2000", "2001") is True
+ assert is_legal_transition("2000", "2002") is False # 越级跳步拒绝
+ assert is_legal_transition("2001", "2002") is False # 未登记拒绝
+
+
+# ---------- 提议为空/非法时保持阶段(现在即应通过) ----------
+
+
+@pytest.mark.asyncio
+async def test_valid_reply_resets_no_reply_count():
+ graph = build_graph()
+ await invoke(graph, "t-reset", "【用户无回复】", stage_code="1002")
+ await invoke(graph, "t-reset", "追尾了")
+ third = await invoke(graph, "t-reset", "【用户无回复】")
+ assert third["stage_code"] == "1002"
+
+
+@pytest.mark.asyncio
+async def test_illegal_proposal_keeps_stage(monkeypatch):
+ # LLM 提议非法跳步(2001 → 2003):校验拒绝,状态不被污染
+ graph = build_graph()
+ monkeypatch.setattr(graph_mod, "_fake_llm_propose", lambda s, e: "2003")
+ result = await invoke(graph, "t-illegal", "【拍摄完成】", stage_code="2001")
+ assert result["stage_code"] == "2001"
+
+
+@pytest.mark.asyncio
+async def test_plain_continue_keeps_photo_stage():
+ graph = build_graph()
+ result = await invoke(graph, "t-keep", "继续", stage_code="2001")
+ assert result["stage_code"] == "2001"
+
+
+# ---------- 白名单内容(填好前失败,属预期) ----------
+
+
+@pytest.mark.asyncio
+async def test_photo_failed_once_goes_to_transfer():
+ # 假 LLM 对拍照失败提议 0005;需白名单登记拍照阶段 → 0005
+ # (本测试走 2011 → 0005)
+ graph = build_graph()
+ result = await invoke(
+ graph,
+ "t-failed",
+ "【客户端连续3次拍摄识别失败:图片过于模糊】",
+ stage_code="2011",
+ )
+ assert result["stage_code"] == "0005"
+
+
+@pytest.mark.asyncio
+async def test_second_no_reply_goes_to_0004():
+ # 第二次无回复时假 LLM 提议 0004;需白名单登记 1002 → 0004
+ graph = build_graph()
+ first = await invoke(graph, "t-noreply", "【用户无回复】", stage_code="1002")
+ assert first["stage_code"] == "1002" # 第一次保持并计数
+ second = await invoke(graph, "t-noreply", "【用户无回复】")
+ assert second["stage_code"] == "0004"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ ("source", "target"),
+ [
+ ("2000", "2001"), # 单车拍照链
+ ("2001", "2002"),
+ ("2002", "2003"),
+ ("2003", "2005"),
+ ("2010", "2011"), # 双车拍照链
+ ("2011", "2012"),
+ ("2012", "2013"),
+ ("2013", "2014"),
+ ("2014", "2015"),
+ ],
+)
+async def test_photo_done_advances_photo_chain(source, target):
+ # 假 LLM 提议 chain 下一格;白名单未登记该转换时保持原阶段
+ graph = build_graph()
+ result = await invoke(graph, f"t-{source}", "【拍摄完成】", stage_code=source)
+ assert result["stage_code"] == target
+ assert result["response_text"].startswith(f"{target}")
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("marker", ["【开始】", "【继续办理】"])
+async def test_session_start_enters_collect_stage(marker):
+ graph = build_graph()
+ result = await invoke(graph, f"t-start-{marker}", marker, stage_code="1001")
+ assert result["stage_code"] == "1002"