T-0 / T-0b(门禁 · 2026-09-10) - T-0:sqlite 与 MySQL 结构对齐 —— core_holding 统一为 qty/cost_amount/as_of/pnl_pct + PK + UNIQUE(customer_id, product_id);补 core_product_nav;新增建库自校验 _assert_ddl_aligned()(R-g);test_db.py 增 3 条门禁用例(含反向验证门禁失效) - T-0b:DB 账号分离(D20)—— 新增 scripts/core/00-grant.sql(三账号逐表授权); settings.py 增 3 组账号;db.py 改 get_engine(db, role),缓存键改为 (库名, 角色), 账号未配置回退单账号;core_ro→ro / gateway_repository→rw / risk·session_repository→rw; tests/conftest.py 四处显式 role="admin"(R-e) T-1(数据层) - scripts/core/01-ddl.sql:新建 core_fee_rule / core_share_lot / core_convert_lot_detail; core_trade 加 convert_group_id + idx_convert_group;core_product 加 8 列 + fee_rate 补 COMMENT - 新增 07-seed-fee-rule.sql(赎回费 5 档 × 14 产品,按 22 号文 §10)/ 08-seed-share-lot.sql (58 行持仓 → 61 行批次,Σ remain_qty 恒等于 qty)/ 09-seed-org.sql(管理人 + TA + 申购费率 + 最低持有余额,v1.1 按「管理人全产品线」重排) - reset.ps1 追加 07/08/09;02-mysql-agent专用.sql 追加 risk_convert_detail - tests/_ddl.py 同步 4 表 + 新增 REQUIRED_CONVERT_TABLES 建库门禁 - 新增 scripts/dev/verify_convert_seed.py(pymysql 等价 reset 流程 + 8 条 DoD 断言, 含断言 ⑧「费率档 ↔ product_type 匹配」,越档即 FAIL) T-2 / T-2b(纯函数包 + 示例实算回填) - 新增 app/service/convert/ 7 文件:__init__ / types / calc / fee / nav / lot_bootstrap / errors (纯函数,不查库、不碰 SQL;所有量化显式 ROUND_HALF_UP;lot_bootstrap 用 zlib.crc32 保证 D18 跨进程同源) - 新增 tests/test_convert_calc.py 93 用例(12 类:HALF_UP 反向自证 / 分档边界 / FIFO 含同 confirmed_at 兜底 / 双口径 / 强制全转与强制赎回 / PRD §5.3 全链自证 / 纯函数零 IO 依赖断言) - 重写 scripts/dev/calc_convert_demo.py:去掉脚本内公式副本,改为调用生产 calc.py, 末尾与 PRD §5.3 逐项比对(不一致即退出码 1),兼作一致性门禁 验证 - pytest 609 passed / 3 skipped(516 → +93,零回归) - verify_convert_seed.py 8/8 PASS;calc_convert_demo.py 15/15 与 PRD §5.3 一致 文档:PRD v0.9.1(费率分类修正)· 架构 §7 签名回填 / §8.3 错误码注 / §15 T-2 完成 · 开发计划 §1.5 新增 R-h + §4.2·§4.3 执行记录 · AGENTS.md · docs/memory
340 lines
13 KiB
Python
340 lines
13 KiB
Python
"""jinrong_agent 会话与消息读写(T-06 · agent_session / agent_message,共用底座)。
|
||
|
||
会话权威副本在 MySQL(FLOW §6 / redis-keys §2.1:Redis 只保滑动窗口,丢失
|
||
可重建);seq_no 由调用方经 next_seq_no 取号(同会话并发写防重锁归
|
||
sess:{agent}:{id}:lock,最小闭环单演示进程暂未启用,留痕)。
|
||
T-04 增 agent_tool_call 落库(对话域第三张表;Tool 调用审计,与
|
||
audit_log/input_guard_log 合规审计表区分——本表由对话链路读写)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.engine import Engine
|
||
|
||
from app.config.settings import settings
|
||
from app.utils.db import get_engine
|
||
|
||
|
||
class SessionRepository:
|
||
"""agent_session / agent_message 读写;audit 类表不在本层(T-02 中间件)。"""
|
||
|
||
def __init__(self, engine: Engine | None = None) -> None:
|
||
self._engine = engine or get_engine(settings.mysql_database, "rw")
|
||
|
||
# ---------- agent_session ----------
|
||
|
||
def get_session(self, session_id: str) -> dict | None:
|
||
with self._engine.connect() as conn:
|
||
row = conn.execute(
|
||
text("SELECT * FROM agent_session WHERE session_id = :sid"), {"sid": session_id}
|
||
).mappings().first()
|
||
return dict(row) if row else None
|
||
|
||
def list_sessions(
|
||
self, *, actor_id: str, agent_type: str, limit: int = 20, offset: int = 0
|
||
) -> tuple[list[dict[str, Any]], int]:
|
||
"""前端会话列表(方案 B):仅本人 + 本 Agent 线,created_at 倒序分页。
|
||
|
||
返回 (items, total);total 供前端分页器。id 倒序兜底同秒并发建的
|
||
会话排序稳定(created_at 精度秒级时并列)。datetime 统一转 str——
|
||
sqlite 返 str、MySQL 返 datetime,响应体跨库同构。
|
||
"""
|
||
where = "WHERE actor_id = :actor AND agent_type = :atype"
|
||
with self._engine.connect() as conn:
|
||
total = int(
|
||
conn.execute(
|
||
text(f"SELECT COUNT(*) FROM agent_session {where}"),
|
||
{"actor": actor_id, "atype": agent_type},
|
||
).scalar_one()
|
||
)
|
||
rows = conn.execute(
|
||
text(
|
||
f"""
|
||
SELECT session_id, agent_type, actor_id, actor_role, customer_id,
|
||
advisor_id, title, status, created_at, closed_at
|
||
FROM agent_session {where}
|
||
ORDER BY created_at DESC, id DESC
|
||
LIMIT :lim OFFSET :off
|
||
"""
|
||
),
|
||
{"actor": actor_id, "atype": agent_type, "lim": limit, "off": offset},
|
||
).mappings().all()
|
||
items = [
|
||
{
|
||
**{k: r[k] for k in (
|
||
"session_id", "agent_type", "actor_id", "actor_role",
|
||
"customer_id", "advisor_id", "title", "status",
|
||
)},
|
||
"created_at": str(r["created_at"]) if r["created_at"] is not None else None,
|
||
"closed_at": str(r["closed_at"]) if r["closed_at"] is not None else None,
|
||
}
|
||
for r in rows
|
||
]
|
||
return items, total
|
||
|
||
def close_session(self, session_id: str) -> bool:
|
||
"""关闭会话(方案 B):active → closed + closed_at 落时间。
|
||
|
||
条件更新(WHERE status='active')保证幂等语义由路由层判定——重复
|
||
关闭返回 False 由路由转 409,不会出现并发双击把 closed_at 刷新的
|
||
静默写。审计表只 INSERT 铁律不适用于本表(agent_session 是对话域
|
||
业务表,status 流转是既有设计,见 _ddl/01-mysql-共用底座)。
|
||
"""
|
||
with self._engine.begin() as conn:
|
||
result = conn.execute(
|
||
text(
|
||
"UPDATE agent_session SET status = 'closed', closed_at = CURRENT_TIMESTAMP"
|
||
" WHERE session_id = :sid AND status = 'active'"
|
||
),
|
||
{"sid": session_id},
|
||
)
|
||
return result.rowcount > 0
|
||
|
||
def create_session(
|
||
self,
|
||
*,
|
||
session_id: str,
|
||
trace_id: str,
|
||
agent_type: str,
|
||
actor_id: str,
|
||
actor_role: str,
|
||
customer_id: str | None,
|
||
advisor_id: str | None = None,
|
||
title: str | None = None,
|
||
) -> dict:
|
||
"""手册 §9:agent_type/actor_id/actor_role/customer_id/advisor_id/trace_id 绑定。"""
|
||
sql = text(
|
||
"""
|
||
INSERT INTO agent_session
|
||
(session_id, trace_id, agent_type, actor_id, actor_role, customer_id, advisor_id, title)
|
||
VALUES (:sid, :trace_id, :atype, :actor_id, :actor_role, :customer_id, :advisor_id, :title)
|
||
"""
|
||
)
|
||
with self._engine.begin() as conn:
|
||
conn.execute(
|
||
sql,
|
||
{
|
||
"sid": session_id,
|
||
"trace_id": trace_id,
|
||
"atype": agent_type,
|
||
"actor_id": actor_id,
|
||
"actor_role": actor_role,
|
||
"customer_id": customer_id,
|
||
"advisor_id": advisor_id,
|
||
"title": title,
|
||
},
|
||
)
|
||
return self.get_session(session_id) # type: ignore[return-value]
|
||
|
||
# ---------- agent_message ----------
|
||
|
||
def next_seq_no(self, session_id: str) -> int:
|
||
with self._engine.connect() as conn:
|
||
current = conn.execute(
|
||
text("SELECT MAX(seq_no) FROM agent_message WHERE session_id = :sid"),
|
||
{"sid": session_id},
|
||
).scalar_one()
|
||
return int(current or 0) + 1
|
||
|
||
def insert_message(
|
||
self,
|
||
*,
|
||
session_id: str,
|
||
trace_id: str,
|
||
seq_no: int,
|
||
role: str,
|
||
content: str,
|
||
has_disclaimer: bool = False,
|
||
token_est: int | None = None,
|
||
) -> None:
|
||
sql = text(
|
||
"""
|
||
INSERT INTO agent_message
|
||
(session_id, trace_id, seq_no, role, content, has_disclaimer, token_est)
|
||
VALUES (:sid, :trace_id, :seq_no, :role, :content, :has_disclaimer, :token_est)
|
||
"""
|
||
)
|
||
with self._engine.begin() as conn:
|
||
conn.execute(
|
||
sql,
|
||
{
|
||
"sid": session_id,
|
||
"trace_id": trace_id,
|
||
"seq_no": seq_no,
|
||
"role": role,
|
||
"content": content,
|
||
"has_disclaimer": 1 if has_disclaimer else 0,
|
||
"token_est": token_est,
|
||
},
|
||
)
|
||
|
||
# ---------- agent_tool_call ----------
|
||
|
||
def insert_tool_call(
|
||
self,
|
||
*,
|
||
session_id: str,
|
||
trace_id: str,
|
||
tool_name: str,
|
||
tool_input: str,
|
||
tool_output: str | None,
|
||
status: str,
|
||
error_code: str | None = None,
|
||
latency_ms: int | None = None,
|
||
) -> None:
|
||
"""Tool 调用留痕(表设计 §01 共用底座;只 INSERT,不更新)。
|
||
|
||
message_id 一期 NULL:Tool 节点先于 LLM 执行,user 消息尚未落库,
|
||
经 session_id + trace_id 已可审计还原;消息级关联归后续增强。
|
||
tool_input/tool_output 由调用方序列化为 JSON 字符串(列类型 JSON)。
|
||
"""
|
||
sql = text(
|
||
"""
|
||
INSERT INTO agent_tool_call
|
||
(session_id, trace_id, message_id, tool_name, tool_input,
|
||
tool_output, status, error_code, latency_ms)
|
||
VALUES (:sid, :trace_id, NULL, :tool_name, :tool_input,
|
||
:tool_output, :status, :error_code, :latency_ms)
|
||
"""
|
||
)
|
||
with self._engine.begin() as conn:
|
||
conn.execute(
|
||
sql,
|
||
{
|
||
"sid": session_id,
|
||
"trace_id": trace_id,
|
||
"tool_name": tool_name,
|
||
"tool_input": tool_input,
|
||
"tool_output": tool_output,
|
||
"status": status,
|
||
"error_code": error_code,
|
||
"latency_ms": latency_ms,
|
||
},
|
||
)
|
||
|
||
def insert_turn(
|
||
self,
|
||
*,
|
||
session_id: str,
|
||
trace_id: str,
|
||
user_content: str,
|
||
assistant_content: str,
|
||
has_disclaimer: bool = False,
|
||
) -> int:
|
||
"""一轮对话(user + assistant)**同事务**落库,返回起始 seq_no。
|
||
|
||
方案 C 评审 P0/P1:两条消息必须原子。分两次写时中途故障会留下
|
||
「user 已落、assistant 未落」的半截历史——下次请求会把它当上下文
|
||
读进 LLM,属于难排查的数据污染。同事务还顺带解决 seq 取号竞态:
|
||
max(seq_no)+1 在事务内计算,并发同会话不重号(并发写锁归后续)。
|
||
"""
|
||
sql = text(
|
||
"""
|
||
INSERT INTO agent_message
|
||
(session_id, trace_id, seq_no, role, content, has_disclaimer, token_est)
|
||
VALUES (:sid, :trace_id, :seq_no, :role, :content, :has_disclaimer, NULL)
|
||
"""
|
||
)
|
||
with self._engine.begin() as conn:
|
||
seq = (
|
||
int(
|
||
conn.execute(
|
||
text(
|
||
"SELECT COALESCE(MAX(seq_no), 0) FROM agent_message WHERE session_id = :sid"
|
||
),
|
||
{"sid": session_id},
|
||
).scalar_one()
|
||
)
|
||
+ 1
|
||
)
|
||
conn.execute(
|
||
sql,
|
||
{
|
||
"sid": session_id,
|
||
"trace_id": trace_id,
|
||
"seq_no": seq,
|
||
"role": "user",
|
||
"content": user_content,
|
||
"has_disclaimer": 0,
|
||
},
|
||
)
|
||
conn.execute(
|
||
sql,
|
||
{
|
||
"sid": session_id,
|
||
"trace_id": trace_id,
|
||
"seq_no": seq + 1,
|
||
"role": "assistant",
|
||
"content": assistant_content,
|
||
"has_disclaimer": 1 if has_disclaimer else 0,
|
||
},
|
||
)
|
||
return seq
|
||
|
||
def list_messages(self, session_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||
"""最近 limit 条(seq_no 升序返回,供 LLM 窗口直用)。
|
||
|
||
派生表必须带 alias(MySQL 1248;sqlite 宽松——测试与生产同 SQL 防双语义)。
|
||
"""
|
||
sql = text(
|
||
"""
|
||
SELECT * FROM (
|
||
SELECT * FROM agent_message WHERE session_id = :sid
|
||
ORDER BY seq_no DESC LIMIT :lim
|
||
) AS recent
|
||
ORDER BY recent.seq_no ASC
|
||
"""
|
||
)
|
||
with self._engine.connect() as conn:
|
||
rows = conn.execute(sql, {"sid": session_id, "lim": limit}).mappings().all()
|
||
return [
|
||
{
|
||
"seq_no": r["seq_no"],
|
||
"role": r["role"],
|
||
"content": r["content"],
|
||
"has_disclaimer": bool(r["has_disclaimer"]),
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
def list_messages_page(
|
||
self, session_id: str, *, limit: int = 50, offset: int = 0
|
||
) -> tuple[list[dict[str, Any]], int]:
|
||
"""前端历史消息分页(方案 B):seq_no 升序 + offset,返回 (items, total)。
|
||
|
||
与 list_messages(LLM 窗口「最近 N 条」)语义不同:前端要全量可翻页,
|
||
从第一条开始升序拉取。created_at 一并返回供前端展示时间。
|
||
"""
|
||
with self._engine.connect() as conn:
|
||
total = int(
|
||
conn.execute(
|
||
text("SELECT COUNT(*) FROM agent_message WHERE session_id = :sid"),
|
||
{"sid": session_id},
|
||
).scalar_one()
|
||
)
|
||
rows = conn.execute(
|
||
text(
|
||
"""
|
||
SELECT seq_no, role, content, has_disclaimer, created_at
|
||
FROM agent_message WHERE session_id = :sid
|
||
ORDER BY seq_no ASC
|
||
LIMIT :lim OFFSET :off
|
||
"""
|
||
),
|
||
{"sid": session_id, "lim": limit, "off": offset},
|
||
).mappings().all()
|
||
items = [
|
||
{
|
||
"seq_no": r["seq_no"],
|
||
"role": r["role"],
|
||
"content": r["content"],
|
||
"has_disclaimer": bool(r["has_disclaimer"]),
|
||
"created_at": str(r["created_at"]) if r["created_at"] is not None else None,
|
||
}
|
||
for r in rows
|
||
]
|
||
return items, total
|