diff --git a/PROGRESS.md b/PROGRESS.md index 11be4cf..4763a3a 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -15,7 +15,7 @@ | 任务 | 状态 | 负责 | 依赖 | 备注 | |---|---|---|---|---| -| AC-1 `ai_messages` 表 + 迁移 | ⬜ | @db | — | 计划 §2 canonical DDL | +| AC-1 `ai_messages` 表 + 迁移 | ✅ @db 2026-07-09 | @db | — | 计划 §2 canonical DDL;迁移 `ceabd3c2c00d`(down=`f6a7b8c9d0e1`);C2 扩已「稳定」,AC-2 可动工 | | 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 | @@ -208,6 +208,7 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st > 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件` +- [2026-07-09] @db — **✅ AC-1 `ai_messages` 表 + 迁移**(计划 §2 canonical DDL)。`packages/db/ww_db/models.py` 加 `AiMessage(UuidPk, CreatedAt, Base)`(补 `Uuid` 导入):`project_id`(FK projects CASCADE, NOT NULL, indexed)/`chapter_no`(Integer nullable=项目级)/`thread_id`(Uuid NOT NULL)/`seq`(Integer NOT NULL, repo 批内 0 基定序)/`kind`(Text 无 CHECK)/`tool_key`(Text nullable)/`role`(Text + `ck_ai_messages_role` CHECK author|ai)/`content`(Text NOT NULL 全文)/`meta`(JSONB NOT NULL server_default `'{}'`)/`id`+`created_at`(mixin);append-only 无 updated_at。复合索引 `ix_ai_messages_project_chapter=(project_id,chapter_no,created_at DESC)` 手写 `sa.text` DESC + FK 单列索引。迁移 `ceabd3c2c00d`(down=`f6a7b8c9d0e1`,**autogenerate 仅捕获 ai_messages 无杂散漂移**,DESC 表达式归一为 `sa.text`)。**T0.2 MVP schema 后第一张新业务表**——对话历史是 18 表都无的能力、正交于 review/job/手稿,属真新增(非重复建表);是**旁路侧记录非手稿真源**,`assemble()`/agent 永不读(守 #1/#3/#6,围栏留 AC-4/P4 收口)。**门禁全绿**:ruff/format 干净 · mypy **233 文件** · `alembic upgrade head` + **`alembic check` 无漂移** + **downgrade→upgrade 往复干净** · pytest **971 passed**(基线不变,无代码用新表)。契约 **C2 扩(AC-1)已登记「稳定」** → AC-2 @backend 可动工。 - [2026-07-08] @devops — **✅ QA-CR CR-D1 抬高后端依赖下界至 lockfile 主次版本(保可复现)**。8 个 pyproject(`apps/api` + `packages/{core,agents,config,db,llm_gateway,shared,skills}`)的后端运行时依赖下界从过旧值抬到实锁 MAJOR.MINOR:`langgraph>=1.2`/`langgraph-checkpoint-postgres>=3.1`/`openai>=2.43`/`anthropic>=0.111`/`google-genai>=2.9`/`instructor>=1.15`/`tenacity>=9.1`/`pydantic>=2.13`/`pydantic-settings>=2.14`/`structlog>=26.1`/`asyncpg>=0.31`/`alembic>=1.18`/`fastapi>=0.137`/`uvicorn>=0.49`/`cryptography>=49`(每条 ≤ lockfile 实锁,同小版本线的 sqlalchemy/psycopg 留原样)。`uv.lock` 变动为**纯元数据刷新**——仅各 workspace 成员 `[package.metadata].requires-dist` 的 specifier 字符串随下界更新,**零 pinned `version=` 变动、零 re-resolve、零第三方版本 bump**。root dev 工具下界(pytest/ruff/mypy/httpx)+ 前端 vitest 3.x/tailwind 4.x 升级评估**明确不纳入**(非运行时可复现关键,超范围)。**门禁**:`uv lock --check` 绿(元数据刷新后一致)· `uv run ruff check .` 干净 · pytest **971 passed**(与基线一致,无回归)。无源码/测试改动。 - [2026-07-08] @llm — **✅ QA-CR MEDIUM/LOW 网关+链整改批(CR-M2/M3/L1@llm/M1.4,TDD 逐项提交)**。**完成 6**:`32451a9` pricing 未知 model 发 `pricing_unknown_model` warning(成本仍 0);`9439b97` 三 adapter 瞬时错误消息经 `provider_error_summary(exc)` 只留类名+status(不泄 vendor 响应体);`832ab7d` `OpenAICompatAdapter.probe_connection()` 公开探测 + provider_deps 弃私有 `_client` 反手(CR-M1.4);`c9ffada` 续写链前一章无已验收终稿发 `chain_prior_accepted_missing` warning(CR-L1);`ec91c8f` 链 runner 改用公开 `graph.aget_state(config).interrupts` 判暂停(弃私有 `__interrupt__`);`6a739ca` review 节点五审 `asyncio.gather` 并行扇出(复用主图 add-only 记账并发范式 T3.8,collect 一次落库)。**SKIP 2**:CR-M2 `is_open()` CQS(惰性半开转移是唯一转半开点、拆分漏发命令会致熔断永不复位,风险>收益);CR-M3 `run_overdue_scan` 幂等(`scan_overdue` 本已是原子 `UPDATE...RETURNING`+`NOT IN[CLOSED,OVERDUE]`+commit-only-if-changed,且下一章 current+1 扫描自愈漏标,再动会触碰验收事务边界)。**门禁全绿**:后端 ruff/format 干净 · mypy **232 文件** · pytest **971 passed**(+11 新测:pricing 2 / 错误脱敏 4 / probe 2 / 续写缺前文告警 2 / 并行审 1)· 无 DDL/无迁移。守不变量 #1/#3/#4(熔断/验收事务/add-only 记账 sink 未动)。 - [2026-07-08] @llm/@backend/@devops/@frontend — **✅ QA-CR CRITICAL + HIGH 整改批全部落地**(14 项 + 4 低危收尾)。多 agent 工作流:先 14 个只读 agent 逐项**扎根真实代码**产精确修复+测试规格(纠出 3 处台账前提漂移),再按 owner 四簇**串行整合**入单工作树(避免并发写树 + repo 级 `ruff format .` 竞态),各 TDD 红→绿、逐项补回归测试、逐 fix 小步提交,末尾 14 个 skeptic **对抗式复核**(实证每条 test 非 vacuous,0 problem)。**关键落地/纠偏**:C1 next 15.1.3→**15.5.20**+react **19.2.7**(`pnpm audit` 清、`dependencyFloors.ts` 下界守卫,**CVE 号无法对真库核验+密钥轮换为用户运维事项**);C2 **前提证伪**(langgraph 默认上限 10007 非 25,无实活崩溃)仍加显式顶层 `recursion_limit`;H4 request_id **条件透传**(否则覆盖 contextvars 反退化);H2 修请求级 httpx 泄漏时**揪出 E2E 会误触真网**(override 跟随修);H9 全文本字段上限+2 MiB body 中间件 413 兜底(契约 C3,`gen:api` 无漂移);H8 `.env.example` 换不可用占位符(**真 key 仍在 git 历史,曾用环境须轮换**)。**门禁全绿**:后端 mypy 229/pytest **934**/alembic 无漂移;前端 vitest **669**/build/coverage 95.41%。契约 C1-扩(LlmRequest.request_id)+ C3(H9 caps)。**MEDIUM/LOW 择批后清理。** 提交 `cb8fddf`→`dd6d2fc`(19 提交)。 diff --git a/memory/contracts.md b/memory/contracts.md index 0271ae7..59c8b98 100644 --- a/memory/contracts.md +++ b/memory/contracts.md @@ -80,6 +80,23 @@ - **迁移**:`packages/db/migrations/versions/1f011c42bd4d_provider_credentials_oauth_columns.py`(down_revision=`220ca2e3d53f`)。up: add_column auth_type/oauth_enc + alter_column api_key_enc→nullable;down 逆向。`alembic check` 无漂移。 - **⚠️ K1.3 @backend 必读**:`api_key_enc` 现为 `bytes | None`,`apps/api/ww_api/services/credentials.py` 的 `StoredCredential.api_key_enc: bytes`(dataclass 字段 line 28)与 store 的 `r.api_key_enc` 赋值(line 75/97)mypy 报 `arg-type`(`bytes | None` ↳ `bytes`)。**K1.1 不动 @backend 文件(目录所有权)**;K1.3 接 OAuth store 时须把 `StoredCredential.api_key_enc` 改成 `bytes | None`(并加 `auth_type`/`oauth_enc` 字段/读写路径),届时 mypy 即绿。当前全仓 mypy 唯余此 2 处错(都在 credentials.py),其余门禁绿(ruff/format/alembic check/pytest 400 passed)。 +### C2 扩展(AC-1, 2026-07-09)· `ai_messages` 表——作者↔AI 往复聊天记录 append-only 侧表 owner @db 状态: 稳定 +- 背景:`docs/design/ai-chat-history-plan.md §2`。持久化 refine/rewrite/clarify/continue/generator 五类往复交换,供每章「AI 对话」抽屉重放。**T0.2 MVP schema 后第一张新业务表**(对话历史是 18 张表都无的能力,正交于 review/job/手稿,塞进 `jobs.result`/`chapter_reviews` 会污染其真源语义)。消费方:`@backend`(AC-2 repo+router,据本条建,勿自拟 DDL)、`@frontend`(AC-3 经 gen:api 消费码生成名)。 +- **侧记录语义(守 #1/#3/#6,AC-2/AC-3 必读)**:`ai_messages` 是「说了什么」的真源,**不是手稿真源**。`assemble()` 与任何 agent/orchestrator 节点**永不读它**——不喂 prompt、不改正文、无记忆注入(#6)/裁决(#3/#4)权威。**append-only**:无 `updated_at`、无 PATCH/PUT、v1 不删。 +- **模型(`packages/db/ww_db/models.py` `AiMessage`,`UuidPk, CreatedAt, Base`)**——列与类型(snake_case,即 OpenAPI/码生成表面): + - `id` UuidPk / `created_at` TIMESTAMPTZ(`func.now()`)——来自 mixin。 + - `project_id: uuid.UUID` FK `projects.id ON DELETE CASCADE`, NOT NULL, `index=True`(→ `ix_ai_messages_project_id`)。 + - `chapter_no: int | None` `Integer` **nullable**(**NULL = 项目级/工具箱生成**;有意偏离 `chapter_reviews` 等 bare-int 的 NOT NULL 先例)。**无 FK**:改章号/删章会孤儿化,同 `chapter_reviews`,**属设计,勿加 cascade「修」**。 + - `thread_id: uuid.UUID` `Uuid` NOT NULL(客户端每次开面板 `crypto.randomUUID()` 生成,归组一次多轮交换;无 threads 表)。 + - `seq: int` `Integer` NOT NULL,**repo 赋值 = append 批内 0 基位置**(无 Identity/无客户端值/无 server_default/无 UNIQUE)。**Postgres `now()` 是事务起始时刻→同批共享 `created_at`,靠 `seq` 批内定序**;跨批 `created_at` 不同自然定序。定序键统一 `(created_at, seq)`——DB list 与前端展示都不许单靠 `created_at`。 + - `kind: str` `Text` NOT NULL,**无 CHECK**(同 `jobs.kind`/`characters.role` 自由 Text;边界由 Pydantic `Literal["refine","rewrite","clarify","continue","generator"]` 校验;加新 kind 零迁移)。 + - `tool_key: str | None` `Text`(continue→`"continue"`;generator→其 key;refine/rewrite/clarify→NULL;同 `prompt_templates.tool_key` 无 FK/CHECK)。 + - `role: str` `Text` NOT NULL + `CheckConstraint("role IN ('author','ai')", name="ck_ai_messages_role")`(二元封闭枚举,CHECK 兜底防御)。 + - `content: str` `Text` NOT NULL(人读 bubble 全文;clarify ai 行存问题文本,结构化选项进 meta;被拒候选无 chapters 行,须存全文不截断)。 + - `meta: dict[str, Any]` `JSONB` NOT NULL `server_default '{}'`(per-kind 形状见 §3.4,AC-2 登记 C3 时随端点定;长文进 content 不进 meta)。 +- **索引**:(1) `ix_ai_messages_project_id`(FK 单列,`index=True`)。(2) `ix_ai_messages_project_chapter = (project_id, chapter_no, created_at DESC)`——**迁移里手写 `sa.text("created_at DESC")`**(autogenerate 无法可靠还原 DESC;沿用 `chapter_reviews` 证明可过 `alembic check` 的形态)。服务抽屉 union 查询 + newest-first。**无 UNIQUE**。 +- **迁移**:`packages/db/migrations/versions/ceabd3c2c00d_add_ai_messages_table_chat_history.py`(**revision=`ceabd3c2c00d`, down_revision=`f6a7b8c9d0e1`**)。仅建 `ai_messages` + 两索引 + role CHECK + FK CASCADE,无其它漂移。DoD 全绿:`alembic upgrade head` → **`alembic check` 无漂移** → **downgrade→upgrade 往复干净** → ruff/format/mypy(233 files)/pytest(**971 passed**) 绿。 + ## C3 · API / OpenAPI 端点 owner @backend 状态: 稳定(M1 端点全落:T1.7 settings + T1.4 projects/chapters, 2026-06-18) - 来源:`ARCHITECTURE.md §7.2` 端点清单(章节端点统一 `/projects/:id/chapters/:no/...`;含 `PUT .../draft`(自动保存)、`/refine`、`/jobs/:id`、`/reviews`)。 - 消费方:`@frontend`(经 OpenAPI→TS 客户端)。**改端点/字段 → 前端必须 `pnpm gen:api` 重生成客户端。** diff --git a/packages/db/migrations/versions/ceabd3c2c00d_add_ai_messages_table_chat_history.py b/packages/db/migrations/versions/ceabd3c2c00d_add_ai_messages_table_chat_history.py new file mode 100644 index 0000000..802bf9d --- /dev/null +++ b/packages/db/migrations/versions/ceabd3c2c00d_add_ai_messages_table_chat_history.py @@ -0,0 +1,66 @@ +"""add ai_messages table (chat history) + +作者↔AI 往复留痕 append-only 侧表(AC-1)——非手稿真源,永不进 assemble()/agent 生成链。 +复合索引 ix_ai_messages_project_chapter 手写 created_at DESC 表达式(沿用 chapter_reviews +证明可过 alembic check 的形态;autogenerate 无法可靠还原 DESC)。role CHECK 兜底二元枚举。 + +Revision ID: ceabd3c2c00d +Revises: f6a7b8c9d0e1 +Create Date: 2026-07-09 16:35:14.948502 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "ceabd3c2c00d" +down_revision: str | None = "f6a7b8c9d0e1" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "ai_messages", + sa.Column("project_id", sa.Uuid(), nullable=False), + sa.Column("chapter_no", sa.Integer(), nullable=True), + sa.Column("thread_id", sa.Uuid(), nullable=False), + sa.Column("seq", sa.Integer(), nullable=False), + sa.Column("kind", sa.Text(), nullable=False), + sa.Column("tool_key", sa.Text(), nullable=True), + sa.Column("role", sa.Text(), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column( + "meta", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'"), + nullable=False, + ), + sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.CheckConstraint("role IN ('author', 'ai')", name="ck_ai_messages_role"), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_ai_messages_project_chapter", + "ai_messages", + ["project_id", "chapter_no", sa.text("created_at DESC")], + unique=False, + ) + op.create_index(op.f("ix_ai_messages_project_id"), "ai_messages", ["project_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_ai_messages_project_id"), table_name="ai_messages") + op.drop_index("ix_ai_messages_project_chapter", table_name="ai_messages") + op.drop_table("ai_messages") diff --git a/packages/db/ww_db/models.py b/packages/db/ww_db/models.py index 71ffc0c..c2d8823 100644 --- a/packages/db/ww_db/models.py +++ b/packages/db/ww_db/models.py @@ -19,6 +19,7 @@ from sqlalchemy import ( LargeBinary, Text, UniqueConstraint, + Uuid, text, ) from sqlalchemy.dialects.postgresql import JSONB @@ -231,6 +232,38 @@ class ChapterReview(UuidPk, CreatedAt, Base): decisions: Mapped[dict[str, Any] | None] = mapped_column(JSONB) +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()) + + # ---- 运营/系统表(续)----