Files
writer-work-flow/apps/api/ww_api/routers/ai_messages.py
Yaojia Wang c675a74f1d 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%。
2026-07-09 16:54:52 +02:00

115 lines
4.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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