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

@@ -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]