落地并存测服全端:智能体 Graph/路由、后台切 8180、主账与部署脚本齐备,便于线上重发与验收。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+39
-2
@@ -1,8 +1,45 @@
|
||||
# 智能体 agent
|
||||
|
||||
Python 3.12 + FastAPI。HTTP 默认 `8910`。
|
||||
Python 3.12 + FastAPI。HTTP 默认 `8910`;Worker 另起进程。
|
||||
|
||||
## 基础框架自检
|
||||
|
||||
```text
|
||||
set REDIS_BACKEND=memory
|
||||
set CHECKPOINT_BACKEND=memory
|
||||
python -m agent.foundation_selftest
|
||||
python -m agent.runtime_db.migrate --list
|
||||
```
|
||||
|
||||
PG 真迁移:配好 `PG_*` 后 `python -m agent.runtime_db.migrate`(禁止 `ytd_runtime`)。
|
||||
|
||||
消息账本:`MESSAGE_STORE_BACKEND=auto|memory|postgres`(有 DSN 时优先 PG inbox/outbox)。
|
||||
|
||||
## Redis 骨架
|
||||
|
||||
- 包:`agent/redis_coord/`(连接+前缀、Stream 入队/认领、短幂等、限流、token 缓存)。
|
||||
- **db1 不是名字**:Redis 只有数字逻辑库(URL `…/1`),与现网 `…/0` 隔离;可读名是键前缀 **`inquiry_robot:`**。
|
||||
- 自检:`set REDIS_BACKEND=memory` 后 `python -m agent.redis_coord.selftest`。
|
||||
- 正式:`REDIS_URL=redis://…@host:6379/1` + `REDIS_KEY_PREFIX=inquiry_robot:`(禁止 `ytd:prod:`)。
|
||||
|
||||
## LangGraph 骨架
|
||||
|
||||
- 包:`agent/graph/`(状态、分文件空节点、interrupt 停点、PG/Memory checkpoint、start/resume/wake)。
|
||||
- **无询价主逻辑**;节点只打日志。HTTP 回调禁止 `invoke` 整图。
|
||||
- 自检:`set CHECKPOINT_BACKEND=memory` 后执行 `python -m agent.graph.selftest`。
|
||||
- 正式:`CHECKPOINT_BACKEND=postgres` + `PG_*` 指向 `inquiry_robot_runtime`(禁止 `ytd_runtime`)。
|
||||
|
||||
## 本迭代骨架(路由 / PDF / 识别)
|
||||
|
||||
- DeepSeek A:`agent/routing/ordered_actions.py` + `deepseek_a.py`(strict `route_decision` tool;默认 stub,不开费)。
|
||||
- 分发:`agent/routing/dispatch.py`;通道 inbox → route → handler 表。
|
||||
- 真网开关:`LLM_DATA_USAGE_CONFIRMED=true` 且 `LLM_ALLOW_NETWORK=true`(Worker)才可能打模型。
|
||||
- LibreOffice:`agent/jobs/libreoffice.py` + `stream:pdf`(仅 1 槽);路径 `LIBREOFFICE_PATH`。
|
||||
- 识别 / 存档:`stream:recognition` / `stream:archive` 入队壳。
|
||||
- 字段合同:仓库 `contracts/*.json`;拉取备份在 `_ref/prod-pull/`(gitignore)。
|
||||
- 本机密钥:仓库根 `.env`(gitignore);新库口令来自服务器 `E:\wwwroot\ops\inquiry_robot_db_secrets.json`。
|
||||
- 另:session/suspend/job/死信、LLM 各 mode 空模块、S3 壳、ledger API 签名壳、模板 registry、policy、AIBOT 长连接壳、Java TMS 空包、`deploy/nssm/SERVICES.md`。
|
||||
|
||||
## 本迭代:企微文字 echo 回复
|
||||
|
||||
链路:回调/注入 → inbox 入队(快回)→ echo handler → outbox → 企微 `message/send`。
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
AIBOT 入站入口:桥进程把长连接消息转到本路由。
|
||||
|
||||
本文件职责:校验 bridge token、归一 InboundMessage、入 inbox 快回。
|
||||
禁止:在本请求线程调 LLM / Graph / 出站企微。
|
||||
路径:/internal/aibot/inbound(对齐 prompt/13)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agent.channel.queue import get_message_store
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
from agent.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/internal/aibot", tags=["aibot"])
|
||||
|
||||
|
||||
class AibotInboundBody(BaseModel):
|
||||
"""桥转发的归一字段(桥侧已解析,智能体不再调企微换 ID)。"""
|
||||
|
||||
sender_id: str = Field(description="企微 userid,必须来自 AIBOT 报文")
|
||||
message_id: str
|
||||
content: str = ""
|
||||
msg_type: str = "text"
|
||||
chat_id: str = ""
|
||||
chat_type: str = "group"
|
||||
agent_id: str = ""
|
||||
create_time: int = 0
|
||||
media: dict[str, Any] = Field(default_factory=dict)
|
||||
raw: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
def _check_bridge_token(x_token: Optional[str]) -> None:
|
||||
s = get_settings()
|
||||
expected = (getattr(s, "wecom_aibot_bridge_token", None) or "").strip()
|
||||
if not expected:
|
||||
if (s.ytd_env or "").lower() == "prod":
|
||||
raise HTTPException(status_code=503, detail="AIBOT bridge token 未配置")
|
||||
logger.warning("aibot inbound:未配置 bridge token,test 跳过校验")
|
||||
return
|
||||
if (x_token or "").strip() != expected:
|
||||
raise HTTPException(status_code=401, detail="unauthorized")
|
||||
|
||||
|
||||
@router.get("/ping")
|
||||
def aibot_ping() -> dict[str, str]:
|
||||
return {"status": "ok", "channel": "aibot"}
|
||||
|
||||
|
||||
@router.post("/inbound")
|
||||
def aibot_inbound(
|
||||
body: AibotInboundBody,
|
||||
x_aibot_bridge_token: Optional[str] = Header(
|
||||
default=None, alias="X-Aibot-Bridge-Token"
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
接收桥转发入站:无 sender_id 拒绝;其余入 inbox。
|
||||
"""
|
||||
_check_bridge_token(x_aibot_bridge_token)
|
||||
msg = InboundMessage(
|
||||
sender_id=body.sender_id.strip(),
|
||||
message_id=body.message_id.strip(),
|
||||
content=body.content or "",
|
||||
msg_type=body.msg_type or "text",
|
||||
chat_id=body.chat_id or "",
|
||||
chat_type=body.chat_type or "group",
|
||||
agent_id=body.agent_id or "",
|
||||
create_time=int(body.create_time or 0),
|
||||
media=body.media or {},
|
||||
raw=body.raw or {},
|
||||
)
|
||||
if not msg.has_sender():
|
||||
raise HTTPException(status_code=400, detail="missing_sender_id")
|
||||
if not msg.message_id:
|
||||
raise HTTPException(status_code=400, detail="missing_message_id")
|
||||
|
||||
ok, info = get_message_store().enqueue_inbound(msg)
|
||||
logger.info(
|
||||
"aibot.inbound ok=%s info=%s sender=%s msg_id=%s",
|
||||
ok,
|
||||
info,
|
||||
msg.sender_id,
|
||||
msg.message_id,
|
||||
)
|
||||
return {"status": "accepted" if ok else "duplicate", "detail": info}
|
||||
@@ -1,18 +1,102 @@
|
||||
"""
|
||||
主账唤醒桥接路由(合并原 bridge_api)。
|
||||
|
||||
本文件职责:接收主账鉴权后的 wake 事件,入队由 Worker 结构化 resume。
|
||||
本文件职责:校验 callback token,接收结构化 wake,写入 Redis Stream 后快回。
|
||||
禁止:在本请求线程跑完整 Graph;禁止本包直连 MySQL / TMS。
|
||||
Worker:jobs.wake_consumer 认领后 GraphRuntime.wake_from_worker。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agent.config import get_settings
|
||||
from agent.graph.wake import WorkerWakePayload
|
||||
from agent.jobs.wake_consumer import enqueue_wake
|
||||
from agent.redis_coord import get_redis_runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/internal/bridge", tags=["bridge"])
|
||||
|
||||
|
||||
class BridgeWakeBody(BaseModel):
|
||||
"""主账 → 智能体结构化唤醒。"""
|
||||
|
||||
thread_id: str
|
||||
inquiry_no: str
|
||||
quote_version: int = 0
|
||||
wait_version: int = 0
|
||||
kind: str = Field(description="如 recognition_done / pdf_done / ledger_transition")
|
||||
detail: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
def _expected_token() -> str:
|
||||
s = get_settings()
|
||||
return (s.agent_callback_token or s.inquiry_agent_callback_token or "").strip()
|
||||
|
||||
|
||||
def _check_token(authorization: Optional[str], x_agent_token: Optional[str]) -> None:
|
||||
expected = _expected_token()
|
||||
if not expected:
|
||||
if (get_settings().ytd_env or "").lower() == "prod":
|
||||
raise HTTPException(status_code=503, detail="callback token 未配置")
|
||||
logger.warning("bridge:未配置 callback token,test 允许空校验(仅骨架)")
|
||||
return
|
||||
provided = (x_agent_token or "").strip()
|
||||
if not provided and authorization:
|
||||
auth = authorization.strip()
|
||||
if auth.lower().startswith("bearer "):
|
||||
provided = auth[7:].strip()
|
||||
else:
|
||||
provided = auth
|
||||
if provided != expected:
|
||||
raise HTTPException(status_code=401, detail="unauthorized")
|
||||
|
||||
|
||||
@router.get("/ping")
|
||||
def bridge_ping() -> dict[str, str]:
|
||||
"""主账唤醒路径占位;正式实现需校验 AGENT_CALLBACK_TOKEN。"""
|
||||
return {"status": "ok", "channel": "bridge", "note": "scaffold-only"}
|
||||
return {"status": "ok", "channel": "bridge"}
|
||||
|
||||
|
||||
@router.post("/wake")
|
||||
def bridge_wake(
|
||||
body: BridgeWakeBody,
|
||||
authorization: Optional[str] = Header(default=None),
|
||||
x_agent_callback_token: Optional[str] = Header(
|
||||
default=None, alias="X-Agent-Callback-Token"
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
接收结构化 wake:校验后写入 Redis Stream,立即返回。
|
||||
|
||||
不在此 invoke Graph。
|
||||
"""
|
||||
_check_token(authorization, x_agent_callback_token)
|
||||
try:
|
||||
payload = WorkerWakePayload(
|
||||
thread_id=body.thread_id,
|
||||
inquiry_no=body.inquiry_no,
|
||||
quote_version=body.quote_version,
|
||||
wait_version=body.wait_version,
|
||||
kind=body.kind,
|
||||
detail=body.detail,
|
||||
)
|
||||
entry_id = enqueue_wake(get_redis_runtime().client, payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("bridge.wake 入队失败")
|
||||
raise HTTPException(status_code=503, detail=f"enqueue_failed:{exc}") from exc
|
||||
|
||||
logger.info(
|
||||
"bridge.wake 已入队 Redis thread=%s kind=%s id=%s",
|
||||
body.thread_id,
|
||||
body.kind,
|
||||
entry_id,
|
||||
)
|
||||
return {"status": "accepted", "entry_id": entry_id}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
clarification / H5 token 绑定(骨架)。
|
||||
|
||||
本文件职责:签发与校验一次性 token;优先写 PG clarification_binding,无库则内存。
|
||||
禁止:URL 携带 userid;身份以绑定时的 sender_id 为准。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from agent.config import get_settings
|
||||
from agent.runtime_db import assert_safe_database, connect_psycopg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# H5 TTL 30 分钟(prompt/14)
|
||||
DEFAULT_TTL_SECONDS = 30 * 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClarificationRecord:
|
||||
token: str
|
||||
thread_id: str
|
||||
wait_version: int
|
||||
sender_id: str
|
||||
allowed_actions: list[str]
|
||||
expires_at: float
|
||||
consumed: bool = False
|
||||
|
||||
|
||||
_MEM: dict[str, ClarificationRecord] = {}
|
||||
_MEM_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def issue_token(
|
||||
*,
|
||||
thread_id: str,
|
||||
wait_version: int,
|
||||
sender_id: str,
|
||||
allowed_actions: list[str],
|
||||
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
||||
) -> str:
|
||||
"""
|
||||
签发 clarification token。
|
||||
|
||||
副作用:写 PG 或内存;返回随机 token(不进 URL 的 userid)。
|
||||
"""
|
||||
token = secrets.token_urlsafe(24)
|
||||
expires = time.time() + ttl_seconds
|
||||
rec = ClarificationRecord(
|
||||
token=token,
|
||||
thread_id=thread_id,
|
||||
wait_version=wait_version,
|
||||
sender_id=sender_id,
|
||||
allowed_actions=list(allowed_actions),
|
||||
expires_at=expires,
|
||||
)
|
||||
if _try_pg_insert(rec, ttl_seconds):
|
||||
return token
|
||||
with _MEM_LOCK:
|
||||
_MEM[token] = rec
|
||||
logger.info("clarification 内存签发 thread=%s wait=%s", thread_id, wait_version)
|
||||
return token
|
||||
|
||||
|
||||
def validate_token(
|
||||
token: str,
|
||||
*,
|
||||
action: str = "",
|
||||
consume: bool = False,
|
||||
) -> Optional[ClarificationRecord]:
|
||||
"""
|
||||
校验 token:存在、未过期、未消费;若指定 action 须在允许集合。
|
||||
|
||||
consume=True:一次性消费。
|
||||
"""
|
||||
if not (token or "").strip():
|
||||
return None
|
||||
rec = _try_pg_load(token) or _mem_load(token)
|
||||
if rec is None:
|
||||
return None
|
||||
if rec.consumed or rec.expires_at < time.time():
|
||||
return None
|
||||
if action and action not in rec.allowed_actions:
|
||||
return None
|
||||
if consume:
|
||||
_consume(token)
|
||||
rec.consumed = True
|
||||
return rec
|
||||
|
||||
|
||||
def _mem_load(token: str) -> Optional[ClarificationRecord]:
|
||||
with _MEM_LOCK:
|
||||
return _MEM.get(token)
|
||||
|
||||
|
||||
def _consume(token: str) -> None:
|
||||
if _try_pg_consume(token):
|
||||
return
|
||||
with _MEM_LOCK:
|
||||
rec = _MEM.get(token)
|
||||
if rec:
|
||||
rec.consumed = True
|
||||
|
||||
|
||||
def _try_pg_insert(rec: ClarificationRecord, ttl_seconds: int) -> bool:
|
||||
settings = get_settings()
|
||||
dsn = settings.resolve_pg_dsn()
|
||||
if not dsn:
|
||||
return False
|
||||
try:
|
||||
assert_safe_database(dsn)
|
||||
expires = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
|
||||
import json
|
||||
|
||||
with connect_psycopg(dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO clarification_binding (
|
||||
token, thread_id, wait_version, sender_id, allowed_actions, expires_at
|
||||
) VALUES (%s, %s, %s, %s, %s::jsonb, %s)
|
||||
""",
|
||||
(
|
||||
rec.token,
|
||||
rec.thread_id,
|
||||
rec.wait_version,
|
||||
rec.sender_id,
|
||||
json.dumps(rec.allowed_actions, ensure_ascii=False),
|
||||
expires,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("clarification PG 写入失败,改用内存:%s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def _try_pg_load(token: str) -> Optional[ClarificationRecord]:
|
||||
settings = get_settings()
|
||||
dsn = settings.resolve_pg_dsn()
|
||||
if not dsn:
|
||||
return None
|
||||
try:
|
||||
with connect_psycopg(dsn) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT thread_id, wait_version, sender_id, allowed_actions,
|
||||
EXTRACT(EPOCH FROM expires_at), consumed_at
|
||||
FROM clarification_binding WHERE token = %s
|
||||
""",
|
||||
(token,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
actions = row[3]
|
||||
if isinstance(actions, str):
|
||||
import json
|
||||
|
||||
actions = json.loads(actions)
|
||||
return ClarificationRecord(
|
||||
token=token,
|
||||
thread_id=str(row[0]),
|
||||
wait_version=int(row[1]),
|
||||
sender_id=str(row[2]),
|
||||
allowed_actions=list(actions or []),
|
||||
expires_at=float(row[4] or 0),
|
||||
consumed=row[5] is not None,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def _try_pg_consume(token: str) -> bool:
|
||||
settings = get_settings()
|
||||
dsn = settings.resolve_pg_dsn()
|
||||
if not dsn:
|
||||
return False
|
||||
try:
|
||||
with connect_psycopg(dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE clarification_binding
|
||||
SET consumed_at = NOW()
|
||||
WHERE token = %s AND consumed_at IS NULL
|
||||
""",
|
||||
(token,),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
@@ -1,22 +1,79 @@
|
||||
"""
|
||||
H5 补问 / 附件上传通道。
|
||||
|
||||
本文件职责:挂载 /inquiry-form 相关壳路由;字段快照与流程状态在 Graph/PG,不在本包。
|
||||
禁止:从 URL 读 userid;禁止在本包改六态或直连 TMS。
|
||||
本文件职责:挂载 /inquiry-form;用 clarification token 绑定身份(URL 禁止 userid)。
|
||||
禁止:从 URL 读 userid;禁止在本包改六态或直连 TMS;禁止同步 invoke Graph。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agent.channel.clarification import validate_token
|
||||
from agent.handlers.card_action import handle_card_action
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/inquiry-form", tags=["h5"])
|
||||
|
||||
|
||||
class H5SubmitBody(BaseModel):
|
||||
"""H5 提交体:业务字段放 fields;身份只认服务端 token 绑定。"""
|
||||
|
||||
action: str = Field(default="submit_clarify")
|
||||
fields: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@router.get("/ping")
|
||||
def h5_ping() -> dict[str, str]:
|
||||
"""
|
||||
H5 路由占位探测。
|
||||
"""存活探测。"""
|
||||
return {"status": "ok", "channel": "h5"}
|
||||
|
||||
正式页用一次性 token;骨架不校验 token、不落库。
|
||||
|
||||
@router.get("/{token}")
|
||||
def h5_open(token: str) -> dict[str, Any]:
|
||||
"""
|
||||
return {"status": "ok", "channel": "h5", "note": "scaffold-only"}
|
||||
打开补问页:校验 token 未过期。
|
||||
|
||||
返回允许动作与 wait_version;不返回内部异常栈。
|
||||
"""
|
||||
rec = validate_token(token)
|
||||
if rec is None:
|
||||
raise HTTPException(status_code=410, detail="表单已过期或无效,请回企微重新打开")
|
||||
return {
|
||||
"status": "ok",
|
||||
"thread_id": rec.thread_id,
|
||||
"wait_version": rec.wait_version,
|
||||
"allowed_actions": rec.allowed_actions,
|
||||
"note": "骨架:字段列表后续按合同生成",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{token}/submit")
|
||||
def h5_submit(token: str, body: H5SubmitBody) -> dict[str, Any]:
|
||||
"""
|
||||
提交补问:消费 token,转入 card_action 骨架(不跑整图)。
|
||||
|
||||
副作用:token 一次性;入队/Handler 后续接 Worker。
|
||||
"""
|
||||
rec = validate_token(token, action=body.action or "submit_clarify", consume=True)
|
||||
if rec is None:
|
||||
raise HTTPException(status_code=410, detail="表单已过期或无效,请回企微重新打开")
|
||||
handle_card_action(
|
||||
sender_id=rec.sender_id,
|
||||
thread_id=rec.thread_id,
|
||||
wait_version=rec.wait_version,
|
||||
action=body.action or "submit_clarify",
|
||||
payload=body.fields,
|
||||
)
|
||||
logger.info(
|
||||
"h5.submit ok thread=%s wait=%s action=%s",
|
||||
rec.thread_id,
|
||||
rec.wait_version,
|
||||
body.action,
|
||||
)
|
||||
return {"status": "accepted", "thread_id": rec.thread_id}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
PostgreSQL inbox / outbox 账本。
|
||||
|
||||
本文件职责:落 agent_inbox / agent_outbox;按条认领 + 超时回收。
|
||||
调用:需已执行 migrations/001_runtime_core_v1.sql。
|
||||
禁止:在回调线程跑长事务;单次操作短 SQL。
|
||||
线程:每个方法短连接或共用连接需自行串行;本实现每操作用独立短连接,避免跨线程共享 cursor。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.channel.queue import InboxItem, OutboxItem, TaskState
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
from agent.runtime_db import assert_safe_database, connect_psycopg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class PostgresMessageStore:
|
||||
"""
|
||||
PG 消息账本。
|
||||
|
||||
claim_timeout_sec:认领超时后可被其他消费者回收。
|
||||
claimed_by:本进程标识,便于排查。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dsn: str,
|
||||
*,
|
||||
claim_timeout_sec: float = 120.0,
|
||||
worker_id: str = "",
|
||||
) -> None:
|
||||
assert_safe_database(dsn)
|
||||
self._dsn = dsn
|
||||
self._claim_timeout = claim_timeout_sec
|
||||
self._worker_id = worker_id or f"pid-{threading.get_ident()}"
|
||||
self._inbox_event = threading.Event()
|
||||
self._outbox_event = threading.Event()
|
||||
|
||||
def enqueue_inbound(self, message: InboundMessage) -> tuple[bool, str]:
|
||||
"""写入 agent_inbox;message_id 唯一冲突视为重复。"""
|
||||
if not message.has_sender():
|
||||
return False, "missing_sender_id"
|
||||
item_id = str(uuid.uuid4())
|
||||
payload = json.dumps(message.to_dict(), ensure_ascii=False)
|
||||
try:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO agent_inbox (
|
||||
id, message_id, sender_id, chat_id, chat_type, payload, state
|
||||
) VALUES (%s, %s, %s, %s, %s, %s::jsonb, 'pending')
|
||||
""",
|
||||
(
|
||||
item_id,
|
||||
message.message_id,
|
||||
message.sender_id,
|
||||
message.chat_id,
|
||||
message.chat_type,
|
||||
payload,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
self._inbox_event.set()
|
||||
logger.info(
|
||||
"pg inbox 入队 id=%s msg_id=%s sender=%s",
|
||||
item_id,
|
||||
message.message_id,
|
||||
message.sender_id,
|
||||
)
|
||||
return True, item_id
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# unique_violation
|
||||
if "unique" in str(exc).lower() or "duplicate" in str(exc).lower():
|
||||
return False, "duplicate_message_id"
|
||||
logger.exception("pg inbox 入队失败")
|
||||
raise
|
||||
|
||||
def claim_inbound(self) -> Optional[InboxItem]:
|
||||
"""认领一条 pending 或超时 claimed。"""
|
||||
timeout = int(self._claim_timeout)
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
WITH cte AS (
|
||||
SELECT id FROM agent_inbox
|
||||
WHERE state = 'pending'
|
||||
OR (
|
||||
state = 'claimed'
|
||||
AND claimed_at IS NOT NULL
|
||||
AND claimed_at < NOW() - (%s || ' seconds')::interval
|
||||
)
|
||||
ORDER BY created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE agent_inbox i
|
||||
SET state = 'claimed',
|
||||
claimed_at = NOW(),
|
||||
claimed_by = %s,
|
||||
attempts = attempts + 1,
|
||||
updated_at = NOW()
|
||||
FROM cte
|
||||
WHERE i.id = cte.id
|
||||
RETURNING i.id, i.payload, i.attempts, i.last_error, i.claimed_at
|
||||
""",
|
||||
(str(timeout), self._worker_id),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
if not row:
|
||||
self._inbox_event.clear()
|
||||
return None
|
||||
payload = row[1]
|
||||
if isinstance(payload, str):
|
||||
payload = json.loads(payload)
|
||||
msg = InboundMessage.from_dict(dict(payload))
|
||||
claimed_at = row[4]
|
||||
ts = claimed_at.timestamp() if hasattr(claimed_at, "timestamp") else time.time()
|
||||
return InboxItem(
|
||||
id=str(row[0]),
|
||||
message=msg,
|
||||
state=TaskState.CLAIMED,
|
||||
claimed_at=ts,
|
||||
attempts=int(row[2] or 0),
|
||||
last_error=str(row[3] or ""),
|
||||
)
|
||||
|
||||
def complete_inbound(self, item_id: str, *, error: str = "") -> None:
|
||||
state = "failed" if error else "done"
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE agent_inbox
|
||||
SET state = %s, last_error = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(state, error or "", item_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def enqueue_outbound(
|
||||
self,
|
||||
*,
|
||||
touser: str,
|
||||
content: str,
|
||||
dedupe_key: str = "",
|
||||
) -> tuple[bool, str]:
|
||||
if not touser or not content:
|
||||
return False, "invalid_outbox"
|
||||
item_id = str(uuid.uuid4())
|
||||
try:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO agent_outbox (
|
||||
id, dedupe_key, touser, content, payload, state
|
||||
) VALUES (%s, %s, %s, %s, '{}'::jsonb, 'pending')
|
||||
""",
|
||||
(item_id, dedupe_key or "", touser, content),
|
||||
)
|
||||
conn.commit()
|
||||
self._outbox_event.set()
|
||||
logger.info("pg outbox 入队 id=%s touser=%s", item_id, touser)
|
||||
return True, item_id
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if dedupe_key and (
|
||||
"unique" in str(exc).lower() or "duplicate" in str(exc).lower()
|
||||
):
|
||||
return False, "duplicate_outbox"
|
||||
logger.exception("pg outbox 入队失败")
|
||||
raise
|
||||
|
||||
def claim_outbound(self) -> Optional[OutboxItem]:
|
||||
timeout = int(self._claim_timeout)
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
WITH cte AS (
|
||||
SELECT id FROM agent_outbox
|
||||
WHERE state = 'pending'
|
||||
OR (
|
||||
state = 'claimed'
|
||||
AND claimed_at IS NOT NULL
|
||||
AND claimed_at < NOW() - (%s || ' seconds')::interval
|
||||
)
|
||||
ORDER BY created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE agent_outbox o
|
||||
SET state = 'claimed',
|
||||
claimed_at = NOW(),
|
||||
claimed_by = %s,
|
||||
attempts = attempts + 1,
|
||||
updated_at = NOW()
|
||||
FROM cte
|
||||
WHERE o.id = cte.id
|
||||
RETURNING o.id, o.touser, o.content, o.attempts, o.last_error,
|
||||
o.created_at, o.dedupe_key, o.claimed_at
|
||||
""",
|
||||
(str(timeout), self._worker_id),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
if not row:
|
||||
self._outbox_event.clear()
|
||||
return None
|
||||
created = row[5]
|
||||
created_ts = created.timestamp() if hasattr(created, "timestamp") else time.time()
|
||||
claimed = row[7]
|
||||
claimed_ts = claimed.timestamp() if hasattr(claimed, "timestamp") else time.time()
|
||||
return OutboxItem(
|
||||
id=str(row[0]),
|
||||
touser=str(row[1]),
|
||||
content=str(row[2]),
|
||||
state=TaskState.CLAIMED,
|
||||
claimed_at=claimed_ts,
|
||||
attempts=int(row[3] or 0),
|
||||
last_error=str(row[4] or ""),
|
||||
created_at=created_ts,
|
||||
dedupe_key=str(row[6] or ""),
|
||||
)
|
||||
|
||||
def complete_outbound(
|
||||
self, item_id: str, *, error: str = "", permanent: bool = False
|
||||
) -> None:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
if error:
|
||||
row = conn.execute(
|
||||
"SELECT attempts FROM agent_outbox WHERE id = %s",
|
||||
(item_id,),
|
||||
).fetchone()
|
||||
attempts = int(row[0]) if row else 0
|
||||
if permanent or attempts >= 5:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE agent_outbox
|
||||
SET state = 'failed', last_error = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(error, item_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE agent_outbox
|
||||
SET state = 'pending', claimed_at = NULL, claimed_by = '',
|
||||
last_error = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(error, item_id),
|
||||
)
|
||||
self._outbox_event.set()
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE agent_outbox
|
||||
SET state = 'done', last_error = '', updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(item_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def wait_inbox(self, timeout: float = 1.0) -> None:
|
||||
self._inbox_event.wait(timeout=timeout)
|
||||
|
||||
def wait_outbox(self, timeout: float = 1.0) -> None:
|
||||
self._outbox_event.wait(timeout=timeout)
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
inbox = conn.execute(
|
||||
"SELECT COUNT(*) FROM agent_inbox WHERE state IN ('pending','claimed')"
|
||||
).fetchone()
|
||||
outbox = conn.execute(
|
||||
"SELECT COUNT(*) FROM agent_outbox WHERE state IN ('pending','claimed')"
|
||||
).fetchone()
|
||||
return {
|
||||
"backend": "postgres",
|
||||
"inbox": int(inbox[0] if inbox else 0),
|
||||
"outbox": int(outbox[0] if outbox else 0),
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"""
|
||||
进程内 inbox / outbox 队列(骨架过渡实现)。
|
||||
进程内 / PostgreSQL inbox·outbox 账本。
|
||||
|
||||
本文件职责:回调快写入队、按条认领、message_id 去重;后续可替换为 PostgreSQL 表。
|
||||
本文件职责:定义任务结构、Memory 实现、按配置选择后端工厂。
|
||||
禁止:Redis 全局 Worker 单例锁;禁止在回调线程里等 LLM。
|
||||
为何先用内存:先打通「收消息→回复」联调;落 PG 迁移不挡本迭代验收。
|
||||
有 PG_DSN 且 MESSAGE_STORE_BACKEND=postgres|auto 时用表;否则内存(本机联调)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,7 +15,7 @@ import uuid
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
|
||||
@@ -207,6 +207,7 @@ class MemoryMessageStore:
|
||||
def stats(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"backend": "memory",
|
||||
"inbox": len(self._inbox),
|
||||
"outbox": len(self._outbox),
|
||||
"seen_msg": len(self._seen_msg),
|
||||
@@ -218,15 +219,55 @@ class MemoryMessageStore:
|
||||
store.popitem(last=False)
|
||||
|
||||
|
||||
# 进程内单例:HTTP 回调与消费线程共享(同进程)。多进程部署时必须换 PG。
|
||||
_STORE: Optional[MemoryMessageStore] = None
|
||||
MessageStoreImpl = Union[MemoryMessageStore, Any]
|
||||
|
||||
_STORE: Optional[MessageStoreImpl] = None
|
||||
_STORE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def get_message_store() -> MemoryMessageStore:
|
||||
"""获取进程内消息账本单例。"""
|
||||
def get_message_store() -> MessageStoreImpl:
|
||||
"""
|
||||
获取消息账本单例。
|
||||
|
||||
MESSAGE_STORE_BACKEND=postgres 且 PG 可用 → PostgresMessageStore;
|
||||
auto 有 DSN 则尝试 PG,失败回退内存;memory 强制内存。
|
||||
"""
|
||||
global _STORE
|
||||
with _STORE_LOCK:
|
||||
if _STORE is None:
|
||||
_STORE = MemoryMessageStore()
|
||||
_STORE = _create_store()
|
||||
return _STORE
|
||||
|
||||
|
||||
def reset_message_store_for_tests() -> None:
|
||||
"""仅自检用:清空单例。"""
|
||||
global _STORE
|
||||
with _STORE_LOCK:
|
||||
_STORE = None
|
||||
|
||||
|
||||
def _create_store() -> MessageStoreImpl:
|
||||
from agent.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
backend = (getattr(settings, "message_store_backend", None) or "auto").strip().lower()
|
||||
dsn = settings.resolve_pg_dsn()
|
||||
|
||||
use_pg = backend == "postgres" or (backend == "auto" and bool(dsn))
|
||||
if use_pg and dsn:
|
||||
try:
|
||||
from agent.channel.pg_store import PostgresMessageStore
|
||||
from agent.runtime_db import assert_safe_database
|
||||
|
||||
assert_safe_database(dsn)
|
||||
store = PostgresMessageStore(dsn)
|
||||
store.stats()
|
||||
logger.info("消息账本后端=postgres")
|
||||
return store
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if backend == "postgres":
|
||||
raise
|
||||
logger.warning("PG 消息账本不可用,回退内存:%s", exc)
|
||||
|
||||
logger.info("消息账本后端=memory")
|
||||
return MemoryMessageStore()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
通道消费循环:inbox → echo handler;outbox → 企微发送。
|
||||
通道消费循环:inbox → RouteDecision → dispatch → handler;outbox → 企微发送。
|
||||
|
||||
本文件职责:在 HTTP 进程内独立线程跑快路径,不与 LibreOffice 串队。
|
||||
禁止:在回调请求线程调用本循环的阻塞逻辑;禁止 Redis 全局单例锁。
|
||||
@@ -12,9 +12,10 @@ import threading
|
||||
from typing import Optional
|
||||
|
||||
from agent.channel.outbox.sender import PERMANENT_SEND_ERRORS, WeComAppClient
|
||||
from agent.channel.queue import MemoryMessageStore, get_message_store
|
||||
from agent.channel.queue import get_message_store
|
||||
from agent.config import Settings
|
||||
from agent.handlers.echo_text import handle_echo_text
|
||||
from agent.routing import IntentRouter
|
||||
from agent.routing.dispatch import dispatch_inbound
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# 避免 httpx 把 access_token / secret 打进 INFO
|
||||
@@ -26,6 +27,7 @@ class ChannelRuntime:
|
||||
企微通道运行时:inbox/outbox 各一条守护线程。
|
||||
|
||||
启动时机:FastAPI lifespan;停止:进程退出。
|
||||
inbox:IntentRouter.route(默认 stub)→ dispatch 查表;无业务分支森林。
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
@@ -33,6 +35,7 @@ class ChannelRuntime:
|
||||
self._store = get_message_store()
|
||||
self._stop = threading.Event()
|
||||
self._threads: list[threading.Thread] = []
|
||||
self._router = IntentRouter(allow_network=False)
|
||||
agent_id = int(settings.wecom_agent_id or "0") or 0
|
||||
self._client = WeComAppClient(
|
||||
corp_id=settings.wecom_corp_id,
|
||||
@@ -41,7 +44,7 @@ class ChannelRuntime:
|
||||
)
|
||||
|
||||
@property
|
||||
def store(self) -> MemoryMessageStore:
|
||||
def store(self):
|
||||
return self._store
|
||||
|
||||
def start(self) -> None:
|
||||
@@ -53,7 +56,7 @@ class ChannelRuntime:
|
||||
]
|
||||
for t in self._threads:
|
||||
t.start()
|
||||
logger.info("ChannelRuntime 已启动(inbox/outbox 独立线程)")
|
||||
logger.info("ChannelRuntime 已启动(inbox/outbox 独立线程;dispatch 分发)")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
@@ -65,12 +68,11 @@ class ChannelRuntime:
|
||||
self._store.wait_inbox(1.0)
|
||||
continue
|
||||
try:
|
||||
handle_echo_text(
|
||||
item.message,
|
||||
self._store,
|
||||
ytd_env=self._settings.ytd_env,
|
||||
reply_template=self._settings.wecom_callback_reply_text,
|
||||
decision = self._router.route(
|
||||
text=item.message.content or "",
|
||||
sender_id=item.message.sender_id or "",
|
||||
)
|
||||
dispatch_inbound(item.message, decision)
|
||||
self._store.complete_inbound(item.id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("inbox 处理失败 id=%s", item.id)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
消息账本协议:inbox / outbox 认领接口。
|
||||
|
||||
Memory 与 Postgres 实现共用此协议,通道运行时不依赖具体后端。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from agent.channel.queue import InboxItem, OutboxItem
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
|
||||
|
||||
class MessageStore(Protocol):
|
||||
"""inbox/outbox 账本协议。"""
|
||||
|
||||
def enqueue_inbound(self, message: InboundMessage) -> tuple[bool, str]: ...
|
||||
|
||||
def claim_inbound(self) -> Optional[InboxItem]: ...
|
||||
|
||||
def complete_inbound(self, item_id: str, *, error: str = "") -> None: ...
|
||||
|
||||
def enqueue_outbound(
|
||||
self,
|
||||
*,
|
||||
touser: str,
|
||||
content: str,
|
||||
dedupe_key: str = "",
|
||||
) -> tuple[bool, str]: ...
|
||||
|
||||
def claim_outbound(self) -> Optional[OutboxItem]: ...
|
||||
|
||||
def complete_outbound(
|
||||
self, item_id: str, *, error: str = "", permanent: bool = False
|
||||
) -> None: ...
|
||||
|
||||
def wait_inbox(self, timeout: float = 1.0) -> None: ...
|
||||
|
||||
def wait_outbox(self, timeout: float = 1.0) -> None: ...
|
||||
|
||||
def stats(self) -> dict[str, Any]: ...
|
||||
@@ -35,3 +35,34 @@ class InboundMessage:
|
||||
def has_sender(self) -> bool:
|
||||
"""是否具备可进入业务的发送人身份。"""
|
||||
return bool(self.sender_id and self.sender_id.strip())
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""序列化进 PG JSONB;不含密钥。"""
|
||||
return {
|
||||
"sender_id": self.sender_id,
|
||||
"message_id": self.message_id,
|
||||
"content": self.content,
|
||||
"msg_type": self.msg_type,
|
||||
"chat_id": self.chat_id,
|
||||
"chat_type": self.chat_type,
|
||||
"agent_id": self.agent_id,
|
||||
"create_time": self.create_time,
|
||||
"media": self.media,
|
||||
"raw": self.raw,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "InboundMessage":
|
||||
"""从 JSONB / dict 还原。"""
|
||||
return cls(
|
||||
sender_id=str(data.get("sender_id") or ""),
|
||||
message_id=str(data.get("message_id") or ""),
|
||||
content=str(data.get("content") or ""),
|
||||
msg_type=str(data.get("msg_type") or "text"),
|
||||
chat_id=str(data.get("chat_id") or ""),
|
||||
chat_type=str(data.get("chat_type") or "single"),
|
||||
agent_id=str(data.get("agent_id") or ""),
|
||||
create_time=int(data.get("create_time") or 0),
|
||||
media=dict(data.get("media") or {}),
|
||||
raw=dict(data.get("raw") or {}),
|
||||
)
|
||||
|
||||
@@ -41,8 +41,14 @@ class Settings(BaseSettings):
|
||||
ytd_env: str = Field(default="test", alias="YTD_ENV")
|
||||
agent_http_port: int = Field(default=8910, alias="AGENT_HTTP_PORT")
|
||||
public_base_url: str = Field(default="", alias="PUBLIC_BASE_URL")
|
||||
biz_api_base_url: str = Field(default="http://127.0.0.1:8180", alias="BIZ_API_BASE_URL")
|
||||
biz_api_base_url: str = Field(default="http://127.0.0.1:8180/jeecgboot", alias="BIZ_API_BASE_URL")
|
||||
biz_api_token: str = Field(default="", alias="BIZ_API_TOKEN")
|
||||
biz_api_username: str = Field(default="", alias="BIZ_API_USERNAME")
|
||||
biz_api_password: str = Field(default="", alias="BIZ_API_PASSWORD")
|
||||
biz_api_timeout_seconds: float = Field(default=10.0, alias="BIZ_API_TIMEOUT_SECONDS")
|
||||
agent_callback_token: str = Field(default="", alias="AGENT_CALLBACK_TOKEN")
|
||||
# 兼容现网变量名
|
||||
inquiry_agent_callback_token: str = Field(default="", alias="INQUIRY_AGENT_CALLBACK_TOKEN")
|
||||
|
||||
# 企微询价应用(与正式同一套应用 ID;密钥只从环境读)
|
||||
wecom_corp_id: str = Field(default="", alias="WECOM_CORP_ID")
|
||||
@@ -62,12 +68,78 @@ class Settings(BaseSettings):
|
||||
pg_database: str = Field(default="", alias="PG_DATABASE")
|
||||
pg_user: str = Field(default="", alias="PG_USER")
|
||||
pg_password: str = Field(default="", alias="PG_PASSWORD")
|
||||
pg_sslmode: str = Field(default="prefer", alias="PG_SSLMODE")
|
||||
# 可直接给完整 DSN;为空则由 host/port/user 等拼装
|
||||
pg_dsn: str = Field(default="", alias="PG_DSN")
|
||||
# postgres | memory;正式必须 postgres;memory 仅本地骨架冒烟
|
||||
checkpoint_backend: str = Field(default="postgres", alias="CHECKPOINT_BACKEND")
|
||||
|
||||
redis_url: str = Field(default="", alias="REDIS_URL")
|
||||
redis_key_prefix: str = Field(default="inquiry_robot:", alias="REDIS_KEY_PREFIX")
|
||||
# redis | memory;正式必须 redis + URL 指向逻辑库 /1(与现网 /0 隔离)
|
||||
redis_backend: str = Field(default="redis", alias="REDIS_BACKEND")
|
||||
redis_required: bool = Field(default=False, alias="REDIS_REQUIRED")
|
||||
# auto | memory | postgres;auto=有 PG_DSN 则尝试 PG
|
||||
message_store_backend: str = Field(default="auto", alias="MESSAGE_STORE_BACKEND")
|
||||
wecom_aibot_bridge_token: str = Field(default="", alias="WECOM_AIBOT_BRIDGE_TOKEN")
|
||||
wecom_aibot_ingress_url: str = Field(
|
||||
default="http://127.0.0.1:8910/internal/aibot/inbound",
|
||||
alias="WECOM_AIBOT_INGRESS_URL",
|
||||
)
|
||||
|
||||
deepseek_api_key: str = Field(default="", alias="DEEPSEEK_API_KEY")
|
||||
deepseek_base_url: str = Field(default="https://api.deepseek.com", alias="DEEPSEEK_BASE_URL")
|
||||
deepseek_model: str = Field(default="", alias="DEEPSEEK_MODEL")
|
||||
qwen_api_key: str = Field(default="", alias="QWEN_API_KEY")
|
||||
qwen_base_url: str = Field(default="", alias="QWEN_BASE_URL")
|
||||
qwen_text_model: str = Field(default="", alias="QWEN_TEXT_MODEL")
|
||||
qwen_vl_model: str = Field(default="", alias="QWEN_VL_MODEL")
|
||||
# prompt/04:未确认不得打真实模型(出站 HTTP 客户端强制检查)
|
||||
llm_data_usage_confirmed: bool = Field(default=False, alias="LLM_DATA_USAGE_CONFIRMED")
|
||||
# Worker 槽是否允许对 ordered_actions 走真网(仍受上一开关约束)
|
||||
llm_allow_network: bool = Field(default=False, alias="LLM_ALLOW_NETWORK")
|
||||
|
||||
# LibreOffice:默认 Windows 安装路径;Linux 可改 /usr/bin/libreoffice
|
||||
libreoffice_path: str = Field(
|
||||
default=r"C:\Program Files\LibreOffice\program\soffice.exe",
|
||||
alias="LIBREOFFICE_PATH",
|
||||
)
|
||||
libreoffice_timeout_seconds: float = Field(
|
||||
default=120.0, alias="LIBREOFFICE_TIMEOUT_SECONDS"
|
||||
)
|
||||
|
||||
object_storage_endpoint_url: str = Field(default="", alias="OBJECT_STORAGE_ENDPOINT_URL")
|
||||
object_storage_bucket: str = Field(default="inquiry-robot", alias="OBJECT_STORAGE_BUCKET")
|
||||
object_storage_access_key: str = Field(default="", alias="OBJECT_STORAGE_ACCESS_KEY")
|
||||
object_storage_secret_key: str = Field(default="", alias="OBJECT_STORAGE_SECRET_KEY")
|
||||
object_storage_path_style: bool = Field(default=True, alias="OBJECT_STORAGE_PATH_STYLE")
|
||||
|
||||
libreoffice_slots: int = Field(default=1, alias="LIBREOFFICE_SLOTS")
|
||||
http_job_slots: int = Field(default=3, alias="HTTP_JOB_SLOTS")
|
||||
|
||||
def resolve_pg_dsn(self) -> str:
|
||||
"""
|
||||
解析 PostgreSQL 连接串(运行账 / Graph checkpoint)。
|
||||
|
||||
返回值:PG_DSN 或由分量拼出的 URI;缺关键字段时返回空串。
|
||||
禁止:连现网 ytd_runtime(由部署/环境保证库名,代码不写死切换)。
|
||||
"""
|
||||
raw = (self.pg_dsn or "").strip()
|
||||
if raw:
|
||||
return raw
|
||||
if not (self.pg_host and self.pg_database and self.pg_user):
|
||||
return ""
|
||||
# 密码可为空(本机信任);sslmode 走环境
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
user = quote_plus(self.pg_user)
|
||||
password = quote_plus(self.pg_password or "")
|
||||
auth = f"{user}:{password}@" if self.pg_password else f"{user}@"
|
||||
return (
|
||||
f"postgresql://{auth}{self.pg_host}:{self.pg_port}/"
|
||||
f"{self.pg_database}?sslmode={self.pg_sslmode or 'prefer'}"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""
|
||||
基础框架联合自检(不连真实 PG/外网模型也可跑)。
|
||||
|
||||
覆盖:迁移、正式合同、ledger、llm、DeepSeek A 解析、LibreOffice 探测、
|
||||
识别/存档入队、routing、storage。
|
||||
用法:
|
||||
set REDIS_BACKEND=memory
|
||||
set CHECKPOINT_BACKEND=memory
|
||||
python -m agent.foundation_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")
|
||||
os.environ["REDIS_BACKEND"] = "memory"
|
||||
os.environ.setdefault("YTD_ENV", "test")
|
||||
os.environ.setdefault("CHECKPOINT_BACKEND", "memory")
|
||||
os.environ.setdefault("REDIS_KEY_PREFIX", "inquiry_robot:")
|
||||
os.environ["LLM_DATA_USAGE_CONFIRMED"] = "false"
|
||||
os.environ["LLM_ALLOW_NETWORK"] = "false"
|
||||
|
||||
from agent.config import get_settings
|
||||
from agent.jobs.archive import enqueue_archive
|
||||
from agent.jobs.libreoffice import LibreOfficeConverter
|
||||
from agent.jobs.recognition import enqueue_recognition
|
||||
from agent.ledger import LedgerClient
|
||||
from agent.llm import LlmGateway, LlmMode
|
||||
from agent.redis_coord import shutdown_redis_runtime
|
||||
from agent.routing import IntentRouter, ordered_actions_tool_definition, parse_ordered_actions_arguments
|
||||
from agent.runtime_db import assert_safe_database, list_migration_files
|
||||
from agent.schema import load_contract, require_formal_contract
|
||||
from agent.storage import ObjectStorageClient
|
||||
|
||||
get_settings.cache_clear()
|
||||
settings = get_settings()
|
||||
|
||||
files = list_migration_files()
|
||||
assert "001_runtime_core_v1.sql" in files, files
|
||||
assert "002_dead_letter_v1.sql" in files, files
|
||||
logger.info("OK: migrations %s", files)
|
||||
|
||||
try:
|
||||
assert_safe_database("postgresql://u:p@127.0.0.1:5432/ytd_runtime")
|
||||
logger.error("应拒绝 ytd_runtime")
|
||||
return 1
|
||||
except RuntimeError:
|
||||
logger.info("OK: 拒绝现网库名 ytd_runtime")
|
||||
|
||||
contract = load_contract("inquiry-required-fields-v1")
|
||||
assert contract["_meta"]["is_placeholder"] is False, contract["_meta"]
|
||||
formal = require_formal_contract("inquiry-required-fields-v1")
|
||||
assert formal.get("contract_id") or formal.get("schema_version") is not None
|
||||
tms = require_formal_contract("tms-dictionary-projection-v2")
|
||||
assert tms["_meta"]["is_placeholder"] is False
|
||||
logger.info("OK: 正式合同已加载 fields+tms")
|
||||
|
||||
tool = ordered_actions_tool_definition()
|
||||
assert tool["function"]["name"] == "route_decision"
|
||||
digest = "a" * 64
|
||||
parsed = parse_ordered_actions_arguments(
|
||||
{
|
||||
"decision_id": "D-1",
|
||||
"intent": "ordinary_text_inquiry",
|
||||
"ordered_action": {
|
||||
"action_type": "ORDINARY_TEXT_INQUIRY",
|
||||
"order": 1,
|
||||
"target": {"work_order_no": ""},
|
||||
"evidence": {"source_ref": "inbound:u1", "content_digest": digest},
|
||||
},
|
||||
}
|
||||
)
|
||||
assert parsed.intent == "ordinary_text_inquiry"
|
||||
logger.info("OK: ordered_actions 解析")
|
||||
|
||||
ledger = LedgerClient.from_settings(settings)
|
||||
assert "8180" in ledger.base_url or ledger.base_url
|
||||
logger.info("OK: ledger client base=%s", ledger.base_url)
|
||||
ping = ledger.ping()
|
||||
logger.info("ledger.ping ok=%s status=%s", ping.ok, ping.status_code)
|
||||
ledger.close()
|
||||
|
||||
gw = LlmGateway.from_settings(settings)
|
||||
enq = gw.enqueue(
|
||||
mode=LlmMode.ORDERED_ACTIONS,
|
||||
payload={"text": "hi"},
|
||||
idempotency_key="foundation-1",
|
||||
)
|
||||
assert enq.accepted, enq
|
||||
stub = gw.invoke_stub(mode=LlmMode.EXTRACT_FIELDS, payload={"a": 1})
|
||||
assert stub.get("stub") is True
|
||||
assert stub.get("error") == "extract_fields_shell_not_implemented"
|
||||
from agent.llm import list_mode_shells
|
||||
|
||||
assert "vision" in list_mode_shells()
|
||||
logger.info("OK: llm enqueue/mode shells")
|
||||
|
||||
decision = IntentRouter().route_stub(text="询价", sender_id="u1")
|
||||
assert decision.intent == "ordinary_text_inquiry", decision.intent
|
||||
assert IntentRouter().route(text="询价", sender_id="").intent == "reject_no_sender"
|
||||
logger.info("OK: DeepSeek A stub 路由")
|
||||
|
||||
from agent.channel.clarification import issue_token, validate_token
|
||||
from agent.channel.queue import reset_message_store_for_tests
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
|
||||
reset_message_store_for_tests()
|
||||
os.environ["MESSAGE_STORE_BACKEND"] = "memory"
|
||||
get_settings.cache_clear()
|
||||
reset_message_store_for_tests()
|
||||
from agent.channel.queue import get_message_store
|
||||
|
||||
store = get_message_store()
|
||||
assert store.stats().get("backend") == "memory"
|
||||
msg = InboundMessage(sender_id="u1", message_id="m-foundation-1", content="hello")
|
||||
ok, iid = store.enqueue_inbound(msg)
|
||||
assert ok, iid
|
||||
claimed = store.claim_inbound()
|
||||
assert claimed is not None
|
||||
store.complete_inbound(claimed.id)
|
||||
logger.info("OK: memory inbox claim")
|
||||
|
||||
tok = issue_token(
|
||||
thread_id="t1",
|
||||
wait_version=1,
|
||||
sender_id="u1",
|
||||
allowed_actions=["submit_clarify"],
|
||||
)
|
||||
rec = validate_token(tok, action="submit_clarify", consume=True)
|
||||
assert rec is not None and rec.thread_id == "t1"
|
||||
assert validate_token(tok) is None
|
||||
logger.info("OK: clarification token")
|
||||
|
||||
from agent.graph import GraphRuntime, shutdown_graph_runtime
|
||||
from agent.graph.wake import WorkerWakePayload
|
||||
from agent.jobs.wake_consumer import claim_and_apply_wake, enqueue_wake, wake_stream
|
||||
from agent.llm import LlmHttpClient
|
||||
from agent.redis_coord import get_redis_runtime
|
||||
|
||||
os.environ["CHECKPOINT_BACKEND"] = "memory"
|
||||
get_settings.cache_clear()
|
||||
gr = GraphRuntime.create(get_settings())
|
||||
started = gr.start_thread(sender_id="u1")
|
||||
tid = started["thread_id"]
|
||||
rr = get_redis_runtime(get_settings())
|
||||
entry = enqueue_wake(
|
||||
rr.client,
|
||||
WorkerWakePayload(
|
||||
thread_id=tid,
|
||||
inquiry_no="F-1",
|
||||
quote_version=1,
|
||||
wait_version=0,
|
||||
kind="recognition_done",
|
||||
),
|
||||
)
|
||||
assert entry
|
||||
q = wake_stream(rr.client)
|
||||
assert claim_and_apply_wake(queue=q, graph=gr, consumer="selftest")
|
||||
logger.info("OK: wake enqueue/apply")
|
||||
|
||||
http_llm = LlmHttpClient.from_settings(get_settings())
|
||||
no_net = http_llm.chat(mode="ordered_actions", messages=[{"role": "user", "content": "hi"}])
|
||||
assert no_net.ok is False and no_net.error == "llm_data_usage_not_confirmed"
|
||||
logger.info("OK: llm http 拒绝未确认数据用途")
|
||||
|
||||
recog = enqueue_recognition(
|
||||
kind="attachment_text",
|
||||
payload={"object_key": "stub"},
|
||||
idempotency_key="recog-1",
|
||||
redis=rr,
|
||||
)
|
||||
assert recog.accepted, recog
|
||||
arch = enqueue_archive(payload={"msg_id": "m1"}, idempotency_key="arch-1", redis=rr)
|
||||
assert arch.accepted, arch
|
||||
logger.info("OK: recognition/archive enqueue")
|
||||
|
||||
lo = LibreOfficeConverter.from_settings(get_settings())
|
||||
logger.info("OK: LibreOffice available=%s path=%s", lo.available(), lo.executable())
|
||||
|
||||
gr.close()
|
||||
shutdown_graph_runtime()
|
||||
|
||||
storage = ObjectStorageClient.from_settings(settings)
|
||||
put_ok = storage.put_bytes_stub(key="x", data=b"1")
|
||||
assert put_ok is storage.configured()
|
||||
logger.info("OK: storage stub configured=%s put_ok=%s", storage.configured(), put_ok)
|
||||
|
||||
from agent.jobs.dead_letter import enqueue_dead_letter
|
||||
from agent.jobs.pg_job import create_job_store
|
||||
from agent.ledger.api_shell import LedgerApiShell
|
||||
from agent.policy import assert_has_sender_id, policy_package_ready
|
||||
from agent.quote_templates import list_template_registry
|
||||
from agent.routing.dispatch import HANDLER_REGISTRY, dispatch_inbound
|
||||
from agent.runtime_db import SessionRecord, create_session_store, create_suspend_store
|
||||
from agent.runtime_db.suspend_store import SuspendRecord
|
||||
from agent.schema import validate_required_fields_shell
|
||||
from agent.storage.s3_client import S3ObjectStorage
|
||||
|
||||
assert policy_package_ready()
|
||||
assert assert_has_sender_id("u1")["ok"] is True
|
||||
assert assert_has_sender_id("")["ok"] is False
|
||||
|
||||
sess = create_session_store(backend="memory")
|
||||
sess.upsert(SessionRecord(thread_id="t-shell", sender_id="u1"))
|
||||
assert sess.get("t-shell") is not None
|
||||
|
||||
sus = create_suspend_store(backend="memory")
|
||||
sus.open_wait(SuspendRecord(thread_id="t-shell", wait_version=1, pause_name="pause_parse_clarify"))
|
||||
assert sus.get("t-shell", 1) is not None
|
||||
assert sus.close_wait("t-shell", 1) is True
|
||||
|
||||
jobs = create_job_store(backend="memory")
|
||||
ok_j, jid = jobs.enqueue(job_type="shell", payload={"x": 1}, idempotency_key="j1")
|
||||
assert ok_j, jid
|
||||
claimed_j = jobs.claim(worker_id="selftest")
|
||||
assert claimed_j is not None
|
||||
jobs.complete(claimed_j.id)
|
||||
|
||||
dl = enqueue_dead_letter(source="selftest", error="demo", job_type="shell")
|
||||
assert dl
|
||||
|
||||
assert "ordinary_text_inquiry" in HANDLER_REGISTRY
|
||||
d2 = IntentRouter().route_stub(text="x", sender_id="u1")
|
||||
msg2 = InboundMessage(sender_id="u1", message_id="m-dispatch", content="x")
|
||||
# dispatch 可能写 outbox(echo);仅验证查表调用不抛
|
||||
dispatch_inbound(msg2, d2)
|
||||
|
||||
shell_api = LedgerApiShell(LedgerClient.from_settings(get_settings()))
|
||||
assert shell_api.create_ticket(payload={}, idempotency_key="x").status_code == 501
|
||||
|
||||
from agent.quote_templates import list_templates_from_ledger
|
||||
|
||||
lt = list_templates_from_ledger(LedgerClient.from_settings(get_settings()))
|
||||
assert lt.status_code == 501
|
||||
assert lt.error and "list_quote_templates" in lt.error
|
||||
|
||||
vf = validate_required_fields_shell(facts={}, require_formal=True)
|
||||
assert vf.get("implemented") is False
|
||||
|
||||
_ = list_template_registry()
|
||||
s3 = S3ObjectStorage.from_settings(get_settings(), allow_network=False)
|
||||
put_s3 = s3.put_bytes(key="k", data=b"1")
|
||||
assert put_s3.get("dry_run") is True or put_s3.get("error") in {
|
||||
"not_configured",
|
||||
"boto3_not_installed",
|
||||
}
|
||||
logger.info("OK: session/suspend/job/dead_letter/dispatch/ledger/schema/s3 shells")
|
||||
|
||||
shutdown_redis_runtime()
|
||||
logger.info("基础框架联合自检通过")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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 {},
|
||||
},
|
||||
}
|
||||
@@ -2,5 +2,9 @@
|
||||
Handler 包:一类意图 / 一类卡片一个模块。
|
||||
|
||||
禁止:单文件处理全部卡片;禁止把 Schema 校验复制粘贴进每个 handler(放 schema/policy)。
|
||||
骨架:仅包占位。
|
||||
当前:echo 联调 + 其余意图空壳(待接 RouteDecision)。
|
||||
"""
|
||||
|
||||
from agent.handlers.echo_text import handle_echo_text
|
||||
|
||||
__all__ = ["handle_echo_text"]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
意图:异常上报(骨架)。
|
||||
|
||||
正式:写主账异常时间线;不在此直连 TMS。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handle_abnormal(*, sender_id: str, inquiry_no: str, reason: str = "") -> None:
|
||||
"""骨架:仅记日志。"""
|
||||
if not sender_id:
|
||||
return
|
||||
logger.info(
|
||||
"handler.abnormal stub sender=%s inquiry=%s reason=%s",
|
||||
sender_id,
|
||||
inquiry_no,
|
||||
reason or "-",
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
意图:模板卡 / 补问卡动作(骨架)。
|
||||
|
||||
正式:校验 clarification token 绑定的 waitVersion + 允许动作集合。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handle_card_action(
|
||||
*,
|
||||
sender_id: str,
|
||||
thread_id: str,
|
||||
wait_version: int,
|
||||
action: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""骨架:仅记日志;无 sender_id 直接返回。"""
|
||||
if not (sender_id or "").strip():
|
||||
return
|
||||
logger.info(
|
||||
"handler.card_action stub sender=%s thread=%s wait=%s action=%s",
|
||||
sender_id,
|
||||
thread_id,
|
||||
wait_version,
|
||||
action,
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
意图:成交 / 未成交(骨架)。
|
||||
|
||||
正式:调主账闭环 API,带幂等键;图成功 ≠ 主账成功。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handle_close_deal(
|
||||
*,
|
||||
sender_id: str,
|
||||
inquiry_no: str,
|
||||
outcome: str,
|
||||
) -> None:
|
||||
"""
|
||||
outcome:won | lost 等;骨架不写主账。
|
||||
"""
|
||||
if not sender_id or not inquiry_no:
|
||||
return
|
||||
logger.info(
|
||||
"handler.close_deal stub sender=%s inquiry=%s outcome=%s",
|
||||
sender_id,
|
||||
inquiry_no,
|
||||
outcome,
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
意图:继续本单 / Graph resume(骨架)。
|
||||
|
||||
正式:校验 thread_id + waitVersion 后 Worker 槽 resume;禁止在 HTTP 回调线程 invoke。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handle_continue_thread(message: InboundMessage, *, thread_id: str = "") -> None:
|
||||
"""骨架:仅记日志。"""
|
||||
if not message.has_sender():
|
||||
return
|
||||
logger.info(
|
||||
"handler.continue_thread stub sender=%s thread_id=%s",
|
||||
message.sender_id,
|
||||
thread_id or "-",
|
||||
)
|
||||
@@ -9,8 +9,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent.channel.queue import MemoryMessageStore
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -18,7 +18,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def handle_echo_text(
|
||||
message: InboundMessage,
|
||||
store: MemoryMessageStore,
|
||||
store: Any,
|
||||
*,
|
||||
ytd_env: str,
|
||||
reply_template: str,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
意图:新开询价(骨架)。
|
||||
|
||||
正式:校验字段合同 → 主账建单准备 → Graph start_thread。
|
||||
本文件不调模型、不写六态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handle_new_inquiry(message: InboundMessage) -> None:
|
||||
"""骨架:仅记日志。须已有 sender_id。"""
|
||||
if not message.has_sender():
|
||||
logger.warning("new_inquiry 拒绝:无 sender_id")
|
||||
return
|
||||
logger.info(
|
||||
"handler.new_inquiry stub sender=%s msg_id=%s",
|
||||
message.sender_id,
|
||||
message.message_id,
|
||||
)
|
||||
@@ -13,6 +13,7 @@ from contextlib import asynccontextmanager
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
|
||||
from agent.channel.aibot import router as aibot_router
|
||||
from agent.channel.bridge import router as bridge_router
|
||||
from agent.channel.h5 import router as h5_router
|
||||
from agent.channel.runtime import start_channel_runtime
|
||||
@@ -71,6 +72,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(wecom_router)
|
||||
app.include_router(h5_router)
|
||||
app.include_router(bridge_router)
|
||||
app.include_router(aibot_router)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
任务槽与认领循环:识别、LibreOffice、Archive 等。
|
||||
任务槽与认领循环:识别、LibreOffice、Archive、wake、LLM Stream。
|
||||
|
||||
本包约束(硬):LibreOffice 仅 1 槽;HTTP 类 2~3 槽;inbox/outbox 不得与 soffice 串同一阻塞线。
|
||||
禁止:一把大锁串行所有工单;禁止 wecom:worker:singleton。
|
||||
@@ -11,7 +11,18 @@ import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
from typing import Callable, Optional
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
from agent.graph.runtime import GraphRuntime
|
||||
from agent.jobs.archive import archive_stream, process_archive_stub
|
||||
from agent.jobs.libreoffice import LibreOfficeConverter
|
||||
from agent.jobs.pdf_job import pdf_stream, process_pdf_job
|
||||
from agent.jobs.recognition import process_recognition_stub
|
||||
from agent.jobs.wake_consumer import claim_and_apply_wake, wake_stream
|
||||
from agent.llm import LlmGateway, LlmMode
|
||||
from agent.redis_coord.runtime import RedisRuntime
|
||||
from agent.routing import DeepSeekARouter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,7 +33,6 @@ class SlotPool:
|
||||
进程内有限槽线程池骨架。
|
||||
|
||||
用途:限制同类慢活并发(尤其 LibreOffice=1)。
|
||||
为何不用进程级 Redis 单例:同机只应起一份 Worker 服务;防重入靠任务认领。
|
||||
"""
|
||||
|
||||
name: str
|
||||
@@ -35,11 +45,7 @@ class SlotPool:
|
||||
self._sem = threading.BoundedSemaphore(self.size)
|
||||
|
||||
def run(self, fn: Callable[[], None], *, label: str = "") -> None:
|
||||
"""
|
||||
占用一个槽执行 fn;槽满则阻塞等待(调用方应在 Worker 线程,勿在 HTTP 回调线程调用)。
|
||||
|
||||
副作用:执行 fn;不写库(骨架)。
|
||||
"""
|
||||
"""占用一个槽执行 fn;勿在 HTTP 回调线程调用。"""
|
||||
acquired = self._sem.acquire(timeout=30)
|
||||
if not acquired:
|
||||
logger.warning("槽超时未获取 name=%s label=%s", self.name, label)
|
||||
@@ -53,20 +59,33 @@ class SlotPool:
|
||||
@dataclass
|
||||
class WorkerRuntime:
|
||||
"""
|
||||
Worker 运行时:持有分槽与 inbox/outbox 占位线程。
|
||||
Worker 运行时:分槽 + wake / LLM / 识别 / 存档 / LibreOffice 循环。
|
||||
|
||||
启动时机:worker_app main;停止:进程退出或 stop()。
|
||||
禁止:把所有任务丢进 LibreOffice 槽。
|
||||
启动时机:worker_app main。
|
||||
"""
|
||||
|
||||
libreoffice: SlotPool
|
||||
http_jobs: SlotPool
|
||||
settings: Settings
|
||||
graph: Optional[GraphRuntime] = None
|
||||
redis: Optional[RedisRuntime] = None
|
||||
llm: Optional[LlmGateway] = None
|
||||
_stop: threading.Event = field(default_factory=threading.Event)
|
||||
_threads: list[threading.Thread] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, libreoffice_slots: int, http_job_slots: int) -> "WorkerRuntime":
|
||||
"""按配置创建槽;强制 LibreOffice 槽为 1,防止误配双 soffice。"""
|
||||
def from_settings(
|
||||
cls,
|
||||
libreoffice_slots: int,
|
||||
http_job_slots: int,
|
||||
*,
|
||||
graph: Optional[GraphRuntime] = None,
|
||||
redis: Optional[RedisRuntime] = None,
|
||||
llm: Optional[LlmGateway] = None,
|
||||
settings: Optional[Settings] = None,
|
||||
) -> "WorkerRuntime":
|
||||
"""按配置创建槽;强制 LibreOffice 槽为 1。"""
|
||||
cfg = settings or get_settings()
|
||||
lo = 1 if libreoffice_slots != 1 else libreoffice_slots
|
||||
if libreoffice_slots != 1:
|
||||
logger.warning(
|
||||
@@ -76,47 +95,178 @@ class WorkerRuntime:
|
||||
return cls(
|
||||
libreoffice=SlotPool("libreoffice", lo),
|
||||
http_jobs=SlotPool("http_jobs", max(2, http_job_slots)),
|
||||
settings=cfg,
|
||||
graph=graph,
|
||||
redis=redis,
|
||||
llm=llm,
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
启动 inbox / outbox / 空转巡检线程(骨架:只心跳日志,不消费真实队列)。
|
||||
|
||||
副作用:创建守护线程;不连 Redis(骨架)。
|
||||
"""
|
||||
"""启动分槽消费循环。"""
|
||||
self._stop.clear()
|
||||
self._threads = [
|
||||
threading.Thread(target=self._inbox_loop, name="inbox-loop", daemon=True),
|
||||
threading.Thread(target=self._outbox_loop, name="outbox-loop", daemon=True),
|
||||
threading.Thread(target=self._http_slot_idle, name="http-slots-idle", daemon=True),
|
||||
threading.Thread(target=self._wake_loop, name="wake-loop", daemon=True),
|
||||
threading.Thread(target=self._llm_loop, name="llm-loop", daemon=True),
|
||||
threading.Thread(target=self._recognition_loop, name="recog-loop", daemon=True),
|
||||
threading.Thread(target=self._archive_loop, name="archive-loop", daemon=True),
|
||||
threading.Thread(target=self._pdf_loop, name="pdf-loop", daemon=True),
|
||||
]
|
||||
for t in self._threads:
|
||||
t.start()
|
||||
lo = LibreOfficeConverter.from_settings(self.settings)
|
||||
logger.info(
|
||||
"WorkerRuntime 已启动 libreoffice_slots=%s http_job_slots=%s",
|
||||
"WorkerRuntime 已启动 libreoffice_slots=%s http_job_slots=%s soffice=%s",
|
||||
self.libreoffice.size,
|
||||
self.http_jobs.size,
|
||||
"ok" if lo.available() else "missing",
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""请求停止占位循环;不保证立刻打断正在跑的槽任务(骨架无真实任务)。"""
|
||||
self._stop.set()
|
||||
|
||||
def _inbox_loop(self) -> None:
|
||||
"""inbox 独立线程:后续从 PG/队列认领入站;禁止与 soffice 共用阻塞队列。"""
|
||||
def _wake_loop(self) -> None:
|
||||
"""HTTP 类槽消费结构化 wake → Graph。"""
|
||||
if not self.redis or not self.graph:
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
return
|
||||
q = wake_stream(self.redis.client)
|
||||
consumer = f"wake-{threading.get_ident()}"
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
|
||||
def _outbox_loop(self) -> None:
|
||||
"""outbox 独立线程:后续发送企微;失败重试;过期丢弃不计业务成功。"""
|
||||
def _job() -> None:
|
||||
claim_and_apply_wake(queue=q, graph=self.graph, consumer=consumer)
|
||||
|
||||
self.http_jobs.run(_job, label="wake")
|
||||
time.sleep(0.2)
|
||||
|
||||
def _llm_loop(self) -> None:
|
||||
"""
|
||||
HTTP 类槽消费 LLM Stream。
|
||||
|
||||
ordered_actions:走 DeepSeekARouter(默认 stub;LLM_ALLOW_NETWORK 才可能真网)。
|
||||
其它 mode:骨架 stub。
|
||||
"""
|
||||
if not self.redis or not self.llm:
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
return
|
||||
stream = self.redis.llm_stream
|
||||
stream.ensure_group()
|
||||
consumer = f"llm-{threading.get_ident()}"
|
||||
router = DeepSeekARouter.from_settings(
|
||||
self.settings,
|
||||
allow_network=bool(self.settings.llm_allow_network),
|
||||
)
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
|
||||
def _http_slot_idle(self) -> None:
|
||||
"""证明 HTTP 类槽可并行占用;骨架不跑真实 LLM。"""
|
||||
def _job() -> None:
|
||||
claimed = stream.claim(consumer=consumer, count=1, block_ms=200)
|
||||
if not claimed:
|
||||
return
|
||||
entry_id, data = claimed[0]
|
||||
mode = str(data.get("mode") or "ordered_actions")
|
||||
payload = dict(data.get("payload") or {})
|
||||
try:
|
||||
if mode == LlmMode.ORDERED_ACTIONS.value:
|
||||
decision = router.route(
|
||||
text=str(payload.get("text") or ""),
|
||||
sender_id=str(payload.get("sender_id") or ""),
|
||||
)
|
||||
logger.info(
|
||||
"llm 槽 A 路由 id=%s intent=%s",
|
||||
entry_id,
|
||||
decision.intent,
|
||||
)
|
||||
else:
|
||||
result = self.llm.invoke_stub(mode=mode, payload=payload)
|
||||
logger.info(
|
||||
"llm 槽处理 id=%s mode=%s stub=%s",
|
||||
entry_id,
|
||||
mode,
|
||||
result.get("stub"),
|
||||
)
|
||||
finally:
|
||||
stream.ack(entry_id)
|
||||
|
||||
self.http_jobs.run(_job, label="llm")
|
||||
time.sleep(0.2)
|
||||
|
||||
def _recognition_loop(self) -> None:
|
||||
"""HTTP 类槽消费识别 Stream。"""
|
||||
if not self.redis:
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
return
|
||||
stream = self.redis.recognition_stream
|
||||
stream.ensure_group()
|
||||
consumer = f"recog-{threading.get_ident()}"
|
||||
while not self._stop.is_set():
|
||||
def _noop() -> None:
|
||||
time.sleep(0.05)
|
||||
|
||||
self.http_jobs.run(_noop, label="idle-ping")
|
||||
time.sleep(5.0)
|
||||
def _job() -> None:
|
||||
claimed = stream.claim(consumer=consumer, count=1, block_ms=200)
|
||||
if not claimed:
|
||||
return
|
||||
entry_id, data = claimed[0]
|
||||
try:
|
||||
process_recognition_stub(data)
|
||||
finally:
|
||||
stream.ack(entry_id)
|
||||
|
||||
self.http_jobs.run(_job, label="recognition")
|
||||
time.sleep(0.2)
|
||||
|
||||
def _archive_loop(self) -> None:
|
||||
"""HTTP 类槽消费存档 Stream(与 soffice 隔离)。"""
|
||||
if not self.redis:
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
return
|
||||
stream = archive_stream(self.redis)
|
||||
stream.ensure_group()
|
||||
consumer = f"arch-{threading.get_ident()}"
|
||||
while not self._stop.is_set():
|
||||
|
||||
def _job() -> None:
|
||||
claimed = stream.claim(consumer=consumer, count=1, block_ms=200)
|
||||
if not claimed:
|
||||
return
|
||||
entry_id, data = claimed[0]
|
||||
try:
|
||||
process_archive_stub(data)
|
||||
finally:
|
||||
stream.ack(entry_id)
|
||||
|
||||
self.http_jobs.run(_job, label="archive")
|
||||
time.sleep(0.2)
|
||||
|
||||
def _pdf_loop(self) -> None:
|
||||
"""仅 LibreOffice 单槽消费 PDF Stream。"""
|
||||
if not self.redis:
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
return
|
||||
stream = pdf_stream(self.redis)
|
||||
stream.ensure_group()
|
||||
converter = LibreOfficeConverter.from_settings(self.settings)
|
||||
consumer = f"pdf-{threading.get_ident()}"
|
||||
while not self._stop.is_set():
|
||||
|
||||
def _job() -> None:
|
||||
claimed = stream.claim(consumer=consumer, count=1, block_ms=200)
|
||||
if not claimed:
|
||||
return
|
||||
entry_id, data = claimed[0]
|
||||
try:
|
||||
result = process_pdf_job(data, converter=converter)
|
||||
logger.info(
|
||||
"pdf 槽 id=%s ok=%s err=%s",
|
||||
entry_id,
|
||||
result.ok,
|
||||
result.error or "-",
|
||||
)
|
||||
finally:
|
||||
stream.ack(entry_id)
|
||||
|
||||
self.libreoffice.run(_job, label="pdf")
|
||||
time.sleep(0.3)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
微盛存档任务壳:独立 Stream,与 LibreOffice 槽隔离。
|
||||
|
||||
本文件职责:enqueue / process_stub;正式下载与登记后续接对象存储 + 主账附件元数据。
|
||||
禁止:与 soffice 共用阻塞线;禁止把存档成功冒充业务成交成功。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.redis_coord import get_redis_runtime, try_acquire_idempotency
|
||||
from agent.redis_coord.keys import STREAM_ARCHIVE
|
||||
from agent.redis_coord.runtime import RedisRuntime
|
||||
from agent.redis_coord.stream_queue import StreamQueue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArchiveEnqueueResult:
|
||||
accepted: bool
|
||||
entry_id: str = ""
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def archive_stream(redis: Optional[RedisRuntime] = None) -> StreamQueue:
|
||||
"""存档 Stream(懒创建消费组由调用方 ensure)。"""
|
||||
rt = redis or get_redis_runtime()
|
||||
return StreamQueue(client=rt.client, stream_suffix=STREAM_ARCHIVE)
|
||||
|
||||
|
||||
def enqueue_archive(
|
||||
*,
|
||||
payload: dict[str, Any],
|
||||
idempotency_key: str = "",
|
||||
redis: Optional[RedisRuntime] = None,
|
||||
) -> ArchiveEnqueueResult:
|
||||
"""存档任务入队(快回)。"""
|
||||
rt = redis or get_redis_runtime()
|
||||
q = archive_stream(rt)
|
||||
q.ensure_group()
|
||||
if idempotency_key:
|
||||
ok = try_acquire_idempotency(
|
||||
rt.client,
|
||||
scope="archive",
|
||||
idempotency_key=idempotency_key,
|
||||
ttl_seconds=600,
|
||||
)
|
||||
if not ok:
|
||||
return ArchiveEnqueueResult(accepted=False, reason="duplicate_idempotency_key")
|
||||
entry_id = q.enqueue(payload=payload, idempotency_key=idempotency_key)
|
||||
logger.info("archive.enqueue id=%s", entry_id)
|
||||
return ArchiveEnqueueResult(accepted=True, entry_id=entry_id)
|
||||
|
||||
|
||||
def process_archive_stub(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""存档占位:不访问微盛、不写对象存储。"""
|
||||
logger.info("archive.stub keys=%s", sorted(data.keys()))
|
||||
return {"ok": True, "stub": True, "note": "存档壳:未下载未登记"}
|
||||
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
死信壳:失败任务落库 / 内存,不含自动重放业务。
|
||||
|
||||
本文件职责:enqueue_dead_letter;正式重放由运维/后续模块显式触发。
|
||||
禁止:把死信成功当成业务成功。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.runtime_db import assert_safe_database, connect_psycopg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeadLetterRecord:
|
||||
id: str
|
||||
source: str
|
||||
source_id: str = ""
|
||||
job_type: str = ""
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
error: str = ""
|
||||
attempts: int = 0
|
||||
|
||||
|
||||
class MemoryDeadLetterStore:
|
||||
"""内存死信壳。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._rows: list[DeadLetterRecord] = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def enqueue(self, record: DeadLetterRecord) -> str:
|
||||
with self._lock:
|
||||
if not record.id:
|
||||
record.id = str(uuid.uuid4())
|
||||
self._rows.append(record)
|
||||
logger.info(
|
||||
"dead_letter.memory source=%s type=%s id=%s",
|
||||
record.source,
|
||||
record.job_type,
|
||||
record.id,
|
||||
)
|
||||
return record.id
|
||||
|
||||
def list_recent(self, limit: int = 20) -> list[DeadLetterRecord]:
|
||||
with self._lock:
|
||||
return list(reversed(self._rows[-limit:]))
|
||||
|
||||
|
||||
class PostgresDeadLetterStore:
|
||||
"""PG agent_dead_letter 壳(须 002 迁移)。"""
|
||||
|
||||
def __init__(self, dsn: str) -> None:
|
||||
assert_safe_database(dsn)
|
||||
self._dsn = dsn
|
||||
|
||||
def enqueue(self, record: DeadLetterRecord) -> str:
|
||||
did = record.id or str(uuid.uuid4())
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO agent_dead_letter (
|
||||
id, source, source_id, job_type, payload, error, attempts
|
||||
) VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s)
|
||||
""",
|
||||
(
|
||||
did,
|
||||
record.source,
|
||||
record.source_id,
|
||||
record.job_type,
|
||||
json.dumps(record.payload, ensure_ascii=False),
|
||||
record.error,
|
||||
record.attempts,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("dead_letter.pg id=%s source=%s", did, record.source)
|
||||
return did
|
||||
|
||||
def list_recent(self, limit: int = 20) -> list[DeadLetterRecord]:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, source, source_id, job_type, payload, error, attempts
|
||||
FROM agent_dead_letter
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [
|
||||
DeadLetterRecord(
|
||||
id=str(r[0]),
|
||||
source=str(r[1]),
|
||||
source_id=str(r[2] or ""),
|
||||
job_type=str(r[3] or ""),
|
||||
payload=dict(r[4] or {}),
|
||||
error=str(r[5] or ""),
|
||||
attempts=int(r[6] or 0),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def create_dead_letter_store(*, dsn: str = "", backend: str = "auto"):
|
||||
"""工厂。"""
|
||||
if backend == "memory" or not dsn:
|
||||
return MemoryDeadLetterStore()
|
||||
return PostgresDeadLetterStore(dsn)
|
||||
|
||||
|
||||
def enqueue_dead_letter(
|
||||
*,
|
||||
source: str,
|
||||
error: str,
|
||||
job_type: str = "",
|
||||
source_id: str = "",
|
||||
payload: Optional[dict[str, Any]] = None,
|
||||
attempts: int = 0,
|
||||
store=None,
|
||||
) -> str:
|
||||
"""
|
||||
统一入死信入口(框架)。
|
||||
|
||||
store 为空则用内存临时店(调用方应注入进程级单例)。
|
||||
"""
|
||||
st = store or MemoryDeadLetterStore()
|
||||
return st.enqueue(
|
||||
DeadLetterRecord(
|
||||
id=str(uuid.uuid4()),
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
job_type=job_type,
|
||||
payload=dict(payload or {}),
|
||||
error=error,
|
||||
attempts=attempts,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
LibreOffice Excel→PDF 转换壳。
|
||||
|
||||
本文件职责:探测 soffice、在「调用方已占用 LibreOffice 单槽」的前提下同步转 PDF。
|
||||
禁止:在企微回调 / H5 线程调用;禁止本进程同时起两台 soffice(由 SlotPool size=1 保证)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LibreOfficeResult:
|
||||
"""转换结果;ok=False 时 error 说明原因,不抛到 Worker 主循环。"""
|
||||
|
||||
ok: bool
|
||||
pdf_path: str = ""
|
||||
error: str = ""
|
||||
returncode: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class LibreOfficeConverter:
|
||||
"""
|
||||
soffice 无界面转换器。
|
||||
|
||||
调用约定:仅从 WorkerRuntime.libreoffice 槽内进入 convert()。
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, settings: Optional[Settings] = None) -> "LibreOfficeConverter":
|
||||
return cls(settings=settings or get_settings())
|
||||
|
||||
def executable(self) -> Path:
|
||||
return Path(self.settings.libreoffice_path)
|
||||
|
||||
def available(self) -> bool:
|
||||
"""本机是否存在配置的 soffice 可执行文件。"""
|
||||
return self.executable().is_file()
|
||||
|
||||
def convert_xlsx_to_pdf(self, xlsx_path: str | Path) -> LibreOfficeResult:
|
||||
"""
|
||||
将 Excel 转为同目录 PDF。
|
||||
|
||||
副作用:启动 soffice 子进程(超时见 settings);成功写 PDF 文件。
|
||||
线程:必须已在 LibreOffice 单槽内。
|
||||
"""
|
||||
source = Path(xlsx_path).resolve()
|
||||
if not source.is_file():
|
||||
return LibreOfficeResult(ok=False, error="source_not_found")
|
||||
exe = self.executable()
|
||||
if not exe.is_file():
|
||||
return LibreOfficeResult(ok=False, error="libreoffice_not_installed")
|
||||
out_dir = source.parent
|
||||
target = source.with_suffix(".pdf")
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
str(exe),
|
||||
"--headless",
|
||||
"--convert-to",
|
||||
"pdf",
|
||||
"--outdir",
|
||||
str(out_dir),
|
||||
str(source),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=float(self.settings.libreoffice_timeout_seconds),
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
logger.warning("LibreOffice 执行失败: %s", exc)
|
||||
return LibreOfficeResult(ok=False, error=f"exec_failed:{exc}")
|
||||
if completed.returncode != 0 or not target.is_file():
|
||||
stderr = (completed.stderr or b"")[:400]
|
||||
logger.warning(
|
||||
"LibreOffice 未生成 PDF rc=%s stderr=%s",
|
||||
completed.returncode,
|
||||
stderr,
|
||||
)
|
||||
return LibreOfficeResult(
|
||||
ok=False,
|
||||
error="pdf_not_generated",
|
||||
returncode=int(completed.returncode),
|
||||
)
|
||||
head = target.read_bytes()[:5]
|
||||
if head != b"%PDF-":
|
||||
target.unlink(missing_ok=True)
|
||||
return LibreOfficeResult(ok=False, error="pdf_header_invalid")
|
||||
logger.info("LibreOffice 转 PDF 成功 path=%s", target)
|
||||
return LibreOfficeResult(ok=True, pdf_path=str(target), returncode=0)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
报价 PDF 任务壳:入队后由 LibreOffice **单槽**消费。
|
||||
|
||||
本文件职责:enqueue_pdf_job / process 调用 LibreOfficeConverter。
|
||||
禁止:在 http_jobs 槽跑 soffice;禁止回调线程转换。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.jobs.libreoffice import LibreOfficeConverter, LibreOfficeResult
|
||||
from agent.redis_coord import get_redis_runtime, try_acquire_idempotency
|
||||
from agent.redis_coord.keys import STREAM_PDF
|
||||
from agent.redis_coord.runtime import RedisRuntime
|
||||
from agent.redis_coord.stream_queue import StreamQueue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PdfEnqueueResult:
|
||||
accepted: bool
|
||||
entry_id: str = ""
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def pdf_stream(redis: Optional[RedisRuntime] = None) -> StreamQueue:
|
||||
rt = redis or get_redis_runtime()
|
||||
return StreamQueue(client=rt.client, stream_suffix=STREAM_PDF)
|
||||
|
||||
|
||||
def enqueue_pdf_job(
|
||||
*,
|
||||
xlsx_path: str,
|
||||
inquiry_no: str = "",
|
||||
quote_version: int = 0,
|
||||
idempotency_key: str = "",
|
||||
redis: Optional[RedisRuntime] = None,
|
||||
) -> PdfEnqueueResult:
|
||||
"""Excel→PDF 任务入队;真正转换只在 LibreOffice 槽。"""
|
||||
rt = redis or get_redis_runtime()
|
||||
q = pdf_stream(rt)
|
||||
q.ensure_group()
|
||||
if idempotency_key:
|
||||
ok = try_acquire_idempotency(
|
||||
rt.client,
|
||||
scope="pdf",
|
||||
idempotency_key=idempotency_key,
|
||||
ttl_seconds=600,
|
||||
)
|
||||
if not ok:
|
||||
return PdfEnqueueResult(accepted=False, reason="duplicate_idempotency_key")
|
||||
payload = {
|
||||
"xlsx_path": xlsx_path,
|
||||
"inquiry_no": inquiry_no,
|
||||
"quote_version": quote_version,
|
||||
}
|
||||
entry_id = q.enqueue(payload=payload, idempotency_key=idempotency_key)
|
||||
logger.info("pdf.enqueue id=%s inquiry=%s", entry_id, inquiry_no or "-")
|
||||
return PdfEnqueueResult(accepted=True, entry_id=entry_id)
|
||||
|
||||
|
||||
def process_pdf_job(
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
converter: Optional[LibreOfficeConverter] = None,
|
||||
) -> LibreOfficeResult:
|
||||
"""
|
||||
处理一条 PDF 任务(调用方必须已占用 libreoffice 槽)。
|
||||
|
||||
缺 xlsx_path 或本机无 soffice 时返回 ok=False,不抛崩 Worker。
|
||||
"""
|
||||
path = str(data.get("xlsx_path") or "")
|
||||
conv = converter or LibreOfficeConverter.from_settings()
|
||||
if not path:
|
||||
return LibreOfficeResult(ok=False, error="missing_xlsx_path")
|
||||
return conv.convert_xlsx_to_pdf(path)
|
||||
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
PG agent_job 认领壳(与 Redis Stream 二选一真相时用)。
|
||||
|
||||
本文件职责:enqueue / claim / complete / requeue_expired;不含业务处理体。
|
||||
禁止:一把大锁串行所有工单;禁止 Redis wecom:worker:singleton。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.runtime_db import assert_safe_database, connect_psycopg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobRecord:
|
||||
"""一条任务行。"""
|
||||
|
||||
id: str
|
||||
job_type: str
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
state: str = "pending"
|
||||
idempotency_key: str = ""
|
||||
attempts: int = 0
|
||||
last_error: str = ""
|
||||
|
||||
|
||||
class MemoryJobStore:
|
||||
"""内存任务认领壳。"""
|
||||
|
||||
def __init__(self, *, claim_timeout_sec: float = 180.0) -> None:
|
||||
self._rows: dict[str, JobRecord] = {}
|
||||
self._claimed_at: dict[str, float] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._claim_timeout = claim_timeout_sec
|
||||
|
||||
def enqueue(
|
||||
self,
|
||||
*,
|
||||
job_type: str,
|
||||
payload: dict[str, Any],
|
||||
idempotency_key: str = "",
|
||||
) -> tuple[bool, str]:
|
||||
with self._lock:
|
||||
if idempotency_key:
|
||||
for j in self._rows.values():
|
||||
if j.job_type == job_type and j.idempotency_key == idempotency_key:
|
||||
return False, "duplicate_idempotency_key"
|
||||
job_id = str(uuid.uuid4())
|
||||
self._rows[job_id] = JobRecord(
|
||||
id=job_id,
|
||||
job_type=job_type,
|
||||
payload=dict(payload),
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
return True, job_id
|
||||
|
||||
def claim(self, *, worker_id: str, job_type: str = "") -> Optional[JobRecord]:
|
||||
import time
|
||||
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
for jid, job in self._rows.items():
|
||||
if job_type and job.job_type != job_type:
|
||||
continue
|
||||
if job.state == "pending" or (
|
||||
job.state == "claimed"
|
||||
and now - self._claimed_at.get(jid, 0) > self._claim_timeout
|
||||
):
|
||||
job.state = "claimed"
|
||||
job.attempts += 1
|
||||
self._claimed_at[jid] = now
|
||||
return JobRecord(
|
||||
id=job.id,
|
||||
job_type=job.job_type,
|
||||
payload=dict(job.payload),
|
||||
state=job.state,
|
||||
idempotency_key=job.idempotency_key,
|
||||
attempts=job.attempts,
|
||||
last_error=job.last_error,
|
||||
)
|
||||
return None
|
||||
|
||||
def complete(self, job_id: str, *, error: str = "", result: Optional[dict] = None) -> None:
|
||||
with self._lock:
|
||||
job = self._rows.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
job.state = "failed" if error else "done"
|
||||
job.last_error = error
|
||||
logger.info("job.memory.complete id=%s state=%s", job_id, job.state)
|
||||
|
||||
|
||||
class PostgresJobStore:
|
||||
"""PG agent_job 认领壳。"""
|
||||
|
||||
def __init__(self, dsn: str, *, claim_timeout_sec: float = 180.0) -> None:
|
||||
assert_safe_database(dsn)
|
||||
self._dsn = dsn
|
||||
self._claim_timeout = int(claim_timeout_sec)
|
||||
|
||||
def enqueue(
|
||||
self,
|
||||
*,
|
||||
job_type: str,
|
||||
payload: dict[str, Any],
|
||||
idempotency_key: str = "",
|
||||
) -> tuple[bool, str]:
|
||||
job_id = str(uuid.uuid4())
|
||||
try:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO agent_job (id, job_type, idempotency_key, payload, state)
|
||||
VALUES (%s, %s, %s, %s::jsonb, 'pending')
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
job_type,
|
||||
idempotency_key,
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return True, job_id
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if "unique" in str(exc).lower() or "duplicate" in str(exc).lower():
|
||||
return False, "duplicate_idempotency_key"
|
||||
raise
|
||||
|
||||
def claim(self, *, worker_id: str, job_type: str = "") -> Optional[JobRecord]:
|
||||
type_filter = "AND job_type = %s" if job_type else ""
|
||||
params: list[Any] = [self._claim_timeout, worker_id]
|
||||
if job_type:
|
||||
params.append(job_type)
|
||||
sql = f"""
|
||||
WITH cte AS (
|
||||
SELECT id FROM agent_job
|
||||
WHERE (
|
||||
state = 'pending'
|
||||
OR (state = 'claimed' AND claimed_at < NOW() - (%s || ' seconds')::interval)
|
||||
)
|
||||
{type_filter}
|
||||
ORDER BY created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE agent_job j SET
|
||||
state = 'claimed',
|
||||
claimed_at = NOW(),
|
||||
claimed_by = %s,
|
||||
attempts = j.attempts + 1,
|
||||
updated_at = NOW()
|
||||
FROM cte WHERE j.id = cte.id
|
||||
RETURNING j.id, j.job_type, j.payload, j.state, j.idempotency_key, j.attempts, j.last_error
|
||||
"""
|
||||
# 参数顺序:timeout, [job_type], worker_id — 上面拼法易错,改用固定两段
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
if job_type:
|
||||
row = conn.execute(
|
||||
"""
|
||||
WITH cte AS (
|
||||
SELECT id FROM agent_job
|
||||
WHERE job_type = %s AND (
|
||||
state = 'pending'
|
||||
OR (state = 'claimed'
|
||||
AND claimed_at < NOW() - (%s || ' seconds')::interval)
|
||||
)
|
||||
ORDER BY created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE agent_job j SET
|
||||
state = 'claimed',
|
||||
claimed_at = NOW(),
|
||||
claimed_by = %s,
|
||||
attempts = j.attempts + 1,
|
||||
updated_at = NOW()
|
||||
FROM cte WHERE j.id = cte.id
|
||||
RETURNING j.id, j.job_type, j.payload, j.state,
|
||||
j.idempotency_key, j.attempts, j.last_error
|
||||
""",
|
||||
(job_type, str(self._claim_timeout), worker_id),
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute(
|
||||
"""
|
||||
WITH cte AS (
|
||||
SELECT id FROM agent_job
|
||||
WHERE state = 'pending'
|
||||
OR (state = 'claimed'
|
||||
AND claimed_at < NOW() - (%s || ' seconds')::interval)
|
||||
ORDER BY created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE agent_job j SET
|
||||
state = 'claimed',
|
||||
claimed_at = NOW(),
|
||||
claimed_by = %s,
|
||||
attempts = j.attempts + 1,
|
||||
updated_at = NOW()
|
||||
FROM cte WHERE j.id = cte.id
|
||||
RETURNING j.id, j.job_type, j.payload, j.state,
|
||||
j.idempotency_key, j.attempts, j.last_error
|
||||
""",
|
||||
(str(self._claim_timeout), worker_id),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
if not row:
|
||||
return None
|
||||
return JobRecord(
|
||||
id=str(row[0]),
|
||||
job_type=str(row[1]),
|
||||
payload=dict(row[2] or {}),
|
||||
state=str(row[3]),
|
||||
idempotency_key=str(row[4] or ""),
|
||||
attempts=int(row[5] or 0),
|
||||
last_error=str(row[6] or ""),
|
||||
)
|
||||
|
||||
def complete(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
error: str = "",
|
||||
result: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
state = "failed" if error else "done"
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE agent_job SET
|
||||
state = %s,
|
||||
last_error = %s,
|
||||
result = %s::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
state,
|
||||
error,
|
||||
json.dumps(result or {}, ensure_ascii=False),
|
||||
job_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("job.pg.complete id=%s state=%s", job_id, state)
|
||||
|
||||
|
||||
def create_job_store(*, dsn: str = "", backend: str = "auto", claim_timeout_sec: float = 180.0):
|
||||
"""工厂:Memory 或 PG。"""
|
||||
if backend == "memory" or not dsn:
|
||||
return MemoryJobStore(claim_timeout_sec=claim_timeout_sec)
|
||||
return PostgresJobStore(dsn, claim_timeout_sec=claim_timeout_sec)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
识别任务壳:入队 Redis Stream,Worker HTTP 槽认领后跑占位处理。
|
||||
|
||||
本文件职责:enqueue / process_stub;正式识别(附件/OCR/字段)后续按 payload.kind 分支。
|
||||
禁止:回调线程同步等识别完成;禁止本模块写主账六态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.redis_coord import get_redis_runtime, try_acquire_idempotency
|
||||
from agent.redis_coord.runtime import RedisRuntime
|
||||
from agent.redis_coord.stream_queue import StreamQueue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecognitionEnqueueResult:
|
||||
accepted: bool
|
||||
entry_id: str = ""
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def recognition_stream(redis: Optional[RedisRuntime] = None) -> StreamQueue:
|
||||
"""返回识别 Stream 句柄。"""
|
||||
rt = redis or get_redis_runtime()
|
||||
return rt.recognition_stream
|
||||
|
||||
|
||||
def enqueue_recognition(
|
||||
*,
|
||||
kind: str,
|
||||
payload: dict[str, Any],
|
||||
idempotency_key: str = "",
|
||||
redis: Optional[RedisRuntime] = None,
|
||||
) -> RecognitionEnqueueResult:
|
||||
"""
|
||||
识别任务入队(快回)。
|
||||
|
||||
kind 例:attachment_text / vision / sheet_map。
|
||||
"""
|
||||
rt = redis or get_redis_runtime()
|
||||
if idempotency_key:
|
||||
ok = try_acquire_idempotency(
|
||||
rt.client,
|
||||
scope="recognition",
|
||||
idempotency_key=idempotency_key,
|
||||
ttl_seconds=600,
|
||||
)
|
||||
if not ok:
|
||||
return RecognitionEnqueueResult(accepted=False, reason="duplicate_idempotency_key")
|
||||
body = {"kind": kind, "payload": payload}
|
||||
entry_id = rt.recognition_stream.enqueue(
|
||||
payload=body,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
logger.info("recognition.enqueue kind=%s id=%s", kind, entry_id)
|
||||
return RecognitionEnqueueResult(accepted=True, entry_id=entry_id)
|
||||
|
||||
|
||||
def process_recognition_stub(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
识别占位:回显 kind,不读附件、不调模型。
|
||||
|
||||
Worker 槽调用;正式实现替换为本函数旁的真实处理器。
|
||||
"""
|
||||
kind = str(data.get("kind") or "unknown")
|
||||
payload = data.get("payload") or {}
|
||||
logger.info("recognition.stub kind=%s keys=%s", kind, sorted(payload.keys()) if isinstance(payload, dict) else [])
|
||||
return {
|
||||
"ok": True,
|
||||
"stub": True,
|
||||
"kind": kind,
|
||||
"note": "识别壳:未跑真实抽取",
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Worker 侧:消费主账/文件完成等结构化 wake。
|
||||
|
||||
本文件职责:Redis Stream 入队/认领;在 HTTP 类槽内调 GraphRuntime.wake_from_worker。
|
||||
禁止:在企微回调线程调用;禁止用户文字冒充本路径。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.graph.runtime import GraphRuntime
|
||||
from agent.graph.wake import WorkerWakePayload
|
||||
from agent.redis_coord.client import RedisClient
|
||||
from agent.redis_coord.stream_queue import StreamQueue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STREAM_WAKE = "stream:wake"
|
||||
WAKE_GROUP = "inquiry-wake-workers"
|
||||
|
||||
|
||||
def wake_stream(client: RedisClient) -> StreamQueue:
|
||||
"""结构化 wake 专用 Stream。"""
|
||||
q = StreamQueue(client=client, stream_suffix=STREAM_WAKE, group=WAKE_GROUP)
|
||||
q.ensure_group()
|
||||
return q
|
||||
|
||||
|
||||
def enqueue_wake(client: RedisClient, payload: WorkerWakePayload) -> str:
|
||||
"""
|
||||
入队 wake(HTTP bridge 快回路径)。
|
||||
|
||||
副作用:XADD;不跑 Graph。
|
||||
"""
|
||||
payload.validate()
|
||||
q = wake_stream(client)
|
||||
return q.enqueue(
|
||||
payload={
|
||||
"thread_id": payload.thread_id,
|
||||
"inquiry_no": payload.inquiry_no,
|
||||
"quote_version": int(payload.quote_version),
|
||||
"wait_version": int(payload.wait_version),
|
||||
"kind": payload.kind,
|
||||
"detail": payload.detail or {},
|
||||
},
|
||||
idempotency_key=f"wake:{payload.thread_id}:{payload.wait_version}:{payload.kind}",
|
||||
)
|
||||
|
||||
|
||||
def claim_and_apply_wake(
|
||||
*,
|
||||
queue: StreamQueue,
|
||||
graph: GraphRuntime,
|
||||
consumer: str,
|
||||
) -> bool:
|
||||
"""
|
||||
认领 1 条并 wake Graph。成功/失败均 ack(骨架防堵死;正式应进死信)。
|
||||
|
||||
返回:是否领到任务。
|
||||
"""
|
||||
claimed = queue.claim(consumer=consumer, count=1, block_ms=200)
|
||||
if not claimed:
|
||||
return False
|
||||
entry_id, data = claimed[0]
|
||||
try:
|
||||
wake = WorkerWakePayload(
|
||||
thread_id=str(data.get("thread_id") or ""),
|
||||
inquiry_no=str(data.get("inquiry_no") or ""),
|
||||
quote_version=int(data.get("quote_version") or 0),
|
||||
wait_version=int(data.get("wait_version") or 0),
|
||||
kind=str(data.get("kind") or ""),
|
||||
detail=dict(data.get("detail") or {}),
|
||||
)
|
||||
graph.wake_from_worker(wake)
|
||||
logger.info("wake 已处理 id=%s thread=%s kind=%s", entry_id, wake.thread_id, wake.kind)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("wake 处理失败 id=%s", entry_id)
|
||||
finally:
|
||||
queue.ack(entry_id)
|
||||
return True
|
||||
@@ -2,25 +2,152 @@
|
||||
主账 HTTP 客户端:只打主账 API,不直连 TMS / MySQL。
|
||||
|
||||
正式写操作必须带幂等键;Graph 成功 ≠ 主账成功。
|
||||
骨架:仅占位客户端。
|
||||
本文件提供可联调的 ping / JSON 请求骨架;不做六态业务分支。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LedgerResponse:
|
||||
"""主账一次 HTTP 调用的结果摘要(不含密钥)。"""
|
||||
|
||||
ok: bool
|
||||
status_code: int
|
||||
data: Any = None
|
||||
error: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LedgerClient:
|
||||
"""
|
||||
主账客户端骨架。
|
||||
主账客户端。
|
||||
|
||||
参数:base_url 来自 BIZ_API_BASE_URL。
|
||||
禁止:在本类拼 TMS 签名或写工单 SQL。
|
||||
base_url:BIZ_API_BASE_URL(新系统多为 http://127.0.0.1:8180/jeecgboot)。
|
||||
超时:默认 10s,禁止无限阻塞 Worker 槽。
|
||||
禁止:在本类拼 TMS HMAC 或写工单 SQL。
|
||||
"""
|
||||
|
||||
base_url: str
|
||||
timeout_seconds: float = 10.0
|
||||
service_token: str = ""
|
||||
_client: Optional[httpx.Client] = field(default=None, repr=False)
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, settings: Optional[Settings] = None) -> "LedgerClient":
|
||||
"""从环境组装客户端。"""
|
||||
cfg = settings or get_settings()
|
||||
return cls(
|
||||
base_url=(cfg.biz_api_base_url or "").rstrip("/"),
|
||||
timeout_seconds=float(getattr(cfg, "biz_api_timeout_seconds", 10) or 10),
|
||||
service_token=(getattr(cfg, "biz_api_token", None) or ""),
|
||||
)
|
||||
|
||||
def _http(self) -> httpx.Client:
|
||||
if self._client is None:
|
||||
headers = {"Accept": "application/json"}
|
||||
if self.service_token:
|
||||
headers["X-Access-Token"] = self.service_token
|
||||
self._client = httpx.Client(
|
||||
base_url=self.base_url,
|
||||
timeout=self.timeout_seconds,
|
||||
headers=headers,
|
||||
)
|
||||
return self._client
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭连接池。"""
|
||||
if self._client is not None:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
|
||||
def health_url(self) -> str:
|
||||
"""返回主账健康检查 URL,供后续联调;骨架不发请求。"""
|
||||
return f"{self.base_url.rstrip('/')}/health"
|
||||
"""健康检查相对路径候选的完整 URL(日志用)。"""
|
||||
return f"{self.base_url}/"
|
||||
|
||||
def ping(self) -> LedgerResponse:
|
||||
"""
|
||||
探测主账是否可达。
|
||||
|
||||
尝试 GET / 或常见健康路径;4xx/5xx 记入 error,不抛给回调线程。
|
||||
副作用:出站 HTTP。
|
||||
"""
|
||||
if not self.base_url:
|
||||
return LedgerResponse(ok=False, status_code=0, error="BIZ_API_BASE_URL 未配置")
|
||||
paths = ["/", "/sys/randomImage/ping-check"]
|
||||
last: LedgerResponse = LedgerResponse(ok=False, status_code=0, error="no_attempt")
|
||||
for path in paths:
|
||||
try:
|
||||
resp = self._http().get(path)
|
||||
last = LedgerResponse(
|
||||
ok=resp.status_code < 500,
|
||||
status_code=resp.status_code,
|
||||
data={"path": path},
|
||||
)
|
||||
if resp.status_code < 500:
|
||||
logger.info(
|
||||
"ledger.ping ok status=%s path=%s",
|
||||
resp.status_code,
|
||||
path,
|
||||
)
|
||||
return last
|
||||
except httpx.HTTPError as exc:
|
||||
last = LedgerResponse(ok=False, status_code=0, error=str(exc))
|
||||
logger.warning("ledger.ping 失败 path=%s err=%s", path, exc)
|
||||
return last
|
||||
|
||||
def request_json(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: Optional[dict[str, Any]] = None,
|
||||
idempotency_key: str = "",
|
||||
) -> LedgerResponse:
|
||||
"""
|
||||
通用 JSON 请求骨架。
|
||||
|
||||
写操作应传入 idempotency_key(写入头 X-Idempotency-Key)。
|
||||
不做业务语义解析;调用方根据主账合同处理 result。
|
||||
"""
|
||||
headers: dict[str, str] = {}
|
||||
if idempotency_key:
|
||||
headers["X-Idempotency-Key"] = idempotency_key
|
||||
try:
|
||||
resp = self._http().request(
|
||||
method.upper(),
|
||||
path,
|
||||
json=json_body,
|
||||
headers=headers,
|
||||
)
|
||||
data: Any
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
data = {"text": resp.text[:500]}
|
||||
ok = 200 <= resp.status_code < 300
|
||||
if not ok:
|
||||
logger.warning(
|
||||
"ledger.request 非成功 status=%s path=%s",
|
||||
resp.status_code,
|
||||
path,
|
||||
)
|
||||
return LedgerResponse(ok=ok, status_code=resp.status_code, data=data)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("ledger.request 异常 path=%s err=%s", path, exc)
|
||||
return LedgerResponse(ok=False, status_code=0, error=str(exc))
|
||||
|
||||
|
||||
from agent.ledger.api_shell import LedgerApiShell # noqa: E402
|
||||
|
||||
__all__ = ["LedgerClient", "LedgerResponse", "LedgerApiShell"]
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
主账 API 方法签名壳。
|
||||
|
||||
本文件职责:列出智能体将调用的主账端点形状;默认返回 not_implemented,不假装成功。
|
||||
禁止:直连 TMS / MySQL;Graph 成功不能冒充本文件成功。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.ledger import LedgerClient, LedgerResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LedgerApiShell:
|
||||
"""
|
||||
主账业务 API 空壳(签名齐、实现空)。
|
||||
|
||||
正式:各方法改走 request_json 打真实路径;路径以主账 OpenAPI 为准。
|
||||
"""
|
||||
|
||||
def __init__(self, client: Optional[LedgerClient] = None) -> None:
|
||||
self.client = client or LedgerClient.from_settings()
|
||||
|
||||
def _nyi(self, name: str) -> LedgerResponse:
|
||||
logger.info("ledger.api_shell.%s not_implemented", name)
|
||||
return LedgerResponse(
|
||||
ok=False,
|
||||
status_code=501,
|
||||
error=f"not_implemented:{name}",
|
||||
data={"method": name},
|
||||
)
|
||||
|
||||
def create_ticket(
|
||||
self,
|
||||
*,
|
||||
payload: dict[str, Any],
|
||||
idempotency_key: str,
|
||||
) -> LedgerResponse:
|
||||
"""建单壳。"""
|
||||
_ = (payload, idempotency_key)
|
||||
return self._nyi("create_ticket")
|
||||
|
||||
def transition_ticket(
|
||||
self,
|
||||
*,
|
||||
inquiry_no: str,
|
||||
to_status: str,
|
||||
idempotency_key: str,
|
||||
reason: str = "",
|
||||
) -> LedgerResponse:
|
||||
"""六态迁移壳。"""
|
||||
_ = (inquiry_no, to_status, idempotency_key, reason)
|
||||
return self._nyi("transition_ticket")
|
||||
|
||||
def get_ticket(self, *, inquiry_no: str) -> LedgerResponse:
|
||||
"""查工单壳。"""
|
||||
_ = inquiry_no
|
||||
return self._nyi("get_ticket")
|
||||
|
||||
def upsert_quote_version(
|
||||
self,
|
||||
*,
|
||||
inquiry_no: str,
|
||||
quote_version: int,
|
||||
payload: dict[str, Any],
|
||||
idempotency_key: str,
|
||||
) -> LedgerResponse:
|
||||
"""报价版本壳。"""
|
||||
_ = (inquiry_no, quote_version, payload, idempotency_key)
|
||||
return self._nyi("upsert_quote_version")
|
||||
|
||||
def register_attachment(
|
||||
self,
|
||||
*,
|
||||
inquiry_no: str,
|
||||
object_key: str,
|
||||
meta: dict[str, Any],
|
||||
idempotency_key: str,
|
||||
) -> LedgerResponse:
|
||||
"""附件元数据登记壳。"""
|
||||
_ = (inquiry_no, object_key, meta, idempotency_key)
|
||||
return self._nyi("register_attachment")
|
||||
|
||||
def get_staff_by_wecom_id(self, *, wecom_id: str) -> LedgerResponse:
|
||||
"""人员角色壳(按已有 wecom_id)。"""
|
||||
_ = wecom_id
|
||||
return self._nyi("get_staff_by_wecom_id")
|
||||
|
||||
def list_quote_templates(self, *, enabled_only: bool = True) -> LedgerResponse:
|
||||
"""
|
||||
报价模板列表壳。
|
||||
|
||||
真相:后台上传/启用;智能体只读已启用项,再经对象存储取字节。
|
||||
"""
|
||||
_ = enabled_only
|
||||
return self._nyi("list_quote_templates")
|
||||
|
||||
def wake_agent_ack(
|
||||
self,
|
||||
*,
|
||||
inquiry_no: str,
|
||||
quote_version: int,
|
||||
wait_version: int,
|
||||
) -> LedgerResponse:
|
||||
"""主账侧唤醒回执壳(智能体被唤醒后可选回写)。"""
|
||||
_ = (inquiry_no, quote_version, wait_version)
|
||||
return self._nyi("wake_agent_ack")
|
||||
@@ -1,6 +1,20 @@
|
||||
"""
|
||||
LLM 网关与队列:DeepSeek / 千问 mode schema、认领、重试。
|
||||
LLM 网关与队列:DeepSeek / 千问 mode、入队、认领(Worker 侧后续接)。
|
||||
|
||||
禁止:在本包维护工单六态;禁止 Redis 全局 Worker 单例锁。
|
||||
骨架:仅包占位。
|
||||
禁止:在本包维护工单六态;禁止 Redis 全局 Worker 单例锁;禁止回调线程等完整 LLM。
|
||||
"""
|
||||
|
||||
from agent.llm.gateway import LlmEnqueueResult, LlmGateway
|
||||
from agent.llm.http_client import LlmHttpClient, LlmHttpResult
|
||||
from agent.llm.mode_registry import invoke_mode_shell, list_mode_shells
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
__all__ = [
|
||||
"LlmMode",
|
||||
"LlmGateway",
|
||||
"LlmEnqueueResult",
|
||||
"LlmHttpClient",
|
||||
"LlmHttpResult",
|
||||
"invoke_mode_shell",
|
||||
"list_mode_shells",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
LLM 网关骨架:入队 Redis Stream,不在回调线程等完整结果。
|
||||
|
||||
本文件职责:校验 mode、短幂等、XADD;真实模型 HTTP 后续按 mode 实现。
|
||||
禁止:wait_result 堵在请求线程;禁止本包维护工单六态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
from agent.llm.modes import LlmMode
|
||||
from agent.redis_coord import get_redis_runtime, try_acquire_idempotency
|
||||
from agent.redis_coord.runtime import RedisRuntime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmEnqueueResult:
|
||||
"""入队结果。"""
|
||||
|
||||
accepted: bool
|
||||
entry_id: str = ""
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmGateway:
|
||||
"""
|
||||
LLM 网关。
|
||||
|
||||
调用:HTTP/Handler 只 enqueue;Worker 槽 claim 后调 invoke_stub/未来真调用。
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
redis: RedisRuntime
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, settings: Optional[Settings] = None) -> "LlmGateway":
|
||||
cfg = settings or get_settings()
|
||||
return cls(settings=cfg, redis=get_redis_runtime(cfg))
|
||||
|
||||
def enqueue(
|
||||
self,
|
||||
*,
|
||||
mode: LlmMode | str,
|
||||
payload: dict[str, Any],
|
||||
idempotency_key: str = "",
|
||||
) -> LlmEnqueueResult:
|
||||
"""
|
||||
将任务写入 LLM Stream。
|
||||
|
||||
重复 idempotency_key(TTL 内)返回 accepted=False。
|
||||
副作用:可能写 Redis;不调外部模型。
|
||||
"""
|
||||
mode_val = mode.value if isinstance(mode, LlmMode) else str(mode)
|
||||
try:
|
||||
LlmMode(mode_val)
|
||||
except ValueError:
|
||||
return LlmEnqueueResult(accepted=False, reason=f"unknown_mode:{mode_val}")
|
||||
|
||||
if idempotency_key:
|
||||
ok = try_acquire_idempotency(
|
||||
self.redis.client,
|
||||
scope=f"llm:{mode_val}",
|
||||
idempotency_key=idempotency_key,
|
||||
ttl_seconds=300,
|
||||
)
|
||||
if not ok:
|
||||
return LlmEnqueueResult(accepted=False, reason="duplicate_idempotency_key")
|
||||
|
||||
body = {"mode": mode_val, "payload": payload}
|
||||
entry_id = self.redis.llm_stream.enqueue(
|
||||
payload=body,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
logger.info("llm.enqueue mode=%s id=%s", mode_val, entry_id)
|
||||
return LlmEnqueueResult(accepted=True, entry_id=entry_id)
|
||||
|
||||
def invoke_stub(self, *, mode: LlmMode | str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
模型调用占位:返回固定结构,不访问外网。
|
||||
|
||||
正式实现按 mode 调 DeepSeek/千问;本方法仅供骨架与单测。
|
||||
"""
|
||||
mode_val = mode.value if isinstance(mode, LlmMode) else str(mode)
|
||||
if mode_val != LlmMode.ORDERED_ACTIONS.value:
|
||||
from agent.llm.mode_registry import invoke_mode_shell
|
||||
|
||||
return invoke_mode_shell(mode_val, payload=payload)
|
||||
return {
|
||||
"ok": True,
|
||||
"stub": True,
|
||||
"mode": mode_val,
|
||||
"echo_keys": sorted(payload.keys()),
|
||||
"note": "骨架不调用真实模型",
|
||||
}
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
*,
|
||||
mode: LlmMode | str,
|
||||
messages: list[dict[str, Any]],
|
||||
allow_network: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Worker 槽内调用模型。
|
||||
|
||||
allow_network=False:走 stub(默认,防误打费)。
|
||||
allow_network=True:走 LlmHttpClient(需已配 API Key)。
|
||||
"""
|
||||
if not allow_network:
|
||||
return self.invoke_stub(mode=mode, payload={"messages": len(messages)})
|
||||
from agent.llm.http_client import LlmHttpClient
|
||||
|
||||
result = LlmHttpClient.from_settings(self.settings).chat(mode=mode, messages=messages)
|
||||
return {
|
||||
"ok": result.ok,
|
||||
"stub": False,
|
||||
"provider": result.provider,
|
||||
"content": result.content,
|
||||
"error": result.error,
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
LLM 出站 HTTP 客户端(DeepSeek / 千问兼容 OpenAI Chat)。
|
||||
|
||||
本文件职责:带超时的 chat.completions 调用;缺 key / 未确认数据用途时明确失败。
|
||||
禁止:在企微回调线程同步长等;正式长调用走 Worker 槽 + 队列。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmHttpResult:
|
||||
ok: bool
|
||||
content: str = ""
|
||||
raw: Optional[dict[str, Any]] = None
|
||||
error: str = ""
|
||||
provider: str = ""
|
||||
tool_calls: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmHttpClient:
|
||||
"""
|
||||
兼容 OpenAI 的 Chat Completions 客户端。
|
||||
|
||||
DeepSeek / 千问 DashScope compatible-mode 共用此壳。
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
timeout_seconds: float = 60.0
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, settings: Optional[Settings] = None) -> "LlmHttpClient":
|
||||
return cls(settings=settings or get_settings())
|
||||
|
||||
def _provider_for_mode(self, mode: LlmMode | str) -> tuple[str, str, str]:
|
||||
"""
|
||||
返回 (provider, base_url, api_key)。
|
||||
|
||||
A/B 文本 → DeepSeek;附件/视觉 → 千问。
|
||||
"""
|
||||
mode_val = mode.value if isinstance(mode, LlmMode) else str(mode)
|
||||
visionish = mode_val in {
|
||||
LlmMode.ATTACHMENT_TEXT.value,
|
||||
LlmMode.VISION.value,
|
||||
LlmMode.SHEET_MAP.value,
|
||||
}
|
||||
if visionish:
|
||||
return (
|
||||
"qwen",
|
||||
(self.settings.qwen_base_url or "").rstrip("/"),
|
||||
self.settings.qwen_api_key or "",
|
||||
)
|
||||
return (
|
||||
"deepseek",
|
||||
(self.settings.deepseek_base_url or "").rstrip("/"),
|
||||
self.settings.deepseek_api_key or "",
|
||||
)
|
||||
|
||||
def _model_for(self, provider: str, mode: LlmMode | str) -> str:
|
||||
mode_val = mode.value if isinstance(mode, LlmMode) else str(mode)
|
||||
if provider == "qwen":
|
||||
if mode_val == LlmMode.VISION.value:
|
||||
return self.settings.qwen_vl_model or "qwen-vl-plus"
|
||||
return self.settings.qwen_text_model or "qwen-plus"
|
||||
return self.settings.deepseek_model or "deepseek-chat"
|
||||
|
||||
def chat(
|
||||
self,
|
||||
*,
|
||||
mode: LlmMode | str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float = 0.0,
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Any = None,
|
||||
) -> LlmHttpResult:
|
||||
"""
|
||||
发起一次 chat.completions(可带 tools)。
|
||||
|
||||
副作用:出站 HTTPS;超时由 timeout_seconds 限制。
|
||||
未确认 LLM_DATA_USAGE_CONFIRMED 时拒绝调用(prompt/04)。
|
||||
"""
|
||||
if not self.settings.llm_data_usage_confirmed:
|
||||
return LlmHttpResult(
|
||||
ok=False,
|
||||
error="llm_data_usage_not_confirmed",
|
||||
provider="none",
|
||||
)
|
||||
provider, base, key = self._provider_for_mode(mode)
|
||||
if not key or not base:
|
||||
return LlmHttpResult(
|
||||
ok=False,
|
||||
error=f"{provider}_not_configured",
|
||||
provider=provider,
|
||||
)
|
||||
model = self._model_for(provider, mode)
|
||||
url = f"{base}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
if tool_choice is not None:
|
||||
body["tool_choice"] = tool_choice
|
||||
try:
|
||||
with httpx.Client(timeout=self.timeout_seconds) as client:
|
||||
resp = client.post(url, headers=headers, json=body)
|
||||
data = resp.json() if resp.content else {}
|
||||
if resp.status_code >= 400:
|
||||
logger.warning(
|
||||
"llm.http 失败 provider=%s status=%s",
|
||||
provider,
|
||||
resp.status_code,
|
||||
)
|
||||
return LlmHttpResult(
|
||||
ok=False,
|
||||
error=f"http_{resp.status_code}",
|
||||
raw=data if isinstance(data, dict) else None,
|
||||
provider=provider,
|
||||
)
|
||||
choices = (data or {}).get("choices") or []
|
||||
content = ""
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
if choices:
|
||||
msg = (choices[0].get("message") or {})
|
||||
content = str(msg.get("content") or "")
|
||||
raw_calls = msg.get("tool_calls") or []
|
||||
if isinstance(raw_calls, list):
|
||||
tool_calls = [c for c in raw_calls if isinstance(c, dict)]
|
||||
return LlmHttpResult(
|
||||
ok=True,
|
||||
content=content,
|
||||
raw=data if isinstance(data, dict) else None,
|
||||
provider=provider,
|
||||
tool_calls=tool_calls or None,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("llm.http 异常 provider=%s err=%s", provider, exc)
|
||||
return LlmHttpResult(ok=False, error=str(exc), provider=provider)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
千问:附件文本 mode 壳。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
MODE = LlmMode.ATTACHMENT_TEXT
|
||||
|
||||
|
||||
def mode_meta() -> dict[str, Any]:
|
||||
return {
|
||||
"mode": MODE.value,
|
||||
"provider": "qwen",
|
||||
"purpose": "attachment_text",
|
||||
"implemented": False,
|
||||
}
|
||||
|
||||
|
||||
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"stub": True,
|
||||
"mode": MODE.value,
|
||||
"error": "attachment_text_shell_not_implemented",
|
||||
"echo_keys": sorted(payload.keys()),
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
DeepSeek B:文本字段抽取 mode 壳。
|
||||
|
||||
本文件职责:声明 mode 元数据与空 invoke_shell;不写字段合同激活规则。
|
||||
禁止:在本文件改主账六态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
MODE = LlmMode.EXTRACT_FIELDS
|
||||
|
||||
|
||||
def mode_meta() -> dict[str, Any]:
|
||||
"""返回本 mode 的框架元数据(无 prompt 正文业务)。"""
|
||||
return {
|
||||
"mode": MODE.value,
|
||||
"provider": "deepseek",
|
||||
"purpose": "text_field_extract",
|
||||
"implemented": False,
|
||||
}
|
||||
|
||||
|
||||
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""空壳:不调模型,返回未实现。"""
|
||||
return {
|
||||
"ok": False,
|
||||
"stub": True,
|
||||
"mode": MODE.value,
|
||||
"error": "extract_fields_shell_not_implemented",
|
||||
"echo_keys": sorted(payload.keys()),
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
群报价抽取 mode 壳。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
MODE = LlmMode.GROUP_QUOTE
|
||||
|
||||
|
||||
def mode_meta() -> dict[str, Any]:
|
||||
return {
|
||||
"mode": MODE.value,
|
||||
"provider": "deepseek",
|
||||
"purpose": "group_quote",
|
||||
"implemented": False,
|
||||
}
|
||||
|
||||
|
||||
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"stub": True,
|
||||
"mode": MODE.value,
|
||||
"error": "group_quote_shell_not_implemented",
|
||||
"echo_keys": sorted(payload.keys()),
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
LLM mode 空壳注册表:按 mode 名取 invoke_shell。
|
||||
|
||||
本文件只做分发到各 mode_* 模块;ordered_actions 走 routing/DeepSeekA。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from agent.llm import mode_attachment_text, mode_extract_fields, mode_group_quote, mode_sheet_map, mode_vision
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
_SHELLS: dict[str, Callable[..., dict[str, Any]]] = {
|
||||
LlmMode.EXTRACT_FIELDS.value: mode_extract_fields.invoke_shell,
|
||||
LlmMode.ATTACHMENT_TEXT.value: mode_attachment_text.invoke_shell,
|
||||
LlmMode.VISION.value: mode_vision.invoke_shell,
|
||||
LlmMode.GROUP_QUOTE.value: mode_group_quote.invoke_shell,
|
||||
LlmMode.SHEET_MAP.value: mode_sheet_map.invoke_shell,
|
||||
}
|
||||
|
||||
|
||||
def invoke_mode_shell(mode: str | LlmMode, *, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""调用对应 mode 空壳;未知 mode 返回错误结构。"""
|
||||
key = mode.value if isinstance(mode, LlmMode) else str(mode)
|
||||
fn = _SHELLS.get(key)
|
||||
if fn is None:
|
||||
return {"ok": False, "stub": True, "mode": key, "error": "unknown_or_unregistered_mode"}
|
||||
return fn(payload=payload)
|
||||
|
||||
|
||||
def list_mode_shells() -> list[str]:
|
||||
"""已注册空壳 mode 名。"""
|
||||
return sorted(_SHELLS.keys())
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
报价工作表映射 mode 壳。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
MODE = LlmMode.SHEET_MAP
|
||||
|
||||
|
||||
def mode_meta() -> dict[str, Any]:
|
||||
return {
|
||||
"mode": MODE.value,
|
||||
"provider": "qwen",
|
||||
"purpose": "sheet_map",
|
||||
"implemented": False,
|
||||
}
|
||||
|
||||
|
||||
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"stub": True,
|
||||
"mode": MODE.value,
|
||||
"error": "sheet_map_shell_not_implemented",
|
||||
"echo_keys": sorted(payload.keys()),
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
千问视觉:读图 mode 壳。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
MODE = LlmMode.VISION
|
||||
|
||||
|
||||
def mode_meta() -> dict[str, Any]:
|
||||
return {
|
||||
"mode": MODE.value,
|
||||
"provider": "qwen",
|
||||
"purpose": "vision",
|
||||
"implemented": False,
|
||||
}
|
||||
|
||||
|
||||
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"stub": True,
|
||||
"mode": MODE.value,
|
||||
"error": "vision_shell_not_implemented",
|
||||
"echo_keys": sorted(payload.keys()),
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
LLM 模式名(对齐 prompt/06)。
|
||||
|
||||
本文件只声明枚举;具体 prompt/schema 后续按 mode 分文件,禁止堆进上帝模块。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class LlmMode(str, Enum):
|
||||
"""允许入队的 LLM 模式。"""
|
||||
|
||||
ORDERED_ACTIONS = "ordered_actions" # DeepSeek A
|
||||
EXTRACT_FIELDS = "extract_fields" # DeepSeek B
|
||||
ATTACHMENT_TEXT = "attachment_text" # 千问读附件
|
||||
VISION = "vision" # 千问读图
|
||||
GROUP_QUOTE = "group_quote" # 群报价抽取
|
||||
SHEET_MAP = "sheet_map" # 报价工作表映射
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
policy 包:跨 handler 的公共校验落点(空壳)。
|
||||
|
||||
本文件职责:占位;身份/Schema/权限等确定性规则后续放本包。
|
||||
禁止:自然语言语义;禁止把全部卡片逻辑堆进本包。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def policy_package_ready() -> bool:
|
||||
"""框架探测:包可导入即 True。"""
|
||||
return True
|
||||
|
||||
|
||||
def assert_has_sender_id(sender_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
身份壳:无 sender_id 则 ok=False。
|
||||
|
||||
不做企微换号;不入 Graph 的裁决由调用方执行。
|
||||
"""
|
||||
if not (sender_id or "").strip():
|
||||
return {"ok": False, "error": "missing_sender_id"}
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
报价模板访问壳。
|
||||
|
||||
真相在主账:后台上传/启用/停用 → 对象存储存字节 → 主账存元数据。
|
||||
智能体生成报价前经 ledger 拉取已启用模板指针,再从对象存储取字节。
|
||||
|
||||
本文件职责:声明「从主账列模板」的空 API;可选本地缓存目录仅联调。
|
||||
禁止:把仓库 resources 当生产模板真相;禁止在本包发明价格或改 quoteVersion。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.ledger import LedgerClient, LedgerResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TemplateEntry:
|
||||
"""一条模板元数据(来自主账或联调缓存)。"""
|
||||
|
||||
code: str
|
||||
filename: str = ""
|
||||
object_key: str = ""
|
||||
enabled: bool = False
|
||||
source: str = "ledger"
|
||||
|
||||
|
||||
def local_cache_dir() -> Path:
|
||||
"""
|
||||
可选本机缓存目录(非生产真相)。
|
||||
|
||||
正式模板由后台上传;此目录仅开发落盘缓存,可空。
|
||||
"""
|
||||
here = Path(__file__).resolve()
|
||||
return here.parents[2] / "resources" / "quote_templates"
|
||||
|
||||
|
||||
def list_templates_from_ledger(
|
||||
client: Optional[LedgerClient] = None,
|
||||
) -> LedgerResponse:
|
||||
"""
|
||||
向主账查询已启用报价模板列表(壳)。
|
||||
|
||||
正式路径以主账 OpenAPI 为准;当前返回 not_implemented,不假装本地文件即生产模板。
|
||||
"""
|
||||
c = client or LedgerClient.from_settings()
|
||||
logger.info("quote_templates.list_from_ledger(壳,未打真实 API)")
|
||||
return LedgerResponse(
|
||||
ok=False,
|
||||
status_code=501,
|
||||
error="not_implemented:list_quote_templates",
|
||||
data={"hint": "admin_upload_via_ledger"},
|
||||
)
|
||||
|
||||
|
||||
def list_local_cache_files() -> list[TemplateEntry]:
|
||||
"""
|
||||
列出本机缓存目录中的 xlsx(仅联调;可空)。
|
||||
|
||||
不等于后台已启用模板。
|
||||
"""
|
||||
root = local_cache_dir()
|
||||
if not root.is_dir():
|
||||
return []
|
||||
out: list[TemplateEntry] = []
|
||||
for path in sorted(root.iterdir()):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.suffix.lower() not in {".xlsx", ".xlsm", ".xltx"}:
|
||||
continue
|
||||
if path.name.startswith("."):
|
||||
continue
|
||||
out.append(
|
||||
TemplateEntry(
|
||||
code=path.stem,
|
||||
filename=path.name,
|
||||
object_key="",
|
||||
enabled=False,
|
||||
source="local_cache",
|
||||
)
|
||||
)
|
||||
logger.info("quote_templates.local_cache count=%s", len(out))
|
||||
return out
|
||||
|
||||
|
||||
def list_template_registry() -> list[TemplateEntry]:
|
||||
"""
|
||||
兼容旧名:框架阶段返回本地缓存列表(可空)。
|
||||
|
||||
生产应改用 list_templates_from_ledger + 对象存储下载。
|
||||
"""
|
||||
return list_local_cache_files()
|
||||
|
||||
|
||||
def registry_as_dicts() -> list[dict[str, Any]]:
|
||||
"""便于 JSON/自检。"""
|
||||
return [
|
||||
{
|
||||
"code": e.code,
|
||||
"filename": e.filename,
|
||||
"object_key": e.object_key,
|
||||
"enabled": e.enabled,
|
||||
"source": e.source,
|
||||
}
|
||||
for e in list_template_registry()
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
Redis 短命协调包(对齐 prompt/14)。
|
||||
|
||||
用途:Stream 队列、短幂等、限流、token 缓存。
|
||||
不用:六态、Graph checkpoint、Worker 全局单例、TMS 伪造开关。
|
||||
|
||||
命名说明:
|
||||
- Redis URL 里的 /1 叫「逻辑库号 db1」,与现网 /0 隔离(Redis 本身没有 inquiry_robot 这种库名)。
|
||||
- 键前缀 inquiry_robot: 才是可读业务名;二者一起用。
|
||||
"""
|
||||
|
||||
from agent.redis_coord.client import RedisClient, create_redis_client
|
||||
from agent.redis_coord.idempotency import try_acquire as try_acquire_idempotency
|
||||
from agent.redis_coord.rate_limit import RateLimitResult, allow as rate_limit_allow
|
||||
from agent.redis_coord.runtime import (
|
||||
RedisRuntime,
|
||||
get_redis_runtime,
|
||||
shutdown_redis_runtime,
|
||||
)
|
||||
from agent.redis_coord.token_cache import get_wecom_token, set_wecom_token
|
||||
|
||||
__all__ = [
|
||||
"RedisClient",
|
||||
"RedisRuntime",
|
||||
"RateLimitResult",
|
||||
"create_redis_client",
|
||||
"get_redis_runtime",
|
||||
"shutdown_redis_runtime",
|
||||
"try_acquire_idempotency",
|
||||
"rate_limit_allow",
|
||||
"get_wecom_token",
|
||||
"set_wecom_token",
|
||||
]
|
||||
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
Redis 连接与前缀客户端。
|
||||
|
||||
本文件职责:从 REDIS_URL 建连;统一 key();禁止 FLUSHALL / 全局单例锁 API。
|
||||
db 号:写在 URL 路径(如 …/1),与现网 …/0 隔离;业务名看 REDIS_KEY_PREFIX。
|
||||
线程:连接对象可被多线程共用(redis-py 连接池);业务认领仍按条,不靠本客户端当进程锁。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from agent.config import Settings
|
||||
from agent.redis_coord.keys import join_key, normalize_prefix
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RedisLike(Protocol):
|
||||
"""骨架可替换的最小 Redis 协议(真客户端或内存假实现)。"""
|
||||
|
||||
def ping(self) -> Any: ...
|
||||
def get(self, name: str) -> Any: ...
|
||||
def set(self, name: str, value: Any, ex: Optional[int] = None, nx: bool = False) -> Any: ...
|
||||
def incr(self, name: str) -> int: ...
|
||||
def expire(self, name: str, time: int) -> Any: ...
|
||||
def delete(self, *names: str) -> Any: ...
|
||||
def xadd(self, name: str, fields: dict, id: str = "*", maxlen: Optional[int] = None) -> Any: ...
|
||||
def xgroup_create(
|
||||
self, name: str, groupname: str, id: str = "0", mkstream: bool = False
|
||||
) -> Any: ...
|
||||
def xreadgroup(
|
||||
self,
|
||||
groupname: str,
|
||||
consumername: str,
|
||||
streams: dict,
|
||||
count: Optional[int] = None,
|
||||
block: Optional[int] = None,
|
||||
) -> Any: ...
|
||||
def xack(self, name: str, groupname: str, *ids: str) -> Any: ...
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryRedis:
|
||||
"""
|
||||
本机骨架假 Redis:仅覆盖自检所需命令,不持久化。
|
||||
|
||||
正式/并存禁止当生产;无 TTL 扫描后台,expire 只在 get 时懒过期。
|
||||
"""
|
||||
|
||||
_kv: dict[str, tuple[Any, Optional[float]]] = field(default_factory=dict)
|
||||
_streams: dict[str, list[tuple[str, dict]]] = field(default_factory=dict)
|
||||
_groups: dict[str, set[str]] = field(default_factory=dict)
|
||||
_seq: int = 0
|
||||
|
||||
def _now(self) -> float:
|
||||
import time
|
||||
|
||||
return time.time()
|
||||
|
||||
def _alive(self, key: str) -> bool:
|
||||
item = self._kv.get(key)
|
||||
if item is None:
|
||||
return False
|
||||
_val, exp = item
|
||||
if exp is not None and exp <= self._now():
|
||||
del self._kv[key]
|
||||
return False
|
||||
return True
|
||||
|
||||
def ping(self) -> bool:
|
||||
return True
|
||||
|
||||
def get(self, name: str) -> Any:
|
||||
if not self._alive(name):
|
||||
return None
|
||||
return self._kv[name][0]
|
||||
|
||||
def set(self, name: str, value: Any, ex: Optional[int] = None, nx: bool = False) -> Any:
|
||||
if nx and self._alive(name):
|
||||
return False
|
||||
exp = self._now() + ex if ex else None
|
||||
self._kv[name] = (value if isinstance(value, (bytes, str)) else str(value), exp)
|
||||
return True
|
||||
|
||||
def incr(self, name: str) -> int:
|
||||
cur = self.get(name)
|
||||
n = int(cur or 0) + 1
|
||||
exp = self._kv[name][1] if self._alive(name) else None
|
||||
self._kv[name] = (str(n), exp)
|
||||
return n
|
||||
|
||||
def expire(self, name: str, time: int) -> bool:
|
||||
if not self._alive(name):
|
||||
return False
|
||||
val, _ = self._kv[name]
|
||||
self._kv[name] = (val, self._now() + time)
|
||||
return True
|
||||
|
||||
def delete(self, *names: str) -> int:
|
||||
n = 0
|
||||
for name in names:
|
||||
if name in self._kv:
|
||||
del self._kv[name]
|
||||
n += 1
|
||||
return n
|
||||
|
||||
def xadd(
|
||||
self, name: str, fields: dict, id: str = "*", maxlen: Optional[int] = None
|
||||
) -> str:
|
||||
self._seq += 1
|
||||
entry_id = id if id != "*" else f"1-{self._seq}"
|
||||
buf = self._streams.setdefault(name, [])
|
||||
# 字段值统一成 str,贴近 Redis
|
||||
norm = {str(k): (v if isinstance(v, str) else str(v)) for k, v in fields.items()}
|
||||
buf.append((entry_id, norm))
|
||||
if maxlen and len(buf) > maxlen:
|
||||
del buf[0 : len(buf) - maxlen]
|
||||
return entry_id
|
||||
|
||||
def xgroup_create(
|
||||
self, name: str, groupname: str, id: str = "0", mkstream: bool = False
|
||||
) -> bool:
|
||||
if mkstream and name not in self._streams:
|
||||
self._streams[name] = []
|
||||
key = f"{name}|{groupname}"
|
||||
if key in self._groups:
|
||||
raise Exception("BUSYGROUP Consumer Group name already exists")
|
||||
self._groups[key] = set()
|
||||
return True
|
||||
|
||||
def xreadgroup(
|
||||
self,
|
||||
groupname: str,
|
||||
consumername: str,
|
||||
streams: dict,
|
||||
count: Optional[int] = None,
|
||||
block: Optional[int] = None,
|
||||
) -> list:
|
||||
# 骨架:简单返回尚未标记的条目(忽略 PEL 细节)
|
||||
out: list = []
|
||||
for stream_name, _id in streams.items():
|
||||
gkey = f"{stream_name}|{groupname}"
|
||||
seen = self._groups.setdefault(gkey, set())
|
||||
entries = []
|
||||
for eid, fields in self._streams.get(stream_name, []):
|
||||
if eid in seen:
|
||||
continue
|
||||
seen.add(eid)
|
||||
entries.append((eid, fields))
|
||||
if count and len(entries) >= count:
|
||||
break
|
||||
if entries:
|
||||
out.append((stream_name, entries))
|
||||
return out
|
||||
|
||||
def xack(self, name: str, groupname: str, *ids: str) -> int:
|
||||
return len(ids)
|
||||
|
||||
def close(self) -> None:
|
||||
self._kv.clear()
|
||||
self._streams.clear()
|
||||
self._groups.clear()
|
||||
|
||||
|
||||
@dataclass
|
||||
class RedisClient:
|
||||
"""
|
||||
带前缀的 Redis 门面。
|
||||
|
||||
backend:redis(真连接)或 memory(自检)。
|
||||
禁止提供 acquire_worker_singleton 之类方法。
|
||||
"""
|
||||
|
||||
raw: RedisLike
|
||||
key_prefix: str
|
||||
backend: str
|
||||
url_safe: str = ""
|
||||
|
||||
def key(self, *parts: str) -> str:
|
||||
"""拼带前缀的完整 key。"""
|
||||
return join_key(self.key_prefix, *parts)
|
||||
|
||||
def ping(self) -> bool:
|
||||
"""连通性探测;失败抛异常。"""
|
||||
return bool(self.raw.ping())
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭底层连接。"""
|
||||
closer = getattr(self.raw, "close", None)
|
||||
if callable(closer):
|
||||
closer()
|
||||
|
||||
|
||||
def _mask_url(url: str) -> str:
|
||||
"""日志用:隐藏密码。"""
|
||||
if not url:
|
||||
return ""
|
||||
try:
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
parts = urlsplit(url)
|
||||
if parts.password is None:
|
||||
return url
|
||||
netloc = parts.hostname or ""
|
||||
if parts.port:
|
||||
netloc = f"{netloc}:{parts.port}"
|
||||
if parts.username:
|
||||
netloc = f"{parts.username}:***@{netloc}"
|
||||
return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
|
||||
except Exception: # noqa: BLE001
|
||||
return "<redis-url>"
|
||||
|
||||
|
||||
def create_redis_client(settings: Settings) -> RedisClient:
|
||||
"""
|
||||
按配置创建客户端。
|
||||
|
||||
- REDIS_BACKEND=memory 或未配 REDIS_URL 且非 prod:MemoryRedis
|
||||
- 否则 redis.from_url(REDIS_URL),decode_responses=True
|
||||
副作用:可能对 Redis PING;不写业务键。
|
||||
"""
|
||||
backend = (getattr(settings, "redis_backend", None) or "redis").strip().lower()
|
||||
prefix = normalize_prefix(settings.redis_key_prefix)
|
||||
if prefix.startswith("ytd:prod:"):
|
||||
raise RuntimeError("禁止使用现网 Redis 前缀 ytd:prod:(prompt/13、14)")
|
||||
|
||||
url = (settings.redis_url or "").strip()
|
||||
force_memory = backend == "memory" or (not url and (settings.ytd_env or "").lower() != "prod")
|
||||
|
||||
if force_memory:
|
||||
if (settings.ytd_env or "").lower() == "prod" and backend == "memory":
|
||||
raise RuntimeError("正式环境禁止 REDIS_BACKEND=memory")
|
||||
logger.warning(
|
||||
"Redis 使用 Memory 假实现(仅骨架);正式须 REDIS_URL 指向 db1 + 前缀 inquiry_robot:"
|
||||
)
|
||||
return RedisClient(raw=MemoryRedis(), key_prefix=prefix, backend="memory")
|
||||
|
||||
if not url:
|
||||
raise RuntimeError("正式/已声明 redis 后端时必须配置 REDIS_URL(建议 …/1)")
|
||||
|
||||
import redis
|
||||
|
||||
raw = redis.Redis.from_url(url, decode_responses=True, socket_connect_timeout=3)
|
||||
client = RedisClient(raw=raw, key_prefix=prefix, backend="redis", url_safe=_mask_url(url))
|
||||
client.ping()
|
||||
logger.info("Redis 已连接 url=%s prefix=%s", client.url_safe, prefix)
|
||||
return client
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
短幂等:同一 idempotency_key 在 TTL 内不重复入队。
|
||||
|
||||
本文件职责:SET NX + EX;成功表示「首次」、失败表示「重复」。
|
||||
禁止:用本机制代替主账写幂等;禁止无 TTL 的永久键。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from agent.redis_coord.client import RedisClient
|
||||
from agent.redis_coord.keys import IDEMPOTENCY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def try_acquire(
|
||||
client: RedisClient,
|
||||
*,
|
||||
scope: str,
|
||||
idempotency_key: str,
|
||||
ttl_seconds: int = 300,
|
||||
) -> bool:
|
||||
"""
|
||||
尝试占用短幂等键。
|
||||
|
||||
返回 True=首次(可入队);False=TTL 内重复。
|
||||
副作用:写带过期的 Redis 键。
|
||||
"""
|
||||
key = client.key(IDEMPOTENCY, scope, idempotency_key)
|
||||
ok = client.raw.set(key, "1", ex=ttl_seconds, nx=True)
|
||||
acquired = bool(ok)
|
||||
if not acquired:
|
||||
logger.info("短幂等命中重复 scope=%s key=%s", scope, idempotency_key)
|
||||
return acquired
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Redis 键名拼装。
|
||||
|
||||
本文件职责:所有 Redis key 必须带 REDIS_KEY_PREFIX,避免与现网 ytd:prod: 冲突。
|
||||
说明:Redis 的「db1」是连接 URL 里的数字库号(/1),不是库名;
|
||||
可读业务名靠前缀 inquiry_robot:(见 prompt/14、13)。
|
||||
禁止:写入 checkpoint / 六态 / Worker 全局单例锁键。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def normalize_prefix(prefix: str) -> str:
|
||||
"""
|
||||
规范化前缀:去掉首尾空白,确保以冒号结尾便于拼接。
|
||||
|
||||
例:inquiry_robot → inquiry_robot:
|
||||
"""
|
||||
p = (prefix or "").strip()
|
||||
if not p:
|
||||
return "inquiry_robot:"
|
||||
return p if p.endswith(":") else f"{p}:"
|
||||
|
||||
|
||||
def join_key(prefix: str, *parts: str) -> str:
|
||||
"""
|
||||
拼完整 key:{prefix}{part1}:{part2}:…
|
||||
|
||||
参数 parts 不得包含前缀本身;空段丢弃。
|
||||
"""
|
||||
base = normalize_prefix(prefix)
|
||||
segs = [str(p).strip(":") for p in parts if p is not None and str(p).strip() != ""]
|
||||
if not segs:
|
||||
return base.rstrip(":")
|
||||
return base + ":".join(segs)
|
||||
|
||||
|
||||
# 约定后缀(骨架);正式业务可扩展,勿改成无前缀的裸键
|
||||
STREAM_LLM = "stream:llm"
|
||||
STREAM_RECOGNITION = "stream:recognition"
|
||||
STREAM_PDF = "stream:pdf"
|
||||
STREAM_ARCHIVE = "stream:archive"
|
||||
STREAM_WAKE = "stream:wake"
|
||||
IDEMPOTENCY = "idem"
|
||||
RATE_LIMIT = "ratelimit"
|
||||
TOKEN_WECOM = "token:wecom"
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
固定窗口限流骨架。
|
||||
|
||||
本文件职责:按 scope 计数;超限返回可重试,不写主账失败态。
|
||||
线程:可在出站/API 路径调用;禁止在限流键上做六态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from agent.redis_coord.client import RedisClient
|
||||
from agent.redis_coord.keys import RATE_LIMIT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimitResult:
|
||||
"""限流判定结果。"""
|
||||
|
||||
allowed: bool
|
||||
count: int
|
||||
limit: int
|
||||
scope: str
|
||||
|
||||
|
||||
def allow(
|
||||
client: RedisClient,
|
||||
*,
|
||||
scope: str,
|
||||
limit: int,
|
||||
window_seconds: int = 60,
|
||||
) -> RateLimitResult:
|
||||
"""
|
||||
固定窗口:INCR + 首次 EXPIRE。
|
||||
|
||||
scope 示例:outbound:wecom / api:global / user:{sender_id}
|
||||
"""
|
||||
key = client.key(RATE_LIMIT, scope)
|
||||
count = int(client.raw.incr(key))
|
||||
if count == 1:
|
||||
client.raw.expire(key, window_seconds)
|
||||
allowed = count <= limit
|
||||
if not allowed:
|
||||
logger.info(
|
||||
"限流触发 scope=%s count=%s limit=%s",
|
||||
scope,
|
||||
count,
|
||||
limit,
|
||||
)
|
||||
return RateLimitResult(allowed=allowed, count=count, limit=limit, scope=scope)
|
||||
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Redis 协调运行时:进程内持有客户端与常用队列句柄。
|
||||
|
||||
本文件职责:懒创建 / 关闭;供 Worker、llm、outbox 使用。
|
||||
禁止:在 HTTP 回调里用本运行时同步等 LLM 结果。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
from agent.redis_coord.client import RedisClient, create_redis_client
|
||||
from agent.redis_coord.stream_queue import StreamQueue, llm_queue, recognition_queue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RedisRuntime:
|
||||
"""
|
||||
Redis 协调门面。
|
||||
|
||||
生命周期:HTTP/Worker 启动时 get_redis_runtime();退出 shutdown。
|
||||
"""
|
||||
|
||||
client: RedisClient
|
||||
llm_stream: StreamQueue
|
||||
recognition_stream: StreamQueue
|
||||
settings: Settings
|
||||
_closed: bool = field(default=False)
|
||||
|
||||
@classmethod
|
||||
def create(cls, settings: Optional[Settings] = None) -> "RedisRuntime":
|
||||
"""
|
||||
建连并准备 LLM/识别 Stream 消费组(骨架)。
|
||||
|
||||
副作用:ensure_group;memory 后端无外部 IO。
|
||||
"""
|
||||
cfg = settings or get_settings()
|
||||
client = create_redis_client(cfg)
|
||||
llm = llm_queue(client)
|
||||
recog = recognition_queue(client)
|
||||
llm.ensure_group()
|
||||
recog.ensure_group()
|
||||
logger.info(
|
||||
"RedisRuntime 就绪 backend=%s prefix=%s",
|
||||
client.backend,
|
||||
client.key_prefix,
|
||||
)
|
||||
return cls(
|
||||
client=client,
|
||||
llm_stream=llm,
|
||||
recognition_stream=recog,
|
||||
settings=cfg,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭底层连接。"""
|
||||
if self._closed:
|
||||
return
|
||||
self.client.close()
|
||||
self._closed = True
|
||||
|
||||
|
||||
_runtime: Optional[RedisRuntime] = None
|
||||
_guard = threading.Lock()
|
||||
|
||||
|
||||
def get_redis_runtime(settings: Optional[Settings] = None) -> RedisRuntime:
|
||||
"""进程内单例;首次创建时可能连 Redis。"""
|
||||
global _runtime
|
||||
with _guard:
|
||||
if _runtime is None:
|
||||
_runtime = RedisRuntime.create(settings)
|
||||
return _runtime
|
||||
|
||||
|
||||
def shutdown_redis_runtime() -> None:
|
||||
"""进程退出时关闭。"""
|
||||
global _runtime
|
||||
with _guard:
|
||||
if _runtime is not None:
|
||||
_runtime.close()
|
||||
_runtime = None
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Redis 协调骨架自检(默认 Memory 后端)。
|
||||
|
||||
用法:
|
||||
set REDIS_BACKEND=memory
|
||||
python -m agent.redis_coord.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")
|
||||
os.environ["REDIS_BACKEND"] = "memory"
|
||||
os.environ.setdefault("YTD_ENV", "test")
|
||||
os.environ.setdefault("REDIS_KEY_PREFIX", "inquiry_robot:")
|
||||
|
||||
from agent.config import get_settings
|
||||
from agent.redis_coord import (
|
||||
get_redis_runtime,
|
||||
get_wecom_token,
|
||||
rate_limit_allow,
|
||||
set_wecom_token,
|
||||
shutdown_redis_runtime,
|
||||
try_acquire_idempotency,
|
||||
)
|
||||
from agent.redis_coord.keys import join_key
|
||||
|
||||
get_settings.cache_clear()
|
||||
# 证明:名字在前缀,不在「库名」
|
||||
k = join_key("inquiry_robot:", "stream", "llm")
|
||||
assert k.startswith("inquiry_robot:"), k
|
||||
logger.info("OK: 键前缀示例 %s(db 号在 REDIS_URL 的 /1,不在键名里)", k)
|
||||
|
||||
rt = get_redis_runtime(get_settings())
|
||||
try:
|
||||
assert rt.client.ping()
|
||||
logger.info("OK: ping backend=%s", rt.client.backend)
|
||||
|
||||
assert try_acquire_idempotency(
|
||||
rt.client, scope="selftest", idempotency_key="once", ttl_seconds=60
|
||||
)
|
||||
assert not try_acquire_idempotency(
|
||||
rt.client, scope="selftest", idempotency_key="once", ttl_seconds=60
|
||||
)
|
||||
logger.info("OK: 短幂等")
|
||||
|
||||
r1 = rate_limit_allow(rt.client, scope="selftest", limit=2, window_seconds=60)
|
||||
r2 = rate_limit_allow(rt.client, scope="selftest", limit=2, window_seconds=60)
|
||||
r3 = rate_limit_allow(rt.client, scope="selftest", limit=2, window_seconds=60)
|
||||
assert r1.allowed and r2.allowed and not r3.allowed
|
||||
logger.info("OK: 限流")
|
||||
|
||||
set_wecom_token(rt.client, agent_id="1000010", token="tok-selftest", ttl_seconds=30)
|
||||
assert get_wecom_token(rt.client, agent_id="1000010") == "tok-selftest"
|
||||
logger.info("OK: token 缓存")
|
||||
|
||||
eid = rt.llm_stream.enqueue(
|
||||
payload={"mode": "ordered_actions", "text": "hi"},
|
||||
idempotency_key="msg-1",
|
||||
)
|
||||
claimed = rt.llm_stream.claim(consumer="selftest-worker", count=1, block_ms=10)
|
||||
assert claimed and claimed[0][0] == eid
|
||||
rt.llm_stream.ack(eid)
|
||||
logger.info("OK: Stream 入队/认领/ack id=%s", eid)
|
||||
logger.info("Redis 骨架自检通过")
|
||||
return 0
|
||||
finally:
|
||||
shutdown_redis_runtime()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Redis Stream 任务队列骨架。
|
||||
|
||||
本文件职责:XADD 入队、确保消费组、XREADGROUP 认领占位;结果不在回调线程 wait。
|
||||
禁止:Stream 与 PG 任务表双写两套真相(选型后只留一条);禁止全局单例锁。
|
||||
线程:enqueue 可在 HTTP 入队后立刻返回;claim 只在 Worker 槽调用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.redis_coord.client import RedisClient
|
||||
from agent.redis_coord.keys import STREAM_LLM, STREAM_RECOGNITION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamQueue:
|
||||
"""
|
||||
一条 Stream + 一个消费组的骨架封装。
|
||||
|
||||
stream_suffix:如 stream:llm;完整 key = prefix + suffix。
|
||||
"""
|
||||
|
||||
client: RedisClient
|
||||
stream_suffix: str
|
||||
group: str = "inquiry-workers"
|
||||
maxlen: int = 10000
|
||||
|
||||
@property
|
||||
def stream_key(self) -> str:
|
||||
return self.client.key(self.stream_suffix)
|
||||
|
||||
def ensure_group(self) -> None:
|
||||
"""
|
||||
创建消费组(已存在则忽略)。
|
||||
|
||||
副作用:可能 MKSTREAM;不消费消息。
|
||||
"""
|
||||
try:
|
||||
self.client.raw.xgroup_create(
|
||||
self.stream_key, self.group, id="0", mkstream=True
|
||||
)
|
||||
logger.info("Stream 消费组已创建 stream=%s group=%s", self.stream_key, self.group)
|
||||
except Exception as exc: # noqa: BLE001 — BUSYGROUP 等
|
||||
if "BUSYGROUP" in str(exc).upper():
|
||||
return
|
||||
# redis-py 用 ResponseError
|
||||
msg = str(exc).upper()
|
||||
if "BUSYGROUP" in msg or "ALREADY EXISTS" in msg:
|
||||
return
|
||||
raise
|
||||
|
||||
def enqueue(self, *, payload: dict[str, Any], idempotency_key: str = "") -> str:
|
||||
"""
|
||||
入队一条任务(只写 Stream,不等待结果)。
|
||||
|
||||
返回:stream entry id。
|
||||
调用方可在 HTTP 线程:禁止随后阻塞等 LLM。
|
||||
"""
|
||||
fields = {
|
||||
"payload": json.dumps(payload, ensure_ascii=False),
|
||||
"idempotency_key": idempotency_key or "",
|
||||
}
|
||||
entry_id = self.client.raw.xadd(
|
||||
self.stream_key, fields, id="*", maxlen=self.maxlen
|
||||
)
|
||||
logger.info(
|
||||
"Stream 入队 stream=%s id=%s idem=%s",
|
||||
self.stream_key,
|
||||
entry_id,
|
||||
idempotency_key or "-",
|
||||
)
|
||||
return str(entry_id)
|
||||
|
||||
def claim(
|
||||
self, *, consumer: str, count: int = 1, block_ms: int = 1000
|
||||
) -> list[tuple[str, dict[str, Any]]]:
|
||||
"""
|
||||
认领最多 count 条(骨架:无 PEL 租约心跳,后续补)。
|
||||
|
||||
必须在 Worker 槽调用;返回 [(entry_id, payload_dict), …]。
|
||||
"""
|
||||
rows = self.client.raw.xreadgroup(
|
||||
self.group,
|
||||
consumer,
|
||||
streams={self.stream_key: ">"},
|
||||
count=count,
|
||||
block=block_ms,
|
||||
)
|
||||
out: list[tuple[str, dict[str, Any]]] = []
|
||||
if not rows:
|
||||
return out
|
||||
for _stream, entries in rows:
|
||||
for entry_id, fields in entries:
|
||||
raw_payload = fields.get("payload") or "{}"
|
||||
try:
|
||||
data = json.loads(raw_payload)
|
||||
except json.JSONDecodeError:
|
||||
data = {"raw": raw_payload}
|
||||
out.append((str(entry_id), data))
|
||||
return out
|
||||
|
||||
def ack(self, entry_id: str) -> None:
|
||||
"""确认处理完成。"""
|
||||
self.client.raw.xack(self.stream_key, self.group, entry_id)
|
||||
|
||||
|
||||
def llm_queue(client: RedisClient) -> StreamQueue:
|
||||
"""LLM 任务 Stream(骨架)。"""
|
||||
return StreamQueue(client=client, stream_suffix=STREAM_LLM)
|
||||
|
||||
|
||||
def recognition_queue(client: RedisClient) -> StreamQueue:
|
||||
"""识别任务 Stream(骨架)。"""
|
||||
return StreamQueue(client=client, stream_suffix=STREAM_RECOGNITION)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
企微 access_token 等短缓存。
|
||||
|
||||
本文件职责:GET/SET+TTL;未命中由调用方再打企微,失败可回退。
|
||||
禁止:把 token 当业务真相;挂了必须能重拉,不要把工单打成「未认证」。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from agent.redis_coord.client import RedisClient
|
||||
from agent.redis_coord.keys import TOKEN_WECOM
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_wecom_token(client: RedisClient, *, agent_id: str) -> Optional[str]:
|
||||
"""读取缓存 token;未命中返回 None。"""
|
||||
key = client.key(TOKEN_WECOM, agent_id)
|
||||
val = client.raw.get(key)
|
||||
if val is None:
|
||||
return None
|
||||
return val if isinstance(val, str) else val.decode("utf-8")
|
||||
|
||||
|
||||
def set_wecom_token(
|
||||
client: RedisClient,
|
||||
*,
|
||||
agent_id: str,
|
||||
token: str,
|
||||
ttl_seconds: int = 7000,
|
||||
) -> None:
|
||||
"""
|
||||
写入 token(默认 TTL 略短于企微 7200s)。
|
||||
|
||||
副作用:覆盖同 agent_id 缓存键。
|
||||
"""
|
||||
key = client.key(TOKEN_WECOM, agent_id)
|
||||
client.raw.set(key, token, ex=ttl_seconds)
|
||||
logger.info("企微 token 已缓存 agent_id=%s ttl=%s", agent_id, ttl_seconds)
|
||||
@@ -2,5 +2,97 @@
|
||||
意图路由:DeepSeek A → 唯一 RouteDecision。
|
||||
|
||||
禁止:在本包产生业务副作用(建单、发消息、改六态)。
|
||||
骨架:仅占位。
|
||||
正式路径:DeepSeekARouter(strict tool);route_stub 仅兼容旧自检名。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from agent.llm import LlmGateway, LlmMode
|
||||
from agent.routing.decision import RouteDecision
|
||||
from agent.routing.deepseek_a import DeepSeekARouter
|
||||
from agent.routing.dispatch import HANDLER_REGISTRY, dispatch_inbound, resolve_handler
|
||||
from agent.routing.ordered_actions import (
|
||||
ORDERED_ACTIONS_TOOL_NAME,
|
||||
OrderedActionsError,
|
||||
OrderedActionsResult,
|
||||
ordered_actions_tool_definition,
|
||||
parse_ordered_actions_arguments,
|
||||
)
|
||||
|
||||
# IntentRouter 定义见下
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntentRouter:
|
||||
"""
|
||||
路由门面。
|
||||
|
||||
- route():优先 DeepSeek A(默认 stub,不开费)
|
||||
- enqueue_ordered_actions():快回路径入 LLM Stream
|
||||
"""
|
||||
|
||||
llm: Optional[LlmGateway] = None
|
||||
deepseek_a: Optional[DeepSeekARouter] = None
|
||||
allow_network: bool = False
|
||||
|
||||
def _a(self) -> DeepSeekARouter:
|
||||
if self.deepseek_a is not None:
|
||||
return self.deepseek_a
|
||||
return DeepSeekARouter.from_settings(allow_network=self.allow_network)
|
||||
|
||||
def route_stub(self, *, text: str, sender_id: str) -> RouteDecision:
|
||||
"""
|
||||
兼容旧名:等价于无网络的 A stub fixture。
|
||||
|
||||
正式生产请用 route() 并在 Worker 开 allow_network + 数据确认。
|
||||
"""
|
||||
return self._a().route_stub_fixture(text=text, sender_id=sender_id)
|
||||
|
||||
def route(self, *, text: str, sender_id: str) -> RouteDecision:
|
||||
"""一条入站一个 RouteDecision。"""
|
||||
return self._a().route(text=text, sender_id=sender_id)
|
||||
|
||||
def enqueue_ordered_actions(
|
||||
self,
|
||||
*,
|
||||
text: str,
|
||||
sender_id: str,
|
||||
idempotency_key: str,
|
||||
) -> str:
|
||||
"""
|
||||
将 A 路由任务入 LLM 队列(快回路径用)。
|
||||
|
||||
返回 stream entry id;无 LlmGateway 时抛错。
|
||||
"""
|
||||
if self.llm is None:
|
||||
raise RuntimeError("IntentRouter 未注入 LlmGateway")
|
||||
result = self.llm.enqueue(
|
||||
mode=LlmMode.ORDERED_ACTIONS,
|
||||
payload={"text": text, "sender_id": sender_id},
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
if not result.accepted:
|
||||
raise RuntimeError(result.reason or "enqueue_rejected")
|
||||
return result.entry_id
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RouteDecision",
|
||||
"IntentRouter",
|
||||
"DeepSeekARouter",
|
||||
"ORDERED_ACTIONS_TOOL_NAME",
|
||||
"OrderedActionsError",
|
||||
"OrderedActionsResult",
|
||||
"ordered_actions_tool_definition",
|
||||
"parse_ordered_actions_arguments",
|
||||
"HANDLER_REGISTRY",
|
||||
"dispatch_inbound",
|
||||
"resolve_handler",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
RouteDecision 值对象。
|
||||
|
||||
本文件只放冻结路由结论;解析逻辑见 ordered_actions.py;副作用在 handlers。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteDecision:
|
||||
"""
|
||||
唯一路由结论(一条入站对应一个)。
|
||||
|
||||
intent:如 ordinary_text_inquiry / attachment_inquiry / reject_no_sender
|
||||
thread_id:继续本单时填工单号;新开可空由 Handler 生成。
|
||||
"""
|
||||
|
||||
intent: str
|
||||
thread_id: str = ""
|
||||
confidence: float = 0.0
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
DeepSeek A 调用壳:组装 messages + route_decision tool,解析为 RouteDecision。
|
||||
|
||||
本文件职责:Worker 槽内跑 A 路由;默认不打外网(需 LLM_DATA_USAGE_CONFIRMED)。
|
||||
禁止:在企微回调线程同步调用本模块的网络路径。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
from agent.llm.http_client import LlmHttpClient, LlmHttpResult
|
||||
from agent.llm.modes import LlmMode
|
||||
from agent.routing.decision import RouteDecision
|
||||
from agent.routing.ordered_actions import (
|
||||
ORDERED_ACTIONS_TOOL_NAME,
|
||||
OrderedActionsError,
|
||||
extract_tool_arguments_from_chat_raw,
|
||||
ordered_actions_tool_definition,
|
||||
parse_ordered_actions_arguments,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SYSTEM = (
|
||||
"You are DeepSeek-A for inquiry routing. "
|
||||
"You must call the function tool route_decision exactly once. "
|
||||
"Do not write user-facing text. Do not extract business fields."
|
||||
)
|
||||
|
||||
|
||||
def _digest_text(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeepSeekARouter:
|
||||
"""
|
||||
DeepSeek A 路由器。
|
||||
|
||||
allow_network:是否允许真 HTTP;仍受 settings.llm_data_usage_confirmed 约束。
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
http: LlmHttpClient
|
||||
allow_network: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_settings(
|
||||
cls,
|
||||
settings: Optional[Settings] = None,
|
||||
*,
|
||||
allow_network: bool = False,
|
||||
) -> "DeepSeekARouter":
|
||||
cfg = settings or get_settings()
|
||||
return cls(
|
||||
settings=cfg,
|
||||
http=LlmHttpClient.from_settings(cfg),
|
||||
allow_network=allow_network,
|
||||
)
|
||||
|
||||
def build_messages(self, *, text: str, sender_id: str) -> list[dict[str, Any]]:
|
||||
"""构造仅含 inbound_facts 的 messages(不做字段抽取)。"""
|
||||
facts = {
|
||||
"sender_id": sender_id,
|
||||
"text": text,
|
||||
"text_sha256": _digest_text(text or ""),
|
||||
}
|
||||
return [
|
||||
{"role": "system", "content": _SYSTEM},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"inbound_facts (JSON-like):\n"
|
||||
f"sender_id={facts['sender_id']}\n"
|
||||
f"text_sha256={facts['text_sha256']}\n"
|
||||
f"text={facts['text']}\n"
|
||||
"Call route_decision once."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
def route_from_tool_args(self, args: dict[str, Any] | str) -> RouteDecision:
|
||||
"""仅解析已有 tool 参数(单测 / stub / 回放)。"""
|
||||
return parse_ordered_actions_arguments(args).to_route_decision()
|
||||
|
||||
def route_stub_fixture(self, *, text: str, sender_id: str) -> RouteDecision:
|
||||
"""
|
||||
无外网固定夹具:把非空文本标成 ordinary_text_inquiry。
|
||||
|
||||
仅骨架联调;正式生产必须走模型或已落库的 tool 回放。
|
||||
"""
|
||||
if not sender_id:
|
||||
return RouteDecision(intent="reject_no_sender", confidence=1.0)
|
||||
content = (text or "").strip()
|
||||
if not content:
|
||||
return RouteDecision(intent="ignore_empty", confidence=1.0)
|
||||
digest = _digest_text(content)
|
||||
args = {
|
||||
"decision_id": f"STUB-{digest[:16]}",
|
||||
"intent": "ordinary_text_inquiry",
|
||||
"ordered_action": {
|
||||
"action_type": "ORDINARY_TEXT_INQUIRY",
|
||||
"order": 1,
|
||||
"target": {"work_order_no": ""},
|
||||
"evidence": {
|
||||
"source_ref": f"inbound:{sender_id}",
|
||||
"content_digest": digest,
|
||||
},
|
||||
},
|
||||
}
|
||||
logger.info("DeepSeekA stub fixture sender=%s", sender_id)
|
||||
return self.route_from_tool_args(args)
|
||||
|
||||
def route(self, *, text: str, sender_id: str) -> RouteDecision:
|
||||
"""
|
||||
执行 A 路由。
|
||||
|
||||
无 sender_id → reject;未开网络或未确认数据用途 → stub fixture;
|
||||
开网络 → tool_choice 强制 route_decision。
|
||||
"""
|
||||
if not sender_id:
|
||||
return RouteDecision(intent="reject_no_sender", confidence=1.0)
|
||||
if not self.allow_network or not self.settings.llm_data_usage_confirmed:
|
||||
if self.allow_network and not self.settings.llm_data_usage_confirmed:
|
||||
logger.warning(
|
||||
"LLM_DATA_USAGE_CONFIRMED 未确认,拒绝打真实 DeepSeek A,改用 stub"
|
||||
)
|
||||
return self.route_stub_fixture(text=text, sender_id=sender_id)
|
||||
|
||||
messages = self.build_messages(text=text, sender_id=sender_id)
|
||||
tools = [ordered_actions_tool_definition()]
|
||||
result: LlmHttpResult = self.http.chat(
|
||||
mode=LlmMode.ORDERED_ACTIONS,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
tool_choice={"type": "function", "function": {"name": ORDERED_ACTIONS_TOOL_NAME}},
|
||||
)
|
||||
if not result.ok:
|
||||
logger.warning("DeepSeek A HTTP 失败 err=%s", result.error)
|
||||
return RouteDecision(
|
||||
intent="route_model_failed",
|
||||
confidence=0.0,
|
||||
raw={"error": result.error, "provider": result.provider},
|
||||
)
|
||||
try:
|
||||
args = extract_tool_arguments_from_chat_raw(result.raw)
|
||||
return self.route_from_tool_args(args)
|
||||
except OrderedActionsError as exc:
|
||||
logger.warning("DeepSeek A 解析失败: %s", exc)
|
||||
return RouteDecision(
|
||||
intent="route_parse_failed",
|
||||
confidence=0.0,
|
||||
raw={"error": str(exc)},
|
||||
)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
入站分发壳:RouteDecision.intent → 唯一 handler。
|
||||
|
||||
本文件职责:维护意图→可调用对象表;dispatch_inbound 只查表调用。
|
||||
禁止:在本文件写补问/建单/TMS 业务;禁止一个函数处理全部卡片。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Callable, Optional
|
||||
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
from agent.handlers import abnormal, card_action, close_deal, continue_thread, new_inquiry
|
||||
from agent.handlers.echo_text import handle_echo_text
|
||||
from agent.routing.decision import RouteDecision
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Handler 签名约定:只收 InboundMessage(出站/store 由 handler 自行取依赖)
|
||||
HandlerFn = Callable[[InboundMessage], None]
|
||||
|
||||
|
||||
def _noop(message: InboundMessage) -> None:
|
||||
"""忽略类意图:不做事。"""
|
||||
logger.info("dispatch.noop sender=%s msg=%s", message.sender_id, message.message_id)
|
||||
|
||||
|
||||
def _wrap_echo(message: InboundMessage) -> None:
|
||||
"""
|
||||
联调占位:文字意图暂走 echo(正式应换成 new_inquiry / Graph)。
|
||||
|
||||
框架接线用;不含字段抽取。
|
||||
"""
|
||||
from agent.channel.queue import get_message_store
|
||||
from agent.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
handle_echo_text(
|
||||
message,
|
||||
get_message_store(),
|
||||
ytd_env=settings.ytd_env,
|
||||
reply_template=settings.wecom_callback_reply_text,
|
||||
)
|
||||
|
||||
|
||||
def _wrap_new(message: InboundMessage) -> None:
|
||||
new_inquiry.handle_new_inquiry(message)
|
||||
|
||||
|
||||
def _wrap_continue(message: InboundMessage) -> None:
|
||||
continue_thread.handle_continue_thread(message, thread_id="")
|
||||
|
||||
|
||||
def _wrap_card(message: InboundMessage) -> None:
|
||||
# 框架:卡片字段尚未从 payload 解析;空壳用默认
|
||||
card_action.handle_card_action(
|
||||
sender_id=message.sender_id,
|
||||
thread_id="",
|
||||
wait_version=0,
|
||||
action="unknown",
|
||||
payload={},
|
||||
)
|
||||
|
||||
|
||||
def _wrap_close(message: InboundMessage) -> None:
|
||||
close_deal.handle_close_deal(
|
||||
sender_id=message.sender_id,
|
||||
inquiry_no="",
|
||||
outcome="unknown",
|
||||
)
|
||||
|
||||
|
||||
def _wrap_abnormal(message: InboundMessage) -> None:
|
||||
abnormal.handle_abnormal(sender_id=message.sender_id, inquiry_no="", reason="dispatch")
|
||||
|
||||
|
||||
# 意图 → handler(空壳或联调 echo);扩展时只改表,不改 ChannelRuntime 分支森林
|
||||
HANDLER_REGISTRY: dict[str, HandlerFn] = {
|
||||
"echo_text": _wrap_echo,
|
||||
"ordinary_text_inquiry": _wrap_echo, # 联调:有回复;正式换业务 handler
|
||||
"ordinary_text_other": _wrap_echo,
|
||||
"attachment_inquiry": _wrap_new,
|
||||
"image_inquiry": _wrap_new,
|
||||
"multi_segment_transport": _wrap_new,
|
||||
"tms_read_only_quote": _wrap_continue,
|
||||
"new_inquiry": _wrap_new,
|
||||
"continue_thread": _wrap_continue,
|
||||
"card_action": _wrap_card,
|
||||
"close_deal": _wrap_close,
|
||||
"abnormal": _wrap_abnormal,
|
||||
"reject_no_sender": _noop,
|
||||
"ignore_empty": _noop,
|
||||
"stub_needs_model": _wrap_echo,
|
||||
}
|
||||
|
||||
|
||||
def resolve_handler(intent: str) -> Optional[HandlerFn]:
|
||||
"""查表;未知意图返回 None。"""
|
||||
return HANDLER_REGISTRY.get(intent)
|
||||
|
||||
|
||||
def dispatch_inbound(message: InboundMessage, decision: RouteDecision) -> str:
|
||||
"""
|
||||
按 RouteDecision 调用唯一 handler。
|
||||
|
||||
返回:实际调用的 intent 名,或 unknown_intent / skipped。
|
||||
副作用:仅 handler 内产生;本函数不做业务。
|
||||
"""
|
||||
if not message.has_sender() and decision.intent != "reject_no_sender":
|
||||
logger.warning("dispatch 拒绝:无 sender_id")
|
||||
HANDLER_REGISTRY["reject_no_sender"](message)
|
||||
return "reject_no_sender"
|
||||
|
||||
fn = resolve_handler(decision.intent)
|
||||
if fn is None:
|
||||
logger.warning("dispatch 未知意图 intent=%s → noop", decision.intent)
|
||||
_noop(message)
|
||||
return "unknown_intent"
|
||||
|
||||
logger.info(
|
||||
"dispatch intent=%s sender=%s msg=%s",
|
||||
decision.intent,
|
||||
message.sender_id,
|
||||
message.message_id,
|
||||
)
|
||||
fn(message)
|
||||
return decision.intent
|
||||
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
DeepSeek A:`ordered_actions` / `route_decision` 工具合同与解析。
|
||||
|
||||
本文件职责:
|
||||
- 给出唯一 strict tool schema(对齐现网 framework_route_contract 垂直切片);
|
||||
- 把模型 tool_call 参数校验并投影为 RouteDecision;
|
||||
- 不产生业务副作用(不建单、不发消息、不改六态)。
|
||||
|
||||
为何独立成文件:schema 与 IntentRouter / HTTP 客户端解耦,便于单测与合同演进。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
from agent.routing.decision import RouteDecision
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ORDERED_ACTIONS_TOOL_NAME = "route_decision"
|
||||
|
||||
# action_type(模型枚举)→ 业务意图(RouteDecision.intent)
|
||||
ACTION_INTENTS: dict[str, str] = {
|
||||
"ATTACHMENT_DOCUMENT_INQUIRY": "attachment_inquiry",
|
||||
"ATTACHMENT_IMAGE_INQUIRY": "image_inquiry",
|
||||
"MULTI_SEGMENT_TRANSPORT": "multi_segment_transport",
|
||||
"TMS_READ_ONLY_QUOTE": "tms_read_only_quote",
|
||||
"ORDINARY_TEXT_INQUIRY": "ordinary_text_inquiry",
|
||||
"ORDINARY_TEXT_OTHER": "ordinary_text_other",
|
||||
}
|
||||
|
||||
|
||||
class OrderedActionsError(ValueError):
|
||||
"""ordered_actions 合同被违反(缺键、枚举不符、证据非法等)。"""
|
||||
|
||||
|
||||
def _nfc(value: str) -> str:
|
||||
"""非空 NFC 规范化;首尾空白视为非法(与现网一致)。"""
|
||||
result = unicodedata.normalize("NFC", str(value))
|
||||
if not result or result != result.strip():
|
||||
raise OrderedActionsError("字符串须为非空 NFC 且无首尾空白")
|
||||
return result
|
||||
|
||||
|
||||
def ordered_actions_tool_definition() -> dict[str, Any]:
|
||||
"""
|
||||
返回 DeepSeek A 唯一 strict tool。
|
||||
|
||||
模型必须且只能调用本 tool;不得输出用户可见正文。
|
||||
"""
|
||||
action_types = sorted(ACTION_INTENTS)
|
||||
intents = sorted(set(ACTION_INTENTS.values()))
|
||||
target_schema = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {"work_order_no": {"type": "string"}},
|
||||
"required": ["work_order_no"],
|
||||
}
|
||||
evidence_schema = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"source_ref": {"type": "string"},
|
||||
"content_digest": {"type": "string"},
|
||||
},
|
||||
"required": ["source_ref", "content_digest"],
|
||||
}
|
||||
action_schema = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"action_type": {"type": "string", "enum": action_types},
|
||||
"order": {"type": "integer", "const": 1},
|
||||
"target": target_schema,
|
||||
"evidence": evidence_schema,
|
||||
},
|
||||
"required": ["action_type", "order", "target", "evidence"],
|
||||
}
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"decision_id": {"type": "string"},
|
||||
"intent": {"type": "string", "enum": intents},
|
||||
# 工具入参用单数;解析后统一成 ordered_actions 列表
|
||||
"ordered_action": action_schema,
|
||||
},
|
||||
"required": ["decision_id", "intent", "ordered_action"],
|
||||
}
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": ORDERED_ACTIONS_TOOL_NAME,
|
||||
"description": (
|
||||
"Return exactly one frozen business route decision "
|
||||
"for the current inbound event."
|
||||
),
|
||||
"strict": True,
|
||||
"parameters": parameters,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OrderedActionsResult:
|
||||
"""校验后的 A 路由冻结结果(一条入站一条)。"""
|
||||
|
||||
decision_id: str
|
||||
intent: str
|
||||
action_type: str
|
||||
work_order_no: str
|
||||
evidence_source_ref: str
|
||||
evidence_content_digest: str
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_route_decision(self, *, confidence: float = 1.0) -> RouteDecision:
|
||||
"""投影为唯一 RouteDecision(仍无副作用)。"""
|
||||
return RouteDecision(
|
||||
intent=self.intent,
|
||||
thread_id=self.work_order_no or "",
|
||||
confidence=confidence,
|
||||
raw={
|
||||
"decision_id": self.decision_id,
|
||||
"action_type": self.action_type,
|
||||
"ordered_actions": self.raw,
|
||||
"source": "deepseek_a",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _coerce_tool_args(value: Any) -> dict[str, Any]:
|
||||
"""接受 dict 或 JSON 字符串。"""
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
data = json.loads(value)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise OrderedActionsError("tool arguments 不是合法 JSON") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise OrderedActionsError("tool arguments 必须是对象")
|
||||
return data
|
||||
raise OrderedActionsError("tool arguments 类型非法")
|
||||
|
||||
|
||||
def parse_ordered_actions_arguments(
|
||||
value: Mapping[str, Any] | str | dict[str, Any],
|
||||
) -> OrderedActionsResult:
|
||||
"""
|
||||
解析并校验 route_decision tool 参数。
|
||||
|
||||
副作用:无。失败抛 OrderedActionsError。
|
||||
"""
|
||||
data = _coerce_tool_args(value)
|
||||
decision_id = _nfc(str(data.get("decision_id") or ""))
|
||||
intent = _nfc(str(data.get("intent") or ""))
|
||||
action = data.get("ordered_action")
|
||||
if action is None and isinstance(data.get("ordered_actions"), list):
|
||||
actions = data["ordered_actions"]
|
||||
if len(actions) != 1 or not isinstance(actions[0], dict):
|
||||
raise OrderedActionsError("ordered_actions 必须恰好一条")
|
||||
action = actions[0]
|
||||
if not isinstance(action, dict):
|
||||
raise OrderedActionsError("缺少 ordered_action")
|
||||
|
||||
action_type = _nfc(str(action.get("action_type") or ""))
|
||||
if action_type not in ACTION_INTENTS:
|
||||
raise OrderedActionsError(f"未知 action_type: {action_type}")
|
||||
if ACTION_INTENTS[action_type] != intent:
|
||||
raise OrderedActionsError("action_type 与 intent 不一致")
|
||||
if action.get("order") != 1:
|
||||
raise OrderedActionsError("order 必须为 1(当前垂直切片)")
|
||||
|
||||
target = action.get("target")
|
||||
if not isinstance(target, dict):
|
||||
raise OrderedActionsError("target 必须为对象")
|
||||
work_order_no = str(target.get("work_order_no") or "").strip()
|
||||
# 允许空工单号(新询价);若有值则 NFC
|
||||
if work_order_no:
|
||||
work_order_no = _nfc(work_order_no)
|
||||
|
||||
evidence = action.get("evidence")
|
||||
# 工具 schema 是单对象;兼容列表写法
|
||||
if isinstance(evidence, list):
|
||||
if len(evidence) != 1 or not isinstance(evidence[0], dict):
|
||||
raise OrderedActionsError("evidence 须恰好一条")
|
||||
evidence = evidence[0]
|
||||
if not isinstance(evidence, dict):
|
||||
raise OrderedActionsError("evidence 必须为对象")
|
||||
source_ref = _nfc(str(evidence.get("source_ref") or ""))
|
||||
digest = str(evidence.get("content_digest") or "").lower()
|
||||
if len(digest) != 64 or any(c not in "0123456789abcdef" for c in digest):
|
||||
raise OrderedActionsError("content_digest 必须是 64 位小写 hex")
|
||||
|
||||
frozen = {
|
||||
"decision_id": decision_id,
|
||||
"intent": intent,
|
||||
"ordered_actions": [
|
||||
{
|
||||
"action_type": action_type,
|
||||
"order": 1,
|
||||
"target": {"work_order_no": work_order_no or None},
|
||||
"evidence": [
|
||||
{"source_ref": source_ref, "content_digest": digest},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
logger.info(
|
||||
"ordered_actions 已校验 decision_id=%s intent=%s action=%s",
|
||||
decision_id,
|
||||
intent,
|
||||
action_type,
|
||||
)
|
||||
return OrderedActionsResult(
|
||||
decision_id=decision_id,
|
||||
intent=intent,
|
||||
action_type=action_type,
|
||||
work_order_no=work_order_no,
|
||||
evidence_source_ref=source_ref,
|
||||
evidence_content_digest=digest,
|
||||
raw=frozen,
|
||||
)
|
||||
|
||||
|
||||
def extract_tool_arguments_from_chat_raw(
|
||||
raw: Optional[Mapping[str, Any]],
|
||||
*,
|
||||
tool_name: str = ORDERED_ACTIONS_TOOL_NAME,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
从 OpenAI 兼容 chat 响应中取出指定 tool 的 arguments。
|
||||
|
||||
未找到则抛 OrderedActionsError。
|
||||
"""
|
||||
if not raw:
|
||||
raise OrderedActionsError("空模型响应")
|
||||
choices = raw.get("choices") or []
|
||||
if not choices:
|
||||
raise OrderedActionsError("无 choices")
|
||||
message = (choices[0] or {}).get("message") or {}
|
||||
tool_calls = message.get("tool_calls") or []
|
||||
for call in tool_calls:
|
||||
fn = (call or {}).get("function") or {}
|
||||
if str(fn.get("name") or "") == tool_name:
|
||||
return _coerce_tool_args(fn.get("arguments"))
|
||||
raise OrderedActionsError(f"未调用 tool: {tool_name}")
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
PostgreSQL 运行账连接与迁移。
|
||||
|
||||
本包职责:解析 PG_DSN、安全拒绝现网库名、执行版本化 SQL。
|
||||
禁止:智能体经本包写 MySQL 工单表;禁止 FLUSH / 删现网库。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 现网运行账库名:新系统迁移脚本绝对不能碰
|
||||
_FORBIDDEN_DB_NAMES = frozenset({"ytd_runtime"})
|
||||
|
||||
|
||||
def migrations_dir() -> Path:
|
||||
"""返回 inquiry-agent/migrations 目录。"""
|
||||
return Path(__file__).resolve().parents[2] / "migrations"
|
||||
|
||||
|
||||
def assert_safe_database(dsn: str) -> str:
|
||||
"""
|
||||
从 DSN 解析库名并校验。
|
||||
|
||||
返回库名;若为 ytd_runtime 则抛错。
|
||||
"""
|
||||
if not dsn:
|
||||
raise RuntimeError("PG_DSN 为空,无法迁移")
|
||||
parsed = urlparse(dsn)
|
||||
db = (parsed.path or "").lstrip("/").split("?")[0]
|
||||
if not db:
|
||||
raise RuntimeError(f"无法从 DSN 解析库名: {dsn[:32]}...")
|
||||
if db in _FORBIDDEN_DB_NAMES:
|
||||
raise RuntimeError(f"禁止对现网库执行迁移: {db}")
|
||||
return db
|
||||
|
||||
|
||||
def connect_psycopg(dsn: str):
|
||||
"""
|
||||
打开 psycopg 连接(调用方负责 close)。
|
||||
|
||||
副作用:网络连库。
|
||||
"""
|
||||
import psycopg
|
||||
|
||||
return psycopg.connect(dsn, connect_timeout=5)
|
||||
|
||||
|
||||
def apply_migrations(settings: Optional[Settings] = None) -> list[str]:
|
||||
"""
|
||||
按文件名顺序执行尚未应用的 *.sql。
|
||||
|
||||
返回:本次新执行的 version 列表。
|
||||
无 DSN:抛错(自检可用 --dry-list)。
|
||||
"""
|
||||
cfg = settings or get_settings()
|
||||
dsn = cfg.resolve_pg_dsn()
|
||||
db_name = assert_safe_database(dsn)
|
||||
logger.info("准备迁移 database=%s", db_name)
|
||||
|
||||
sql_files = sorted(migrations_dir().glob("*.sql"))
|
||||
if not sql_files:
|
||||
logger.warning("migrations 目录无 .sql 文件")
|
||||
return []
|
||||
|
||||
applied: list[str] = []
|
||||
with connect_psycopg(dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
description TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
for path in sql_files:
|
||||
version = path.stem
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM schema_migrations WHERE version = %s",
|
||||
(version,),
|
||||
).fetchone()
|
||||
if row:
|
||||
logger.info("跳过已应用 migration=%s", version)
|
||||
continue
|
||||
sql = path.read_text(encoding="utf-8")
|
||||
# 去掉纯注释行后的空脚本保护
|
||||
body = "\n".join(
|
||||
ln for ln in sql.splitlines() if not ln.strip().startswith("--")
|
||||
).strip()
|
||||
if not body:
|
||||
continue
|
||||
logger.info("执行 migration=%s", version)
|
||||
conn.execute(sql)
|
||||
# 若脚本未自行写入 schema_migrations,补写
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM schema_migrations WHERE version = %s",
|
||||
(version,),
|
||||
).fetchone()
|
||||
if not exists:
|
||||
conn.execute(
|
||||
"INSERT INTO schema_migrations(version, description) VALUES (%s, %s)",
|
||||
(version, path.name),
|
||||
)
|
||||
conn.commit()
|
||||
applied.append(version)
|
||||
return applied
|
||||
|
||||
|
||||
def list_migration_files() -> list[str]:
|
||||
"""列出迁移文件名(不连库)。"""
|
||||
return [p.name for p in sorted(migrations_dir().glob("*.sql"))]
|
||||
|
||||
|
||||
# 会话 / 停等仓储壳(表已在 001;本包只提供读写空实现)
|
||||
from agent.runtime_db.session_store import ( # noqa: E402
|
||||
MemorySessionStore,
|
||||
SessionRecord,
|
||||
create_session_store,
|
||||
)
|
||||
from agent.runtime_db.suspend_store import ( # noqa: E402
|
||||
MemorySuspendStore,
|
||||
SuspendRecord,
|
||||
create_suspend_store,
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
运行账迁移 CLI。
|
||||
|
||||
用法:
|
||||
python -m agent.runtime_db.migrate --list
|
||||
python -m agent.runtime_db.migrate
|
||||
|
||||
需配置 PG_DSN 或 PG_HOST/PG_DATABASE/PG_USER;目标库必须是 inquiry_robot_runtime(禁止 ytd_runtime)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from agent.config import get_settings
|
||||
from agent.runtime_db import apply_migrations, assert_safe_database, list_migration_files
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
parser = argparse.ArgumentParser(description="inquiry_robot_runtime 迁移")
|
||||
parser.add_argument("--list", action="store_true", help="只列出 SQL 文件,不连库")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.list:
|
||||
for name in list_migration_files():
|
||||
print(name)
|
||||
return 0
|
||||
|
||||
settings = get_settings()
|
||||
dsn = settings.resolve_pg_dsn()
|
||||
if not dsn:
|
||||
logging.error("未配置 PG_DSN / PG_*,无法迁移")
|
||||
return 2
|
||||
try:
|
||||
db = assert_safe_database(dsn)
|
||||
logging.info("目标库 %s", db)
|
||||
applied = apply_migrations(settings)
|
||||
logging.info("新应用 %s 个迁移: %s", len(applied), applied or "(无)")
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logging.exception("迁移失败: %s", exc)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
会话仓储壳:conversation_session 读写。
|
||||
|
||||
本文件职责:按 sender_id / thread_id 存取活动工单与打断栈。
|
||||
禁止:写入主账六态;本壳不做询价业务分支。
|
||||
线程:短连接;Memory 后端仅自检。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.runtime_db import assert_safe_database, connect_psycopg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionRecord:
|
||||
"""一条会话快照(非六态真相)。"""
|
||||
|
||||
thread_id: str
|
||||
sender_id: str
|
||||
active_inquiry_no: str = ""
|
||||
interrupt_stack: list[Any] = field(default_factory=list)
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class MemorySessionStore:
|
||||
"""内存会话壳(自检 / 无 PG)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._by_thread: dict[str, SessionRecord] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get(self, thread_id: str) -> Optional[SessionRecord]:
|
||||
with self._lock:
|
||||
rec = self._by_thread.get(thread_id)
|
||||
return None if rec is None else SessionRecord(
|
||||
thread_id=rec.thread_id,
|
||||
sender_id=rec.sender_id,
|
||||
active_inquiry_no=rec.active_inquiry_no,
|
||||
interrupt_stack=list(rec.interrupt_stack),
|
||||
meta=dict(rec.meta),
|
||||
)
|
||||
|
||||
def upsert(self, record: SessionRecord) -> None:
|
||||
with self._lock:
|
||||
self._by_thread[record.thread_id] = SessionRecord(
|
||||
thread_id=record.thread_id,
|
||||
sender_id=record.sender_id,
|
||||
active_inquiry_no=record.active_inquiry_no,
|
||||
interrupt_stack=list(record.interrupt_stack),
|
||||
meta=dict(record.meta),
|
||||
)
|
||||
logger.info(
|
||||
"session.memory.upsert thread=%s sender=%s",
|
||||
record.thread_id,
|
||||
record.sender_id,
|
||||
)
|
||||
|
||||
def find_by_sender(self, sender_id: str) -> list[SessionRecord]:
|
||||
with self._lock:
|
||||
return [
|
||||
SessionRecord(
|
||||
thread_id=r.thread_id,
|
||||
sender_id=r.sender_id,
|
||||
active_inquiry_no=r.active_inquiry_no,
|
||||
interrupt_stack=list(r.interrupt_stack),
|
||||
meta=dict(r.meta),
|
||||
)
|
||||
for r in self._by_thread.values()
|
||||
if r.sender_id == sender_id
|
||||
]
|
||||
|
||||
|
||||
class PostgresSessionStore:
|
||||
"""
|
||||
PG 会话壳。
|
||||
|
||||
须已执行 001 迁移;读写 conversation_session,无业务推导。
|
||||
"""
|
||||
|
||||
def __init__(self, dsn: str) -> None:
|
||||
assert_safe_database(dsn)
|
||||
self._dsn = dsn
|
||||
|
||||
def get(self, thread_id: str) -> Optional[SessionRecord]:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT thread_id, sender_id, active_inquiry_no, interrupt_stack, meta
|
||||
FROM conversation_session WHERE thread_id = %s
|
||||
""",
|
||||
(thread_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return SessionRecord(
|
||||
thread_id=str(row[0]),
|
||||
sender_id=str(row[1]),
|
||||
active_inquiry_no=str(row[2] or ""),
|
||||
interrupt_stack=list(row[3] or []),
|
||||
meta=dict(row[4] or {}),
|
||||
)
|
||||
|
||||
def upsert(self, record: SessionRecord) -> None:
|
||||
import json
|
||||
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO conversation_session (
|
||||
thread_id, sender_id, active_inquiry_no, interrupt_stack, meta, updated_at
|
||||
) VALUES (%s, %s, %s, %s::jsonb, %s::jsonb, %s)
|
||||
ON CONFLICT (thread_id) DO UPDATE SET
|
||||
sender_id = EXCLUDED.sender_id,
|
||||
active_inquiry_no = EXCLUDED.active_inquiry_no,
|
||||
interrupt_stack = EXCLUDED.interrupt_stack,
|
||||
meta = EXCLUDED.meta,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
""",
|
||||
(
|
||||
record.thread_id,
|
||||
record.sender_id,
|
||||
record.active_inquiry_no,
|
||||
json.dumps(record.interrupt_stack, ensure_ascii=False),
|
||||
json.dumps(record.meta, ensure_ascii=False),
|
||||
_utcnow(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("session.pg.upsert thread=%s", record.thread_id)
|
||||
|
||||
def find_by_sender(self, sender_id: str) -> list[SessionRecord]:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT thread_id, sender_id, active_inquiry_no, interrupt_stack, meta
|
||||
FROM conversation_session WHERE sender_id = %s
|
||||
""",
|
||||
(sender_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
SessionRecord(
|
||||
thread_id=str(r[0]),
|
||||
sender_id=str(r[1]),
|
||||
active_inquiry_no=str(r[2] or ""),
|
||||
interrupt_stack=list(r[3] or []),
|
||||
meta=dict(r[4] or {}),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def create_session_store(*, dsn: str = "", backend: str = "auto"):
|
||||
"""
|
||||
工厂:有 DSN 且 backend!=memory → PG;否则 Memory。
|
||||
|
||||
框架壳,不自动建业务会话。
|
||||
"""
|
||||
if backend == "memory" or not dsn:
|
||||
return MemorySessionStore()
|
||||
return PostgresSessionStore(dsn)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
停等账仓储壳:suspend_wait(waitVersion)。
|
||||
|
||||
本文件职责:登记/关闭停点;与 Graph interrupt、H5 token 对齐的版本号。
|
||||
禁止:用本表冒充主账六态;禁止用户文字伪造 Worker 完成。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.runtime_db import assert_safe_database, connect_psycopg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SuspendRecord:
|
||||
"""一条停等记录。"""
|
||||
|
||||
thread_id: str
|
||||
wait_version: int
|
||||
pause_name: str = ""
|
||||
inquiry_no: str = ""
|
||||
status: str = "open"
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class MemorySuspendStore:
|
||||
"""内存停等壳。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._rows: dict[tuple[str, int], SuspendRecord] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def open_wait(self, record: SuspendRecord) -> None:
|
||||
key = (record.thread_id, record.wait_version)
|
||||
with self._lock:
|
||||
self._rows[key] = SuspendRecord(
|
||||
thread_id=record.thread_id,
|
||||
wait_version=record.wait_version,
|
||||
pause_name=record.pause_name,
|
||||
inquiry_no=record.inquiry_no,
|
||||
status="open",
|
||||
payload=dict(record.payload),
|
||||
)
|
||||
logger.info(
|
||||
"suspend.memory.open thread=%s wait=%s pause=%s",
|
||||
record.thread_id,
|
||||
record.wait_version,
|
||||
record.pause_name,
|
||||
)
|
||||
|
||||
def get(self, thread_id: str, wait_version: int) -> Optional[SuspendRecord]:
|
||||
with self._lock:
|
||||
rec = self._rows.get((thread_id, wait_version))
|
||||
if rec is None:
|
||||
return None
|
||||
return SuspendRecord(
|
||||
thread_id=rec.thread_id,
|
||||
wait_version=rec.wait_version,
|
||||
pause_name=rec.pause_name,
|
||||
inquiry_no=rec.inquiry_no,
|
||||
status=rec.status,
|
||||
payload=dict(rec.payload),
|
||||
)
|
||||
|
||||
def close_wait(self, thread_id: str, wait_version: int) -> bool:
|
||||
with self._lock:
|
||||
rec = self._rows.get((thread_id, wait_version))
|
||||
if rec is None:
|
||||
return False
|
||||
rec.status = "closed"
|
||||
return True
|
||||
|
||||
|
||||
class PostgresSuspendStore:
|
||||
"""PG 停等壳(表 suspend_wait)。"""
|
||||
|
||||
def __init__(self, dsn: str) -> None:
|
||||
assert_safe_database(dsn)
|
||||
self._dsn = dsn
|
||||
|
||||
def open_wait(self, record: SuspendRecord) -> None:
|
||||
import json
|
||||
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO suspend_wait (
|
||||
thread_id, wait_version, pause_name, inquiry_no, status, payload
|
||||
) VALUES (%s, %s, %s, %s, 'open', %s::jsonb)
|
||||
ON CONFLICT (thread_id, wait_version) DO UPDATE SET
|
||||
pause_name = EXCLUDED.pause_name,
|
||||
inquiry_no = EXCLUDED.inquiry_no,
|
||||
status = 'open',
|
||||
payload = EXCLUDED.payload,
|
||||
closed_at = NULL
|
||||
""",
|
||||
(
|
||||
record.thread_id,
|
||||
record.wait_version,
|
||||
record.pause_name,
|
||||
record.inquiry_no,
|
||||
json.dumps(record.payload, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(
|
||||
"suspend.pg.open thread=%s wait=%s",
|
||||
record.thread_id,
|
||||
record.wait_version,
|
||||
)
|
||||
|
||||
def get(self, thread_id: str, wait_version: int) -> Optional[SuspendRecord]:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT thread_id, wait_version, pause_name, inquiry_no, status, payload
|
||||
FROM suspend_wait
|
||||
WHERE thread_id = %s AND wait_version = %s
|
||||
""",
|
||||
(thread_id, wait_version),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return SuspendRecord(
|
||||
thread_id=str(row[0]),
|
||||
wait_version=int(row[1]),
|
||||
pause_name=str(row[2] or ""),
|
||||
inquiry_no=str(row[3] or ""),
|
||||
status=str(row[4] or ""),
|
||||
payload=dict(row[5] or {}),
|
||||
)
|
||||
|
||||
def close_wait(self, thread_id: str, wait_version: int) -> bool:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
UPDATE suspend_wait
|
||||
SET status = 'closed', closed_at = %s
|
||||
WHERE thread_id = %s AND wait_version = %s AND status = 'open'
|
||||
""",
|
||||
(_utcnow(), thread_id, wait_version),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def create_suspend_store(*, dsn: str = "", backend: str = "auto"):
|
||||
"""工厂:Memory 或 PG。"""
|
||||
if backend == "memory" or not dsn:
|
||||
return MemorySuspendStore()
|
||||
return PostgresSuspendStore(dsn)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
Schema / 字段合同包。
|
||||
|
||||
校验规则读 contracts JSON;本包不做自然语言抽取。
|
||||
"""
|
||||
|
||||
from agent.schema.contracts_loader import (
|
||||
contracts_dir,
|
||||
load_contract,
|
||||
require_formal_contract,
|
||||
resolve_contract_path,
|
||||
)
|
||||
from agent.schema.field_validate import validate_required_fields_shell
|
||||
|
||||
__all__ = [
|
||||
"contracts_dir",
|
||||
"load_contract",
|
||||
"require_formal_contract",
|
||||
"resolve_contract_path",
|
||||
"validate_required_fields_shell",
|
||||
]
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
字段 / 字典合同加载。
|
||||
|
||||
本文件职责:从仓库 contracts/ 读 JSON;优先正式文件名,其次 PLACEHOLDER。
|
||||
没读到真合同前,业务校验代码不得假装已激活字段规则(prompt/05)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def contracts_dir() -> Path:
|
||||
"""
|
||||
定位仓库 contracts 目录。
|
||||
|
||||
查找顺序:inquiry-agent 上一级 / 再上一级(兼容拷贝到 C:\\ytd)。
|
||||
"""
|
||||
here = Path(__file__).resolve()
|
||||
for parent in here.parents:
|
||||
candidate = parent / "contracts"
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
# 回退:与 agent 包同级仓库根的常见布局
|
||||
return here.parents[3] / "contracts"
|
||||
|
||||
|
||||
def _load_json_file(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def resolve_contract_path(stem: str) -> Optional[Path]:
|
||||
"""
|
||||
解析合同文件路径。
|
||||
|
||||
stem 例:inquiry-required-fields-v1 / tms-dictionary-projection-v2
|
||||
优先 `{stem}.json`,否则 `{stem}.PLACEHOLDER.json`。
|
||||
"""
|
||||
root = contracts_dir()
|
||||
formal = root / f"{stem}.json"
|
||||
if formal.is_file():
|
||||
return formal
|
||||
placeholder = root / f"{stem}.PLACEHOLDER.json"
|
||||
if placeholder.is_file():
|
||||
return placeholder
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def load_contract(stem: str) -> dict[str, Any]:
|
||||
"""
|
||||
加载合同 JSON。
|
||||
|
||||
返回:dict;文件缺失抛 FileNotFoundError。
|
||||
is_placeholder:由 meta 或文件名推断,供调用方拒绝「用占位做生产校验」。
|
||||
"""
|
||||
path = resolve_contract_path(stem)
|
||||
if path is None:
|
||||
raise FileNotFoundError(f"合同文件不存在: {stem}(目录 {contracts_dir()})")
|
||||
data = _load_json_file(path)
|
||||
is_ph = "PLACEHOLDER" in path.name or str(data.get("schemaVersion", "")).endswith(
|
||||
"placeholder"
|
||||
)
|
||||
data = dict(data)
|
||||
data["_meta"] = {
|
||||
"path": str(path),
|
||||
"is_placeholder": is_ph,
|
||||
"stem": stem,
|
||||
}
|
||||
logger.info(
|
||||
"已加载合同 stem=%s placeholder=%s path=%s",
|
||||
stem,
|
||||
is_ph,
|
||||
path.name,
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def require_formal_contract(stem: str) -> dict[str, Any]:
|
||||
"""
|
||||
加载且必须为正式文件(非 PLACEHOLDER)。
|
||||
|
||||
骨架联调请用 load_contract;写字段校验业务前必须走本函数。
|
||||
"""
|
||||
data = load_contract(stem)
|
||||
if data.get("_meta", {}).get("is_placeholder"):
|
||||
raise RuntimeError(
|
||||
f"合同仍为占位,禁止做业务校验: {stem}。"
|
||||
"请将正式 JSON 放到 contracts/(见 _ref/prod-pull 对照)"
|
||||
)
|
||||
return data
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
字段合同校验入口壳。
|
||||
|
||||
本文件职责:暴露 validate_required_fields_shell;正式规则读 contracts JSON 后再实现。
|
||||
禁止:在未 require_formal_contract 时假装已激活字段;禁止用正则替代模型语义。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent.schema.contracts_loader import load_contract, require_formal_contract
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def validate_required_fields_shell(
|
||||
*,
|
||||
facts: dict[str, Any],
|
||||
business_line: str = "",
|
||||
require_formal: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
字段校验空壳。
|
||||
|
||||
返回结构固定:ok / implemented / missing / errors。
|
||||
implemented=False 表示规则引擎尚未接入(框架阶段预期)。
|
||||
"""
|
||||
stem = "inquiry-required-fields-v1"
|
||||
try:
|
||||
contract = (
|
||||
require_formal_contract(stem) if require_formal else load_contract(stem)
|
||||
)
|
||||
except (FileNotFoundError, RuntimeError) as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"implemented": False,
|
||||
"missing": [],
|
||||
"errors": [str(exc)],
|
||||
"business_line": business_line,
|
||||
}
|
||||
|
||||
is_ph = bool(contract.get("_meta", {}).get("is_placeholder"))
|
||||
logger.info(
|
||||
"schema.validate_shell line=%s placeholder=%s facts_keys=%s(未跑激活规则)",
|
||||
business_line or "-",
|
||||
is_ph,
|
||||
sorted(facts.keys()),
|
||||
)
|
||||
return {
|
||||
"ok": False,
|
||||
"implemented": False,
|
||||
"missing": [],
|
||||
"errors": ["field_activation_rules_not_implemented"],
|
||||
"business_line": business_line,
|
||||
"contract_placeholder": is_ph,
|
||||
}
|
||||
@@ -2,5 +2,68 @@
|
||||
对象存储:只存字节;元数据登记走主账 attachment。
|
||||
|
||||
禁止:在本包维护工单状态。
|
||||
骨架:仅占位。
|
||||
骨架:S3 兼容客户端工厂与 put/get 占位(未配 endpoint 时不连网)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ObjectStorageClient:
|
||||
"""
|
||||
对象存储骨架。
|
||||
|
||||
正式用 boto3/minio SDK;本版仅记录配置是否齐全,put_bytes 在未配置时返回 False。
|
||||
"""
|
||||
|
||||
endpoint_url: str
|
||||
bucket: str
|
||||
access_key: str = ""
|
||||
secret_key: str = ""
|
||||
path_style: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, settings: Optional[Settings] = None) -> "ObjectStorageClient":
|
||||
cfg = settings or get_settings()
|
||||
return cls(
|
||||
endpoint_url=getattr(cfg, "object_storage_endpoint_url", "") or "",
|
||||
bucket=getattr(cfg, "object_storage_bucket", "") or "",
|
||||
access_key=getattr(cfg, "object_storage_access_key", "") or "",
|
||||
secret_key=getattr(cfg, "object_storage_secret_key", "") or "",
|
||||
path_style=bool(getattr(cfg, "object_storage_path_style", True)),
|
||||
)
|
||||
|
||||
def configured(self) -> bool:
|
||||
"""endpoint + bucket 是否已配(不含密钥是否正确)。"""
|
||||
return bool(self.endpoint_url and self.bucket)
|
||||
|
||||
def put_bytes_stub(self, *, key: str, data: bytes, content_type: str = "") -> bool:
|
||||
"""
|
||||
上传占位:未配置则 False;已配置也仅打日志不真正上传(骨架防误写桶)。
|
||||
|
||||
正式实现再打开真实 PUT。
|
||||
"""
|
||||
if not self.configured():
|
||||
logger.warning("object_storage 未配置,跳过 put key=%s", key)
|
||||
return False
|
||||
logger.info(
|
||||
"object_storage.put_bytes_stub bucket=%s key=%s bytes=%s(骨架不上传)",
|
||||
self.bucket,
|
||||
key,
|
||||
len(data),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
# SDK 壳:见 s3_client(默认 dry_run)
|
||||
from agent.storage.s3_client import S3ObjectStorage # noqa: E402
|
||||
|
||||
__all__ = ["ObjectStorageClient", "S3ObjectStorage"]
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
对象存储:S3 兼容客户端壳(boto3)。
|
||||
|
||||
本文件职责:组装 client、put_bytes / get_bytes;缺依赖或未配齐时明确失败。
|
||||
禁止:在本包写工单状态;正式登记附件元数据走主账。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class S3ObjectStorage:
|
||||
"""
|
||||
基于 boto3 的 S3/MinIO 壳。
|
||||
|
||||
allow_network=False(默认):不真正 PUT,只校验配置与 SDK 可导入。
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
allow_network: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_settings(
|
||||
cls,
|
||||
settings: Optional[Settings] = None,
|
||||
*,
|
||||
allow_network: bool = False,
|
||||
) -> "S3ObjectStorage":
|
||||
return cls(settings=settings or get_settings(), allow_network=allow_network)
|
||||
|
||||
def _cfg(self) -> tuple[str, str, str, str, bool]:
|
||||
s = self.settings
|
||||
return (
|
||||
(s.object_storage_endpoint_url or "").rstrip("/"),
|
||||
s.object_storage_bucket or "",
|
||||
s.object_storage_access_key or "",
|
||||
s.object_storage_secret_key or "",
|
||||
bool(s.object_storage_path_style),
|
||||
)
|
||||
|
||||
def configured(self) -> bool:
|
||||
endpoint, bucket, *_rest = self._cfg()
|
||||
return bool(endpoint and bucket)
|
||||
|
||||
def sdk_available(self) -> bool:
|
||||
try:
|
||||
import boto3 # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def _client(self):
|
||||
"""构造 boto3 client;未配置抛 RuntimeError。"""
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
|
||||
endpoint, bucket, ak, sk, path_style = self._cfg()
|
||||
if not endpoint or not bucket or not ak or not sk:
|
||||
raise RuntimeError("object_storage_not_configured")
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=endpoint,
|
||||
aws_access_key_id=ak,
|
||||
aws_secret_access_key=sk,
|
||||
config=Config(s3={"addressing_style": "path" if path_style else "auto"}),
|
||||
)
|
||||
|
||||
def put_bytes(self, *, key: str, data: bytes, content_type: str = "") -> dict:
|
||||
"""
|
||||
上传字节。
|
||||
|
||||
allow_network=False:返回 dry_run,不访问网络。
|
||||
"""
|
||||
endpoint, bucket, ak, sk, _ps = self._cfg()
|
||||
if not endpoint or not bucket:
|
||||
return {"ok": False, "error": "not_configured"}
|
||||
if not self.sdk_available():
|
||||
return {"ok": False, "error": "boto3_not_installed"}
|
||||
if not self.allow_network:
|
||||
logger.info(
|
||||
"storage.s3.put dry_run bucket=%s key=%s bytes=%s",
|
||||
bucket,
|
||||
key,
|
||||
len(data),
|
||||
)
|
||||
return {"ok": True, "dry_run": True, "bucket": bucket, "key": key}
|
||||
extra = {}
|
||||
if content_type:
|
||||
extra["ContentType"] = content_type
|
||||
self._client().put_object(Bucket=bucket, Key=key, Body=data, **extra)
|
||||
return {"ok": True, "dry_run": False, "bucket": bucket, "key": key}
|
||||
|
||||
def get_bytes(self, *, key: str) -> dict:
|
||||
"""下载壳;默认 dry_run 不拉网。"""
|
||||
endpoint, bucket, *_r = self._cfg()
|
||||
if not endpoint or not bucket:
|
||||
return {"ok": False, "error": "not_configured"}
|
||||
if not self.allow_network:
|
||||
return {"ok": True, "dry_run": True, "key": key, "data": b""}
|
||||
if not self.sdk_available():
|
||||
return {"ok": False, "error": "boto3_not_installed"}
|
||||
obj = self._client().get_object(Bucket=bucket, Key=key)
|
||||
body = obj["Body"].read()
|
||||
return {"ok": True, "dry_run": False, "key": key, "data": body}
|
||||
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
智能体唯一 Worker 入口(LLM / 识别 / 报价 PDF / 存档)。
|
||||
|
||||
本文件职责:组装分槽运行时并阻塞至进程结束;不在此写各业务 Handler。
|
||||
禁止:Redis wecom:worker:singleton;禁止五进程拆分;禁止在 HTTP 回调里跑本模块逻辑。
|
||||
本文件职责:组装分槽、Graph、Redis、LLM 网关;阻塞至进程结束。
|
||||
禁止:Redis wecom:worker:singleton;禁止在 HTTP 回调里跑本模块逻辑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,26 +12,34 @@ import signal
|
||||
import threading
|
||||
|
||||
from agent.config import get_settings
|
||||
from agent.graph import get_graph_runtime, shutdown_graph_runtime
|
||||
from agent.jobs import WorkerRuntime
|
||||
from agent.llm import LlmGateway
|
||||
from agent.redis_coord import get_redis_runtime, shutdown_redis_runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
启动 Worker 槽与 inbox/outbox 占位线程,主线程等待停止信号。
|
||||
启动 Worker:wake Stream + LLM Stream + LibreOffice 槽。
|
||||
|
||||
副作用:创建后台线程;骨架不消费真实队列、不连库。
|
||||
并发:LibreOffice 1 槽;HTTP 类至少 2~3 槽;与 inbox/outbox 隔离。
|
||||
Graph resume/wake 仅走 http_jobs 槽。
|
||||
"""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||
)
|
||||
settings = get_settings()
|
||||
graph_runtime = get_graph_runtime(settings)
|
||||
redis_runtime = get_redis_runtime(settings)
|
||||
llm = LlmGateway.from_settings(settings)
|
||||
runtime = WorkerRuntime.from_settings(
|
||||
libreoffice_slots=settings.libreoffice_slots,
|
||||
http_job_slots=settings.http_job_slots,
|
||||
settings.libreoffice_slots,
|
||||
settings.http_job_slots,
|
||||
graph=graph_runtime,
|
||||
redis=redis_runtime,
|
||||
llm=llm,
|
||||
)
|
||||
runtime.start()
|
||||
|
||||
@@ -40,12 +48,19 @@ def main() -> None:
|
||||
def _handle_signal(signum: int, _frame: object) -> None:
|
||||
logger.info("收到停止信号 signum=%s", signum)
|
||||
runtime.stop()
|
||||
shutdown_graph_runtime()
|
||||
shutdown_redis_runtime()
|
||||
stop_event.set()
|
||||
|
||||
signal.signal(signal.SIGINT, _handle_signal)
|
||||
signal.signal(signal.SIGTERM, _handle_signal)
|
||||
|
||||
logger.info("Worker 骨架运行中 ytd_env=%s(Ctrl+C 退出)", settings.ytd_env)
|
||||
logger.info(
|
||||
"Worker 运行中 ytd_env=%s checkpoint=%s redis=%s(Ctrl+C 退出)",
|
||||
settings.ytd_env,
|
||||
graph_runtime.checkpoint.backend,
|
||||
redis_runtime.client.backend,
|
||||
)
|
||||
stop_event.wait()
|
||||
logger.info("Worker 已退出")
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
-- 001_runtime_core_v1.sql
|
||||
-- 新系统运行账核心表(inquiry_robot_runtime)
|
||||
-- 禁止对现网 ytd_runtime 执行。
|
||||
-- Graph checkpoint 表由 LangGraph PostgresSaver.setup() 另建,不在此脚本。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
description TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- 企微入站 inbox:回调只写入;Worker/通道线程认领
|
||||
CREATE TABLE IF NOT EXISTS agent_inbox (
|
||||
id UUID PRIMARY KEY,
|
||||
message_id TEXT NOT NULL,
|
||||
sender_id TEXT NOT NULL,
|
||||
chat_id TEXT NOT NULL DEFAULT '',
|
||||
chat_type TEXT NOT NULL DEFAULT '',
|
||||
payload JSONB NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
claimed_at TIMESTAMPTZ,
|
||||
claimed_by TEXT NOT NULL DEFAULT '',
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT agent_inbox_message_id_uq UNIQUE (message_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_inbox_state_created
|
||||
ON agent_inbox (state, created_at);
|
||||
|
||||
-- 企微出站 outbox:发送成功 ≠ 业务成功;过期可丢弃
|
||||
CREATE TABLE IF NOT EXISTS agent_outbox (
|
||||
id UUID PRIMARY KEY,
|
||||
dedupe_key TEXT NOT NULL DEFAULT '',
|
||||
touser TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
msg_type TEXT NOT NULL DEFAULT 'text',
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
claimed_at TIMESTAMPTZ,
|
||||
claimed_by TEXT NOT NULL DEFAULT '',
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
max_age_seconds INT NOT NULL DEFAULT 86400,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_agent_outbox_dedupe
|
||||
ON agent_outbox (dedupe_key)
|
||||
WHERE dedupe_key <> '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_outbox_state_created
|
||||
ON agent_outbox (state, created_at);
|
||||
|
||||
-- 会话:活动工单与打断栈(非六态真相)
|
||||
CREATE TABLE IF NOT EXISTS conversation_session (
|
||||
thread_id TEXT PRIMARY KEY,
|
||||
sender_id TEXT NOT NULL,
|
||||
active_inquiry_no TEXT NOT NULL DEFAULT '',
|
||||
interrupt_stack JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
meta JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_conversation_session_sender
|
||||
ON conversation_session (sender_id);
|
||||
|
||||
-- 停等账:waitVersion(与卡片/H5 绑定)
|
||||
CREATE TABLE IF NOT EXISTS suspend_wait (
|
||||
thread_id TEXT NOT NULL,
|
||||
wait_version INT NOT NULL,
|
||||
pause_name TEXT NOT NULL DEFAULT '',
|
||||
inquiry_no TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
closed_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (thread_id, wait_version)
|
||||
);
|
||||
|
||||
-- 补问 / 卡片 token(H5 TTL 约 30 分钟由应用写入 expires_at)
|
||||
CREATE TABLE IF NOT EXISTS clarification_binding (
|
||||
token TEXT PRIMARY KEY,
|
||||
thread_id TEXT NOT NULL,
|
||||
wait_version INT NOT NULL,
|
||||
sender_id TEXT NOT NULL DEFAULT '',
|
||||
allowed_actions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
consumed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_clarification_thread_wait
|
||||
ON clarification_binding (thread_id, wait_version);
|
||||
|
||||
-- 可选:PG 任务认领表(与 Redis Stream 二选一做真相;骨架两套都建好,业务接入时只留一条)
|
||||
CREATE TABLE IF NOT EXISTS agent_job (
|
||||
id UUID PRIMARY KEY,
|
||||
job_type TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL DEFAULT '',
|
||||
payload JSONB NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
claimed_at TIMESTAMPTZ,
|
||||
claimed_by TEXT NOT NULL DEFAULT '',
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
result JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_agent_job_idem
|
||||
ON agent_job (job_type, idempotency_key)
|
||||
WHERE idempotency_key <> '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_job_state_created
|
||||
ON agent_job (state, created_at);
|
||||
|
||||
INSERT INTO schema_migrations (version, description)
|
||||
VALUES ('001_runtime_core_v1', 'inbox/outbox/session/suspend/clarification/job 核心表')
|
||||
ON CONFLICT (version) DO NOTHING;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 002_dead_letter_v1.sql
|
||||
-- 死信表:失败任务落点(框架壳,不含业务重放逻辑)
|
||||
-- 禁止对现网 ytd_runtime 执行。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_dead_letter (
|
||||
id UUID PRIMARY KEY,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
source_id TEXT NOT NULL DEFAULT '',
|
||||
job_type TEXT NOT NULL DEFAULT '',
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
replayed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_dead_letter_created
|
||||
ON agent_dead_letter (created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_dead_letter_job_type
|
||||
ON agent_dead_letter (job_type, created_at DESC);
|
||||
|
||||
INSERT INTO schema_migrations (version, description)
|
||||
VALUES ('002_dead_letter_v1', 'agent_dead_letter 死信表壳')
|
||||
ON CONFLICT (version) DO NOTHING;
|
||||
@@ -1,7 +1,24 @@
|
||||
# PostgreSQL 运行账迁移
|
||||
|
||||
后续在此目录添加版本化 SQL(inbox / outbox / checkpoint / 任务认领等)。
|
||||
- 只对当前环境变量 `PG_DATABASE` / `PG_DSN` 执行(新系统 **`inquiry_robot_runtime`**)。
|
||||
- **禁止**对现网 `ytd_runtime` 执行迁移(代码会拒绝)。
|
||||
|
||||
- 只对当前环境变量 `PG_DATABASE` 执行(新系统 `inquiry_robot_runtime`)。
|
||||
- **禁止**对现网 `ytd_runtime` 执行迁移。
|
||||
- 骨架阶段不建业务表。
|
||||
## 文件
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `001_runtime_core_v1.sql` | inbox / outbox / conversation_session / suspend_wait / clarification_binding / agent_job |
|
||||
|
||||
## Graph checkpoint
|
||||
|
||||
LangGraph `PostgresSaver.setup()` 会在连接库内另建 checkpoint 表,与上表分开。
|
||||
|
||||
## 怎么跑
|
||||
|
||||
```text
|
||||
cd inquiry-agent
|
||||
python -m agent.runtime_db.migrate --list
|
||||
python -m agent.runtime_db.migrate
|
||||
```
|
||||
|
||||
需已配置 `PG_DSN` 或 `PG_HOST`+`PG_DATABASE`+`PG_USER`(及密码)。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Python agent dependencies (ASCII only for Windows GBK pip)
|
||||
# Python agent dependencies (ASCII only; avoid Windows GBK pip decode errors)
|
||||
fastapi>=0.115.0,<1.0
|
||||
uvicorn[standard]>=0.32.0,<1.0
|
||||
httpx>=0.27.0,<1.0
|
||||
@@ -6,3 +6,11 @@ python-dotenv>=1.0.0,<2.0
|
||||
pydantic>=2.0,<3.0
|
||||
pydantic-settings>=2.0,<3.0
|
||||
cryptography>=42,<45
|
||||
# LangGraph orchestration (checkpoint in PG)
|
||||
langgraph>=1.0,<1.3
|
||||
langgraph-checkpoint>=2.0,<5.0
|
||||
langgraph-checkpoint-postgres>=2.0,<4.0
|
||||
psycopg[binary]>=3.1,<4.0
|
||||
psycopg-pool>=3.2,<4.0
|
||||
# Redis short-lived coordination only
|
||||
redis>=5.0,<6.0
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# 报价模板本地缓存(可选,非生产真相)
|
||||
|
||||
生产流程:管理后台上传/启用/停用 → 主账元数据 + 对象存储字节。
|
||||
智能体生成时经主账取已启用模板,再下载对象;**不要**把本目录当模板源。
|
||||
|
||||
本目录仅可作开发机临时缓存,可空。
|
||||
Reference in New Issue
Block a user