test(qa): AI 对话(聊天记录)E2E + #1/#6 真源围栏守卫(AC-4)
计划 §6/§8。收口 AI-chat-history 功能(AC-1..AC-4 全交付)。 - tests/test_ai_chat_history_e2e.py:真 pg、零 LLM/无网关(纯 CRUD 侧记录), 覆盖 §6 全 6 用例——缓冲多轮线程一批落库 + GET newest-first/meta 保真、 作用域分区(本章 ∪ 项目级 NULL)、跨批分页 + kind 过滤、 append 只写 ai_messages(其它业务表零变 + usage_ledger 恒 0)/GET 只读、 404 + 五类 422 失败零落库、clarify-abandon(服务端无部分写路径)。 - tests/test_ai_messages_not_in_generation_path.py:AST 静态围栏—— memory/orchestrator 两目录无一 import/引用 ai_messages, MemoryRepos 捆绑与 assemble 形参都不含它;植入违规自证探测器非空,能变红。 - memory/gotchas.md:登记 ai_messages 真源围栏(append-only 侧记录, 绝不喂 assemble/prompt;接入生成链 = code-review BLOCKER)。 门禁全绿:ruff/format · mypy 238 · alembic 无漂移 · pytest 1002 passed(+10)。
This commit is contained in:
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 进行中:AI 对话(聊天记录)持久化 · develop 上新功能
|
## ✅ 已交付:AI 对话(聊天记录)持久化(AC-1..AC-4 全绿)· develop 上新功能
|
||||||
|
|
||||||
> **背景**:[2026-07-09] 用户要求把作者↔AI 每轮往复(refine/rewrite/clarify/continue/工具箱生成器)**落库为聊天记录**,每章一个「AI 对话」抽屉展示。多 agent(4 学科起草→4 视角交叉评审→合稿)产出实施计划 `docs/design/ai-chat-history-plan.md`。**决策全 A**:clarify buffer-until-concluded / v1 只记 5 类交互 / append-only 不删。
|
> **背景**:[2026-07-09] 用户要求把作者↔AI 每轮往复(refine/rewrite/clarify/continue/工具箱生成器)**落库为聊天记录**,每章一个「AI 对话」抽屉展示。多 agent(4 学科起草→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 守卫)。
|
> **契约与不变量纪律**:@db 建表(契约 C2 扩)→@backend 端点(C3 扩)→@frontend gen:api+接线→@qa E2E,逐级等上游契约「稳定」。守 #1/#3/#8:聊天记录是**旁路留痕**(同 `usage_ledger` 物种),前端**显式 append**、生成端点**保持只读不变**、**绝不**喂 `assemble`/prompt(计划 §8 有围栏 + code-review 守卫)。
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
| AC-1 `ai_messages` 表 + 迁移 | ✅ @db 2026-07-09 | @db | — | 计划 §2 canonical DDL;迁移 `ceabd3c2c00d`(down=`f6a7b8c9d0e1`);C2 扩已「稳定」,AC-2 可动工 |
|
| 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 2026-07-09 | @backend | AC-1 | `c675a74`;`SqlAiMessageRepo`(批 append seq/thread_id + newest-first union/分页) + `POST/GET /projects/{id}/ai-messages` + schemas;C3 扩已「稳定」→ AC-3 可动工(须 `pnpm gen:api`) |
|
| AC-2 repo + append/list 端点 + schema + 契约 | ✅ @backend 2026-07-09 | @backend | AC-1 | `c675a74`;`SqlAiMessageRepo`(批 append seq/thread_id + newest-first union/分页) + `POST/GET /projects/{id}/ai-messages` + schemas;C3 扩已「稳定」→ AC-3 可动工(须 `pnpm gen:api`) |
|
||||||
| AC-3 gen:api + AiConversationDrawer + useAiConversation + 接线 5 处交互 | ✅ @frontend 2026-07-09 | @frontend | AC-2 | `dce882c`/`ddf7fa0`/`59f2f60`/`33a9b76`;gen:api + 纯视图模型 + hook + 抽屉 + 五类交互留痕(clarify buffer-until-concluded);vitest 706 passed / cov 95% / build OK |
|
| AC-3 gen:api + AiConversationDrawer + useAiConversation + 接线 5 处交互 | ✅ @frontend 2026-07-09 | @frontend | AC-2 | `dce882c`/`ddf7fa0`/`59f2f60`/`33a9b76`;gen:api + 纯视图模型 + hook + 抽屉 + 五类交互留痕(clarify buffer-until-concluded);vitest 706 passed / cov 95% / build OK |
|
||||||
| AC-4 E2E + repo/端点/hook 测试 | ⬜ | @qa | AC-3 | 计划 §6;剩 E2E 6 流程(含 clarify-abandon 不落库、refine 漂移不盲替换、项目级现于本章抽屉) |
|
| AC-4 E2E + #1/#6 围栏守卫 | ✅ @qa 2026-07-09 | @qa | AC-3 | 计划 §6/§8;`tests/test_ai_chat_history_e2e.py`(6 用例,真 pg 零 LLM)+ `tests/test_ai_messages_not_in_generation_path.py`(AST 围栏,能变红);pytest **1002 passed**(+10)。**AI 对话(聊天记录)AC-1..AC-4 全交付** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -208,6 +208,7 @@ T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users st
|
|||||||
|
|
||||||
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
|
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
|
||||||
|
|
||||||
|
- [2026-07-09] @qa — **✅ AC-4 E2E + #1/#6 真源围栏守卫|AI 对话(聊天记录)AC-1..AC-4 全交付**(计划 §6/§8)。**E2E**(`tests/test_ai_chat_history_e2e.py`,真 pg、**零 LLM 无网关**——纯 CRUD 侧记录,镜像 `test_t6_toolbox_e2e.py` 的 `e2e_sm` 生命周期)覆盖 §6 全 6 用例:① 缓冲多轮线程一批落库(作者含糊→AI 反问〔选项进 meta〕→作者答→AI 定稿,同 `thread_id`/`seq` 0..3)→ GET newest-first(`seq`/`role`/`thread_id`/`meta{options,version_no,segment}` 往复保真、全文 content 未截断);② 作用域分区(章级 ∪ 项目级 `chapter_no IS NULL`——`?chapter_no=N` 两者都返回、缺省仅项目级、项目级现于任一章抽屉);③ 跨多批 newest-first + `limit/offset` 分页 + `kind` 过滤;④ **append 只写 `ai_messages`**(UsageLedger/Chapter/ChapterReview/WorldEntity/Job 逐表零变化、`usage_ledger` 恒 0 证无网关)、GET 只读不建行;⑤ 未知项目 POST/GET 皆 404、非法 role/kind·空 messages·超上限 content/meta 五类 422 且失败零落库;⑥ clarify-abandon 代理(服务端无部分写路径,只持久化被显式 POST 的已定线程)。**围栏**(`tests/test_ai_messages_not_in_generation_path.py`,AST 静态守卫,**能变红**):memory/orchestrator 两目录 23 文件无一 import/引用 `AiMessage*`/`ai_message_repo`(Name/Attribute/Import 全扫)+ `MemoryRepos` 捆绑与 `assemble` 形参都不含它 + 植入违规自证探测器非空——把 ai_message repo 接进 `assemble()` 会直接失败(已用真 assemble.py 源注入验证变红)。守 #1/#6:聊天记录是 append-only 旁路侧记录(同 `usage_ledger` 物种),绝不喂 prompt/记忆注入。**门禁全绿**:ruff/format 干净 · mypy **238 文件** · **`alembic check` 无漂移** · pytest **1002 passed**(992 基线 + 10:E2E 6 + 围栏 4)。gotcha 已登记(ai_messages 真源围栏 = code-review BLOCKER)。仅动 `tests/` + `memory/gotchas.md` + `PROGRESS.md`,**未碰任何产品代码**。
|
||||||
- [2026-07-09] @frontend — **✅ AC-3 gen:api + AI 对话 hook/抽屉 + 五类交互留痕**(计划 §4,`dce882c`/`ddf7fa0`/`59f2f60`/`33a9b76`)。**gen:api**(`dce882c`):`POST/GET /projects/{id}/ai-messages` + `AiMessage*` 4 schema 入 `schema.d.ts`(openapi.json 为 gitignore 构建产物),`lib/api/types.ts` re-export 码生成名。**核心**(`ddf7fa0`):`lib/workbench/aiConversation.ts` 纯视图模型(`kindLabel/kindBadge`、`bubbleSide`、`groupByThread` 按 `(created_at,seq)` 组内+线程定序〔**绝不单靠 created_at**——同批共享 created_at 靠 repo 的 seq 消歧〕、`partitionByScope`〔本章 ∪ 项目级〕、`acceptActionFor`〔rewrite→替换整章 / continue·generator→插入正文 / refine→回填选段 / clarify→无〕)+ 11 单测;`lib/workbench/useAiConversation.ts` Workbench 级单实例(懒加载去重 + `mergeById` 防并发覆盖 + 乐观插入/正位对账/失败回滚,**fail-soft 绝不 throw 进生成/HITL**〔守 #3 意图〕;`silent` 直投供无抽屉的工具箱页)+ 9 renderHook 测试;`components/workbench/AiConversationDrawer.tsx` 克隆 ContextDrawer 的 a11y(`role=dialog/log`、`aria-live` additions、Esc、focus trap、还原焦点、prefers-reduced-motion),本章对话 + 项目级生成两段气泡、clarify 只读选项芯片、ai 气泡挂 DRAFT-only 再接受。**hook 返回产物**(`59f2f60`):四生成 hook 加返回值(`RefineVersion|null`/`string|null`/`RewriteVersion|null`/`GeneratorArtifact|null`,向后兼容,补成功用例断言)。**五处接线**(`33a9b76`):RefinePanel/ChapterRewritePanel 结束后 append `[author(意见/答复), ai(产出)]`,**clarify buffer-until-concluded**——澄清 Q&A(原含糊意见→AI 反问〔选项进 meta〕→作者答复)缓冲,随最终产出批一起落库同 `thread_id`,**纯放弃不记**;refine ai 行 meta 存 `version_no`+原选段(每版重锚);ContinuePanel ai-only(续写无作者输入)meta `candidate_index`;GeneratorRunner append `[author(字段摘要,meta{tool_key,input_fields}), ai(渲染预览,meta{tool_key,output_kind})]`(空产物不记,`lib/toolbox/aiSummary.ts` 纯助手+5 单测),InlineToolbox 挂本章号、ToolboxPage silent 项目级(`chapter_no=null`);Workbench 挂 hook 单实例 + 抽屉 + 底栏「AI 对话」入口(`AI_CONVERSATION_ENABLED` 灰度)+ 三个再接受回调(成功关抽屉+toast、refine 漂移不盲替换)。**留痕永不改「是否/何时写正文」**——正文仍只在验收事务落库(守 #3)。**门禁全绿**:lint/typecheck 干净 · vitest **706 passed**(681 基线 + 25:aiConversation 11 + useAiConversation 9 + aiSummary 5)· `test:coverage` 通过(`lib/**` 95.44% ≥ 80,新纯模块/hook 全测)· `build` OK。→ 剩 **AC-4 @qa** E2E(计划 §6)+ P4 #1/#6 围栏。
|
- [2026-07-09] @frontend — **✅ AC-3 gen:api + AI 对话 hook/抽屉 + 五类交互留痕**(计划 §4,`dce882c`/`ddf7fa0`/`59f2f60`/`33a9b76`)。**gen:api**(`dce882c`):`POST/GET /projects/{id}/ai-messages` + `AiMessage*` 4 schema 入 `schema.d.ts`(openapi.json 为 gitignore 构建产物),`lib/api/types.ts` re-export 码生成名。**核心**(`ddf7fa0`):`lib/workbench/aiConversation.ts` 纯视图模型(`kindLabel/kindBadge`、`bubbleSide`、`groupByThread` 按 `(created_at,seq)` 组内+线程定序〔**绝不单靠 created_at**——同批共享 created_at 靠 repo 的 seq 消歧〕、`partitionByScope`〔本章 ∪ 项目级〕、`acceptActionFor`〔rewrite→替换整章 / continue·generator→插入正文 / refine→回填选段 / clarify→无〕)+ 11 单测;`lib/workbench/useAiConversation.ts` Workbench 级单实例(懒加载去重 + `mergeById` 防并发覆盖 + 乐观插入/正位对账/失败回滚,**fail-soft 绝不 throw 进生成/HITL**〔守 #3 意图〕;`silent` 直投供无抽屉的工具箱页)+ 9 renderHook 测试;`components/workbench/AiConversationDrawer.tsx` 克隆 ContextDrawer 的 a11y(`role=dialog/log`、`aria-live` additions、Esc、focus trap、还原焦点、prefers-reduced-motion),本章对话 + 项目级生成两段气泡、clarify 只读选项芯片、ai 气泡挂 DRAFT-only 再接受。**hook 返回产物**(`59f2f60`):四生成 hook 加返回值(`RefineVersion|null`/`string|null`/`RewriteVersion|null`/`GeneratorArtifact|null`,向后兼容,补成功用例断言)。**五处接线**(`33a9b76`):RefinePanel/ChapterRewritePanel 结束后 append `[author(意见/答复), ai(产出)]`,**clarify buffer-until-concluded**——澄清 Q&A(原含糊意见→AI 反问〔选项进 meta〕→作者答复)缓冲,随最终产出批一起落库同 `thread_id`,**纯放弃不记**;refine ai 行 meta 存 `version_no`+原选段(每版重锚);ContinuePanel ai-only(续写无作者输入)meta `candidate_index`;GeneratorRunner append `[author(字段摘要,meta{tool_key,input_fields}), ai(渲染预览,meta{tool_key,output_kind})]`(空产物不记,`lib/toolbox/aiSummary.ts` 纯助手+5 单测),InlineToolbox 挂本章号、ToolboxPage silent 项目级(`chapter_no=null`);Workbench 挂 hook 单实例 + 抽屉 + 底栏「AI 对话」入口(`AI_CONVERSATION_ENABLED` 灰度)+ 三个再接受回调(成功关抽屉+toast、refine 漂移不盲替换)。**留痕永不改「是否/何时写正文」**——正文仍只在验收事务落库(守 #3)。**门禁全绿**:lint/typecheck 干净 · vitest **706 passed**(681 基线 + 25:aiConversation 11 + useAiConversation 9 + aiSummary 5)· `test:coverage` 通过(`lib/**` 95.44% ≥ 80,新纯模块/hook 全测)· `build` OK。→ 剩 **AC-4 @qa** E2E(计划 §6)+ P4 #1/#6 围栏。
|
||||||
- [2026-07-09] @backend — **✅ AC-2 repo + append/list 端点 + schema + 契约**(计划 §3,`c675a74`)。repo `packages/core/ww_core/domain/ai_message_repo.py`(`AiMessageRepo`/`SqlAiMessageRepo`/`AiMessageView`/`AiTurnRow`,经 `ww_core.domain` 导出):`append`(一批多条 bubble → 批内 `seq`=enumerate 0 基位置、同 `thread_id`、meta 新 dict 不原地 mutate、`add_all`+一次 flush+refresh,**只 flush 不 commit**) + `list_for_chapter`(`created_at DESC, seq DESC` newest-first;`chapter_no` 非 None → 本章 ∪ 项目级 `IS NULL`,None → 仅项目级;可选 `kind`;`limit/offset`)。schemas `apps/api/ww_api/schemas/ai_messages.py`:批级 append 信封 `AiMessageAppendRequest{thread_id,chapter_no(ge=1)|null,kind,tool_key,messages}` + `AiMessageInput{role,content(min1,max200_000),meta(序列化≤50_000)}` + `AiMessageView` + `AiMessageListResponse{messages}`;`Literal` 为 kind/role 枚举权威(边界 422)。端点 `apps/api/ww_api/routers/ai_messages.py`(prefix `/projects`,tag `ai-messages`,已注册 main):**`POST /projects/{id}/ai-messages`**(201,项目 404,校验 422,append 只 flush + 端点 `commit()`) + **`GET /projects/{id}/ai-messages?chapter_no=&kind=`+`PageDep`**(200,newest-first,只读)。**这是显式记录写入**——五个生成端点逐字节不变、只读(守 #3);`ai_messages` 旁路侧记录,`assemble()`/agent 永不读(守 #1/#6)。注入缝 `get_ai_message_repo`。**TDD**:repo 真 pg 单测 9 passed(seq 0..n-1 / 同 thread_id / `created_at DESC,seq DESC` / union 含 NULL / 分页 / flush-only 跨 session 不可见);端点集成 12 passed(round-trip / 本章∪项目级 / kind 过滤 / 404×2 / 422×5〔非法 role/kind、空 messages、空 content、超上限 content、超上限 meta〕 / commit 边界 / 响应 key 集合不泄漏)。**门禁全绿**:ruff/format 干净 · mypy **238 文件** · **`alembic check` 无漂移**(本任务不加模型)· `export_openapi` 成功(新端点+4 schema 已入表面)· pytest **992 passed**(971 基线 + 21)· 新模块 cov router 100%/schemas 100%/repo 97%(余 Protocol `...` 桩),全局 89%。契约 **C3 扩(AC-2)已登记「稳定」** → **@frontend AC-3 须 `cd apps/web && pnpm gen:api`** 后消费码生成名。
|
- [2026-07-09] @backend — **✅ AC-2 repo + append/list 端点 + schema + 契约**(计划 §3,`c675a74`)。repo `packages/core/ww_core/domain/ai_message_repo.py`(`AiMessageRepo`/`SqlAiMessageRepo`/`AiMessageView`/`AiTurnRow`,经 `ww_core.domain` 导出):`append`(一批多条 bubble → 批内 `seq`=enumerate 0 基位置、同 `thread_id`、meta 新 dict 不原地 mutate、`add_all`+一次 flush+refresh,**只 flush 不 commit**) + `list_for_chapter`(`created_at DESC, seq DESC` newest-first;`chapter_no` 非 None → 本章 ∪ 项目级 `IS NULL`,None → 仅项目级;可选 `kind`;`limit/offset`)。schemas `apps/api/ww_api/schemas/ai_messages.py`:批级 append 信封 `AiMessageAppendRequest{thread_id,chapter_no(ge=1)|null,kind,tool_key,messages}` + `AiMessageInput{role,content(min1,max200_000),meta(序列化≤50_000)}` + `AiMessageView` + `AiMessageListResponse{messages}`;`Literal` 为 kind/role 枚举权威(边界 422)。端点 `apps/api/ww_api/routers/ai_messages.py`(prefix `/projects`,tag `ai-messages`,已注册 main):**`POST /projects/{id}/ai-messages`**(201,项目 404,校验 422,append 只 flush + 端点 `commit()`) + **`GET /projects/{id}/ai-messages?chapter_no=&kind=`+`PageDep`**(200,newest-first,只读)。**这是显式记录写入**——五个生成端点逐字节不变、只读(守 #3);`ai_messages` 旁路侧记录,`assemble()`/agent 永不读(守 #1/#6)。注入缝 `get_ai_message_repo`。**TDD**:repo 真 pg 单测 9 passed(seq 0..n-1 / 同 thread_id / `created_at DESC,seq DESC` / union 含 NULL / 分页 / flush-only 跨 session 不可见);端点集成 12 passed(round-trip / 本章∪项目级 / kind 过滤 / 404×2 / 422×5〔非法 role/kind、空 messages、空 content、超上限 content、超上限 meta〕 / commit 边界 / 响应 key 集合不泄漏)。**门禁全绿**:ruff/format 干净 · mypy **238 文件** · **`alembic check` 无漂移**(本任务不加模型)· `export_openapi` 成功(新端点+4 schema 已入表面)· pytest **992 passed**(971 基线 + 21)· 新模块 cov router 100%/schemas 100%/repo 97%(余 Protocol `...` 桩),全局 89%。契约 **C3 扩(AC-2)已登记「稳定」** → **@frontend AC-3 须 `cd apps/web && pnpm gen:api`** 后消费码生成名。
|
||||||
- [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-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 可动工。
|
||||||
|
|||||||
@@ -90,3 +90,4 @@
|
|||||||
- [2026-06-24] @llm/@devops Prompt 外置:`prompts/*.md` 文件名按 **spec.name(连字符)** 非 Python 变量名——`style` agent 的 name 是 `"style"`→`style.md`(非 `style_drift`),另有 `character-gen.md`/`golden-finger.md`/`book-title.md`/`fine-outline.md`/`de-ai.md`。**改 prompt 内容后必须重生成金标准** `uv run python packages/agents/tests/_gen_golden.py`(覆盖 `tests/fixtures/prompt_hashes.json`),否则 `test_prompt_loader.py` 字节回归红;旧常量含反斜杠折行——外迁/比对一律用**运行时值**别用源码文本。`.gitattributes` 锁 `prompts/*.md text eol=lf`(路径含斜杠是**根锚定**,必须写完整嵌套路径 `packages/agents/ww_agents/prompts/*.md` 才匹配,裸 `prompts/*.md` 不生效)。
|
- [2026-06-24] @llm/@devops Prompt 外置:`prompts/*.md` 文件名按 **spec.name(连字符)** 非 Python 变量名——`style` agent 的 name 是 `"style"`→`style.md`(非 `style_drift`),另有 `character-gen.md`/`golden-finger.md`/`book-title.md`/`fine-outline.md`/`de-ai.md`。**改 prompt 内容后必须重生成金标准** `uv run python packages/agents/tests/_gen_golden.py`(覆盖 `tests/fixtures/prompt_hashes.json`),否则 `test_prompt_loader.py` 字节回归红;旧常量含反斜杠折行——外迁/比对一律用**运行时值**别用源码文本。`.gitattributes` 锁 `prompts/*.md text eol=lf`(路径含斜杠是**根锚定**,必须写完整嵌套路径 `packages/agents/ww_agents/prompts/*.md` 才匹配,裸 `prompts/*.md` 不生效)。
|
||||||
- [2026-06-24] @devops wheel 默认不打非-`.py` 数据文件——`prompts/*.md` 必须在 `packages/agents/pyproject.toml` 显式纳入(hatchling 用 `artifacts`,**别用 `force-include`**:hatchling 已含包目录下全部文件,force-include 会 duplicate-path 构建失败);源码树 pytest **测不出**漏带(fail-fast 仅裸装时触发),靠 CI `agents-wheel-smoke`(build→裸装→`import ww_agents; assert ww_agents.SPECS`)守。`ww_agents` import 链拉起 `ww_llm_gateway`(`types.Tier`)→需 `structlog`,故 `packages/{llm_gateway,core}` 必须自声明 `structlog`(原靠 apps/api 传递,裸装 import 会 ModuleNotFoundError)。
|
- [2026-06-24] @devops wheel 默认不打非-`.py` 数据文件——`prompts/*.md` 必须在 `packages/agents/pyproject.toml` 显式纳入(hatchling 用 `artifacts`,**别用 `force-include`**:hatchling 已含包目录下全部文件,force-include 会 duplicate-path 构建失败);源码树 pytest **测不出**漏带(fail-fast 仅裸装时触发),靠 CI `agents-wheel-smoke`(build→裸装→`import ww_agents; assert ww_agents.SPECS`)守。`ww_agents` import 链拉起 `ww_llm_gateway`(`types.Tier`)→需 `structlog`,故 `packages/{llm_gateway,core}` 必须自声明 `structlog`(原靠 apps/api 传递,裸装 import 会 ModuleNotFoundError)。
|
||||||
- [2026-06-24] @backend `SpecResolver`:内置 name 走 `SPECS` 纯内存、**零 DB**;保留命名空间守卫在 **SkillRegistry 入库校验期**(非 resolver 读路径),用户 skill 与内置同名(`REVIEW_RESERVED_NAMES ∪ set(SPECS)`)→ `AppError(VALIDATION)`。name **精确字符串相等**:拼错近似名(`character_gen` vs `character-gen`)`output_schema_for` 返 None/不命中,是预期行为非 bug。
|
- [2026-06-24] @backend `SpecResolver`:内置 name 走 `SPECS` 纯内存、**零 DB**;保留命名空间守卫在 **SkillRegistry 入库校验期**(非 resolver 读路径),用户 skill 与内置同名(`REVIEW_RESERVED_NAMES ∪ set(SPECS)`)→ `AppError(VALIDATION)`。name **精确字符串相等**:拼错近似名(`character_gen` vs `character-gen`)`output_schema_for` 返 None/不命中,是预期行为非 bug。
|
||||||
|
- [2026-07-09] @qa **`ai_messages` 是 append-only 旁路侧记录(与 `usage_ledger` 同物种),绝不喂 `assemble()`/prompt——守不变量 #1/#6(计划 §8)。** 它是「作者↔AI 说了什么」的真源,**不是手稿/审稿真源**:无记忆注入(#6)、无裁决(#3/#4)权威。生成链(`packages/core/ww_core/{memory,orchestrator}`)任一模块 import/引用 `AiMessage`/`AiMessageRepo`/`ai_message_repo` = 制造第二个非确定性真源,**是 code-review BLOCKER**。围栏已工具化:`tests/test_ai_messages_not_in_generation_path.py`(AST 扫两目录 import/Name/Attribute + 断言 `MemoryRepos` 捆绑与 `assemble` 形参都不含它;植入违规自证非空守卫)——把 ai_message repo 接进 `assemble()`/prompt 会直接变红。日后加新 kind 或改 repo 都别越过这道墙。
|
||||||
|
|||||||
531
tests/test_ai_chat_history_e2e.py
Normal file
531
tests/test_ai_chat_history_e2e.py
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
"""AC-4 端到端:AI 对话(聊天记录)持久化(真实 pg,**零 LLM / 无网关**)。
|
||||||
|
|
||||||
|
本功能是纯 CRUD 旁路留痕(`docs/design/ai-chat-history-plan.md`)——五个生成端点逐字节
|
||||||
|
不变、只读;作者↔AI 每轮往复经 **`POST /projects/{id}/ai-messages`** 显式 append,供每章
|
||||||
|
「AI 对话」抽屉重放。故这里**不** override 任何网关(无 `_FakeAdapter`):只打两端点、断
|
||||||
|
DB 真源。镜像 `tests/test_t6_toolbox_e2e.py` 的 `e2e_sm` engine/session 生命周期;无 pg → skip。
|
||||||
|
|
||||||
|
覆盖计划 §6 的 API 级用例:
|
||||||
|
1. 缓冲多轮线程:一批落一次「已定的 refine+clarify 交换」(作者含糊→AI 反问→作者答→AI 定稿),
|
||||||
|
同 `thread_id`、`seq` 0..n;GET newest-first、`seq`/`role`/`thread_id`/`meta` 往复保真。
|
||||||
|
2. 作用域分区:章级线程(chapter_no=N) ∪ 项目级(chapter_no=null) 生成——GET `?chapter_no=N`
|
||||||
|
两者都返回;GET 缺省(项目级)只返回项目级。
|
||||||
|
3. newest-first + 分页(跨多批已提交)+ `kind` 过滤。
|
||||||
|
4. append-only / read-only:append 只写 `ai_messages`(其它业务表零变化、`usage_ledger` 恒 0
|
||||||
|
证明无网关);GET 只读不建行。
|
||||||
|
5. 未知项目 → 404;非法 role/kind、空 messages、超上限 content/meta → 422(失败零落库)。
|
||||||
|
6. clarify-abandon 的 API 级代理:服务端无部分写路径——缓冲在客户端,端点只持久化被显式
|
||||||
|
POST 的已定线程;从未 POST 的放弃线程在库里不留痕。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from asgi_lifespan import LifespanManager
|
||||||
|
from sqlalchemy import delete, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
from ww_db import get_sessionmaker
|
||||||
|
from ww_db.models import (
|
||||||
|
AiMessage,
|
||||||
|
Chapter,
|
||||||
|
ChapterReview,
|
||||||
|
Job,
|
||||||
|
Project,
|
||||||
|
UsageLedger,
|
||||||
|
WorldEntity,
|
||||||
|
)
|
||||||
|
|
||||||
|
# content 超上限探针 = 手稿上限 + 1(AI_MESSAGE_CONTENT_MAX = 200_000)。
|
||||||
|
_CONTENT_CAP = 200_000
|
||||||
|
# meta 序列化上界(_META_MAX_SERIALIZED = 50_000)——用超此长度的字符串塞 meta 触发 422。
|
||||||
|
_META_CAP = 50_000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def e2e_sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
|
||||||
|
"""真实 DB session 工厂;无 pg 时跳过(每测试清缓存重建 engine、结束 dispose)。"""
|
||||||
|
get_sessionmaker.cache_clear()
|
||||||
|
maker = get_sessionmaker()
|
||||||
|
try:
|
||||||
|
async with maker() as probe:
|
||||||
|
await probe.execute(select(1))
|
||||||
|
except Exception:
|
||||||
|
pytest.skip("postgres not reachable")
|
||||||
|
yield maker
|
||||||
|
await maker.kw["bind"].dispose()
|
||||||
|
get_sessionmaker.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
async def _count(sm: async_sessionmaker[AsyncSession], model: type, project_uuid: uuid.UUID) -> int:
|
||||||
|
async with sm() as s:
|
||||||
|
return int(
|
||||||
|
(
|
||||||
|
await s.execute(
|
||||||
|
select(func.count()).select_from(model).where(model.project_id == project_uuid) # type: ignore[attr-defined]
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _cleanup(sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> None:
|
||||||
|
"""按 FK 顺序清理(ai_messages 虽 CASCADE,显式删更清晰)。"""
|
||||||
|
async with sm() as cleanup:
|
||||||
|
await cleanup.execute(delete(AiMessage).where(AiMessage.project_id == project_uuid))
|
||||||
|
await cleanup.execute(delete(Project).where(Project.id == project_uuid))
|
||||||
|
await cleanup.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_project(client: httpx.AsyncClient, title: str) -> uuid.UUID:
|
||||||
|
resp = await client.post("/projects", json={"title": title, "genre": "玄幻"})
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return uuid.UUID(resp.json()["id"])
|
||||||
|
|
||||||
|
|
||||||
|
async def test_buffered_multi_turn_thread_persists_and_lists_newest_first(
|
||||||
|
e2e_sm: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
"""用例 1/4:一批落「已定的 refine+clarify 交换」→ GET newest-first,seq/role/meta 保真。"""
|
||||||
|
from ww_api.main import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
thread_id = uuid.uuid4()
|
||||||
|
original_segment = "他站在雨里,一动不动。"
|
||||||
|
options = [
|
||||||
|
{"label": "保留第一人称", "value": "keep_first"},
|
||||||
|
{"label": "改第三人称", "value": "third"},
|
||||||
|
]
|
||||||
|
project_uuid: uuid.UUID | None = None
|
||||||
|
try:
|
||||||
|
async with LifespanManager(app):
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
project_uuid = await _create_project(client, "AC-4 缓冲线程作品")
|
||||||
|
|
||||||
|
# 一批落库:作者(含糊)→AI(反问)→作者(答)→AI(定稿);选项/version_no/segment 进 meta。
|
||||||
|
resp = await client.post(
|
||||||
|
f"/projects/{project_uuid}/ai-messages",
|
||||||
|
json={
|
||||||
|
"thread_id": str(thread_id),
|
||||||
|
"chapter_no": 5,
|
||||||
|
"kind": "refine",
|
||||||
|
"messages": [
|
||||||
|
{"role": "author", "content": "帮我把这段润色得更有张力", "meta": {}},
|
||||||
|
{
|
||||||
|
"role": "ai",
|
||||||
|
"content": "你希望保留原文的第一人称视角吗?",
|
||||||
|
"meta": {
|
||||||
|
"parent_kind": "refine",
|
||||||
|
"options": options,
|
||||||
|
"allow_free_text": True,
|
||||||
|
"verification": "视角一致性",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"role": "author", "content": "保留第一人称", "meta": {}},
|
||||||
|
{
|
||||||
|
"role": "ai",
|
||||||
|
"content": "雨丝斜织,他钉在原地,任凭寒意浸透脊背。",
|
||||||
|
"meta": {"version_no": 1, "segment": original_segment},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
appended = resp.json()["messages"]
|
||||||
|
# 201 回显:4 条、seq 0..3(repo 赋值)、同 thread_id、同 chapter_no。
|
||||||
|
assert [m["seq"] for m in appended] == [0, 1, 2, 3]
|
||||||
|
assert {m["thread_id"] for m in appended} == {str(thread_id)}
|
||||||
|
assert {m["chapter_no"] for m in appended} == {5}
|
||||||
|
assert all(m["id"] for m in appended) # id/created_at 已回填供乐观对账。
|
||||||
|
|
||||||
|
# GET newest-first:同批共享 created_at → 靠 seq DESC 定序 → [3,2,1,0]。
|
||||||
|
listed = await client.get(f"/projects/{project_uuid}/ai-messages?chapter_no=5")
|
||||||
|
assert listed.status_code == 200
|
||||||
|
rows = listed.json()["messages"]
|
||||||
|
assert [r["seq"] for r in rows] == [3, 2, 1, 0]
|
||||||
|
assert [r["role"] for r in rows] == ["ai", "author", "ai", "author"]
|
||||||
|
assert {r["thread_id"] for r in rows} == {str(thread_id)}
|
||||||
|
# meta 往复保真:AI 反问的选项列表 + AI 定稿的 version_no/segment。
|
||||||
|
ai_question = next(r for r in rows if r["seq"] == 1)
|
||||||
|
assert ai_question["meta"]["options"] == options
|
||||||
|
assert ai_question["meta"]["allow_free_text"] is True
|
||||||
|
ai_final = next(r for r in rows if r["seq"] == 3)
|
||||||
|
assert ai_final["meta"]["version_no"] == 1
|
||||||
|
# segment 存于 AI 定稿行 → 抽屉「回填选段」可重锚(计划 §3.4/§4.5)。
|
||||||
|
assert ai_final["meta"]["segment"] == original_segment
|
||||||
|
# 抽屉 re-accept 直读全文 content(未截断)。
|
||||||
|
assert ai_final["content"] == "雨丝斜织,他钉在原地,任凭寒意浸透脊背。"
|
||||||
|
|
||||||
|
# DB 真源:恰 4 行。
|
||||||
|
assert project_uuid is not None
|
||||||
|
assert await _count(e2e_sm, AiMessage, project_uuid) == 4
|
||||||
|
finally:
|
||||||
|
if project_uuid is not None:
|
||||||
|
await _cleanup(e2e_sm, project_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_scope_partition_chapter_union_and_project_only(
|
||||||
|
e2e_sm: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
"""用例 2/6:章级 ∪ 项目级(NULL)——GET `?chapter_no=N` 两者都返回;缺省只返回项目级。"""
|
||||||
|
from ww_api.main import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
chapter_thread = uuid.uuid4()
|
||||||
|
project_thread = uuid.uuid4()
|
||||||
|
project_uuid: uuid.UUID | None = None
|
||||||
|
try:
|
||||||
|
async with LifespanManager(app):
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
project_uuid = await _create_project(client, "AC-4 作用域分区作品")
|
||||||
|
|
||||||
|
# A) 章级线程(rewrite,chapter_no=3)。
|
||||||
|
a = await client.post(
|
||||||
|
f"/projects/{project_uuid}/ai-messages",
|
||||||
|
json={
|
||||||
|
"thread_id": str(chapter_thread),
|
||||||
|
"chapter_no": 3,
|
||||||
|
"kind": "rewrite",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "author",
|
||||||
|
"content": "整章节奏太平",
|
||||||
|
"meta": {"version_no": 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "ai",
|
||||||
|
"content": "<重写后的整章正文>",
|
||||||
|
"meta": {"version_no": 1},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert a.status_code == 201, a.text
|
||||||
|
|
||||||
|
# B) 项目级工具箱生成(generator,chapter_no 缺省 = NULL)。
|
||||||
|
b = await client.post(
|
||||||
|
f"/projects/{project_uuid}/ai-messages",
|
||||||
|
json={
|
||||||
|
"thread_id": str(project_thread),
|
||||||
|
"kind": "generator",
|
||||||
|
"tool_key": "brainstorm",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "author",
|
||||||
|
"content": "想要三个突破常规的脑洞",
|
||||||
|
"meta": {
|
||||||
|
"tool_key": "brainstorm",
|
||||||
|
"input_fields": {"brief": "..."},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "ai",
|
||||||
|
"content": "1. 天降系统…\n2. 转生灵宠…\n3. 末世种田…",
|
||||||
|
"meta": {"tool_key": "brainstorm", "output_kind": "IdeaListResult"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert b.status_code == 201, b.text
|
||||||
|
assert all(m["chapter_no"] is None for m in b.json()["messages"])
|
||||||
|
|
||||||
|
# GET ?chapter_no=3 → 本章 ∪ 项目级 = A + B(4 行,两线程都在)。
|
||||||
|
union = (
|
||||||
|
await client.get(f"/projects/{project_uuid}/ai-messages?chapter_no=3")
|
||||||
|
).json()["messages"]
|
||||||
|
assert len(union) == 4
|
||||||
|
assert {r["thread_id"] for r in union} == {
|
||||||
|
str(chapter_thread),
|
||||||
|
str(project_thread),
|
||||||
|
}
|
||||||
|
assert {r["chapter_no"] for r in union} == {3, None}
|
||||||
|
|
||||||
|
# GET 缺省(无 chapter_no)→ 仅项目级 B(2 行,chapter_no 全 NULL)。
|
||||||
|
project_only = (await client.get(f"/projects/{project_uuid}/ai-messages")).json()[
|
||||||
|
"messages"
|
||||||
|
]
|
||||||
|
assert len(project_only) == 2
|
||||||
|
assert {r["thread_id"] for r in project_only} == {str(project_thread)}
|
||||||
|
assert all(r["chapter_no"] is None for r in project_only)
|
||||||
|
|
||||||
|
# GET 另一无对话的章 → union 仍带出项目级 B(项目级现于每章抽屉,计划 §6-(6))。
|
||||||
|
other_chapter = (
|
||||||
|
await client.get(f"/projects/{project_uuid}/ai-messages?chapter_no=99")
|
||||||
|
).json()["messages"]
|
||||||
|
assert {r["thread_id"] for r in other_chapter} == {str(project_thread)}
|
||||||
|
finally:
|
||||||
|
if project_uuid is not None:
|
||||||
|
await _cleanup(e2e_sm, project_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_newest_first_pagination_and_kind_filter_across_batches(
|
||||||
|
e2e_sm: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
"""用例 3:跨多批已提交 → newest-first + 分页 + `kind` 过滤。"""
|
||||||
|
from ww_api.main import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
continue_thread = uuid.uuid4()
|
||||||
|
refine_thread = uuid.uuid4()
|
||||||
|
project_uuid: uuid.UUID | None = None
|
||||||
|
try:
|
||||||
|
async with LifespanManager(app):
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
project_uuid = await _create_project(client, "AC-4 分页过滤作品")
|
||||||
|
|
||||||
|
async def _post(body: dict) -> None:
|
||||||
|
resp = await client.post(f"/projects/{project_uuid}/ai-messages", json=body)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
# 分批提交须 created_at 单调递增 → 睡极短保跨批定序确定(同批仍靠 seq)。
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
|
||||||
|
# 批1:续写候选0(ai-only,续写无作者输入)。
|
||||||
|
await _post(
|
||||||
|
{
|
||||||
|
"thread_id": str(continue_thread),
|
||||||
|
"chapter_no": 7,
|
||||||
|
"kind": "continue",
|
||||||
|
"tool_key": "continue",
|
||||||
|
"messages": [
|
||||||
|
{"role": "ai", "content": "候选一…", "meta": {"candidate_index": 0}}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# 批2:同线程「再来一个」候选1。
|
||||||
|
await _post(
|
||||||
|
{
|
||||||
|
"thread_id": str(continue_thread),
|
||||||
|
"chapter_no": 7,
|
||||||
|
"kind": "continue",
|
||||||
|
"tool_key": "continue",
|
||||||
|
"messages": [
|
||||||
|
{"role": "ai", "content": "候选二…", "meta": {"candidate_index": 1}}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# 批3:refine 两条(最新批)。
|
||||||
|
await _post(
|
||||||
|
{
|
||||||
|
"thread_id": str(refine_thread),
|
||||||
|
"chapter_no": 7,
|
||||||
|
"kind": "refine",
|
||||||
|
"messages": [
|
||||||
|
{"role": "author", "content": "再紧凑些", "meta": {}},
|
||||||
|
{
|
||||||
|
"role": "ai",
|
||||||
|
"content": "定稿…",
|
||||||
|
"meta": {"version_no": 1, "segment": "原段"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# newest-first:批3(created_at 最大) → 批2 → 批1;批内 seq DESC。
|
||||||
|
rows = (
|
||||||
|
await client.get(f"/projects/{project_uuid}/ai-messages?chapter_no=7")
|
||||||
|
).json()["messages"]
|
||||||
|
assert [r["kind"] for r in rows] == [
|
||||||
|
"refine", # 批3 seq1 (ai 定稿)
|
||||||
|
"refine", # 批3 seq0 (author)
|
||||||
|
"continue", # 批2
|
||||||
|
"continue", # 批1
|
||||||
|
]
|
||||||
|
assert [r["role"] for r in rows] == ["ai", "author", "ai", "ai"]
|
||||||
|
|
||||||
|
# 分页:limit=1 取最新一行;offset 前移。
|
||||||
|
page0 = (
|
||||||
|
await client.get(f"/projects/{project_uuid}/ai-messages?chapter_no=7&limit=1")
|
||||||
|
).json()["messages"]
|
||||||
|
assert len(page0) == 1 and page0[0]["content"] == "定稿…"
|
||||||
|
page1 = (
|
||||||
|
await client.get(
|
||||||
|
f"/projects/{project_uuid}/ai-messages?chapter_no=7&limit=1&offset=1"
|
||||||
|
)
|
||||||
|
).json()["messages"]
|
||||||
|
assert len(page1) == 1 and page1[0]["role"] == "author"
|
||||||
|
|
||||||
|
# kind 过滤:只取 continue(批2、批1),candidate_index newest-first [1,0]。
|
||||||
|
only_continue = (
|
||||||
|
await client.get(
|
||||||
|
f"/projects/{project_uuid}/ai-messages?chapter_no=7&kind=continue"
|
||||||
|
)
|
||||||
|
).json()["messages"]
|
||||||
|
assert [r["kind"] for r in only_continue] == ["continue", "continue"]
|
||||||
|
assert [r["meta"]["candidate_index"] for r in only_continue] == [1, 0]
|
||||||
|
finally:
|
||||||
|
if project_uuid is not None:
|
||||||
|
await _cleanup(e2e_sm, project_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_append_writes_only_ai_messages_and_get_is_read_only(
|
||||||
|
e2e_sm: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
"""用例 4:append 只写 ai_messages(其它业务表零变、usage_ledger 恒 0);GET 只读不建行。"""
|
||||||
|
from ww_api.main import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
project_uuid: uuid.UUID | None = None
|
||||||
|
try:
|
||||||
|
async with LifespanManager(app):
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
project_uuid = await _create_project(client, "AC-4 旁路留痕作品")
|
||||||
|
|
||||||
|
# 关键旁路表快照(append 前)。
|
||||||
|
side_models = [UsageLedger, Chapter, ChapterReview, WorldEntity, Job]
|
||||||
|
before = {m.__name__: await _count(e2e_sm, m, project_uuid) for m in side_models}
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/projects/{project_uuid}/ai-messages",
|
||||||
|
json={
|
||||||
|
"thread_id": str(uuid.uuid4()),
|
||||||
|
"kind": "generator",
|
||||||
|
"tool_key": "book-title",
|
||||||
|
"messages": [
|
||||||
|
{"role": "author", "content": "起个书名", "meta": {}},
|
||||||
|
{"role": "ai", "content": "《九霄剑主》", "meta": {}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
|
||||||
|
# 只写 ai_messages:其它业务表逐一零变化。
|
||||||
|
after = {m.__name__: await _count(e2e_sm, m, project_uuid) for m in side_models}
|
||||||
|
assert after == before, f"append touched a non-ai_messages table: {before}->{after}"
|
||||||
|
# 无网关/无 LLM:usage_ledger 恒 0(纯 CRUD 证据)。
|
||||||
|
assert await _count(e2e_sm, UsageLedger, project_uuid) == 0
|
||||||
|
assert await _count(e2e_sm, AiMessage, project_uuid) == 2
|
||||||
|
|
||||||
|
# GET 只读:读后 ai_messages 计数不变(不建行)。
|
||||||
|
assert (
|
||||||
|
await client.get(f"/projects/{project_uuid}/ai-messages")
|
||||||
|
).status_code == 200
|
||||||
|
assert await _count(e2e_sm, AiMessage, project_uuid) == 2
|
||||||
|
finally:
|
||||||
|
if project_uuid is not None:
|
||||||
|
await _cleanup(e2e_sm, project_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unknown_project_404_and_validation_422(
|
||||||
|
e2e_sm: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
"""用例 5:未知项目 → 404;各类非法请求(role/kind/空/超上限)→ 422,且零落库。"""
|
||||||
|
from ww_api.main import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
project_uuid: uuid.UUID | None = None
|
||||||
|
try:
|
||||||
|
async with LifespanManager(app):
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
# 未知项目:POST + GET 皆 404 NOT_FOUND 信封。
|
||||||
|
ghost = uuid.uuid4()
|
||||||
|
miss_post = await client.post(
|
||||||
|
f"/projects/{ghost}/ai-messages",
|
||||||
|
json={
|
||||||
|
"thread_id": str(uuid.uuid4()),
|
||||||
|
"kind": "refine",
|
||||||
|
"messages": [{"role": "author", "content": "x", "meta": {}}],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert miss_post.status_code == 404
|
||||||
|
assert miss_post.json()["error"]["code"] == "NOT_FOUND"
|
||||||
|
miss_get = await client.get(f"/projects/{ghost}/ai-messages")
|
||||||
|
assert miss_get.status_code == 404
|
||||||
|
assert miss_get.json()["error"]["code"] == "NOT_FOUND"
|
||||||
|
|
||||||
|
project_uuid = await _create_project(client, "AC-4 校验作品")
|
||||||
|
thread = str(uuid.uuid4())
|
||||||
|
base = {"thread_id": thread, "kind": "refine"}
|
||||||
|
|
||||||
|
# 各 422 非法体:非法 role / 非法 kind / 空 messages / 超上限 content / meta。
|
||||||
|
invalid_bodies = [
|
||||||
|
{**base, "messages": [{"role": "system", "content": "x"}]}, # 非法 role
|
||||||
|
{
|
||||||
|
**base,
|
||||||
|
"kind": "nonsense", # 非法 kind
|
||||||
|
"messages": [{"role": "author", "content": "x"}],
|
||||||
|
},
|
||||||
|
{**base, "messages": []}, # 空 messages
|
||||||
|
{
|
||||||
|
**base,
|
||||||
|
"messages": [{"role": "author", "content": "x" * (_CONTENT_CAP + 1)}],
|
||||||
|
}, # content 超上限
|
||||||
|
{
|
||||||
|
**base,
|
||||||
|
"messages": [
|
||||||
|
{"role": "author", "content": "x", "meta": {"blob": "y" * _META_CAP}}
|
||||||
|
],
|
||||||
|
}, # meta 序列化超上限
|
||||||
|
]
|
||||||
|
for body in invalid_bodies:
|
||||||
|
bad = await client.post(f"/projects/{project_uuid}/ai-messages", json=body)
|
||||||
|
assert bad.status_code == 422, (
|
||||||
|
f"expected 422 for {body!r}, got {bad.status_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 失败零落库:所有 422 都没有写入任何行。
|
||||||
|
assert project_uuid is not None
|
||||||
|
assert await _count(e2e_sm, AiMessage, project_uuid) == 0
|
||||||
|
finally:
|
||||||
|
if project_uuid is not None:
|
||||||
|
await _cleanup(e2e_sm, project_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_clarify_abandon_persists_only_explicit_posts(
|
||||||
|
e2e_sm: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
"""用例 6:clarify-abandon 代理——服务端无部分写路径,只持久化被显式 POST 的已定线程。"""
|
||||||
|
from ww_api.main import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
concluded_thread = uuid.uuid4()
|
||||||
|
project_uuid: uuid.UUID | None = None
|
||||||
|
try:
|
||||||
|
async with LifespanManager(app):
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
project_uuid = await _create_project(client, "AC-4 澄清放弃作品")
|
||||||
|
|
||||||
|
# 从未 POST 的(放弃的)线程 → 库里无痕:GET 空、计数 0。
|
||||||
|
# 缓冲是客户端的;纯放弃 = 不 POST,服务端没有任何部分写入路径。
|
||||||
|
empty = await client.get(f"/projects/{project_uuid}/ai-messages?chapter_no=8")
|
||||||
|
assert empty.status_code == 200
|
||||||
|
assert empty.json()["messages"] == []
|
||||||
|
assert await _count(e2e_sm, AiMessage, project_uuid) == 0
|
||||||
|
|
||||||
|
# 只有「已定」线程被显式 POST 才落库。
|
||||||
|
await client.post(
|
||||||
|
f"/projects/{project_uuid}/ai-messages",
|
||||||
|
json={
|
||||||
|
"thread_id": str(concluded_thread),
|
||||||
|
"chapter_no": 8,
|
||||||
|
"kind": "refine",
|
||||||
|
"messages": [
|
||||||
|
{"role": "author", "content": "润色这段", "meta": {}},
|
||||||
|
{
|
||||||
|
"role": "ai",
|
||||||
|
"content": "润色结果",
|
||||||
|
"meta": {"version_no": 1, "segment": "原段"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# GET 恰返回被显式 POST 的 2 行——无任何「缓冲/放弃」幻影行。
|
||||||
|
after = (
|
||||||
|
await client.get(f"/projects/{project_uuid}/ai-messages?chapter_no=8")
|
||||||
|
).json()["messages"]
|
||||||
|
assert len(after) == 2
|
||||||
|
assert {r["thread_id"] for r in after} == {str(concluded_thread)}
|
||||||
|
assert project_uuid is not None
|
||||||
|
assert await _count(e2e_sm, AiMessage, project_uuid) == 2
|
||||||
|
finally:
|
||||||
|
if project_uuid is not None:
|
||||||
|
await _cleanup(e2e_sm, project_uuid)
|
||||||
144
tests/test_ai_messages_not_in_generation_path.py
Normal file
144
tests/test_ai_messages_not_in_generation_path.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"""AC-4 / 计划 §8(#1/#6) 真源围栏守卫:`ai_messages` 绝不进生成链。
|
||||||
|
|
||||||
|
`ai_messages` 是 append-only 旁路侧记录(与 `usage_ledger` 同物种)——记录「作者↔AI 说了
|
||||||
|
什么」,**不是手稿真源**。守不变量 #1(DB 单一真源指手稿/审稿真源)/ #6(记忆注入是确定性
|
||||||
|
选择,非把聊天记录喂进 prompt):`assemble()` 与任何 memory/orchestrator 节点**永不读它**。
|
||||||
|
|
||||||
|
这是一道**能变红的**真守卫(非空断言):
|
||||||
|
- 若日后有人把 `AiMessageRepo`/`AiMessage` 接入 `packages/core/ww_core/{memory,orchestrator}`
|
||||||
|
下任一模块(import 或引用),`test_generation_path_never_references_ai_messages` 直接失败。
|
||||||
|
- 若有人把 ai_message repo 塞进 `assemble()` 消费的 `MemoryRepos` 捆绑,
|
||||||
|
`test_assemble_repo_bundle_excludes_ai_messages` 直接失败。
|
||||||
|
- `test_detector_flags_a_planted_violation` 用植入的违规源证明探测器确会命中——守卫非空。
|
||||||
|
|
||||||
|
纯静态分析(AST + 反射),无 DB / 无网络 / 无 LLM。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import dataclasses
|
||||||
|
import inspect
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import ww_core.memory.assemble as assemble_mod
|
||||||
|
from ww_core.domain.repositories import MemoryRepos
|
||||||
|
from ww_core.memory import assemble
|
||||||
|
|
||||||
|
# 生成链禁止出现的标识符(repo/ORM 模型/视图/行)。
|
||||||
|
_FORBIDDEN_NAMES = frozenset(
|
||||||
|
{
|
||||||
|
"AiMessage",
|
||||||
|
"AiMessageRepo",
|
||||||
|
"SqlAiMessageRepo",
|
||||||
|
"AiMessageView",
|
||||||
|
"AiTurnRow",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# 禁止 import 的模块子串(catch `from ww_core.domain.ai_message_repo import ...`)。
|
||||||
|
_FORBIDDEN_MODULE_SUBSTR = "ai_message"
|
||||||
|
|
||||||
|
# 受守卫的生成链目录(记忆装配 + 编排器;相对仓库根)。
|
||||||
|
_GUARDED_DIRS = (
|
||||||
|
"packages/core/ww_core/memory",
|
||||||
|
"packages/core/ww_core/orchestrator",
|
||||||
|
)
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _ai_message_refs(source: str, filename: str) -> list[str]:
|
||||||
|
"""返回 `source` 里对 ai_messages 生成链禁忌标识符的所有引用(AST 精确匹配)。
|
||||||
|
|
||||||
|
命中面(真实的「接入生成链」向量):
|
||||||
|
- `import ...ai_message...` / `from ...ai_message... import ...`(模块子串);
|
||||||
|
- `from X import AiMessageRepo`(导入名在禁忌集);
|
||||||
|
- 代码里任意 `AiMessageRepo` 名引用 / `x.AiMessageView` 属性访问(Name/Attribute)。
|
||||||
|
docstring/注释是 `ast.Constant`,不计入——避免「文档提到它」的假阳性。
|
||||||
|
"""
|
||||||
|
hits: list[str] = []
|
||||||
|
tree = ast.parse(source, filename=filename)
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.ImportFrom):
|
||||||
|
if node.module and _FORBIDDEN_MODULE_SUBSTR in node.module.lower():
|
||||||
|
hits.append(f"from {node.module} import ...")
|
||||||
|
for alias in node.names:
|
||||||
|
if alias.name in _FORBIDDEN_NAMES:
|
||||||
|
hits.append(f"from {node.module} import {alias.name}")
|
||||||
|
elif isinstance(node, ast.Import):
|
||||||
|
for alias in node.names:
|
||||||
|
if _FORBIDDEN_MODULE_SUBSTR in alias.name.lower():
|
||||||
|
hits.append(f"import {alias.name}")
|
||||||
|
elif isinstance(node, ast.Name) and node.id in _FORBIDDEN_NAMES:
|
||||||
|
hits.append(f"name {node.id}")
|
||||||
|
elif isinstance(node, ast.Attribute) and node.attr in _FORBIDDEN_NAMES:
|
||||||
|
hits.append(f"attr .{node.attr}")
|
||||||
|
return hits
|
||||||
|
|
||||||
|
|
||||||
|
def _guarded_py_files() -> list[Path]:
|
||||||
|
files: list[Path] = []
|
||||||
|
for rel in _GUARDED_DIRS:
|
||||||
|
d = _REPO_ROOT / rel
|
||||||
|
assert d.is_dir(), f"guarded dir missing (moved?): {d}"
|
||||||
|
files.extend(sorted(d.rglob("*.py")))
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def test_generation_path_never_references_ai_messages() -> None:
|
||||||
|
"""memory/orchestrator 下任一模块都不得 import/引用 ai_messages(否则真源蔓延)。"""
|
||||||
|
files = _guarded_py_files()
|
||||||
|
# 非空守卫:确有文件被扫(否则断言形同虚设)。
|
||||||
|
assert len(files) >= 20, f"expected the generation path to have modules; scanned {len(files)}"
|
||||||
|
|
||||||
|
violations: dict[str, list[str]] = {}
|
||||||
|
for path in files:
|
||||||
|
refs = _ai_message_refs(path.read_text(encoding="utf-8"), str(path))
|
||||||
|
if refs:
|
||||||
|
violations[str(path.relative_to(_REPO_ROOT))] = refs
|
||||||
|
|
||||||
|
assert not violations, (
|
||||||
|
"ai_messages leaked into the generation path (violates invariant #1/#6 — it is an "
|
||||||
|
f"append-only side-record, never a truth-source): {violations}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assemble_repo_bundle_excludes_ai_messages() -> None:
|
||||||
|
"""`assemble()` 消费的 `MemoryRepos` 捆绑不得含任何 ai_message repo 字段。"""
|
||||||
|
for field in dataclasses.fields(MemoryRepos):
|
||||||
|
assert "ai_message" not in field.name.lower(), (
|
||||||
|
f"MemoryRepos.{field.name} wires ai_messages into assemble() — forbidden (#1/#6)"
|
||||||
|
)
|
||||||
|
annotation = str(field.type)
|
||||||
|
assert "AiMessage" not in annotation, (
|
||||||
|
f"MemoryRepos.{field.name} type {annotation!r} references AiMessage — forbidden (#1/#6)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assemble_signature_only_takes_memory_repos_bundle() -> None:
|
||||||
|
"""`assemble` 的 repo 入参恒为 `MemoryRepos`——阻止旁路加一个 ai_message repo 形参。"""
|
||||||
|
# eval_str 求值 PEP 563 字符串化注解(assemble.py 有 `from __future__ import annotations`)。
|
||||||
|
sig = inspect.signature(assemble, eval_str=True)
|
||||||
|
repos_param = sig.parameters["repos"]
|
||||||
|
assert repos_param.annotation is MemoryRepos
|
||||||
|
# assemble 模块本身也不得引用禁忌标识符(双保险,直接扫其源)。
|
||||||
|
source = inspect.getsource(assemble_mod)
|
||||||
|
assert _ai_message_refs(source, "assemble.py") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_detector_flags_a_planted_violation() -> None:
|
||||||
|
"""证明守卫非空:植入的违规源确被探测器命中(否则上面的绿全是假绿)。"""
|
||||||
|
planted_import = "from ww_core.domain.ai_message_repo import AiMessageRepo\n"
|
||||||
|
assert _ai_message_refs(planted_import, "<planted>")
|
||||||
|
|
||||||
|
planted_usage = (
|
||||||
|
"from ww_core.domain import AiMessageRepo\n"
|
||||||
|
"async def assemble(repos, ai_log: AiMessageRepo):\n"
|
||||||
|
" rows = await ai_log.list_for_chapter(...)\n"
|
||||||
|
" return rows\n"
|
||||||
|
)
|
||||||
|
assert _ai_message_refs(planted_usage, "<planted>")
|
||||||
|
|
||||||
|
# 干净源不命中(避免误报——探测器有区分度)。
|
||||||
|
clean = "from ww_core.domain.repositories import MemoryRepos\nx = 1\n"
|
||||||
|
assert _ai_message_refs(clean, "<clean>") == []
|
||||||
Reference in New Issue
Block a user