Files
hunjing/backend/app/models.py
T
chenxin b4242f3844 ✨ feat: WP-05 模型网关核心——逻辑模型、流式适配与 Run 真实执行
- 迁移 7+8:logical_model(能力目录)、provider_endpoint(密钥只存环境变量名,永不落库)、turn、model_call(每 attempt 单独持久化:usage/TTFT/错误,Provider 未返回保持 null 不估算——ADR-003);message.run_id 关联
- OpenAI-compatible 流式适配器:httpx SSE 解析、标准化 StreamChunk(delta/usage/done)、transport 可注入(测试与故障注入);密钥经环境变量读取不进日志
- Run 执行器:后台执行完整生命周期——running → content.block.started → 真实 content.block.delta*(流式产生,delta_index 单调)→ content.block.completed → message.completed → 终态;assistant 消息为权威快照;ModelCall 记录 usage 与 TTFT;无可用端点时 Run 显式失败并留 error 事件(fail-closed)
- post_message:响应提交后由框架 BackgroundTasks 执行 Run(显式预提交保证 Run 落库);SSE 改为增量轮询(重放 + 等待新事件至终态,30s 上限)
- 测试 54 项全绿(+2):端到端 mock 流验证 ModelCall usage/TTFT/assistant 权威消息、无端点 Run failed + content.block.failed 事件
2026-09-11 09:44:54 +08:00

