落地并存测服全端:智能体 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
+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)