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:
@@ -20,6 +20,7 @@ from ww_api.middleware import (
|
||||
request_id_middleware,
|
||||
)
|
||||
from ww_api.routers import (
|
||||
ai_messages,
|
||||
chain,
|
||||
foreshadow,
|
||||
generation,
|
||||
@@ -123,6 +124,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(rules.router)
|
||||
app.include_router(style.router)
|
||||
app.include_router(templates.router)
|
||||
app.include_router(ai_messages.router)
|
||||
app.include_router(generation.router)
|
||||
app.include_router(generation.skills_router)
|
||||
app.include_router(toolbox.router)
|
||||
|
||||
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])
|
||||
74
apps/api/ww_api/schemas/ai_messages.py
Normal file
74
apps/api/ww_api/schemas/ai_messages.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""AI 对话(聊天记录)端点的请求/响应 schema(AC-2 / ai-chat-history-plan §3.2)。
|
||||
|
||||
snake_case;前端经 OpenAPI 生成 TS 类型消费(改字段 → 前端 `pnpm gen:api`)。
|
||||
`Literal` 是 kind/role 的**枚举权威**(DB 列自由 Text,边界在此收窄):非法 kind/role → 422。
|
||||
批级字段(thread_id/chapter_no/kind/tool_key)提到请求顶层,`messages` 是本批各条 bubble。
|
||||
长文进 `content`(有手稿级上限),`meta` 有序列化上界兜底防绕过 content 上限。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, StringConstraints, field_validator
|
||||
|
||||
# kind/role 的枚举权威(DB 列自由 Text;加新 kind 改这里 + 零迁移)。
|
||||
AiKind = Literal["refine", "rewrite", "clarify", "continue", "generator"]
|
||||
AiRole = Literal["author", "ai"]
|
||||
|
||||
# content 上界 = 手稿上限(rewrite 整章文本级);命名常量非魔法数(CR-H9)。
|
||||
AI_MESSAGE_CONTENT_MAX = 200_000
|
||||
# meta 序列化上界兜底:防长文绕过 content 上限塞进 meta。
|
||||
_META_MAX_SERIALIZED = 50_000
|
||||
# 一次 append 的最大条数(一次交换的多轮 bubble)。
|
||||
_MAX_TURNS_PER_CALL = 50
|
||||
|
||||
|
||||
class AiMessageInput(BaseModel):
|
||||
"""一条待落库 bubble(角色 + 人读文本 + 结构化 meta)。"""
|
||||
|
||||
role: AiRole
|
||||
content: Annotated[str, StringConstraints(min_length=1, max_length=AI_MESSAGE_CONTENT_MAX)]
|
||||
meta: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("meta")
|
||||
@classmethod
|
||||
def _bound_meta(cls, v: dict[str, Any]) -> dict[str, Any]:
|
||||
if len(json.dumps(v, ensure_ascii=False)) > _META_MAX_SERIALIZED:
|
||||
raise ValueError("meta too large; put long text in content")
|
||||
return v
|
||||
|
||||
|
||||
class AiMessageAppendRequest(BaseModel):
|
||||
"""POST /projects/{id}/ai-messages:一次交换的一批 bubble(批级字段提顶层)。"""
|
||||
|
||||
thread_id: uuid.UUID
|
||||
chapter_no: Annotated[int, Field(ge=1)] | None = None # NULL = 项目级(工具箱)
|
||||
kind: AiKind
|
||||
tool_key: str | None = None
|
||||
messages: Annotated[list[AiMessageInput], Field(min_length=1, max_length=_MAX_TURNS_PER_CALL)]
|
||||
|
||||
|
||||
class AiMessageView(BaseModel):
|
||||
"""聊天留痕视图(append/list 回显;snake_case,镜像 repo view)。"""
|
||||
|
||||
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] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AiMessageListResponse(BaseModel):
|
||||
"""append/list 信封:`messages` 复数键(repo 约定,非 `items`)。"""
|
||||
|
||||
messages: list[AiMessageView]
|
||||
@@ -18,6 +18,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_config import get_settings
|
||||
from ww_core.domain import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo
|
||||
from ww_core.domain.ai_message_repo import AiMessageRepo, SqlAiMessageRepo
|
||||
from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo
|
||||
from ww_core.domain.character_repo import CharacterWriteRepo, SqlCharacterWriteRepo
|
||||
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
|
||||
@@ -160,6 +161,16 @@ def get_rule_write_repo(
|
||||
return SqlRuleWriteRepo(session)
|
||||
|
||||
|
||||
def get_ai_message_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> AiMessageRepo:
|
||||
"""AI 对话留痕 repo(POST/GET /projects/{id}/ai-messages;append 只 flush,端点提交)。
|
||||
|
||||
测试经 `app.dependency_overrides[get_ai_message_repo]` 注入内存 fake。
|
||||
"""
|
||||
return SqlAiMessageRepo(session)
|
||||
|
||||
|
||||
async def get_template_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> TemplateRepo:
|
||||
|
||||
Reference in New Issue
Block a user