在 C2 扩(AC-1) `ai_messages` 表上落 AC-2 后端(计划 §3):
- repo `SqlAiMessageRepo`:批 append(seq=批内 0 基位置、同 thread_id、只 flush)
+ `list_for_chapter` newest-first(created_at DESC, seq DESC)、chapter_no 过滤含
项目级 NULL union、可选 kind、limit/offset。
- schemas `ai_messages.py`:批级 append 信封 + Literal 枚举权威(kind/role)+ content
200k 上限 + meta 序列化兜底。
- 端点 `POST/GET /projects/{id}/ai-messages`(tag ai-messages,已注册):显式记录写入,
五个生成端点保持只读不变(守 #3);项目 404、校验 422、POST 端点提交。
- 注入缝 `get_ai_message_repo`。
TDD:repo 真 pg 单测(seq/定序/union/分页/flush-only)9 passed;端点集成测试
(round-trip/union/kind/404/422×5/commit 边界)12 passed。门禁全绿:ruff/format/mypy
238 files/alembic check 无漂移/pytest 992 passed;新模块 cov router+schemas 100% / repo 97%。
326 lines
12 KiB
Python
326 lines
12 KiB
Python
"""AC-2 POST/GET /projects/{id}/ai-messages 端点(内存替身,无 DB/无网络)。
|
||
|
||
docs/design/ai-chat-history-plan.md §3.3 / §6。覆盖:
|
||
- POST 201 + 回显持久化视图(seq/id/created_at)+ 端点 commit 边界;
|
||
- append→list 回环;项目级(chapter_no null) vs 本章 union;
|
||
- 校验 → 422(非法 role、超上限 content、空 messages、超大 meta);
|
||
- 未知项目 → 404;响应 key 集合不泄漏意外字段。
|
||
|
||
owner_id 全程 stub(单用户原型)。AI 一律不触达(纯 CRUD)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import uuid
|
||
from datetime import UTC, datetime, timedelta
|
||
|
||
import httpx
|
||
import pytest
|
||
from cryptography.fernet import Fernet
|
||
from fakes_projects import FakeProjectRepo, FakeSession
|
||
from ww_core.domain.ai_message_repo import AiMessageView, AiTurnRow
|
||
from ww_core.domain.project_repo import ProjectView
|
||
|
||
# 期望的响应 bubble 字段集合(防泄漏意外字段)。
|
||
_EXPECTED_KEYS = {
|
||
"id",
|
||
"project_id",
|
||
"chapter_no",
|
||
"thread_id",
|
||
"seq",
|
||
"kind",
|
||
"tool_key",
|
||
"role",
|
||
"content",
|
||
"meta",
|
||
"created_at",
|
||
}
|
||
|
||
|
||
class _FakeAiMessageRepo:
|
||
"""镜像 `SqlAiMessageRepo` 语义的内存版(批内 seq、newest-first union)。"""
|
||
|
||
def __init__(self) -> None:
|
||
self.rows: list[AiMessageView] = []
|
||
self._clock = 0
|
||
|
||
async def append(
|
||
self,
|
||
project_id: uuid.UUID,
|
||
*,
|
||
thread_id: uuid.UUID,
|
||
chapter_no: int | None,
|
||
kind: str,
|
||
tool_key: str | None,
|
||
rows: list[AiTurnRow],
|
||
) -> list[AiMessageView]:
|
||
self._clock += 1
|
||
created = datetime(2026, 7, 9, tzinfo=UTC) + timedelta(seconds=self._clock)
|
||
out: list[AiMessageView] = []
|
||
for i, r in enumerate(rows):
|
||
view = AiMessageView(
|
||
id=uuid.uuid4(),
|
||
project_id=project_id,
|
||
chapter_no=chapter_no,
|
||
thread_id=thread_id,
|
||
seq=i,
|
||
kind=kind,
|
||
tool_key=tool_key,
|
||
role=r.role,
|
||
content=r.content,
|
||
meta=dict(r.meta),
|
||
created_at=created,
|
||
)
|
||
self.rows.append(view)
|
||
out.append(view)
|
||
return out
|
||
|
||
async def list_for_chapter(
|
||
self,
|
||
project_id: uuid.UUID,
|
||
chapter_no: int | None,
|
||
*,
|
||
kind: str | None = None,
|
||
limit: int,
|
||
offset: int,
|
||
) -> list[AiMessageView]:
|
||
matched = [r for r in self.rows if r.project_id == project_id]
|
||
if chapter_no is not None:
|
||
matched = [r for r in matched if r.chapter_no in (chapter_no, None)]
|
||
else:
|
||
matched = [r for r in matched if r.chapter_no is None]
|
||
if kind is not None:
|
||
matched = [r for r in matched if r.kind == kind]
|
||
matched.sort(key=lambda r: (r.created_at, r.seq), reverse=True)
|
||
return matched[offset : offset + limit]
|
||
|
||
|
||
def _make_client() -> tuple[httpx.AsyncClient, _FakeAiMessageRepo, FakeProjectRepo, FakeSession]:
|
||
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||
from ww_api.main import create_app
|
||
from ww_api.services.credentials import STUB_OWNER_ID
|
||
from ww_api.services.project_deps import get_ai_message_repo, get_project_repo
|
||
from ww_db import get_session
|
||
|
||
repo = _FakeAiMessageRepo()
|
||
project_repo = FakeProjectRepo()
|
||
session = FakeSession()
|
||
|
||
# 一个已存在的项目(owner = stub),供 404 对照——直接种入 fake 的行表(避开事件循环)。
|
||
project_id = uuid.uuid4()
|
||
project_repo.rows[project_id] = (STUB_OWNER_ID, ProjectView(id=project_id, title="AC-2"))
|
||
|
||
app = create_app()
|
||
app.dependency_overrides[get_ai_message_repo] = lambda: repo
|
||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||
app.dependency_overrides[get_session] = lambda: session
|
||
transport = httpx.ASGITransport(app=app)
|
||
client = httpx.AsyncClient(transport=transport, base_url="http://test")
|
||
client.project_id = project_id # type: ignore[attr-defined]
|
||
return client, repo, project_repo, session
|
||
|
||
|
||
def _refine_batch(chapter_no: int | None = 5) -> dict[str, object]:
|
||
return {
|
||
"thread_id": str(uuid.uuid4()),
|
||
"chapter_no": chapter_no,
|
||
"kind": "refine",
|
||
"messages": [
|
||
{"role": "author", "content": "原选段"},
|
||
{"role": "ai", "content": "重写段", "meta": {"version_no": 1, "segment": "原选段"}},
|
||
],
|
||
}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_append_returns_201_persisted_views_and_commits() -> None:
|
||
client, repo, _pr, session = _make_client()
|
||
pid = client.project_id # type: ignore[attr-defined]
|
||
async with client:
|
||
resp = await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(5))
|
||
assert resp.status_code == 201, resp.text
|
||
body = resp.json()
|
||
msgs = body["messages"]
|
||
assert [m["seq"] for m in msgs] == [0, 1]
|
||
assert [m["role"] for m in msgs] == ["author", "ai"]
|
||
assert msgs[1]["meta"] == {"version_no": 1, "segment": "原选段"}
|
||
assert all(set(m.keys()) == _EXPECTED_KEYS for m in msgs)
|
||
assert all(m["id"] and m["created_at"] for m in msgs)
|
||
assert session.commits == 1
|
||
assert len(repo.rows) == 2
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_append_then_list_roundtrip() -> None:
|
||
client, _repo, _pr, _session = _make_client()
|
||
pid = client.project_id # type: ignore[attr-defined]
|
||
async with client:
|
||
await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(5))
|
||
resp = await client.get(f"/projects/{pid}/ai-messages", params={"chapter_no": 5})
|
||
assert resp.status_code == 200
|
||
msgs = resp.json()["messages"]
|
||
# newest-first:批内 seq DESC → ai(seq1) 先于 author(seq0)。
|
||
assert [m["content"] for m in msgs] == ["重写段", "原选段"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_chapter_union_includes_project_level() -> None:
|
||
client, _repo, _pr, _session = _make_client()
|
||
pid = client.project_id # type: ignore[attr-defined]
|
||
async with client:
|
||
await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(5))
|
||
# 项目级工具箱生成(chapter_no 省略 → NULL)。
|
||
await client.post(
|
||
f"/projects/{pid}/ai-messages",
|
||
json={
|
||
"thread_id": str(uuid.uuid4()),
|
||
"kind": "generator",
|
||
"tool_key": "expand",
|
||
"messages": [{"role": "ai", "content": "扩写预览"}],
|
||
},
|
||
)
|
||
chapter_view = await client.get(f"/projects/{pid}/ai-messages", params={"chapter_no": 5})
|
||
toolbox_view = await client.get(f"/projects/{pid}/ai-messages")
|
||
chapter_contents = {m["content"] for m in chapter_view.json()["messages"]}
|
||
toolbox_contents = {m["content"] for m in toolbox_view.json()["messages"]}
|
||
assert chapter_contents == {"原选段", "重写段", "扩写预览"} # 本章 ∪ 项目级
|
||
assert toolbox_contents == {"扩写预览"} # 仅项目级
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_kind_filter() -> None:
|
||
client, _repo, _pr, _session = _make_client()
|
||
pid = client.project_id # type: ignore[attr-defined]
|
||
async with client:
|
||
await client.post(f"/projects/{pid}/ai-messages", json=_refine_batch(2))
|
||
await client.post(
|
||
f"/projects/{pid}/ai-messages",
|
||
json={
|
||
"thread_id": str(uuid.uuid4()),
|
||
"chapter_no": 2,
|
||
"kind": "rewrite",
|
||
"messages": [{"role": "ai", "content": "整章新版"}],
|
||
},
|
||
)
|
||
resp = await client.get(
|
||
f"/projects/{pid}/ai-messages", params={"chapter_no": 2, "kind": "rewrite"}
|
||
)
|
||
msgs = resp.json()["messages"]
|
||
assert [m["kind"] for m in msgs] == ["rewrite"]
|
||
assert [m["content"] for m in msgs] == ["整章新版"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_append_unknown_project_returns_404() -> None:
|
||
client, repo, _pr, session = _make_client()
|
||
async with client:
|
||
resp = await client.post(f"/projects/{uuid.uuid4()}/ai-messages", json=_refine_batch(5))
|
||
assert resp.status_code == 404
|
||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||
assert session.commits == 0
|
||
assert repo.rows == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_unknown_project_returns_404() -> None:
|
||
client, _repo, _pr, _session = _make_client()
|
||
async with client:
|
||
resp = await client.get(f"/projects/{uuid.uuid4()}/ai-messages", params={"chapter_no": 1})
|
||
assert resp.status_code == 404
|
||
assert resp.json()["error"]["code"] == "NOT_FOUND"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_append_invalid_role_returns_422() -> None:
|
||
client, _repo, _pr, session = _make_client()
|
||
pid = client.project_id # type: ignore[attr-defined]
|
||
async with client:
|
||
resp = await client.post(
|
||
f"/projects/{pid}/ai-messages",
|
||
json={
|
||
"thread_id": str(uuid.uuid4()),
|
||
"chapter_no": 5,
|
||
"kind": "refine",
|
||
"messages": [{"role": "system", "content": "x"}],
|
||
},
|
||
)
|
||
assert resp.status_code == 422
|
||
assert session.commits == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_append_invalid_kind_returns_422() -> None:
|
||
client, _repo, _pr, _session = _make_client()
|
||
pid = client.project_id # type: ignore[attr-defined]
|
||
async with client:
|
||
resp = await client.post(
|
||
f"/projects/{pid}/ai-messages",
|
||
json={
|
||
"thread_id": str(uuid.uuid4()),
|
||
"kind": "translate", # 非五类枚举 → 422
|
||
"messages": [{"role": "ai", "content": "x"}],
|
||
},
|
||
)
|
||
assert resp.status_code == 422
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_append_empty_messages_returns_422() -> None:
|
||
client, _repo, _pr, _session = _make_client()
|
||
pid = client.project_id # type: ignore[attr-defined]
|
||
async with client:
|
||
resp = await client.post(
|
||
f"/projects/{pid}/ai-messages",
|
||
json={"thread_id": str(uuid.uuid4()), "kind": "refine", "messages": []},
|
||
)
|
||
assert resp.status_code == 422
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_append_empty_content_returns_422() -> None:
|
||
client, _repo, _pr, _session = _make_client()
|
||
pid = client.project_id # type: ignore[attr-defined]
|
||
async with client:
|
||
resp = await client.post(
|
||
f"/projects/{pid}/ai-messages",
|
||
json={
|
||
"thread_id": str(uuid.uuid4()),
|
||
"kind": "refine",
|
||
"messages": [{"role": "ai", "content": ""}],
|
||
},
|
||
)
|
||
assert resp.status_code == 422
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_append_content_over_cap_returns_422() -> None:
|
||
client, _repo, _pr, _session = _make_client()
|
||
pid = client.project_id # type: ignore[attr-defined]
|
||
async with client:
|
||
resp = await client.post(
|
||
f"/projects/{pid}/ai-messages",
|
||
json={
|
||
"thread_id": str(uuid.uuid4()),
|
||
"kind": "rewrite",
|
||
"messages": [{"role": "ai", "content": "x" * 200_001}],
|
||
},
|
||
)
|
||
assert resp.status_code == 422
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_append_oversize_meta_returns_422() -> None:
|
||
client, _repo, _pr, _session = _make_client()
|
||
pid = client.project_id # type: ignore[attr-defined]
|
||
async with client:
|
||
resp = await client.post(
|
||
f"/projects/{pid}/ai-messages",
|
||
json={
|
||
"thread_id": str(uuid.uuid4()),
|
||
"kind": "refine",
|
||
"messages": [{"role": "ai", "content": "x", "meta": {"blob": "y" * 60_000}}],
|
||
},
|
||
)
|
||
assert resp.status_code == 422
|