W12 本书事实、关系与时间边界基础:本书事实、关系提案、时点边界与角色知情历史。

按 R2 串行阶段整理提交;包内文件为该阶段交付(含后续小增量),状态以工作包清单为准。
This commit is contained in:
zizi 2026-09-10 19:25:41 +08:00
parent 98530437f2
commit 2f15c5b3bc
13 changed files with 2370 additions and 0 deletions

View File

@ -0,0 +1,120 @@
"""本书事实的实际查询、提案和作者决定;不接受客户端自报作者。"""
from typing import Literal
from fastapi import APIRouter, Query, Request
from muse.接入.http.作者会话 import 作者依赖
from muse.接入.创作操作 import (
世界查询结果,
事实决定请求,
事实创建请求,
事实审阅结果,
事实审阅请求,
事实提案结果,
创作身份,
核对路径,
正式回执结果,
)
from muse.故事世界.接口 import 事实结构
from muse.正式变更.接口 import 作者动作
路由 = APIRouter(prefix="/api/v1", tags=["故事世界"])
@路由.get("/works/{work_id}/facts/schema", response_model=事实结构, operation_id="read_fact_schema")
def 读取事实结构(
work_id: str,
object_id: str,
schema_id: str,
request: Request,
作者: 作者依赖,
base_version: int = Query(default=1, ge=1),
instance_family: Literal["entity", "relation"] = "entity",
extension_version: int | None = Query(default=None, ge=1),
) -> 事实结构:
装配 = request.app.state.装配
return 装配.要求故事世界().新事实结构(
创作身份(作者, 装配.配置),
work_id,
object_id,
schema_id,
base_version,
家族=instance_family,
扩展版本=extension_version,
)
@路由.get(
"/works/{work_id}/facts",
response_model=世界查询结果,
response_model_exclude_unset=True,
operation_id="query_world_state",
)
def 查询事实(
work_id: str,
request: Request,
作者: 作者依赖,
as_of: str | None = None,
system_revision: int | None = Query(default=None, ge=0),
view: Literal["author", "reader", "character"] = "author",
character_id: str | None = None,
) -> dict:
装配 = request.app.state.装配
return 装配.要求故事世界().查询世界(
创作身份(作者, 装配.配置),
work_id,
as_of,
系统版本=system_revision,
视角=view,
知情主体ID=character_id,
)
@路由.post(
"/works/{work_id}/fact-proposals",
response_model=正式回执结果,
operation_id="propose_world_fact",
)
def 提出事实(work_id: str, 请求: 事实创建请求, request: Request, 作者: 作者依赖) -> dict:
核对路径(work_id, 请求.proposal.work_id)
装配 = request.app.state.装配
return 装配.要求故事世界().提出事实(创作身份(作者, 装配.配置), 请求.command_id, 请求.proposal)
@路由.get(
"/fact-proposals/{proposal_id}", response_model=事实提案结果, operation_id="read_fact_proposal"
)
def 读取提案(proposal_id: str, request: Request, 作者: 作者依赖) -> dict:
装配 = request.app.state.装配
return 装配.要求故事世界().读取提案(创作身份(作者, 装配.配置), proposal_id)
@路由.post(
"/fact-proposals/{proposal_id}/reviews",
response_model=事实审阅结果,
operation_id="open_fact_review",
)
def 打开事实审阅(proposal_id: str, 请求: 事实审阅请求, request: Request, 作者: 作者依赖) -> dict:
核对路径(proposal_id, 请求.proposal_id)
装配 = request.app.state.装配
return 装配.要求故事世界().打开事实审阅(创作身份(作者, 装配.配置), 请求.定位())
@路由.post(
"/fact-proposals/{proposal_id}/decisions",
response_model=正式回执结果,
operation_id="decide_world_fact",
)
def 决定事实(proposal_id: str, 请求: 事实决定请求, request: Request, 作者: 作者依赖) -> dict:
核对路径(proposal_id, 请求.proposal_id)
装配 = request.app.state.装配
return 装配.要求故事世界().决定事实(
创作身份(作者, 装配.配置),
请求.command_id,
请求.定位(),
作者动作(请求.action),
请求.author_review_id,
请求.review_hash,
tuple(请求.approved_changes),
)

View File

@ -0,0 +1 @@
"""本书实体、关系、时点与事实;业务入口见接口.py。"""

View File

@ -0,0 +1,128 @@
"""把事实提案的字面依据绑定到固定正文;不改字节,不因引文存在而自动确认。"""
from dataclasses import replace
from muse.元数据.接口 import 有效结构, 校验值, 类型定义, 选择实例维护者
from muse.故事世界.模型 import 事实条目, 事实错误, 引文锚点, 引用对象
from muse.故事世界.连续性 import 核对叙事窗口
from muse.正文写作.接口 import 可见文本, 正文草稿
def 定位字面引文(正文: 正文草稿, 引文: str) -> 引文锚点:
"""定位一个实际出现位置,仅用于来源引用;不用于自动修改正文。"""
if 引文:
for 段 in 正文.paragraphs:
内容 = 可见文本(正文草稿((段,)))
开始 = 内容.find(引文)
if 开始 >= 0:
return 引文锚点(段.paragraph_id, 开始, 开始 + len(引文), 引文)
raise 事实错误("FACT_QUOTE_NOT_FOUND", "证据不在正文的所声明段落中")
def 核对原文引文(正文: 正文草稿, 引用: 引文锚点) -> str:
段 = next((p for p in 正文.paragraphs if p.paragraph_id == 引用.paragraph_id), None)
if 段 is None or 可见文本(正文草稿((段,)))[引用.start : 引用.end] != 引用.quote:
raise 事实错误("FACT_QUOTE_NOT_FOUND", "引文位置与固定正文不一致")
return 引用.quote
def 解析类型选择(选择: str, 类型目录: tuple[类型定义, ...]) -> 类型定义:
按ID = next((t for t in 类型目录 if t.type_id == 选择), None)
if 按ID is not None:
return 按ID
中文命中 = [t for t in 类型目录 if t.名称 == 选择]
if len(中文命中) != 1:
raise 事实错误("FACT_TYPE_UNKNOWN", "提案类型未登记或名称不能唯一定位")
return 中文命中[0]
def 校验事实条目(
条目: 事实条目,
*,
作品ID: str,
类型: 类型定义,
结构: 有效结构,
正文: 正文草稿,
可用对象: dict[str, 引用对象],
章节顺序: dict[str, int] | None = None,
) -> 事实条目:
"""类型/字段来自元数据;实际对象及当前可用性由B02在同一核验范围中提供。"""
if 条目.type_id != 类型.type_id or 结构.基础.type_id != 类型.type_id:
raise 事实错误("FACT_TYPE_UNKNOWN", "事实类型与固定结构不一致")
if 选择实例维护者(类型, 条目.instance_family) != "B02":
raise 事实错误("SCOPE_DENIED", "本书事实不能写入参考分析或公共方法")
结果 = 校验值(结构, 条目.content)
if 条目.planned_window is not None:
核对叙事窗口(条目.planned_window, 章节顺序 or {})
for 引文 in 条目.evidence:
核对原文引文(正文, 引文)
引用数 = 0
for 引用 in 结果.引用需求:
值列表 = 引用.值 if isinstance(引用.值, list) else [引用.值]
for id_ in 值列表:
对象 = 可用对象.get(id_) if isinstance(id_, str) else None
if 对象 is None or 对象.work_id != 作品ID or 对象.type_id not in 引用.规则.目标类型:
raise 事实错误("FACT_REFERENCE_INVALID", "引用对象缺失、类型不符或不属于本书")
if 对象.state not in {"confirmed", "approved_in_batch"} or not 对象.available:
raise 事实错误("FACT_REFERENCE_INVALID", "引用对象尚未批准或在当前时点不可用")
引用数 += 1
if 条目.instance_family == "relation" and 引用数 < 2:
raise 事实错误("FACT_REFERENCE_INVALID", "关系需要由字段合同声明并解析的端点")
return replace(条目, content=结果.值)
def 核对提案身份(条目: tuple[事实条目, ...]) -> None:
if len({p.proposal_id for p in 条目}) != len(条目):
raise 事实错误("FACT_DUPLICATE_ID", "同一提案批次不能包含重复条目身份")
def 校验事实批次(
条目: tuple[事实条目, ...],
*,
作品ID: str,
结构目录: dict[str, tuple[类型定义, 有效结构]],
正文: 正文草稿,
可用对象: dict[str, 引用对象],
章节顺序: dict[str, int] | None = None,
) -> tuple[事实条目, ...]:
核对提案身份(条目)
结果 = []
for 项 in 条目:
if 项.type_id not in 结构目录:
raise 事实错误("FACT_TYPE_UNKNOWN", "提案类型没有可用的已登记结构")
类型, 结构 = 结构目录[项.type_id]
结果.append(
校验事实条目(
项,
作品ID=作品ID,
类型=类型,
结构=结构,
正文=正文,
可用对象=可用对象,
章节顺序=章节顺序,
)
)
return tuple(结果)
def 保留名称提及(原输出: dict, 正文: 正文草稿, 类型目录: tuple[类型定义, ...]) -> dict:
"""失败产物只保留可定位的名称提及;不保留无依据属性,不当成完整抽取成功。"""
提及 = []
舍弃 = {"entities": 0, "relations": len(原输出.get("relations", []))}
for 实体 in 原输出.get("entities", []):
try:
类型 = 解析类型选择(实体["type"], 类型目录)
if 选择实例维护者(类型, "entity") != "B02":
raise 事实错误("SCOPE_DENIED", "不是本书实体类型")
名称 = 实体["name"]
引文 = 定位字面引文(正文, 名称)
except (KeyError, TypeError, 事实错误):
舍弃["entities"] += 1
continue
提及.append({"type_id": 类型.type_id, "name": 名称, "evidence": 引文})
return {
"mentions": 提及,
"relations": [],
"mechanical_drops": 舍弃,
"state": "needs_confirmation",
}

View File

