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,293 @@
"""AC-2 `SqlAiMessageRepo` 单测(真 pg——seq/定序是 SQL 语义fake 无法验证)。
docs/design/ai-chat-history-plan.md §3.1 / §6。验证 append-only 侧表的两操作:
- `append`:一批多条 bubble → 批内 `seq` = 0..n-1、同 `thread_id`、meta round-trip、
项目级 `chapter_no=None` 持久化;只 flush提交交调用方
- `list_for_chapter``(created_at DESC, seq DESC)` newest-first + 分页;`chapter_no`
非 None → 本章 项目级(NULL)None → 仅项目级;可选 `kind` 过滤。
关键 gotcha本测据此设计Postgres `now()` 是事务起始恒定时刻——同一事务里两批共享
`created_at`。故要拿到「跨批 created_at 不同」必须**每批各自 commit**(模拟每次 POST 独立
事务),批内再靠 `seq` 定序。无 pg → skip。
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime
import pytest
from pydantic import ValidationError
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain.ai_message_repo import (
AiMessageRepo,
AiMessageView,
AiTurnRow,
SqlAiMessageRepo,
)
from ww_db import get_sessionmaker
from ww_db.models import AiMessage, Project, User
@pytest.fixture
async def seeded() -> AsyncIterator[tuple[AsyncSession, uuid.UUID]]:
"""真 pg session + 一个 seed 好的 projectowner user。无 pg → skip结束清理。"""
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")
owner_id = uuid.uuid4()
project_id = uuid.uuid4()
async with maker() as setup:
setup.add(User(id=owner_id, email=f"ac2-{owner_id}@local", display_name="ac2"))
await setup.flush() # user 先落库,满足 project.owner_id FK。
setup.add(Project(id=project_id, owner_id=owner_id, title="AC-2 测试作品"))
await setup.commit()
session = maker()
try:
yield session, project_id
finally:
await session.close()
async with maker() as cleanup:
await cleanup.execute(delete(AiMessage).where(AiMessage.project_id == project_id))
await cleanup.execute(delete(Project).where(Project.id == project_id))
await cleanup.execute(delete(User).where(User.id == owner_id))
await cleanup.commit()
await maker.kw["bind"].dispose()
get_sessionmaker.cache_clear()
async def test_append_assigns_seq_0_based_and_shares_thread_id(
seeded: tuple[AsyncSession, uuid.UUID],
) -> None:
# Arrange
session, project_id = seeded
repo: AiMessageRepo = SqlAiMessageRepo(session)
thread_id = uuid.uuid4()
# Act一次交换的 3 条 bubble 作为一批 append。
views = await repo.append(
project_id,
thread_id=thread_id,
chapter_no=5,
kind="refine",
tool_key=None,
rows=[
AiTurnRow(role="author", content="原选段"),
AiTurnRow(role="ai", content="重写段", meta={"version_no": 1, "segment": "原选段"}),
AiTurnRow(role="author", content="再改一版"),
],
)
# Assert批内 seq = 0..n-1同 thread_id字段/meta round-trip有 id/created_at。
assert [v.seq for v in views] == [0, 1, 2]
assert {v.thread_id for v in views} == {thread_id}
assert [v.role for v in views] == ["author", "ai", "author"]
assert [v.content for v in views] == ["原选段", "重写段", "再改一版"]
assert views[1].meta == {"version_no": 1, "segment": "原选段"}
assert all(v.chapter_no == 5 and v.kind == "refine" for v in views)
assert all(isinstance(v.id, uuid.UUID) and v.created_at is not None for v in views)
async def test_append_project_level_chapter_no_none_persists(
seeded: tuple[AsyncSession, uuid.UUID],
) -> None:
# Arrange
session, project_id = seeded
repo = SqlAiMessageRepo(session)
# Act工具箱项目级生成chapter_no=None
views = await repo.append(
project_id,
thread_id=uuid.uuid4(),
chapter_no=None,
kind="generator",
tool_key="expand",
rows=[AiTurnRow(role="ai", content="扩写预览", meta={"tool_key": "expand"})],
)
# Assert
assert views[0].chapter_no is None
assert views[0].tool_key == "expand"
async def test_list_newest_first_created_at_desc_then_seq_desc(
seeded: tuple[AsyncSession, uuid.UUID],
) -> None:
# Arrange两批各自 commit模拟两次 POST 独立事务)→ created_at 不同。
session, project_id = seeded
repo = SqlAiMessageRepo(session)
await repo.append(
project_id,
thread_id=uuid.uuid4(),
chapter_no=1,
kind="rewrite",
tool_key=None,
rows=[AiTurnRow(role="author", content="A0"), AiTurnRow(role="ai", content="A1")],
)
await session.commit()
await repo.append(
project_id,
thread_id=uuid.uuid4(),
chapter_no=1,
kind="rewrite",
tool_key=None,
rows=[AiTurnRow(role="author", content="B0"), AiTurnRow(role="ai", content="B1")],
)
await session.commit()
# Act
rows = await repo.list_for_chapter(project_id, 1, limit=50, offset=0)
# Assert新批B整体先于旧批A批内 seq DESC。
assert [r.content for r in rows] == ["B1", "B0", "A1", "A0"]
async def test_list_chapter_union_includes_project_level(
seeded: tuple[AsyncSession, uuid.UUID],
) -> None:
# Arrange第 5 章 + 项目级(NULL) + 第 6 章 各一批。
session, project_id = seeded
repo = SqlAiMessageRepo(session)
await _append(repo, session, project_id, chapter_no=5, kind="refine", content="ch5")
await _append(repo, session, project_id, chapter_no=None, kind="generator", content="proj")
await _append(repo, session, project_id, chapter_no=6, kind="refine", content="ch6")
# Act看第 5 章抽屉 = 本章 项目级,绝不含第 6 章。
rows = await repo.list_for_chapter(project_id, 5, limit=50, offset=0)
# Assert
contents = {r.content for r in rows}
assert contents == {"ch5", "proj"}
assert "ch6" not in contents
async def test_list_project_only_when_chapter_no_none(
seeded: tuple[AsyncSession, uuid.UUID],
) -> None:
# Arrange
session, project_id = seeded
repo = SqlAiMessageRepo(session)
await _append(repo, session, project_id, chapter_no=5, kind="refine", content="ch5")
await _append(repo, session, project_id, chapter_no=None, kind="generator", content="proj")
# Act工具箱页chapter_no=None只看项目级。
rows = await repo.list_for_chapter(project_id, None, limit=50, offset=0)
# Assert
assert [r.content for r in rows] == ["proj"]
async def test_list_kind_filter(seeded: tuple[AsyncSession, uuid.UUID]) -> None:
# Arrange
session, project_id = seeded
repo = SqlAiMessageRepo(session)
await _append(repo, session, project_id, chapter_no=2, kind="refine", content="r")
await _append(repo, session, project_id, chapter_no=2, kind="rewrite", content="w")
# Act
rows = await repo.list_for_chapter(project_id, 2, kind="refine", limit=50, offset=0)
# Assert
assert [r.kind for r in rows] == ["refine"]
assert [r.content for r in rows] == ["r"]
async def test_list_pagination_limit_offset(
seeded: tuple[AsyncSession, uuid.UUID],
) -> None:
# Arrange三批各一条newest-first = c,b,a
session, project_id = seeded
repo = SqlAiMessageRepo(session)
await _append(repo, session, project_id, chapter_no=3, kind="continue", content="a")
await _append(repo, session, project_id, chapter_no=3, kind="continue", content="b")
await _append(repo, session, project_id, chapter_no=3, kind="continue", content="c")
# Act + Assertlimit=1 取最新offset=1 取第二新。
first = await repo.list_for_chapter(project_id, 3, limit=1, offset=0)
second = await repo.list_for_chapter(project_id, 3, limit=1, offset=1)
assert [r.content for r in first] == ["c"]
assert [r.content for r in second] == ["b"]
async def test_append_only_flushes_not_commits(
seeded: tuple[AsyncSession, uuid.UUID],
) -> None:
# Arrangeappend 只 flush另一独立 session 在本 session commit 前看不到行。
session, project_id = seeded
repo = SqlAiMessageRepo(session)
thread_id = uuid.uuid4()
await repo.append(
project_id,
thread_id=thread_id,
chapter_no=9,
kind="refine",
tool_key=None,
rows=[AiTurnRow(role="ai", content="uncommitted")],
)
# Act + Assert未提交 → 别的连接查不到。
maker = get_sessionmaker()
async with maker() as other:
before = (
(await other.execute(select(AiMessage).where(AiMessage.thread_id == thread_id)))
.scalars()
.all()
)
assert before == []
# 提交后可见。
await session.commit()
async with maker() as other:
after = (
(await other.execute(select(AiMessage).where(AiMessage.thread_id == thread_id)))
.scalars()
.all()
)
assert len(after) == 1
def test_view_is_frozen() -> None:
view = AiMessageView(
id=uuid.uuid4(),
project_id=uuid.uuid4(),
chapter_no=None,
thread_id=uuid.uuid4(),
seq=0,
kind="refine",
tool_key=None,
role="ai",
content="x",
meta={},
created_at=datetime.now(UTC),
)
with pytest.raises(ValidationError):
view.content = "y"
async def _append(
repo: SqlAiMessageRepo,
session: AsyncSession,
project_id: uuid.UUID,
*,
chapter_no: int | None,
kind: str,
content: str,
) -> None:
"""一条 ai bubble 一批 + commit各批独立事务 → created_at 递增)。"""
await repo.append(
project_id,
thread_id=uuid.uuid4(),
chapter_no=chapter_no,
kind=kind,
tool_key=None,
rows=[AiTurnRow(role="ai", content=content)],
)
await session.commit()

View File

@@ -2,6 +2,12 @@
from __future__ import annotations
from ww_core.domain.ai_message_repo import (
AiMessageRepo,
AiMessageView,
AiTurnRow,
SqlAiMessageRepo,
)
from ww_core.domain.chapter_repo import (
ChapterDraftView,
ChapterRepo,
@@ -80,6 +86,10 @@ from ww_core.domain.world_entity_repo import (
)
__all__ = [
"AiMessageRepo",
"AiMessageView",
"AiTurnRow",
"SqlAiMessageRepo",
"ChapterDraftView",
"ChapterRepo",
"ChapterView",

View File

@@ -0,0 +1,160 @@
"""作者↔AI 往复聊天记录 Repositoryappend-only 侧表AC-2 / ai-chat-history-plan §3.1)。
`ai_messages` 是「说了什么」的真源,**非手稿真源**assemble()/agent 永不读它(守 #1/#3/#6
两操作:
- `append`:一次交换的多条 bubble 作为**一批**落库,批内 `seq` = enumerate 0 基位置、同
`thread_id`;只 `flush()`提交交端点事务。Postgres `now()` 事务起始恒定 → 同批共享
`created_at`,靠 `seq` 批内定序;跨批 `created_at` 不同自然定序。
- `list_for_chapter`:按 project(+可选 chapter_no) 过滤,`(created_at DESC, seq DESC)`
newest-first + `limit/offset` 分页。`chapter_no` 非 None → 本章 项目级(NULL)None →
仅项目级。可选 `kind` 过滤。
视图 frozen、snake_case、与 ORM 解耦(路由不碰 SQLAlchemy 行。JSONB `meta` 写入建**新
dict**(不原地 mutate见 memory/gotchas
"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any, Protocol
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import AiMessage
class AiTurnRow(BaseModel):
"""一批中的一条消息(批级字段 thread_id/chapter_no/kind/tool_key 作 `append` 形参)。"""
role: str
content: str
meta: dict[str, Any] = Field(default_factory=dict)
class AiMessageView(BaseModel):
"""聊天留痕只读快照snake_casefrozen——镜像端点出参字段。"""
model_config = {"frozen": True}
id: uuid.UUID
project_id: uuid.UUID
chapter_no: int | None
thread_id: uuid.UUID
seq: int
kind: str
tool_key: str | None
role: str
content: str
meta: dict[str, Any]
created_at: datetime
class AiMessageRepo(Protocol):
"""聊天留痕读写接口append 批 + newest-first 列表append 只 flush端点提交"""
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]:
"""一批消息 appendseq = 批内 0 基位置;同 thread_id。只 flush 不 commit。"""
...
async def list_for_chapter(
self,
project_id: uuid.UUID,
chapter_no: int | None,
*,
kind: str | None = None,
limit: int,
offset: int,
) -> list[AiMessageView]:
"""按 project(+chapter_no) 过滤newest-firstcreated_at DESC, seq DESC分页。"""
...
def _to_view(row: AiMessage) -> AiMessageView:
return AiMessageView(
id=row.id,
project_id=row.project_id,
chapter_no=row.chapter_no,
thread_id=row.thread_id,
seq=row.seq,
kind=row.kind,
tool_key=row.tool_key,
role=row.role,
content=row.content,
meta=dict(row.meta or {}),
created_at=row.created_at,
)
class SqlAiMessageRepo:
"""SQLAlchemy 实现:批 append + newest-first 列表(只 flush 不 commit"""
def __init__(self, session: AsyncSession) -> None:
self._s = session
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]:
orm_rows = [
AiMessage(
project_id=project_id,
chapter_no=chapter_no,
thread_id=thread_id,
seq=i, # 批内 0 基位置:同批共享 created_at靠 seq 稳定定序。
kind=kind,
tool_key=tool_key,
role=row.role,
content=row.content,
meta=dict(row.meta), # 新 dict避免原地 mutateJSONB gotcha
)
for i, row in enumerate(rows)
]
self._s.add_all(orm_rows)
await self._s.flush()
for row_ in orm_rows:
await self._s.refresh(row_) # 回填 id / created_at。
return [_to_view(r) for r in orm_rows]
async def list_for_chapter(
self,
project_id: uuid.UUID,
chapter_no: int | None,
*,
kind: str | None = None,
limit: int,
offset: int,
) -> list[AiMessageView]:
stmt = select(AiMessage).where(AiMessage.project_id == project_id)
if chapter_no is not None:
# 本章 项目级(NULL)——正是抽屉 union。
stmt = stmt.where(
(AiMessage.chapter_no == chapter_no) | (AiMessage.chapter_no.is_(None))
)
else:
stmt = stmt.where(AiMessage.chapter_no.is_(None)) # 工具箱页仅项目级。
if kind is not None:
stmt = stmt.where(AiMessage.kind == kind)
stmt = (
stmt.order_by(AiMessage.created_at.desc(), AiMessage.seq.desc())
.limit(limit)
.offset(offset)
)
rows = (await self._s.execute(stmt)).scalars().all()
return [_to_view(r) for r in rows]