docs: AI 对话(聊天记录)实施计划入库 + 台账登记(决策全 A,开工)

This commit is contained in:
Yaojia Wang
2026-07-09 16:33:14 +02:00
parent 31067abbed
commit 9da938d39b
2 changed files with 316 additions and 1 deletions

View File

@@ -8,7 +8,21 @@
--- ---
## 当前阶段QA-CR · 全项目 Code Review 整改批 ## 进行中AI 对话(聊天记录)持久化 · develop 上新功能
> **背景**[2026-07-09] 用户要求把作者↔AI 每轮往复refine/rewrite/clarify/continue/工具箱生成器)**落库为聊天记录**每章一个「AI 对话」抽屉展示。多 agent4 学科起草→4 视角交叉评审→合稿)产出实施计划 `docs/design/ai-chat-history-plan.md`。**决策全 A**clarify buffer-until-concluded / v1 只记 5 类交互 / append-only 不删。
> **契约与不变量纪律**@db 建表(契约 C2 扩)→@backend 端点(C3 扩)→@frontend gen:api+接线→@qa E2E逐级等上游契约「稳定」。守 #1/#3/#8聊天记录是**旁路留痕**(同 `usage_ledger` 物种),前端**显式 append**、生成端点**保持只读不变**、**绝不**喂 `assemble`/prompt计划 §8 有围栏 + code-review 守卫)。
| 任务 | 状态 | 负责 | 依赖 | 备注 |
|---|---|---|---|---|
| AC-1 `ai_messages` 表 + 迁移 | ⬜ | @db | — | 计划 §2 canonical DDL |
| AC-2 repo + append/list 端点 + schema + 契约 | ⬜ | @backend | AC-1 | 计划 §3批级 append 信封、newest-first 分页 |
| AC-3 gen:api + AiConversationDrawer + useAiConversation + 接线 5 处交互 | ⬜ | @frontend | AC-2 | 计划 §4本章对话 + 项目级生成两段 |
| AC-4 E2E + repo/端点/hook 测试 | ⬜ | @qa | AC-3 | 计划 §6 |
---
## 已完成阶段QA-CR · 全项目 Code Review 整改批(全 ✅)
> **背景**[2026-06-26] 对全仓做了一次**七维度 Code Review**(后端 Python 规范 / LangGraph 最佳实践 / 后端 FastAPI 架构 / 前端编程规范 / 前端架构 / 应用安全 / 依赖时效6 个专科 reviewer 并行 + 依赖自查。绿门禁下仍揪出 **2 CRITICAL + 13 HIGH + 一批 MEDIUM/LOW**——再次证伪「测试过=没问题」。架构本身成熟、不变量纪律强provider 中立 / HITL accept 单写 / checkpoint 仅控制流 / SecretStr 全覆盖);**确认无虞**SSRF`base_url` 仅来自硬编码 `_PROVIDER_BASE_URLS`,无用户 URL 入网)/ SQL 注入(全 ORM 参数化)/ XSS无 `dangerouslySetInnerHTML`/`eval`/ 错误信封不泄栈密钥 / Kimi 用 RFC 8628 device flow 无 CSRF 面。 > **背景**[2026-06-26] 对全仓做了一次**七维度 Code Review**(后端 Python 规范 / LangGraph 最佳实践 / 后端 FastAPI 架构 / 前端编程规范 / 前端架构 / 应用安全 / 依赖时效6 个专科 reviewer 并行 + 依赖自查。绿门禁下仍揪出 **2 CRITICAL + 13 HIGH + 一批 MEDIUM/LOW**——再次证伪「测试过=没问题」。架构本身成熟、不变量纪律强provider 中立 / HITL accept 单写 / checkpoint 仅控制流 / SecretStr 全覆盖);**确认无虞**SSRF`base_url` 仅来自硬编码 `_PROVIDER_BASE_URLS`,无用户 URL 入网)/ SQL 注入(全 ORM 参数化)/ XSS无 `dangerouslySetInnerHTML`/`eval`/ 错误信封不泄栈密钥 / Kimi 用 RFC 8628 device flow 无 CSRF 面。
> **整改 DoD**CRITICAL + HIGH 全部 ✅ 且各自补回归测试MEDIUM 择批清理;门禁全绿(后端 ruff/format/mypy/pytest + alembic 无漂移;前端 lint/tsc/vitest/build。**守 §Definition of Done**:每项 TDD 先红后绿、完成即小步 commit。 > **整改 DoD**CRITICAL + HIGH 全部 ✅ 且各自补回归测试MEDIUM 择批清理;门禁全绿(后端 ruff/format/mypy/pytest + alembic 无漂移;前端 lint/tsc/vitest/build。**守 §Definition of Done**:每项 TDD 先红后绿、完成即小步 commit。

View File

@@ -0,0 +1,301 @@
# 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).