@ -0,0 +1,160 @@
"""B02所属提案、实例和事实版本SQL;不跨owner写入。"""
import hashlib
from dataclasses import asdict
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
from muse.故事世界.模型 import 事实错误
from muse.故事世界.连续性 import 选择时点记录
class 故事存储:
def __init__(self, 连):
self.连 = 连
def 读取对象(self, object_id: str):
return (
self.连.cursor(row_factory=dict_row)
.execute(
"SELECT o.*,v.revision,v.system_revision,v.content,v.content_hash,v.schema_binding,"
"v.sources,v.independent,v.effective_from,v.knowledge_mode,"
"v.known_to,v.reader_visible,v.planned_window "
"FROM muse_world_object o JOIN muse_world_version v ON v.object_id=o.object_id "
"AND v.revision=o.current_revision WHERE o.object_id=%s",
(object_id,),
)
.fetchone()
)
def 读取提案(self, proposal_id: str):
return (
self.连.cursor(row_factory=dict_row)
.execute(
"SELECT p.*,v.revision,v.request,v.proposed_content,v.proposal_hash "
"FROM muse_fact_proposal p JOIN muse_fact_proposal_version v "
"ON v.proposal_id=p.proposal_id AND v.revision=p.current_revision "
"WHERE p.proposal_id=%s",
(proposal_id,),
)
.fetchone()
)
def 写提案(self, proposal_id: str, 请求, 内容: dict, 哈希: str) -> dict:
self.连.execute(
"INSERT INTO muse_fact_proposal(proposal_id,work_id,object_id,current_revision) "
"VALUES (%s,%s,%s,1)",
(proposal_id, 请求.work_id, 请求.object_id),
)
self.连.execute(
"INSERT INTO muse_fact_proposal_version"
"(proposal_id,revision,request,proposed_content,proposal_hash) "
"VALUES (%s,1,%s,%s,%s)",
(proposal_id, Jsonb(asdict(请求)), Jsonb(内容), 哈希),
)
return {"proposal_id": proposal_id, "revision": 1, "proposal_hash": 哈希}
def 锁对象(self, object_id: str) -> int:
key = int.from_bytes(
hashlib.sha256(("B02:object:" + object_id).encode()).digest()[:8], signed=True
)
self.连.execute("SELECT pg_advisory_xact_lock(%s)", (key,))
row = self.连.execute(
"SELECT current_revision FROM muse_world_object WHERE object_id=%s FOR UPDATE",
(object_id,),
).fetchone()
return row[0] if row else 0
def 锁提案(self, proposal_id: str) -> int:
key = int.from_bytes(
hashlib.sha256(("B02:proposal:" + proposal_id).encode()).digest()[:8], signed=True
)
self.连.execute("SELECT pg_advisory_xact_lock(%s)", (key,))
row = self.连.execute(
"SELECT current_revision FROM muse_fact_proposal WHERE proposal_id=%s FOR UPDATE",
(proposal_id,),
).fetchone()
return row[0] if row else 0
def 当前系统版本(self, work_id: str) -> int:
row = self.连.execute(
"SELECT system_revision FROM muse_world_clock WHERE work_id=%s", (work_id,)
).fetchone()
return row[0] if row else 0
def 占系统版本(self, work_id: str) -> int:
return self.连.execute(
"INSERT INTO muse_world_clock(work_id,system_revision) VALUES (%s,1) "
"ON CONFLICT(work_id) DO UPDATE SET system_revision=muse_world_clock.system_revision+1 "
"RETURNING system_revision",
(work_id,),
).fetchone()[0]
def 写对象(
self, 作者: str, 请求, 类型ID: str, 内容: dict, 绑定, 系统版本: int, 内容哈希: str
) -> dict:
version = 请求.expected_revision + 1
if 请求.expected_revision == 0:
self.连.execute(
"INSERT INTO muse_world_object"
"(object_id,work_id,type_id,instance_family,current_revision) "
"VALUES (%s,%s,%s,%s,%s)",
(请求.object_id, 请求.work_id, 类型ID, 请求.instance_family, version),
)
else:
row = self.连.execute(
"UPDATE muse_world_object SET current_revision=%s WHERE object_id=%s "
"AND current_revision=%s RETURNING object_id",
(version, 请求.object_id, 请求.expected_revision),
).fetchone()
if row is None:
raise 事实错误("REVISION_CONFLICT", "事实对象版本已改变")
self.连.execute(
"INSERT INTO muse_world_version"
"(object_id,revision,system_revision,content,content_hash,"
"schema_binding,sources,independent,effective_from,knowledge_mode,known_to,"
"reader_visible,author_id,planned_window) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)",
(
请求.object_id,
version,
系统版本,
Jsonb(内容),
内容哈希,
Jsonb(asdict(绑定)),
Jsonb([asdict(s) for s in 请求.sources]),
请求.independent,
请求.effective_from,
请求.knowledge_mode,
Jsonb([asdict(k) for k in 请求.known_to]),
请求.reader_visible,
作者,
Jsonb(asdict(请求.planned_window)) if 请求.planned_window is not None else None,
),
)
return {
"object_id": 请求.object_id,
"revision": version,
"system_revision": 系统版本,
"content_hash": 内容哈希,
}
def 读取时点对象(self, work_id: str, object_id: str, 章序: dict[str, int], 截止: int):
return 选择时点记录(
self.读取历史(work_id, self.当前系统版本(work_id), object_id), 章序, 截止
).get(object_id)
def 读取历史(self, work_id: str, 系统版本: int, object_id: str | None = None) -> list[dict]:
return (
self.连.cursor(row_factory=dict_row)
.execute(
"SELECT o.object_id,o.work_id,o.type_id,o.instance_family,o.current_revision,v.* "
"FROM muse_world_object o JOIN muse_world_version v ON v.object_id=o.object_id "
"WHERE o.work_id=%s AND v.system_revision<=%s "
"AND (%s::text IS NULL OR o.object_id=%s) "
"ORDER BY o.object_id,v.system_revision DESC",
(work_id, 系统版本, object_id, object_id),
)
.fetchall()
)

View File

@ -0,0 +1,247 @@
"""故事世界唯一公开表面;当前提案依据校验,后续同表面接入确认和查询。"""
from dataclasses import asdict
from uuid import NAMESPACE_URL, uuid5
from pydantic import TypeAdapter
from muse.作品规划.接口 import 读取作品范围
from muse.元数据.接口 import 元数据服务, 投影值, 投影字段
from muse.基础设施.数据库.连接 import 数据库工厂
from muse.故事世界.事实提案 import (
保留名称提及,
定位字面引文,
校验事实批次,
校验事实条目,
核对原文引文,
核对提案身份,
解析类型选择,
)
from muse.故事世界.存储 import 故事存储
from muse.故事世界.时点查询 import 查询世界状态
from muse.故事世界.来源有效性 import 当前依据可用, 读取依据状态
from muse.故事世界.模型 import (
事实定位,
事实提案请求,
事实条目,
事实结构,
事实错误,
叙事窗口,
引文锚点,
引用对象,
正文依据,
知情记录,
)
from muse.故事世界.设定修订 import (
世界参与者,
世界对象依赖,
世界提案依赖,
事实候选入口,
事实决定入口,
事实授权,
提案保存,
提案参与者,
核对提案归属,
读取事实结构,
)
from muse.正式变更.接口 import (
作者动作,
依赖引用,
参与者目录,
变更命令,
固定哈希,
正式变更服务,
读取作者决定,
)
def 登记故事参与者(目录: 参与者目录) -> None:
目录.登记入口(事实候选入口())
目录.登记入口(事实决定入口())
目录.登记参与者(提案参与者())
目录.登记参与者(世界参与者())
目录.登记依赖(世界对象依赖())
目录.登记依赖(世界提案依赖())
class 故事服务:
def __init__(self, 数据库: 数据库工厂, 正式: 正式变更服务):
self.数据库, self.正式 = 数据库, 正式
def _核对作者(self, 身份):
if not 身份.允许写正式内容 or 身份.用途 is not self.数据库.用途:
raise 事实错误("SCOPE_DENIED", "作者事实入口需要本用途的已认证作者")
def 新事实结构(
self,
身份,
work_id: str,
object_id: str,
schema_id: str,
版本: int,
*,
家族: str = "entity",
扩展版本: int | None = None,
) -> 事实结构:
self._核对作者(身份)
with self.数据库.连接(只读=True) as 连:
读取作品范围(连, 身份.作者, work_id)
结构, _, 投影 = 读取事实结构(
连, 身份.作者, work_id, 事实结构(schema_id, 版本, "", "", 扩展版本), 家族
)
return 事实结构(
schema_id, 版本, 结构.effective_schema_hash, 投影.projection_version, 扩展版本
)
def 提出事实(self, 身份, 命令ID: str, 请求: 事实提案请求) -> dict:
self._核对作者(身份)
id_ = str(uuid5(NAMESPACE_URL, "muse:B02:proposal:" + 固定哈希([身份.作者, 命令ID])))
return self.正式.提交变更(
身份,
变更命令(命令ID, "B02.propose_fact", id_, 作者动作.人工保存, 0, 提案保存(id_, 请求)),
)
def 读取提案(self, 身份, proposal_id: str) -> dict:
self._核对作者(身份)
with self.数据库.连接(只读=True) as 连:
行, 请求 = 核对提案归属(连, 身份.作者, proposal_id)
结构, 类型, _ = 读取事实结构(
连, 身份.作者, 请求.work_id, 请求.schema, 请求.instance_family
)
投影 = 投影字段(
结构,
元数据服务(连).当前策略(类型.type_id),
事实授权(身份.作者, 请求.work_id),
字段用途名="uiVisible",
内容用途="planning",
运行用途="production",
)
return {
"proposal_id": str(行["proposal_id"]),
"revision": 行["revision"],
"proposal_hash": 行["proposal_hash"],
"work_id": 请求.work_id,
"object_id": 请求.object_id,
"expected_revision": 请求.expected_revision,
"schema": asdict(请求.schema),
"content": 投影值(投影, 行["proposed_content"]),
"independent": 请求.independent,
"sources": [asdict(s) for s in 请求.sources],
"effective_from": 请求.effective_from,
"knowledge_mode": 请求.knowledge_mode,
"known_to": [asdict(k) for k in 请求.known_to],
"planned_window": asdict(请求.planned_window) if 请求.planned_window else None,
"decision": 读取作者决定(连, proposal_id, 行["revision"]),
}
def 打开事实审阅(self, 身份, 定位: 事实定位) -> dict:
self._核对作者(身份)
审阅 = self.正式.打开审阅(
身份,
变更命令(
"review",
"B02.decide_fact",
定位.target_ref,
作者动作.采纳,
定位.expected_revision,
定位,
),
)
return {**审阅, "proposal": self.读取提案(身份, 定位.proposal_id)}
def 决定事实(
self,
身份,
命令ID: str,
定位: 事实定位,
动作: 作者动作,
审阅ID: str,
审阅哈希: str,
批准: tuple[str, ...],
) -> dict:
self._核对作者(身份)
return self.正式.提交变更(
身份,
变更命令(
命令ID,
"B02.decide_fact",
定位.target_ref,
动作,
定位.expected_revision,
定位,
审阅ID,
审阅哈希,
批准,
),
)
def 查询世界(
self,
身份,
work_id: str,
截止章节: str | None,
*,
系统版本: int | None = None,
视角: str = "author",
知情主体ID: str | None = None,
) -> dict:
self._核对作者(身份)
with self.数据库.连接(只读=True) as 连:
return 查询世界状态(
连,
身份.作者,
work_id,
截止章节,
系统版本=系统版本,
视角=视角,
知情主体ID=知情主体ID,
)
__all__ = [
"事实条目",
"事实错误",
"叙事窗口",
"引用对象",
"引文锚点",
"定位字面引文",
"保留名称提及",
"核对原文引文",
"解析类型选择",
"校验事实条目",
"校验事实批次",
"核对提案身份",
"事实定位",
"事实提案请求",
"事实结构",
"正文依据",
"知情记录",
"故事服务",
"登记故事参与者",
"解析本书规划引用",
]
def 解析本书规划引用(连, 作者: str, work_id: str, 需求, *, 必须有效: bool) -> tuple:
"""规划只取得本书对象身份和必要依赖,不越权读取参考实例或自行确认实体。"""
读取作品范围(连, 作者, work_id)
deps = []
for value in 需求.值 if isinstance(需求.值, list) else [需求.值]:
id_ = value.get("id") if isinstance(value, dict) else value
obj = 故事存储(连).读取对象(id_) if isinstance(id_, str) else None
if obj is None or obj["work_id"] != work_id or obj["type_id"] not in 需求.规则.目标类型:
raise 事实错误("FACT_REFERENCE_INVALID", "规划引用对象缺失、类型不符或超出本书")
if isinstance(value, dict) and value.get("type") != obj["type_id"]:
raise 事实错误("FACT_REFERENCE_INVALID", "规划引用自报类型与真实本书对象不符")
if 必须有效 and not 当前依据可用(连, 作者, obj):
raise 事实错误("SOURCE_STALE", "规划引用的本书对象已失去当前依据")
deps.append(
依赖引用("B02.object", "world:" + obj["object_id"], str(obj["current_revision"]))
)
if not obj["independent"]:
for raw in obj["sources"]:
source = 读取依据状态(连, 作者, work_id, TypeAdapter(正文依据).validate_python(raw))
deps.append(
依赖引用("B05.document", source["document_id"], str(source["current_revision"]))
)
return tuple(sorted(set(deps)))

