✨ 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:
1 parent
491136c770
commit
37c53862c4
6 files changed
+627
-23
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user