接续 88dd570,保存 W20–W24 已实现的共享接口、业务入口、迁移、工作台、测试与文档。 W20/W22/W23 保持 in_progress,W21/W24 保持 verified;此提交不宣称方法或规则正式启用、多轮返修、真实角色评测完成。 W25 新增实验、标定、逐调用交付与角色执行及其迁移/测试/索引留在实施工作树,原有私人和旧实现保留项不纳入。 验证:离线 571、前端 33 通过;PG 469 项通过、2 项浏览器未启用,2 项误带入的 W25 用例已移出本提交;最终任务与交付边界 37 项通过。make 检查、最终类型、84 项资源及 diff 检查通过。未重跑浏览器或 Pi 宿主,不以合成调用认证外部模型效果。 独立整体审查四维通过;证据保存在 R2-20260909/提交W20-W24。
408 lines
17 KiB
Python
408 lines
17 KiB
Python
"""旧快照身份与显式映射;不连接数据库,不把分流建议当作导入成功。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import hashlib
|
||
import json
|
||
import re
|
||
from dataclasses import asdict, dataclass
|
||
from datetime import date, datetime
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from uuid import NAMESPACE_URL, UUID, uuid5
|
||
|
||
|
||
class 迁移错误(ValueError):
|
||
def __init__(self, code: str, detail: str):
|
||
self.code, self.detail = code, detail
|
||
super().__init__(f"{code}: {detail}")
|
||
|
||
|
||
def 稳定JSON(值: Any) -> str:
|
||
def 编码(对象):
|
||
if isinstance(对象, (date, datetime, Decimal, UUID)):
|
||
return str(对象)
|
||
if isinstance(对象, bytes):
|
||
return {"__bytea_base64__": base64.b64encode(对象).decode("ascii")}
|
||
raise TypeError(f"快照不能隐式转换 {type(对象).__name__}")
|
||
|
||
return json.dumps(
|
||
值, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False, default=编码
|
||
)
|
||
|
||
|
||
def 读取JSON(文本: str):
|
||
def 组装(键值):
|
||
结果 = {}
|
||
for k, v in 键值:
|
||
if k in 结果:
|
||
raise 迁移错误("json_duplicate_key", "JSON包含重复键:" + k)
|
||
结果[k] = v
|
||
return 结果
|
||
|
||
def 拒绝常量(值):
|
||
raise 迁移错误("json_invalid", "JSON不能包含非有限数值")
|
||
|
||
return json.loads(文本, object_pairs_hook=组装, parse_constant=拒绝常量)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class 源身份:
|
||
system: str
|
||
database: str
|
||
table: str
|
||
id: str
|
||
revision: str
|
||
|
||
def __post_init__(self):
|
||
if self.system not in {"pg", "sqlite", "file"}:
|
||
raise 迁移错误("source_invalid", "未知来源系统")
|
||
if any(not isinstance(v, str) or not v.strip() for v in asdict(self).values()):
|
||
raise 迁移错误("source_invalid", "来源身份各项必须是明确的非空字符串")
|
||
|
||
@property
|
||
def 对象键(self) -> str:
|
||
return 稳定JSON([self.system, self.database, self.table, self.id])
|
||
|
||
@property
|
||
def 记录键(self) -> str:
|
||
return 稳定JSON([self.system, self.database, self.table, self.id, self.revision])
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class 旧快照:
|
||
source: 源身份
|
||
原文JSON: str
|
||
|
||
@classmethod
|
||
def 从载荷(cls, 载荷: dict) -> 旧快照:
|
||
if set(载荷) - {"source", "record", "source_hash"} or not isinstance(
|
||
载荷.get("record"), dict
|
||
):
|
||
raise 迁移错误("snapshot_invalid", "快照需要完整 record 与 source")
|
||
try:
|
||
源 = 源身份(**载荷["source"])
|
||
结果 = cls(源, 稳定JSON(载荷["record"]))
|
||
except (TypeError, KeyError, ValueError) as 错:
|
||
raise 迁移错误("snapshot_invalid", str(错)) from 错
|
||
for 键 in ("id", "revision"):
|
||
if 键 in 结果.原行 and str(结果.原行[键]) != getattr(源, 键):
|
||
raise 迁移错误("source_invalid", "原行与快照来源身份不一致:" + 键)
|
||
if 载荷.get("source_hash", 结果.源哈希) != 结果.源哈希:
|
||
raise 迁移错误("snapshot_hash_mismatch", "输入快照与自报哈希不一致")
|
||
return 结果
|
||
|
||
@property
|
||
def 原行(self) -> dict:
|
||
# 返回副本,归一或调用方修改不能改变冻结原文与源哈希。
|
||
return json.loads(self.原文JSON)
|
||
|
||
@property
|
||
def 源哈希(self) -> str:
|
||
return hashlib.sha256(self.原文JSON.encode()).hexdigest()
|
||
|
||
def 导出(self) -> dict:
|
||
return {"source": asdict(self.source), "record": self.原行, "source_hash": self.源哈希}
|
||
|
||
def 建议身份(self, owner: str, type_id: str, *, scope: str, author_id: str) -> str:
|
||
材料 = [self.source.对象键, owner, type_id, scope, author_id]
|
||
return str(uuid5(NAMESPACE_URL, "muse:legacy:" + 稳定JSON(材料)))
|
||
|
||
|
||
def 数据集命名空间(标识: str, **范围) -> str:
|
||
if (
|
||
not isinstance(标识, str)
|
||
or not 标识.strip()
|
||
or re.search(r"(?i)postgres(?:ql)?://|\b(?:password|host|dbname|user)\s*=", 标识)
|
||
):
|
||
raise 迁移错误("source_invalid", "数据集标识必须非空,不能使用连接串或凭据")
|
||
return 稳定JSON({"dataset": 标识, **范围})
|
||
|
||
|
||
def 旧表白名单(系统: str) -> set[str]:
|
||
if 系统 not in ("pg", "sqlite"):
|
||
raise 迁移错误("source_invalid", "未支持的旧库类型")
|
||
目录 = Path(__file__).parent / "旧结构" / ("PostgreSQL" if 系统 == "pg" else "SQLite")
|
||
模式 = re.compile(r"(?im)^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-z_][a-z0-9_]*)")
|
||
return {
|
||
名 for 路径 in 目录.glob("*.sql") for 名 in 模式.findall(路径.read_text(encoding="utf-8"))
|
||
}
|
||
|
||
|
||
def 封装旧行(系统: str, 命名空间: str, 表: str, 行: dict, 主键: list[str], 快照版本: str) -> 旧快照:
|
||
if not 主键 or any(行.get(k) is None for k in 主键):
|
||
raise 迁移错误("source_invalid", "旧记录缺少可解释的原生主键")
|
||
修订 = 行.get("revision") if "revision" in 行 else 快照版本
|
||
if 修订 is None or isinstance(修订, bool) or not str(修订).strip():
|
||
raise 迁移错误("source_invalid", "缺少原生修订或显式快照版本")
|
||
编号 = str(行["id"]) if 主键 == ["id"] else 稳定JSON({k: 行[k] for k in 主键})
|
||
return 旧快照.从载荷(
|
||
{
|
||
"source": {
|
||
"system": 系统,
|
||
"database": 命名空间,
|
||
"table": 表,
|
||
"id": 编号,
|
||
"revision": str(修订),
|
||
},
|
||
"record": 行,
|
||
}
|
||
)
|
||
|
||
|
||
def 核对读取上限(已读: int, 已读字节: int, 新快照: 旧快照, 最大行数: int) -> int:
|
||
字节 = 已读字节 + len(稳定JSON(新快照.导出()).encode()) + 1
|
||
if 已读 >= 最大行数 or 字节 > 16 * 1024 * 1024:
|
||
raise 迁移错误("snapshot_limit", "快照超出行数或16MiB限制;没有截断,请显式缩小源范围")
|
||
return 字节
|
||
|
||
|
||
def 检查读取请求(系统: str, 表: tuple[str, ...], 快照版本: str, 最大行数: int, 范围: dict) -> None:
|
||
if not 表 or len(set(表)) != len(表) or any(t not in 旧表白名单(系统) for t in 表):
|
||
raise 迁移错误("source_table_denied", "必须选择旧DDL登记的明确表集合")
|
||
if not isinstance(快照版本, str) or not 快照版本.strip():
|
||
raise 迁移错误("source_invalid", "必须提供明确快照版本,不能默认为原生修订")
|
||
if isinstance(最大行数, bool) or not isinstance(最大行数, int) or 最大行数 < 1:
|
||
raise 迁移错误("snapshot_limit", "最大行数必须为正整数")
|
||
if not isinstance(范围, dict) or set(范围) - set(表):
|
||
raise 迁移错误("source_range_invalid", "主键范围只能指定本次读取的表")
|
||
|
||
|
||
def 主键边界(主键: list[str], 范围: dict) -> list[tuple[str, list]]:
|
||
if not isinstance(范围, dict) or set(范围) - {"start", "end"}:
|
||
raise 迁移错误("source_range_invalid", "主键范围只能包含start与end")
|
||
结果 = []
|
||
for 键, 运算符 in (("start", ">="), ("end", "<")):
|
||
if 键 not in 范围:
|
||
continue
|
||
值 = 范围[键]
|
||
if not isinstance(值, list) or len(值) != len(主键) or any(v is None for v in 值):
|
||
raise 迁移错误("source_range_invalid", "范围值必须按原主键列顺序完整给出")
|
||
结果.append((运算符, 值))
|
||
return 结果
|
||
|
||
|
||
class 映射配置:
|
||
def __init__(self, 值: dict):
|
||
if "works" not in 值 or not isinstance(值["works"], list):
|
||
raise 迁移错误("work_map_required", "必须显式提供作品用途映射 works")
|
||
self.值 = json.loads(稳定JSON(值))
|
||
for 名 in (
|
||
"works",
|
||
"sources",
|
||
"objects",
|
||
"confirmations",
|
||
"routes",
|
||
"body_bindings",
|
||
"file_routes",
|
||
):
|
||
行 = self.值.get(名, [])
|
||
if not isinstance(行, list) or not all(isinstance(r, dict) for r in 行):
|
||
raise 迁移错误("mapping_invalid", 名 + " 必须是对象列表")
|
||
for r in 行:
|
||
for 键 in ("aliases", "refs"):
|
||
if 键 in r and (
|
||
not isinstance(r[键], list) or not all(isinstance(v, str) for v in r[键])
|
||
):
|
||
raise 迁移错误("mapping_invalid", 键 + " 必须是明确字符串列表")
|
||
for 名 in ("type_aliases", "schemas", "target_schemas"):
|
||
if not isinstance(self.值.get(名, {}), dict):
|
||
raise 迁移错误("mapping_invalid", 名 + " 必须是对象")
|
||
|
||
def 文件路由(self, 快照: 旧快照) -> dict:
|
||
if 快照.source.system != "file":
|
||
raise 迁移错误("file_route_invalid", "非文件来源不能使用文件路由")
|
||
项 = [
|
||
r for r in self.值.get("file_routes", []) if r.get("source_key") == 快照.source.记录键
|
||
]
|
||
if len(项) != 1:
|
||
raise 迁移错误(
|
||
"file_route_missing" if not 项 else "file_route_ambiguous", "文件路由缺失或多义"
|
||
)
|
||
r = 项[0]
|
||
if r.get("source_hash") != 快照.源哈希:
|
||
raise 迁移错误("file_route_drift", "文件路由不属于当前源快照")
|
||
基础 = {"source_key", "source_hash", "route"}
|
||
if r.get("route") == "archive" and set(r) == 基础:
|
||
return r
|
||
必需 = 基础 | {"format", "legacy_work_id", "target"}
|
||
if (
|
||
r.get("route") != "body_candidate"
|
||
or not 必需 <= set(r)
|
||
or set(r) - 必需 - {"paragraphs"}
|
||
):
|
||
raise 迁移错误("file_route_invalid", "文件用途或路由字段不符合合同")
|
||
if r["format"] not in ("text_utf8", "document_v1") or (
|
||
r["format"] != "text_utf8" and "paragraphs" in r
|
||
):
|
||
raise 迁移错误("file_format_invalid", "文件解释格式未登记或不接受分段表")
|
||
t = r["target"]
|
||
if not isinstance(t, dict) or set(t) != {"chapter_id", "branch_id", "base_revision"}:
|
||
raise 迁移错误("file_target_invalid", "正文目标须明确章、分支与基线")
|
||
if (
|
||
any(not isinstance(t[k], str) or not t[k].strip() for k in ("chapter_id", "branch_id"))
|
||
or type(t["base_revision"]) is not int
|
||
or t["base_revision"] < 0
|
||
):
|
||
raise 迁移错误("file_target_invalid", "正文目标标识或基线不合法")
|
||
作品 = self.作品(快照.source, r["legacy_work_id"], "author")
|
||
if 作品["use"] != "author":
|
||
raise 迁移错误("unknown_use", "正文文件只能指向明确作者作品")
|
||
return r
|
||
|
||
def 条目(self, 名: str, 源: 源身份) -> list[dict]:
|
||
return [
|
||
r
|
||
for r in self.值.get(名, [])
|
||
if r.get("system") == 源.system and r.get("database") == 源.database
|
||
]
|
||
|
||
def 选择用途(self, 快照: 旧快照) -> str | None:
|
||
项 = [
|
||
r for r in self.条目("routes", 快照.source) if r.get("source_key") == 快照.source.记录键
|
||
]
|
||
if not 项:
|
||
return None
|
||
if (
|
||
len(项) != 1
|
||
or 项[0].get("source_hash") != 快照.源哈希
|
||
or 项[0].get("use") not in ("author", "reference", "method")
|
||
):
|
||
raise 迁移错误("unknown_use", "逐条用途选择多义或与原快照不一致")
|
||
return 项[0]["use"]
|
||
|
||
def 作品(self, 源: 源身份, work_id: Any, use: str | None = None) -> dict:
|
||
if (
|
||
isinstance(work_id, bool)
|
||
or not isinstance(work_id, (str, int))
|
||
or not str(work_id).strip()
|
||
):
|
||
raise 迁移错误("unknown_use", "旧作品编号缺失或类型不符")
|
||
项 = [
|
||
r
|
||
for r in self.条目("works", 源)
|
||
if str(r.get("legacy_id")) == str(work_id) and (use is None or r.get("use") == use)
|
||
]
|
||
if len(项) != 1:
|
||
raise 迁移错误("unknown_use", "作品用途缺失或多义,必须明确选择")
|
||
结果 = 项[0]
|
||
if (
|
||
not isinstance(结果.get("use"), str)
|
||
or 结果["use"] not in {"author", "reference", "method"}
|
||
or not all(
|
||
isinstance(结果.get(k), str) and 结果[k] for k in ("target_ref", "author_id")
|
||
)
|
||
):
|
||
raise 迁移错误("unknown_use", "作品用途、作者或目标无效")
|
||
return 结果
|
||
|
||
def 来源(self, 源: 源身份, source_type: str, source_id: Any) -> dict:
|
||
if (
|
||
isinstance(source_id, bool)
|
||
or not isinstance(source_id, (str, int))
|
||
or not str(source_id).strip()
|
||
):
|
||
raise 迁移错误("missing_source", "旧来源编号缺失或类型不符")
|
||
项 = [
|
||
r
|
||
for r in self.条目("sources", 源)
|
||
if r.get("source_type") == source_type and str(r.get("legacy_id")) == str(source_id)
|
||
]
|
||
if len(项) != 1 or not all(
|
||
isinstance(项[0].get(k), str) and 项[0][k] for k in ("target_ref", "revision")
|
||
):
|
||
raise 迁移错误("missing_source", "来源版本没有唯一显式映射")
|
||
return 项[0]
|
||
|
||
def 实例来源(self, 源: 源身份, 书: str) -> dict:
|
||
项 = [r for r in self.条目("sources", 源) if 书 in r.get("aliases", [])]
|
||
if len(项) != 1 or not all(
|
||
isinstance(项[0].get(k), str) and 项[0][k] for k in ("target_ref", "revision")
|
||
):
|
||
raise 迁移错误("missing_source", "实例中的书缺少唯一显式来源映射")
|
||
return 项[0]
|
||
|
||
def 端点(self, 源: 源身份, work_id: Any, 引用: list[str], *, 作品: dict) -> str:
|
||
if not 引用:
|
||
raise 迁移错误("missing_endpoint", "关系端点缺失")
|
||
对象 = [
|
||
r
|
||
for r in self.条目("objects", 源)
|
||
if str(r.get("work_id")) == str(work_id)
|
||
and r.get("use") == 作品["use"]
|
||
and r.get("scope") == 作品["target_ref"]
|
||
]
|
||
强引用 = [r for r in 引用 if not r.startswith("name:")]
|
||
选用 = 强引用 or 引用
|
||
目标 = set()
|
||
for 引 in 选用:
|
||
命中 = [r for r in 对象 if 引 in r.get("refs", [])]
|
||
if (
|
||
len(命中) != 1
|
||
or not isinstance(命中[0].get("target_ref"), str)
|
||
or not 命中[0]["target_ref"]
|
||
):
|
||
raise 迁移错误("ambiguous_endpoint" if 命中 else "missing_endpoint", 引)
|
||
目标.add(命中[0]["target_ref"])
|
||
if len(目标) != 1:
|
||
raise 迁移错误("endpoint_conflict", "端点的多个身份指向不同对象")
|
||
target = next(iter(目标))
|
||
别名 = {别 for r in 对象 if r.get("target_ref") == target for 别 in r.get("refs", [])}
|
||
if any(引 not in 别名 for 引 in 引用):
|
||
raise 迁移错误("endpoint_conflict", "端点编号与明确名称不一致")
|
||
return target
|
||
|
||
def 确认(self, 快照: 旧快照, 旧目标引用: list[str], *, 作品: dict) -> dict | None:
|
||
项 = [
|
||
r
|
||
for r in self.条目("confirmations", 快照.source)
|
||
if r.get("source_key") == 快照.source.记录键 and r.get("source_hash") == 快照.源哈希
|
||
]
|
||
if len(项) == 1 and all(
|
||
isinstance(项[0].get(k), str) and 项[0][k] for k in ("target_ref", "decision_ref")
|
||
):
|
||
对象 = [
|
||
o
|
||
for o in self.条目("objects", 快照.source)
|
||
if o.get("target_ref") == 项[0]["target_ref"]
|
||
and str(o.get("work_id")) == str(快照.原行.get("work_id"))
|
||
and o.get("use") == 作品["use"]
|
||
and o.get("scope") == 作品["target_ref"]
|
||
]
|
||
if (
|
||
len(对象) == 1
|
||
and 旧目标引用
|
||
and all(引 in 对象[0].get("refs", []) for 引 in 旧目标引用)
|
||
):
|
||
return 项[0]
|
||
return None
|
||
|
||
|
||
class 身份映射:
|
||
"""映射台账的确定性规则;存储 owner 负责持久化,不在此伪装数据库提交。"""
|
||
|
||
def __init__(self):
|
||
self.记录: dict[str, dict] = {}
|
||
self.对象目标: dict[str, tuple[str, str]] = {}
|
||
|
||
def 登记(self, 快照: 旧快照, target_ref: str, rule_id: str, *, scope: str) -> bool:
|
||
key = 快照.source.记录键
|
||
新 = {
|
||
"source_hash": 快照.源哈希,
|
||
"target_ref": target_ref,
|
||
"rule_id": rule_id,
|
||
"scope": scope,
|
||
}
|
||
if key in self.记录:
|
||
if self.记录[key] != 新:
|
||
raise 迁移错误("drift", "同一源记录已存在不同内容或映射")
|
||
return False
|
||
目标身份 = (target_ref, scope)
|
||
if self.对象目标.get(快照.source.对象键, 目标身份) != 目标身份:
|
||
raise 迁移错误("identity_conflict", "同一旧对象不能因版本变化映射成另一个对象")
|
||
self.记录[key] = 新
|
||
self.对象目标[快照.source.对象键] = 目标身份
|
||
return True
|