View File

@ -0,0 +1,117 @@
"""事件生效、披露时点与确认版本分开;不改变事实的保存状态。"""
from pydantic import TypeAdapter
from muse.作品规划.接口 import 读取作品范围
from muse.元数据.接口 import 元数据服务, 投影值, 投影字段, 结构绑定
from muse.故事世界.存储 import 故事存储
from muse.故事世界.来源有效性 import 当前依据可用, 读取依据状态
from muse.故事世界.模型 import 事实错误, 正文依据
from muse.故事世界.设定修订 import 事实授权
from muse.故事世界.连续性 import 选择时点记录
from muse.正式变更.接口 import 固定哈希
def 查询世界状态(
连,
作者: str,
work_id: str,
截止章节: str | None,
*,
系统版本: int | None = None,
视角: str = "author",
知情主体ID: str | None = None,
) -> dict:
if 视角 not in {"author", "reader", "character"} or ((视角 == "character") != bool(知情主体ID)):
raise 事实错误("FACT_SCOPE_INVALID", "查询需要明确的作者、读者或角色视角")
目录 = 读取作品范围(连, 作者, work_id)
章序 = {c["chapter_id"]: c["position"] for c in 目录["chapters"]}
if 截止章节 is not None and 截止章节 not in 章序:
raise 事实错误("FACT_TIME_INVALID", "查询截止位置不属于本书")
截止 = 章序[截止章节] if 截止章节 is not None else max(章序.values(), default=0)
存储 = 故事存储(连)
当前系统版 = 存储.当前系统版本(work_id)
系统版 = 当前系统版 if 系统版本 is None else 系统版本
if 系统版 < 0 or 系统版 > 当前系统版:
raise 事实错误("FACT_TIME_INVALID", "请求的事实系统版本不存在")
if 知情主体ID:
主体 = 选择时点记录(存储.读取历史(work_id, 系统版, 知情主体ID), 章序, 截止).get(知情主体ID)
if (
主体 is None
or 主体["instance_family"] != "entity"
or 主体["knowledge_mode"] not in {"fact", "setting"}
or not 当前依据可用(连, 作者, 主体)
):
raise 事实错误("SCOPE_DENIED", "知情主体在查询时点不可用")
结果 = {
"work_id": work_id,
"directory_revision": 目录["revision"],
"system_revision": 系统版,
"facts": [],
"beliefs": [],
"plans": [],
"omitted": [],
}
元数据 = 元数据服务(连)
for 行 in 选择时点记录(存储.读取历史(work_id, 系统版), 章序, 截止).values():
id_ = 行["object_id"]
原因 = None
if 固定哈希(行["content"]) != 行["content_hash"]:
raise 事实错误("FACT_INVALID", "已保存事实与内容哈希不一致")
if not 当前依据可用(连, 作者, 行):
原因 = "SOURCE_STALE"
elif 视角 == "reader" and not 行["reader_visible"]:
原因 = "KNOWLEDGE_SCOPE"
elif 视角 == "character" and not any(
k["actor_id"] == 知情主体ID and 章序.get(k["from_chapter_id"], 截止 + 1) <= 截止
for k in 行["known_to"]
):
原因 = "KNOWLEDGE_SCOPE"
elif 视角 == "reader" and not 行["independent"]:
已披露 = False
for 源值 in 行["sources"]:
源 = TypeAdapter(正文依据).validate_python(源值)
if 章序[源.chapter_id] <= 截止 and 读取依据状态(连, 作者, work_id, 源)["current"]:
已披露 = True
if not 已披露:
原因 = "FUTURE_EVIDENCE"
if 原因:
结果["omitted"].append({"object_id": id_, "reason": 原因})
continue
binding = 结构绑定(**行["schema_binding"])
结构 = 元数据.读取有效结构(
binding.schema_id,
binding.base_version,
work_id=work_id,
extension_version=binding.extension_version,
)
投影 = 投影字段(
结构,
元数据.当前策略(行["type_id"]),
事实授权(作者, work_id),
字段用途名="uiVisible",
内容用途="planning",
运行用途="production",
)
item = {
"object_id": id_,
"type_id": 行["type_id"],
"revision": 行["revision"],
"system_revision": 行["system_revision"],
"content": 投影值(投影, 行["content"]),
"effective_from": 行["effective_from"],
"knowledge_mode": 行["knowledge_mode"],
"independent": 行["independent"],
"content_hash": 行["content_hash"],
"projection_version": 投影.projection_version,
"schema_binding": 行["schema_binding"],
"sources": [
{k: s[k] for k in ("chapter_id", "branch_id", "revision", "document_hash")}
for s in 行["sources"]
],
}
if 视角 == "author":
item["planned_window"] = 行["planned_window"]
key = {"belief": "beliefs", "plan": "plans"}.get(行["knowledge_mode"], "facts")
结果[key].append(item)
return 结果

View File

@ -0,0 +1,34 @@
"""当前正文依据即时核对;独立作者依据与其他充分依据分开计算。"""
from pydantic import TypeAdapter
from muse.故事世界.事实提案 import 核对原文引文
from muse.故事世界.模型 import 事实错误, 正文依据
from muse.正文写作.接口 import 读取正文依据
def 读取依据状态(连, 作者: str, work_id: str, 来源: 正文依据) -> dict:
原 = 读取正文依据(连, 作者, 来源.chapter_id, 来源.revision, 分支=来源.branch_id)
if 原["work_id"] != work_id:
raise 事实错误("SCOPE_DENIED", "事实依据不属于当前作品")
if 原["document_hash"] != 来源.document_hash:
raise 事实错误("SOURCE_STALE", "正文依据哈希与固定版本不一致")
for 引文 in 来源.evidence:
核对原文引文(原["document"], 引文)
return {
"document_id": 原["document_id"],
"chapter_id": 来源.chapter_id,
"current_revision": 原["current_revision"],
"source_revision": 来源.revision,
"current": 原["current_revision"] == 来源.revision and 来源.branch_id == "main",
}
def 当前依据可用(连, 作者: str, 记录: dict) -> bool:
if 记录["independent"]:
return True
for 原 in 记录["sources"]:
来源 = TypeAdapter(正文依据).validate_python(原)
if 读取依据状态(连, 作者, 记录["work_id"], 来源)["current"]:
return True
return False

View File

@ -0,0 +1,123 @@
"""故事事实的身份与证据外壳;动态内容由S04结构描述。"""
from typing import Literal
from pydantic import ConfigDict
from pydantic.dataclasses import dataclass
from muse.共享.错误 import Muse错误
class 事实错误(Muse错误):
def __init__(self, 错误码: str, 说明: str):
self.错误码 = 错误码
super().__init__(说明)
@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid"))
class 引文锚点:
paragraph_id: str
start: int
end: int
quote: str
def __post_init__(self):
if (
not self.paragraph_id
or not self.quote
or self.start < 0
or self.end - self.start != len(self.quote)
):
raise 事实错误("FACT_QUOTE_INVALID", "引文需要段落身份、实际文字和一致的码点区间")
@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid"))
class 叙事窗口:
from_chapter_id: str
to_chapter_id: str
@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid"))
class 事实条目:
proposal_id: str
object_id: str
type_id: str
content: dict
evidence: tuple[引文锚点, ...]
instance_family: Literal["entity", "relation"] = "entity"
planned_window: 叙事窗口 | None = None
def __post_init__(self):
if not self.proposal_id or not self.object_id or not self.type_id or not self.evidence:
raise 事实错误("FACT_INVALID", "事实提案需要稳定身份、登记类型与实际正文依据")
@dataclass(frozen=True, slots=True)
class 引用对象:
object_id: str
work_id: str
type_id: str
state: Literal["confirmed", "approved_in_batch", "unconfirmed"] = "confirmed"
available: bool = True
@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid"))
class 事实结构:
schema_id: str
base_version: int
effective_schema_hash: str
projection_version: str
extension_version: int | None = None
@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid"))
class 正文依据:
chapter_id: str
branch_id: str
revision: int
document_hash: str
evidence: tuple[引文锚点, ...]
def __post_init__(self):
if self.revision < 1 or not self.document_hash or not self.evidence:
raise 事实错误("FACT_INVALID", "正文依据需要确切的已保存版本、哈希和引文")
@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid"))
class 知情记录:
actor_id: str
from_chapter_id: str
@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid"))
class 事实提案请求:
work_id: str
object_id: str
expected_revision: int
schema: 事实结构
content: dict
sources: tuple[正文依据, ...]
effective_from: str | None
instance_family: Literal["entity", "relation"] = "entity"
independent: bool = False
knowledge_mode: Literal["fact", "belief", "plan", "setting"] = "fact"
known_to: tuple[知情记录, ...] = ()
reader_visible: bool = True
planned_window: 叙事窗口 | None = None
def __post_init__(self):
if not self.work_id or not self.object_id or self.expected_revision < 0:
raise 事实错误("FACT_INVALID", "事实提案需要作品、对象和预期版本")
if not self.independent and not self.sources:
raise 事实错误("FACT_INVALID", "抽取事实需要正文依据;独立设定必须由作者明确声明")
if len({k.actor_id for k in self.known_to}) != len(self.known_to):
raise 事实错误("FACT_INVALID", "同一事实版本的角色获知位置不能重复")
@dataclass(frozen=True, slots=True, config=ConfigDict(extra="forbid"))
class 事实定位:
proposal_id: str
proposal_revision: int
proposal_hash: str
target_ref: str
expected_revision: int

View File

