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