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:
114
apps/api/ww_api/routers/ai_messages.py
Normal file
114
apps/api/ww_api/routers/ai_messages.py
Normal 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 一批 append(201;项目不存在 → 404)。
|
||||
- GET /projects/{id}/ai-messages 列出(newest-first;`chapter_no` 过滤含项目级 NULL;分页)。
|
||||
|
||||
提交边界:`AiMessageRepo.append` 只 flush,POST 端点写后 `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 append(201)。项目不存在 → 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])
|
||||
Reference in New Issue
Block a user