Files
writer-work-flow/docs/design/ai-chat-history-plan.md

32 KiB
Raw Permalink Blame History

AI 对话(聊天记录)实施计划

多 agent 生成4 学科起草(@db/@backend/@frontend/@architect→ 4 视角对抗交叉评审 → 综合合稿。 状态:决策已定(全 A2026-07-09实施中。① clarify = buffer-until-concluded澄清 Q&A 随最终产出批一起落库,纯放弃=不记);② v1 只记 5 类交互refine/rewrite/clarify/continue/generator初始写章指令留后续③ append-onlyv1 不做删除。


Unified Plan — Persist author↔AI exchanges as a chat history (ai_messages)

This is the single reconciled, implementation-ready spec. It supersedes the four discipline drafts wherever they disagreed; every high/med cross-review concern is folded in and every contradiction is resolved to one concrete answer below.


1. Overview & goal

Goal. Persist every author↔AI exchange — 润色/再沟通 (refine), 整章重写 (rewrite), AI 反问澄清 (clarify), 续写 (continue, multi-candidate), 工具箱生成器 (generator) — to the database so version stacks survive reload/panel-close/device change. Surface them as a per-chapter 「AI 对话」drawer (chat-bubble view) with two sections: 本章对话 (this chapter's turns) + 项目级生成 (project-level toolbox generations, chapter_no IS NULL).

One new table. ai_messages is the first new business table since the T0.2 MVP schema. Justification (record in PROGRESS.md): conversation history is a capability absent from the MVP data model (18 tables, none for messages — verified grep); it is orthogonal to every existing table (not a review result, not a job, not manuscript text); stuffing it into jobs.result or chapter_reviews would pollute those tables' truth-source semantics.

Persistence seam (decided): frontend-driven explicit atomic-batch append. The frontend POSTs a concluded turn to a dedicated POST /projects/{project_id}/ai-messages. The five generation endpoints (refine, refine/clarify, rewrite SSE, rewrite/clarify, skills/{tool_key}/generate — the last backs BOTH continue and every generator) stay byte-for-byte unchanged and read-only. Rejected alternative: server-side logging inside the streaming endpoints — it worsens the fragile sse_commit_failed tail, forces buffering the whole ≤200k-char chapter, and produces a structure-blind log missing the version_no/candidate_index/thread grouping that only the client holds.

Sequencing (forced by sole-writer ownership): @db (table+migration, register C2 稳定) → @backend (repo+schemas+router, register C3 稳定) → @frontend (gen:api + wiring + drawer) → @qa (E2E) → spec write-back (fence). No downstream agent starts until the upstream contract is 稳定 in memory/contracts.md.


2. Data model + migration (owner @db, packages/db/)

Canonical DDL — this is the ONLY model spec; @backend/@frontend build against it, not their own drafts.

Add to packages/db/ww_db/models.py, immediately after ChapterReview. Import delta: add Uuid to the from sqlalchemy import (...) block (currently missing; CheckConstraint, Integer, Text, Index, ForeignKey, text already present). No Identity needed (see seq decision).

class AiMessage(UuidPk, CreatedAt, Base):
    """作者↔AI 往复留痕append-only 辅助侧记录,非手稿真源)。

    不喂 prompt、不改正文、永不被 assemble()/agent 读回生成链(守 #1/#3/#6    一行一条 bubble一次多轮交换用 thread_id 归组;同一 append 批次内 created_at
    相同,靠 seq(批内 0 基位置) 稳定定序。仅 CreatedAt无 updated_at不可改写    """

    __tablename__ = "ai_messages"
    __table_args__ = (
        CheckConstraint("role IN ('author', 'ai')", name="ck_ai_messages_role"),
        Index(
            "ix_ai_messages_project_chapter",
            "project_id",
            "chapter_no",
            text("created_at DESC"),
        ),
    )
    project_id: Mapped[uuid.UUID] = mapped_column(
        ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
    )
    chapter_no: Mapped[int | None] = mapped_column(Integer)          # NULL = 项目级(工具箱)
    thread_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)  # 客户端生成,归组一次交换
    seq: Mapped[int] = mapped_column(Integer, nullable=False)        # repo 赋值:批内 0 基位置
    kind: Mapped[str] = mapped_column(Text, nullable=False)          # 自由 Text边界 Literal 校验
    tool_key: Mapped[str | None] = mapped_column(Text)              # generator/continue其余 NULL
    role: Mapped[str] = mapped_column(Text, nullable=False)         # author | ai (有 CHECK)
    content: Mapped[str] = mapped_column(Text, nullable=False)      # 人读 bubble 文本(完整)
    meta: Mapped[dict[str, Any]] = mapped_column(
        JSONB, nullable=False, server_default=text("'{}'")
    )
    # id, created_at 来自 UuidPk / CreatedAt (TIMESTAMPTZ func.now())

Reconciled column decisions (resolving all cross-review contradictions):

Column Decision Why (resolves)
chapter_no Integer nullable=True, NO FK Follows the chapter_reviews/chapter_digests/chapter_injection bare-int precedent — BUT explicitly departs on nullability (those are nullable=False; here NULL = project-level). Do not copy the precedent's NOT NULL by muscle memory.
thread_id Uuid NOT NULL, no FK, no threads table Client always supplies it (one crypto.randomUUID() per panel-open). Single-table + grouping column is the YAGNI-correct call for this flat-table repo.
seq plain Integer NOT NULL, repo-assigned 0-based position within the append batch. NO Identity, NO client-supplied value, NO server_default, NO UNIQUE(thread_id,seq). The 3-way seq contradiction is resolved here. Postgres now() is transaction-start time, so a batch shares created_at; seq disambiguates within a batch. Across batches (separate requests/txns) created_at differs and orders them. Rejected: Identity (zero repo precedent → unproven against the strict alembic check gate that already bit ad2c4c663daf); client-supplied seq (pushes ordering onto the FE, 409 risk); server_default '0'+UNIQUE (latent bug — a 2-row batch both default to 0 → unique violation).
kind free Text, NO CHECK Repo convention (verified): only lifecycle status columns carry CHECK; discriminant columns characters.role/jobs.kind/world_entities.type are free Text. Keeps "add a future kind (扩写…)" a zero-migration change. Enforced by Pydantic Literal at the boundary (#8).
role free Text + CheckConstraint("role IN ('author','ai')") role is genuinely binary/closed (never grows), so a cheap CHECK is defensible defense-in-depth. The migration-treadmill objection applies only to kind, which is why kind gets no CHECK.
tool_key free Text nullable, no FK Matches prompt_templates.tool_key; dynamic registry, no CHECK. continue'continue'; refine/rewrite/clarify → NULL.
content Text NOT NULL FE always has renderable text (clarify AI rows store the question text in content, structured options in meta). NOT NULL removes null branches the UI never produces. Full text stored (not truncated) — rejected candidates have no chapters row, so a truncation/reference would lose the record.
meta JSONB NOT NULL server_default '{}' Cleaner than nullable (no null branches); precedent chapter_reviews.conflicts server_default '[]'.

Indexes. (1) FK index ix_ai_messages_project_id (from index=True). (2) ix_ai_messages_project_chapter = (project_id, chapter_no, text("created_at DESC"))hand-write the created_at DESC expression in the migration (autogenerate can't reliably restore it); this exact shape exists on chapter_reviews and is proven alembic check-clean. Serves both the drawer union query and the newest-first ordering. Drop @backend's extra (project_id, thread_id) index (YAGNI — no load-one-thread endpoint in v1). No UNIQUE constraint.

Migration (packages/db/migrations/versions/<rev>_ai_messages_chat_history.py, down_revision = "f6a7b8c9d0e1" — verified current head, nothing chains off it): hand-write op.create_table("ai_messages", ...) with sa.DateTime(timezone=True) for created_at, sa.Integer() for seq, the role CHECK, the FK ondelete="CASCADE", PK, and the two indexes (FK index via op.f(...), composite via sa.text("created_at DESC")). downgrade() drops the two indexes then the table.

@db DoD (blocking gate before C2 稳定): docker compose up -d pguv run alembic upgrade headuv run alembic check no driftupgrade→downgrade→upgrade round-trip clean on a fresh DBruff/ruff format/mypy packages apps green. Register the DDL as the C2 extension in memory/contracts.md, mark 稳定. Add gotcha entries: (a) "now() is txn-constant → ai_messages needs seq for intra-batch order"; (b) "ai_messages.chapter_no orphans on chapter renumber/delete, same as chapter_reviews — by design, no FK; do not 'fix' with a cascade".


3. Backend (owner @backend)

3.1 Repository — packages/core/ww_core/domain/ai_message_repo.py

Mirror template_repo.py: Protocol + frozen Pydantic Views + Sql* impl, flush-only (endpoint commits).

class AiTurnRow(BaseModel):                 # one message in a batch (batch-level fields are args)
    role: str
    content: str
    meta: dict[str, Any] = Field(default_factory=dict)

class AiMessageView(BaseModel):
    model_config = {"frozen": True}
    id: uuid.UUID
    project_id: uuid.UUID
    chapter_no: int | None
    thread_id: uuid.UUID
    seq: int
    kind: str
    tool_key: str | None
    role: str
    content: str
    meta: dict[str, Any]
    created_at: datetime

class AiMessageRepo(Protocol):
    async def append(
        self, project_id: uuid.UUID, *, thread_id: uuid.UUID, chapter_no: int | None,
        kind: str, tool_key: str | None, rows: list[AiTurnRow],
    ) -> list[AiMessageView]: ...        # flush-only; seq = enumerate(rows) index; returns persisted views
    async def list_for_chapter(
        self, project_id: uuid.UUID, chapter_no: int | None, *,
        kind: str | None = None, limit: int, offset: int,
    ) -> list[AiMessageView]: ...

SqlAiMessageRepo:

  • append: build one AiMessage(...) per row with seq=i from enumerate(rows), write a fresh meta dict per row (never in-place mutate — JSONB gotcha, injection_repo.py:97), session.add_all(rows), await session.flush(), await session.refresh(r) each (populate id/created_at), return [_to_view(r) for r in rows]. No commit.
  • list_for_chapter filter semantics (this IS the "toolbox/project-level" answer):
    • chapter_no is not NoneWHERE project_id=:pid AND (chapter_no=:cno OR chapter_no IS NULL) → this chapter's turns project-level generations (exactly the drawer union).
    • chapter_no is NoneWHERE project_id=:pid AND chapter_no IS NULL (toolbox-only view).
    • optional kind filter; ORDER BY created_at DESC, seq DESC (newest window first for pagination); LIMIT/OFFSET from PageDep.

Export SqlAiMessageRepo + AiMessageRepo from packages/core/ww_core/domain/__init__.py.

3.2 Schemas — apps/api/ww_api/schemas/ai_messages.py (snake_case; Literal = the enum authority)

AiKind = Literal["refine", "rewrite", "clarify", "continue", "generator"]
AiRole = Literal["author", "ai"]
AI_MESSAGE_CONTENT_MAX = 200_000     # == manuscript ceiling (rewrite prior_draft/output bound); named, not magic
_META_MAX_SERIALIZED = 50_000        # backstop so meta can't bypass the content cap
_MAX_TURNS_PER_CALL = 50

class AiMessageInput(BaseModel):
    role: AiRole
    content: Annotated[str, StringConstraints(min_length=1, max_length=AI_MESSAGE_CONTENT_MAX)]
    meta: dict[str, Any] = Field(default_factory=dict)
    @field_validator("meta")
    @classmethod
    def _bound_meta(cls, v: dict[str, Any]) -> dict[str, Any]:
        if len(json.dumps(v, ensure_ascii=False)) > _META_MAX_SERIALIZED:
            raise ValueError("meta too large; put long text in content")
        return v

class AiMessageAppendRequest(BaseModel):          # BATCH-LEVEL fields hoisted (resolves per-message vs batch)
    thread_id: uuid.UUID
    chapter_no: Annotated[int, Field(ge=1)] | None = None
    kind: AiKind
    tool_key: str | None = None
    messages: Annotated[list[AiMessageInput], Field(min_length=1, max_length=_MAX_TURNS_PER_CALL)]

class AiMessageView(BaseModel):                   # mirrors repo view exactly
    id: uuid.UUID; project_id: uuid.UUID; chapter_no: int | None
    thread_id: uuid.UUID; seq: int; kind: str; tool_key: str | None
    role: str; content: str; meta: dict[str, Any] = Field(default_factory=dict); created_at: datetime

class AiMessageListResponse(BaseModel):
    messages: list[AiMessageView]                 # named-plural key `messages` (repo convention; NOT `items`)

Frozen schema names (so FE consumes codegen names, never hand-written): AiMessageAppendRequest, AiMessageView, AiMessageListResponse, envelope key messages.

3.3 Endpoints — apps/api/ww_api/routers/ai_messages.py (prefix="/projects", tag ai-messages)

Mirror templates.py: STUB_OWNER_ID, project-existence 404, commit-at-endpoint.

  1. POST /projects/{project_id}/ai-messages → 201 AiMessageListResponse. Body AiMessageAppendRequest. Flow: project_repo.get(STUB_OWNER_ID, project_id) → 404 NOT_FOUND if missing; views = await repo.append(project_id, thread_id=body.thread_id, chapter_no=body.chapter_no, kind=body.kind, tool_key=body.tool_key, rows=[AiTurnRow(**m) for m in body.messages]); await session.commit(); log.info("ai_messages_appended", project_id, request_id, thread_id, kind, count); return {messages: views} (with ids/seq/created_at for optimistic reconcile). Invalid kind/role/empty/oversize → FastAPI 422. No idempotency guard (see §7) — single-user, best-effort, no client auto-retry.
  2. GET /projects/{project_id}/ai-messages?chapter_no=&kind= + PageDep → 200 AiMessageListResponse. Project-existence 404 (consistent with GET .../style). Delegates to repo.list_for_chapter(...). Full content in every row (drawer re-accept reads it directly); payload bounded by newest-first ordering + PageDep. Read-only (no commit).
  3. Delete/clear: OPTIONAL P2, not v1. The log is append-only by design — no PATCH/PUT. (See open decision.)

Wiring: get_ai_message_repo(session) -> AiMessageRepo in apps/api/ww_api/services/project_deps.py (shape of get_template_repo); add ai_messages to the routers import tuple + app.include_router(ai_messages.router) after templates.router in main.py.

3.4 Authoritative per-kind meta shapes (one table — all three disciplines cite THIS, verbatim)

meta is free-form JSONB but the FE writes these documented shapes (register in memory/contracts.md; large text goes in content, NOT meta):

kind author row meta ai row meta
refine {} (content = original selected segment) {"version_no": n, "segment": "<THREAD's original segment text>"}segment stored on EVERY ai row (not just v1) so re-accept of any version reanchors
rewrite {"version_no": n} (content = folded feedback) {"version_no": n} (content = the version text)
clarify {} (content = the answer) {"parent_kind":"refine"|"rewrite", "options":[{"label","value"}], "allow_free_text": bool, "verification": "..."} (content = the question text)
continue — (no author row) {"candidate_index": i} (content = candidate text; tool_key="continue")
generator {"tool_key","input_fields": {...}} (content = brief/compact field summary) {"tool_key","output_kind"} (content = human-readable rendered preview; do NOT dump full raw output into meta)

4. Frontend (owner @frontend, apps/web/)

Blocked until C2+C3 are 稳定 and pnpm gen:api regenerates lib/api/schema.d.ts. No hand-written shared types (#8) — re-export the ACTUAL codegen names after gen:api in lib/api/types.ts (AiMessageView, AiMessageAppendRequest, AiMessageListResponse).

4.1 Pure view-model module lib/workbench/aiConversation.ts (unit-tested)

No React/IO. KIND_LABEL (refine:"润色", rewrite:"整章重写", clarify:"反问澄清", continue:"续写", generator:"工具箱"); KIND_BADGE → existing badgeClass variants; bubbleSide(role). groupByThread(messages): group by thread_id; within a thread order by (created_at ASC, seq ASC)NOT created_at alone (this resolves the contradiction that reintroduced the intra-batch tie the backend added seq to fix); threads ordered by their first message's (created_at, seq). partitionByScope(threads, currentChapterNo){chapterThreads (chapterNo===current), projectThreads (chapterNo==null)}. acceptActionFor(thread)"replace_chapter"|"append_draft"|"refill_segment"|null. Test: grouping stability, same-created_at batch renders in seq order, scope partition, accept-action per kind.

4.2 Data/append hook lib/workbench/useAiConversation.ts (renderHook-tested)

One instance at Workbench level (appends fire even while the drawer is closed). Mirrors useContextDrawerData single-flight + reload-nonce + project/chapter reset.

  • Load: lazy ensureLoaded() on first drawer-open OR first append; GET .../ai-messages?chapter_no={n} returns this chapter's rows project-level. Error → status:"error" + toast, reload() retry.
  • appendMessages(turn: AiTurnInput) where AiTurnInput = {threadId, kind, toolKey?, chapterNo, messages:{role,content,meta?}[]}:
    1. ensureLoaded().
    2. Build optimistic AiMessageView[] (temp client id, created_at:new Date().toISOString(), seq = local index); if ready, setMessages(prev => [...prev, ...optimistic]).
    3. api.POST(".../ai-messages", {body: {thread_id, chapter_no, kind, tool_key, messages}})one batch, one round-trip.
    4. Success → positional reconcile (replace the N just-pushed temp rows, tracked by reference, with the N returned server rows in order — the response does NOT echo temp ids, so match by position, not id). Error → remove temp rows + toast. Never throws into the caller (a failed log must not break generation — invariant #3 intent).

renderHook test (// @vitest-environment jsdom, mock @/lib/api/client + @/components/Toast): lazy-load-once, optimistic→reconcile, rollback-on-error, project/chapter reset.

4.3 Make the 4 generation hooks additively return their artifact

Currently they set…State + resolve void; change the mutating method to also return the committed artifact (backward-compatible — existing callers ignore it; add return-value assertions to existing tests). useRefine.runRefineRefineVersion|null; useChapterRewrite.sendRewriteVersion|null; useContinue.generatestring|null; lib/toolbox/useGenerator.generate{preview,rawPreview,outputKind}|null. Clarify hooks already return ClarifyDecisionVM — no change. Panels guard if (artifact) appendMessages(...) so empty-candidate / error paths (useContinue empty toast, useGenerator error status) log nothing.

4.4 Per-hook append wiring — fires in the PANELS (buffer-until-concluded)

Each panel creates one threadId = useRef(crypto.randomUUID()).current per open. Workbench passes conversation.appendMessages + chapterNo as props. Clarify is buffered, not eager (resolves the timing contradiction): the panel holds the in-flight turn's messages and appends the whole thread as one batch when the AI produces its final output; pure-abandon (close after clarify, before output) = no append.

kind Panel / seam Turn appended (one batch per concluded turn)
refine (direct) RefinePanel open-effect → await refine(...) [author(content=original segment), ai(content=refined, meta{version_no:1, segment:original})]
refine (recommunicate) RefinePanel.runRecommunicate [author(content=folded instruction), ai(content=refined, meta{version_no, segment:<thread original>})] — skip the author bubble ONLY when this round came from a clarify answer (that author turn is the clarify answer)
clarify (refine) buffered in RefinePanel, flushed with the concluded refine batch prepend [author(content=original vague instruction), ai(content=question, meta{parent_kind:"refine",options,allow_free_text,verification}), author(content=answer)]the original vague instruction IS logged as the opening author bubble (resolves the dropped-author-turn concern)
rewrite ChapterRewritePanel.runSend after stream done [author(content=folded feedback, meta{version_no}), ai(content=version text, meta{version_no})]; each iterated version = another batch on the same threadId
clarify (rewrite) buffered in ChapterRewritePanel, same shape as refine clarify, parent_kind:"rewrite"
continue ContinuePanel initial + "再来一个" ai-only, NO synthetic author bubble (continue takes no authored input) — one ai(content=candidate, meta{candidate_index:i}) per call on the same threadId
generator GeneratorRunner.onGenerate after await gen.generate [author(content=brief/field summary, meta{tool_key,input_fields}), ai(content=rendered preview text, meta{tool_key,output_kind})]; inline toolbox → chapterNo=current; standalone ToolboxPagechapterNo=null

Generator scope: thread chapterNo + appendMessages through InlineToolbox → GeneratorRunner (add optional props). ToolboxPage (no drawer) uses a lightweight append-only path: skip ensureLoaded, suppress the load/append failure toast (fire-and-forget), so a log failure doesn't surface a confusing toast on a page with no conversation UI.

4.5 Re-accept from history (§5.4) — reuses existing DRAFT-only HITL callbacks (invariant-safe, verified)

Verified in code: onAcceptRewrite/appendToDraft/onAcceptRefine only setText(...) + autosave.onChange(...) — they mutate the editor draft, NOT chapters. The durable accept still flows through the separate accept transaction with its conflict gate (CONFLICT_UNRESOLVED). So re-accept from the drawer only repopulates the draft; it cannot bypass the accept transaction. State this dependency: correctness relies on the GET union filter never surfacing another chapter's turns in this chapter's drawer (it filters chapter_no=X OR IS NULL), so onReplaceChapter targets the right chapter.

Workbench passes three callbacks into the drawer:

  • rewrite ai bubble → "接受这版(替换整章)" → existing onAcceptRewrite(content).
  • continue / generator(text) ai bubble → "插入正文(章末)" → existing appendToDraft(content).
  • refine ai bubble → "回填选段" → new wrapper onRefillSegment(originalSegment, refined) = applyRefinement(text, originalSegment, refined, {start:0, end:0}) (synthetic zero range forces the slice check to fail → indexOf-reanchor on the stored original segment); null → existing drift toast "正文已改动,找不到原选段…". originalSegment = thread.messages[0]'s / the ai row's meta.segment (stored on every refine ai row per §3.4).
  • clarify bubbles → no accept action.

Success feedback (resolves the hidden-behind-scrim concern): on successful re-accept, close the drawer (revealing the changed editor) and toast ("已替换整章" / "已插入正文" / "已回填选段"). Refine's null branch keeps the drift toast.

4.6 Drawer components/workbench/AiConversationDrawer.tsx + Workbench wiring

Structural clone of ContextDrawer.tsx: right slide-over, overlayScrim, role="dialog" + aria-modal="true" + aria-label="AI 对话", Esc-to-close, handleTabTrap, useBodyScrollLock, useRestoreFocus, prefers-reduced-motion on transitions. Body: two sections from partitionByScope; thread blocks (kind Badge + tool_key + relative time, then bubbles); bubbleSide alignment (author right cinnabar-wash, ai left border-line bg-bg), whitespace-pre-wrap; clarify ai bubbles render question + read-only option chips; accept-action button per acceptActionFor. States: loading ThinkingIndicator, error StatusNote+retry→reload, empty EmptyState. a11y: message-list container role="log" aria-live="polite" aria-relevant="additions" (announces optimistic additions) + an aria-label/aria-describedby summarizing counts on open ("本章 N 段对话,项目级 M 项生成") so a screen-reader user hears existing history, not just new turns. Large rows (rewrite/generator) get max-h + overflow-y-auto to avoid layout jank.

Workbench: const conversation = useAiConversation(project.id, chapterNo); toolbar button (icon MessagesSquare, label "AI 对话", ref, onClick) gated behind AI_CONVERSATION_ENABLED const (one-line rollback, mirrors CONTEXT_DRAWER_ENABLED); mount <AiConversationDrawer …/> next to <ContextDrawer/> passing conversation + the three re-accept callbacks; pass conversation.appendMessages + chapterNo into RefinePanel/ContinuePanel/ChapterRewritePanel/InlineToolbox.

4.7 @frontend DoD: pnpm gen:api → TDD units (aiConversation.test.ts, useAiConversation.test.ts, renderHook required) keeping lib/** cov ≥80% → pnpm lint · typecheck · test · build green.


5. Phased task breakdown (owner tags + per-phase DoD)

  • P0 · @db — table + migration. Canonical DDL (§2) into models.py (add Uuid import); hand-written migration down_revision="f6a7b8c9d0e1". DoD: alembic upgrade head + alembic check no drift + upgrade→downgrade→upgrade round-trip + ruff/mypy green. Register C2 ext 稳定; add the two gotchas. Gate: no @backend start until C2 稳定 AND alembic check green with the real DDL.
  • P1 · @backend — repo + schemas + router + deps + registration (contract-first). §3. DoD: repo unit + endpoint integration tests (§6), ruff/mypy packages apps/pytest -q green, ≥80% cov, alembic check clean. Register C3 ext 稳定 (endpoints + meta shapes + frozen schema names) before @frontend.
  • P2 · @frontend — gen:api + wiring + drawer. §4. DoD: pnpm gen:api synced, renderHook tests, lib/** cov ≥80%, lint/typecheck/test/build green.
  • P3 · @qa — E2E + guards. §6 flows. DoD: E2E green with mock gateway.
  • P4 · spec write-back + the #1/#6 fence (BLOCKS "done"). Owner: the landing agent (docs are cross-cutting — coordinate via PROGRESS.md; do not let sole-writer ownership drop it). Deliverables: (a) add the invariant note to CLAUDE.md invariants + ARCHITECTURE.md §5.2 truth-source boundary + PRODUCT_SPEC.md data-model/endpoints: "ai_messages is an append-only display/audit record, never a truth-source. assemble() and every agent/orchestrator node MUST NOT read it. It carries no memory-injection (#6) or adjudication (#3/#4) authority."; (b) code guard — keep AiMessageRepo OUT of the repo bundle assemble() consumes; (c) memory/gotchas.md + a code-review checklist blocker: "any read of ai_messages inside packages/core/ww_core/memory/ or packages/agents/ is a BLOCKER"; (d) memory/decisions.md: seam decision + seq-ordering + best-effort-no-idempotency + un-hashed content rationale; (e) PROGRESS.md "first post-T0.2 business table" justification.

6. Test plan (TDD-first; LLM never involved — pure CRUD, no gateway mock)

  • @db: alembic upgrade/check/downgrade round-trip; model import.
  • @backend repo units (packages/core/tests/test_ai_message_repo.py): append single + batch (list order preserved, seq = 0..n-1 within batch, meta round-trips, project-level chapter_no=None persists); list_for_chapter (chapterNULL union, project-only when chapter_no=None, kind filter, limit/offset, ORDER BY created_at DESC, seq DESC); flushed-not-committed.
  • @backend endpoint integration (dependency_overrides like templates): POST returns persisted views; 404 missing project; 422 on invalid kind/role, empty messages, content at cap+1, oversize meta; GET drawer union + ordering; commit boundary (row persists after 201).
  • @frontend units: aiConversation.test.ts (grouping, same-created_at→seq order, partition, accept-action); useAiConversation.test.ts (lazy-load, optimistic reconcile, rollback, reset).
  • @qa E2E (highest-value — the ONLY place the log touches the truth source): (1) refine → reload → open drawer → see the turn; (2) rewrite → reload → drawer → "接受这版" → editor content updates + drawer closes + toast; (3) refine re-accept when the segment no longer exists → returns null → drift toast, never blind-replaces; (4) multi-turn clarify arc renders author(vague)→ai(question)→author(answer)→ai(refined); (5) clarify-abandon → nothing persisted; (6) project-level (chapter_no IS NULL) generation surfaces in a chapter's drawer.
  • Guard (low, cheap insurance for the load-bearing #3 claim): a review-checklist item that the five generation endpoints add no DB write beyond the pre-existing usage_ledger commit.

7. Risks & mitigations

  • #1/#6 truth-source creep (biggest latent risk): a future dev wires ai_messages into assemble()/a prompt → a second non-deterministic truth-source. Mitigation: the P4 fence (written invariant + AiMessageRepo kept out of the assemble bundle + code-review blocker). Feature is not "done" until the fence lands.
  • Payload size (full-content-on-list): a 200k-char rewrite × N rows, worsened by project-level rows appearing in every chapter drawer. Mitigation: newest-first ordering + PageDep pagination + content cap + Postgres TOAST (storage). Excerpt+GET-by-id deferred unless a real perf test shows a problem (it would break the re-accept read path). Gotcha logged that project-level accumulation may later need keyset pagination.
  • Idempotency: no guard in v1. Mitigation/decision: single-user, best-effort, FE appends are optimistic + rollback-on-error with no auto-retry, so duplicate rows can't arise today. Documented in decisions.md; if retry is ever added, introduce a client-supplied message id + ON CONFLICT DO NOTHING then.
  • meta bypassing the content cap: _bound_meta validator (≤50k serialized) + guidance "long text in content, not meta".
  • Ordering nondeterminism: solved by (created_at, seq) everywhere (DB list + FE display); FE MUST NOT sort by created_at alone.
  • Cross-owner model conflict: eliminated — ONE canonical DDL in §2 owned by @db; @backend/@frontend build against the registered C2/C3, not their drafts.
  • chapter_no orphan on renumber/delete: by design (no FK), same as chapter_reviews; gotcha logged so it isn't "fixed".
  • Trust boundary: client writes role='ai' content directly (single-user, own data). Never let the server treat logged content as authoritative downstream (reinforced by the fence).

8. Invariant-compliance note

  • #1 (DB single source of truth): ai_messages is the source of truth for "what was said," not for the manuscript. assemble()/memory injection never read it (P4 fence).
  • #3 (read-only/HITL): the five generation endpoints are unchanged and read-only; the log is a separate explicit append, architecturally identical to the usage_ledger add-only commit those endpoints already do (verified style.py). Re-accept-from-history only repopulates the editor draft (verified: setText+autosave, not a chapters commit); the durable accept still passes the accept transaction's conflict gate.
  • #6 (deterministic memory selection): unaffected — the log is never selected into prompts.
  • #8 (snake_case): all DB columns, Pydantic fields, and the OpenAPI surface are snake_case; FE consumes codegen names.
  • Immutability/append-only: no updated_at, no PATCH/PUT; repo writes fresh meta dicts (no in-place mutation).