@ -0,0 +1,322 @@
"""S01中的B02事实写入;结构与来源均由所属公开接口核对。"""
from dataclasses import asdict, dataclass
from pydantic import TypeAdapter
from muse.作品规划.接口 import 读取作品范围
from muse.元数据.接口 import 元数据服务, 字段变更, 投影字段, 授权摘要, 校验字段变更, 选择实例维护者
from muse.故事世界.存储 import 故事存储
from muse.故事世界.来源有效性 import 当前依据可用, 读取依据状态
from muse.故事世界.模型 import 事实定位, 事实提案请求, 事实结构, 事实错误, 正文依据
from muse.故事世界.连续性 import 核对叙事窗口
from muse.正式变更.接口 import 作者动作, 依赖引用, 修改影响, 参与操作, 固定哈希, 审阅内容, 提交计划
def 事实授权(作者: str, work_id: str) -> 授权摘要:
return 授权摘要(固定哈希({"author": 作者}), 固定哈希({"work": work_id, "owner": "B02"}))
def 读取事实结构(连, 作者: str, work_id: str, 选择: 事实结构, 家族: str):
元数据 = 元数据服务(连)
结构 = 元数据.读取有效结构(
选择.schema_id, 选择.base_version, work_id=work_id, extension_version=选择.extension_version
)
类型 = 元数据.读取类型(结构.基础.type_id)
if 选择实例维护者(类型, 家族) != "B02":
raise 事实错误("SCOPE_DENIED", "结构不属于本书事实生命周期")
投影 = 投影字段(
结构,
元数据.当前策略(类型.type_id),
事实授权(作者, work_id),
字段用途名="userEditable",
内容用途="planning",
运行用途="production",
)
return 结构, 类型, 投影
def 核验提案内容(连, 作者: str, 请求: 事实提案请求):
目录 = 读取作品范围(连, 作者, 请求.work_id)
章序 = {c["chapter_id"]: c["position"] for c in 目录["chapters"]}
if 请求.effective_from is not None and 请求.effective_from not in 章序:
raise 事实错误("FACT_TIME_INVALID", "事实生效位置不属于本书目录")
if 请求.effective_from is None and not 请求.independent:
raise 事实错误("FACT_TIME_INVALID", "正文事实需要明确的生效章节")
if 请求.planned_window is not None:
核对叙事窗口(请求.planned_window, 章序)
存储 = 故事存储(连)
当前 = 存储.读取对象(请求.object_id)
if 当前 is not None and 当前["work_id"] != 请求.work_id:
raise 事实错误("SCOPE_DENIED", "事实对象不属于本书")
if (当前["current_revision"] if 当前 else 0) != 请求.expected_revision:
raise 事实错误("REVISION_CONFLICT", "事实对象已被其他决定更新")
结构, 类型, 投影 = 读取事实结构(连, 作者, 请求.work_id, 请求.schema, 请求.instance_family)
if 结构.effective_schema_hash != 请求.schema.effective_schema_hash:
raise 事实错误("SCHEMA_STALE", "事实结构与请求的固定版本不一致")
if 投影.projection_version != 请求.schema.projection_version:
raise 事实错误("PROJECTION_STALE", "事实可编辑字段已经变化")
if 当前 is not None and (当前["type_id"], 当前["instance_family"]) != (
类型.type_id,
请求.instance_family,
):
raise 事实错误("SCOPE_DENIED", "现有对象不能被提案改为其他类型或生命周期")
值 = 校验字段变更(
结构,
投影,
当前["content"] if 当前 else {},
tuple(字段变更((key,), "set", val) for key, val in 请求.content.items()),
)
依据 = [读取依据状态(连, 作者, 请求.work_id, 源) for 源 in 请求.sources]
if not 请求.independent and not any(s["current"] for s in 依据):
raise 事实错误("SOURCE_STALE", "事实已失去当前正文依据")
# 回忆可在后章揭示早期事件;证据何时可见由时点查询独立控制。
引用版本 = {}
截止 = 章序[请求.effective_from] if 请求.effective_from else 0
for 需求 in 值.引用需求:
for id_ in 需求.值 if isinstance(需求.值, list) else [需求.值]:
对象 = (
存储.读取时点对象(请求.work_id, id_, 章序, 截止) if isinstance(id_, str) else None
)
if (
对象 is None
or 对象["work_id"] != 请求.work_id
or 对象["type_id"] not in 需求.规则.目标类型
or 请求.knowledge_mode in {"fact", "setting"}
and 对象["knowledge_mode"] not in {"fact", "setting"}
):
raise 事实错误(
"FACT_REFERENCE_INVALID", "字段引用在该时点不可用、类型不符或不是已发生事实"
)
if not 当前依据可用(连, 作者, 对象):
raise 事实错误("FACT_REFERENCE_INVALID", "引用对象已失去当前依据")
引用版本[id_] = 对象["current_revision"]
引用数 = sum(len(v.值) if isinstance(v.值, list) else 1 for v in 值.引用需求)
if 请求.instance_family == "relation" and 引用数 < 2:
raise 事实错误("FACT_REFERENCE_INVALID", "关系结构必须提供真实端点")
for 知情 in 请求.known_to:
if 知情.from_chapter_id not in 章序:
raise 事实错误("FACT_TIME_INVALID", "角色获知位置必须属于本书目录")
对象 = 存储.读取时点对象(请求.work_id, 知情.actor_id, 章序, 章序[知情.from_chapter_id])
if (
对象 is None
or 对象["instance_family"] != "entity"
or 对象["knowledge_mode"] not in {"fact", "setting"}
or not 当前依据可用(连, 作者, 对象)
):
raise 事实错误("FACT_REFERENCE_INVALID", "知情主体必须是获知时点内有效的本书实体")
引用版本[知情.actor_id] = 对象["current_revision"]
引用来源 = 读取引用依赖(连, 作者, 请求.work_id, 引用版本, 章序, 截止, 请求.known_to)[1]
return 值.值, 结构, 类型, 投影, ([] if 请求.independent else 依据) + 引用来源, 引用版本, 目录
def 读取引用依赖(连, 作者: str, work_id: str, 对象ID集, 章序: dict[str, int], 截止: int, 知情范围):
"""引用内容的有效性也依赖其正文;S01需共同保护这些当前指针。"""
版本 = {}
来源 = []
for id_ in 对象ID集:
对象 = 故事存储(连).读取对象(id_)
版本[id_] = 对象["current_revision"] if 对象 else 0
if 对象 is not None:
if 对象["work_id"] != work_id:
raise 事实错误("SCOPE_DENIED", "引用对象不属于本书")
时点集 = {截止} | {章序[k.from_chapter_id] for k in 知情范围 if k.actor_id == id_}
for 时点 in sorted(时点集):
时点对象 = 故事存储(连).读取时点对象(work_id, id_, 章序, 时点)
if 时点对象 is not None and not 时点对象["independent"]:
for 原 in 时点对象["sources"]:
来源.append(
读取依据状态(
连, 作者, work_id, TypeAdapter(正文依据).validate_python(原)
)
)
return 版本, 来源
@dataclass(frozen=True)
class 世界写入:
作者: str
请求: 事实提案请求
class 世界参与者:
身份 = "B02.write_object"
请求类型 = 世界写入
def 提交(self, 连, 操作):
请求 = 操作.请求
内容, 结构, 类型, _, _, _, _ = 核验提案内容(连, 操作.作者, 请求)
元数据 = 元数据服务(连)
target = "world:" + 请求.object_id
if 故事存储(连).读取对象(请求.object_id) is None:
绑定 = 元数据.建立初始绑定(target, 请求.work_id, 结构)
else:
绑定 = 元数据.读取绑定(target)
元数据.保护提交(
target,
请求.schema.effective_schema_hash,
请求.schema.projection_version,
事实授权(操作.作者, 请求.work_id),
字段用途名="userEditable",
内容用途="planning",
运行用途="production",
)
存储 = 故事存储(连)
系统版本 = 存储.占系统版本(请求.work_id)
return 存储.写对象(操作.作者, 请求, 类型.type_id, 内容, 绑定, 系统版本, 固定哈希(内容))
@dataclass(frozen=True)
class 提案保存:
proposal_id: str
请求: 事实提案请求
@dataclass(frozen=True)
class 提案写入:
作者: str
输入: 提案保存
def 核对提案归属(连, 作者: str, proposal_id: str):
行 = 故事存储(连).读取提案(proposal_id)
if 行 is None:
raise 事实错误("FACT_PROPOSAL_NOT_FOUND", "事实提案不存在")
读取作品范围(连, 作者, 行["work_id"])
请求 = TypeAdapter(事实提案请求).validate_python(行["request"])
if 固定哈希([行["request"], 行["proposed_content"]]) != 行["proposal_hash"]:
raise 事实错误("FACT_INVALID", "已保存提案与内容哈希不一致")
return 行, 请求
def 形成依赖(
请求, 目录, 依据, 引用版本, proposal_id: str, proposal_revision: int, object_revision: int
):
依赖 = {
("B01.directory", "directory:" + 请求.work_id): str(目录["revision"]),
("B02.object", "world:" + 请求.object_id): str(object_revision),
("B02.proposal", proposal_id): str(proposal_revision),
}
for 源 in 依据:
依赖[("B05.document", 源["document_id"])] = str(源["current_revision"])
for id_, 版 in 引用版本.items():
依赖[("B02.object", "world:" + id_)] = str(版)
return tuple(依赖引用(类, id_, 版) for (类, id_), 版 in sorted(依赖.items()))
class 事实候选入口:
身份 = "B02.propose_fact"
请求类型 = 提案保存
def 查看(self, 连, 身份, 命令):
输入 = 命令.业务请求
请求 = 输入.请求
内容, 结构, _, 投影, 依据, 引用, 目录 = 核验提案内容(连, 身份.作者, 请求)
原 = 故事存储(连).读取提案(输入.proposal_id)
版 = 原["current_revision"] if 原 else 0
return 审阅内容(
输入.proposal_id,
版,
None,
None,
None,
固定哈希([asdict(请求), 内容]),
结构.effective_schema_hash,
投影.projection_version,
固定哈希(依据),
("proposal",),
形成依赖(请求, 目录, 依据, 引用, 输入.proposal_id, 版, 请求.expected_revision),
)
def 准备(self, 连, 身份, 命令, 当前):
return 提交计划((参与操作("B02.write_proposal", 提案写入(身份.作者, 命令.业务请求)),))
class 提案参与者:
身份 = "B02.write_proposal"
请求类型 = 提案写入
def 提交(self, 连, 操作):
请求 = 操作.输入.请求
内容, *_ = 核验提案内容(连, 操作.作者, 请求)
return 故事存储(连).写提案(
操作.输入.proposal_id, 请求, 内容, 固定哈希([asdict(请求), 内容])
)
class 事实决定入口:
身份 = "B02.decide_fact"
请求类型 = 事实定位
def 查看(self, 连, 身份, 命令):
定位 = 命令.业务请求
行, 请求 = 核对提案归属(连, 身份.作者, 定位.proposal_id)
if (行["revision"], 行["proposal_hash"]) != (定位.proposal_revision, 定位.proposal_hash):
raise 事实错误("REVISION_CONFLICT", "提案版本与被审内容不一致")
target = "world:" + 请求.object_id
if 定位.target_ref != target:
raise 事实错误("SCOPE_DENIED", "提案与事实对象不一致")
对象 = 故事存储(连).读取对象(请求.object_id)
if 对象 is not None and 对象["work_id"] != 请求.work_id:
raise 事实错误("SCOPE_DENIED", "事实对象不属于本书")
版 = 对象["current_revision"] if 对象 else 0
目录 = 读取作品范围(连, 身份.作者, 请求.work_id)
依据 = [读取依据状态(连, 身份.作者, 请求.work_id, s) for s in 请求.sources]
结构, _, 投影 = 读取事实结构(连, 身份.作者, 请求.work_id, 请求.schema, 请求.instance_family)
# 依赖由已保存提案的完整规范值识别,不相信客户端自报预检。
from muse.元数据.接口 import 校验值
引用 = {}
for 需 in 校验值(结构, 行["proposed_content"]).引用需求:
for id_ in 需.值 if isinstance(需.值, list) else [需.值]:
obj = 故事存储(连).读取对象(id_) if isinstance(id_, str) else None
if isinstance(id_, str):
引用[id_] = obj["current_revision"] if obj else 0
for 知情 in 请求.known_to:
obj = 故事存储(连).读取对象(知情.actor_id)
引用[知情.actor_id] = obj["current_revision"] if obj else 0
章序 = {c["chapter_id"]: c["position"] for c in 目录["chapters"]}
截止 = 章序[请求.effective_from] if 请求.effective_from else 0
引用, 其他依据 = 读取引用依赖(连, 身份.作者, 请求.work_id, 引用, 章序, 截止, 请求.known_to)
依据 = ([] if 请求.independent else 依据) + 其他依据
return 审阅内容(
target,
版,
定位.proposal_id,
行["revision"],
行["proposal_hash"],
固定哈希([对象["content_hash"] if 对象 else None, 行["proposed_content"]]),
结构.effective_schema_hash,
投影.projection_version,
固定哈希([依据, 引用, 目录]),
("object",),
形成依赖(请求, 目录, 依据, 引用, 定位.proposal_id, 行["revision"], 版),
)
def 准备(self, 连, 身份, 命令, 当前):
if 命令.动作 in {作者动作.拒绝, 作者动作.暂缓}:
return 提交计划(())
if 命令.动作 is not 作者动作.采纳:
raise 事实错误("FACT_INVALID", "事实提案需要明确的作者决定")
_, 请求 = 核对提案归属(连, 身份.作者, 命令.业务请求.proposal_id)
核验提案内容(连, 身份.作者, 请求)
return 提交计划(
(参与操作("B02.write_object", 世界写入(身份.作者, 请求)),),
(修改影响(当前.目标, "fact_changed", str(当前.数据版本), str(当前.数据版本 + 1)),),
)
class 世界对象依赖:
种类 = "B02.object"
def 锁定当前(self, 连, 对象ID):
if not 对象ID.startswith("world:"):
raise 事实错误("SCOPE_DENIED", "不是故事世界对象身份")
return str(故事存储(连).锁对象(对象ID.removeprefix("world:")))
class 世界提案依赖:
种类 = "B02.proposal"
def 锁定当前(self, 连, 对象ID):
return str(故事存储(连).锁提案(对象ID))

