✨ 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 非法拒绝、分支切换与跨域
This commit is contained in:
chenxin committed 2026-09-10 13:17:34 +08:00
1 parent 491136c770
commit 37c53862c4
6 files changed
+627 -23

No files matched your search

+86 -17
View File
@@ -102,7 +102,7 @@ async def run_events(
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> StreamingResponse:
"""Run SSE:返回当前快照与终止状态(垂直切片空状态闭环)。"""
"""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:
@@ -111,29 +111,98 @@ async def run_events(
if run.personal_domain_id != domain.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Run 不存在")
terminal = run.status in {"completed", "failed", "cancelled"}
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]:
def envelope(event_type: str, data: dict[str, object], sequence: int) -> str:
for event in events:
payload = json.dumps(
{"type": event_type, "data": data, "sequence": sequence}, ensure_ascii=False
)
return f"data: {payload}\n\n"
yield envelope("run.status.changed", {"run_id": run.id, "status": run.status}, 0)
if terminal:
yield envelope(
"message.completed",
{
"run_id": run.id,
"message_ids": [],
"final_status": run.status,
},
1,
{"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 ""}
+218 -4
View File
@@ -1,7 +1,8 @@
"""对话域 schema:ContentBlock 判别联合(垂直切片子集)。
"""对话域 schema:统一 ContentBlock 判别联合(21 类型)。
完整 21 类型随 WP-03 落地;本切片登记 text/markdown 并拒绝未知类型,
契约真源为 openapi/openapi.yaml。
与 openapi/openapi.yaml 的 components.schemas 一一对齐;此为服务端
Pydantic 边界(AGENTS 8.2),字段语义以契约 docs/contracts/content-blocks-and-streaming.md 为准。
markdown block 在服务端经灵台引擎规范化(契约第 4 节)。
"""
from typing import Annotated, Literal
@@ -16,6 +17,9 @@ class BlockBase(BaseModel):
status: Literal["pending", "streaming", "completed", "failed", "cancelled"] | None = None
# ===== 文本与结构 =====
class TextBlock(BlockBase):
type: Literal["text"]
text: str
@@ -28,7 +32,203 @@ class MarkdownBlock(BlockBase):
flavor: Literal["hunjing-gfm-v1"] = "hunjing-gfm-v1"
ContentBlock = Annotated[TextBlock | MarkdownBlock, Field(discriminator="type")]
class CodeBlock(BlockBase):
type: Literal["code"]
source: str
language: str | None = None
filename: str | None = None
class QuoteBlock(BlockBase):
type: Literal["quote"]
content: list["ContentBlock"]
source_ref: str | None = None
class TableBlock(BlockBase):
type: Literal["table"]
columns: list[str]
rows: list[list[object]]
caption: str | None = None
class CalloutBlock(BlockBase):
type: Literal["callout"]
kind: Literal["info", "warning", "danger", "success"]
title: str | None = None
content: list["ContentBlock"]
# ===== 数学与图 =====
class MathBlock(BlockBase):
type: Literal["math"]
notation: Literal["latex", "asciimath"]
display: Literal["inline", "block"]
source: str
fallback_text: str | None = None
label: str | None = None
class MermaidBlock(BlockBase):
type: Literal["mermaid"]
source: str
title: str | None = None
caption: str | None = None
fallback_code_language: str = "mermaid"
# ===== 媒体与文件 =====
class ImageBlock(BlockBase):
type: Literal["image"]
file_id: str | None = None
url_ref: str | None = None
alt: str
width: int | None = Field(default=None, ge=1)
height: int | None = Field(default=None, ge=1)
caption: str | None = None
class VideoBlock(BlockBase):
type: Literal["video"]
file_id: str
poster_file_id: str | None = None
caption: str | None = None
transcript_file_id: str | None = None
duration_ms: int | None = Field(default=None, ge=0)
class AudioBlock(BlockBase):
type: Literal["audio"]
file_id: str
transcript: str | None = None
duration_ms: int | None = Field(default=None, ge=0)
waveform: str | None = None
class FileBlock(BlockBase):
type: Literal["file"]
file_id: str
filename: str
media_type: str
size_bytes: int = Field(ge=0)
description: str | None = None
class GalleryItem(BaseModel):
model_config = ConfigDict(extra="forbid")
type: Literal["image", "video"]
file_id: str
alt: str | None = None
caption: str | None = None
class GalleryBlock(BlockBase):
type: Literal["gallery"]
items: list[GalleryItem]
layout: str | None = None
class AvatarBlock(BlockBase):
type: Literal["avatar"]
asset_id: str
format: Literal["live2d", "vrm", "gltf"]
preview_file_id: str | None = None
# ===== Agent 与工具 =====
class ToolCallBlock(BlockBase):
type: Literal["tool_call"]
tool_name: str
call_id: str
arguments_redacted: dict[str, object] | None = None
approval_id: str | None = None
class ToolResultBlock(BlockBase):
type: Literal["tool_result"]
call_id: str
summary: dict[str, object] | None = None
file_ids: list[str] | None = None
is_error: bool | None = None
class AgentStatusBlock(BlockBase):
type: Literal["agent_status"]
agent_id: str | None = None
child_run_id: str | None = None
stage: str
progress: dict[str, object] | None = None
waiting_reason: str | None = None
class ApprovalRequestBlock(BlockBase):
type: Literal["approval_request"]
approval_id: str
action: str
impact: str | None = None
arguments_summary: dict[str, object] | None = None
expires_at: str | None = None
class ArtifactBlock(BlockBase):
type: Literal["artifact"]
artifact_id: str
artifact_type: str
version: int | None = None
class CitationBlock(BlockBase):
type: Literal["citation"]
source_id: str
title: str
quoted_text: str
locator: dict[str, object] | None = None
class ErrorBlock(BlockBase):
type: Literal["error"]
message: str
retryable: bool | None = None
fallback: str | None = None
_BlockTypes = (
TextBlock
| MarkdownBlock
| CodeBlock
| QuoteBlock
| TableBlock
| CalloutBlock
| MathBlock
| MermaidBlock
| ImageBlock
| VideoBlock
| AudioBlock
| FileBlock
| GalleryBlock
| AvatarBlock
| ToolCallBlock
| ToolResultBlock
| AgentStatusBlock
| ApprovalRequestBlock
| ArtifactBlock
| CitationBlock
| ErrorBlock
)
ContentBlock = Annotated[_BlockTypes, Field(discriminator="type")]
# 系统产生类 block:普通 Message 请求不允许客户端伪造(契约 3.5 节)
RUNTIME_BLOCK_TYPES = frozenset(
{"tool_call", "tool_result", "agent_status", "approval_request", "artifact"}
)
class MessageIn(BaseModel):
@@ -59,3 +259,17 @@ class RunOut(BaseModel):
class MessageCreatedOut(BaseModel):
message: MessageOut
run: RunOut | None
class BranchOut(BaseModel):
id: str
conversation_id: str
title: str
created_at: str
class ConversationOut(BaseModel):
id: str
title: str
current_branch_id: str | None = None
created_at: str
+112 -1
View File
@@ -13,7 +13,7 @@ from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.conversations.schemas import MessageIn
from app.conversations.schemas import RUNTIME_BLOCK_TYPES, MessageIn
from app.core import lingtai
from app.core.ids import new_id
from app.models import (
@@ -24,6 +24,7 @@ from app.models import (
Message,
OutboxEvent,
Run,
RunEvent,
)
MESSAGE_ROUTE = "/v1/conversations/{conversation_id}/messages"
@@ -123,6 +124,13 @@ async def replay_or_execute_message(
conversation = await _require_conversation(db, domain_id, conversation_id)
branch = await _get_default_branch(db, conversation.id)
forged = [b.type for b in body.content if b.type in RUNTIME_BLOCK_TYPES]
if forged:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"系统产生类 block 不允许客户端提交:{', '.join(sorted(set(forged)))}",
)
blocks: list[dict[str, Any]] = []
parser_version: str | None = None
for b in body.content:
@@ -161,6 +169,7 @@ async def replay_or_execute_message(
resource_ids: dict[str, Any] = {"message_id": message.id}
if run is not None:
resource_ids["run_id"] = run.id
_record_run_events(db, run, message, blocks)
db.add(
AuditEvent(
actor_type="user",
@@ -191,3 +200,105 @@ async def replay_or_execute_message(
)
await db.flush()
return message, run, False
def _record_run_events(
db: AsyncSession, run: Run, message: Message, blocks: list[dict[str, Any]]
) -> None:
"""空状态闭环的事件流:状态事件 + 每 block completed + message.completed。
sequence 在 Run 内从 0 单调;事件与权威状态同事务提交(契约第 5 节)。
delta/progress 事件在模型流式接入(WP-05)后由真实流产生,此处不伪造。
"""
seq = 0
events: list[tuple[str, dict[str, Any]]] = [
("run.status.changed", {"run_id": run.id, "status": run.status})
]
for index, block in enumerate(blocks):
events.append(
(
"content.block.completed",
{"run_id": run.id, "message_id": message.id, "index": index,
"block": {"id": block["id"], "type": block["type"], "status": "completed"}},
)
)
events.append(
(
"message.completed",
{"run_id": run.id, "message_ids": [message.id], "final_status": run.status},
)
)
for event_type, data in events:
db.add(RunEvent(run_id=run.id, sequence=seq, type=event_type, data=data))
seq += 1
async def list_messages(
db: AsyncSession,
domain_id: str,
conversation_id: str,
branch_id: str | None,
limit: int,
cursor: str | None,
) -> tuple[list[Message], str | None, bool]:
"""按创建时间升序的消息列表(opaque cursor 分页,契约第 4.1 节)。"""
conversation = await _require_conversation(db, domain_id, conversation_id)
stmt = select(Message).where(Message.conversation_id == conversation.id)
if branch_id is not None:
stmt = stmt.where(Message.branch_id == branch_id)
after_created, after_id = decode_cursor(cursor)
if after_created is not None:
# (created_at, id) 复合键分页:严格大于游标位置
stmt = stmt.where(
(Message.created_at > after_created)
| ((Message.created_at == after_created) & (Message.id > after_id))
)
stmt = stmt.order_by(Message.created_at.asc(), Message.id.asc()).limit(limit + 1)
rows = list((await db.scalars(stmt)).all())
has_more = len(rows) > limit
page = rows[:limit]
next_cursor = encode_cursor(page[-1]) if has_more and page else None
return page, next_cursor, has_more
def encode_cursor(message: Message) -> str:
import base64
raw = f"{message.created_at.isoformat()}|{message.id}"
return base64.urlsafe_b64encode(raw.encode()).decode()
def decode_cursor(cursor: str | None) -> tuple[Any | None, str]:
import base64
if cursor is None:
return None, ""
try:
raw = base64.urlsafe_b64decode(cursor.encode()).decode()
created_str, _, mid = raw.partition("|")
return datetime.fromisoformat(created_str), mid
except Exception as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "cursor 不合法") from exc
async def list_branches(
db: AsyncSession, domain_id: str, conversation_id: str
) -> list[Branch]:
conversation = await _require_conversation(db, domain_id, conversation_id)
result = await db.execute(
select(Branch).where(Branch.conversation_id == conversation.id).order_by(Branch.created_at)
)
return list(result.scalars().all())
async def set_current_branch(
db: AsyncSession, domain_id: str, conversation_id: str, branch_id: str
) -> Conversation:
"""切换当前路径:分支必须属于该会话(跨域/跨会话统一 404/422 fail-closed)。"""
conversation = await _require_conversation(db, domain_id, conversation_id)
branch = await db.get(Branch, branch_id)
if branch is None or branch.conversation_id != conversation.id:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "分支不属于该会话")
conversation.current_branch_id = branch_id
await db.flush()
return conversation