落地并存测服全端:智能体 Graph/路由、后台切 8180、主账与部署脚本齐备,便于线上重发与验收。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-13 21:15:58 +08:00
co-authored by Cursor
parent 0eb1adecc8
commit 8103b3b651
143 changed files with 8968 additions and 277 deletions
+26 -3
View File
@@ -1,6 +1,29 @@
"""
LangGraph 编排:图定义与节点分文件;checkpoint 在 PostgreSQL。
LangGraph 编排包:图定义、节点分文件、pause/resume、结构化 wake。
禁止:单文件堆全部节点;禁止在本包处理企微 HTTP 或 LibreOffice。
骨架:仅包占位,不注册图。
本包职责(对齐 prompt/14、06):
- 一单一个 thread_id;checkpoint 在 PostgreSQL(可开发降级 Memory);
- 停点 interrupt_before;Worker 槽内 start/resume/wake;
- 无 sender_id 不进图。
禁止:企微 HTTP、LibreOffice、主账六态真相、单文件堆全部节点业务。
骨架:节点仅打日志;HTTP 回调进程不要 import 后 invoke 整图。
"""
from agent.graph.runtime import (
GraphIdentityError,
GraphRuntime,
GraphWakeError,
get_graph_runtime,
shutdown_graph_runtime,
)
from agent.graph.wake import WorkerWakePayload
__all__ = [
"GraphIdentityError",
"GraphRuntime",
"GraphWakeError",
"WorkerWakePayload",
"get_graph_runtime",
"shutdown_graph_runtime",
]
+76
View File
@@ -0,0 +1,76 @@
"""
装配询价 StateGraph(骨架线性流水线)。
本文件职责:把分文件节点串成一张图并 compile;不写业务分支。
停点:interrupt_before=INTERRUPT_BEFORE(见 pauses.py)。
禁止:单文件堆全部节点实现;禁止在本文件调企微 / soffice。
"""
from __future__ import annotations
import logging
from typing import Any
from langgraph.graph import END, START, StateGraph
from agent.graph import nodes as graph_nodes
from agent.graph.pauses import (
INTERRUPT_BEFORE,
PAUSE_ADOPT_ADJUST,
PAUSE_CLOSE_DEAL,
PAUSE_COLLABORATE,
PAUSE_CREATE_ORDER,
PAUSE_GENERATE_QUOTE,
PAUSE_PARSE_CLARIFY,
PAUSE_QUERY_QUOTE,
)
from agent.graph.state import InquiryGraphState
logger = logging.getLogger(__name__)
def build_state_graph() -> StateGraph:
"""
构建未编译的状态图。
节点顺序对齐 prompt/14:解析/补问 → 建单 → 查价 → 协同 → 报价 → 采用/调价 → 成交。
骨架为直线边;后续可加条件边,勿在此塞主账逻辑。
"""
g: StateGraph = StateGraph(InquiryGraphState)
g.add_node(PAUSE_PARSE_CLARIFY, graph_nodes.node_parse_clarify)
g.add_node(PAUSE_CREATE_ORDER, graph_nodes.node_create_order)
g.add_node(PAUSE_QUERY_QUOTE, graph_nodes.node_query_quote)
g.add_node(PAUSE_COLLABORATE, graph_nodes.node_collaborate)
g.add_node(PAUSE_GENERATE_QUOTE, graph_nodes.node_generate_quote)
g.add_node(PAUSE_ADOPT_ADJUST, graph_nodes.node_adopt_adjust)
g.add_node(PAUSE_CLOSE_DEAL, graph_nodes.node_close_deal)
g.add_edge(START, PAUSE_PARSE_CLARIFY)
g.add_edge(PAUSE_PARSE_CLARIFY, PAUSE_CREATE_ORDER)
g.add_edge(PAUSE_CREATE_ORDER, PAUSE_QUERY_QUOTE)
g.add_edge(PAUSE_QUERY_QUOTE, PAUSE_COLLABORATE)
g.add_edge(PAUSE_COLLABORATE, PAUSE_GENERATE_QUOTE)
g.add_edge(PAUSE_GENERATE_QUOTE, PAUSE_ADOPT_ADJUST)
g.add_edge(PAUSE_ADOPT_ADJUST, PAUSE_CLOSE_DEAL)
g.add_edge(PAUSE_CLOSE_DEAL, END)
return g
def compile_inquiry_graph(*, checkpointer: Any) -> Any:
"""
编译可执行图。
参数:LangGraph checkpointer(Postgres 或 Memory)。
返回:CompiledGraph;带 interrupt_before。
线程:应在 Worker 启动线程调用一次,缓存复用。
"""
graph = build_state_graph()
compiled = graph.compile(
checkpointer=checkpointer,
interrupt_before=list(INTERRUPT_BEFORE),
)
logger.info(
"询价 Graph 已编译 interrupt_before=%s",
list(INTERRUPT_BEFORE),
)
return compiled
+91
View File
@@ -0,0 +1,91 @@
"""
Checkpoint 工厂:PostgreSQL 优先,开发可降级内存。
本文件职责:按 CHECKPOINT_BACKEND / PG_DSN 创建 LangGraph checkpointer。
副作用:postgres 模式会 setup() 建 checkpoint 表(仅当前 PG_DATABASE)。
禁止:把 checkpoint 写入 Redis;禁止指向现网 ytd_runtime。
线程:Worker 进程启动时创建一次,供多槽共享(同一 thread_id 写冲突由 runtime 锁)。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Optional
from agent.config import Settings
logger = logging.getLogger(__name__)
@dataclass
class CheckpointBundle:
"""
checkpointer 与可选连接资源。
postgres 模式下保留 context/cm 以便进程退出时关闭连接池。
"""
checkpointer: Any
backend: str
_closer: Optional[Any] = None
def close(self) -> None:
"""释放 postgres 连接(若有)。无副作用于业务数据。"""
if self._closer is None:
return
try:
# from_conn_string 返回上下文管理器
self._closer.__exit__(None, None, None)
except Exception: # noqa: BLE001 — 关闭失败只记日志
logger.exception("关闭 Postgres checkpointer 失败")
self._closer = None
def create_checkpointer(settings: Settings) -> CheckpointBundle:
"""
创建 checkpointer。
- CHECKPOINT_BACKEND=memory:仅本地骨架冒烟,进程重启丢书签。
- postgres:需要可连的 resolve_pg_dsn();失败时若 ytd_env=test 可降级 memory 并打警告。
"""
backend = (settings.checkpoint_backend or "postgres").strip().lower()
if backend == "memory":
from langgraph.checkpoint.memory import MemorySaver
logger.warning("Graph checkpoint 使用 MemorySaver(仅骨架/本机;正式必须 postgres)")
return CheckpointBundle(checkpointer=MemorySaver(), backend="memory")
dsn = settings.resolve_pg_dsn()
if not dsn:
if (settings.ytd_env or "").lower() == "prod":
raise RuntimeError("正式环境必须配置 PG_DSN 或 PG_HOST/PG_DATABASE/PG_USER")
from langgraph.checkpoint.memory import MemorySaver
logger.warning(
"未配置 PG_DSN,ytd_env=%s 降级 MemorySaver;正式切流前必须接 inquiry_robot_runtime",
settings.ytd_env,
)
return CheckpointBundle(checkpointer=MemorySaver(), backend="memory")
try:
from langgraph.checkpoint.postgres import PostgresSaver
# from_conn_string 返回上下文管理器;进入后得到 saver,并需保持打开
cm = PostgresSaver.from_conn_string(dsn)
saver = cm.__enter__()
saver.setup()
logger.info(
"Graph Postgres checkpointer 就绪 database=%s",
settings.pg_database or "(from DSN)",
)
return CheckpointBundle(checkpointer=saver, backend="postgres", _closer=cm)
except Exception as exc: # noqa: BLE001
if (settings.ytd_env or "").lower() == "prod":
raise
from langgraph.checkpoint.memory import MemorySaver
logger.warning(
"Postgres checkpointer 初始化失败,降级 MemorySaver:%s",
exc,
)
return CheckpointBundle(checkpointer=MemorySaver(), backend="memory")
@@ -0,0 +1,23 @@
"""
Graph 节点包:一阶段一文件。
本包只做编排占位;自然语言抽取、主账写、LibreOffice 不在本包实现。
"""
from agent.graph.nodes.adopt_adjust import node_adopt_adjust
from agent.graph.nodes.close_deal import node_close_deal
from agent.graph.nodes.collaborate import node_collaborate
from agent.graph.nodes.create_order import node_create_order
from agent.graph.nodes.generate_quote import node_generate_quote
from agent.graph.nodes.parse_clarify import node_parse_clarify
from agent.graph.nodes.query_quote import node_query_quote
__all__ = [
"node_parse_clarify",
"node_create_order",
"node_query_quote",
"node_collaborate",
"node_generate_quote",
"node_adopt_adjust",
"node_close_deal",
]
@@ -0,0 +1,23 @@
"""
节点:采用 / 调价(骨架)。
正式:卡片动作 + waitVersion 校验后调主账报价版本;本文件不写主账。
"""
from __future__ import annotations
import logging
from typing import Any
from agent.graph.state import InquiryGraphState
logger = logging.getLogger(__name__)
def node_adopt_adjust(state: InquiryGraphState) -> dict[str, Any]:
"""骨架:仅标记 phase=adopt_adjust。"""
logger.info(
"graph.node adopt_adjust thread_id=%s(骨架无采用/调价)",
state.get("thread_id") or "",
)
return {"phase": "adopt_adjust"}
@@ -0,0 +1,23 @@
"""
节点:成交 / 未成交收尾(骨架)。
正式:调主账闭环;图结束 ≠ 主账已成交。
"""
from __future__ import annotations
import logging
from typing import Any
from agent.graph.state import InquiryGraphState
logger = logging.getLogger(__name__)
def node_close_deal(state: InquiryGraphState) -> dict[str, Any]:
"""骨架:仅标记 phase=close_deal。"""
logger.info(
"graph.node close_deal thread_id=%s(骨架无闭环)",
state.get("thread_id") or "",
)
return {"phase": "close_deal"}
@@ -0,0 +1,23 @@
"""
节点:协同 / 群报价等待(骨架)。
正式:等群内报价或协同结果;用户文字不得冒充文件完成(wake 另走结构化载荷)。
"""
from __future__ import annotations
import logging
from typing import Any
from agent.graph.state import InquiryGraphState
logger = logging.getLogger(__name__)
def node_collaborate(state: InquiryGraphState) -> dict[str, Any]:
"""骨架:仅标记 phase=collaborate。"""
logger.info(
"graph.node collaborate thread_id=%s(骨架无协同)",
state.get("thread_id") or "",
)
return {"phase": "collaborate"}
@@ -0,0 +1,24 @@
"""
节点:建单准备(骨架)。
正式职责将是:调用主账建单 API;六态成功只认主账返回,图成功不能冒充。
本文件不写 MySQL、不直连 TMS。
"""
from __future__ import annotations
import logging
from typing import Any
from agent.graph.state import InquiryGraphState
logger = logging.getLogger(__name__)
def node_create_order(state: InquiryGraphState) -> dict[str, Any]:
"""骨架:仅标记 phase=create_order。"""
logger.info(
"graph.node create_order thread_id=%s(骨架无建单)",
state.get("thread_id") or "",
)
return {"phase": "create_order"}
@@ -0,0 +1,23 @@
"""
节点:报价文件生成等待(骨架)。
正式:等 Worker LibreOffice/对象存储完成后的结构化 wake;本节点不跑 soffice。
"""
from __future__ import annotations
import logging
from typing import Any
from agent.graph.state import InquiryGraphState
logger = logging.getLogger(__name__)
def node_generate_quote(state: InquiryGraphState) -> dict[str, Any]:
"""骨架:仅标记 phase=generate_quote。"""
logger.info(
"graph.node generate_quote thread_id=%s(骨架不转 PDF)",
state.get("thread_id") or "",
)
return {"phase": "generate_quote"}
@@ -0,0 +1,27 @@
"""
节点:解析 / 补问阶段(骨架)。
正式职责将是:触发字段合同检查与补问等待;本文件不抽字段、不调模型。
线程:仅在 Worker Graph 槽内执行,禁止占用企微回调线程。
"""
from __future__ import annotations
import logging
from typing import Any
from agent.graph.state import InquiryGraphState
logger = logging.getLogger(__name__)
def node_parse_clarify(state: InquiryGraphState) -> dict[str, Any]:
"""
骨架节点:标记 phase=parse_clarify,不产生业务副作用。
参数:当前 checkpoint 状态。
返回:状态增量(合并进 checkpoint)。
"""
thread_id = state.get("thread_id") or ""
logger.info("graph.node parse_clarify thread_id=%s(骨架无业务)", thread_id)
return {"phase": "parse_clarify"}
@@ -0,0 +1,23 @@
"""
节点:查价 / TMS 结果编排(骨架)。
正式:经主账查价;分类结果给人选择。禁止在本节点直连 TMS。
"""
from __future__ import annotations
import logging
from typing import Any
from agent.graph.state import InquiryGraphState
logger = logging.getLogger(__name__)
def node_query_quote(state: InquiryGraphState) -> dict[str, Any]:
"""骨架:仅标记 phase=query_quote。"""
logger.info(
"graph.node query_quote thread_id=%s(骨架无查价)",
state.get("thread_id") or "",
)
return {"phase": "query_quote"}
+29
View File
@@ -0,0 +1,29 @@
"""
Graph 停靠点名称(interrupt_before)。
本文件职责:集中声明 pause_* 节点名,与 prompt/14「停点中断」对齐。
调用时机:builder.compile(interrupt_before=INTERRUPT_BEFORE)。
禁止:在停点名上写业务 if/else;业务唤醒校验走 waitVersion(runtime/wake)。
"""
from __future__ import annotations
# 节点名 = builder 里 add_node 的名字;interrupt_before 表示「跑该节点前停下等人/等文件」
PAUSE_PARSE_CLARIFY = "pause_parse_clarify"
PAUSE_CREATE_ORDER = "pause_create_order"
PAUSE_QUERY_QUOTE = "pause_query_quote"
PAUSE_COLLABORATE = "pause_collaborate"
PAUSE_GENERATE_QUOTE = "pause_generate_quote"
PAUSE_ADOPT_ADJUST = "pause_adopt_adjust"
PAUSE_CLOSE_DEAL = "pause_close_deal"
# 骨架:全部标为可中断;后续可按业务收窄列表
INTERRUPT_BEFORE: tuple[str, ...] = (
PAUSE_PARSE_CLARIFY,
PAUSE_CREATE_ORDER,
PAUSE_QUERY_QUOTE,
PAUSE_COLLABORATE,
PAUSE_GENERATE_QUOTE,
PAUSE_ADOPT_ADJUST,
PAUSE_CLOSE_DEAL,
)
+197
View File
@@ -0,0 +1,197 @@
"""
Graph 运行时公开 API:start / resume / wake。
本文件职责:
- Worker 进程内持有已编译图;
- 按 thread_id 启停;无 sender_id 拒绝进图;
- wake 只接受结构化 WorkerWakePayload。
禁止:在 HTTP 回调线程调用 invoke/resume(会卡企微);同一 thread_id 写冲突用细粒度锁。
骨架:节点无业务,invoke 会在第一个 interrupt_before 停下。
"""
from __future__ import annotations
import logging
import threading
import uuid
from dataclasses import dataclass, field
from typing import Any, Optional
from agent.config import Settings, get_settings
from agent.graph.builder import compile_inquiry_graph
from agent.graph.checkpoint import CheckpointBundle, create_checkpointer
from agent.graph.wake import WorkerWakePayload
logger = logging.getLogger(__name__)
class GraphIdentityError(ValueError):
"""无 sender_id 或其他身份不满足时抛出。"""
class GraphWakeError(ValueError):
"""结构化 wake 校验失败。"""
@dataclass
class GraphRuntime:
"""
进程级 Graph 句柄。
生命周期:Worker 启动 create(),停止 close()。
并发:不同 thread_id 可并行;同一 thread_id 串行(_thread_locks)。
"""
settings: Settings
compiled: Any
checkpoint: CheckpointBundle
_thread_locks: dict[str, threading.Lock] = field(default_factory=dict)
_locks_guard: threading.Lock = field(default_factory=threading.Lock)
@classmethod
def create(cls, settings: Optional[Settings] = None) -> "GraphRuntime":
"""
编译图并挂 checkpointer。
副作用:可能连接 PG 并 setup checkpoint 表;失败策略见 create_checkpointer。
"""
cfg = settings or get_settings()
bundle = create_checkpointer(cfg)
compiled = compile_inquiry_graph(checkpointer=bundle.checkpointer)
logger.info(
"GraphRuntime 就绪 checkpoint_backend=%s ytd_env=%s",
bundle.backend,
cfg.ytd_env,
)
return cls(settings=cfg, compiled=compiled, checkpoint=bundle)
def close(self) -> None:
"""关闭 checkpointer 连接。"""
self.checkpoint.close()
def _lock_for(self, thread_id: str) -> threading.Lock:
"""取得同一 thread_id 的互斥锁(禁止全局大锁串行所有工单)。"""
with self._locks_guard:
lock = self._thread_locks.get(thread_id)
if lock is None:
lock = threading.Lock()
self._thread_locks[thread_id] = lock
return lock
@staticmethod
def _require_sender_id(sender_id: str) -> str:
sid = (sender_id or "").strip()
if not sid:
raise GraphIdentityError("无 sender_id,拒绝进入 Graph(prompt/09、14)")
return sid
def start_thread(
self,
*,
sender_id: str,
thread_id: Optional[str] = None,
inquiry_no: Optional[str] = None,
) -> dict[str, Any]:
"""
新开一条询价图(新 thread_id)。
调用线程:必须是 Worker HTTP 类槽,禁止企微回调线程。
副作用:写入 checkpoint;骨架会在首个 pause 中断。
返回:含 thread_id 与 invoke 原始结果摘要。
"""
sid = self._require_sender_id(sender_id)
tid = (thread_id or "").strip() or str(uuid.uuid4())
config = {"configurable": {"thread_id": tid}}
initial = {
"thread_id": tid,
"sender_id": sid,
"inquiry_no": inquiry_no,
"wait_version": 0,
"phase": "start",
}
lock = self._lock_for(tid)
with lock:
# 骨架:invoke 至 interrupt_before 第一个停点
result = self.compiled.invoke(initial, config=config)
logger.info("graph.start_thread thread_id=%s sender_id=%s", tid, sid)
return {"thread_id": tid, "state": result, "interrupted": True}
def resume_thread(
self,
*,
thread_id: str,
sender_id: str,
values: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""
从精确断点继续(入站「继续本单」路径的图侧入口)。
无业务路由:values 仅合并进状态;真正 Handler 后续再接。
"""
sid = self._require_sender_id(sender_id)
tid = (thread_id or "").strip()
if not tid:
raise GraphIdentityError("resume 缺少 thread_id")
config = {"configurable": {"thread_id": tid}}
lock = self._lock_for(tid)
with lock:
patch = dict(values or {})
patch["sender_id"] = sid
patch["thread_id"] = tid
# None 表示从 checkpoint 继续;有 patch 时先 update 再跑
if patch:
self.compiled.update_state(config, patch)
result = self.compiled.invoke(None, config=config)
logger.info("graph.resume_thread thread_id=%s", tid)
return {"thread_id": tid, "state": result}
def wake_from_worker(self, payload: WorkerWakePayload) -> dict[str, Any]:
"""
Worker 结构化唤醒(识别/PDF 完成)。
拒绝:缺 inquiry_no/quoteVersion/waitVersion;用户纯文字不得走本 API。
"""
try:
patch = payload.as_state_patch()
except ValueError as exc:
raise GraphWakeError(str(exc)) from exc
tid = payload.thread_id.strip()
config = {"configurable": {"thread_id": tid}}
lock = self._lock_for(tid)
with lock:
self.compiled.update_state(config, patch)
result = self.compiled.invoke(None, config=config)
logger.info(
"graph.wake_from_worker thread_id=%s kind=%s wait_version=%s",
tid,
payload.kind,
payload.wait_version,
)
return {"thread_id": tid, "state": result, "wake": patch["last_wake"]}
_runtime_singleton: Optional[GraphRuntime] = None
_runtime_guard = threading.Lock()
def get_graph_runtime(settings: Optional[Settings] = None) -> GraphRuntime:
"""
进程内单例 GraphRuntime(懒创建)。
供 Worker 与后续 jobs 槽使用;HTTP 进程不应依赖本单例去 invoke。
"""
global _runtime_singleton
with _runtime_guard:
if _runtime_singleton is None:
_runtime_singleton = GraphRuntime.create(settings)
return _runtime_singleton
def shutdown_graph_runtime() -> None:
"""进程退出时关闭单例。"""
global _runtime_singleton
with _runtime_guard:
if _runtime_singleton is not None:
_runtime_singleton.close()
_runtime_singleton = None
+68
View File
@@ -0,0 +1,68 @@
"""
Graph 骨架自检:编译 + start_thread(Memory)+ 无 sender_id 拒绝。
用法(在 inquiry-agent 目录、已装依赖):
set CHECKPOINT_BACKEND=memory
python -m agent.graph.selftest
不连企微、不连主账;正式验收仍以真实企微事件为准。
"""
from __future__ import annotations
import logging
import os
import sys
logger = logging.getLogger(__name__)
def main() -> int:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
# 自检强制内存,避免误连生产 PG
os.environ["CHECKPOINT_BACKEND"] = "memory"
os.environ.setdefault("YTD_ENV", "test")
from agent.config import get_settings
from agent.graph.runtime import GraphIdentityError, GraphRuntime, shutdown_graph_runtime
from agent.graph.wake import WorkerWakePayload
get_settings.cache_clear()
rt = GraphRuntime.create(get_settings())
try:
try:
rt.start_thread(sender_id="")
logger.error("预期应拒绝空 sender_id")
return 1
except GraphIdentityError:
logger.info("OK: 空 sender_id 已拒绝")
started = rt.start_thread(sender_id="selftest_user")
tid = started["thread_id"]
logger.info("OK: start_thread thread_id=%s", tid)
# 骨架:wake 需要已有 thread;写入 last_wake 后再 resume 一步
wake = WorkerWakePayload(
thread_id=tid,
inquiry_no="SELFTEST-001",
quote_version=1,
wait_version=0,
kind="recognition_done",
)
woken = rt.wake_from_worker(wake)
logger.info("OK: wake_from_worker kind=%s", woken["wake"]["kind"])
resumed = rt.resume_thread(thread_id=tid, sender_id="selftest_user")
logger.info("OK: resume_thread thread_id=%s", resumed["thread_id"])
logger.info(
"Graph 骨架自检通过 checkpoint_backend=%s",
rt.checkpoint.backend,
)
return 0
finally:
rt.close()
shutdown_graph_runtime()
if __name__ == "__main__":
sys.exit(main())
+32
View File
@@ -0,0 +1,32 @@
"""
询价 Graph 状态(骨架)。
本文件职责:定义一条询价在图内携带的最小字段;checkpoint 会持久化本结构。
禁止:把主账六态真相写进本状态并当作已成功;禁止在本文件写业务分支。
"""
from __future__ import annotations
from typing import Any, Optional, TypedDict
class InquiryGraphState(TypedDict, total=False):
"""
一单一份图状态。
thread_id:LangGraph / checkpoint 主键(一询价一 thread)。
sender_id:入站企微 userid;为空时 runtime 拒绝进图。
wait_version:与补问卡/采用卡绑定;错版本点击应丢弃(业务层后续实现)。
last_wake:最近一次 Worker 结构化唤醒快照(仅记录,不替代主账)。
"""
thread_id: str
sender_id: str
inquiry_no: Optional[str]
wait_version: int
quote_version: Optional[int]
# 骨架占位:后续由各节点写入阶段标记;不得冒充主账六态
phase: str
last_wake: dict[str, Any]
# 调试备注,正式路径勿依赖
notes: str
+61
View File
@@ -0,0 +1,61 @@
"""
Worker 结构化唤醒载荷。
本文件职责:定义「识别/PDF 完成」唤醒图的数据结构;禁止用用户聊天文字冒充文件完成。
调用方:jobs 槽完成后 → GraphRuntime.wake_from_worker。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Optional
@dataclass(frozen=True)
class WorkerWakePayload:
"""
结构化 wake(对齐 prompt/14)。
inquiry_no + quote_version + wait_version 三者用于对齐停点;缺任一则 runtime 拒绝。
kind:识别完成 / PDF 完成等枚举字符串,骨架只做透传校验。
"""
thread_id: str
inquiry_no: str
quote_version: int
wait_version: int
kind: str
detail: Optional[dict[str, Any]] = None
def validate(self) -> None:
"""
校验必填字段。
副作用:无效时抛 ValueError;不写库。
"""
if not (self.thread_id or "").strip():
raise ValueError("wake 缺少 thread_id")
if not (self.inquiry_no or "").strip():
raise ValueError("wake 缺少 inquiry_no")
if self.quote_version is None or int(self.quote_version) < 0:
raise ValueError("wake quote_version 无效")
if self.wait_version is None or int(self.wait_version) < 0:
raise ValueError("wake wait_version 无效")
if not (self.kind or "").strip():
raise ValueError("wake 缺少 kind")
def as_state_patch(self) -> dict[str, Any]:
"""转为写入图状态的增量(骨架:只记录 last_wake,不推进六态)。"""
self.validate()
return {
"inquiry_no": self.inquiry_no,
"quote_version": int(self.quote_version),
"wait_version": int(self.wait_version),
"last_wake": {
"kind": self.kind,
"inquiry_no": self.inquiry_no,
"quote_version": int(self.quote_version),
"wait_version": int(self.wait_version),
"detail": self.detail or {},
},
}