落地并存测服全端:智能体 Graph/路由、后台切 8180、主账与部署脚本齐备,便于线上重发与验收。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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 {},
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user