- 迁移 5:conversation.realm(默认真界,检查约束);message.realm 快照、canonized_at、parent_message_id 自引用与 variant_kind(origin/revision/candidate)
- 界域端点:GET conversation(realm/当前分支)与 POST realm(入境/出境显式切换,留审计——ADR-020 切换必须可见)
- 归真端点 POST /v1/messages/{id}/canonizations:scope 必须与消息界域一致(不一致 422);幂等(重复归真 200 原状态);境界归真产出 world.canonized Outbox 事件(世界知识候选入口,WP-04 消费);幻境归真仅标记试演记录
- 编辑版本 POST /v1/messages/{id}/revisions:新消息 variant=revision 引用原消息(markdown 修订同样经灵台引擎规范化),原消息永不修改
- 测试 44 项全绿(+8):界域快照、归真正/反/幂等、幻境试演、revision 原消息不变、跨域 401/403/404 fail-closed
442 lines
15 KiB
Python
442 lines
15 KiB
Python
"""对话域服务:幂等、原子事务与越权防护。
|
||
|
||
不变量(契约 4.3/4.4 节):Message 与可选 Run 在同一事务创建;权威状态、
|
||
AuditEvent 与 OutboxEvent 同事务提交;跨域对象按 404 fail-closed。
|
||
"""
|
||
|
||
import hashlib
|
||
import json
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
|
||
from fastapi import HTTPException, status
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
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 (
|
||
AuditEvent,
|
||
Branch,
|
||
Conversation,
|
||
IdempotencyRecord,
|
||
Message,
|
||
OutboxEvent,
|
||
Run,
|
||
RunEvent,
|
||
)
|
||
|
||
MESSAGE_ROUTE = "/v1/conversations/{conversation_id}/messages"
|
||
|
||
|
||
def _request_hash(body: MessageIn) -> str:
|
||
canonical = json.dumps(
|
||
{
|
||
"content": [b.model_dump(exclude_none=True) for b in body.content],
|
||
"start_run": body.start_run,
|
||
},
|
||
sort_keys=True,
|
||
ensure_ascii=False,
|
||
)
|
||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||
|
||
|
||
async def require_conversation(
|
||
db: AsyncSession, domain_id: str, conversation_id: str
|
||
) -> Conversation:
|
||
conversation = await db.get(Conversation, conversation_id)
|
||
# 跨域对象统一 404,不泄露存在性(契约第 7 节)
|
||
if conversation is None or conversation.personal_domain_id != domain_id:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "对话不存在")
|
||
return conversation
|
||
|
||
|
||
async def _get_default_branch(db: AsyncSession, conversation_id: str) -> Branch:
|
||
result = await db.execute(
|
||
select(Branch).where(Branch.conversation_id == conversation_id).limit(1)
|
||
)
|
||
branch = result.scalar_one_or_none()
|
||
assert branch is not None, "会话必须存在默认分支(创建事务保证)"
|
||
return branch
|
||
|
||
|
||
async def create_conversation(
|
||
db: AsyncSession, domain_id: str, title: str, trace_id: str | None
|
||
) -> Conversation:
|
||
conversation = Conversation(personal_domain_id=domain_id, title=title)
|
||
db.add(conversation)
|
||
await db.flush()
|
||
branch = Branch(conversation_id=conversation.id, title="主脉")
|
||
db.add(branch)
|
||
db.add(
|
||
AuditEvent(
|
||
actor_type="user",
|
||
actor_id=domain_id,
|
||
action="conversation.created",
|
||
aggregate_type="conversation",
|
||
aggregate_id=conversation.id,
|
||
trace_id=trace_id,
|
||
)
|
||
)
|
||
db.add(
|
||
OutboxEvent(
|
||
aggregate_type="conversation",
|
||
aggregate_id=conversation.id,
|
||
event_type="conversation.created",
|
||
payload={"conversation_id": conversation.id, "title": title},
|
||
)
|
||
)
|
||
await db.flush()
|
||
return conversation
|
||
|
||
|
||
async def replay_or_execute_message(
|
||
db: AsyncSession,
|
||
domain_id: str,
|
||
conversation_id: str,
|
||
idempotency_key: str,
|
||
body: MessageIn,
|
||
trace_id: str | None,
|
||
) -> tuple[Message, Run | None, bool]:
|
||
"""返回(消息,Run,是否为重放)。幂等:相同键+相同请求返回原资源。"""
|
||
route = MESSAGE_ROUTE
|
||
existing = await db.execute(
|
||
select(IdempotencyRecord).where(
|
||
IdempotencyRecord.owner_domain_id == domain_id,
|
||
IdempotencyRecord.route == route,
|
||
IdempotencyRecord.key == idempotency_key,
|
||
)
|
||
)
|
||
record = existing.scalar_one_or_none()
|
||
request_hash = _request_hash(body)
|
||
if record is not None:
|
||
if record.request_hash != request_hash:
|
||
raise HTTPException(status.HTTP_409_CONFLICT, "相同幂等键对应不同请求体")
|
||
message = await db.get(Message, record.resource_ids.get("message_id"))
|
||
run = None
|
||
run_id = record.resource_ids.get("run_id")
|
||
if run_id is not None:
|
||
run = await db.get(Run, run_id)
|
||
assert message is not None
|
||
return message, run, True
|
||
|
||
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:
|
||
if b.type == "markdown":
|
||
# 契约第 4 节:markdown 输入经确定性 parser 规范化为 canonical blocks
|
||
normalized = lingtai.transform_markdown(b.text)
|
||
parser_version = str(normalized["parser_version"])
|
||
blocks.extend(normalized["blocks"])
|
||
else:
|
||
blocks.append({**b.model_dump(exclude_none=True), "status": "completed"})
|
||
for block in blocks:
|
||
block["id"] = new_id("block_")
|
||
block.setdefault("status", "completed")
|
||
message = Message(
|
||
conversation_id=conversation.id,
|
||
branch_id=branch.id,
|
||
role="user",
|
||
realm=conversation.realm,
|
||
content=blocks,
|
||
parser_version=parser_version,
|
||
)
|
||
db.add(message)
|
||
await db.flush()
|
||
|
||
run = None
|
||
if body.start_run:
|
||
# 垂直切片空状态闭环:不调用模型,Run 原子创建并直接进入终态 completed。
|
||
run = Run(
|
||
personal_domain_id=domain_id,
|
||
conversation_id=conversation.id,
|
||
status="completed",
|
||
completed_at=datetime.now(UTC),
|
||
)
|
||
db.add(run)
|
||
await db.flush()
|
||
|
||
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",
|
||
actor_id=domain_id,
|
||
action="message.created",
|
||
aggregate_type="message",
|
||
aggregate_id=message.id,
|
||
trace_id=trace_id,
|
||
)
|
||
)
|
||
db.add(
|
||
OutboxEvent(
|
||
aggregate_type="message",
|
||
aggregate_id=message.id,
|
||
event_type="message.created",
|
||
payload={"message_id": message.id, "conversation_id": conversation.id},
|
||
)
|
||
)
|
||
db.add(
|
||
IdempotencyRecord(
|
||
key=idempotency_key,
|
||
owner_domain_id=domain_id,
|
||
route=route,
|
||
request_hash=request_hash,
|
||
response_status=201,
|
||
resource_ids=resource_ids,
|
||
)
|
||
)
|
||
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
|
||
|
||
|
||
async def set_realm(
|
||
db: AsyncSession,
|
||
domain_id: str,
|
||
conversation_id: str,
|
||
realm: str,
|
||
trace_id: str | None,
|
||
) -> Conversation:
|
||
"""入境/出境:显式切换对话界域并留审计(ADR-020:切换必须可见)。"""
|
||
if realm not in {"reality", "world", "simulation"}:
|
||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "realm 取值不合法")
|
||
conversation = await require_conversation(db, domain_id, conversation_id)
|
||
previous = conversation.realm
|
||
conversation.realm = realm
|
||
db.add(
|
||
AuditEvent(
|
||
actor_type="user",
|
||
actor_id=domain_id,
|
||
action="conversation.realm_changed",
|
||
aggregate_type="conversation",
|
||
aggregate_id=conversation.id,
|
||
payload={"from": previous, "to": realm},
|
||
trace_id=trace_id,
|
||
)
|
||
)
|
||
await db.flush()
|
||
return conversation
|
||
|
||
|
||
async def _require_message(
|
||
db: AsyncSession, domain_id: str, message_id: str
|
||
) -> Message:
|
||
message = await db.get(Message, message_id)
|
||
if message is None:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "消息不存在")
|
||
conversation = await db.get(Conversation, message.conversation_id)
|
||
assert conversation is not None
|
||
if conversation.personal_domain_id != domain_id:
|
||
raise HTTPException(status.HTTP_404_NOT_FOUND, "消息不存在")
|
||
return message
|
||
|
||
|
||
async def canonize_message(
|
||
db: AsyncSession,
|
||
domain_id: str,
|
||
message_id: str,
|
||
scope: str,
|
||
trace_id: str | None,
|
||
) -> tuple[Message, bool]:
|
||
"""归真:scope 必须与消息所在界域一致(ADR-020);重复归真幂等返回原状态。
|
||
|
||
境界归真产出 world.canonized Outbox 事件(世界知识候选入口,WP-04 消费);
|
||
幻境归真仅标记试演记录,不产生长期记忆通道。
|
||
"""
|
||
if scope not in {"reality", "world", "simulation"}:
|
||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "scope 取值不合法")
|
||
message = await _require_message(db, domain_id, message_id)
|
||
if message.realm != scope:
|
||
raise HTTPException(
|
||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||
f"归真作用域与消息界域不一致:消息在 {message.realm},请求 {scope}",
|
||
)
|
||
if message.canonized_at is not None:
|
||
return message, True # 幂等:重复归真返回原状态
|
||
message.canonized_at = datetime.now(UTC)
|
||
event_type = "message.canonized" if scope != "world" else "world.canonized"
|
||
db.add(
|
||
AuditEvent(
|
||
actor_type="user",
|
||
actor_id=domain_id,
|
||
action="message.canonized",
|
||
aggregate_type="message",
|
||
aggregate_id=message.id,
|
||
payload={"canonical_scope": scope},
|
||
trace_id=trace_id,
|
||
)
|
||
)
|
||
db.add(
|
||
OutboxEvent(
|
||
aggregate_type="message",
|
||
aggregate_id=message.id,
|
||
event_type=event_type,
|
||
payload={"message_id": message.id, "canonical_scope": scope},
|
||
)
|
||
)
|
||
await db.flush()
|
||
return message, False
|
||
|
||
|
||
async def revise_message(
|
||
db: AsyncSession,
|
||
domain_id: str,
|
||
message_id: str,
|
||
body: MessageIn,
|
||
trace_id: str | None,
|
||
) -> Message:
|
||
"""编辑版本:创建引用原消息的新 Message(variant=revision),原消息不变。"""
|
||
original = await _require_message(db, domain_id, message_id)
|
||
blocks: list[dict[str, Any]] = []
|
||
parser_version: str | None = None
|
||
for b in body.content:
|
||
if b.type == "markdown":
|
||
normalized = lingtai.transform_markdown(b.text)
|
||
parser_version = str(normalized["parser_version"])
|
||
blocks.extend(normalized["blocks"])
|
||
else:
|
||
blocks.append({**b.model_dump(exclude_none=True), "status": "completed"})
|
||
for block in blocks:
|
||
block["id"] = new_id("block_")
|
||
block.setdefault("status", "completed")
|
||
revision = Message(
|
||
conversation_id=original.conversation_id,
|
||
branch_id=original.branch_id,
|
||
role=original.role,
|
||
realm=original.realm,
|
||
parent_message_id=original.id,
|
||
variant_kind="revision",
|
||
content=blocks,
|
||
parser_version=parser_version,
|
||
)
|
||
db.add(revision)
|
||
await db.flush() # 生成 revision.id 后再写审计
|
||
db.add(
|
||
AuditEvent(
|
||
actor_type="user",
|
||
actor_id=domain_id,
|
||
action="message.revised",
|
||
aggregate_type="message",
|
||
aggregate_id=revision.id,
|
||
payload={"revises": original.id},
|
||
trace_id=trace_id,
|
||
)
|
||
)
|
||
await db.flush()
|
||
return revision
|