zizi cf0f4fb985 W20–W24:保存知识方法、迁移框架、审校修订与案例行为的集成成果
接续 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。
2026-09-13 16:24:42 +08:00

408 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""旧快照身份与显式映射;不连接数据库,不把分流建议当作导入成功。"""
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