Files
writer-work-flow/tests/test_ai_chat_history_e2e.py
Yaojia Wang f6e88d7438 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)。
2026-07-09 17:42:45 +02:00

532 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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..nGET 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-onlyappend 只写 `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 超上限探针 = 手稿上限 + 1AI_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-firstseq/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..3repo 赋值)、同 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) 章级线程rewritechapter_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) 项目级工具箱生成generatorchapter_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 + B4 行,两线程都在)。
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→ 仅项目级 B2 行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续写候选0ai-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}}
],
}
)
# 批3refine 两条(最新批)。
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、批1candidate_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:
"""用例 4append 只写 ai_messages其它业务表零变、usage_ledger 恒 0GET 只读不建行。"""
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}"
# 无网关/无 LLMusage_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:
"""用例 6clarify-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)