Files
group_xinghuo_jinrong/app/repository/session_repository.py
T
GaoYiYuan_0626 c5182f1910 feat(convert): 基金转换 T-0/T-0b 门禁 + T-1 数据层 + T-2/T-2b 纯函数与实算回填
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
2026-09-10 14:45:55 +08:00

340 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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