View File

@ -0,0 +1,20 @@
"""故事时间约束使用稳定章节身份和指定目录版本的顺序。"""
from muse.故事世界.模型 import 事实错误, 叙事窗口
def 核对叙事窗口(窗口: 叙事窗口, 章节顺序: dict[str, int]) -> None:
起 = 章节顺序.get(窗口.from_chapter_id)
止 = 章节顺序.get(窗口.to_chapter_id)
if 起 is None or 止 is None or 起 > 止:
raise 事实错误("FACT_TIME_INVALID", "计划窗口需要本书存在且顺序正确的起止章节")
def 选择时点记录(记录: list[dict], 章节顺序: dict[str, int], 截止: int) -> dict[str, dict]:
"""记录按系统版本倒序;每个对象取叙事时点内最新的已确认版本。"""
结果 = {}
for 行 in 记录:
起点 = 章节顺序.get(行["effective_from"]) if 行["effective_from"] else 0
if 起点 is not None and 起点 <= 截止:
结果.setdefault(行["object_id"], 行)
return 结果

View File

@ -0,0 +1,331 @@
"""逐条承接引文行为;不执行旧Skill脚本、不规范化正文。"""
from dataclasses import asdict, replace
import pytest
from pydantic import TypeAdapter, ValidationError
from muse.元数据.接口 import (
值规则,
元数据错误,
合成结构,
字段定义,
引用规则,
类型定义,
结构定义,
读取内置种子,
)
from muse.故事世界.接口 import (
事实条目,
事实错误,
保留名称提及,
叙事窗口,
定位字面引文,
引用对象,
校验事实批次,
核对原文引文,
解析类型选择,
)
from muse.正文写作.接口 import 换行节点, 文本节点, 正文草稿, 段落
原增量正文 = "林深把黑纹缠上手臂,异种核心在胸腔里低鸣。何岚站在舱门口没有说话。"
原抽取正文 = "林深走进舰桥,何岚把黑色钥匙交给他。"
def 单段(内容):
return 正文草稿((段落("p1", (文本节点(内容),)),))
def test_quote_not_found_fails_closed__3554e5():
with pytest.raises(事实错误) as 错误:
定位字面引文(单段(原增量正文), "正文里不存在的句子")
assert 错误.value.错误码 == "FACT_QUOTE_NOT_FOUND"
def test_quote_is_normalized_before_matching__505864():
# 保留原LC-e3eca2942895样本;格式1不允许把字面反斜杠改成真正换行。
escaped_body = 原增量正文 + "\\n舱灯闪了一下"
quote = "\n舱灯闪了一下"
with pytest.raises(事实错误) as 错误:
定位字面引文(单段(escaped_body), quote)
assert 错误.value.错误码 == "FACT_QUOTE_NOT_FOUND"
assert escaped_body.endswith("\\n舱灯闪了一下")
原文 = 正文草稿((段落("p1", (文本节点(原增量正文), 换行节点(), 文本节点("舱灯闪了一下"))),))
证据 = 定位字面引文(原文, quote)
assert 核对原文引文(原文, 证据) == quote
assert 证据.start == len(原增量正文) and 证据.end == len(原增量正文 + quote)
def test_normalize_rejects_fabricated_evidence__9e1858():
原输入 = {
"type": "item",
"name": "黑钥匙",
"brief": "钥匙",
"fields": {},
"evidence": "正文没有这句",
}
with pytest.raises(事实错误) as 错误:
定位字面引文(单段(原抽取正文), 原输入["evidence"])
assert 错误.value.错误码 == "FACT_QUOTE_NOT_FOUND"
def test_normalize_binds_evidence_and_aliases__1b9888():
原输入 = {
"type": "人物",
"name": "林深",
"brief": "机师",
"fields": {},
"evidence": "林深走进舰桥",
}
种子 = next(s for s in 读取内置种子() if s.类型.type_id == "character")
# 此旧场景中的中文类型名在输入目录登记;解析代码没有固定中文别名表。
类型 = replace(种子.类型, 名称="人物")
选中 = 解析类型选择(原输入["type"], (类型,))
正文 = 单段(原抽取正文)
提案 = 事实条目(
"proposal-lin",
"character-lin",
选中.type_id,
{"名称": 原输入["name"], "一句话摘要": 原输入["brief"], **原输入["fields"]},
(定位字面引文(正文, 原输入["evidence"]),),
)
结果 = 校验事实批次(
(提案,),
作品ID="synthetic-work",
结构目录={"character": (类型, 合成结构(种子.结构))},
正文=正文,
可用对象={},
)
assert 结果[0].type_id == "character"
assert 结果[0].evidence[0].quote in 原抽取正文
assert 结果[0].content["名称"] == "林深"
旧增量样本 = {
"character_location_changed": (
{"characterName": "林深", "toLocation": "舱室底层"},
"异种核心在胸腔里低鸣",
),
"character_knowledge_added": (
{"characterName": "何岚", "knowledge": "林深体内有异种核心"},
"何岚站在舱门口没有说话",
),
"relationship_changed": (
{"fromName": "林深", "toName": "何岚", "relation": "互相提防"},
"何岚站在舱门口没有说话",
),
"hook_advanced": (
{
"hookId": "hook-voice",
"action": "advanced",
"dueWindow": {"fromChapter": 5, "toChapter": 8},
},
"异种核心在胸腔里低鸣",
),
"timeline_event_added": ({"event": "隔离舱首次审讯结束"}, "何岚站在舱门口没有说话"),
"setting_added": ({"factType": "污染规则", "text": "黑纹扩散不可逆"}, "林深把黑纹缠上手臂"),
}
名称映射 = {
"林深": "character-lin",
"何岚": "character-he",
"舱室底层": "location-bottom",
"指挥舱": "location-command",
"hook-voice": "hook-voice",
"h1": "hook-one",
}
@pytest.fixture
def 旧增量环境():
# 六行保留旧输入种类;它们仅是本用例的动态登记,不是生产类型白名单。
字符 = 值规则("text")
def 引用(type_id):
return 值规则("reference", 引用=引用规则((type_id,)))
角色, 地点, 伏笔 = 引用("character"), 引用("location"), 引用("foreshadowing")
字段表 = {
"character_location_changed": [
("角色", "characterName", 角色),
("目标地点", "toLocation", 地点),
],
"character_knowledge_added": [
("角色", "characterName", 角色),
("新增认知", "knowledge", 字符),
],
"relationship_changed": [
("甲方", "fromName", 角色),
("乙方", "toName", 角色),
("关系", "relation", 字符),
],
"hook_advanced": [
("伏笔", "hookId", 伏笔),
("动作", "action", 值规则("enum", 枚举=("planted", "advanced", "paid"))),
],
"timeline_event_added": [("事件", "event", 字符)],
"setting_added": [("设定类型", "factType", 字符), ("内容", "text", 字符)],
}
结构目录 = {}
for id_, fields in 字段表.items():
家族 = "relation" if id_ == "relationship_changed" else "entity"
类型 = 类型定义(id_, id_, "world", 家族, (家族,))
结构 = 结构定义(
id_,
id_,
1,
tuple(
字段定义(id_ + "." + key, key, key, rule, 必填=True, 别名=(alias,))
for key, alias, rule in fields
),
)
结构目录[id_] = (类型, 合成结构(结构))
对象 = {
id_: 引用对象(
id_,
"synthetic-work",
"character"
if id_.startswith("character-")
else "location"
if id_.startswith("location-")
else "foreshadowing",
)
for id_ in 名称映射.values()
}
return {
"作品ID": "synthetic-work",
"结构目录": 结构目录,
"正文": 单段(原增量正文),
"可用对象": 对象,
"章节顺序": {f"chapter-{i}": i for i in range(1, 13)},
}
def 旧样本提案(
类型名="character_location_changed", *, payload=None, quote=None, delta_id="delta-1"
):
原字段, 原引文 = 旧增量样本.get(类型名, 旧增量样本["character_location_changed"])
内容 = dict(原字段 if payload is None else payload)
窗口 = 内容.pop("dueWindow", None)
# 已知旧名称在输入边界转换为固定对象ID;生产事实不由显示名拼接身份。
for key in ("characterName", "fromName", "toName", "toLocation", "hookId"):
if key in 内容:
内容[key] = 名称映射[内容[key]]
return 事实条目(
delta_id,
内容.get("hookId", "object-" + delta_id),
类型名,
内容,
(定位字面引文(单段(原增量正文), 原引文 if quote is None else quote),),
instance_family="relation" if 类型名 == "relationship_changed" else "entity",
planned_window=叙事窗口(f"chapter-{窗口['fromChapter']}", f"chapter-{窗口['toChapter']}")
if 窗口
else None,
)
@pytest.mark.parametrize("delta_type", tuple(旧增量样本), ids=tuple(旧增量样本))
def test_each_delta_type_has_working_contract__d0e17d(旧增量环境, delta_type):
原字段, 引文 = 旧增量样本[delta_type]
提案 = 旧样本提案(delta_type, payload=原字段, quote=引文)
结果 = 校验事实批次((提案,), **旧增量环境)
assert 结果[0].type_id == delta_type and 结果[0].object_id
assert 结果[0].evidence[0].quote == 引文
def test_subject_key_derivation__e42f4b(旧增量环境):
提案 = 旧样本提案(
"relationship_changed",
payload={"fromName": "林深", "toName": "何岚", "relation": "同盟"},
quote="何岚站在舱门口没有说话",
)
结果 = 校验事实批次((提案,), **旧增量环境)[0]
assert (结果.content["甲方"], 结果.content["乙方"]) == ("character-lin", "character-he")
hook = 旧样本提案(
"hook_advanced",
payload={"hookId": "hook-voice", "action": "planted"},
quote="异种核心在胸腔里低鸣",
)
assert 校验事实批次((hook,), **旧增量环境)[0].object_id == "hook-voice"
def test_closed_field_set_and_type_enum__42dd3b(旧增量环境):
extra = asdict(旧样本提案())
extra["extraField"] = 1
with pytest.raises(ValidationError):
TypeAdapter(事实条目).validate_python(extra)
bad_type = 旧样本提案("character_resurrected")
with pytest.raises(事实错误) as 错误:
校验事实批次((bad_type,), **旧增量环境)
assert 错误.value.错误码 == "FACT_TYPE_UNKNOWN"
def test_payload_contract_enforced_per_type__a30962(旧增量环境):
missing = 旧样本提案(payload={"characterName": "林深"})
with pytest.raises(元数据错误):
校验事实批次((missing,), **旧增量环境)
action = 旧样本提案(
"hook_advanced",
payload={"hookId": "h1", "action": "detonated"},
quote="异种核心在胸腔里低鸣",
)
with pytest.raises(元数据错误):
校验事实批次((action,), **旧增量环境)
window = 旧样本提案(
"hook_advanced",
payload={
"hookId": "h1",
"action": "planted",
"dueWindow": {"fromChapter": 9, "toChapter": 3},
},
quote="异种核心在胸腔里低鸣",
)
with pytest.raises(事实错误) as 错误:
校验事实批次((window,), **旧增量环境)
assert 错误.value.错误码 == "FACT_TIME_INVALID"
def test_batch_rejects_duplicate_ids__b430df(旧增量环境):
首 = 旧样本提案(delta_id="delta-1")
后 = 旧样本提案(delta_id="delta-1", payload={"characterName": "何岚", "toLocation": "指挥舱"})
with pytest.raises(事实错误) as 错误:
校验事实批次((首, 后), **旧增量环境)
assert 错误.value.错误码 == "FACT_DUPLICATE_ID"
assert [
x.proposal_id for x in 校验事实批次((首, replace(后, proposal_id="delta-2")), **旧增量环境)
] == ["delta-1", "delta-2"]
def test_salvage_drops_unbound_relation_and_keeps_exact_name_evidence__8101bb():
原输出 = {
"entities": [
{
"type": "item",
"name": "黑色钥匙",
"brief": "钥匙",
"fields": {},
"evidence": "模型编造",
},
{
"type": "item",
"name": "不存在的物件",
"brief": "?",
"fields": {},
"evidence": "模型编造",
},
],
"relations": [
{
"source": "林深",
"target": "何岚",
"type": "伙伴",
"description": "?",
"evidence": "模型编造",
}
],
"state": {},
}
结果 = 保留名称提及(原输出, 单段(原抽取正文), tuple(s.类型 for s in 读取内置种子()))
assert [x["name"] for x in 结果["mentions"]] == ["黑色钥匙"]
assert 结果["mentions"][0]["evidence"].quote == "黑色钥匙"
assert 结果["relations"] == [] and 结果["mechanical_drops"] == {"entities": 1, "relations": 1}
assert "brief" not in 结果["mentions"][0] and 结果["state"] == "needs_confirmation"

