2026-09-07 02:41:42 +08:00
|
|
|
|
"""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,最小闭环单演示进程暂未启用,留痕)。
|
2026-09-07 08:43:36 +08:00
|
|
|
|
T-04 增 agent_tool_call 落库(对话域第三张表;Tool 调用审计,与
|
|
|
|
|
|
audit_log/input_guard_log 合规审计表区分——本表由对话链路读写)。
|
2026-09-07 02:41:42 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-09-10 14:45:55 +08:00
|
|
|
|
self._engine = engine or get_engine(settings.mysql_database, "rw")
|
2026-09-07 02:41:42 +08:00
|
|
|
|
|
|
|
|
|
|
# ---------- 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
|
|
|
|
|
|
|
2026-09-08 14:07:38 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-09-07 02:41:42 +08:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-07 08:43:36 +08:00
|
|
|
|
# ---------- 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,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-09-08 14:23:33 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-09-07 02:41:42 +08:00
|
|
|
|
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
|
|
|
|
|
|
]
|
2026-09-08 14:07:38 +08:00
|
|
|
|
|
|
|
|
|
|
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
|