计划 §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)。
532 lines
25 KiB
Python
532 lines
25 KiB
Python
"""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)
|