✨ feat: WP-03 三界归真作用域与消息版本谱系(ADR-020 落地)
- 迁移 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
This commit is contained in:
1 parent
37c53862c4
commit
603a683c14
6 files changed
+432
-6
No files matched your search
@@ -42,7 +42,7 @@ def _request_hash(body: MessageIn) -> str:
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def _require_conversation(
|
||||
async def require_conversation(
|
||||
db: AsyncSession, domain_id: str, conversation_id: str
|
||||
) -> Conversation:
|
||||
conversation = await db.get(Conversation, conversation_id)
|
||||
@@ -121,7 +121,7 @@ async def replay_or_execute_message(
|
||||
assert message is not None
|
||||
return message, run, True
|
||||
|
||||
conversation = await _require_conversation(db, domain_id, conversation_id)
|
||||
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]
|
||||
@@ -148,6 +148,7 @@ async def replay_or_execute_message(
|
||||
conversation_id=conversation.id,
|
||||
branch_id=branch.id,
|
||||
role="user",
|
||||
realm=conversation.realm,
|
||||
content=blocks,
|
||||
parser_version=parser_version,
|
||||
)
|
||||
@@ -242,7 +243,7 @@ async def list_messages(
|
||||
cursor: str | None,
|
||||
) -> tuple[list[Message], str | None, bool]:
|
||||
"""按创建时间升序的消息列表(opaque cursor 分页,契约第 4.1 节)。"""
|
||||
conversation = await _require_conversation(db, domain_id, conversation_id)
|
||||
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)
|
||||
@@ -284,7 +285,7 @@ def decode_cursor(cursor: str | None) -> tuple[Any | None, str]:
|
||||
async def list_branches(
|
||||
db: AsyncSession, domain_id: str, conversation_id: str
|
||||
) -> list[Branch]:
|
||||
conversation = await _require_conversation(db, domain_id, conversation_id)
|
||||
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)
|
||||
)
|
||||
@@ -295,10 +296,146 @@ 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)
|
||||
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
|
||||
Reference in new issue
Block a user