feat(agent,report): 会话持久化与画像注入、报告反馈与预约留资——PM/开发双Agent迭代1-3
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s

- agent: ag_conversation/ag_message 表(迁移0007),上下文组装注入最近报告画像摘要
  (report.profile.latest_profile_summary 经 apps 层闭包注入,同层不互调),历史带最近
  AGENT_HISTORY_TURNS=10 轮;POST/GET /agent/conversations 与消息历史接口
- agent 修复:ChatRequest.conversation_id 改 str(雪花ID超2^53,JS Number丢精度);
  _build_context 挪进 reserve commit 后同一 try,异常走 refund 兜底
- report: rp_feedback(维度反馈 upsert,认同率/修正率 SQL 可算,T-P3-21 最小版——
  L1 记忆未建,不回写结论)+ rp_reservation(预约留资幂等,T-P3-22,预约率口径
  行数/full_report.card_view 去重曝光,门槛≥8%),迁移0008
- 测试:全量 606 passed;ruff/mypy/lint-imports 全绿;E2E 真实 deepseek-v4-flash 验证
  画像注入与历史携带
This commit is contained in:
xsl
2026-09-11 21:50:58 +08:00
parent 00ef56b08b
commit 7bcbb8d40b
25 changed files with 1860 additions and 25 deletions
+5 -4
View File
@@ -14,15 +14,16 @@
| --- | --- |
| ① 输入审核(TMS + 备案关键词库),fail-close | ❌ CR-06 |
| ② 意图识别(含越界:医疗、身材评判) | ❌ FR-AGT-02 |
| ③ 上下文组装(token 预算严格分配) | 部分:只有固定前缀 + 当前输入 |
| ③ 上下文组装(token 预算严格分配) | 部分:画像摘要 + 最近 N 轮历史(`AGENT_HISTORY_TURNS`,默认 10);检索知识与会话摘要未做 |
| ④ 混合检索 | ❌ FR-AGT-04 |
| ⑤ LLM 生成(流式 SSE) | 部分:非流式 |
| ⑥ 生成后校验(禁忌违反 = 0、色值在板内) | ❌ FR-AGT-05 |
| ⑦ 输出审核 | ❌ CR-06 |
| ⑧ 落库 message + retrieval_trace + rule_hits + 成本 | 部分:只有成本 |
| ⑨ 异步偏好抽取 → 写 L2 记忆 | ❌ FR-MEM-03 |
| ⑧ 落库 message + retrieval_trace + rule_hits + 成本 | 部分:ag_message 双行 + 成本已实现;retrieval_trace / rule_hits 列已建、待步骤④填充 |
表 `ag_conversation` / `ag_message` / `ag_feedback` 尚未创建。
表 `ag_conversation` / `ag_message` 已建(迁移 0007,T-P4-01);`ag_feedback` 尚未创建。
画像摘要经 `report.profile.latest_profile_summary` 由 apps 层闭包注入
(agent 与 report 同层,不允许直接互调)。
## Token 预算(架构 §7.2,必须硬性限制)
+57
View File
@@ -0,0 +1,57 @@
"""上下文组装(架构 §7.2 步骤③)的纯逻辑:无 IO、无 LLM 调用,可单测。
预算分配(packages/agent/README.md 的预算表):
- 画像摘要 ≤ 500 token —— 按中文约 1.6 字符/token 保守取 800 字符上限,
摘要本身的生产侧(report 域)按 ``PROFILE_SUMMARY_MAX_CHARS`` 裁剪;
- 历史轮数默认 10 轮(``AGENT_HISTORY_TURNS``),一轮 = user + assistant 两条。
缓存纪律:画像与历史都是**逐用户逐轮变化**的内容,一律走 gateway 的
``messages`` 通道排在固定 system 前缀之后,绝不进 ``system_prefix``——
否则缓存命中单价 1/30 的红利会被画像差异完全打掉(README「两件容易踩的事」)。
"""
from __future__ import annotations
from collections.abc import Sequence
from packages.llm.types import Message
#: 画像摘要载荷上限(字符),对应 ≤ 500 token 的预算行。
PROFILE_SUMMARY_MAX_CHARS = 800
#: 画像摘要的消息壳。摘要是 JSON 素材(report 域 compose_prompt_material 的
#: 产物),需要这句说明告诉模型怎么用它;措辞保持中性,不引入新规则。
_PROFILE_MESSAGE_TEMPLATE = (
"以下是该用户最近一份美学诊断报告的画像摘要(JSON),"
"回答时据此给出贴合其条件的建议;用户未问及时不要主动复述全文。\n"
)
#: 单条历史消息的字符上限:用户输入已被 ChatRequest 卡在 2000 字符,
#: assistant 回复被提示词约束在 300 字以内,这里只做兜底截断防脏数据。
_HISTORY_MESSAGE_MAX_CHARS = 2000
def build_profile_message(profile_summary: str) -> Message:
return Message(role="system", content=_PROFILE_MESSAGE_TEMPLATE + profile_summary)
def build_context_messages(
*,
profile_summary: str | None,
history: Sequence[Message],
history_turns: int,
) -> list[Message]:
"""组装 gateway ``messages`` 通道的内容(固定前缀与当前输入由 gateway 自己拼)。
- 画像摘要在最前(无画像用户为 None 即不注入,走通用建议降级路径 FR-AGT-10);
- 历史按时间正序,只保留最近 ``history_turns`` 轮(一轮 = user+assistant 两条),
截尾不截头——越早的轮次对当前回答价值越低。
"""
messages: list[Message] = []
if profile_summary:
messages.append(build_profile_message(profile_summary))
tail = list(history)[-(history_turns * 2) :] if history_turns > 0 else []
for item in tail:
messages.append(Message(role=item.role, content=item.content[:_HISTORY_MESSAGE_MAX_CHARS]))
return messages
+108
View File
@@ -0,0 +1,108 @@
"""ag_conversation / ag_message 的数据访问。**只有本模块可以触这两张表**。"""
from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from packages.shared.models import AgConversation, AgMessage
ROLE_USER = "user"
ROLE_ASSISTANT = "assistant"
async def create_conversation(
session: AsyncSession, *, conversation_id: int, user_id: int
) -> AgConversation:
conversation = AgConversation(conversation_id=conversation_id, user_id=user_id)
session.add(conversation)
await session.flush()
return conversation
async def get_conversation(session: AsyncSession, conversation_id: int) -> AgConversation | None:
return await session.get(AgConversation, conversation_id)
async def list_conversations(
session: AsyncSession, *, user_id: int, limit: int = 20
) -> list[AgConversation]:
"""会话列表:按最近活跃倒序;从未发言的会话(last_message_at 为 NULL)排最后。"""
result = await session.execute(
select(AgConversation)
.where(AgConversation.user_id == user_id)
.order_by(AgConversation.last_message_at.desc().nulls_last())
.limit(limit)
)
return list(result.scalars().all())
async def list_messages(
session: AsyncSession, *, conversation_id: int, limit: int = 100
) -> list[AgMessage]:
"""历史接口用:按时间正序返回。"""
result = await session.execute(
select(AgMessage)
.where(AgMessage.conversation_id == conversation_id)
.order_by(AgMessage.created_at.asc(), AgMessage.message_id.asc())
.limit(limit)
)
return list(result.scalars().all())
async def list_recent_messages(
session: AsyncSession, *, conversation_id: int, limit: int
) -> list[AgMessage]:
"""上下文组装用:取最近 ``limit`` 条,返回时按时间正序(旧→新)。"""
result = await session.execute(
select(AgMessage)
.where(AgMessage.conversation_id == conversation_id)
.order_by(AgMessage.created_at.desc(), AgMessage.message_id.desc())
.limit(limit)
)
return list(reversed(result.scalars().all()))
async def append_message(
session: AsyncSession,
*,
message_id: int,
conversation: AgConversation,
role: str,
content: str,
call_id: int | None = None,
model: str | None = None,
tokens_in: int | None = None,
tokens_out: int | None = None,
tokens_cached: int | None = None,
latency_ms: int | None = None,
cost_cents: Decimal | None = None,
) -> AgMessage:
"""追加消息并推进会话的 message_count / last_message_at / title。
title 只在首条用户消息时落定(截断前 20 字),之后不再改。
"""
message = AgMessage(
message_id=message_id,
conversation_id=conversation.conversation_id,
user_id=conversation.user_id,
role=role,
content=content,
call_id=call_id,
model=model,
tokens_in=tokens_in,
tokens_out=tokens_out,
tokens_cached=tokens_cached,
latency_ms=latency_ms,
cost_cents=cost_cents,
)
session.add(message)
conversation.message_count += 1
conversation.last_message_at = datetime.now(UTC)
if not conversation.title and role == ROLE_USER:
conversation.title = content[:20]
await session.flush()
return message
+127 -10
View File
@@ -1,20 +1,23 @@
"""Agent 对话编排 — the scaffold's thin vertical slice.
It walks the full 架构文档 §7.2 spine but only the load-bearing parts:
"""Agent 对话编排(架构 §7.2)。
① 输入审核 ← NOT implemented (compliance.moderate, CR-06)
② 意图识别 ← NOT implemented (FR-AGT-02)
③ 上下文组装 ← only the fixed system prefix + current turn
③ 上下文组装 ← IMPLEMENTED:画像摘要 + 最近 N 轮历史(context.py)
④ 混合检索 ← NOT implemented (retrieval-svc, FR-AGT-04)
⑤ LLM 生成 ← IMPLEMENTED (non-streaming)
⑥ 生成后校验 ← NOT implemented (FR-AGT-05)
⑦ 输出审核 ← NOT implemented (CR-06)
⑧ 落库 + 成本 ← cost only; ag_message is not in this scaffold
⑧ 落库 + 成本 ← IMPLEMENTED:ag_message 双行 + ob_ai_call 成本行
⑨ 异步偏好抽取 ← NOT implemented (FR-MEM-03)
What *is* fully wired is the plumbing every future feature needs: JWT identity,
the quota three-phase protocol, the LLM gateway, cost attribution, and the
unified envelope.
会话持久化(FR-AGT-09 / T-P4-01):会话与消息落 ``ag_conversation`` /
``ag_message``;assistant 行的 ``call_id`` 关联 ``ob_ai_call``,每条回答可
复盘到模型、token 与成本。检索轨迹与规则命中列已建,等步骤④落地后填充。
画像注入:用户最近一份 succeeded 报告的画像摘要以独立 system 消息排在固定
前缀之后(不进缓存前缀,理由见 context.py)。摘要的读取由 apps 层以
``profile_provider`` 闭包注入——agent 与 report 在 import-linter 分层里是
同层,不允许直接互调。
Transaction discipline: the reservation is committed **before** the LLM call.
If the process dies mid-call, the reservation survives and can be reconciled;
@@ -24,24 +27,46 @@ API layer's rollback cannot undo it.
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from decimal import Decimal
from sqlalchemy.ext.asyncio import AsyncSession
from packages.agent import repo
from packages.agent.context import build_context_messages
from packages.billing.service import BillingService, Reservation
from packages.domain.ids import next_id
from packages.llm.gateway import LLMGateway
from packages.llm.types import Scene
from packages.llm.types import Message, Scene
from packages.shared.errors import NotFoundError
from packages.shared.logging import get_logger
from packages.shared.models import AgConversation, AgMessage
logger = get_logger(__name__)
QUOTA_KEY_AGENT_CHAT = "agent_chat"
REF_TYPE = "agent_chat"
#: 画像提供者:user_id → 画像摘要素材(无报告用户返回 None)。由 apps 层接线。
ProfileProvider = Callable[[int], Awaitable[str | None]]
def _parse_conversation_id(raw: str) -> int:
"""wire 侧雪花 ID 是字符串(超 2^53,JS Number 会丢精度)。
非法值与「会话不存在」同口径 40400:不区分「格式错」与「不存在」,
避免给遍历者任何额外的探测信号。
"""
try:
return int(raw)
except (TypeError, ValueError) as exc:
raise NotFoundError("会话不存在或已删除") from exc
@dataclass(frozen=True, slots=True)
class ChatResult:
conversation_id: int
reply: str
provider: str
model: str
@@ -64,12 +89,55 @@ class AgentService:
*,
gateway: LLMGateway,
billing: BillingService,
profile_provider: ProfileProvider | None = None,
history_turns: int = 10,
) -> None:
self._session = session
self._gateway = gateway
self._billing = billing
self._profile_provider = profile_provider
self._history_turns = history_turns
async def create_conversation(self, *, user_id: int) -> AgConversation:
conversation = await repo.create_conversation(
self._session, conversation_id=next_id(), user_id=user_id
)
await self._session.commit()
return conversation
async def list_conversations(self, *, user_id: int) -> list[AgConversation]:
return await repo.list_conversations(self._session, user_id=user_id)
async def list_messages(self, *, user_id: int, conversation_id: int) -> list[AgMessage]:
await self.get_owned_conversation(user_id=user_id, conversation_id=conversation_id)
return await repo.list_messages(self._session, conversation_id=conversation_id)
async def get_owned_conversation(self, *, user_id: int, conversation_id: int) -> AgConversation:
conversation = await repo.get_conversation(self._session, conversation_id)
if conversation is None:
raise NotFoundError("会话不存在或已删除")
if conversation.user_id != user_id:
# 不暴露「存在但属于别人」:与 NOT_FOUND 同口径,防会话 ID 遍历
raise NotFoundError("会话不存在或已删除")
return conversation
async def chat(
self,
*,
user_id: int,
message: str,
request_key: str,
conversation_id: str | None = None,
) -> ChatResult:
if conversation_id is None:
conversation = await repo.create_conversation(
self._session, conversation_id=next_id(), user_id=user_id
)
else:
conversation = await self.get_owned_conversation(
user_id=user_id, conversation_id=_parse_conversation_id(conversation_id)
)
async def chat(self, *, user_id: int, message: str, request_key: str) -> ChatResult:
reservation = await self._billing.reserve(
user_id=user_id,
quota_key=QUOTA_KEY_AGENT_CHAT,
@@ -81,9 +149,13 @@ class AgentService:
)
await self._session.commit()
# 预扣已 commit,自此到 commit/reserve 冲销之间的任何异常都必须退还——
# 包括上下文组装(历史消息/画像查询同样是 DB 调用,也会抛)。
try:
context = await self._build_context(user_id=user_id, conversation=conversation)
completion = await self._gateway.complete(
scene=Scene.AGENT_CHAT,
messages=context,
prompt_vars={"user_input": message},
user_id=user_id,
ref_type=REF_TYPE,
@@ -94,9 +166,31 @@ class AgentService:
raise
await self._billing.commit(reservation)
await repo.append_message(
self._session,
message_id=next_id(),
conversation=conversation,
role=repo.ROLE_USER,
content=message,
)
await repo.append_message(
self._session,
message_id=next_id(),
conversation=conversation,
role=repo.ROLE_ASSISTANT,
content=completion.text,
call_id=completion.call_id,
model=completion.model,
tokens_in=completion.usage.tokens_in,
tokens_out=completion.usage.tokens_out,
tokens_cached=completion.usage.tokens_cached,
latency_ms=completion.latency_ms,
cost_cents=completion.cost_cents,
)
await self._session.commit()
return ChatResult(
conversation_id=conversation.conversation_id,
reply=completion.text,
provider=completion.provider,
model=completion.model,
@@ -112,6 +206,29 @@ class AgentService:
reserve_tx_id=reservation.tx_id,
)
async def _build_context(self, *, user_id: int, conversation: AgConversation) -> list[Message]:
# 画像取不到(无报告 / 读取异常)不阻断对话:降级为通用建议(FR-AGT-10)。
# NFR-SEC-03:摘要内容本身不进日志,只记有无与长度。
profile_summary: str | None = None
if self._profile_provider is not None:
try:
profile_summary = await self._profile_provider(user_id)
except Exception:
logger.warning("agent_profile_load_failed", user_id=user_id)
history: list[Message] = []
if conversation.message_count > 0:
recent = await repo.list_recent_messages(
self._session,
conversation_id=conversation.conversation_id,
limit=self._history_turns * 2,
)
history = [Message(role=item.role, content=item.content) for item in recent]
return build_context_messages(
profile_summary=profile_summary,
history=history,
history_turns=self._history_turns,
)
async def _refund(self, reservation: Reservation, *, reason: str) -> None:
try:
await self._billing.refund(reservation, reason=reason)
+16
View File
@@ -3,6 +3,9 @@
**职责**(架构 §4.1):触发判定、生成编排、版本管理、分享。
**不负责**:具体的视觉分析与文案生成(调 AI 能力层)。
`profile.py` 对外提供「最近一份 succeeded 报告的画像摘要」(Agent 上下文
注入用,P0-2);rp_report / rp_input 仍只有本域 repo 可触,外部一律走这里。
## 待实现
- 表:`rp_report` `rp_input` `rp_share`
@@ -17,3 +20,16 @@
(也就不需要用户重新上传)。
4. **生成后校验不可跳过**:禁忌违反、色值在板内、维度引用存在、无医疗/贬损表述。
最多重试 2 次,仍失败降级为规则直出模板文案,**绝不放行违规内容**。
## 结论反馈(T-P3-21 最小可用版,迭代 3 落地)
`feedback_service.py` + `rp_feedback`:维度级「准/不准 + 可选修正」,同维度
重复反馈 = 覆盖更新(upsert)。**有意裁剪**:原文要求的「修正后置信度置 1.0
并即时写 L1 记忆」未做——L1 记忆系统未建,本迭代只落表,认同率/修正率
两项指标可从 SQL 直接算出,不回写报告结论。
## 预约留资(T-P3-22,迭代 3 落地)
`reservation_service.py` + `rp_reservation`:topic 预留通用性(首期
full_report)。指标口径:**预约率 = rp_reservation(topic='full_report') 行数
÷ 埋点 full_report.card_view 去重曝光数**;决策门槛 ≥8%(产品方案 §2.2.3)。
+108
View File
@@ -0,0 +1,108 @@
"""报告结论反馈服务(T-P3-21 最小可用版)。
有意裁剪(防误解):T-P3-21 原文要求「修正后置信度置 1.0 并即时写 L1 记忆」
——L1 记忆系统未建,本迭代只落 rp_feedback 表,保证认同率/修正率两项指标
可从 SQL 直接算出,**不回写报告结论**。
归属口径:他人报告与不存在同口径 40420(不透出报告是否存在的额外信息)。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from packages.domain.ids import next_id
from packages.report import repo
from packages.report.schemas import FeedbackVerdict
from packages.shared.errors import AppError, ErrorCode
from packages.shared.models import RpFeedback, RpReport
DIMENSION_STATUS_OK = "ok"
@dataclass(frozen=True, slots=True)
class FeedbackUpsert:
feedback: RpFeedback
updated: bool # true=同维度已有反馈被覆盖;false=新建
def ok_dimension_keys(report_result: dict[str, Any]) -> set[str]:
"""报告产物中可反馈的维度集合:存在于 diagnosis.dimensions 且 status=ok。
纯函数(不碰 I/O),路由层校验与单测共用同一口径。
"""
diagnosis = report_result.get("diagnosis")
if not isinstance(diagnosis, dict):
return set()
dimensions = diagnosis.get("dimensions")
if not isinstance(dimensions, list):
return set()
keys: set[str] = set()
for item in dimensions:
if (
isinstance(item, dict)
and item.get("status") == DIMENSION_STATUS_OK
and isinstance(item.get("key"), str)
):
keys.add(item["key"])
return keys
class FeedbackService:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def _get_owned_report(self, *, user_id: int, report_id: int) -> RpReport:
report = await repo.get_report(self._session, report_id)
# 归属校验沿用 get_owned 语义,但对外同口径 40420:
# 不存在与他人报告不可区分,不透出报告是否真实存在。
if report is None or report.user_id != user_id:
raise AppError(ErrorCode.REPORT_NOT_FOUND)
return report
async def upsert_feedback(
self,
*,
user_id: int,
report_id: int,
dimension_key: str,
verdict: FeedbackVerdict,
correction: str | None,
) -> FeedbackUpsert:
"""同维度重复反馈 = 覆盖更新(uq_rp_feedback_report_dimension 兜底)。"""
report = await self._get_owned_report(user_id=user_id, report_id=report_id)
if dimension_key not in ok_dimension_keys(report.result or {}):
raise AppError(
ErrorCode.VALIDATION_FAILED,
message="该维度没有可反馈的结论",
hint="只能对报告中已生成的维度结论反馈",
)
correction = correction.strip() if correction is not None else None
if correction == "":
correction = None
existing = await repo.get_feedback(
self._session, report_id=report_id, dimension_key=dimension_key
)
if existing is not None:
existing.verdict = verdict
existing.correction = correction
await self._session.flush()
return FeedbackUpsert(feedback=existing, updated=True)
row = RpFeedback(
feedback_id=next_id(),
report_id=report_id,
user_id=user_id,
dimension_key=dimension_key,
verdict=verdict,
correction=correction,
)
self._session.add(row)
await self._session.flush()
return FeedbackUpsert(feedback=row, updated=False)
async def list_feedback(self, *, user_id: int, report_id: int) -> list[RpFeedback]:
await self._get_owned_report(user_id=user_id, report_id=report_id)
return await repo.list_feedback(self._session, report_id=report_id)
+50
View File
@@ -0,0 +1,50 @@
"""对外(同层以上)提供「最近一份报告的画像摘要」,供 Agent 上下文注入。
只取结论摘要而非报告全文:素材走 ``copy.compose_prompt_material``——与
REPORT_COPY 场景同一套裁剪纪律(skipped 维度不进素材防幻觉、超长按优先级
裁、硬结论永不裁),只是预算从报告的 6000 字符收紧到调用方给的额度。
归属纪律不变:rp_report / rp_input 只有 report 域内部可触(repo.py 头部
约定),外部要画像只能走这里。
"""
from __future__ import annotations
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from packages.report import repo
from packages.report.copy import compose_prompt_material
from packages.shared.logging import get_logger
logger = get_logger(__name__)
#: 往前翻几份报告找可用的诊断产物。正常用户只有几份;留余量是为了容错
#: 「最新一份 failed/processing 而上一份 succeeded」的场景。
_SCAN_LIMIT = 5
async def latest_profile_summary(
session: AsyncSession, *, user_id: int, max_chars: int
) -> str | None:
"""该用户最近一份带诊断结论的 succeeded 报告 → 紧凑 JSON 素材;无则 None。
NFR-SEC-03:摘要内容不进日志,这里只记「命中第几份报告」。
"""
reports = await repo.list_reports(session, user_id=user_id, limit=_SCAN_LIMIT)
for index, report in enumerate(reports):
if report.status != repo.STATUS_SUCCEEDED:
continue
diagnosis: Any = (report.result or {}).get("diagnosis")
if not isinstance(diagnosis, dict) or not diagnosis.get("dimensions"):
continue
material, dropped = compose_prompt_material(diagnosis, max_chars=max_chars)
logger.info(
"report_profile_summary_built",
report_id=report.report_id,
scan_depth=index,
dropped_keys=len(dropped),
)
return material
return None
+54 -2
View File
@@ -1,11 +1,14 @@
"""rp_report / rp_input 的数据访问。**只有本模块可以触这两张表**(rp_share 归后续任务)。"""
"""rp_report / rp_input / rp_feedback / rp_reservation 的数据访问。
**只有本模块可以触这四张表**(rp_share 归后续任务)。
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from packages.shared.models import RpInput, RpReport
from packages.shared.models import RpFeedback, RpInput, RpReport, RpReservation
STATUS_SUCCEEDED = "succeeded"
TIER_BASIC = "basic"
@@ -45,3 +48,52 @@ async def list_reports(session: AsyncSession, *, user_id: int, limit: int = 20)
async def get_input(session: AsyncSession, report_id: int) -> RpInput | None:
result = await session.execute(select(RpInput).where(RpInput.report_id == report_id))
return result.scalar_one_or_none()
# -- rp_feedback(T-P3-21:同维度覆盖更新,upsert 语义) -----------------------
async def get_feedback(
session: AsyncSession, *, report_id: int, dimension_key: str
) -> RpFeedback | None:
result = await session.execute(
select(RpFeedback).where(
RpFeedback.report_id == report_id,
RpFeedback.dimension_key == dimension_key,
)
)
return result.scalar_one_or_none()
async def list_feedback(session: AsyncSession, *, report_id: int) -> list[RpFeedback]:
result = await session.execute(
select(RpFeedback)
.where(RpFeedback.report_id == report_id)
.order_by(RpFeedback.created_at.asc(), RpFeedback.feedback_id.asc())
)
return list(result.scalars().all())
# -- rp_reservation(T-P3-22:重复预约幂等) ------------------------------------
async def get_reservation(
session: AsyncSession, *, user_id: int, topic: str
) -> RpReservation | None:
result = await session.execute(
select(RpReservation).where(
RpReservation.user_id == user_id,
RpReservation.topic == topic,
)
)
return result.scalar_one_or_none()
async def list_reservation_topics(
session: AsyncSession, *, user_id: int, topic: str | None = None
) -> list[str]:
stmt = select(RpReservation.topic).where(RpReservation.user_id == user_id)
if topic is not None:
stmt = stmt.where(RpReservation.topic == topic)
result = await session.execute(stmt.order_by(RpReservation.topic.asc()))
return [str(row) for row in result.scalars().all()]
+43
View File
@@ -0,0 +1,43 @@
"""预约留资服务(T-P3-22)。
topic 预留通用性,首期只有 full_report(全面报告上线提醒)。
幂等口径:UniqueConstraint(user_id, topic)——重复预约返回已有记录
(created=false,HTTP 仍 200),绝不堆行。
指标口径:预约率 = rp_reservation(topic='full_report') 行数 ÷ 埋点
full_report.card_view 去重曝光数;决策门槛 ≥8%(产品方案 §2.2.3)。
"""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy.ext.asyncio import AsyncSession
from packages.domain.ids import next_id
from packages.report import repo
from packages.shared.models import RpReservation
@dataclass(frozen=True, slots=True)
class ReservationResult:
reservation: RpReservation
created: bool # false=幂等命中已有记录
class ReservationService:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def reserve(self, *, user_id: int, topic: str) -> ReservationResult:
"""幂等预约:同 user+topic 已存在即返回原记录。"""
existing = await repo.get_reservation(self._session, user_id=user_id, topic=topic)
if existing is not None:
return ReservationResult(reservation=existing, created=False)
row = RpReservation(reservation_id=next_id(), user_id=user_id, topic=topic)
self._session.add(row)
await self._session.flush()
return ReservationResult(reservation=row, created=True)
async def list_topics(self, *, user_id: int, topic: str | None = None) -> list[str]:
return await repo.list_reservation_topics(self._session, user_id=user_id, topic=topic)
+47
View File
@@ -7,6 +7,7 @@ Wire 响应统一走 ok() 信封(架构文档 §9.2),这里的模型只描
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
@@ -57,3 +58,49 @@ class ReportItem(BaseModel):
)
created_at: datetime
completed_at: datetime | None
# ---------------------------------------------------------------------------
# T-P3-21:维度级结论反馈
# ---------------------------------------------------------------------------
FeedbackVerdict = Literal["accurate", "inaccurate"]
class FeedbackCreateRequest(BaseModel):
dimension_key: str = Field(
min_length=1, max_length=64, description="报告 result.diagnosis.dimensions 的 key"
)
verdict: FeedbackVerdict = Field(description="准(accurate)/ 不准(inaccurate)")
correction: str | None = Field(
default=None, max_length=200, description="verdict=inaccurate 时的可选修正,≤200 字"
)
class FeedbackUpsertResponse(BaseModel):
dimension_key: str
verdict: str
updated: bool = Field(description="true=同维度已有反馈被覆盖更新;false=新建")
class FeedbackItem(BaseModel):
dimension_key: str
verdict: str
correction: str | None
created_at: datetime
# ---------------------------------------------------------------------------
# T-P3-22:预约留资(topic 预留通用性,首期 full_report)
# ---------------------------------------------------------------------------
class ReservationCreateRequest(BaseModel):
topic: str = Field(min_length=1, max_length=32)
class ReservationResponse(BaseModel):
topic: str
created: bool = Field(description="false=已存在(幂等命中,HTTP 仍 200)")
created_at: datetime
class ReservationListResponse(BaseModel):
topics: list[str]
+4
View File
@@ -126,6 +126,10 @@ class Settings(BaseSettings):
# --- Auth: refresh sessions (FR-ACC-05) --------------------------------
refresh_token_ttl_seconds: int = 2_592_000 # 30 天
# --- Agent 对话(FR-AGT-09)--------------------------------------------
# 对话上下文携带的最近轮数(一轮 = user + assistant 两条),默认 10。
agent_history_turns: int = 10
# --- 任务奖励(FR-BILL-03/04,配置化)-----------------------------------
# amount 全部是占位值 1:正式数值待产品在产品方案 §3.2 定稿后改 env,不改代码。
# claim_period 决定幂等粒度(同一任务同周期只能领一次)。
+106 -3
View File
@@ -1,9 +1,9 @@
"""SQLAlchemy 2.0 ORM models.
Scope of this scaffold: only the tables needed to prove the vertical slice
(架构文档 §6.2 identity / billing / task / 观测与成本). All remaining domains
(pf_ / rp_ / kb_ / ag_ / tr_ / cp_) are intentionally absent — see
packages/*/README.md for what still has to be built.
(架构文档 §6.2 identity / billing / task / 观测与成本) plus the Sprint batches
that have landed since (kb_ / rp_ / ag_). Remaining domains (pf_ / tr_ / cp_)
are intentionally absent — see packages/*/README.md for what still has to be built.
Conventions enforced here (架构文档 §6.1):
1. 表名带域前缀 (`id_` / `bl_` / `tk_` / `ob_`),为将来拆库预留边界。
@@ -541,3 +541,106 @@ class RpShare(Base):
)
__table_args__ = (UniqueConstraint("share_token", name="uq_rp_share_token"),)
class RpFeedback(Base):
"""维度级结论反馈(T-P3-21 最小可用版):准/不准 + 可选修正文本。
同维度重复反馈是覆盖更新(upsert),由 uq_rp_feedback_report_dimension
兜底;认同率/修正率两项指标从本表 SQL 直接算出。
有意裁剪:不回写报告结论、不写 L1 记忆(记忆系统未建,见迁移 0008)。"""
__tablename__ = "rp_feedback"
feedback_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
report_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
dimension_key: Mapped[str] = mapped_column(String(64), nullable=False)
verdict: Mapped[str] = mapped_column(String(16), nullable=False) # accurate | inaccurate
correction: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
__table_args__ = (
UniqueConstraint("report_id", "dimension_key", name="uq_rp_feedback_report_dimension"),
Index("ix_rp_feedback_user_created", "user_id", "created_at"),
)
class RpReservation(Base):
"""预约留资(T-P3-22):topic 预留通用性,首期只有 full_report。
uq_rp_reservation_user_topic 承载幂等:重复预约返回已有记录(created=false)。
指标口径:预约率 = topic='full_report' 行数 ÷ 埋点 full_report.card_view
去重曝光数;决策门槛 ≥8%(产品方案 §2.2.3)。"""
__tablename__ = "rp_reservation"
reservation_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
topic: Mapped[str] = mapped_column(String(32), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (UniqueConstraint("user_id", "topic", name="uq_rp_reservation_user_topic"),)
# ---------------------------------------------------------------------------
# agent 域(FR-AGT-09 / T-P4-01:会话与消息,含检索轨迹与规则命中)
# ---------------------------------------------------------------------------
class AgConversation(Base):
"""对话会话。title 取首条用户消息的截断,列表页展示用。
保留策略(合规方案 §6:对话原文 90 天)由后续清理任务按
``last_message_at`` 执行,表结构先把时间锚点留好。"""
__tablename__ = "ag_conversation"
conversation_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
title: Mapped[str] = mapped_column(String(64), nullable=False, default="")
message_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
last_message_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (Index("ix_ag_conversation_user_active", "user_id", "last_message_at"),)
class AgMessage(Base):
"""单条对话消息。``retrieval_trace`` / ``rule_hits`` 是 FR-AGT-09「每条回答
可复盘」的复盘锚点:检索与规则命中在对应步骤(架构 §7.2 ④)落地前留空。
``call_id`` 关联 ``ob_ai_call``,一条 assistant 消息的模型/token/成本可
一路追到账本;user 消息没有 LLM 调用,这些字段恒 NULL。"""
__tablename__ = "ag_message"
message_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
conversation_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
role: Mapped[str] = mapped_column(String(16), nullable=False) # user / assistant
content: Mapped[str] = mapped_column(Text, nullable=False)
call_id: Mapped[int | None] = mapped_column(BigInteger)
model: Mapped[str | None] = mapped_column(String(64))
tokens_in: Mapped[int | None] = mapped_column(Integer)
tokens_out: Mapped[int | None] = mapped_column(Integer)
tokens_cached: Mapped[int | None] = mapped_column(Integer)
latency_ms: Mapped[int | None] = mapped_column(Integer)
cost_cents: Mapped[Decimal | None] = mapped_column(Numeric(14, 6))
retrieval_trace: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
rule_hits: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index("ix_ag_message_conversation_created", "conversation_id", "created_at"),
Index("ix_ag_message_user_created", "user_id", "created_at"),
)