feat(backend): AI 对话聊天记录 repo + append/list 端点(AC-2)

在 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%。
This commit is contained in:
Yaojia Wang
2026-07-09 16:54:52 +02:00
parent 1a188e6e5b
commit c675a74f1d
8 changed files with 989 additions and 0 deletions

View File

@@ -0,0 +1,325 @@
"""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