#!/usr/bin/env python3 """确定性组装 WriterContext v1 与可审阅 RetrievalManifest。 组装器不访问数据库、不调用模型。调用方必须先完成检索计划、卡索引和 sourceRefs 原文回读,再把冻结结果交给本模块。 """ from __future__ import annotations import copy import hashlib import json from typing import Any, Mapping, Sequence from writer_contract import ( MANIFEST_VERSION, canonical_json, normalize_text, retrieval_identity, validate_writer_context, ) class AssemblyError(ValueError): """上下文不连续、预算不足或证据合同非法时抛出。""" def _hash_text(text: str) -> str: """对已经归一化的证据文本计算带算法前缀的哈希。""" return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest() def _source_key(source_ref: Mapping[str, Any]) -> tuple[str, str, int]: """来源版本、来源 ID 与偏移共同定义片段身份。""" return ( str(source_ref.get("sourceVersion") or ""), str(source_ref.get("sourceId") or ""), int(source_ref.get("startCodePoint") or 0), ) def _whole_source_key(source_ref: Mapping[str, Any]) -> tuple[str, str]: """同一块全文已存在时,卡片子区间不再重复注入。""" return (str(source_ref.get("sourceVersion") or ""), str(source_ref.get("sourceId") or "")) def _normalize_prose(raw: Mapping[str, Any], *, recent: bool, purpose: str | None = None) -> dict[str, Any]: """规范化单条原文证据,并机械复核内容哈希和字符区间。""" required = {"chapter", "sourceRef", "text"} if not isinstance(raw, Mapping) or not required.issubset(raw): raise AssemblyError("原文证据缺少 chapter/sourceRef/text") chapter = raw["chapter"] if isinstance(chapter, bool) or not isinstance(chapter, int) or chapter <= 0: raise AssemblyError("原文证据 chapter 必须是正整数") source_ref = copy.deepcopy(dict(raw["sourceRef"])) if isinstance(raw["sourceRef"], Mapping) else None if source_ref is None or not source_ref.get("sourceId") or not source_ref.get("sourceVersion"): raise AssemblyError("原文证据缺少不可变来源引用") text = normalize_text(str(raw["text"])) if not text: raise AssemblyError("原文证据不能为空") source_ref["chapter"] = chapter source_ref.setdefault("startCodePoint", 0) source_ref.setdefault("endCodePoint", len(text)) if source_ref["endCodePoint"] > len(text) and source_ref["startCodePoint"] == 0: raise AssemblyError("原文来源字符区间超过文本长度") return { "evidenceId": str(raw.get("evidenceId") or f"prose:{source_ref['sourceId']}:{source_ref['startCodePoint']}"), "chapter": chapter, "sourceRef": source_ref, "contentSha256": _hash_text(text), "purpose": purpose or str(raw.get("purpose") or "card_source"), "text": text, "isRecentBaseline": recent, } def _normalize_fact(raw: Mapping[str, Any]) -> dict[str, Any]: """规范化事实证据,并保留来源类型与风险优先级。""" required = {"evidenceId", "fact", "sourceType", "sourceRef"} if not isinstance(raw, Mapping) or not required.issubset(raw): raise AssemblyError("事实证据缺少 evidenceId/fact/sourceType/sourceRef") source_type = str(raw["sourceType"]) if source_type not in {"historical_prose", "formal_setting", "canonical_state", "fine_outline_declared_new"}: raise AssemblyError("事实证据 sourceType 非法") source_ref = copy.deepcopy(dict(raw["sourceRef"])) if isinstance(raw["sourceRef"], Mapping) else None if source_ref is None or not source_ref.get("sourceId") or not source_ref.get("sourceVersion"): raise AssemblyError("事实证据缺少不可变来源引用") fact = normalize_text(str(raw["fact"])) if not fact: raise AssemblyError("事实证据内容不能为空") risk = str(raw.get("riskLevel") or "medium") if risk not in {"low", "medium", "high"}: raise AssemblyError("事实证据 riskLevel 非法") return { "evidenceId": str(raw["evidenceId"]), "fact": fact, "sourceType": source_type, "sourceRef": source_ref, "contentSha256": _hash_text(fact), "riskLevel": risk, } def _select_recent_baseline(recent_chapters: Sequence[Mapping[str, Any]], *, as_of: int) -> list[dict[str, Any]]: """选择冻结章起连续四章全文;作品不足四章时从第一章开始。""" by_chapter: dict[int, dict[str, Any]] = {} for raw in recent_chapters: normalized = _normalize_prose(raw, recent=True, purpose="recent_full_chapter") chapter = normalized["chapter"] if chapter > as_of: raise AssemblyError("近章原文包含目标章或未来章") if chapter in by_chapter: raise AssemblyError(f"第 {chapter} 章完整原文重复") by_chapter[chapter] = normalized first = max(1, as_of - 3) expected = list(range(first, as_of + 1)) missing = [chapter for chapter in expected if chapter not in by_chapter] if missing: raise AssemblyError(f"连续四章基线缺章: {','.join(map(str, missing))}") return [by_chapter[chapter] for chapter in expected] def _outline_contract(fine_outline: Mapping[str, Any]) -> dict[str, Any]: """只保留 WriterContext 合同字段,检索用实体列表不会被倾倒给写手。""" required = {"sourceRef", "hardConstraints", "adjustableBeats", "declaredNewFacts"} if not isinstance(fine_outline, Mapping) or not required.issubset(fine_outline): raise AssemblyError("fineOutline 缺少严格合同字段") return {key: copy.deepcopy(fine_outline[key]) for key in ("sourceRef", "hardConstraints", "adjustableBeats", "declaredNewFacts")} def _coverage_elements(fine_outline: Mapping[str, Any]) -> list[dict[str, str]]: """从细纲提取人物、关系、物品、地点和力量体系覆盖目标。""" groups = ( ("entities", None), ("relations", "character_relation"), ("items", "item"), ("locations", "location"), ("powerSystems", "power_system"), ) result: list[dict[str, str]] = [] seen: set[str] = set() for field, fallback_type in groups: values = fine_outline.get(field, []) if not isinstance(values, list): raise AssemblyError(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 AssemblyError(f"fineOutline.{field}[{index}] 类型非法") if not item["name"]: raise AssemblyError(f"fineOutline.{field}[{index}] 缺少名称") if item["id"] not in seen: seen.add(item["id"]) result.append(item) return result def _build_coverage( fine_outline: Mapping[str, Any], facts: Sequence[Mapping[str, Any]], prose: Sequence[Mapping[str, Any]], cards: Sequence[Mapping[str, Any]], ) -> list[dict[str, Any]]: """把五类细纲要素映射到最终保留的事实与原文证据。""" prose_by_source = { str(item["sourceRef"]["sourceId"]): item["evidenceId"] for item in prose } declared = fine_outline.get("declaredNewFacts", []) result: list[dict[str, Any]] = [] for element in _coverage_elements(fine_outline): fact_ids = [item["evidenceId"] for item in facts if element["name"] in item["fact"]] prose_ids: list[str] = [] for card in cards: if str(card.get("name")) != element["name"] and str(card.get("type")) != element["type"]: continue for ref in card.get("sourceRefs") or []: evidence_id = prose_by_source.get(str(ref.get("sourceId"))) if evidence_id and evidence_id not in prose_ids: prose_ids.append(evidence_id) is_declared = any( isinstance(item, Mapping) and (str(item.get("factId")) == element["id"] or element["name"] in str(item.get("text") or "")) for item in declared ) if is_declared: status, reason = "declared_new", "" elif fact_ids and prose_ids: status, reason = "supported", "" elif fact_ids: status, reason = "style_gap", "缺少历史表现原文" elif prose_ids: status, reason = "card_gap", "原文可证但缺少冻结事实索引" else: status, reason = "unsupported", "没有可信事实证据" result.append( { "elementId": element["id"], "elementType": element["type"], "name": element["name"], "status": status, "factEvidenceIds": sorted(fact_ids), "proseEvidenceIds": sorted(prose_ids), "gapReason": reason, } ) return result def _manifest( *, plan_id: str, facts: Sequence[Mapping[str, Any]], prose: Sequence[Mapping[str, Any]], pattern_references: Sequence[Mapping[str, Any]], omitted: Sequence[Mapping[str, str]], ) -> dict[str, Any]: """由最终入包来源集合计算稳定 manifest,不含 runId 或时间戳。""" unique: dict[tuple[str, str, int], dict[str, Any]] = {} for item in [*facts, *prose]: ref = copy.deepcopy(dict(item["sourceRef"])) unique[_source_key(ref)] = ref for raw_ref in pattern_references: ref = copy.deepcopy(dict(raw_ref)) unique[_source_key(ref)] = ref sources = [unique[key] for key in sorted(unique)] omitted_rows = sorted( [copy.deepcopy(dict(item)) for item in omitted], key=lambda item: (str(item.get("reason")), str(item.get("sourceId"))), ) payload = { "manifestVersion": MANIFEST_VERSION, "planId": plan_id, "sources": sources, "omittedSources": omitted_rows, } return {**payload, "manifestId": retrieval_identity(payload)} def _markdown_manifest(manifest: Mapping[str, Any]) -> str: """渲染不含运行时元数据的人类审阅清单,保证字节稳定。""" lines = [ "# 正文检索清单", "", f"- 计划:`{manifest['planId']}`", f"- 清单:`{manifest['manifestId']}`", f"- 纳入来源:{len(manifest['sources'])}", f"- 排除来源:{len(manifest['omittedSources'])}", "", "## 纳入来源", "", ] if manifest["sources"]: for source in manifest["sources"]: location = f"第{source['chapter']}章" if "chapter" in source else "权威版本" lines.append(f"- `{source['sourceId']}` @ `{source['sourceVersion']}`,{location}") else: lines.append("- 无") lines.extend(["", "## 排除来源", ""]) if manifest["omittedSources"]: for source in manifest["omittedSources"]: lines.append(f"- `{source['sourceId']}`:{source['reason']}") else: lines.append("- 无") return "\n".join(lines) + "\n" def _context_size(context: Mapping[str, Any]) -> int: """用最终规范 JSON 的 Unicode code point 数作为确定性预算单位。""" return len(canonical_json(context)) def assemble_context( *, run_id: str, attempt: int, mode: str, purpose: str, quality_policy_version: str, work_id: int, target_chapter: int, as_of: int, source_version: str, authorization_snapshot: Mapping[str, Any], source_status: str, retrieval_plan: Mapping[str, Any], retrieval_result: Mapping[str, Any], fine_outline: Mapping[str, Any], narrative_state: Mapping[str, Any], recent_chapters: Sequence[Mapping[str, Any]], output_contract: Mapping[str, Any], token_budget: Mapping[str, int], pattern_references: Sequence[Mapping[str, Any]] = (), generated_at: str, ) -> dict[str, Any]: """组装稳定 WriterContext,并返回规范 JSON 与 Markdown manifest。""" if retrieval_plan.get("runId") != run_id or retrieval_plan.get("asOf") != as_of: raise AssemblyError("retrievalPlan 与当前 runId/asOf 不一致") if retrieval_plan.get("filters", {}).get("workId") != work_id: raise AssemblyError("retrievalPlan 与当前 workId 不一致") max_chars = token_budget.get("maxContextChars") if isinstance(max_chars, bool) or not isinstance(max_chars, int) or max_chars <= 0: raise AssemblyError("tokenBudget.maxContextChars 必须是正整数") baseline = _select_recent_baseline(recent_chapters, as_of=as_of) baseline_blocks = {_whole_source_key(item["sourceRef"]) for item in baseline} supplemental: list[dict[str, Any]] = [] seen_fragments = {_source_key(item["sourceRef"]) for item in baseline} for raw in retrieval_result.get("proseEvidence", []): item = _normalize_prose(raw, recent=False) if item["chapter"] > as_of: raise AssemblyError("卡来源原文包含目标章或未来章") if _whole_source_key(item["sourceRef"]) in baseline_blocks: continue key = _source_key(item["sourceRef"]) if key not in seen_fragments: seen_fragments.add(key) supplemental.append(item) supplemental.sort(key=lambda item: _source_key(item["sourceRef"])) facts = sorted( [_normalize_fact(item) for item in retrieval_result.get("factEvidence", [])], key=lambda item: ({"high": 0, "medium": 1, "low": 2}[item["riskLevel"]], item["evidenceId"]), ) cards = [copy.deepcopy(dict(item)) for item in retrieval_result.get("cards", [])] inherited_omitted = [ {"sourceId": str(item.get("sourceId") or "unknown"), "reason": str(item.get("reason") or "not_relevant")} for item in retrieval_result.get("manifest", {}).get("omittedSources", []) if isinstance(item, Mapping) ] selected_facts: list[dict[str, Any]] = [] # 连续前四章全文是正文实验 v1 的不可裁剪基线。预算容不下时必须失败关闭, # 不能静默退化成只保留最近一两章。 selected_prose: list[dict[str, Any]] = list(baseline) omitted = list(inherited_omitted) candidates: list[tuple[str, dict[str, Any]]] = [] candidates.extend(("fact", item) for item in facts if item["riskLevel"] == "high") candidates.extend(("fact", item) for item in facts if item["riskLevel"] == "medium") candidates.extend(("fact", item) for item in facts if item["riskLevel"] == "low") candidates.extend(("prose", item) for item in supplemental) def make_context() -> dict[str, Any]: """用当前选择集生成完整上下文,供预算试算与最终冻结。""" ordered_prose = sorted( selected_prose, key=lambda item: ( 0 if item["isRecentBaseline"] else 1, item["chapter"] if item["isRecentBaseline"] else 0, _source_key(item["sourceRef"]), ), ) ordered_facts = sorted(selected_facts, key=lambda item: item["evidenceId"]) manifest = _manifest( plan_id=str(retrieval_plan["planId"]), facts=ordered_facts, prose=ordered_prose, pattern_references=pattern_references, omitted=omitted, ) context = { "schemaVersion": "writer-context-v1", "runId": run_id, "attempt": attempt, "mode": mode, "purpose": purpose, "qualityPolicyVersion": quality_policy_version, "workId": work_id, "targetChapter": target_chapter, "asOf": as_of, "contextSnapshot": { "manifestId": manifest["manifestId"], "contextSha256": "sha256:" + "0" * 64, "generatedAt": normalize_text(generated_at), }, "sourceVersion": source_version, "authorizationSnapshot": copy.deepcopy(dict(authorization_snapshot)), "sourceStatus": source_status, "retrievalPlan": copy.deepcopy(dict(retrieval_plan)), "retrievalManifest": manifest, "fineOutline": _outline_contract(fine_outline), "narrativeState": copy.deepcopy(dict(narrative_state)), "factEvidence": ordered_facts, "proseEvidence": ordered_prose, "patternReferences": [copy.deepcopy(dict(item)) for item in pattern_references], "evidenceCoverage": _build_coverage(fine_outline, ordered_facts, ordered_prose, cards), "outputContract": copy.deepcopy(dict(output_contract)), "tokenBudget": {"maxContextChars": max_chars, "usedContextChars": 0}, "omittedSources": sorted(copy.deepcopy(omitted), key=lambda item: (item["reason"], item["sourceId"])), "acceptanceEligible": mode == "production" and purpose == "production", } # usedContextChars 自身位数会影响 JSON 长度,迭代到数值稳定。 for _ in range(8): used = _context_size(context) if context["tokenBudget"]["usedContextChars"] == used: break context["tokenBudget"]["usedContextChars"] = used context["contextSnapshot"]["contextSha256"] = retrieval_identity(context) return context baseline_context = make_context() if _context_size(baseline_context) > max_chars: raise AssemblyError("上下文预算不足以容纳细纲硬约束与连续前四章全文基线") for kind, item in candidates: target = selected_facts if kind == "fact" else selected_prose target.append(item) trial = make_context() if _context_size(trial) > max_chars: target.pop() omitted.append({"sourceId": str(item["sourceRef"]["sourceId"]), "reason": "token_budget"}) context = make_context() # 添加裁剪回显可能占用少量预算;若越界,按最低优先级继续移除。 while _context_size(context) > max_chars and (selected_prose or selected_facts): removable_prose = [item for item in selected_prose if not item["isRecentBaseline"]] if removable_prose: removed = removable_prose[-1] selected_prose.remove(removed) elif selected_facts and selected_facts[-1]["riskLevel"] != "high": removed = selected_facts.pop() elif selected_facts: removed = selected_facts.pop() else: raise AssemblyError("上下文预算不足以保留连续前四章全文基线") omitted.append({"sourceId": str(removed["sourceRef"]["sourceId"]), "reason": "token_budget"}) context = make_context() if _context_size(context) > max_chars: raise AssemblyError("上下文预算不足以保留可用证据") context["tokenBudget"]["usedContextChars"] = _context_size(context) context["contextSnapshot"]["contextSha256"] = retrieval_identity(context) normalized_context = validate_writer_context(context) return { "context": normalized_context, "contextJson": canonical_json(normalized_context), "manifestMarkdown": _markdown_manifest(normalized_context["retrievalManifest"]), } __all__ = ["AssemblyError", "assemble_context"]