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:
293
packages/core/tests/test_ai_message_repo.py
Normal file
293
packages/core/tests/test_ai_message_repo.py
Normal 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 好的 project(owner 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 + Assert:limit=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:
|
||||
# Arrange:append 只 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()
|
||||
@@ -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",
|
||||
|
||||
160
packages/core/ww_core/domain/ai_message_repo.py
Normal file
160
packages/core/ww_core/domain/ai_message_repo.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""作者↔AI 往复聊天记录 Repository(append-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_case,frozen)——镜像端点出参字段。"""
|
||||
|
||||
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]:
|
||||
"""一批消息 append(seq = 批内 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-first(created_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,避免原地 mutate(JSONB 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]
|
||||
Reference in New Issue
Block a user