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,114 @@
"""AI 对话聊天记录端点AC-2 / ai-chat-history-plan §3.3)。
作者↔AI 五类往复交换refine/rewrite/clarify/continue/generator落库供每章「AI 对话」
抽屉重放。**本端点是显式的记录写入**(前端在一轮交换结束后 POST 一批)——不是生成端点;
五个生成端点保持只读、逐字节不变(守不变量 #3。`ai_messages` 是侧记录assemble()/agent
永不读它(守 #1/#6
- POST /projects/{id}/ai-messages 一批 append201项目不存在 → 404
- GET /projects/{id}/ai-messages 列出newest-first`chapter_no` 过滤含项目级 NULL分页
提交边界:`AiMessageRepo.append` 只 flushPOST 端点写后 `commit()`(仿模板/规则写侧)。
GET 只读不 commit。
"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain import AiMessageRepo, AiTurnRow, ProjectRepo
from ww_core.domain.ai_message_repo import AiMessageView as AiMessageRepoView
from ww_db import get_session
from ww_shared import AppError, ErrorCode
from ww_api.logging_config import get_logger
from ww_api.pagination import PageDep
from ww_api.schemas.ai_messages import (
AiKind,
AiMessageAppendRequest,
AiMessageListResponse,
AiMessageView,
)
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.project_deps import get_ai_message_repo, get_project_repo
log = get_logger("ww.api.ai_messages")
router = APIRouter(prefix="/projects", tags=["ai-messages"])
AiMessageRepoDep = Annotated[AiMessageRepo, Depends(get_ai_message_repo)]
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
def _to_response_view(v: AiMessageRepoView) -> AiMessageView:
"""repo 视图 → API 视图(同字段镜像;避免路由碰 repo 视图类型泄漏)。"""
return AiMessageView(
id=v.id,
project_id=v.project_id,
chapter_no=v.chapter_no,
thread_id=v.thread_id,
seq=v.seq,
kind=v.kind,
tool_key=v.tool_key,
role=v.role,
content=v.content,
meta=v.meta,
created_at=v.created_at,
)
@router.post("/{project_id}/ai-messages", status_code=201)
async def append_ai_messages(
project_id: uuid.UUID,
body: AiMessageAppendRequest,
request: Request,
project_repo: ProjectRepoDep,
repo: AiMessageRepoDep,
session: SessionDep,
) -> AiMessageListResponse:
"""一批往复 bubble append201。项目不存在 → 404非法 kind/role/空/超上限 → 422。"""
request_id = getattr(request.state, "request_id", None)
project = await project_repo.get(STUB_OWNER_ID, project_id)
if project is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
views = await repo.append(
project_id,
thread_id=body.thread_id,
chapter_no=body.chapter_no,
kind=body.kind,
tool_key=body.tool_key,
rows=[AiTurnRow(role=m.role, content=m.content, meta=m.meta) for m in body.messages],
)
await session.commit()
log.info(
"ai_messages_appended",
project_id=str(project_id),
request_id=request_id,
thread_id=str(body.thread_id),
kind=body.kind,
count=len(views),
)
return AiMessageListResponse(messages=[_to_response_view(v) for v in views])
@router.get("/{project_id}/ai-messages")
async def list_ai_messages(
project_id: uuid.UUID,
project_repo: ProjectRepoDep,
repo: AiMessageRepoDep,
page: PageDep,
chapter_no: Annotated[int | None, Query(ge=1)] = None,
kind: AiKind | None = None,
) -> AiMessageListResponse:
"""列出 AI 对话newest-first。`chapter_no` → 本章 项目级(NULL);缺省 → 仅项目级。"""
project = await project_repo.get(STUB_OWNER_ID, project_id)
if project is None:
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
views = await repo.list_for_chapter(
project_id, chapter_no, kind=kind, limit=page.limit, offset=page.offset
)
return AiMessageListResponse(messages=[_to_response_view(v) for v in views])