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

302 lines
32 KiB
Markdown
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.

# 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).
```python
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 pg``uv run alembic upgrade head`**`uv run alembic check` no drift** → **upgrade→downgrade→upgrade round-trip clean on a fresh DB**`ruff`/`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)**.
```python
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 None``WHERE 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 None``WHERE 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)
```python
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**) |
### 3.5 `@backend` DoD (before C3 `稳定`): repo unit tests + endpoint integration tests (see §6), `ruff`/`mypy packages apps`/`pytest -q` green, ≥80% cov on new modules, `alembic check` clean. Register the endpoints + `meta` shapes as the **C3 extension** with the footer action `cd apps/web && pnpm gen:api`.
---
## 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.runRefine``RefineVersion|null`; `useChapterRewrite.send``RewriteVersion|null`; `useContinue.generate``string|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 `ToolboxPage``chapterNo=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).