View File

@ -0,0 +1,715 @@
"""真实元数据、正文、B02与S01共同验证事实来源和时点,不调用模型。"""
from dataclasses import replace
import pytest
from pydantic import TypeAdapter
from muse.作品规划.接口 import 档案保存
from muse.元数据.接口 import 元数据服务, 导入内置结构
from muse.共享.调用身份 import 内容用途, 用途, 调用身份
from muse.共享.错误 import Muse错误
from muse.启动 import 构建
from muse.故事世界.接口 import 事实定位, 事实提案请求, 定位字面引文, 正文依据
from muse.正式变更.接口 import 作者动作
from muse.正文写作.接口 import 文本节点, 正文草稿, 段落
from muse.配置 import 应用配置
pytestmark = pytest.mark.数据库
@pytest.fixture
def 故事环境(应用测试库):
with 应用测试库[用途.维护].连接() as 连, 连.transaction():
导入内置结构(元数据服务(连))
装配 = 构建(应用配置(应用测试库[用途.生产].引用, "synthetic"))
作者 = 调用身份("author", None, 用途.生产, 内容用途.规划)
作品 = 装配.要求作品()
结构 = 作品.新档案结构(作者, "story-work", "work_core", 1)
作品.保存档案(作者, "create-story", 档案保存("story-work", 0, {"名称": "合成故事世界"}, 结构))
for i in range(1, 4):
作品.添加章节(
作者, f"chapter-{i}", "story-work", f"chapter-{i}", f"第{i}幕", 预期目录版本=i - 1
)
正文 = 装配.要求正文()
for i, text in [
(1, "林深停在码头,何岚站在船旁。"),
(2, "何岚知道林深已经登船。"),
(3, "林深来到北岸。"),
]:
正文.保存人工(
作者, f"body-{i}", f"chapter-{i}", 0, 正文草稿((段落(f"p{i}", (文本节点(text),)),))
)
return 装配, 作者, 应用测试库
def 来源(装配, 作者, chapter, quote):
记录 = 装配.要求正文().读取正文(作者, chapter)
原文 = TypeAdapter(正文草稿).validate_python(记录["document"])
return 正文依据(
chapter, "main", 记录["revision"], 记录["document_hash"], (定位字面引文(原文, quote),)
)
def 提案(
装配,
作者,
object_id,
*,
sources=(),
independent=False,
effective="chapter-1",
mode="fact",
known_to=(),
):
世界 = 装配.要求故事世界()
结构 = 世界.新事实结构(作者, "story-work", object_id, "character", 1)
请求 = 事实提案请求(
"story-work",
object_id,
0,
结构,
{"名称": object_id},
tuple(sources),
effective,
independent=independent,
knowledge_mode=mode,
known_to=tuple({"actor_id": id_, "from_chapter_id": effective} for id_ in known_to),
)
回执 = 世界.提出事实(作者, "propose-" + object_id, 请求)
记录 = 世界.读取提案(作者, 回执["results"][0]["proposal_id"])
return 记录, 事实定位(
记录["proposal_id"], 记录["revision"], 记录["proposal_hash"], "world:" + object_id, 0
)
def 确认(装配, 作者, 定位, command):
世界 = 装配.要求故事世界()
审阅 = 世界.打开事实审阅(作者, 定位)
return 世界.决定事实(
作者, command, 定位, 作者动作.采纳, 审阅["review_id"], 审阅["review_hash"], ("object",)
)
def test_事实确认与来源失效保留独立设定__ac1001(故事环境):
装配, 作者, _ = 故事环境
源 = 来源(装配, 作者, "chapter-1", "林深停在码头")
_, 位置 = 提案(装配, 作者, "person-dependent", sources=(源,))
assert 装配.要求故事世界().查询世界(作者, "story-work", "chapter-1")["facts"] == []
回执 = 确认(装配, 作者, 位置, "confirm-dependent")
assert 回执["results"][0]["revision"] == 1
_, 独立 = 提案(装配, 作者, "person-independent", independent=True)
确认(装配, 作者, 独立, "confirm-independent")
初 = 装配.要求故事世界().查询世界(作者, "story-work", "chapter-1")
assert {v["object_id"] for v in 初["facts"]} == {"person-dependent", "person-independent"}
装配.要求正文().保存人工(
作者,
"changed-source",
"chapter-1",
1,
正文草稿((段落("p1", (文本节点("码头上没有人。"),)),)),
)
后 = 装配.要求故事世界().查询世界(作者, "story-work", "chapter-1")
assert {v["object_id"] for v in 后["facts"]} == {"person-independent"}
assert {v["object_id"]: v["reason"] for v in 后["omitted"]}[
"person-dependent"
] == "SOURCE_STALE"
def test_叙事时点与系统确认截止分开__ac1002(故事环境):
装配, 作者, _ = 故事环境
_, 一 = 提案(装配, 作者, "person-before", independent=True)
确认(装配, 作者, 一, "confirm-before")
切点 = 装配.要求故事世界().查询世界(作者, "story-work", "chapter-1")["system_revision"]
_, 后 = 提案(
装配,
作者,
"person-future",
sources=(来源(装配, 作者, "chapter-3", "林深来到北岸"),),
effective="chapter-3",
)
确认(装配, 作者, 后, "confirm-future")
assert {
v["object_id"]
for v in 装配.要求故事世界().查询世界(作者, "story-work", "chapter-1")["facts"]
} == {"person-before"}
assert {
v["object_id"]
for v in 装配.要求故事世界().查询世界(作者, "story-work", "chapter-3", 系统版本=切点)[
"facts"
]
} == {"person-before"}
assert {
v["object_id"]
for v in 装配.要求故事世界().查询世界(作者, "story-work", "chapter-3")["facts"]
} == {"person-before", "person-future"}
def test_后文披露早期事件和角色猜测分别查询__ac1003(故事环境):
装配, 作者, _ = 故事环境
_, 早期 = 提案(
装配,
作者,
"past-recalled",
sources=(来源(装配, 作者, "chapter-3", "林深来到北岸"),),
effective="chapter-1",
)
确认(装配, 作者, 早期, "confirm-recalled")
世界 = 装配.要求故事世界()
assert [v["object_id"] for v in 世界.查询世界(作者, "story-work", "chapter-1")["facts"]] == [
"past-recalled"
]
读者 = 世界.查询世界(作者, "story-work", "chapter-1", 视角="reader")
assert 读者["facts"] == [] and 读者["omitted"][0]["reason"] == "FUTURE_EVIDENCE"
assert len(世界.查询世界(作者, "story-work", "chapter-3", 视角="reader")["facts"]) == 1
_, 主体 = 提案(装配, 作者, "knower", independent=True)
确认(装配, 作者, 主体, "confirm-knower")
_, 猜测 = 提案(装配, 作者, "guess", independent=True, mode="belief", known_to=("knower",))
确认(装配, 作者, 猜测, "confirm-guess")
_, 计划 = 提案(装配, 作者, "future-plan", independent=True, mode="plan", known_to=("knower",))
确认(装配, 作者, 计划, "confirm-plan")
角色 = 世界.查询世界(作者, "story-work", "chapter-1", 视角="character", 知情主体ID="knower")
assert [x["object_id"] for x in 角色["beliefs"]] == ["guess"]
assert [x["object_id"] for x in 角色["plans"]] == ["future-plan"]
assert 角色["facts"] == []
def test_其他充分正文依据保持已确认事实有效__ac1004(故事环境):
装配, 作者, _ = 故事环境
一 = 来源(装配, 作者, "chapter-1", "林深停在码头")
二 = 来源(装配, 作者, "chapter-3", "林深来到北岸")
_, 定位 = 提案(装配, 作者, "two-sources", sources=(一, 二))
确认(装配, 作者, 定位, "confirm-two-sources")
装配.要求正文().保存人工(
作者,
"change-one-source",
"chapter-1",
1,
正文草稿((段落("p1", (文本节点("码头上没有人。"),)),)),
)
当前 = 装配.要求故事世界().查询世界(作者, "story-work", "chapter-3")
assert [v["object_id"] for v in 当前["facts"]] == ["two-sources"]
def test_源变后旧审阅不能确认但可以重新审阅拒绝__ac1005(故事环境):
装配, 作者, _ = 故事环境
世界 = 装配.要求故事世界()
_, 定位 = 提案(
装配, 作者, "stale-review", sources=(来源(装配, 作者, "chapter-1", "林深停在码头"),)
)
审阅 = 世界.打开事实审阅(作者, 定位)
装配.要求正文().保存人工(
作者,
"change-reviewed-source",
"chapter-1",
1,
正文草稿((段落("p1", (文本节点("没有人停在码头。"),)),)),
)
with pytest.raises(Muse错误) as 捕获:
世界.决定事实(
作者,
"stale-accept",
定位,
作者动作.采纳,
审阅["review_id"],
审阅["review_hash"],
("object",),
)
assert 捕获.value.错误码 == "SOURCE_STALE"
新审阅 = 世界.打开事实审阅(作者, 定位)
回执 = 世界.决定事实(
作者, "reject-stale", 定位, 作者动作.拒绝, 新审阅["review_id"], 新审阅["review_hash"], ()
)
assert (
回执["action"] == "reject" and 世界.查询世界(作者, "story-work", "chapter-1")["facts"] == []
)
def test_新动态类型与计划窗口贯通真实持久化__ac1006(故事环境):
from muse.元数据.接口 import 值规则, 字段定义, 类型定义, 结构定义
from muse.故事世界.接口 import 叙事窗口
装配, 作者, 库 = 故事环境
with 库[用途.维护].连接() as 连, 连.transaction():
元数据 = 元数据服务(连)
元数据.登记类型(类型定义("landing_point", "登船点", "world", "entity", ("entity",)))
结构 = 结构定义(
"landing_point",
"landing_point",
1,
(
字段定义("landing.name", "名称", "名称", 值规则("text"), 必填=True),
字段定义(
"landing.hints", "位置提示", "位置提示", 值规则("list", 元素=值规则("text"))
),
),
)
元数据.登记结构候选(结构)
元数据.发布结构("landing_point", 1, 结构.内容哈希)
世界 = 装配.要求故事世界()
选择 = 世界.新事实结构(作者, "story-work", "landing-one", "landing_point", 1)
请求 = 事实提案请求(
"story-work",
"landing-one",
0,
选择,
{"名称": "码头", "位置提示": ["船旁"]},
(来源(装配, 作者, "chapter-1", "林深停在码头,何岚站在船旁"),),
"chapter-1",
planned_window=叙事窗口("chapter-2", "chapter-3"),
)
with pytest.raises(Muse错误) as 捕获:
世界.提出事实(
作者, "bad-window", replace(请求, planned_window=叙事窗口("chapter-3", "chapter-1"))
)
assert 捕获.value.错误码 == "FACT_TIME_INVALID"
回执 = 世界.提出事实(作者, "dynamic-proposal", 请求)
原 = 世界.读取提案(作者, 回执["results"][0]["proposal_id"])
确认(
装配,
作者,
事实定位(原["proposal_id"], 1, 原["proposal_hash"], "world:landing-one", 0),
"confirm-dynamic",
)
实体 = 世界.查询世界(作者, "story-work", "chapter-1")["facts"][0]
assert 实体["type_id"] == "landing_point" and 实体["content"] == 请求.content
assert 实体["planned_window"] == {"from_chapter_id": "chapter-2", "to_chapter_id": "chapter-3"}
读者 = 世界.查询世界(作者, "story-work", "chapter-1", 视角="reader")["facts"][0]
assert "planned_window" not in 读者
def test_HTTP事实提案确认与查询使用真实作者会话__ac1007(故事环境, tmp_path):
from dataclasses import asdict
from fastapi.testclient import TestClient
from muse.接入.http.应用 import 创建应用
from muse.配置 import 服务配置
装配, 作者, 库 = 故事环境
口令 = tmp_path / "fact-author.txt"
口令.write_text("synthetic-fact-author")
口令.chmod(0o600)
配置 = 应用配置(
库[用途.生产].引用,
"synthetic",
HTTP=服务配置(
str(口令),
作者ID="author",
公开地址="http://testserver",
允许来源=("http://testserver",),
),
)
with TestClient(创建应用(配置), headers={"origin": "http://testserver"}) as client:
assert client.get("/api/v1/works/story-work/facts").status_code == 401
assert (
client.post("/api/v1/session", json={"password": "synthetic-fact-author"}).status_code
== 200
)
frame = client.get(
"/api/v1/works/story-work/facts/schema",
params={"object_id": "http-person", "schema_id": "character"},
)
assert frame.status_code == 200, frame.text
req = {
"work_id": "story-work",
"object_id": "http-person",
"expected_revision": 0,
"schema": frame.json(),
"content": {"名称": "林深"},
"sources": [asdict(来源(装配, 作者, "chapter-1", "林深停在码头"))],
"effective_from": "chapter-1",
}
creation = {"command_id": "http-fact", "proposal": req}
result = client.post("/api/v1/works/story-work/fact-proposals", json=creation)
assert result.status_code == 200, result.text
assert (
client.post("/api/v1/works/story-work/fact-proposals", json=creation).json()
== result.json()
)
pid = result.json()["results"][0]["proposal_id"]
item = client.get(f"/api/v1/fact-proposals/{pid}").json()
assert client.get("/api/v1/works/story-work/facts").json()["facts"] == []
review_req = {
"proposal_id": pid,
"proposal_revision": 1,
"proposal_hash": item["proposal_hash"],
"target_ref": "world:http-person",
"expected_revision": 0,
}
review = client.post(f"/api/v1/fact-proposals/{pid}/reviews", json=review_req)
assert review.status_code == 200, review.text
decision = {
**review_req,
"command_id": "http-fact-confirm",
"action": "adopt",
"author_review_id": review.json()["review_id"],
"review_hash": review.json()["review_hash"],
"approved_changes": ["object"],
}
assert (
client.post(
f"/api/v1/fact-proposals/{pid}/decisions", json={**decision, "decided_by": "author"}
).status_code
== 422
)
accepted = client.post(f"/api/v1/fact-proposals/{pid}/decisions", json=decision)
assert accepted.status_code == 200, accepted.text
assert (
client.post(f"/api/v1/fact-proposals/{pid}/decisions", json=decision).json()
== accepted.json()
)
facts = client.get(
"/api/v1/works/story-work/facts", params={"as_of": "chapter-1", "view": "reader"}
).json()
assert facts["facts"][0]["content"]["名称"] == "林深" and facts["facts"][0]["revision"] == 1
def 关系提案(装配, 作者, object_id="relation-main"):
世界 = 装配.要求故事世界()
_, 甲 = 提案(
装配, 作者, "person-left", sources=(来源(装配, 作者, "chapter-1", "林深停在码头"),)
)
确认(装配, 作者, 甲, "confirm-left")
_, 乙 = 提案(装配, 作者, "person-right", independent=True)
确认(装配, 作者, 乙, "confirm-right")
schema = 世界.新事实结构(
作者, "story-work", object_id, "character_relation", 1, 家族="relation"
)
req = 事实提案请求(
"story-work",
object_id,
0,
schema,
{"甲方": "person-left", "乙方": "person-right"},
(来源(装配, 作者, "chapter-2", "何岚知道林深已经登船"),),
"chapter-2",
instance_family="relation",
)
return req
def test_关系端点和正文引文必须真实__ac1008(故事环境):
装配, 作者, _ = 故事环境
世界 = 装配.要求故事世界()
req = 关系提案(装配, 作者)
with pytest.raises(Muse错误):
世界.提出事实(作者, "missing-endpoint", replace(req, content={"甲方": "person-left"}))
with pytest.raises(Muse错误) as 错:
世界.提出事实(
作者,
"unknown-endpoint",
replace(req, content={"甲方": "person-left", "乙方": "missing-person"}),
)
assert 错.value.错误码 == "FACT_REFERENCE_INVALID"
anchor = replace(
req.sources[0].evidence[0], quote="正文没有这条依据句", end=len("正文没有这条依据句")
)
with pytest.raises(Muse错误) as 错:
世界.提出事实(
作者, "fake-quote", replace(req, sources=(replace(req.sources[0], evidence=(anchor,)),))
)
assert 错.value.错误码 == "FACT_QUOTE_NOT_FOUND"
result = 世界.提出事实(作者, "real-relation", req)
item = 世界.读取提案(作者, result["results"][0]["proposal_id"])
确认(
装配,
作者,
事实定位(item["proposal_id"], 1, item["proposal_hash"], "world:relation-main", 0),
"confirm-relation",
)
assert any(
v["object_id"] == "relation-main"
for v in 世界.查询世界(作者, "story-work", "chapter-2")["facts"]
)
def test_关系确认同时保护端点的正文依据__ac1009(故事环境, monkeypatch):
from concurrent.futures import ThreadPoolExecutor
from threading import Event
from time import monotonic, sleep
from muse.故事世界.存储 import 故事存储
装配, 作者, 库 = 故事环境
世界 = 装配.要求故事世界()
req = 关系提案(装配, 作者)
result = 世界.提出事实(作者, "relation-with-lock", req)
item = 世界.读取提案(作者, result["results"][0]["proposal_id"])
loc = 事实定位(item["proposal_id"], 1, item["proposal_hash"], "world:relation-main", 0)
review = 世界.打开事实审阅(作者, loc)
entered, release = Event(), Event()
pids = []
original = 故事存储.写对象
def pause_write(self, *args, **kwargs):
# 只暂停调度,随后调用真实存储;不替换数据库行为或回执。
pids.append(self.连.info.backend_pid)
entered.set()
assert release.wait(5)
return original(self, *args, **kwargs)
monkeypatch.setattr(故事存储, "写对象", pause_write)
with ThreadPoolExecutor(2) as pool:
accepted = pool.submit(
世界.决定事实,
作者,
"locked-confirm",
loc,
作者动作.采纳,
review["review_id"],
review["review_hash"],
("object",),
)
assert entered.wait(5)
edited = pool.submit(
装配.要求正文().保存人工,
作者,
"parallel-source-write",
"chapter-1",
1,
正文草稿((段落("p1", (文本节点("新的码头段落。"),)),)),
)
try:
end = monotonic() + 3
blocked = False
while monotonic() < end and not blocked:
with 库[用途.维护].连接() as conn:
blocked = conn.execute(
"SELECT EXISTS(SELECT 1 FROM pg_stat_activity "
"WHERE %s=ANY(pg_blocking_pids(pid)))",
(pids[0],),
).fetchone()[0]
if not blocked:
sleep(0.01)
assert blocked and not edited.done()
finally:
release.set()
assert accepted.result(timeout=5)["results"][0]["object_id"] == "relation-main"
assert edited.result(timeout=5)["results"][0]["revision"] == 2
def test_跨作品来源和提案目标拒绝__ac1010(故事环境):
装配, 作者, _ = 故事环境
作品 = 装配.要求作品()
schema = 作品.新档案结构(作者, "another-work", "work_core", 1)
作品.保存档案(
作者, "create-another", 档案保存("another-work", 0, {"名称": "另一部合成作品"}, schema)
)
作品.添加章节(
作者, "another-chapter", "another-work", "another-chapter", "另一章", 预期目录版本=0
)
装配.要求正文().保存人工(
作者,
"another-body",
"another-chapter",
0,
正文草稿((段落("other-p", (文本节点("另一部作品的角色。"),)),)),
)
世界 = 装配.要求故事世界()
frame = 世界.新事实结构(作者, "story-work", "wrong-source", "character", 1)
req = 事实提案请求(
"story-work",
"wrong-source",
0,
frame,
{"名称": "另一部作品的角色"},
(来源(装配, 作者, "another-chapter", "另一部作品的角色"),),
"chapter-1",
)
with pytest.raises(Muse错误) as err:
世界.提出事实(作者, "cross-source", req)
assert err.value.错误码 == "SCOPE_DENIED"
_, loc = 提案(装配, 作者, "own-person", independent=True)
with pytest.raises(Muse错误) as err:
世界.打开事实审阅(作者, replace(loc, target_ref="world:another-person"))
assert err.value.错误码 == "SCOPE_DENIED"
assert 世界.查询世界(作者, "story-work", "chapter-1")["facts"] == []
def test_事实确认失败回滚且原命令可恢复__ac1011(故事环境):
import psycopg
装配, 作者, 库 = 故事环境
_, loc = 提案(装配, 作者, "rollback-person", independent=True)
世界 = 装配.要求故事世界()
review = 世界.打开事实审阅(作者, loc)
with 库[用途.维护].连接() as conn:
conn.execute("""CREATE FUNCTION reject_world_decision() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN RAISE EXCEPTION 'synthetic world decision failure'; END $$;
CREATE TRIGGER reject_world_decision BEFORE INSERT ON muse_candidate_decision
FOR EACH ROW EXECUTE FUNCTION reject_world_decision();""")
args = (
作者,
"world-retry",
loc,
作者动作.采纳,
review["review_id"],
review["review_hash"],
("object",),
)
with pytest.raises(psycopg.Error):
世界.决定事实(*args)
assert 世界.查询世界(作者, "story-work", "chapter-1")["system_revision"] == 0
with 库[用途.维护].连接() as conn:
assert conn.execute("SELECT count(*) FROM muse_world_object").fetchone()[0] == 0
conn.execute("DROP TRIGGER reject_world_decision ON muse_candidate_decision")
receipt = 世界.决定事实(*args)
assert 世界.决定事实(*args) == receipt
assert receipt["results"][0]["system_revision"] == 1
def test_事实修订追加版本且历史系统截止仍可回查__ac1012(故事环境):
装配, 作者, _ = 故事环境
世界 = 装配.要求故事世界()
_, loc = 提案(装配, 作者, "versioned-person", independent=True)
首 = 确认(装配, 作者, loc, "confirm-v1")
frame = 世界.新事实结构(作者, "story-work", "versioned-person", "character", 1)
req = 事实提案请求(
"story-work",
"versioned-person",
1,
frame,
{"名称": "作者修订后的名字"},
(),
"chapter-2",
independent=True,
)
created = 世界.提出事实(作者, "revised-proposal", req)
p = 世界.读取提案(作者, created["results"][0]["proposal_id"])
确认(
装配,
作者,
事实定位(p["proposal_id"], 1, p["proposal_hash"], "world:versioned-person", 1),
"confirm-v2",
)
assert (
世界.查询世界(作者, "story-work", "chapter-2")["facts"][0]["content"]["名称"]
== "作者修订后的名字"
)
早期 = 世界.查询世界(作者, "story-work", "chapter-1")["facts"][0]
assert 早期["revision"] == 1
历史 = 世界.查询世界(
作者, "story-work", "chapter-2", 系统版本=首["results"][0]["system_revision"]
)["facts"][0]
assert 历史["revision"] == 1 and 历史["content"] == 早期["content"]
with pytest.raises(Muse错误) as err:
世界.提出事实(作者, "old-version-write", req)
assert err.value.错误码 == "REVISION_CONFLICT"
@pytest.mark.parametrize(
"模式,生效",
[("fact", "chapter-3"), ("plan", "chapter-1"), ("belief", "chapter-1")],
ids=["future", "plan", "belief"],
)
def test_事实引用不借用未来或猜测计划实体__ac1013(故事环境, 模式, 生效):
装配, 作者, _ = 故事环境
世界 = 装配.要求故事世界()
req = 关系提案(装配, 作者)
_, loc = 提案(装配, 作者, "unavailable-endpoint", independent=True, mode=模式, effective=生效)
确认(装配, 作者, loc, "confirm-unavailable-endpoint")
with pytest.raises(Muse错误) as 错:
世界.提出事实(
作者,
"fact-with-unavailable-endpoint",
replace(req, content={"甲方": "person-left", "乙方": "unavailable-endpoint"}),
)
assert 错.value.错误码 == "FACT_REFERENCE_INVALID"
# 规划仍能引用其时点内的规划对象,不能把计划伪装成事实。
if 模式 == "plan":
assert 世界.提出事实(
作者,
"planned-relation",
replace(
req,
knowledge_mode="plan",
content={"甲方": "person-left", "乙方": "unavailable-endpoint"},
),
)["results"]
def test_历史事实按保存结构解释并应用当前策略__ac1014(故事环境):
from muse.元数据.接口 import 值规则, 合成结构, 启用命令, 字段定义, 投影字段
from muse.故事世界.设定修订 import 事实授权
装配, 作者, 库 = 故事环境
世界 = 装配.要求故事世界()
_, loc = 提案(装配, 作者, "historical-schema", independent=True)
确认(装配, 作者, loc, "confirm-historical-schema")
before = 世界.查询世界(作者, "story-work", "chapter-1")["facts"][0]
with 库[用途.维护].连接() as 连, 连.transaction():
meta = 元数据服务(连)
old = meta.读取结构("character", 1)
new = replace(
old,
schema_version=2,
父版本=1,
字段=old.字段 + (字段定义("character.new", "新提示", "新提示", 值规则("text")),),
)
meta.登记结构候选(new)
meta.发布结构("character", 2, new.内容哈希)
effective = meta.读取有效结构("character", 2)
meta.启用绑定(
"world:historical-schema",
"story-work",
effective,
启用命令(
"schema-addition",
effective.effective_schema_hash,
1,
"author",
"world:historical-schema",
),
)
expected = 投影字段(
合成结构(old),
meta.当前策略("character"),
事实授权("author", "story-work"),
字段用途名="uiVisible",
内容用途="planning",
运行用途="production",
)
after = 世界.查询世界(作者, "story-work", "chapter-1")["facts"][0]
assert after["content"] == before["content"]
assert after["schema_binding"]["base_version"] == 1
assert after["projection_version"] == expected.projection_version
def test_角色获知时点独立于事件生效__ac1015(故事环境):
装配, 作者, _ = 故事环境
世界 = 装配.要求故事世界()
_, 主体 = 提案(装配, 作者, "late-knower", independent=True)
确认(装配, 作者, 主体, "confirm-late-knower")
schema = 世界.新事实结构(作者, "story-work", "late-known-event", "character", 1)
请求 = 事实提案请求(
"story-work",
"late-known-event",
0,
schema,
{"名称": "林深登船"},
(来源(装配, 作者, "chapter-1", "林深停在码头"),),
"chapter-1",
known_to=({"actor_id": "late-knower", "from_chapter_id": "chapter-3"},),
)
回执 = 世界.提出事实(作者, "propose-late-knowledge", 请求)
原 = 世界.读取提案(作者, 回执["results"][0]["proposal_id"])
确认(
装配,
作者,
事实定位(原["proposal_id"], 1, 原["proposal_hash"], "world:late-known-event", 0),
"confirm-late-knowledge",
)
assert (
世界.查询世界(作者, "story-work", "chapter-1", 视角="character", 知情主体ID="late-knower")[
"facts"
]
== []
)
assert [
v["object_id"]
for v in 世界.查询世界(
作者, "story-work", "chapter-3", 视角="character", 知情主体ID="late-knower"
)["facts"]
] == ["late-known-event"]

