Files
ZNJJ-api-server/src/agent/graph.py
T
Eric Wang 8323d53a70 feat(agent): domain events and transition whitelist validation (assignment 3)
Architecture (mirrors workflow/20261016 chatNode structure):
- events.py: client markers parsed to typed ClientEvent (sources
  verified against workflow JSON); plain text like '拍好了' is not an event
- transitions.py: LEGAL_TRANSITIONS whitelist drafted from chatNode
  prompts and Condition edges (16 entries, each with source_ref);
  is_legal_transition rejects unlisted/terminal-violating moves
- graph.py: multi-node graph (collect/single_photo/dual_photo/...
  matching workflow chatNodes), route_by_stage conditional edges,
  _fake_llm_propose simulates LLM stage-code proposals (assignment 4
  replaces with real LLM); code only keeps context bookkeeping
  (no_reply_count) and validation
- state.py: expanded domain fields (pending_question, accident/
  person fields, clarify/no-reply counts, turn_patch, state_version)

Key finding: 单车拍照#6/双车拍照#7 are browser_addon branches ending
at 0000, so 2005/2015 allow both 300x (direct) and 0000.

Verified: pytest 87 passed (24 transition/event tests newly green).
Known limit: stage proposals come from a fake LLM; per-stage scripts
and real LLM integration are assignments 4-5.
2026-10-19 16:25:04 +08:00

229 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""LangGraph 事故流程图。
架构(对照 ``workflow/20261016/事故信息采集2025118.json``,作业 3 修订):
- 每个主要阶段是独立节点,与工作流 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 -m examples.langgraph_smoke
对应测试: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
# 缺省阶段(首轮会话无 checkpoint 时);后续由规则/模型驱动。
DEFAULT_STAGE_CODE = "1002"
def normalize_input(state: AgentState) -> dict:
"""归一化本轮输入:清理文本、解析客户端事件、清空本轮 patch。
- 普通输入事件为 "";带标记输入解析为 ClientEvent.value;
- ``turn_patch`` 每轮开始清空,避免上一轮差量残留重复发布
(计划作业 3-7)。
"""
cleaned = state["input_text"].strip()
event = parse_client_event(cleaned)
return {
"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,响应带 ``<state>`` 前缀(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"<state>{stage_code}</state>"
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``。
调用方通过 ``config={"configurable": {"thread_id": <会话>}}`` 区分会话。
"""
builder = StateGraph(AgentState)
builder.add_node("normalize_input", normalize_input)
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_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())