Files
meida_backend/packages/shared/models.py
T
xsl 7bcbb8d40b
CI / lint / typecheck / import-linter / tests / secrets (push) Canceled after 0s
feat(agent,report): 会话持久化与画像注入、报告反馈与预约留资——PM/开发双Agent迭代1-3
- 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 验证
  画像注入与历史携带
2026-09-11 21:50:58 +08:00

647 lines
30 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.
"""SQLAlchemy 2.0 ORM models.
Scope of this scaffold: only the tables needed to prove the vertical slice
(架构文档 §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_`),为将来拆库预留边界。
2. 主键统一雪花 ID (bigint),不用自增、不用 UUID。
3. 涉及配额的表必须有幂等键与流水。
4. JSONB 只用于「结构会演进且不需要索引全部字段」的场景。
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Any
from pgvector.sqlalchemy import Vector
from sqlalchemy import (
BigInteger,
Boolean,
Computed,
DateTime,
Index,
Integer,
Numeric,
PrimaryKeyConstraint,
SmallInteger,
String,
Text,
UniqueConstraint,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
"""Declarative base; ``Base.metadata`` is Alembic's ``target_metadata``."""
# ---------------------------------------------------------------------------
# identity 域
# ---------------------------------------------------------------------------
class User(Base):
__tablename__ = "id_user"
user_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
nickname: Mapped[str | None] = mapped_column(String(64))
avatar_key: Mapped[str | None] = mapped_column(String(255))
register_source: Mapped[str] = mapped_column(String(32), nullable=False, default="unknown")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
last_active_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (Index("ix_id_user_status", "status"),)
class UserIdentity(Base):
"""External identity mapping. UNIQUE(provider, union_id) per 架构文档 §6.2."""
__tablename__ = "id_user_identity"
identity_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
provider: Mapped[str] = mapped_column(String(32), nullable=False)
union_id: Mapped[str] = mapped_column(String(128), nullable=False)
open_id: Mapped[str | None] = mapped_column(String(128))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
UniqueConstraint("provider", "union_id", name="uq_id_user_identity_provider_union"),
Index("ix_id_user_identity_user", "user_id"),
)
class Consent(Base):
"""CR-01 留证:人脸的单独同意必须独立成行,不得混入总协议。"""
__tablename__ = "id_consent"
consent_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
consent_type: Mapped[str] = mapped_column(String(32), nullable=False)
doc_version: Mapped[str] = mapped_column(String(32), nullable=False)
granted_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
client_ip: Mapped[str | None] = mapped_column(String(64))
ua: Mapped[str | None] = mapped_column(String(512))
__table_args__ = (Index("ix_id_consent_user_type", "user_id", "consent_type"),)
# ---------------------------------------------------------------------------
# billing 域(V1:配额与计量,无支付)
# ---------------------------------------------------------------------------
class QuotaRule(Base):
"""FR-BILL-04 配额规则配置化:改配置不发版。"""
__tablename__ = "bl_quota_rule"
quota_key: Mapped[str] = mapped_column(String(64), primary_key=True)
period_type: Mapped[str] = mapped_column(String(16), nullable=False) # day/month/lifetime
base_limit: Mapped[int] = mapped_column(Integer, nullable=False)
task_bonus_max: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
hard_cap: Mapped[int] = mapped_column(Integer, nullable=False)
description: Mapped[str | None] = mapped_column(String(255))
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class Quota(Base):
"""周期配额计量:按 ``period_key`` 自然重置,无需定时任务清零。"""
__tablename__ = "bl_quota"
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
quota_key: Mapped[str] = mapped_column(String(64), nullable=False)
period_key: Mapped[str] = mapped_column(String(32), nullable=False)
used: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 本周期创建时从规则拷下的 base_limit。规则中途被改,本周期仍按快照判定。
limit_value: Mapped[int] = mapped_column(Integer, nullable=False)
bonus_granted: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
PrimaryKeyConstraint("user_id", "quota_key", "period_key", name="pk_bl_quota"),
)
class Ledger(Base):
"""流水是真相,余额是缓存 (架构文档 §6.1 原则 4 / §6.2 bl_ledger).
``tx_type`` 在 V1 只用 system_grant / consume / refund;V2 增加
purchase / gift —— 这是 V1→V2 免数据迁移的关键,不要拆表。
``status`` 承载 §7.4 的预扣三段式:reserved → committed / refunded。
"""
__tablename__ = "bl_ledger"
tx_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
delta: Mapped[int] = mapped_column(Integer, nullable=False)
tx_type: Mapped[str] = mapped_column(String(24), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="committed")
reason: Mapped[str | None] = mapped_column(String(128))
ref_type: Mapped[str | None] = mapped_column(String(32))
ref_id: Mapped[str | None] = mapped_column(String(64))
quota_key: Mapped[str | None] = mapped_column(String(64))
period_key: Mapped[str | None] = mapped_column(String(32))
balance_after: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
UniqueConstraint("idempotency_key", name="uq_bl_ledger_idempotency_key"),
Index("ix_bl_ledger_user_created", "user_id", "created_at"),
)
# ---------------------------------------------------------------------------
# task 域(统一任务中心)
# ---------------------------------------------------------------------------
class Task(Base):
"""全系统唯一的长耗时任务视图,前端任务中心直接读它 (架构文档 §6.2)。"""
__tablename__ = "tk_task"
task_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
task_type: Mapped[str] = mapped_column(String(32), nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False)
progress: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0)
stage_text: Mapped[str | None] = mapped_column(String(128))
idempotency_key: Mapped[str] = mapped_column(String(128), nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
result: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
error_code: Mapped[int | None] = mapped_column(Integer)
error_message: Mapped[str | None] = mapped_column(String(512))
retry_count: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0)
quota_reserved: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
reserve_tx_id: Mapped[int | None] = mapped_column(BigInteger)
submitted_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
eta_seconds: Mapped[int | None] = mapped_column(Integer)
__table_args__ = (
UniqueConstraint("idempotency_key", name="uq_tk_task_idempotency_key"),
Index("ix_tk_task_user_status", "user_id", "status"),
)
# ---------------------------------------------------------------------------
# 观测与成本
# ---------------------------------------------------------------------------
class AiCall(Base):
"""成本归因的唯一数据源 (架构文档 §13, FR-BILL-08).
**不允许「先上线后补埋点」** —— V1 不收费,成本是唯一财务变量,且这张表
是 V2 定价的唯一数据来源。每一次 provider 调用(含失败与结构化重试)都写
一行。生产环境按月分区。
"""
__tablename__ = "ob_ai_call"
call_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
user_id: Mapped[int | None] = mapped_column(BigInteger)
scene: Mapped[str] = mapped_column(String(48), nullable=False)
provider: Mapped[str] = mapped_column(String(32), nullable=False)
model: Mapped[str] = mapped_column(String(64), nullable=False)
# Prompt 版本:架构文档 §8.1「可复现」要求记录,§6.2 的列清单漏了,这里补上。
prompt_id: Mapped[str | None] = mapped_column(String(64))
prompt_version: Mapped[str | None] = mapped_column(String(16))
tokens_in: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
tokens_out: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
tokens_cached: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# 思考模式 token 单独记,不并入 tokens_out:它按输出价计费,混在一起就看不出
# 开关思考模式的成本差异,而这正是 T-P1-24a 要长期盯的数。
tokens_reasoning: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
latency_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
cost_cents: Mapped[Decimal] = mapped_column(
Numeric(14, 6), nullable=False, default=Decimal("0")
)
# 该次调用适用的峰谷档位。没有它就无法事后复核 cost_cents ——
# 单价随时刻变化,事后按「当前价」回算会得出不同结果 (T-P1-25c)。
rate_tier: Mapped[str | None] = mapped_column(String(16))
success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
error_code: Mapped[str | None] = mapped_column(String(64))
error_message: Mapped[str | None] = mapped_column(Text)
ref_type: Mapped[str | None] = mapped_column(String(32))
ref_id: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index("ix_ob_ai_call_created", "created_at"),
Index("ix_ob_ai_call_scene_created", "scene", "created_at"),
Index("ix_ob_ai_call_user_created", "user_id", "created_at"),
)
class UserSession(Base):
"""refresh token 的服务端会话(架构文档 §6.2 id_session,FR-ACC-05).
刻意叫 UserSession 而不是 Session:避免与 SQLAlchemy 的 AsyncSession 混淆。
refresh token 本体是 opaque 随机串(不出现在任何存储里),库里只存 sha256
哈希 —— 泄漏数据库不等于泄漏令牌。轮换 = 旧行 revoked + 新行,同一事务。
"""
__tablename__ = "id_session"
session_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
refresh_token_hash: Mapped[str] = mapped_column(String(64), nullable=False)
expire_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
revoked_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__ = (
UniqueConstraint("refresh_token_hash", name="uq_id_session_token_hash"),
Index("ix_id_session_user", "user_id"),
)
class ObEvent(Base):
"""客户端埋点事件(架构文档 §6.2 ob_event,FR-SYS-06).
架构原文「按月分区」—— V1 先普通表(与 ob_ai_call 同样处理),
数据量上来后再分区,先不预支复杂度。
"""
__tablename__ = "ob_event"
event_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
event_name: Mapped[str] = mapped_column(String(64), nullable=False)
props: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
client_ts: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
server_ts: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index("ix_ob_event_name_ts", "event_name", "server_ts"),
Index("ix_ob_event_user_ts", "user_id", "server_ts"),
)
# ---------------------------------------------------------------------------
# kb 域(知识库,架构文档 §6.2)
# ---------------------------------------------------------------------------
class KbEntity(Base):
"""12 类实体统一表:attrs JSONB 承载类目差异化字段(架构 §6.2).
状态机 draft→reviewing→published→archived 由 service 层校验;
published 实体不可直接改(version 递增需回 draft 重走评审)。
"""
__tablename__ = "kb_entity"
entity_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
entity_type: Mapped[str] = mapped_column(String(32), nullable=False)
name: Mapped[str] = mapped_column(String(128), nullable=False)
attrs: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft")
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
source: Mapped[str | None] = mapped_column(String(255))
license: Mapped[str | None] = mapped_column(String(64))
updated_by: Mapped[str | None] = mapped_column(String(64))
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()
)
__table_args__ = (
UniqueConstraint("entity_type", "name", name="uq_kb_entity_type_name"),
Index("ix_kb_entity_type_status", "entity_type", "status"),
)
class KbRelation(Base):
"""邻接表 + 递归 CTE 做 1~3 跳(TD-09:不上 Neo4j)."""
__tablename__ = "kb_relation"
relation_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
from_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
rel_type: Mapped[str] = mapped_column(String(64), nullable=False)
to_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
weight: Mapped[Decimal] = mapped_column(Numeric(4, 3), nullable=False, default=Decimal("1"))
confidence: Mapped[Decimal] = mapped_column(Numeric(4, 3), nullable=False, default=Decimal("1"))
evidence: Mapped[str | None] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
UniqueConstraint("from_id", "rel_type", "to_id", name="uq_kb_relation_triple"),
Index("ix_kb_relation_to", "to_id"),
)
class KbRule(Base):
"""决策表(TD-10 自研规则引擎)。conditions/effects 为 JSONB DSL."""
__tablename__ = "kb_rule"
rule_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
rule_key: Mapped[str] = mapped_column(String(64), nullable=False)
category: Mapped[str] = mapped_column(String(32), nullable=False)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft")
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=60)
conditions: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
effects: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
is_hard: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
confidence: Mapped[Decimal] = mapped_column(
Numeric(4, 3), nullable=False, default=Decimal("0.9")
)
evidence: Mapped[str | None] = mapped_column(String(255))
author: Mapped[str | None] = mapped_column(String(64))
reviewer: Mapped[str | None] = mapped_column(String(64))
published_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__ = (
UniqueConstraint("rule_key", name="uq_kb_rule_key"),
Index("ix_kb_rule_status_priority", "status", "priority"),
)
class KbChunk(Base):
"""向量 + 全文双索引,支撑混合检索(架构 §8.3).
embedding_model/version 必填:没有版本号的向量不可重跑(T-P2-03)。
tsv 是生成列——写入时不要手工填。
"""
__tablename__ = "kb_chunk"
chunk_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
entity_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
chunk_type: Mapped[str] = mapped_column(String(32), nullable=False, default="text")
text: Mapped[str] = mapped_column(Text, nullable=False)
embedding = mapped_column(Vector(1024), nullable=False)
embedding_model: Mapped[str] = mapped_column(String(64), nullable=False)
embedding_version: Mapped[str] = mapped_column(String(16), nullable=False)
tsv = mapped_column(
TSVECTOR, Computed("to_tsvector('simple', text)", persisted=True), nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (Index("ix_kb_chunk_entity", "entity_id"),)
class KbAsset(Base):
"""图像资产。tryon_ready 是换装闭环的关键标志位(架构 §6.2)."""
__tablename__ = "kb_asset"
asset_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
entity_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
asset_type: Mapped[str] = mapped_column(String(32), nullable=False)
cos_key: Mapped[str] = mapped_column(String(255), nullable=False)
spec_check: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
tryon_ready: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
review_status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
license: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index("ix_kb_asset_entity", "entity_id"),
Index("ix_kb_asset_tryon_ready", "tryon_ready", postgresql_where=text("tryon_ready")),
)
class KbColor(Base):
"""自建色板(TD-18:规避「四季色彩/CMB」商标)。Lab 用于 ΔE 数值匹配."""
__tablename__ = "kb_color"
color_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
season_code: Mapped[str] = mapped_column(String(16), nullable=False)
role: Mapped[str] = mapped_column(String(8), nullable=False)
lab_l: Mapped[Decimal] = mapped_column(Numeric(6, 2), nullable=False)
lab_a: Mapped[Decimal] = mapped_column(Numeric(6, 2), nullable=False)
lab_b: Mapped[Decimal] = mapped_column(Numeric(6, 2), nullable=False)
hex: Mapped[str] = mapped_column(String(7), nullable=False)
name_cn: Mapped[str] = mapped_column(String(64), nullable=False)
__table_args__ = (Index("ix_kb_color_season", "season_code"),)
class KbGap(Base):
"""知识缺口埋点:无检索结果的查询,驱动内容生产(T-P2-06)."""
__tablename__ = "kb_gap"
gap_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
query_text: Mapped[str] = mapped_column(Text, nullable=False)
filter_ctx: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
hit_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
first_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
last_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (Index("ix_kb_gap_query", "query_text"),)
# ---------------------------------------------------------------------------
# report 域(美学报告,架构文档 §6.2 / §7.1)
# ---------------------------------------------------------------------------
class RpReport(Base):
"""报告主表。result JSONB 同时承载流水线中间态与最终产物(按 stage 键)——
阶段逐段落库是 T-P3-09「失败不需重新上传」的结构基础。"""
__tablename__ = "rp_report"
report_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
user_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
tier: Mapped[str] = mapped_column(String(8), nullable=False, default="basic")
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
result: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
model_info: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
content_code: Mapped[str | None] = mapped_column(String(32))
rendered_asset_key: Mapped[str | None] = mapped_column(String(255))
cost_cents: Mapped[Decimal] = mapped_column(
Numeric(14, 6), nullable=False, default=Decimal("0")
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (Index("ix_rp_report_user_created", "user_id", "created_at"),)
class RpInput(Base):
"""报告输入。derived_features 是视觉分析的中间态:重试/重新生成时
已存在则跳过视觉阶段(T-P3-09)。只存派生特征与元信息,不存原图(CR-02)。"""
__tablename__ = "rp_input"
input_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
report_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
questionnaire: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
photo_meta: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
derived_features: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
__table_args__ = (UniqueConstraint("report_id", name="uq_rp_input_report"),)
class RpShare(Base):
"""分享链接:可失效、可撤回(V1 只建结构,路由后续任务)。"""
__tablename__ = "rp_share"
share_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
report_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
share_token: Mapped[str] = mapped_column(String(64), nullable=False)
expire_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
view_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__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"),
)