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

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