Files
hunjing/backend/app/conversations/routes.py
T
chenxin 37c53862c4 ✨ feat: WP-03 主体——ContentBlock 全类型、Run 事件重放、分页与分支(WP-03)
- ContentBlock 全 21 类型 Pydantic 判别联合(与 openapi 契约一一对齐,extra=forbid);系统产生类 block(tool_call/tool_result/agent_status/approval_request/artifact)拒绝客户端伪造(契约 3.5)
- 迁移 4:run_event 表((run_id, sequence) 唯一、事件类型检查约束)+ conversation.current_branch_id
- Run 事件持久化:状态事件、每 block completed、message.completed 与权威状态同事务写入;sequence 单调从 0;SSE 改为重放持久化事件(不再现场构造),delta/progress 留待真实模型流(WP-05),不伪造
- GET messages:opaque cursor 分页((created_at,id) 复合键、base64 编码、limit 1-200、has_more)
- branches:列表 + 当前路径切换(采用≠归真);跨会话/不存在分支 422 fail-closed
- 测试 36 项全绿:21 类型接受、runtime block 拒绝、事件重放序号单调、分页无重叠、cursor 非法拒绝、分支切换与跨域
2026-09-10 13:17:34 +08:00

209 lines
6.9 KiB
Python

"""对话域路由:创建会话、发送消息(幂等原子)与 Run SSE。"""
import json
from collections.abc import AsyncIterator
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy import select
from app.auth.dependencies import get_current_user, require_state_change
from app.conversations import service
from app.conversations.schemas import MessageCreatedOut, MessageIn, MessageOut, RunOut
from app.db.session import DbSession
from app.models import PersonalDomain, Run, User
router = APIRouter(prefix="/v1/conversations", tags=["conversations"])
async def _domain_of(db: DbSession, user: User) -> PersonalDomain:
result = await db.execute(
select(PersonalDomain).where(PersonalDomain.user_id == user.id).limit(1)
)
domain = result.scalar_one_or_none()
if domain is None:
raise HTTPException(status.HTTP_409_CONFLICT, "用户缺少个人域")
return domain
@router.post("", status_code=201)
async def create_conversation(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, str]:
domain = await _domain_of(db, user)
conversation = await service.create_conversation(
db, domain.id, body.get("title", "未命名对话"), getattr(request.state, "trace_id", None)
)
return {"id": conversation.id, "title": conversation.title}
@router.post(
"/{conversation_id}/messages",
status_code=201,
response_model=None,
)
async def post_message(
conversation_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: MessageIn,
idempotency_key: Annotated[str, Header(alias="Idempotency-Key")],
) -> MessageCreatedOut | JSONResponse:
if not idempotency_key or len(idempotency_key) > 128:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Idempotency-Key 不合法")
domain = await _domain_of(db, user)
message, run, replayed = await service.replay_or_execute_message(
db,
domain.id,
conversation_id,
idempotency_key,
body,
getattr(request.state, "trace_id", None),
)
payload = MessageCreatedOut(
message=MessageOut(
id=message.id,
conversation_id=message.conversation_id,
branch_id=message.branch_id,
role=message.role,
content=message.content,
parser_version=message.parser_version,
created_at=message.created_at.isoformat(),
),
run=(
RunOut(
id=run.id,
conversation_id=run.conversation_id,
status=run.status,
created_at=run.created_at.isoformat(),
completed_at=run.completed_at.isoformat() if run.completed_at else None,
)
if run is not None
else None
),
)
if replayed:
# 重放成功按契约返回 200 与原资源(避开装饰器固定 201)
return JSONResponse(status_code=200, content=json.loads(payload.model_dump_json()))
return payload
run_router = APIRouter(prefix="/v1/runs", tags=["runs"])
@run_router.get("/{run_id}/events")
async def run_events(
run_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> StreamingResponse:
"""Run SSE:重放持久化事件流(sequence 单调),客户端按序去重(契约第 5 节)。"""
result = await db.execute(select(Run).where(Run.id == run_id))
run = result.scalar_one_or_none()
if run is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Run 不存在")
domain = await _domain_of(db, user)
if run.personal_domain_id != domain.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Run 不存在")
from app.models import RunEvent
events = (
await db.execute(
select(RunEvent)
.where(RunEvent.run_id == run.id)
.order_by(RunEvent.sequence.asc())
)
).scalars().all()
async def event_stream() -> AsyncIterator[str]:
for event in events:
payload = json.dumps(
{"type": event.type, "data": event.data, "sequence": event.sequence},
ensure_ascii=False,
)
yield f"data: {payload}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-store"},
)
@router.get("/{conversation_id}/messages")
async def list_messages(
conversation_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
limit: int = 50,
cursor: str | None = None,
branch_id: str | None = None,
) -> dict[str, object]:
"""消息列表:opaque cursor 分页(默认 50,契约第 4.1 节)。"""
if not 1 <= limit <= 200:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "limit 取值范围 1-200")
domain = await _domain_of(db, user)
page, next_cursor, has_more = await service.list_messages(
db, domain.id, conversation_id, branch_id, limit, cursor
)
from app.conversations.schemas import MessageOut
return {
"data": [
MessageOut(
id=m.id,
conversation_id=m.conversation_id,
branch_id=m.branch_id,
role=m.role,
content=m.content,
parser_version=m.parser_version,
created_at=m.created_at.isoformat(),
).model_dump()
for m in page
],
"next_cursor": next_cursor,
"has_more": has_more,
}
@router.get("/{conversation_id}/branches")
async def list_branches(
conversation_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, object]:
domain = await _domain_of(db, user)
branches = await service.list_branches(db, domain.id, conversation_id)
from app.conversations.schemas import BranchOut
return {
"data": [
BranchOut(
id=b.id, conversation_id=b.conversation_id, title=b.title,
created_at=b.created_at.isoformat(),
).model_dump()
for b in branches
]
}
@router.post("/{conversation_id}/current-branch")
async def set_current_branch(
conversation_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
body: dict[str, str],
) -> dict[str, str]:
"""切换当前路径(主脉工作入口;采用≠归真)。"""
domain = await _domain_of(db, user)
conversation = await service.set_current_branch(
db, domain.id, conversation_id, body.get("branch_id", "")
)
return {"id": conversation.id, "current_branch_id": conversation.current_branch_id or ""}