View File

@ -0,0 +1,52 @@
-- B02本书实例、不可变事实版本与提案;作者决定仍由S01保存。
CREATE TABLE public.muse_world_clock (
work_id text PRIMARY KEY REFERENCES public.muse_work(work_id),
system_revision bigint NOT NULL CHECK(system_revision>=0)
);
CREATE TABLE public.muse_world_object (
object_id text PRIMARY KEY,
work_id text NOT NULL REFERENCES public.muse_work(work_id),
type_id text NOT NULL,
instance_family text NOT NULL CHECK(instance_family IN ('entity','relation')),
current_revision bigint NOT NULL CHECK(current_revision>0)
);
CREATE TABLE public.muse_world_version (
object_id text NOT NULL REFERENCES public.muse_world_object(object_id),
revision bigint NOT NULL CHECK(revision>0),
system_revision bigint NOT NULL CHECK(system_revision>0),
content jsonb NOT NULL,
content_hash text NOT NULL,
schema_binding jsonb NOT NULL,
sources jsonb NOT NULL,
independent boolean NOT NULL,
effective_from text REFERENCES public.muse_chapter(chapter_id),
knowledge_mode text NOT NULL CHECK(knowledge_mode IN ('fact','belief','plan','setting')),
known_to jsonb NOT NULL,
reader_visible boolean NOT NULL,
planned_window jsonb,
author_id text NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY(object_id,revision)
);
ALTER TABLE public.muse_world_object ADD CONSTRAINT world_current_version_fk
FOREIGN KEY(object_id,current_revision) REFERENCES public.muse_world_version(object_id,revision)
DEFERRABLE INITIALLY DEFERRED;
CREATE TABLE public.muse_fact_proposal (
proposal_id uuid PRIMARY KEY,
work_id text NOT NULL REFERENCES public.muse_work(work_id),
object_id text NOT NULL,
current_revision bigint NOT NULL CHECK(current_revision>0)
);
CREATE TABLE public.muse_fact_proposal_version (
proposal_id uuid NOT NULL REFERENCES public.muse_fact_proposal(proposal_id),
revision bigint NOT NULL CHECK(revision>0),
request jsonb NOT NULL,
proposed_content jsonb NOT NULL,
proposal_hash text NOT NULL,
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY(proposal_id,revision)
);
ALTER TABLE public.muse_fact_proposal ADD CONSTRAINT fact_proposal_current_version_fk
FOREIGN KEY(proposal_id,current_revision) REFERENCES public.muse_fact_proposal_version(proposal_id,revision)
DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX world_version_system_idx ON public.muse_world_version(object_id,system_revision DESC);