475 lines
20 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.
"""首批领域模型:身份、会话、服务账号、审计与 Outbox(WP-01)。
- 主键为带前缀的 UUIDv7 文本(ADR-015);Session ID 为 CSPRNG opaque(秘密不用 UUID)。
- 命名约定见 app/db/base.py(pk_/uq_/ix_/fk_,AGENTS.md 8.6)。
- AuditEvent 与 OutboxEvent 不可变:无 updated_at,修正写新记录(契约第 4.4 节)。
"""
from datetime import datetime
from typing import Any
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_id
from app.db.base import Base
class User(Base):
__tablename__ = "user"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("user_"))
email: Mapped[str] = mapped_column(String, nullable=False, unique=True)
display_name: Mapped[str] = mapped_column(String, nullable=False)
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()
)
class PersonalDomain(Base):
__tablename__ = "personal_domain"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("domain_")
)
user_id: Mapped[str] = mapped_column(
ForeignKey("user.id", ondelete="restrict"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String, nullable=False)
# 区域驻留(ADR-024 之前的数据驻留决策):默认区域,迁移走正式协议
data_region: Mapped[str] = mapped_column(String, nullable=False, default="default")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class Session(Base):
"""Web 会话:ID 为 CSPRNG opaque value,数据库只存校验值(ADR-015)。"""
__tablename__ = "session"
id: Mapped[str] = mapped_column(String, primary_key=True)
user_id: Mapped[str] = mapped_column(
ForeignKey("user.id", ondelete="cascade"), nullable=False, index=True
)
token_hash: Mapped[str] = mapped_column(String, nullable=False, unique=True)
csrf_token_hash: Mapped[str] = mapped_column(String, nullable=False)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class ServiceAccount(Base):
"""自动化账号:API key 明文只显示一次,只保存哈希、前缀、scope 与撤销状态。"""
__tablename__ = "service_account"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("service_account_")
)
domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False
)
name: Mapped[str] = mapped_column(String, nullable=False)
key_prefix: Mapped[str] = mapped_column(String, nullable=False, index=True)
key_hash: Mapped[str] = mapped_column(String, nullable=False)
scopes: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (Index("uq_service_account_domain_name", "domain_id", "name", unique=True),)
class AuditEvent(Base):
"""不可变审计链:状态与 Outbox 在同一事务提交(契约第 4.4 节)。"""
__tablename__ = "audit_event"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("audit_"))
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
actor_type: Mapped[str] = mapped_column(String, nullable=False)
actor_id: Mapped[str] = mapped_column(String, nullable=False)
action: Mapped[str] = mapped_column(String, nullable=False)
aggregate_type: Mapped[str] = mapped_column(String, nullable=False)
aggregate_id: Mapped[str] = mapped_column(String, nullable=False)
payload: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
trace_id: Mapped[str | None] = mapped_column(String, nullable=True)
__table_args__ = (
# 聚合时间线是审计的主要查询路径(最左前缀覆盖聚合定位)
Index("ix_audit_event_aggregate", "aggregate_type", "aggregate_id", "occurred_at"),
Index("ix_audit_event_occurred_at", "occurred_at"),
)
class OutboxEvent(Base):
"""Transactional Outbox:已提交领域事实的可靠投递队列。"""
__tablename__ = "outbox_event"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("event_"))
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
aggregate_type: Mapped[str] = mapped_column(String, nullable=False)
aggregate_id: Mapped[str] = mapped_column(String, nullable=False)
event_type: Mapped[str] = mapped_column(String, nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
status: Mapped[str] = mapped_column(String, nullable=False, default="pending")
attempts: Mapped[int] = mapped_column(nullable=False, default=0)
delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
CheckConstraint(
"status IN ('pending', 'delivered', 'failed')",
name="status_values",
),
# 投递循环按状态+时间扫描待处理事件
Index("ix_outbox_event_status_occurred", "status", "occurred_at"),
Index("ix_outbox_event_aggregate", "aggregate_type", "aggregate_id"),
)
CONVERSATION_REALM_CK = CheckConstraint(
"realm IN ('reality', 'world', 'simulation')", name="realm_values"
)
class Conversation(Base):
"""对话容器:归属个人域(垂直切片 WP-03 最小集)。"""
__tablename__ = "conversation"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("conversation_")
)
personal_domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String, nullable=False, default="未命名对话")
realm: Mapped[str] = mapped_column(String, nullable=False, default="reality")
current_branch_id: Mapped[str | None] = mapped_column(
ForeignKey("branch.id", ondelete="set null", use_alter=True), nullable=True
)
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__ = (CONVERSATION_REALM_CK,)
class Branch(Base):
"""对话分支:创建会话时生成默认主分支。"""
__tablename__ = "branch"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("branch_"))
conversation_id: Mapped[str] = mapped_column(
ForeignKey("conversation.id", ondelete="cascade"), nullable=False, index=True
)
title: Mapped[str] = mapped_column(String, nullable=False, default="主脉")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class Message(Base):
"""消息:content 为 canonical ContentBlock JSON(本切片为 Text/Markdown 子集)。"""
__tablename__ = "message"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("message_"))
conversation_id: Mapped[str] = mapped_column(
ForeignKey("conversation.id", ondelete="cascade"), nullable=False, index=True
)
branch_id: Mapped[str] = mapped_column(
ForeignKey("branch.id", ondelete="cascade"), nullable=False, index=True
)
role: Mapped[str] = mapped_column(String, nullable=False)
realm: Mapped[str] = mapped_column(String, nullable=False, default="reality")
canonized_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
parent_message_id: Mapped[str | None] = mapped_column(
ForeignKey("message.id", ondelete="set null"), nullable=True
)
variant_kind: Mapped[str] = mapped_column(String, nullable=False, default="origin")
run_id: Mapped[str | None] = mapped_column(
ForeignKey("run.id", ondelete="set null"), nullable=True, index=True
)
content: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False)
parser_version: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint("role IN ('user', 'assistant', 'system', 'tool')", name="role_values"),
CheckConstraint(
"realm IN ('reality', 'world', 'simulation')", name="realm_values"
),
CheckConstraint(
"variant_kind IN ('origin', 'revision', 'candidate')", name="variant_kind_values"
),
)
class Run(Base):
"""Agent 执行:状态机见 Agent 运行时第 3 节;本切片空转即完成。"""
__tablename__ = "run"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("run_"))
personal_domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
conversation_id: Mapped[str | None] = mapped_column(
ForeignKey("conversation.id", ondelete="cascade"), nullable=True, index=True
)
status: Mapped[str] = mapped_column(String, nullable=False, default="queued")
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), nullable=True)
__table_args__ = (
CheckConstraint(
"status IN ('queued', 'running', 'waiting_approval', "
"'completed', 'failed', 'cancelled')",
name="status_values",
),
Index("ix_run_domain_status", "personal_domain_id", "status"),
)
class IdempotencyRecord(Base):
"""幂等键记录:按 认证主体+路由+键 隔离,保存请求哈希与结果引用(契约第 6 节)。"""
__tablename__ = "idempotency_record"
key: Mapped[str] = mapped_column(String, primary_key=True)
owner_domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False
)
route: Mapped[str] = mapped_column(String, nullable=False)
request_hash: Mapped[str] = mapped_column(String, nullable=False)
response_status: Mapped[int] = mapped_column(nullable=False)
resource_ids: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index("uq_idempotency_owner_route_key", "owner_domain_id", "route", "key", unique=True),
)
class RunEvent(Base):
"""Run 事件流:持久化后可重放;sequence 在 Run 内单调(契约第 5 节)。"""
__tablename__ = "run_event"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("event_"))
run_id: Mapped[str] = mapped_column(
ForeignKey("run.id", ondelete="cascade"), nullable=False
)
sequence: Mapped[int] = mapped_column(nullable=False)
type: Mapped[str] = mapped_column(String, nullable=False)
data: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint(
"type IN ('content.block.started', 'content.block.delta', 'content.block.progress', "
"'content.block.completed', 'content.block.failed', 'content.block.cancelled', "
"'message.completed', 'run.status.changed')",
name="type_values",
),
# Run 内 sequence 单调且唯一(重放去重的服务端保证)
Index("uq_run_event_run_sequence", "run_id", "sequence", unique=True),
)
class CharacterVersion(Base):
"""角色版本链:baseline/初照自陈/内驱力修订/铸魂/人格候选(不可变版本)。"""
__tablename__ = "character_version"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("character_version_")
)
character_id: Mapped[str] = mapped_column(
ForeignKey("character.id", ondelete="cascade"), nullable=False, index=True
)
kind: Mapped[str] = mapped_column(String, nullable=False)
status: Mapped[str] = mapped_column(String, nullable=False, default="approved")
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint(
"kind IN ('baseline', 'first_statement', 'drive_revision', 'reforging', "
"'persona_candidate')",
name="kind_values",
),
CheckConstraint("status IN ('pending', 'approved', 'rejected')", name="status_values"),
)
class Character(Base):
"""角色:稳定身份、生命周期状态、道体基线与内驱力(WP-02)。"""
__tablename__ = "character"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("character_")
)
personal_domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String, nullable=False)
# 生命周期:灵胚 → 启镜 → 静修/归藏(可复照);死亡走两步确认,deceased 不可逆
status: Mapped[str] = mapped_column(String, nullable=False, default="draft")
origin: Mapped[str] = mapped_column(String, nullable=False, default="original")
based_on_real_person: Mapped[bool] = mapped_column(nullable=False, default=False)
real_person_attested: Mapped[bool] = mapped_column(nullable=False, default=False)
# 道体基线(identity_baseline):最小稳定身份锚点,普通对话/记忆不可静默覆盖
identity_baseline: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
# 内驱力(character_drives):声明式动机维度,用户可审阅编辑(ADR-018)
drives: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list)
current_version_id: Mapped[str | None] = mapped_column(
ForeignKey("character_version.id", ondelete="set null", use_alter=True), nullable=True
)
death_requested_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
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__ = (
CheckConstraint(
"status IN ('draft', 'active', 'resting', 'archived', 'death_pending', 'deceased')",
name="status_values",
),
CheckConstraint(
"origin IN ('original', 'serendipity', 'template', 'import', 'fork')",
name="origin_values",
),
)
class LogicalModel(Base):
"""逻辑模型:业务只引用能力与逻辑名,不感知 Provider(契约第 5 节分面)。"""
__tablename__ = "logical_model"
id: Mapped[str] = mapped_column(String, primary_key=True) # kebab-case,如 default-chat
capabilities: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
description: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class ProviderEndpoint(Base):
"""Provider 端点:密钥只存环境变量名,永不落库明文。"""
__tablename__ = "provider_endpoint"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("endpoint_")
)
logical_model_id: Mapped[str] = mapped_column(
ForeignKey("logical_model.id", ondelete="cascade"), nullable=False, index=True
)
provider: Mapped[str] = mapped_column(String, nullable=False)
name: Mapped[str] = mapped_column(String, nullable=False)
base_url: Mapped[str] = mapped_column(String, nullable=False)
model_id: Mapped[str] = mapped_column(String, nullable=False)
api_key_env: Mapped[str] = mapped_column(String, nullable=False)
priority: Mapped[int] = mapped_column(nullable=False, default=100)
enabled: Mapped[bool] = mapped_column(nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint(
"provider IN ('openai_compatible', 'anthropic')", name="provider_values"
),
)
class Turn(Base):
"""Turn:Run 内一次模型决策及其工具交互阶段。"""
__tablename__ = "turn"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("turn_"))
run_id: Mapped[str] = mapped_column(
ForeignKey("run.id", ondelete="cascade"), nullable=False, index=True
)
index: Mapped[int] = mapped_column(nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
Index("uq_turn_run_index", "run_id", "index", unique=True),
)
class ModelCall(Base):
"""每次上游 attempt 单独持久化:重试/故障转移不覆盖(ADR-003)。
Provider 未返回的 usage 字段保持 null,不估算伪装。
"""
__tablename__ = "model_call"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("model_call_")
)
turn_id: Mapped[str] = mapped_column(
ForeignKey("turn.id", ondelete="cascade"), nullable=False, index=True
)
endpoint_id: Mapped[str | None] = mapped_column(
ForeignKey("provider_endpoint.id", ondelete="set null"), nullable=True
)
attempt: Mapped[int] = mapped_column(nullable=False, default=1)
status: Mapped[str] = mapped_column(String, nullable=False, default="pending")
request_summary: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
usage: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
ttft_ms: Mapped[int | None] = mapped_column(nullable=True)
error: Mapped[str | None] = mapped_column(String, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
CheckConstraint(
"status IN ('pending', 'streaming', 'succeeded', 'failed')", name="status_values"
),
Index("uq_model_call_turn_attempt", "turn_id", "attempt", unique=True),
)