2026-10-19 16:25:04 +08:00
|
|
|
|
"""LangGraph 事故流程图。
|
2026-10-19 13:32:43 +08:00
|
|
|
|
|
2026-10-19 16:25:04 +08:00
|
|
|
|
架构(对照 ``workflow/20261016/事故信息采集2025118.json``,作业 3 修订):
|
2026-10-19 13:32:43 +08:00
|
|
|
|
|
2026-10-19 16:25:04 +08:00
|
|
|
|
- 每个主要阶段是独立节点,与工作流 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)
|
2026-10-19 13:32:43 +08:00
|
|
|
|
|
|
|
|
|
|
离线运行示例:
|
|
|
|
|
|
|
2026-10-19 16:25:04 +08:00
|
|
|
|
python -m examples.langgraph_smoke
|
2026-10-19 13:32:43 +08:00
|
|
|
|
|
2026-10-19 16:25:04 +08:00
|
|
|
|
对应测试:test/agent/test_scaffold.py、test/agent/test_events.py、
|
|
|
|
|
|
test/agent/test_transitions.py
|
2026-10-19 13:32:43 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from langgraph.checkpoint.memory import InMemorySaver
|
|
|
|
|
|
from langgraph.graph import END, START, StateGraph
|
|
|
|
|
|
|
2026-10-19 16:25:04 +08:00
|
|
|
|
from .events import ClientEvent, parse_client_event
|
2026-10-19 13:32:43 +08:00
|
|
|
|
from .state import AgentState
|
2026-10-19 16:25:04 +08:00
|
|
|
|
from .transitions import TERMINAL_STAGES, is_legal_transition
|
2026-10-19 13:32:43 +08:00
|
|
|
|
|
2026-10-19 16:25:04 +08:00
|
|
|
|
# 缺省阶段(首轮会话无 checkpoint 时);后续由规则/模型驱动。
|
2026-10-19 13:32:43 +08:00
|
|
|
|
DEFAULT_STAGE_CODE = "1002"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_input(state: AgentState) -> dict:
|
2026-10-19 16:25:04 +08:00
|
|
|
|
"""归一化本轮输入:清理文本、解析客户端事件、清空本轮 patch。
|
2026-10-19 13:32:43 +08:00
|
|
|
|
|
2026-10-19 16:25:04 +08:00
|
|
|
|
- 普通输入事件为 "";带标记输入解析为 ClientEvent.value;
|
|
|
|
|
|
- ``turn_patch`` 每轮开始清空,避免上一轮差量残留重复发布
|
|
|
|
|
|
(计划作业 3-7)。
|
2026-10-19 13:32:43 +08:00
|
|
|
|
"""
|
|
|
|
|
|
cleaned = state["input_text"].strip()
|
2026-10-19 16:25:04 +08:00
|
|
|
|
event = parse_client_event(cleaned)
|
2026-10-19 13:32:43 +08:00
|
|
|
|
return {
|
2026-10-19 16:25:04 +08:00
|
|
|
|
"input_text": cleaned,
|
|
|
|
|
|
"client_event": event.value if event else "",
|
|
|
|
|
|
"turn_patch": {},
|
2026-10-19 13:32:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 16:25:04 +08:00
|
|
|
|
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): 实现双车信息确认节点")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-19 13:32:43 +08:00
|
|
|
|
def build_graph(checkpointer=None):
|
2026-10-19 16:25:04 +08:00
|
|
|
|
"""装配并编译图。
|
2026-10-19 13:32:43 +08:00
|
|
|
|
|
|
|
|
|
|
参数:
|
2026-10-19 16:25:04 +08:00
|
|
|
|
checkpointer: 自定义 checkpointer;缺省 ``InMemorySaver``,
|
|
|
|
|
|
作业 7 替换为 ``AsyncPostgresSaver``。
|
2026-10-19 13:32:43 +08:00
|
|
|
|
|
2026-10-19 16:25:04 +08:00
|
|
|
|
调用方通过 ``config={"configurable": {"thread_id": <会话>}}`` 区分会话。
|
2026-10-19 13:32:43 +08:00
|
|
|
|
"""
|
|
|
|
|
|
builder = StateGraph(AgentState)
|
|
|
|
|
|
builder.add_node("normalize_input", normalize_input)
|
2026-10-19 16:25:04 +08:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-10-19 13:32:43 +08:00
|
|
|
|
builder.add_edge(START, "normalize_input")
|
2026-10-19 16:25:04 +08:00
|
|
|
|
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)
|
2026-10-19 13:32:43 +08:00
|
|
|
|
return builder.compile(checkpointer=checkpointer or InMemorySaver())
|