"""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])