落地并存测服全端:智能体 Graph/路由、后台切 8180、主账与部署脚本齐备,便于线上重发与验收。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
PostgreSQL 运行账连接与迁移。
|
||||
|
||||
本包职责:解析 PG_DSN、安全拒绝现网库名、执行版本化 SQL。
|
||||
禁止:智能体经本包写 MySQL 工单表;禁止 FLUSH / 删现网库。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 现网运行账库名:新系统迁移脚本绝对不能碰
|
||||
_FORBIDDEN_DB_NAMES = frozenset({"ytd_runtime"})
|
||||
|
||||
|
||||
def migrations_dir() -> Path:
|
||||
"""返回 inquiry-agent/migrations 目录。"""
|
||||
return Path(__file__).resolve().parents[2] / "migrations"
|
||||
|
||||
|
||||
def assert_safe_database(dsn: str) -> str:
|
||||
"""
|
||||
从 DSN 解析库名并校验。
|
||||
|
||||
返回库名;若为 ytd_runtime 则抛错。
|
||||
"""
|
||||
if not dsn:
|
||||
raise RuntimeError("PG_DSN 为空,无法迁移")
|
||||
parsed = urlparse(dsn)
|
||||
db = (parsed.path or "").lstrip("/").split("?")[0]
|
||||
if not db:
|
||||
raise RuntimeError(f"无法从 DSN 解析库名: {dsn[:32]}...")
|
||||
if db in _FORBIDDEN_DB_NAMES:
|
||||
raise RuntimeError(f"禁止对现网库执行迁移: {db}")
|
||||
return db
|
||||
|
||||
|
||||
def connect_psycopg(dsn: str):
|
||||
"""
|
||||
打开 psycopg 连接(调用方负责 close)。
|
||||
|
||||
副作用:网络连库。
|
||||
"""
|
||||
import psycopg
|
||||
|
||||
return psycopg.connect(dsn, connect_timeout=5)
|
||||
|
||||
|
||||
def apply_migrations(settings: Optional[Settings] = None) -> list[str]:
|
||||
"""
|
||||
按文件名顺序执行尚未应用的 *.sql。
|
||||
|
||||
返回:本次新执行的 version 列表。
|
||||
无 DSN:抛错(自检可用 --dry-list)。
|
||||
"""
|
||||
cfg = settings or get_settings()
|
||||
dsn = cfg.resolve_pg_dsn()
|
||||
db_name = assert_safe_database(dsn)
|
||||
logger.info("准备迁移 database=%s", db_name)
|
||||
|
||||
sql_files = sorted(migrations_dir().glob("*.sql"))
|
||||
if not sql_files:
|
||||
logger.warning("migrations 目录无 .sql 文件")
|
||||
return []
|
||||
|
||||
applied: list[str] = []
|
||||
with connect_psycopg(dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
description TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
for path in sql_files:
|
||||
version = path.stem
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM schema_migrations WHERE version = %s",
|
||||
(version,),
|
||||
).fetchone()
|
||||
if row:
|
||||
logger.info("跳过已应用 migration=%s", version)
|
||||
continue
|
||||
sql = path.read_text(encoding="utf-8")
|
||||
# 去掉纯注释行后的空脚本保护
|
||||
body = "\n".join(
|
||||
ln for ln in sql.splitlines() if not ln.strip().startswith("--")
|
||||
).strip()
|
||||
if not body:
|
||||
continue
|
||||
logger.info("执行 migration=%s", version)
|
||||
conn.execute(sql)
|
||||
# 若脚本未自行写入 schema_migrations,补写
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM schema_migrations WHERE version = %s",
|
||||
(version,),
|
||||
).fetchone()
|
||||
if not exists:
|
||||
conn.execute(
|
||||
"INSERT INTO schema_migrations(version, description) VALUES (%s, %s)",
|
||||
(version, path.name),
|
||||
)
|
||||
conn.commit()
|
||||
applied.append(version)
|
||||
return applied
|
||||
|
||||
|
||||
def list_migration_files() -> list[str]:
|
||||
"""列出迁移文件名(不连库)。"""
|
||||
return [p.name for p in sorted(migrations_dir().glob("*.sql"))]
|
||||
|
||||
|
||||
# 会话 / 停等仓储壳(表已在 001;本包只提供读写空实现)
|
||||
from agent.runtime_db.session_store import ( # noqa: E402
|
||||
MemorySessionStore,
|
||||
SessionRecord,
|
||||
create_session_store,
|
||||
)
|
||||
from agent.runtime_db.suspend_store import ( # noqa: E402
|
||||
MemorySuspendStore,
|
||||
SuspendRecord,
|
||||
create_suspend_store,
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
运行账迁移 CLI。
|
||||
|
||||
用法:
|
||||
python -m agent.runtime_db.migrate --list
|
||||
python -m agent.runtime_db.migrate
|
||||
|
||||
需配置 PG_DSN 或 PG_HOST/PG_DATABASE/PG_USER;目标库必须是 inquiry_robot_runtime(禁止 ytd_runtime)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from agent.config import get_settings
|
||||
from agent.runtime_db import apply_migrations, assert_safe_database, list_migration_files
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
parser = argparse.ArgumentParser(description="inquiry_robot_runtime 迁移")
|
||||
parser.add_argument("--list", action="store_true", help="只列出 SQL 文件,不连库")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.list:
|
||||
for name in list_migration_files():
|
||||
print(name)
|
||||
return 0
|
||||
|
||||
settings = get_settings()
|
||||
dsn = settings.resolve_pg_dsn()
|
||||
if not dsn:
|
||||
logging.error("未配置 PG_DSN / PG_*,无法迁移")
|
||||
return 2
|
||||
try:
|
||||
db = assert_safe_database(dsn)
|
||||
logging.info("目标库 %s", db)
|
||||
applied = apply_migrations(settings)
|
||||
logging.info("新应用 %s 个迁移: %s", len(applied), applied or "(无)")
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logging.exception("迁移失败: %s", exc)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
会话仓储壳:conversation_session 读写。
|
||||
|
||||
本文件职责:按 sender_id / thread_id 存取活动工单与打断栈。
|
||||
禁止:写入主账六态;本壳不做询价业务分支。
|
||||
线程:短连接;Memory 后端仅自检。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.runtime_db import assert_safe_database, connect_psycopg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionRecord:
|
||||
"""一条会话快照(非六态真相)。"""
|
||||
|
||||
thread_id: str
|
||||
sender_id: str
|
||||
active_inquiry_no: str = ""
|
||||
interrupt_stack: list[Any] = field(default_factory=list)
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class MemorySessionStore:
|
||||
"""内存会话壳(自检 / 无 PG)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._by_thread: dict[str, SessionRecord] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get(self, thread_id: str) -> Optional[SessionRecord]:
|
||||
with self._lock:
|
||||
rec = self._by_thread.get(thread_id)
|
||||
return None if rec is None else SessionRecord(
|
||||
thread_id=rec.thread_id,
|
||||
sender_id=rec.sender_id,
|
||||
active_inquiry_no=rec.active_inquiry_no,
|
||||
interrupt_stack=list(rec.interrupt_stack),
|
||||
meta=dict(rec.meta),
|
||||
)
|
||||
|
||||
def upsert(self, record: SessionRecord) -> None:
|
||||
with self._lock:
|
||||
self._by_thread[record.thread_id] = SessionRecord(
|
||||
thread_id=record.thread_id,
|
||||
sender_id=record.sender_id,
|
||||
active_inquiry_no=record.active_inquiry_no,
|
||||
interrupt_stack=list(record.interrupt_stack),
|
||||
meta=dict(record.meta),
|
||||
)
|
||||
logger.info(
|
||||
"session.memory.upsert thread=%s sender=%s",
|
||||
record.thread_id,
|
||||
record.sender_id,
|
||||
)
|
||||
|
||||
def find_by_sender(self, sender_id: str) -> list[SessionRecord]:
|
||||
with self._lock:
|
||||
return [
|
||||
SessionRecord(
|
||||
thread_id=r.thread_id,
|
||||
sender_id=r.sender_id,
|
||||
active_inquiry_no=r.active_inquiry_no,
|
||||
interrupt_stack=list(r.interrupt_stack),
|
||||
meta=dict(r.meta),
|
||||
)
|
||||
for r in self._by_thread.values()
|
||||
if r.sender_id == sender_id
|
||||
]
|
||||
|
||||
|
||||
class PostgresSessionStore:
|
||||
"""
|
||||
PG 会话壳。
|
||||
|
||||
须已执行 001 迁移;读写 conversation_session,无业务推导。
|
||||
"""
|
||||
|
||||
def __init__(self, dsn: str) -> None:
|
||||
assert_safe_database(dsn)
|
||||
self._dsn = dsn
|
||||
|
||||
def get(self, thread_id: str) -> Optional[SessionRecord]:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT thread_id, sender_id, active_inquiry_no, interrupt_stack, meta
|
||||
FROM conversation_session WHERE thread_id = %s
|
||||
""",
|
||||
(thread_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return SessionRecord(
|
||||
thread_id=str(row[0]),
|
||||
sender_id=str(row[1]),
|
||||
active_inquiry_no=str(row[2] or ""),
|
||||
interrupt_stack=list(row[3] or []),
|
||||
meta=dict(row[4] or {}),
|
||||
)
|
||||
|
||||
def upsert(self, record: SessionRecord) -> None:
|
||||
import json
|
||||
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO conversation_session (
|
||||
thread_id, sender_id, active_inquiry_no, interrupt_stack, meta, updated_at
|
||||
) VALUES (%s, %s, %s, %s::jsonb, %s::jsonb, %s)
|
||||
ON CONFLICT (thread_id) DO UPDATE SET
|
||||
sender_id = EXCLUDED.sender_id,
|
||||
active_inquiry_no = EXCLUDED.active_inquiry_no,
|
||||
interrupt_stack = EXCLUDED.interrupt_stack,
|
||||
meta = EXCLUDED.meta,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
""",
|
||||
(
|
||||
record.thread_id,
|
||||
record.sender_id,
|
||||
record.active_inquiry_no,
|
||||
json.dumps(record.interrupt_stack, ensure_ascii=False),
|
||||
json.dumps(record.meta, ensure_ascii=False),
|
||||
_utcnow(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("session.pg.upsert thread=%s", record.thread_id)
|
||||
|
||||
def find_by_sender(self, sender_id: str) -> list[SessionRecord]:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT thread_id, sender_id, active_inquiry_no, interrupt_stack, meta
|
||||
FROM conversation_session WHERE sender_id = %s
|
||||
""",
|
||||
(sender_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
SessionRecord(
|
||||
thread_id=str(r[0]),
|
||||
sender_id=str(r[1]),
|
||||
active_inquiry_no=str(r[2] or ""),
|
||||
interrupt_stack=list(r[3] or []),
|
||||
meta=dict(r[4] or {}),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def create_session_store(*, dsn: str = "", backend: str = "auto"):
|
||||
"""
|
||||
工厂:有 DSN 且 backend!=memory → PG;否则 Memory。
|
||||
|
||||
框架壳,不自动建业务会话。
|
||||
"""
|
||||
if backend == "memory" or not dsn:
|
||||
return MemorySessionStore()
|
||||
return PostgresSessionStore(dsn)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
停等账仓储壳:suspend_wait(waitVersion)。
|
||||
|
||||
本文件职责:登记/关闭停点;与 Graph interrupt、H5 token 对齐的版本号。
|
||||
禁止:用本表冒充主账六态;禁止用户文字伪造 Worker 完成。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.runtime_db import assert_safe_database, connect_psycopg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SuspendRecord:
|
||||
"""一条停等记录。"""
|
||||
|
||||
thread_id: str
|
||||
wait_version: int
|
||||
pause_name: str = ""
|
||||
inquiry_no: str = ""
|
||||
status: str = "open"
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class MemorySuspendStore:
|
||||
"""内存停等壳。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._rows: dict[tuple[str, int], SuspendRecord] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def open_wait(self, record: SuspendRecord) -> None:
|
||||
key = (record.thread_id, record.wait_version)
|
||||
with self._lock:
|
||||
self._rows[key] = SuspendRecord(
|
||||
thread_id=record.thread_id,
|
||||
wait_version=record.wait_version,
|
||||
pause_name=record.pause_name,
|
||||
inquiry_no=record.inquiry_no,
|
||||
status="open",
|
||||
payload=dict(record.payload),
|
||||
)
|
||||
logger.info(
|
||||
"suspend.memory.open thread=%s wait=%s pause=%s",
|
||||
record.thread_id,
|
||||
record.wait_version,
|
||||
record.pause_name,
|
||||
)
|
||||
|
||||
def get(self, thread_id: str, wait_version: int) -> Optional[SuspendRecord]:
|
||||
with self._lock:
|
||||
rec = self._rows.get((thread_id, wait_version))
|
||||
if rec is None:
|
||||
return None
|
||||
return SuspendRecord(
|
||||
thread_id=rec.thread_id,
|
||||
wait_version=rec.wait_version,
|
||||
pause_name=rec.pause_name,
|
||||
inquiry_no=rec.inquiry_no,
|
||||
status=rec.status,
|
||||
payload=dict(rec.payload),
|
||||
)
|
||||
|
||||
def close_wait(self, thread_id: str, wait_version: int) -> bool:
|
||||
with self._lock:
|
||||
rec = self._rows.get((thread_id, wait_version))
|
||||
if rec is None:
|
||||
return False
|
||||
rec.status = "closed"
|
||||
return True
|
||||
|
||||
|
||||
class PostgresSuspendStore:
|
||||
"""PG 停等壳(表 suspend_wait)。"""
|
||||
|
||||
def __init__(self, dsn: str) -> None:
|
||||
assert_safe_database(dsn)
|
||||
self._dsn = dsn
|
||||
|
||||
def open_wait(self, record: SuspendRecord) -> None:
|
||||
import json
|
||||
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO suspend_wait (
|
||||
thread_id, wait_version, pause_name, inquiry_no, status, payload
|
||||
) VALUES (%s, %s, %s, %s, 'open', %s::jsonb)
|
||||
ON CONFLICT (thread_id, wait_version) DO UPDATE SET
|
||||
pause_name = EXCLUDED.pause_name,
|
||||
inquiry_no = EXCLUDED.inquiry_no,
|
||||
status = 'open',
|
||||
payload = EXCLUDED.payload,
|
||||
closed_at = NULL
|
||||
""",
|
||||
(
|
||||
record.thread_id,
|
||||
record.wait_version,
|
||||
record.pause_name,
|
||||
record.inquiry_no,
|
||||
json.dumps(record.payload, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(
|
||||
"suspend.pg.open thread=%s wait=%s",
|
||||
record.thread_id,
|
||||
record.wait_version,
|
||||
)
|
||||
|
||||
def get(self, thread_id: str, wait_version: int) -> Optional[SuspendRecord]:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT thread_id, wait_version, pause_name, inquiry_no, status, payload
|
||||
FROM suspend_wait
|
||||
WHERE thread_id = %s AND wait_version = %s
|
||||
""",
|
||||
(thread_id, wait_version),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return SuspendRecord(
|
||||
thread_id=str(row[0]),
|
||||
wait_version=int(row[1]),
|
||||
pause_name=str(row[2] or ""),
|
||||
inquiry_no=str(row[3] or ""),
|
||||
status=str(row[4] or ""),
|
||||
payload=dict(row[5] or {}),
|
||||
)
|
||||
|
||||
def close_wait(self, thread_id: str, wait_version: int) -> bool:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
UPDATE suspend_wait
|
||||
SET status = 'closed', closed_at = %s
|
||||
WHERE thread_id = %s AND wait_version = %s AND status = 'open'
|
||||
""",
|
||||
(_utcnow(), thread_id, wait_version),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def create_suspend_store(*, dsn: str = "", backend: str = "auto"):
|
||||
"""工厂:Memory 或 PG。"""
|
||||
if backend == "memory" or not dsn:
|
||||
return MemorySuspendStore()
|
||||
return PostgresSuspendStore(dsn)
|
||||
Reference in New Issue
Block a user