32 KiB
AI 对话(聊天记录)实施计划
多 agent 生成:4 学科起草(@db/@backend/@frontend/@architect)→ 4 视角对抗交叉评审 → 综合合稿。 状态:决策已定(全 A,2026-07-09),实施中。① clarify = buffer-until-concluded(澄清 Q&A 随最终产出批一起落库,纯放弃=不记);② v1 只记 5 类交互(refine/rewrite/clarify/continue/generator),初始写章指令留后续;③ append-only,v1 不做删除。
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 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).
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 oneAiMessage(...)per row withseq=ifromenumerate(rows), write a freshmetadict per row (never in-place mutate — JSONB gotcha,injection_repo.py:97),session.add_all(rows),await session.flush(),await session.refresh(r)each (populateid/created_at), return[_to_view(r) for r in rows]. No commit.list_for_chapterfilter 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
kindfilter;ORDER BY created_at DESC, seq DESC(newest window first for pagination);LIMIT/OFFSETfromPageDep.
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.
POST /projects/{project_id}/ai-messages→ 201AiMessageListResponse. BodyAiMessageAppendRequest. Flow:project_repo.get(STUB_OWNER_ID, project_id)→ 404NOT_FOUNDif 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). Invalidkind/role/empty/oversize → FastAPI 422. No idempotency guard (see §7) — single-user, best-effort, no client auto-retry.GET /projects/{project_id}/ai-messages?chapter_no=&kind=+PageDep→ 200AiMessageListResponse. Project-existence 404 (consistent withGET .../style). Delegates torepo.list_for_chapter(...). Fullcontentin every row (drawer re-accept reads it directly); payload bounded by newest-first ordering +PageDep. Read-only (no commit).- 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)whereAiTurnInput = {threadId, kind, toolKey?, chapterNo, messages:{role,content,meta?}[]}:ensureLoaded().- Build optimistic
AiMessageView[](temp client id,created_at:new Date().toISOString(),seq= local index); ifready,setMessages(prev => [...prev, ...optimistic]). api.POST(".../ai-messages", {body: {thread_id, chapter_no, kind, tool_key, messages}})— one batch, one round-trip.- 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'smeta.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) intomodels.py(addUuidimport); hand-written migrationdown_revision="f6a7b8c9d0e1". DoD:alembic upgrade head+alembic checkno drift + upgrade→downgrade→upgrade round-trip +ruff/mypygreen. Register C2 ext稳定; add the two gotchas. Gate: no@backendstart until C2稳定ANDalembic checkgreen 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 -qgreen, ≥80% cov,alembic checkclean. Register C3 ext稳定(endpoints +metashapes + frozen schema names) before@frontend. - P2 ·
@frontend— gen:api + wiring + drawer. §4. DoD:pnpm gen:apisynced, renderHook tests,lib/**cov ≥80%,lint/typecheck/test/buildgreen. - 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 toCLAUDE.mdinvariants +ARCHITECTURE.md§5.2 truth-source boundary +PRODUCT_SPEC.mddata-model/endpoints: "ai_messagesis 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 — keepAiMessageRepoOUT of the repo bundleassemble()consumes; (c)memory/gotchas.md+ a code-review checklist blocker: "any read ofai_messagesinsidepackages/core/ww_core/memory/orpackages/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):appendsingle + batch (list order preserved,seq= 0..n-1 within batch,metaround-trips, project-levelchapter_no=Nonepersists);list_for_chapter(chapter∪NULL union, project-only whenchapter_no=None,kindfilter,limit/offset,ORDER BY created_at DESC, seq DESC); flushed-not-committed. - @backend endpoint integration (
dependency_overrideslike templates): POST returns persisted views; 404 missing project; 422 on invalid kind/role, emptymessages,contentat cap+1, oversizemeta; 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_ledgercommit.
7. Risks & mitigations
- #1/#6 truth-source creep (biggest latent risk): a future dev wires
ai_messagesintoassemble()/a prompt → a second non-deterministic truth-source. Mitigation: the P4 fence (written invariant +AiMessageRepokept 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 +
PageDeppagination +contentcap + 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 NOTHINGthen. metabypassing the content cap:_bound_metavalidator (≤50k serialized) + guidance "long text incontent, notmeta".- Ordering nondeterminism: solved by
(created_at, seq)everywhere (DB list + FE display); FE MUST NOT sort bycreated_atalone. - Cross-owner model conflict: eliminated — ONE canonical DDL in §2 owned by
@db;@backend/@frontendbuild against the registered C2/C3, not their drafts. chapter_noorphan on renumber/delete: by design (no FK), same aschapter_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_messagesis 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_ledgeradd-only commit those endpoints already do (verifiedstyle.py). Re-accept-from-history only repopulates the editor draft (verified:setText+autosave, not achapterscommit); 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 freshmetadicts (no in-place mutation).