"""ORM models for roundtable-planning drafts and recommendation history. A "draft" is one full roundtable-planning session (Step 1 intent + Step 2 multi-agent roundtable), persisted per user so it follows the account across devices/sessions — replacing the old browser-only ``localStorage`` storage. ``step1`` / ``step2`` are stored as JSON serialized into ``PortableLongText`` (MySQL ``LONGTEXT``) rather than ``PortableJSON`` (MySQL ``TEXT``, 64 KB cap), because the Step 2 snapshot can carry a long roundtable transcript plus per-seat stub messages that easily exceed 64 KB — mirroring ``ai_writing_sessions``. """ from __future__ import annotations from datetime import UTC, datetime from sqlalchemy import Integer, String from sqlalchemy.orm import Mapped, mapped_column from deerflow.persistence.base import Base from deerflow.persistence.types import BeijingDateTime, PortableLongText class RoundtableDraftRow(Base): __tablename__ = "roundtable_drafts" id: Mapped[str] = mapped_column(String(64), primary_key=True) user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) # 外部任务 id(无界嵌入抽屉传入)。非空时该草稿按 task_id 共享、不再按 user_id 分权 # (读/写/删放开归属校验,见 sql.py);为空时保持原「按登录用户隔离」语义。 task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) # Auto-derived task summary (objective → first user message → timestamp). title: Mapped[str] = mapped_column(String(512), nullable=False, default="") # Furthest step reached when this snapshot was taken (1 | 2 | 3). furthest_step: Mapped[int] = mapped_column(Integer, nullable=False, default=1) # JSON blobs matching the frontend Step1Snapshot / Step2Snapshot shapes. step1: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object step2: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object # Step 3「结果绘制」snapshot: { html, generatedAt, model, summary? }. The full # report HTML is stored inline so a reloaded draft can show the report without # depending on the ephemeral report thread (LONGTEXT — HTML can exceed 64 KB). step3: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON object created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)) updated_at: Mapped[datetime] = mapped_column( BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC), ) # 乐观并发控制版本号:每次 update_draft 自增;传入 expected_version 时校验,命中 # 0 行即并发冲突(防 step2.runs 读-改-写丢失更新——后台作业与前台 PUT 并发)。 version: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") class RoundtableRecommendHistoryRow(Base): """One agent-recommendation run, bound to a draft/session. Lets the recommend dialog show "last recommendation for this session" on open, while still letting the user re-analyze (which appends a new row). """ __tablename__ = "roundtable_recommend_history" id: Mapped[str] = mapped_column(String(64), primary_key=True) user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) draft_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) # Denormalized intent objective at recommend time (list display / context). objective: Mapped[str] = mapped_column(String(512), nullable=False, default="") # How the run ended on the backend: "done" | "fallback" | "asking" | "error". status: Mapped[str] = mapped_column(String(32), nullable=False, default="done") model: Mapped[str | None] = mapped_column(String(128), nullable=True) # Natural-language rationale (may be long → LONGTEXT). rationale: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON: [{"agent_id": str, "reason": str}, ...] picks: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # JSON snapshot of the candidate pool at recommend time: # [{"agent_id": str, "name": str, "description": str}, ...] candidates: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))