#!/usr/bin/env python3 """根据固定卡索引计划回读冻结历史原文。 抽取卡只提供定位与历史状态索引。任何由卡承载的历史事实都必须展开 sourceRefs 并成功读取 asOf 以内的原文;正式设定、Canonical 状态和细纲 新事实通过独立不可变引用进入事实证据。 """ from __future__ import annotations import copy import hashlib import pathlib import sys from typing import Any, Callable, Mapping, Protocol, Sequence from writer_contract import PLAN_VERSION, TIE_BREAK, canonical_json, normalize_text, retrieval_identity class RetrievalError(ValueError): """检索计划、授权、冻结或来源引用不能证明安全时抛出。""" class CardIndexRepository(Protocol): """卡索引仓储只接收固定计划,不允许写手自行追加查询。""" def search(self, plan: Mapping[str, Any]) -> list[dict[str, Any]]: """返回带稳定排序字段、历史里程碑和来源指针的卡索引。""" class ProseRepository(Protocol): """原文仓储只能读取冻结线内的不可变来源引用。""" def read_source_refs( self, *, work_id: int, as_of: int, source_refs: Sequence[Mapping[str, Any]], ) -> list[dict[str, Any]]: """展开已校验的来源指针。""" def _load_search_cards() -> Callable[..., list[dict[str, Any]]]: """延迟导入 search skill,保持纯数据测试不触发嵌入依赖。""" search_scripts = pathlib.Path(__file__).resolve().parents[2] / "search" / "scripts" sys.path.insert(0, str(search_scripts)) from search import search_cards return search_cards def _chapter(value: Any, path: str) -> int: """严格解析正整数章号,拒绝 bool 和猜测性字符串。""" if isinstance(value, bool): raise RetrievalError(f"{path} 必须是正整数章号") if isinstance(value, str) and value.isdigit(): value = int(value) if not isinstance(value, int) or value <= 0: raise RetrievalError(f"{path} 必须是正整数章号") return value def _milestone_chapter(value: Mapping[str, Any]) -> int: """兼容实验库中英文里程碑章号字段,但不解析模糊文本。""" for key in ("chapter", "章", "chapterNo", "order_no"): if key in value: return _chapter(value[key], f"milestone.{key}") raise RetrievalError("卡里程碑缺少可证明的绝对章号") def stable_sort_cards(cards: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: """按合同固定卡片顺序,同分召回不会因数据库执行计划漂移。""" copied = [copy.deepcopy(dict(card)) for card in cards] try: return sorted( copied, key=lambda item: ( -float(item["score"]), str(item["sourceVersion"]), str(item["sourceId"]), int(item.get("sourceOffset") or 0), ), ) except (KeyError, TypeError, ValueError) as error: raise RetrievalError("卡缺少稳定排序字段") from error def _outline_elements(fine_outline: Mapping[str, Any]) -> list[dict[str, str]]: """按合同顺序提取需要检索的人物、关系、物品、地点和力量体系。""" groups = ( ("entities", None), ("relations", "character_relation"), ("items", "item"), ("locations", "location"), ("powerSystems", "power_system"), ("stateNeeds", "unknown"), ) result: list[dict[str, str]] = [] seen: set[tuple[str, str]] = set() for field, fallback_type in groups: values = fine_outline.get(field, []) if not isinstance(values, list): raise RetrievalError(f"fineOutline.{field} 必须是数组") for index, raw in enumerate(values): if isinstance(raw, str): item = {"id": f"{field}:{index}", "type": fallback_type or "unknown", "name": raw} elif isinstance(raw, Mapping): item = { "id": str(raw.get("id") or f"{field}:{index}"), "type": str(raw.get("type") or fallback_type or "unknown"), "name": str(raw.get("name") or ""), } else: raise RetrievalError(f"fineOutline.{field}[{index}] 类型非法") if not item["name"]: raise RetrievalError(f"fineOutline.{field}[{index}] 缺少 name") identity = (item["type"], item["name"]) if identity not in seen: seen.add(identity) result.append(item) return result def build_retrieval_plan( *, run_id: str, work_id: int, target_chapter: int, as_of: int, fine_outline: Mapping[str, Any], card_index_version: str, prose_index_version: str, token_budget: Mapping[str, int], top_k: int = 5, ) -> dict[str, Any]: """从已确认细纲确定查询集合,并在执行前冻结计划身份。""" target = _chapter(target_chapter, "targetChapter") freeze = _chapter(as_of, "asOf") if freeze >= target: raise RetrievalError("asOf 必须早于 targetChapter") if not isinstance(work_id, int) or isinstance(work_id, bool) or work_id <= 0: raise RetrievalError("workId 必须是正整数") if not isinstance(top_k, int) or isinstance(top_k, bool) or top_k <= 0: raise RetrievalError("topK 必须是正整数") hard_constraints = fine_outline.get("hardConstraints", []) if not isinstance(hard_constraints, list) or any(not isinstance(item, str) for item in hard_constraints): raise RetrievalError("fineOutline.hardConstraints 必须是字符串数组") suffix = " ".join(hard_constraints) queries = [ { "queryId": f"query-{index:03d}-{item['id']}", "text": normalize_text(f"{item['name']} {suffix}".strip()), "entityTypes": [item["type"]], "purpose": "fact_and_prose_evidence", "topK": top_k, } for index, item in enumerate(_outline_elements(fine_outline), 1) ] plan = { "planVersion": PLAN_VERSION, "runId": run_id, "asOf": freeze, "queries": queries, "cardIndexVersion": str(card_index_version), "proseIndexVersion": str(prose_index_version), "filters": { "workId": work_id, "asOfChapter": freeze, "sourceStatus": "active", "authorizationRequired": True, }, "tieBreak": TIE_BREAK, "tokenBudget": copy.deepcopy(dict(token_budget)), } plan["planId"] = retrieval_identity(plan) return plan def _validate_source_ref(ref: Mapping[str, Any], *, as_of: int, path: str) -> dict[str, Any]: """校验块级原文引用完整且不越过冻结线。""" required = {"sourceId", "sourceVersion", "chapter", "blockId", "startCodePoint", "endCodePoint"} if not isinstance(ref, Mapping) or not required.issubset(ref): raise RetrievalError(f"{path} 缺少块级来源定位") chapter = _chapter(ref["chapter"], f"{path}.chapter") if chapter > as_of: raise RetrievalError(f"{path} 包含目标章或未来章") start = ref["startCodePoint"] end = ref["endCodePoint"] if any(isinstance(value, bool) or not isinstance(value, int) for value in (ref["blockId"], start, end)): raise RetrievalError(f"{path} 的块或字符区间非法") if ref["blockId"] <= 0 or start < 0 or end <= start: raise RetrievalError(f"{path} 的块或字符区间非法") if not str(ref["sourceId"]).strip() or not str(ref["sourceVersion"]).strip(): raise RetrievalError(f"{path} 缺少来源 ID 或版本") return copy.deepcopy(dict(ref)) def freeze_card(card: Mapping[str, Any], *, as_of: int) -> dict[str, Any]: """仅用冻结线内里程碑重建卡状态,不透传终态摘要和未来字段。""" freeze = _chapter(as_of, "asOf") milestones = card.get("milestones") if not isinstance(milestones, list): raise RetrievalError(f"卡 {card.get('cardId')} 缺少历史里程碑") kept: list[dict[str, Any]] = [] for index, milestone in enumerate(milestones): if not isinstance(milestone, Mapping): raise RetrievalError(f"卡里程碑[{index}] 必须是对象") chapter = _milestone_chapter(milestone) if chapter <= freeze: normalized = copy.deepcopy(dict(milestone)) normalized["chapter"] = chapter for alias in ("章", "chapterNo", "order_no"): normalized.pop(alias, None) kept.append(normalized) kept.sort(key=lambda item: (item["chapter"], str(item.get("id") or ""))) if not kept: raise RetrievalError(f"卡 {card.get('cardId')} 在冻结线内没有可证明状态") refs = [ _validate_source_ref(ref, as_of=freeze, path=f"card.sourceRefs[{index}]") for index, ref in enumerate(card.get("sourceRefs") or []) ] return { "cardId": str(card.get("cardId") or ""), "type": str(card.get("type") or ""), "name": str(card.get("name") or ""), "score": float(card.get("score") or 0), "sourceId": str(card.get("sourceId") or ""), "sourceVersion": str(card.get("sourceVersion") or ""), "sourceOffset": int(card.get("sourceOffset") or 0), "sourceRefs": refs, "stateAsOf": kept, "sourceKind": str(card.get("sourceKind") or ""), "productionRetrievalEligible": bool(card.get("productionRetrievalEligible")), } class ProductionCardIndexRepository: """生产卡仓储固定调用 search.py 的 work 授权面。""" def __init__(self, *, search_function: Callable[..., list[dict[str, Any]]] | None = None): self._search = search_function or _load_search_cards() def search(self, plan: Mapping[str, Any]) -> list[dict[str, Any]]: """按计划逐查询召回,并再次失败关闭验证生产资格。""" cards: list[dict[str, Any]] = [] for query in plan.get("queries", []): entity_types = query.get("entityTypes") or [] card_type = entity_types[0] if len(entity_types) == 1 and entity_types[0] != "unknown" else None cards.extend( self._search( intent=query["text"], scope="work", work_id=plan["filters"]["workId"], ttype=card_type, purpose="generation", top=query["topK"], ) ) unique: dict[str, dict[str, Any]] = {} for card in stable_sort_cards(cards): if ( card.get("sourceKind") != "canonical_entity" or card.get("sourceStatus") not in {"active", "authorized"} or card.get("bindingStatus") != "active" or card.get("productionRetrievalEligible") is not True ): raise RetrievalError("生产检索命中非 active Canonical entity 或无效 binding") unique.setdefault(str(card.get("cardId")), copy.deepcopy(card)) return stable_sort_cards(list(unique.values())) class ReplayCardIndexRepository: """回放卡仓储只接受预注册 upgrade_book 评测投影。""" def __init__( self, cards: Sequence[Mapping[str, Any]], *, preregistered_card_ids: Sequence[str], _validated_replay: bool = False, ): if not _validated_replay: raise RetrievalError("回放仓储必须通过 from_replay_config 完成授权与泄露门禁") expected = {str(item) for item in preregistered_card_ids} actual = {str(item.get("cardId")) for item in cards} if not expected or expected != actual: raise RetrievalError("回放卡与预注册 ID 不一致") self._cards = [] for card in cards: if ( card.get("sourceType") != "upgrade_book" or card.get("evaluationStatus") != "eval_draft" or card.get("productionRetrievalEligible") is not False ): raise RetrievalError("回放只允许不可生产检索的 upgrade_book 评测卡") self._cards.append(copy.deepcopy(dict(card))) @classmethod def from_replay_config( cls, config: Mapping[str, Any], *, cards: Sequence[Mapping[str, Any]], preregistered_card_ids: Sequence[str], ) -> "ReplayCardIndexRepository": """复用 replay-eval 的授权、冻结来源和内容泄露审计后构造仓储。""" scripts = pathlib.Path(__file__).resolve().parents[2] / "replay-eval" / "scripts" sys.path.insert(0, str(scripts)) from audit_leakage import audit_snapshot from check_snapshot import check_authorization, check_target_sources if not isinstance(config, Mapping): raise RetrievalError("回放配置必须是对象") target = _chapter(config.get("targetChapter"), "config.targetChapter") snapshot = config.get("snapshot") if not isinstance(snapshot, Mapping): raise RetrievalError("回放配置缺少冻结快照") as_of = _chapter(snapshot.get("asOfChapter"), "config.snapshot.asOfChapter") if target != as_of + 1: raise RetrievalError("回放 targetChapter 必须等于 asOfChapter+1") authorization_result = check_authorization(config.get("authorization")) if not authorization_result.get("ok"): raise RetrievalError("回放授权快照未通过既有门禁") source_result = check_target_sources(target, config.get("sources", [])) if not source_result.get("ok"): raise RetrievalError("回放来源包含目标章、未来章或不可证明区间") leakage = config.get("leakageAudit") target_facts = leakage.get("targetFacts") if isinstance(leakage, Mapping) else None audit_result = audit_snapshot(snapshot, target_facts, as_of=as_of, target=target) if not audit_result.get("ok"): raise RetrievalError("回放冻结快照未通过既有内容泄露审计") return cls( cards, preregistered_card_ids=preregistered_card_ids, _validated_replay=True, ) def search(self, plan: Mapping[str, Any]) -> list[dict[str, Any]]: """返回预注册集合;计划不能把评测卡转换为生产卡。""" del plan return stable_sort_cards(self._cards) class FrozenProseRepository: """复用 load_reference_work 的唯一冻结原文 SQL 入口。""" def __init__(self, *, dsn: str, tenant_id: int): self.dsn = dsn self.tenant_id = tenant_id def read_source_refs(self, *, work_id: int, as_of: int, source_refs: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: """延迟导入 replay-eval,避免复制 SQL 或建立第二套权限语义。""" scripts = pathlib.Path(__file__).resolve().parents[2] / "replay-eval" / "scripts" sys.path.insert(0, str(scripts)) from load_reference_work import load_frozen_prose_rows return load_frozen_prose_rows( dsn=self.dsn, tenant_id=self.tenant_id, work_id=work_id, as_of=as_of, source_refs=source_refs, ) def _content_hash(text: str) -> str: """对规范化文本计算带算法前缀的内容哈希。""" normalized = normalize_text(text) return "sha256:" + hashlib.sha256(normalized.encode("utf-8")).hexdigest() def _authoritative_evidence(facts: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: """把正式设定、Canonical 状态和细纲新事实转换为独立事实证据。""" allowed = {"formal_setting", "canonical_state", "fine_outline_declared_new"} result: list[dict[str, Any]] = [] for index, raw in enumerate(facts): source_type = raw.get("sourceType") source_ref = raw.get("sourceRef") fact = normalize_text(str(raw.get("fact") or "")) if source_type not in allowed or not fact or not isinstance(source_ref, Mapping): raise RetrievalError(f"authoritativeFacts[{index}] 合同非法") if not source_ref.get("sourceId") or not source_ref.get("sourceVersion"): raise RetrievalError(f"authoritativeFacts[{index}] 缺少不可变来源引用") result.append( { "evidenceId": str(raw.get("factId") or f"authoritative:{index}"), "fact": fact, "sourceType": source_type, "sourceRef": copy.deepcopy(dict(source_ref)), "contentSha256": _content_hash(fact), "riskLevel": str(raw.get("riskLevel") or "medium"), } ) return result def retrieve_writer_sources( *, plan: Mapping[str, Any], card_repository: CardIndexRepository, prose_repository: ProseRepository, authoritative_facts: Sequence[Mapping[str, Any]] = (), ) -> dict[str, Any]: """执行固定计划,展开卡来源并形成事实/原文两条证据线。""" as_of = _chapter(plan.get("asOf"), "plan.asOf") work_id = plan.get("filters", {}).get("workId") if not isinstance(work_id, int) or isinstance(work_id, bool) or work_id <= 0: raise RetrievalError("plan.filters.workId 非法") cards = [freeze_card(card, as_of=as_of) for card in stable_sort_cards(card_repository.search(plan))] source_refs: list[dict[str, Any]] = [] unverified: list[dict[str, Any]] = [] for card_item in cards: if card_item["sourceRefs"]: source_refs.extend(card_item["sourceRefs"]) else: unverified.append( { "cardId": card_item["cardId"], "reason": "missing_source_refs", "classification": "unverifiedIndexHint", } ) deduplicated = { (str(ref["sourceVersion"]), str(ref["sourceId"]), int(ref["startCodePoint"])): ref for ref in source_refs } ordered_refs = [deduplicated[key] for key in sorted(deduplicated)] prose_rows = prose_repository.read_source_refs(work_id=work_id, as_of=as_of, source_refs=ordered_refs) if ordered_refs else [] def ref_key(ref: Mapping[str, Any]) -> tuple[str, str, int]: """同一块可有多个字符区间,映射键必须包含偏移。""" return (str(ref.get("sourceVersion")), str(ref.get("sourceId")), int(ref.get("startCodePoint") or 0)) rows_by_source = {ref_key(row.get("sourceRef", {})): row for row in prose_rows} missing = [ref["sourceId"] for ref in ordered_refs if ref_key(ref) not in rows_by_source] if missing: raise RetrievalError(f"卡来源未能回读原文: {','.join(map(str, missing))}") prose_evidence: list[dict[str, Any]] = [] for index, ref in enumerate(ordered_refs): row = rows_by_source[ref_key(ref)] text = normalize_text(str(row.get("text") or "")) if not text: raise RetrievalError(f"来源 {ref['sourceId']} 原文为空") prose_evidence.append( { "evidenceId": f"prose:card:{index:04d}", "chapter": _chapter(row.get("chapter"), "prose.chapter"), "sourceRef": copy.deepcopy(ref), "contentSha256": _content_hash(text), "purpose": str(row.get("purpose") or "card_source"), "text": text, "isRecentBaseline": False, } ) fact_evidence = _authoritative_evidence(authoritative_facts) for card_item in cards: if not card_item["sourceRefs"]: continue latest = card_item["stateAsOf"][-1] fact_text = normalize_text(str(latest.get("fact") or latest.get("台阶") or canonical_json(latest))) fact_evidence.append( { "evidenceId": f"fact:card:{card_item['cardId']}", "fact": fact_text, "sourceType": "historical_prose", "sourceRef": copy.deepcopy(card_item["sourceRefs"][0]), "contentSha256": _content_hash(fact_text), "riskLevel": "medium", } ) fact_evidence.sort(key=lambda item: item["evidenceId"]) sources = sorted( [copy.deepcopy(ref) for ref in ordered_refs] + [copy.deepcopy(item["sourceRef"]) for item in fact_evidence if item["sourceType"] != "historical_prose"], key=lambda item: (str(item["sourceVersion"]), str(item["sourceId"]), int(item.get("startCodePoint") or 0)), ) manifest_payload = { "manifestVersion": "writer-retrieval-manifest-v1", "planId": plan["planId"], "sources": sources, "omittedSources": [ {"sourceId": f"card:{item['cardId']}", "reason": item["reason"]} for item in unverified ], } manifest = {**manifest_payload, "manifestId": retrieval_identity(manifest_payload)} return { "cards": cards, "factEvidence": fact_evidence, "proseEvidence": prose_evidence, "unverifiedIndexHints": unverified, "manifest": manifest, } __all__ = [ "RetrievalError", "CardIndexRepository", "ProseRepository", "ProductionCardIndexRepository", "ReplayCardIndexRepository", "FrozenProseRepository", "stable_sort_cards", "freeze_card", "build_retrieval_plan", "retrieve_writer_sources", ]