From 7cade98245b2b6a6d7eca889ffce4f6a1b87ac7a Mon Sep 17 00:00:00 2001
From: Cai <cai@nbcai.cc>
Date: Sun, 09 Aug 2026 23:08:44 +0800
Subject: [PATCH] research(new-energy): add batch automation utilities
---
ana-data/tools/newenergy_batch001_accept.py | 370 +++
ana-data/tools/newenergy_batch002_build.py | 2210 ++++++++++++++++++
ana-data/tools/verify_newenergy_batch002_acceptance.py | 101
ana-data/tools/newenergy_batch001_repair005.py | 871 +++++++
ana-data/tools/newenergy_batch001_repair004.py | 1258 ++++++++++
ana-data/tools/newenergy_batch001_repair.py | 1731 ++++++++++++++
ana-data/tools/newenergy_batch002_query_probe.py | 159 +
ana-data/tools/newenergy_batch002_accept.py | 509 ++++
ana-data/img/新能源案例/ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001/.gitkeep | 1
9 files changed, 7,210 insertions(+), 0 deletions(-)
diff --git "a/ana-data/img/\346\226\260\350\203\275\346\272\220\346\241\210\344\276\213/ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001/.gitkeep" "b/ana-data/img/\346\226\260\350\203\275\346\272\220\346\241\210\344\276\213/ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001/.gitkeep"
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ "b/ana-data/img/\346\226\260\350\203\275\346\272\220\346\241\210\344\276\213/ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001/.gitkeep"
@@ -0,0 +1 @@
+
diff --git a/ana-data/tools/newenergy_batch001_accept.py b/ana-data/tools/newenergy_batch001_accept.py
new file mode 100644
index 0000000..ac296d2
--- /dev/null
+++ b/ana-data/tools/newenergy_batch001_accept.py
@@ -0,0 +1,370 @@
+#!/usr/bin/env python3
+"""Apply the independently approved terminal status sync for NEWENERGY BATCH-001.
+
+This is an L0 state-only transition. It changes review/document/package state,
+recomputes receipt/output/artifact hashes affected by that state transition, and
+creates a terminal acceptance receipt. It does not change source content,
+evidence text, eligibility, ranking, mappings, or research conclusions.
+"""
+
+from __future__ import annotations
+
+import csv
+import json
+import re
+from collections import Counter
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+from zoneinfo import ZoneInfo
+
+import newenergy_batch001_repair as base
+import newenergy_batch001_repair004 as r4
+
+
+TASK_ID = "TASK-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001"
+CASE_ID = "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001"
+BATCH_ID = "BATCH-001"
+RUN_ID = "RUN-ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001-BATCH-001-001"
+FINAL_AUDIT = "AUDIT-ANA-NEWENERGY-FOUR-TRACK-ATLAS-BATCH001-EXECUTION-OUTPUT-REPAIR005-REREVIEW-20260806-001"
+PARENT_STATUS = "COMPLETED_ACCEPTED_AND_CLOSED"
+ACCEPTED = "ACCEPTED_BY_INDEPENDENT_REVIEW"
+ARTIFACT_STATUS = "FINAL_ACCEPTED_BY_INDEPENDENT_REVIEW"
+TOOL_VERSION = "REPAIR-005"
+
+PROJECT_ROOT = Path(__file__).resolve().parents[2]
+INDUSTRY_ROOT = PROJECT_ROOT / "ana-data/cases/新能源案例"
+CASE_ROOT = INDUSTRY_ROOT / CASE_ID
+CASE_OUTPUTS = CASE_ROOT / "outputs"
+CASE_MANIFEST = CASE_ROOT / "manifest"
+RESULT_ROOT = PROJECT_ROOT / "ana-data/result/新能源案例" / CASE_ID
+ACCEPTANCE_RECORD = RESULT_ROOT / "acceptance_record.md"
+ACCEPTANCE_VALIDATION = CASE_MANIFEST / "acceptance_validation_receipt.json"
+
+
+def sha256_file(path: Path) -> str:
+ return r4.sha256_file(path)
+
+
+def stable_accepted_at() -> str:
+ if ACCEPTANCE_RECORD.exists():
+ text = ACCEPTANCE_RECORD.read_text(encoding="utf-8")
+ match = re.search(r"^- `accepted_at=([^`]+)`$", text, flags=re.M)
+ if match:
+ return match.group(1)
+ return datetime.now(ZoneInfo("Asia/Shanghai")).replace(microsecond=0).isoformat()
+
+
+def read_csv_with_header(path: Path) -> tuple[list[str], list[dict[str, str]]]:
+ with path.open("r", encoding="utf-8-sig", newline="") as handle:
+ reader = csv.DictReader(handle)
+ return list(reader.fieldnames or []), list(reader)
+
+
+def canonical_receipt_hash(row: dict[str, str]) -> str:
+ core = {key: value for key, value in row.items() if key != "receipt_sha256"}
+ payload = json.dumps(core, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
+ return r4.sha256_bytes(payload)
+
+
+def sync_csv_review_statuses() -> dict[str, int]:
+ counters: Counter[str] = Counter()
+ artifact_manifest = INDUSTRY_ROOT / "manifest" / "artifact_manifest.csv"
+ for path in sorted(INDUSTRY_ROOT.rglob("*.csv")):
+ if path.resolve() == artifact_manifest.resolve():
+ continue
+ headers, rows = read_csv_with_header(path)
+ if "review_status" not in headers:
+ continue
+ for row in rows:
+ row["review_status"] = ACCEPTED
+ if path.name == "human_doc_validation_receipt.csv":
+ row["document_status"] = ACCEPTED
+ if path.name == "output_manifest.csv":
+ row["data_status"] = "FINAL_ACCEPTED"
+ if "receipt_sha256" in headers:
+ row["receipt_sha256"] = canonical_receipt_hash(row)
+ base.write_csv(path, headers, rows)
+ counters["csv_files"] += 1
+ counters["csv_rows"] += len(rows)
+ counters[path.name] += len(rows)
+ return dict(counters)
+
+
+def replace_required(path: Path, old: str, new: str, *, minimum: int = 1) -> int:
+ text = path.read_text(encoding="utf-8")
+ count = text.count(old)
+ if count < minimum and new not in text:
+ raise RuntimeError(f"required acceptance transition marker missing in {path}: {old!r}")
+ if count:
+ path.write_text(text.replace(old, new), encoding="utf-8", newline="\n")
+ return count
+
+
+def sync_markdown_statuses() -> dict[str, int]:
+ counts: Counter[str] = Counter()
+ for path in sorted(CASE_OUTPUTS.rglob("*.md")):
+ counts["output_status_markers"] += replace_required(
+ path, "> 状态:`DRAFT_FOR_REVIEW`", f"> 状态:`{ACCEPTED}`"
+ )
+ counts["output_files"] += 1
+
+ summary = CASE_OUTPUTS / "summary.md"
+ old = (
+ "输出仍为 `DRAFT_FOR_REVIEW`。在独立执行/输出审核 PASS 前,不标记完成、"
+ "不回写父级终态、不对外宣称体系成果已经验收。"
+ )
+ new = (
+ f"本批执行/输出已由独立审核 `{FINAL_AUDIT}` 以 `PASS / 0 / 0` 接受,"
+ f"父级状态已回写为 `{PARENT_STATUS}`。接受仅覆盖本 case/BATCH/run 的现有公开资料成果,"
+ "不放行下一批、范围扩展、完整覆盖声明、估值、行情、交易、收益、市场反向补漏或核电敏感内容。"
+ )
+ counts["summary_terminal_paragraph"] += replace_required(summary, old, new)
+
+ batch_summary = CASE_MANIFEST / "batch_summary.md"
+ counts["candidate_count_fix"] += replace_required(
+ batch_summary,
+ "candidate_state_count=INCLUDED_T1:16,INCLUDED_T2:16,HELD_BY_EVIDENCE_GAP:4479",
+ "candidate_state_count=INCLUDED_T1:16,INCLUDED_T2:16,ELIGIBLE_NOT_SELECTED_BATCH001:40,HELD_BY_EVIDENCE_GAP:4479",
+ )
+ counts["batch_output_status"] += replace_required(
+ batch_summary, "output_status=DRAFT_FOR_REVIEW", "output_status=FINAL_ACCEPTED"
+ )
+ counts["batch_review_status"] += replace_required(
+ batch_summary, "review_status=PENDING_REPAIR005_FOCUSED_REREVIEW", f"review_status={ACCEPTED}"
+ )
+
+ result_index = RESULT_ROOT / "result_index.md"
+ counts["result_header"] += replace_required(
+ result_index, "> 状态:`DRAFT_FOR_REVIEW`", f"> 状态:`{ACCEPTED}`"
+ )
+ counts["result_current"] += replace_required(
+ result_index, "当前状态:`DRAFT_FOR_REVIEW`", f"当前状态:`{ACCEPTED}`"
+ )
+ counts["result_review"] += replace_required(
+ result_index, "执行/输出独立审核:`PENDING`", f"执行/输出独立审核:`PASS`(`{FINAL_AUDIT}`)"
+ )
+ counts["result_parent"] += replace_required(
+ result_index, "父级终态回写:`NOT_ALLOWED_BEFORE_REVIEW_PASS`", f"父级终态回写:`{PARENT_STATUS}`"
+ )
+ return dict(counts)
+
+
+def rebuild_output_manifest() -> list[dict[str, str]]:
+ path = CASE_MANIFEST / "output_manifest.csv"
+ headers, rows = read_csv_with_header(path)
+ for row in rows:
+ output = PROJECT_ROOT / row["output_path"]
+ if not output.exists():
+ raise FileNotFoundError(output)
+ row["output_sha256"] = sha256_file(output)
+ row["data_status"] = "FINAL_ACCEPTED"
+ row["review_status"] = ACCEPTED
+ base.write_csv(path, headers, rows)
+ return rows
+
+
+def write_acceptance_record(accepted_at: str, output_manifest_hash: str) -> None:
+ RESULT_ROOT.mkdir(parents=True, exist_ok=True)
+ text = f"""# {CASE_ID} 正式接受记录
+
+- `task_id={TASK_ID}`
+- `case_id={CASE_ID}`
+- `batch_id={BATCH_ID}`
+- `run_id={RUN_ID}`
+- `status={ACCEPTED}`
+- `accepted_at={accepted_at}`
+- `final_audit={FINAL_AUDIT}`
+- `audit_result=PASS/0/0/0`
+- `closed_blockers=B1-R004-A,B1-R004-B`
+- `parent_status={PARENT_STATUS}`
+- `accepted_output_manifest=ana-data/cases/新能源案例/{CASE_ID}/manifest/output_manifest.csv`
+- `accepted_output_manifest_sha256={output_manifest_hash.upper()}`
+- `governance=OK_PROJECTS1_WARNINGS0`
+
+## 接受范围
+
+本记录接受本 case/BATCH/run 的锂电、光伏、风电、核电四赛道基础行业图谱、16 份子行业正文、三类顶层视图、4,551 条唯一候选映射账本、32 条正式 selected 公司—赛道映射、786/786 份 source/conversion、82 条 evidence fact、110 条结论映射和 24 项 output manifest。候选终态为 `INCLUDED_T1=16 / INCLUDED_T2=16 / ELIGIBLE_NOT_SELECTED_BATCH001=40 / HELD_BY_EVIDENCE_GAP=4479`。
+
+执行/输出独立复审 `{FINAL_AUDIT}` 已关闭 B1-R004-A/B,并给出 `PASS / blocker 0 / non-blocking 0`;此前设计及执行审核的所有 HOLD/PASS 记录和冻结哈希继续保留。审核后仅执行获准的 L0 状态同步,未修改来源内容、证据原文、资格判断、机械排序、结论强度或研究边界。
+
+## 父级关闭与边界
+
+项目管理员已在 `ana-doc/案例总纲.md` append-only 回写 `{PARENT_STATUS}`。本接受不放行下一批或扩域;继续保持 `primary_region=MAINLAND_CHINA`、全球/境外分账、`coverage_claim=NONE_INITIAL_CANDIDATE_POOL_ONLY`,不形成完整覆盖、估值、行情、交易、收益、市场反向补漏或核电敏感结论。
+"""
+ ACCEPTANCE_RECORD.write_text(text, encoding="utf-8", newline="\n")
+
+
+def count_csv(path: Path) -> tuple[int, list[dict[str, str]]]:
+ _, rows = read_csv_with_header(path)
+ return len(rows), rows
+
+
+def write_acceptance_validation(accepted_at: str, output_rows: list[dict[str, str]]) -> dict[str, Any]:
+ candidate_path = INDUSTRY_ROOT / "extracted" / "company_track_candidate_ledger.csv"
+ evidence_path = INDUSTRY_ROOT / "evidence" / "evidence_fact_table.csv"
+ case_map_path = CASE_ROOT / "evidence" / "case_evidence_map.csv"
+ role_path = INDUSTRY_ROOT / "extracted" / "candidate_role_adjudication_receipt.csv"
+ page_path = INDUSTRY_ROOT / "extracted" / "candidate_page_qualification_receipt.csv"
+ source_path = INDUSTRY_ROOT / "manifest" / "source_document.csv"
+ conversion_path = INDUSTRY_ROOT / "manifest" / "conversion_status.csv"
+
+ _, candidates = count_csv(candidate_path)
+ source_count, _ = count_csv(source_path)
+ conversion_count, _ = count_csv(conversion_path)
+ evidence_count, _ = count_csv(evidence_path)
+ map_count, _ = count_csv(case_map_path)
+ _, roles = count_csv(role_path)
+ _, pages = count_csv(page_path)
+ candidate_states = dict(sorted(Counter(row["candidate_state"] for row in candidates).items()))
+
+ errors: list[str] = []
+ expected_states = {
+ "ELIGIBLE_NOT_SELECTED_BATCH001": 40,
+ "HELD_BY_EVIDENCE_GAP": 4479,
+ "INCLUDED_T1": 16,
+ "INCLUDED_T2": 16,
+ }
+ if candidate_states != expected_states:
+ errors.append(f"candidate state mismatch: {candidate_states}")
+ if (source_count, conversion_count, evidence_count, map_count, len(output_rows)) != (786, 786, 82, 110, 24):
+ errors.append(
+ f"accepted package counts mismatch: source={source_count} conversion={conversion_count} "
+ f"evidence={evidence_count} map={map_count} output={len(output_rows)}"
+ )
+ for label, rows in (("role", roles), ("page", pages)):
+ invalid = sum(row.get("receipt_sha256") != canonical_receipt_hash(row) for row in rows)
+ if invalid:
+ errors.append(f"{label} receipt hash mismatch={invalid}")
+ if any(row["review_status"] != ACCEPTED or row["data_status"] != "FINAL_ACCEPTED" for row in output_rows):
+ errors.append("output manifest terminal status mismatch")
+ output_hash_errors = sum(
+ row["output_sha256"] != sha256_file(PROJECT_ROOT / row["output_path"])
+ for row in output_rows
+ )
+ if output_hash_errors:
+ errors.append(f"output hash mismatch={output_hash_errors}")
+
+ receipt = {
+ "task_id": TASK_ID,
+ "case_id": CASE_ID,
+ "batch_id": BATCH_ID,
+ "run_id": RUN_ID,
+ "accepted_at": accepted_at,
+ "status": ACCEPTED,
+ "final_audit": FINAL_AUDIT,
+ "parent_status": PARENT_STATUS,
+ "transition_type": "L0_REVIEW_STATUS_AND_MANIFEST_HASH_SYNC_ONLY",
+ "candidate_state_distribution": candidate_states,
+ "source_count": source_count,
+ "conversion_count": conversion_count,
+ "evidence_fact_count": evidence_count,
+ "case_map_count": map_count,
+ "output_count": len(output_rows),
+ "role_receipt_count": len(roles),
+ "page_receipt_count": len(pages),
+ "core_hashes": {
+ "candidate_ledger": sha256_file(candidate_path),
+ "evidence_fact_table": sha256_file(evidence_path),
+ "case_evidence_map": sha256_file(case_map_path),
+ "role_adjudication_receipt": sha256_file(role_path),
+ "candidate_page_qualification_receipt": sha256_file(page_path),
+ "output_manifest": sha256_file(CASE_MANIFEST / "output_manifest.csv"),
+ "acceptance_record": sha256_file(ACCEPTANCE_RECORD),
+ },
+ "historical_frozen_receipt": "manifest/repair005_validation_receipt.json (pre-acceptance audit snapshot; intentionally unchanged)",
+ "validation_errors": errors,
+ "validation_status": "PASS" if not errors else "FAIL",
+ }
+ ACCEPTANCE_VALIDATION.write_text(
+ json.dumps(receipt, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
+ encoding="utf-8",
+ newline="\n",
+ )
+ if errors:
+ raise RuntimeError("acceptance validation failed: " + "; ".join(errors))
+ return receipt
+
+
+def rebuild_artifact_manifest(output_rows: list[dict[str, str]]) -> list[dict[str, str]]:
+ source_rows = base.read_csv(INDUSTRY_ROOT / "manifest" / "source_document.csv")
+ collected = [row.get("collected_at", "") for row in source_rows if row.get("collected_at")]
+ base.COLLECTED_AT = max(collected, default="2026-08-06T00:00:00+08:00")
+ base.ARTIFACT_TOOL_VERSION = TOOL_VERSION
+ base.ARTIFACT_PARAMETERS_SUMMARY = (
+ "B1 REPAIR005 independently accepted; terminal L0 review/document/package status sync; "
+ "no source, evidence, eligibility, ranking, mapping, or conclusion change"
+ )
+ base.ARTIFACT_STATUS = ARTIFACT_STATUS
+ audited_replay_tool = Path(__file__).with_name("newenergy_batch001_repair005.py")
+ return base.build_artifact_manifest(source_rows, output_rows, audited_replay_tool)
+
+
+def validate_artifact_manifest(rows: list[dict[str, str]]) -> dict[str, Any]:
+ audited_replay_tool = Path(__file__).with_name("newenergy_batch001_repair005.py")
+ formal = base.formal_files(audited_replay_tool)
+ by_path = {row["relative_path"]: row for row in rows}
+ errors: list[str] = []
+ if len(by_path) != len(rows):
+ errors.append("duplicate artifact relative path")
+ if set(by_path) != {base.rel(path) for path in formal}:
+ errors.append("artifact/formal exact-set mismatch")
+ for path in formal:
+ row = by_path.get(base.rel(path))
+ if row is None:
+ continue
+ if row["file_size"] != str(path.stat().st_size) or row["sha256"] != sha256_file(path):
+ errors.append(f"artifact hash mismatch: {base.rel(path)}")
+ break
+ if any(row["artifact_status"] != ARTIFACT_STATUS or row["tool_version"] != TOOL_VERSION for row in rows):
+ errors.append("artifact terminal status/tool version mismatch")
+ if errors:
+ raise RuntimeError("artifact validation failed: " + "; ".join(errors))
+ return {
+ "artifact_exact_set": len(rows),
+ "artifact_manifest_sha256": sha256_file(INDUSTRY_ROOT / "manifest" / "artifact_manifest.csv"),
+ "package_exact_set_receipt_sha256": sha256_file(CASE_MANIFEST / "package_exact_set_receipt.md"),
+ }
+
+
+def assert_no_pending_markers() -> None:
+ forbidden = ("DRAFT_FOR_REVIEW", "PENDING_REPAIR005_FOCUSED_REREVIEW", "NOT_ALLOWED_BEFORE_REVIEW_PASS")
+ files = list(CASE_OUTPUTS.rglob("*.md")) + [CASE_MANIFEST / "batch_summary.md", RESULT_ROOT / "result_index.md"]
+ hits = [(path, marker) for path in files for marker in forbidden if marker in path.read_text(encoding="utf-8")]
+ if hits:
+ raise RuntimeError(f"terminal Markdown still contains pending marker: {hits[:3]}")
+
+
+def run() -> dict[str, Any]:
+ accepted_at = stable_accepted_at()
+ csv_sync = sync_csv_review_statuses()
+ markdown_sync = sync_markdown_statuses()
+ output_rows = rebuild_output_manifest()
+ output_manifest_hash = sha256_file(CASE_MANIFEST / "output_manifest.csv")
+ write_acceptance_record(accepted_at, output_manifest_hash)
+ acceptance = write_acceptance_validation(accepted_at, output_rows)
+ artifacts = rebuild_artifact_manifest(output_rows)
+ artifact_validation = validate_artifact_manifest(artifacts)
+ assert_no_pending_markers()
+ return {
+ "status": "ACCEPTED_BY_INDEPENDENT_REVIEW_AND_PARENT_CLOSED",
+ "final_audit": FINAL_AUDIT,
+ "parent_status": PARENT_STATUS,
+ "accepted_at": accepted_at,
+ "candidate_state_distribution": acceptance["candidate_state_distribution"],
+ "csv_sync": csv_sync,
+ "markdown_sync": markdown_sync,
+ "output_count": len(output_rows),
+ "output_manifest_sha256": output_manifest_hash,
+ "acceptance_record_sha256": sha256_file(ACCEPTANCE_RECORD),
+ "acceptance_validation_sha256": sha256_file(ACCEPTANCE_VALIDATION),
+ **artifact_validation,
+ }
+
+
+def main() -> None:
+ print(json.dumps(run(), ensure_ascii=False, sort_keys=True, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/ana-data/tools/newenergy_batch001_repair.py b/ana-data/tools/newenergy_batch001_repair.py
new file mode 100644
index 0000000..57b392a
--- /dev/null
+++ b/ana-data/tools/newenergy_batch001_repair.py
@@ -0,0 +1,1731 @@
+#!/usr/bin/env python3
+"""Rebuild the governed BATCH-001 new-energy evidence package after HOLD/3.
+
+The script is intentionally deterministic at the transformation layer. Network
+snapshots are timestamped and hashed; all downstream CSV/Markdown files are
+rebuilt from those snapshots plus the already archived 38 official sources.
+"""
+
+from __future__ import annotations
+
+import csv
+import hashlib
+import html
+import io
+import json
+import math
+import re
+import shutil
+import sys
+import time
+import urllib.parse
+import urllib.request
+from collections import defaultdict
+from datetime import datetime
+from html.parser import HTMLParser
+from pathlib import Path
+from zoneinfo import ZoneInfo
+
+import openpyxl # noqa: F401 - dependency check and XLSX header validation
+from pypdf import PdfReader
+
+
+TASK_ID = "TASK-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001"
+CASE_ID = "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001"
+BATCH_ID = "BATCH-001"
+RUN_ID = "RUN-ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001-BATCH-001-001"
+SCHEMA_VERSION = "NEWENERGY_EXTENSION_V1"
+REVIEW_STATUS = "DRAFT_FOR_REVIEW"
+SOURCE_CUTOFF = "2026-08-05T23:59:59+08:00"
+AS_OF_DATE = "2026-08-05"
+COLLECTED_AT = datetime.now(ZoneInfo("Asia/Shanghai")).replace(microsecond=0).isoformat()
+ARTIFACT_TOOL_VERSION = "REPAIR-005"
+ARTIFACT_PARAMETERS_SUMMARY = (
+ "B1 REPAIR005; reuse 729 acquired PDFs; replay 858 pairs through the "
+ "company-self and target-bucket semantic role gate; clear non-eligible "
+ "derived fields and dangling evidence FKs; mechanical rerank; exact-set hash coverage"
+)
+ARTIFACT_STATUS = "READY_FOR_REPAIR005_FOCUSED_REREVIEW"
+
+PROJECT_ROOT = Path(__file__).resolve().parents[2]
+INDUSTRY_ROOT = PROJECT_ROOT / "ana-data/cases/新能源案例"
+CASE_ROOT = INDUSTRY_ROOT / CASE_ID
+RESULT_ROOT = PROJECT_ROOT / f"ana-data/result/新能源案例/{CASE_ID}"
+RAW_ROOT = INDUSTRY_ROOT / "raw"
+CONVERTED_ROOT = INDUSTRY_ROOT / "converted"
+EXTRACTED_ROOT = INDUSTRY_ROOT / "extracted"
+SUPPLEMENT_ROOT = INDUSTRY_ROOT / "supplement"
+EVIDENCE_ROOT = INDUSTRY_ROOT / "evidence"
+MANIFEST_ROOT = INDUSTRY_ROOT / "manifest"
+CASE_EVIDENCE = CASE_ROOT / "evidence"
+CASE_MANIFEST = CASE_ROOT / "manifest"
+CASE_OUTPUTS = CASE_ROOT / "outputs"
+
+OLD_CASE_EVIDENCE = CASE_EVIDENCE
+OLD_CASE_MANIFEST = CASE_MANIFEST
+
+HEADERS = {
+ "artifact_manifest": [
+ "artifact_id", "task_id", "case_id", "batch_id", "run_id", "artifact_type",
+ "industry_case", "industry_id", "subindustry_id", "company_id", "logical_path",
+ "relative_path", "absolute_path", "file_name", "file_ext", "file_size", "sha256",
+ "source_doc_id", "source_url", "source_collected_at", "raw_pool_path",
+ "source_file_name", "detected_type", "archive_file_name",
+ "extension_added_by_archive_flag", "extension_mismatch_flag", "created_at",
+ "created_by", "tool_or_method", "tool_version", "parameters_summary",
+ "source_snapshot_id", "artifact_status", "sensitivity_screen",
+ ],
+ "conversion_status": [
+ "conversion_id", "task_id", "case_id", "batch_id", "run_id", "source_doc_id",
+ "raw_pool_path", "raw_file_path", "raw_file_sha256", "detected_type",
+ "conversion_method", "parameters_summary", "converted_text_path",
+ "converted_markdown_path", "converted_path", "converted_sha256",
+ "page_or_duration_count", "status", "error_code", "error_summary", "created_at",
+ ],
+ "universe": [
+ "universe_row_id", "exchange_code", "security_code", "security_name", "legal_name",
+ "listing_status", "listing_date", "board", "source_doc_id", "source_record_locator",
+ "as_of_date", "source_url", "raw_path", "raw_sha256", "review_status",
+ ],
+ "human_receipt": [
+ "validation_item_id", "task_id", "case_id", "batch_id", "run_id", "view_type",
+ "output_path", "document_status", "not_applicable_reason", "evidence_boundary",
+ "source_count", "evidence_count", "conclusion_count", "unknown_count",
+ "review_status", "validated_by", "validated_at",
+ ],
+}
+
+QUERY_CONFIG = [
+ ("BATTERY", "A", "资源与主材", "正极材料"),
+ ("BATTERY", "B", "电芯制造", "锂离子电池"),
+ ("BATTERY", "C", "系统/部件/BMS-Pack", "电池管理系统"),
+ ("BATTERY", "D", "设备与回收循环", "锂电设备"),
+ ("SOLAR", "A", "硅料/硅片与材料", "光伏硅片"),
+ ("SOLAR", "B", "电池片/组件", "光伏组件"),
+ ("SOLAR", "C", "设备/辅材/逆变器", "光伏逆变器"),
+ ("SOLAR", "D", "系统集成/电站建设运营", "光伏电站"),
+ ("WIND", "A", "材料与关键零部件", "风电零部件"),
+ ("WIND", "B", "整机", "风力发电机组"),
+ ("WIND", "C", "塔筒/海缆/工程配套", "风电塔筒"),
+ ("WIND", "D", "项目运营与运维服务", "风电场"),
+ ("NUCLEAR", "A", "运营商", "核电运营"),
+ ("NUCLEAR", "B", "工程/EPC", "核电工程"),
+ ("NUCLEAR", "C", "核岛/常规岛主设备", "核电设备"),
+ ("NUCLEAR", "D", "核级部件/材料/仪控电气", "核级阀门"),
+]
+
+TRACK_ORDER = {"BATTERY": 0, "SOLAR": 1, "WIND": 2, "NUCLEAR": 3}
+BUCKET_ORDER = {(track, bucket): i for i, (track, _, bucket, _) in enumerate(QUERY_CONFIG)}
+
+
+def rel(path: Path) -> str:
+ return path.resolve().relative_to(PROJECT_ROOT.resolve()).as_posix()
+
+
+def sha256_bytes(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest()
+
+
+def sha256_file(path: Path) -> str:
+ h = hashlib.sha256()
+ with path.open("rb") as f:
+ for chunk in iter(lambda: f.read(1024 * 1024), b""):
+ h.update(chunk)
+ return h.hexdigest()
+
+
+def write_bytes(path: Path, data: bytes) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(data)
+
+
+def write_text(path: Path, text: str) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text.replace("\r\n", "\n"), encoding="utf-8", newline="\n")
+
+
+def read_csv(path: Path) -> list[dict[str, str]]:
+ with path.open("r", encoding="utf-8-sig", newline="") as f:
+ return list(csv.DictReader(f))
+
+
+def write_csv(path: Path, fieldnames: list[str], rows: list[dict[str, object]]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("w", encoding="utf-8", newline="") as f:
+ w = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore", quoting=csv.QUOTE_ALL)
+ w.writeheader()
+ for row in rows:
+ w.writerow({k: "" if row.get(k) is None else row.get(k, "") for k in fieldnames})
+
+
+def fetch(url: str, *, method: str = "GET", body: bytes | None = None, referer: str = "") -> bytes:
+ headers = {
+ "User-Agent": "Mozilla/5.0",
+ "Accept": "application/json, text/plain, */*",
+ "Referer": referer or url,
+ "X-Requested-With": "XMLHttpRequest",
+ }
+ req = urllib.request.Request(url, data=body, headers=headers, method=method)
+ for attempt in range(5):
+ try:
+ with urllib.request.urlopen(req, timeout=45) as response:
+ return response.read()
+ except Exception:
+ if attempt == 4:
+ raise
+ time.sleep(1.5 * (attempt + 1))
+ raise RuntimeError("unreachable")
+
+
+class TextExtractor(HTMLParser):
+ def __init__(self) -> None:
+ super().__init__()
+ self.parts: list[str] = []
+
+ def handle_data(self, data: str) -> None:
+ value = re.sub(r"\s+", " ", data).strip()
+ if value:
+ self.parts.append(value)
+
+
+def html_to_text(data: bytes) -> str:
+ text = data.decode("utf-8", errors="replace")
+ parser = TextExtractor()
+ parser.feed(text)
+ return "\n".join(parser.parts) + "\n"
+
+
+def clean_html_text(value: str) -> str:
+ return html.unescape(re.sub(r"<[^>]+>", "", value or "")).strip()
+
+
+def clean_annual_title(title: str) -> bool:
+ compact = re.sub(r"\s+", "", clean_html_text(title))
+ if "2025年年度报告" not in compact:
+ return False
+ excluded = ["摘要", "审计", "问询", "回复", "说明", "意见", "更正公告", "英文版"]
+ return not any(token in compact for token in excluded)
+
+
+def ensure_roots() -> None:
+ for p in [
+ CONVERTED_ROOT / "official_filings", CONVERTED_ROOT / "official_market",
+ CONVERTED_ROOT / "official_discovery", EXTRACTED_ROOT, SUPPLEMENT_ROOT,
+ EVIDENCE_ROOT, MANIFEST_ROOT, CASE_EVIDENCE, CASE_MANIFEST,
+ RAW_ROOT / "official_universe", RAW_ROOT / "official_discovery",
+ ]:
+ p.mkdir(parents=True, exist_ok=True)
+
+
+def collect_sse_universe() -> tuple[list[dict[str, str]], Path, str]:
+ endpoint = "https://query.sse.com.cn/sseQuery/commonQuery.do"
+ payloads = []
+ records: list[dict[str, str]] = []
+ for stock_type, board in [("1", "SSE_MAIN"), ("8", "SSE_STAR")]:
+ params = {
+ "STOCK_TYPE": stock_type, "REG_PROVINCE": "", "CSRC_CODE": "",
+ "STOCK_CODE": "", "sqlId": "COMMON_SSE_CP_GPJCTPZ_GPLB_GP_L",
+ "COMPANY_STATUS": "2,4,5,7,8", "type": "inParams", "isPagination": "true",
+ "pageHelp.cacheSize": "1", "pageHelp.beginPage": "1",
+ "pageHelp.pageSize": "5000", "pageHelp.pageNo": "1",
+ }
+ url = endpoint + "?" + urllib.parse.urlencode(params)
+ obj = json.loads(fetch(url, referer="https://www.sse.com.cn/assortment/stock/home/"))
+ payloads.append({"request_url": url, "response": obj})
+ for item in obj.get("result", []):
+ code = str(item.get("A_STOCK_CODE", "")).strip()
+ if not re.fullmatch(r"\d{6}", code):
+ continue
+ records.append({
+ "exchange_code": "SSE", "security_code": code,
+ "security_name": str(item.get("COMPANY_ABBR", "")).strip(),
+ "legal_name": str(item.get("FULL_NAME", "")).strip(),
+ "listing_status": "LISTED", "listing_date": str(item.get("LIST_DATE", "")).strip(),
+ "board": board, "locator": f"STOCK_TYPE={stock_type};A_STOCK_CODE={code}",
+ })
+ wrapper = {
+ "snapshot_id": "S-UNIVERSE-SSE-20260805", "as_of_date": AS_OF_DATE,
+ "source": "上海证券交易所股票列表官方查询接口", "payloads": payloads,
+ }
+ path = RAW_ROOT / "official_universe/SSE_A_SHARE_UNIVERSE_20260805.json"
+ write_text(path, json.dumps(wrapper, ensure_ascii=False, indent=2) + "\n")
+ return records, path, "https://www.sse.com.cn/assortment/stock/home/"
+
+
+def collect_szse_universe() -> tuple[list[dict[str, str]], Path, str]:
+ endpoint = "https://www.szse.cn/api/report/ShowReport"
+ referer = "https://www.szse.cn/market/stock/company/"
+ params = {"SHOWTYPE": "xlsx", "CATALOGID": "1110x", "TABKEY": "tab1"}
+ url = endpoint + "?" + urllib.parse.urlencode(params)
+ raw_bytes = fetch(url, referer=referer)
+ if not raw_bytes.startswith(b"PK\x03\x04"):
+ raise RuntimeError("SZSE company-list download is not an XLSX/ZIP payload")
+ path = RAW_ROOT / "official_universe/SZSE_A_SHARE_UNIVERSE_20260805.xlsx"
+ write_bytes(path, raw_bytes)
+ # The official workbook advertises an incorrect A1:A1 worksheet dimension;
+ # normal mode is required for openpyxl to discover all physical rows.
+ workbook = openpyxl.load_workbook(io.BytesIO(raw_bytes), read_only=False, data_only=True)
+ worksheet = workbook.active
+ records: list[dict[str, str]] = []
+ for row_no, values in enumerate(worksheet.iter_rows(values_only=True), 1):
+ code = str(values[0] or "").strip().zfill(6)
+ if not re.fullmatch(r"(?:00|30)\d{4}", code):
+ continue
+ records.append({
+ "exchange_code": "SZSE", "security_code": code,
+ "security_name": "", "legal_name": "", "listing_status": "LISTED", "listing_date": "",
+ "board": "SZSE_CHINEXT" if code.startswith("30") else "SZSE_MAIN",
+ "locator": f"xlsx_row={row_no};security_code={code}",
+ })
+ return records, path, referer
+
+
+def collect_bse_universe() -> tuple[list[dict[str, str]], Path, str]:
+ endpoint = "https://www.bse.cn/nqxxController/nqxxCnzq.do"
+ referer = "https://www.bse.cn/nq/listedcompany.html"
+ payloads = []
+ records: list[dict[str, str]] = []
+ page = 0
+ total_pages = None
+ while total_pages is None or page < total_pages:
+ body = urllib.parse.urlencode([
+ ("page", str(page)), ("typejb", "T"), ("xxfcbj[]", "2"), ("xxzqdm", ""),
+ ("sortfield", "xxzqdm"), ("sorttype", "asc"), ("callback", "cb"),
+ ]).encode("ascii")
+ raw = fetch(endpoint, method="POST", body=body, referer=referer).decode("utf-8")
+ if not raw.startswith("cb(") or not raw.endswith(")"):
+ raise RuntimeError("Unexpected BSE JSONP envelope")
+ obj = json.loads(raw[3:-1])
+ page_obj = obj[0]
+ content = page_obj["content"]
+ total_pages = int(page_obj["totalPages"])
+ payloads.append({"request": urllib.parse.parse_qs(body.decode("ascii")), "response": obj})
+ for item in content:
+ code = str(item.get("xxzqdm", "")).strip()
+ if not re.fullmatch(r"92\d{4}", code):
+ continue
+ records.append({
+ "exchange_code": "BSE", "security_code": code,
+ "security_name": str(item.get("xxzqjc", "")).strip(), "legal_name": "",
+ "listing_status": "LISTED", "listing_date": str(item.get("fxssrq", "")).strip(),
+ "board": "BSE", "locator": f"page={page};xxzqdm={code}",
+ })
+ page += 1
+ wrapper = {
+ "snapshot_id": "S-UNIVERSE-BSE-20260805", "as_of_date": AS_OF_DATE,
+ "source": "北京证券交易所股票列表官方查询接口", "payloads": payloads,
+ }
+ path = RAW_ROOT / "official_universe/BSE_A_SHARE_UNIVERSE_20260805.json"
+ write_text(path, json.dumps(wrapper, ensure_ascii=False, indent=2) + "\n")
+ return records, path, referer
+
+
+def normalize_universe() -> tuple[list[dict[str, str]], dict[str, dict[str, str]], list[dict[str, str]]]:
+ sources = []
+ all_records: list[dict[str, str]] = []
+ for collector, source_id, org in [
+ (collect_sse_universe, "S-UNIVERSE-SSE-20260805", "上海证券交易所"),
+ (collect_szse_universe, "S-UNIVERSE-SZSE-20260805", "深圳证券交易所"),
+ (collect_bse_universe, "S-UNIVERSE-BSE-20260805", "北京证券交易所"),
+ ]:
+ records, raw_path, source_url = collector()
+ raw_hash = sha256_file(raw_path)
+ for rec in records:
+ rec.update({"source_doc_id": source_id, "source_url": source_url,
+ "raw_path": rel(raw_path), "raw_sha256": raw_hash})
+ all_records.extend(records)
+ sources.append({
+ "doc_id": source_id, "title": f"{org}A股上市公司基准快照({AS_OF_DATE})",
+ "source_org": org, "author": org, "source_url": source_url,
+ "raw_path": raw_path, "raw_hash": raw_hash, "doc_type": "OFFICIAL_A_SHARE_UNIVERSE_SNAPSHOT",
+ "subindustry_id": "", "sensitivity": "LEGAL_PUBLIC_SCREENED",
+ })
+ dedup: dict[tuple[str, str], dict[str, str]] = {}
+ for rec in all_records:
+ key = (rec["exchange_code"], rec["security_code"])
+ if key in dedup:
+ raise RuntimeError(f"Duplicate universe key: {key}")
+ dedup[key] = rec
+ rows = []
+ for i, rec in enumerate(sorted(dedup.values(), key=lambda r: (r["exchange_code"], r["security_code"])), 1):
+ rows.append({
+ "universe_row_id": f"UNIV-{i:05d}", **rec, "as_of_date": AS_OF_DATE,
+ "review_status": REVIEW_STATUS,
+ })
+ write_csv(EXTRACTED_ROOT / "a_share_universe.csv", HEADERS["universe"], rows)
+ by_code: dict[str, dict[str, str]] = {}
+ for rec in rows:
+ code = rec["security_code"]
+ if code in by_code:
+ raise RuntimeError(f"Cross-exchange duplicate security code: {code}")
+ by_code[code] = rec
+ return rows, by_code, sources
+
+
+def collect_discovery_queries(universe_by_code: dict[str, dict[str, str]]) -> tuple[dict[tuple[str, str], list[dict]], list[dict[str, str]], list[dict]]:
+ endpoint = "https://www.cninfo.com.cn/new/fulltextSearch/full"
+ referer = "https://www.cninfo.com.cn/new/fulltextSearch?searchType=1"
+ hits: dict[tuple[str, str], list[dict]] = defaultdict(list)
+ source_records: list[dict[str, str]] = []
+ summaries: list[dict] = []
+ for track, bucket_code, bucket, keyword in QUERY_CONFIG:
+ pages = []
+ page = 1
+ total_pages = None
+ total_records = None
+ archived_records = 0
+ while total_records is None or archived_records < total_records:
+ params = {
+ "searchkey": keyword, "sdate": "2026-03-01", "edate": "2026-05-15",
+ "isfulltext": "true", "sortName": "pubdate", "sortType": "asc",
+ "pageNum": str(page), "pageSize": "100", "type": "szb,cyb,hzb,kcb,bjs",
+ }
+ url = endpoint + "?" + urllib.parse.urlencode(params)
+ response_bytes = fetch(url, referer=referer)
+ obj = json.loads(response_bytes)
+ if total_pages is None:
+ total_pages = int(obj.get("totalpages") or 0)
+ total_records = int(obj.get("totalRecordNum") or 0)
+ elif int(obj.get("totalRecordNum") or 0) != total_records:
+ raise RuntimeError(f"CNINFO totalRecordNum changed during pagination: {track}/{bucket_code}")
+ announcements = obj.get("announcements") or []
+ if not announcements and archived_records < total_records:
+ raise RuntimeError(
+ f"CNINFO empty page before total reached: {track}/{bucket_code} "
+ f"page={page} archived={archived_records} total={total_records}"
+ )
+ # Preserve the annual-report full-text hit context used by the
+ # REPAIR003 qualification attempt. CNINFO occasionally returns
+ # U+FFFD inside its own snippets; make those source positions
+ # explicit without retaining the replacement character itself.
+ compact_announcements = []
+ for ann in announcements:
+ announcement_content = str(ann.get("announcementContent") or "")
+ compact_announcements.append({
+ key: ann.get(key)
+ for key in [
+ "announcementId", "announcementTime", "announcementTitle",
+ "secCode", "secName", "adjunctUrl",
+ ]
+ })
+ compact_announcements[-1]["announcementContent_sanitized"] = announcement_content.replace(
+ "\ufffd", "<CNINFO_SOURCE_U_FFFD>"
+ )
+ compact_announcements[-1]["source_replacement_character_count"] = announcement_content.count("\ufffd")
+ pages.append({
+ "request_url": url,
+ "response_byte_count": len(response_bytes),
+ "response_sha256": hashlib.sha256(response_bytes).hexdigest(),
+ "response": {
+ "totalpages": obj.get("totalpages"),
+ "totalRecordNum": obj.get("totalRecordNum"),
+ "announcements": compact_announcements,
+ },
+ })
+ for ann in announcements:
+ if not clean_annual_title(str(ann.get("announcementTitle", ""))):
+ continue
+ code = str(ann.get("secCode", "")).strip()
+ if code not in universe_by_code:
+ continue
+ announcement_content = str(ann.get("announcementContent") or "")
+ hit = {
+ "track": track, "bucket_code": bucket_code, "bucket": bucket,
+ "keyword": keyword, "source_doc_id": f"S-DISCOVERY-{track}-{bucket_code}-20260805",
+ "security_code": code, "announcement_id": str(ann.get("announcementId", "")),
+ "security_name": str(ann.get("secName", "")).strip(),
+ "announcement_time": str(ann.get("announcementTime", "")),
+ "announcement_title": clean_html_text(str(ann.get("announcementTitle", ""))),
+ "adjunct_url": str(ann.get("adjunctUrl", "")),
+ "announcement_content_sanitized": announcement_content.replace(
+ "\ufffd", "<CNINFO_SOURCE_U_FFFD>"
+ ),
+ "source_replacement_character_count": announcement_content.count("\ufffd"),
+ }
+ hits[(code, track)].append(hit)
+ archived_records += len(announcements)
+ page += 1
+ if archived_records != total_records:
+ raise RuntimeError(
+ f"CNINFO archived record mismatch: {track}/{bucket_code} "
+ f"archived={archived_records} total={total_records}"
+ )
+ wrapper = {
+ "snapshot_id": f"S-DISCOVERY-{track}-{bucket_code}-20260805",
+ "as_of_date": AS_OF_DATE, "source_cutoff_at": SOURCE_CUTOFF,
+ "purpose": "candidate_discovery_and_uniform_annual_report_context_qualification_attempt_not_direct_business_evidence",
+ "query_contract": {
+ "keyword": keyword, "track_code": track, "selection_bucket": bucket,
+ "sdate": "2026-03-01", "edate": "2026-05-15", "isfulltext": True,
+ "sortName": "pubdate", "sortType": "asc", "pageSize": 100,
+ "type": "szb,cyb,hzb,kcb,bjs",
+ "annual_report_filter": "clean title contains 2025年年度报告; exclude 摘要/审计/问询/回复/说明/意见/更正公告/英文版",
+ "a_share_identity_filter": "must join official SSE/SZSE/BSE as-of universe",
+ },
+ "server_total_record_num": total_records, "server_total_pages": total_pages,
+ "server_total_pages_semantics": "LAST_FULL_PAGE_NUMBER; FINAL_REMAINDER_IS_PAGE_PLUS_ONE",
+ "archived_record_count": archived_records,
+ "archived_page_count": len(pages),
+ "terminal_page_num": len(pages),
+ "terminal_page_record_count": len(pages[-1]["response"]["announcements"]) if pages else 0,
+ "termination_reason": "ARCHIVED_RECORD_COUNT_EQUALS_SERVER_TOTAL_RECORD_NUM",
+ "pages": pages,
+ }
+ raw_path = RAW_ROOT / f"official_discovery/{track}_{bucket_code}_CNINFO_2025_AR_QUERY_20260805.json"
+ write_text(raw_path, json.dumps(wrapper, ensure_ascii=False, indent=2) + "\n")
+ source_id = f"S-DISCOVERY-{track}-{bucket_code}-20260805"
+ raw_hash = sha256_file(raw_path)
+ hit_pairs = {key for key, values in hits.items() if any(v["source_doc_id"] == source_id for v in values)}
+ summary = {
+ "source_doc_id": source_id, "track_code": track, "bucket_code": bucket_code,
+ "selection_bucket": bucket, "keyword": keyword, "server_total_record_num": total_records,
+ "server_total_pages": total_pages, "annual_report_a_share_unique_pair_hits": len(hit_pairs),
+ "archived_record_count": archived_records, "archived_page_count": len(pages),
+ "terminal_page_num": len(pages),
+ "terminal_page_record_count": len(pages[-1]["response"]["announcements"]) if pages else 0,
+ "termination_reason": "ARCHIVED_RECORD_COUNT_EQUALS_SERVER_TOTAL_RECORD_NUM",
+ "raw_path": rel(raw_path), "raw_sha256": raw_hash,
+ }
+ summaries.append(summary)
+ source_records.append({
+ "doc_id": source_id, "title": f"巨潮资讯2025年年度报告全文候选发现回执:{track}/{bucket}/{keyword}",
+ "source_org": "巨潮资讯网", "author": "巨潮资讯网", "source_url": referer,
+ "raw_path": raw_path, "raw_hash": raw_hash, "doc_type": "OFFICIAL_DISCOVERY_QUERY_RECEIPT",
+ "subindustry_id": track, "sensitivity": "LEGAL_PUBLIC_SCREENED",
+ })
+ return hits, source_records, summaries
+
+
+def build_candidate_ledger(
+ current_candidates: list[dict[str, str]], hits: dict[tuple[str, str], list[dict]],
+ universe_by_code: dict[str, dict[str, str]], source_docs_by_id: dict[str, dict[str, str]],
+) -> list[dict[str, str]]:
+ current_by_key = {(r["security_code"], r["track_code"]): dict(r) for r in current_candidates}
+ all_keys = set(current_by_key) | set(hits)
+ rows: list[dict[str, str]] = []
+ for code, track in all_keys:
+ if (code, track) in current_by_key:
+ row = current_by_key[(code, track)]
+ row["candidate_discovery_channel"] = "OFFICIAL_A_SHARE_UNIVERSE_CNINFO_FULLTEXT_AND_DIRECT_REPORT_VERIFICATION"
+ row["include_or_exclude_reason"] = (
+ "直接业务年报页级证据通过;同桶只对ELIGIBLE对象按来源等级、暴露具体性、披露期间、"
+ "年报发布日期、交易所和证券代码机械排序;未取得页级直接业务证据的查询命中保持HELD,不参与排序"
+ )
+ rows.append(row)
+ continue
+ universe = universe_by_code[code]
+ pair_hits = sorted(hits[(code, track)], key=lambda x: (BUCKET_ORDER[(track, x["bucket"])], x["announcement_time"], x["announcement_id"]))
+ chosen = pair_hits[0]
+ source_ids = sorted({x["source_doc_id"] for x in pair_hits})
+ locators = sorted({f"{x['announcement_id']}@{x['announcement_time']}" for x in pair_hits if x["announcement_id"]})
+ exchange = universe["exchange_code"]
+ rows.append({
+ "candidate_id": f"CAND-DISC-{track}-{code}", "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID,
+ "company_id": f"CN_A:{exchange}:{code}", "security_code": code,
+ "security_name": universe["security_name"] or chosen.get("security_name", ""), "legal_name": universe["legal_name"],
+ "exchange_code": exchange, "listed_status": "LISTED", "identity_as_of": AS_OF_DATE,
+ "track_code": track, "chain_nodes": "", "candidate_discovery_channel": "CNINFO_OFFICIAL_FULLTEXT_2025_ANNUAL_REPORT_QUERY",
+ "candidate_source_id": chosen["source_doc_id"], "direct_business_source_id": "",
+ "direct_business_locator": "QUERY_HITS=" + ";".join(locators[:8]),
+ "evidence_grade": "UNVERIFIED_QUERY_HIT", "exposure_specificity": "UNKNOWN",
+ "latest_disclosed_period": "2025-12-31", "selection_bucket": chosen["bucket"],
+ "candidate_state": "HELD_BY_EVIDENCE_GAP", "selection_rank": "", "tier": "",
+ "tie_break_rule": "NOT_ELIGIBLE_NO_DIRECT_BUSINESS_PAGE_EVIDENCE",
+ "include_or_exclude_reason": (
+ f"官方A股基准内公司;2025年年度报告全文检索命中{len(pair_hits)}次(回执{';'.join(source_ids)});"
+ "未完成公司—赛道页级直接业务证据验证,保持HELD,不进入T1/T2机械排序"
+ ),
+ "coverage_claim": "NONE_DISCOVERY_POOL_NOT_COMPLETE_COVERAGE_CLAIM", "review_status": REVIEW_STATUS,
+ })
+ def sort_key(row: dict[str, str]):
+ state_rank = {"INCLUDED_T1": 0, "INCLUDED_T2": 1}.get(row["candidate_state"], 2)
+ return (TRACK_ORDER[row["track_code"]], BUCKET_ORDER[(row["track_code"], row["selection_bucket"])], state_rank,
+ int(row["selection_rank"] or 999), row["exchange_code"], row["security_code"])
+ rows.sort(key=sort_key)
+ seen = set()
+ for row in rows:
+ key = (row["company_id"], row["track_code"])
+ if key in seen:
+ raise RuntimeError(f"Duplicate candidate pair: {key}")
+ seen.add(key)
+ return rows
+
+
+def apply_candidate_qualification_funnel(
+ candidates: list[dict[str, str]],
+ hits: dict[tuple[str, str], list[dict]],
+ universe_by_code: dict[str, dict[str, str]],
+ source_rows: list[dict[str, str]],
+ evidence_rows: list[dict[str, str]],
+) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
+ """Retrieve and assess official annual-report context for every pair.
+
+ The full discovery pool receives the same replayable attempt: acquire the
+ official CNINFO annual-report hit context and attachment locator, classify
+ its business context, then test whether a stable page/text locator and a
+ company evidence fact exist. A keyword hit is never silently promoted to
+ direct business evidence.
+ """
+ source_by_id = {row["doc_id"]: row for row in source_rows}
+ company_evidence: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
+ for evidence in evidence_rows:
+ if evidence.get("company_id") and evidence.get("doc_id"):
+ company_evidence[(evidence["company_id"], evidence["doc_id"])].append(evidence)
+
+ gate_by_pair: dict[tuple[str, str], dict[str, object]] = {}
+ allowed_specificity = {
+ "SEGMENT_REVENUE_OR_ASSET_DISCLOSED",
+ "NAMED_PRODUCT_PROJECT_AND_OPERATING_FACT",
+ "GENERAL_DIRECT_BUSINESS_DESCRIPTION",
+ }
+ direct_context_tokens = [
+ "公司主要从事", "主营业务", "主要业务为", "公司从事", "公司产品",
+ "公司生产", "公司业务", "业务包括", "产品包括", "本公司", "项目",
+ ]
+ negative_context_tokens = ["不涉及", "不从事", "无相关业务", "未从事"]
+ for row in candidates:
+ pair = (row["company_id"], row["track_code"])
+ query_hits = hits.get((row["security_code"], row["track_code"]), [])
+ direct_source_id = row.get("direct_business_source_id", "")
+ evidence_matches = company_evidence.get((row["company_id"], direct_source_id), [])
+ query_contexts = [
+ clean_html_text(hit.get("announcement_content_sanitized", ""))
+ for hit in query_hits
+ if hit.get("announcement_content_sanitized", "")
+ ]
+ verified_contexts = [evidence["evidence_text"] for evidence in evidence_matches if evidence.get("evidence_text")]
+ all_contexts = query_contexts + verified_contexts
+ context_joined = "\n---CONTEXT---\n".join(all_contexts)
+ context_sha256 = hashlib.sha256(context_joined.encode("utf-8")).hexdigest() if context_joined else ""
+ annual_context_pass = bool(query_contexts or verified_contexts)
+ context_screen_pass = annual_context_pass
+ if verified_contexts:
+ context_result = "VERIFIED_PAGE_EVIDENCE_CONTEXT"
+ elif any(token in context_joined for token in negative_context_tokens):
+ context_result = "EXPLICIT_NEGATIVE_OR_NON_DIRECT_CONTEXT"
+ elif any(token in context_joined for token in direct_context_tokens):
+ context_result = "POTENTIAL_DIRECT_BUSINESS_CONTEXT_REQUIRES_PAGE_VERIFICATION"
+ elif query_contexts:
+ context_result = "FULLTEXT_CONTEXT_INSUFFICIENT_FOR_DIRECT_BUSINESS"
+ else:
+ context_result = "NO_ANNUAL_REPORT_CONTEXT_ACQUIRED"
+ identity_pass = row["security_code"] in universe_by_code
+ source_pass = bool(direct_source_id and direct_source_id in source_by_id)
+ locator_pass = bool(
+ row.get("direct_business_locator")
+ and any(
+ evidence.get("source_page")
+ or evidence.get("locator_value")
+ or evidence.get("source_sentence_index")
+ for evidence in evidence_matches
+ )
+ )
+ evidence_pass = bool(evidence_matches)
+ grade_pass = row.get("evidence_grade") in {"S", "A"}
+ specificity_pass = row.get("exposure_specificity") in allowed_specificity
+ eligible = all([
+ identity_pass, annual_context_pass, context_screen_pass,
+ source_pass, locator_pass, evidence_pass, grade_pass, specificity_pass,
+ ])
+ failed = []
+ for label, passed in [
+ ("A_SHARE_IDENTITY", identity_pass),
+ ("ANNUAL_REPORT_CONTEXT_ACQUIRED", annual_context_pass),
+ ("BUSINESS_CONTEXT_SCREEN_PERFORMED", context_screen_pass),
+ ("DIRECT_BUSINESS_SOURCE", source_pass),
+ ("PAGE_OR_TEXT_LOCATOR", locator_pass),
+ ("COMPANY_EVIDENCE_FACT", evidence_pass),
+ ("SOURCE_GRADE_S_OR_A", grade_pass),
+ ("EXPOSURE_SPECIFICITY", specificity_pass),
+ ]:
+ if not passed:
+ failed.append(label)
+ gate_by_pair[pair] = {
+ "query_hits": query_hits,
+ "query_contexts": query_contexts,
+ "context_sha256": context_sha256,
+ "context_result": context_result,
+ "context_excerpt": context_joined[:5000],
+ "annual_context_pass": annual_context_pass,
+ "context_screen_pass": context_screen_pass,
+ "source_replacement_count": sum(int(hit.get("source_replacement_character_count", 0)) for hit in query_hits),
+ "identity_pass": identity_pass,
+ "source_pass": source_pass,
+ "locator_pass": locator_pass,
+ "evidence_pass": evidence_pass,
+ "grade_pass": grade_pass,
+ "specificity_pass": specificity_pass,
+ "eligible": eligible,
+ "failed": failed,
+ "evidence_matches": evidence_matches,
+ }
+ if eligible:
+ row["candidate_state"] = "ELIGIBLE"
+ row["selection_rank"] = ""
+ row["tier"] = ""
+ row["include_or_exclude_reason"] = (
+ "REPAIR003统一年度报告语境核验通过:官方年报语境已取得并判定,且A股身份、直接业务主源、"
+ "页级/文本定位、公司证据事实、S/A来源等级和暴露具体性均通过;进入全部ELIGIBLE机械排序。"
+ )
+ else:
+ row["candidate_state"] = "HELD_BY_EVIDENCE_GAP"
+ row["selection_rank"] = ""
+ row["tier"] = ""
+ row["tie_break_rule"] = "NOT_ELIGIBLE_REPAIR003_ANNUAL_REPORT_CONTEXT_AND_PAGE_FUNNEL"
+ row["include_or_exclude_reason"] = (
+ f"REPAIR003已取得/处理官方年度报告全文命中语境,语境判定={context_result};"
+ "资格 gate 未通过:" + ",".join(failed)
+ + ";无稳定页级直接业务证据时保持HELD且不参与T1/T2排序。"
+ )
+
+ grade_order = {"S": 0, "A": 1}
+ specificity_order = {
+ "SEGMENT_REVENUE_OR_ASSET_DISCLOSED": 0,
+ "NAMED_PRODUCT_PROJECT_AND_OPERATING_FACT": 1,
+ "GENERAL_DIRECT_BUSINESS_DESCRIPTION": 2,
+ }
+
+ def descending_date(value: str) -> int:
+ digits = re.sub(r"\D", "", value or "")[:8]
+ return -int(digits or "0")
+
+ eligible_groups: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
+ for row in candidates:
+ if row["candidate_state"] == "ELIGIBLE":
+ eligible_groups[(row["track_code"], row["selection_bucket"])].append(row)
+ for group_rows in eligible_groups.values():
+ group_rows.sort(key=lambda row: (
+ grade_order.get(row["evidence_grade"], 9),
+ specificity_order.get(row["exposure_specificity"], 9),
+ descending_date(row["latest_disclosed_period"]),
+ descending_date(source_by_id[row["direct_business_source_id"]].get("publish_date", "")),
+ row["exchange_code"],
+ row["security_code"],
+ ))
+ for rank, row in enumerate(group_rows, 1):
+ row["selection_rank"] = str(rank)
+ row["tie_break_rule"] = (
+ "source_grade>exposure_specificity>latest_period>publish_date>exchange_code>security_code"
+ )
+ if rank == 1:
+ row["candidate_state"] = "INCLUDED_T1"
+ row["tier"] = "T1_PRIMARY"
+ elif rank == 2:
+ row["candidate_state"] = "INCLUDED_T2"
+ row["tier"] = "T2_CANDIDATE"
+ else:
+ row["candidate_state"] = "ELIGIBLE_NOT_SELECTED_BATCH001"
+ row["tier"] = ""
+ row["include_or_exclude_reason"] += " 超过本批同桶两家上限,保留为ELIGIBLE_NOT_SELECTED_BATCH001。"
+
+ state_order = {"INCLUDED_T1": 0, "INCLUDED_T2": 1, "ELIGIBLE_NOT_SELECTED_BATCH001": 2, "HELD_BY_EVIDENCE_GAP": 3}
+ candidates.sort(key=lambda row: (
+ TRACK_ORDER[row["track_code"]],
+ BUCKET_ORDER[(row["track_code"], row["selection_bucket"])],
+ state_order.get(row["candidate_state"], 9),
+ int(row["selection_rank"] or 999999),
+ row["exchange_code"],
+ row["security_code"],
+ ))
+
+ funnel_headers = [
+ "qualification_row_id", "task_id", "case_id", "batch_id", "run_id",
+ "company_id", "security_code", "track_code", "selection_bucket",
+ "query_hit_count", "query_source_ids", "announcement_ids", "annual_report_adjunct_urls",
+ "annual_report_retrieval_attempt", "annual_report_retrieval_result",
+ "fulltext_context_locator", "fulltext_context_count", "fulltext_context_sha256",
+ "fulltext_context_excerpt_sanitized", "source_replacement_character_count",
+ "business_context_rule_result", "page_level_verification_attempt",
+ "page_level_verification_result", "a_share_identity_gate",
+ "direct_business_source_id", "direct_source_gate", "locator_gate",
+ "company_evidence_fact_gate", "source_grade_gate", "exposure_specificity_gate",
+ "evidence_fact_ids", "failed_gates", "eligibility_result",
+ "eligible_rank_in_bucket", "final_candidate_state", "mechanical_sort_key",
+ "funnel_rule_version", "replay_status", "review_status",
+ ]
+ funnel_rows = []
+ for seq, row in enumerate(candidates, 1):
+ pair = (row["company_id"], row["track_code"])
+ gate = gate_by_pair[pair]
+ query_hits = gate["query_hits"]
+ evidence_matches = gate["evidence_matches"]
+ direct_src = source_by_id.get(row.get("direct_business_source_id", ""), {})
+ mechanical_key = "|".join([
+ row.get("evidence_grade", ""), row.get("exposure_specificity", ""),
+ row.get("latest_disclosed_period", ""), direct_src.get("publish_date", ""),
+ row.get("exchange_code", ""), row.get("security_code", ""),
+ ])
+ funnel_rows.append({
+ "qualification_row_id": f"QUAL-{seq:05d}", "task_id": TASK_ID,
+ "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
+ "company_id": row["company_id"], "security_code": row["security_code"],
+ "track_code": row["track_code"], "selection_bucket": row["selection_bucket"],
+ "query_hit_count": str(len(query_hits)),
+ "query_source_ids": ";".join(sorted({hit["source_doc_id"] for hit in query_hits})),
+ "announcement_ids": ";".join(sorted({hit["announcement_id"] for hit in query_hits if hit["announcement_id"]})),
+ "annual_report_adjunct_urls": ";".join(sorted({hit["adjunct_url"] for hit in query_hits if hit["adjunct_url"]})),
+ "annual_report_retrieval_attempt": "CNINFO_OFFICIAL_2025_ANNUAL_REPORT_FULLTEXT_CONTEXT_FETCH",
+ "annual_report_retrieval_result": (
+ "ACQUIRED_FULLTEXT_QUERY_CONTEXT"
+ if gate["query_contexts"]
+ else "ACQUIRED_ARCHIVED_ANNUAL_REPORT_PAGE_CONTEXT"
+ if evidence_matches
+ else "FAILED_NO_ANNUAL_REPORT_CONTEXT"
+ ),
+ "fulltext_context_locator": ";".join(
+ sorted({f"{hit['source_doc_id']}:{hit['announcement_id']}" for hit in query_hits})
+ ),
+ "fulltext_context_count": str(len(gate["query_contexts"])),
+ "fulltext_context_sha256": gate["context_sha256"],
+ "fulltext_context_excerpt_sanitized": gate["context_excerpt"],
+ "source_replacement_character_count": str(gate["source_replacement_count"]),
+ "business_context_rule_result": gate["context_result"],
+ "page_level_verification_attempt": "CHECK_ARCHIVED_PDF_OR_STABLE_TEXT_LOCATOR_AFTER_FULLTEXT_CONTEXT",
+ "page_level_verification_result": (
+ "PASS_VERIFIED_DIRECT_BUSINESS_LOCATOR"
+ if gate["locator_pass"]
+ else "INSUFFICIENT_NO_STABLE_PAGE_LEVEL_DIRECT_BUSINESS_LOCATOR"
+ ),
+ "a_share_identity_gate": "PASS" if gate["identity_pass"] else "FAIL",
+ "direct_business_source_id": row.get("direct_business_source_id", ""),
+ "direct_source_gate": "PASS" if gate["source_pass"] else "FAIL",
+ "locator_gate": "PASS" if gate["locator_pass"] else "FAIL",
+ "company_evidence_fact_gate": "PASS" if gate["evidence_pass"] else "FAIL",
+ "source_grade_gate": "PASS" if gate["grade_pass"] else "FAIL",
+ "exposure_specificity_gate": "PASS" if gate["specificity_pass"] else "FAIL",
+ "evidence_fact_ids": ";".join(sorted(evidence["evidence_fact_id"] for evidence in evidence_matches)),
+ "failed_gates": ";".join(gate["failed"]),
+ "eligibility_result": "ELIGIBLE" if gate["eligible"] else "HELD_BY_EVIDENCE_GAP",
+ "eligible_rank_in_bucket": row["selection_rank"] if gate["eligible"] else "",
+ "final_candidate_state": row["candidate_state"],
+ "mechanical_sort_key": mechanical_key,
+ "funnel_rule_version": "REPAIR003_ANNUAL_REPORT_CONTEXT_QUALIFICATION_V1",
+ "replay_status": "ATTEMPT_COMPLETED_FOR_DISCOVERED_PAIR",
+ "review_status": REVIEW_STATUS,
+ })
+ write_csv(EXTRACTED_ROOT / "candidate_qualification_funnel.csv", funnel_headers, funnel_rows)
+ return candidates, funnel_rows
+
+
+def make_source_documents(existing_rows: list[dict[str, str]], new_sources: list[dict[str, str]]) -> list[dict[str, str]]:
+ headers = list(existing_rows[0].keys())
+ rows = []
+ for old in existing_rows:
+ row = dict(old)
+ if row["doc_id"] in {"S-MARKET-NUCLEAR-CNEA-2024", "S-MARKET-NUCLEAR-CEC-2024"}:
+ row["doc_id"] = "S-MARKET-NUCLEAR-CEC-2024"
+ row["source_org"] = "中国电力企业联合会"
+ row["author"] = "中国电力企业联合会"
+ row["raw_file_path"] = row["raw_file_path"].replace("NUCLEAR_CNEA_2024_OPERATION.html", "NUCLEAR_CEC_2024_OPERATION.html")
+ row["file_name"] = "NUCLEAR_CEC_2024_OPERATION.html"
+ row["legal_access_note"] = "中国电力企业联合会公开页;页面明确标注来源;仅作民用核电公开历史对照。"
+ rows.append(row)
+ for src in new_sources:
+ raw_path: Path = src["raw_path"]
+ rows.append({
+ "doc_id": src["doc_id"], "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "doc_type": src["doc_type"],
+ "title": src["title"], "source_org": src["source_org"], "author": src["author"],
+ "publish_date": AS_OF_DATE, "collected_at": COLLECTED_AT, "source_url": src["source_url"],
+ "industry_case": "新能源案例", "industry_id": "IND-NEWENERGY",
+ "subindustry_id": src["subindustry_id"], "company_id": "",
+ "raw_pool_path": "ana-data/cases/新能源案例/raw/", "raw_file_path": rel(raw_path),
+ "converted_text_path": "", "converted_markdown_path": "", "file_sha256": src["raw_hash"],
+ "file_name": raw_path.name, "file_size": str(raw_path.stat().st_size),
+ "detected_type": "XLSX" if raw_path.suffix.lower() == ".xlsx" else "JSON",
+ "source_language": "zh-CN", "public_access_basis": "OFFICIAL_PUBLIC_QUERY_DIRECT",
+ "access_status": "PUBLIC_DIRECT", "source_level": "S", "sensitivity_screen": src["sensitivity"],
+ "legal_access_note": "官方公开查询回执;只用于A股身份或候选发现,不替代公司—赛道直接业务页级证据。",
+ "doc_status": "INCLUDED_DISCOVERY_ONLY", "processing_status": "RAW_ARCHIVED_DISCOVERY_INDEXED",
+ })
+ # Preserve the existing schema order; all appended rows use those fields.
+ for row in rows:
+ for h in headers:
+ row.setdefault(h, "")
+ return rows
+
+
+def convert_sources(source_rows: list[dict[str, str]], evidence_rows: list[dict[str, str]], discovery_summaries: list[dict]) -> list[dict[str, str]]:
+ pages_by_doc: dict[str, set[int]] = defaultdict(set)
+ for ev in evidence_rows:
+ if ev.get("source_page", "").isdigit():
+ pages_by_doc[ev["doc_id"]].add(int(ev["source_page"]))
+ summary_by_source = {s["source_doc_id"]: s for s in discovery_summaries}
+ evidence_docs = {ev["doc_id"] for ev in evidence_rows}
+ conversions = []
+ for row in source_rows:
+ raw_path = PROJECT_ROOT / row["raw_file_path"]
+ if not raw_path.exists():
+ raise FileNotFoundError(raw_path)
+ doc_id = row["doc_id"]
+ detected = row["detected_type"].upper()
+ if detected == "PDF":
+ out = CONVERTED_ROOT / f"official_filings/{doc_id}__cited_pages.txt"
+ reader = PdfReader(str(raw_path))
+ page_numbers = sorted(pages_by_doc.get(doc_id) or {1})
+ parts = [
+ f"source_doc_id={doc_id}", f"raw_path={rel(raw_path)}", f"raw_sha256={sha256_file(raw_path)}",
+ f"conversion_scope=CITED_PAGES_ONLY:{','.join(map(str, page_numbers))}",
+ "note=正式证据只引用列出的页码;本转换不是全文覆盖声明。", "",
+ ]
+ for page_no in page_numbers:
+ if not 1 <= page_no <= len(reader.pages):
+ raise RuntimeError(f"Page {page_no} out of range for {doc_id}")
+ parts.extend([f"===== PDF_PAGE {page_no} =====", reader.pages[page_no - 1].extract_text() or "", ""])
+ write_text(out, "\n".join(parts))
+ method = "PYPDF_CITED_PAGE_TEXT_EXTRACTION"
+ params = f"UTF-8; cited_pages={','.join(map(str, page_numbers))}; page markers retained"
+ page_count = str(len(reader.pages))
+ elif detected == "HTML":
+ out = CONVERTED_ROOT / f"official_market/{doc_id}.txt"
+ text = html_to_text(raw_path.read_bytes())
+ write_text(out, f"source_doc_id={doc_id}\nraw_path={rel(raw_path)}\n\n{text}")
+ method = "HTML_TEXT_NORMALIZATION"
+ params = "Python HTMLParser; whitespace normalized; source URL retained in source_document"
+ page_count = "N/A_HTML"
+ elif detected == "JSON":
+ out = CONVERTED_ROOT / f"official_discovery/{doc_id}.txt"
+ if doc_id in summary_by_source:
+ s = summary_by_source[doc_id]
+ text = "\n".join(f"{k}={v}" for k, v in s.items()) + "\n"
+ else:
+ obj = json.loads(raw_path.read_text(encoding="utf-8"))
+ payload_count = len(obj.get("payloads", []))
+ text = f"source_doc_id={doc_id}\nas_of_date={AS_OF_DATE}\npayload_count={payload_count}\n"
+ write_text(out, text)
+ method = "JSON_RECEIPT_SUMMARY"
+ params = "Full official JSON payload retained in raw; converted text is a deterministic index summary"
+ page_count = "N/A_JSON"
+ elif detected == "XLSX":
+ out = CONVERTED_ROOT / f"official_discovery/{doc_id}.txt"
+ # The official workbook has a stale A1:A1 dimension, so read-only
+ # mode would silently expose only its header row.
+ workbook = openpyxl.load_workbook(raw_path, read_only=False, data_only=True)
+ worksheet = workbook.active
+ codes = []
+ for values in worksheet.iter_rows(values_only=True):
+ value = str(values[0] or "").strip().zfill(6)
+ if re.fullmatch(r"(?:00|30)\d{4}", value):
+ codes.append(value)
+ write_text(
+ out,
+ f"source_doc_id={doc_id}\nraw_path={rel(raw_path)}\nrecord_count={len(codes)}\n"
+ + "\n".join(codes)
+ + "\n",
+ )
+ method = "XLSX_FIRST_COLUMN_CODE_EXTRACTION"
+ params = "openpyxl normal mode due stale A1:A1 dimension; official workbook; first column security codes retained in source order"
+ page_count = f"ROWS={len(codes)}"
+ else:
+ raise RuntimeError(f"Unsupported source type {detected} for {doc_id}")
+ row["converted_text_path"] = rel(out)
+ row["processing_status"] = "TEXT_CONVERTED_EVIDENCE_EXTRACTED" if doc_id in evidence_docs else "TEXT_CONVERTED_INDEXED_DISCOVERY_ONLY"
+ conversions.append({
+ "conversion_id": f"CONV-{doc_id}", "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "source_doc_id": doc_id,
+ "raw_pool_path": row["raw_pool_path"], "raw_file_path": row["raw_file_path"],
+ "raw_file_sha256": row["file_sha256"], "detected_type": detected,
+ "conversion_method": method, "parameters_summary": params,
+ "converted_text_path": rel(out), "converted_markdown_path": "", "converted_path": rel(out),
+ "converted_sha256": sha256_file(out), "page_or_duration_count": page_count,
+ "status": "CONVERTED_EVIDENCE_EXTRACTED" if doc_id in evidence_docs else "CONVERTED_INDEXED_DISCOVERY_ONLY",
+ "error_code": "", "error_summary": "", "created_at": COLLECTED_AT,
+ })
+ return conversions
+
+
+def update_evidence_facts(rows: list[dict[str, str]], source_rows: list[dict[str, str]]) -> list[dict[str, str]]:
+ source_by_id = {r["doc_id"]: r for r in source_rows}
+ for row in rows:
+ if row["doc_id"] in {"S-MARKET-NUCLEAR-CNEA-2024", "S-MARKET-NUCLEAR-CEC-2024"}:
+ row["doc_id"] = "S-MARKET-NUCLEAR-CEC-2024"
+ row["original_qualifier"] = "中国电力企业联合会公开运行情况;归档页明确标注来源"
+ row["source_text_path"] = source_by_id[row["doc_id"]]["converted_text_path"]
+ if row["doc_id"] in {
+ "S-MARKET-BATTERY-MIIT-2025-0104",
+ "S-MARKET-RENEWABLE-NEA-2025",
+ "S-MARKET-WINDSOLAR-NEA-2025",
+ "S-MARKET-NUCLEAR-CEC-2024",
+ }:
+ row["processing_status"] = "EVIDENCE_EXTRACTED_AND_LINKED_TO_OUTPUT"
+ return rows
+
+
+def expand_classification(current_rows: list[dict[str, str]], candidates: list[dict[str, str]], source_rows: list[dict[str, str]]) -> list[dict[str, str]]:
+ headers = list(current_rows[0].keys())
+ current_by_pair = {(r["company_id"], r["track_code"]): dict(r) for r in current_rows}
+ source_by_id = {r["doc_id"]: r for r in source_rows}
+ rows = []
+ for cand in candidates:
+ key = (cand["company_id"], cand["track_code"])
+ if key in current_by_pair:
+ rows.append(current_by_pair[key])
+ continue
+ src = source_by_id[cand["candidate_source_id"]]
+ rows.append({
+ "classification_id": f"CLASS-DISC-{cand['track_code']}-{cand['security_code']}",
+ "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
+ "subject_type": "COMPANY_QUERY_HIT", "subject_id": cand["company_id"],
+ "industry_id": "IND-NEWENERGY", "subindustry_id": cand["track_code"],
+ "company_id": cand["company_id"], "track_code": cand["track_code"], "chain_node_id": "",
+ "scope_type": "", "classification_reason": cand["include_or_exclude_reason"],
+ "source_doc_id": cand["candidate_source_id"], "evidence_fact_id": "",
+ "raw_pool_path": src["raw_pool_path"], "raw_file_sha256": src["file_sha256"],
+ "data_status": cand["candidate_state"], "review_status": REVIEW_STATUS,
+ })
+ for row in rows:
+ for h in headers:
+ row.setdefault(h, "")
+ return rows
+
+
+def build_input_manifests(source_rows: list[dict[str, str]]) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
+ header = [
+ "input_item_id", "task_id", "case_id", "batch_id", "run_id", "source_doc_id",
+ "include_decision", "exclude_reason", "raw_pool_path", "raw_file_path", "raw_file_sha256",
+ "source_url", "source_level", "public_access_basis", "sensitivity_screen",
+ "processing_status", "review_status",
+ ]
+ industry_rows = []
+ case_rows = []
+ for src in source_rows:
+ discovery = src["doc_type"] in {"OFFICIAL_A_SHARE_UNIVERSE_SNAPSHOT", "OFFICIAL_DISCOVERY_QUERY_RECEIPT"}
+ row = {
+ "input_item_id": f"INPUT-{src['doc_id']}", "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "source_doc_id": src["doc_id"],
+ "include_decision": "INCLUDE_DISCOVERY_ONLY" if discovery else "INCLUDE",
+ "exclude_reason": "", "raw_pool_path": src["raw_pool_path"],
+ "raw_file_path": src["raw_file_path"], "raw_file_sha256": src["file_sha256"],
+ "source_url": src["source_url"], "source_level": src["source_level"],
+ "public_access_basis": src["public_access_basis"], "sensitivity_screen": src["sensitivity_screen"],
+ "processing_status": src["processing_status"], "review_status": REVIEW_STATUS,
+ }
+ industry_rows.append(row)
+ case_row = dict(row)
+ case_row["input_item_id"] = f"CASE-INPUT-{src['doc_id']}"
+ case_rows.append(case_row)
+ write_csv(MANIFEST_ROOT / "input_manifest.csv", header, industry_rows)
+ case_header = ["case_input_item_id"] + header[1:]
+ converted_case = [{"case_input_item_id": r.pop("input_item_id"), **r} for r in case_rows]
+ write_csv(CASE_MANIFEST / "case_input_manifest.csv", case_header, converted_case)
+ return industry_rows, converted_case
+
+
+def build_source_gap_audit(source_rows: list[dict[str, str]], evidence_rows: list[dict[str, str]]) -> list[dict[str, str]]:
+ header = [
+ "source_gap_audit_id", "task_id", "case_id", "batch_id", "run_id", "source_doc_id",
+ "source_received_flag", "raw_archived_flag", "converted_flag", "indexed_flag",
+ "evidence_linked_flag", "raw_pool_path", "raw_file_sha256", "gap_type", "impact",
+ "status", "review_status",
+ ]
+ evidence_docs = {r["doc_id"] for r in evidence_rows}
+ rows = []
+ for src in source_rows:
+ linked = src["doc_id"] in evidence_docs
+ rows.append({
+ "source_gap_audit_id": f"SGA-{src['doc_id']}", "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "source_doc_id": src["doc_id"],
+ "source_received_flag": "YES", "raw_archived_flag": "YES", "converted_flag": "YES",
+ "indexed_flag": "YES", "evidence_linked_flag": "YES" if linked else "DISCOVERY_ONLY_NOT_FORMAL_EVIDENCE",
+ "raw_pool_path": src["raw_pool_path"], "raw_file_sha256": src["file_sha256"],
+ "gap_type": "NONE", "impact": "NONE",
+ "status": "COMPLETE_EVIDENCE_LINKED" if linked else "COMPLETE_DISCOVERY_ONLY",
+ "review_status": REVIEW_STATUS,
+ })
+ write_csv(MANIFEST_ROOT / "source_gap_audit.csv", header, rows)
+ return rows
+
+
+def update_outputs() -> None:
+ replacements = {
+ "中国核能行业协会历史对照": "中国电力企业联合会历史对照",
+ "[中国核能行业协会:2024年全国核电运行情况]": "[中国电力企业联合会:2024年全国核电运行情况]",
+ "../evidence/evidence_fact.csv": "../../evidence/evidence_fact_table.csv",
+ "../evidence/company_track_candidate_ledger.csv": "../../extracted/company_track_candidate_ledger.csv",
+ "../evidence/newenergy_company_exposure_matrix.csv": "../../extracted/newenergy_company_exposure_matrix.csv",
+ "../evidence/newenergy_supply_demand_price_metric.csv": "../../extracted/newenergy_supply_demand_price_metric.csv",
+ "../evidence/newenergy_technology_route_matrix.csv": "../../extracted/newenergy_technology_route_matrix.csv",
+ "../evidence/newenergy_project_capacity_event.csv": "../../extracted/newenergy_project_capacity_event.csv",
+ "../manifest/source_document.csv": "../../manifest/source_document.csv",
+ "../../../../evidence/company_track_candidate_ledger.csv": "../../../../../extracted/company_track_candidate_ledger.csv",
+ "../../../../evidence/newenergy_technology_route_matrix.csv": "../../../../../extracted/newenergy_technology_route_matrix.csv",
+ "../../../../evidence/newenergy_supply_demand_price_metric.csv": "../../../../../extracted/newenergy_supply_demand_price_metric.csv",
+ }
+ for path in CASE_OUTPUTS.rglob("*.md"):
+ text = path.read_text(encoding="utf-8")
+ for old, new in replacements.items():
+ text = text.replace(old, new)
+ text = re.sub(
+ r"(?:\.\./)+manifest/source_document\.csv",
+ "../../manifest/source_document.csv",
+ text,
+ )
+ write_text(path, text)
+ result = RESULT_ROOT / "result_index.md"
+ text = result.read_text(encoding="utf-8")
+ text = re.sub(
+ r"(?:\.\./)+cases/[^/]+/" + re.escape(CASE_ID) + r"/outputs/",
+ f"../../../cases/{INDUSTRY_ROOT.name}/{CASE_ID}/outputs/",
+ text,
+ )
+ write_text(result, text)
+
+
+def expand_case_evidence_map(
+ current_rows: list[dict[str, str]], candidates: list[dict[str, str]], metrics: list[dict[str, str]],
+ evidence_rows: list[dict[str, str]],
+) -> list[dict[str, str]]:
+ headers = list(current_rows[0].keys())
+ rows = []
+ for row in current_rows:
+ row = dict(row)
+ if row["evidence_fact_id"] == "EVF-MKT-NUC-2024":
+ row["contradiction_or_limit"] = row["contradiction_or_limit"].replace("中国核能行业协会", "中国电力企业联合会")
+ row["conclusion_text"] = row["conclusion_text"].replace("中国核能行业协会", "中国电力企业联合会")
+ elif row["evidence_fact_id"] == "EVF-MKT-NUC-01":
+ row["contradiction_or_limit"] = row["contradiction_or_limit"].replace("中国电力企业联合会", "中国核能行业协会")
+ row["conclusion_text"] = row["conclusion_text"].replace("中国电力企业联合会", "中国核能行业协会")
+ rows.append(row)
+ selected = [r for r in candidates if r["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}]
+ ev_by_company = {r["company_id"]: r for r in evidence_rows if r["subject_type"] == "COMPANY"}
+ track_dir = {"BATTERY": "01_锂电", "SOLAR": "02_光伏", "WIND": "03_风电", "NUCLEAR": "04_核电"}
+ seq = 1
+ existing = {(r["output_path"], r["evidence_fact_id"], r["conclusion_text"]) for r in rows}
+
+ def add(output_path: str, evidence_fact_id: str, conclusion_text: str, anchor: str, strength: str = "DIRECT_FACT", limit: str = "") -> None:
+ nonlocal seq
+ key = (output_path, evidence_fact_id, conclusion_text)
+ if key in existing:
+ return
+ rows.append({
+ "conclusion_evidence_map_id": f"CEM-REPAIR-{seq:04d}", "task_id": TASK_ID,
+ "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
+ "conclusion_id": f"CONC-REPAIR-{seq:04d}", "output_path": output_path,
+ "section_anchor": anchor, "conclusion_text": conclusion_text,
+ "conclusion_strength": strength, "evidence_fact_id": evidence_fact_id,
+ "support_type": "SUPPORT", "contradiction_or_limit": limit,
+ "review_status": REVIEW_STATUS,
+ })
+ existing.add(key)
+ seq += 1
+
+ first_by_track = {}
+ for cand in selected:
+ ev = ev_by_company[cand["company_id"]]
+ related = rel(CASE_OUTPUTS / f"核心文档/子行业图谱/{track_dir[cand['track_code']]}/相关企业.md")
+ add(related, ev["evidence_fact_id"], ev["evidence_text"], f"company-{cand['security_code']}",
+ limit="仅证明直接业务暴露;T1/T2只是本批研究深度,不是质量、估值或投资排序。")
+ first_by_track.setdefault(cand["track_code"], (cand, ev))
+ for track, (cand, ev) in first_by_track.items():
+ industry_view = rel(CASE_OUTPUTS / "新能源行业视图.md")
+ tech_doc = rel(CASE_OUTPUTS / f"核心文档/子行业图谱/{track_dir[track]}/产业链与技术路线.md")
+ add(industry_view, ev["evidence_fact_id"], f"{cand['security_name']}的官方年报直接支持其{track}业务映射。",
+ f"track-{track.lower()}", limit="公司例证不构成行业或公司全集。")
+ add(tech_doc, ev["evidence_fact_id"], f"{cand['security_name']}的公开产品/业务事实作为产业链节点例证。",
+ "official-company-example", strength="MECHANISM_ONLY",
+ limit="只支持公开产品/业务节点,不据此推导技术优劣、份额或投资结论。")
+ for metric in metrics:
+ track = metric["track_code"]
+ if track == "CROSS_TRACK":
+ industry_view = rel(CASE_OUTPUTS / "新能源行业视图.md")
+ text = f"{metric['metric_name']}={metric['metric_value']}{metric['metric_unit']}({metric['metric_period']})"
+ add(
+ industry_view,
+ metric["evidence_fact_id"],
+ text,
+ f"metric-{metric['metric_id'].lower()}",
+ limit=metric["qualifier"],
+ )
+ continue
+ market_doc = rel(CASE_OUTPUTS / f"核心文档/子行业图谱/{track_dir[track]}/市场与供需.md")
+ text = f"{metric['metric_name']}={metric['metric_value']}{metric['metric_unit']}({metric['metric_period']})"
+ add(market_doc, metric["evidence_fact_id"], text, f"metric-{metric['metric_id'].lower()}",
+ limit=metric["qualifier"])
+ primary_market_ev = {"BATTERY": "EVF-MKT-BAT-01", "SOLAR": "EVF-MKT-SOL-01",
+ "WIND": "EVF-MKT-WIND-01", "NUCLEAR": "EVF-MKT-NUC-01"}
+ ev_by_id = {r["evidence_fact_id"]: r for r in evidence_rows}
+ for track, evidence_id in primary_market_ev.items():
+ overview = rel(CASE_OUTPUTS / f"核心文档/子行业图谱/{track_dir[track]}/总览.md")
+ ev = ev_by_id[evidence_id]
+ add(overview, evidence_id, ev["evidence_text"], "market-fact-summary",
+ limit="按来源原始期间、地域和阶段使用;不得外推或跨口径拼接。")
+ nuclear_2024 = ev_by_id["EVF-MKT-NUC-2024"]
+ for name in ["市场与供需.md", "总览.md"]:
+ path = rel(CASE_OUTPUTS / f"核心文档/子行业图谱/04_核电/{name}")
+ add(path, nuclear_2024["evidence_fact_id"], nuclear_2024["evidence_text"], "nuclear-2024-history",
+ limit="来源为中国电力企业联合会;仅作2024历史对照,不与2025口径静默拼接。")
+ return rows
+
+
+def materialize_evidence_locators(case_map: list[dict[str, str]]) -> list[dict[str, str]]:
+ """Make every map row resolve to an explicit anchor and verbatim statement."""
+ begin = "<!-- BEGIN GENERATED EVIDENCE LOCATORS REPAIR004 -->"
+ end = "<!-- END GENERATED EVIDENCE LOCATORS REPAIR004 -->"
+ by_output: dict[str, list[dict[str, str]]] = defaultdict(list)
+ for row in case_map:
+ row["conclusion_text"] = re.sub(r"\s+", " ", row["conclusion_text"]).strip()
+ if row["evidence_fact_id"] == "EVF-MKT-NUC-2024":
+ row["conclusion_text"] = row["conclusion_text"].replace("中国核能行业协会", "中国电力企业联合会")
+ row["contradiction_or_limit"] = row["contradiction_or_limit"].replace("中国核能行业协会", "中国电力企业联合会")
+ elif row["evidence_fact_id"] == "EVF-MKT-NUC-01":
+ row["conclusion_text"] = row["conclusion_text"].replace("中国电力企业联合会", "中国核能行业协会")
+ row["contradiction_or_limit"] = row["contradiction_or_limit"].replace("中国电力企业联合会", "中国核能行业协会")
+ by_output[row["output_path"]].append(row)
+
+ global_seq = 1
+ for output_path in sorted(by_output):
+ path = PROJECT_ROOT / output_path
+ if not path.exists():
+ raise FileNotFoundError(path)
+ text = path.read_text(encoding="utf-8")
+ text = re.sub(
+ r"\n## 证据定位索引(REPAIR\d+)\n\n?"
+ r"<!-- BEGIN GENERATED EVIDENCE LOCATORS REPAIR\d+ -->.*?"
+ r"<!-- END GENERATED EVIDENCE LOCATORS REPAIR\d+ -->\n?",
+ "\n",
+ text,
+ flags=re.S,
+ ).rstrip()
+ lines = ["", "", "## 证据定位索引(REPAIR004)", "", begin, ""]
+ for row in sorted(
+ by_output[output_path],
+ key=lambda item: (item["evidence_fact_id"], item["conclusion_evidence_map_id"], item["conclusion_text"]),
+ ):
+ anchor = f"evidence-locator-{global_seq:04d}"
+ global_seq += 1
+ row["section_anchor"] = anchor
+ row["review_status"] = REVIEW_STATUS
+ lines.extend([
+ f'<a id="{anchor}"></a>',
+ f'- `{row["evidence_fact_id"]}`:{row["conclusion_text"]}',
+ "",
+ ])
+ lines.append(end)
+ write_text(path, text + "\n".join(lines) + "\n")
+ return case_map
+
+
+def rewrite_batch_summary(candidate_rows: list[dict[str, str]], source_count: int, conversion_count: int, universe_rows: list[dict[str, str]]) -> None:
+ counts = defaultdict(int)
+ for row in candidate_rows:
+ counts[row["candidate_state"]] += 1
+ exchange_counts = defaultdict(int)
+ for row in universe_rows:
+ exchange_counts[row["exchange_code"]] += 1
+ text = f"""# {CASE_ID} / {BATCH_ID} 批次摘要
+
+- `task_id={TASK_ID}`
+- `run_id={RUN_ID}`
+- `schema_version={SCHEMA_VERSION}`
+- `primary_region=MAINLAND_CHINA`
+- `global_comparator=SEPARATE_CONTEXT_ONLY`
+- `source_cutoff_at={SOURCE_CUTOFF}`
+- `track_count=4`
+- `official_a_share_universe_count={len(universe_rows)}`(SSE={exchange_counts['SSE']},SZSE={exchange_counts['SZSE']},BSE={exchange_counts['BSE']})
+- `candidate_discovery_pair_count={len(candidate_rows)}`
+- `candidate_state_count=INCLUDED_T1:{counts['INCLUDED_T1']},INCLUDED_T2:{counts['INCLUDED_T2']},HELD_BY_EVIDENCE_GAP:{counts['HELD_BY_EVIDENCE_GAP']}`
+- `selected_company_track_mapping_count={counts['INCLUDED_T1'] + counts['INCLUDED_T2']}`
+- `tier_count=T1_PRIMARY:{counts['INCLUDED_T1']},T2_CANDIDATE:{counts['INCLUDED_T2']}`
+- `source_document_count={source_count}`(原38份研究来源 + 3份交易所A股基准 + 16份候选发现回执)
+- `conversion_status_count={conversion_count}`
+- `valuation_market_interface=NOT_APPLICABLE_BATCH001`
+- `market_reverse_gap_scan=NOT_APPLICABLE_BATCH001`
+- `coverage_claim=NONE_INITIAL_CANDIDATE_POOL_ONLY`
+- `research_mode=darkline_广撒网收集`
+- `output_status=DRAFT_FOR_REVIEW`
+- `review_status=PENDING_REPAIR005_FOCUSED_REREVIEW`
+
+本批仍只形成中国大陆四赛道基础行业图谱与 A 股直接业务导航。候选发现先以三家交易所官方 A 股全集为身份基准,再以巨潮资讯 16 组固定全文检索生成完整命中账本;只有已有法定年报页级直接业务证据的对象进入 ELIGIBLE 排序,其余命中全部保持 `HELD_BY_EVIDENCE_GAP`。T1/T2 只表示本批研究深度。未进行估值、行情、交易、收益、完整覆盖或公司质量排名。核电仅使用民用公开高层信息并执行关键基础设施敏感信息停止边界。
+"""
+ write_text(CASE_MANIFEST / "batch_summary.md", text)
+
+
+def write_discovery_receipt(summaries: list[dict], candidates: list[dict[str, str]], universe_rows: list[dict[str, str]]) -> None:
+ state_counts = defaultdict(int)
+ track_counts = defaultdict(int)
+ for row in candidates:
+ state_counts[row["candidate_state"]] += 1
+ track_counts[row["track_code"]] += 1
+ lines = [
+ "# BATCH-001 候选发现与机械选择回执", "",
+ f"- `task_id={TASK_ID}`", f"- `case_id={CASE_ID}`", f"- `batch_id={BATCH_ID}`", f"- `run_id={RUN_ID}`",
+ f"- `as_of_date={AS_OF_DATE}`", f"- `source_cutoff_at={SOURCE_CUTOFF}`", "- `coverage_claim=NONE`", "",
+ "## 1. A股身份基准", "",
+ f"官方交易所基准合计 `{len(universe_rows)}` 条;唯一键为 `(exchange_code, security_code)`。三份完整官方响应保存于行业统一 raw,规范化全集保存于 `extracted/a_share_universe.csv`。该基准只证明证券身份,不证明新能源业务。", "",
+ "## 2. 发现查询", "",
+ "查询时间窗固定为 `2026-03-01..2026-05-15`,只查 `szb,cyb,hzb,kcb,bjs`,全文检索、发布日期升序、每页100条;从 pageNum=1 开始,持续取得直到 archived_record_count 精确等于 server_totalRecordNum。接口 totalpages 表示最后一个完整页号,存在残页时继续抓取 totalpages+1。只保留标题为2025年年度报告全文且能回连官方A股基准的命中。搜索引擎未作为纳入证据。", "",
+ "| 赛道 | 桶 | 关键词 | 服务器记录 | 已归档记录 | API页值 | 实际页数 | 终止页条数 | A股年报唯一命中 | raw SHA-256 |", "|---|---|---|---:|---:|---:|---:|---:|---:|---|",
+ ]
+ for s in summaries:
+ lines.append(f"| `{s['track_code']}` | {s['selection_bucket']} | {s['keyword']} | {s['server_total_record_num']} | {s['archived_record_count']} | {s['server_total_pages']} | {s['archived_page_count']} | {s['terminal_page_record_count']} | {s['annual_report_a_share_unique_pair_hits']} | `{s['raw_sha256']}` |")
+ lines.extend([
+ "", "## 3. 全量账本与选择", "",
+ f"规范化后唯一 `(company_id, track_code)` 共 `{len(candidates)}` 条:BATTERY={track_counts['BATTERY']}、SOLAR={track_counts['SOLAR']}、WIND={track_counts['WIND']}、NUCLEAR={track_counts['NUCLEAR']}。",
+ f"其中 `INCLUDED_T1={state_counts['INCLUDED_T1']}`、`INCLUDED_T2={state_counts['INCLUDED_T2']}`、`HELD_BY_EVIDENCE_GAP={state_counts['HELD_BY_EVIDENCE_GAP']}`。所有命中均保留,未入选/证据不足对象没有删除。", "",
+ "`extracted/candidate_qualification_funnel.csv` 对完整唯一命中池逐行保存实际年度报告语境处理回执:公告 ID/附件 URL → 官方全文命中语境 → 语境 locator/hash/规则判定 → 稳定页级直接业务定位 → 公司 evidence fact → S/A来源等级 → 暴露具体性。每个发现 pair 均记录取得、处理、失败/不足结果,不再只做既有 evidence join。机械顺序仍为:直接业务来源等级 → 暴露具体性 → 披露期间 → 发布日期 → 交易所 → 证券代码。只有所有 gate 通过的对象进入 ELIGIBLE 排序;全文命中本身不升级为业务证据。", "",
+ "## 4. 边界", "",
+ "该回执只证明本次冻结查询的命中集合和选择过程可重跑,不声称覆盖全部新能源公司。核电查询只用于民用公开高层业务发现,不读取、保存或推断关键基础设施敏感细节。", "",
+ ])
+ write_text(SUPPLEMENT_ROOT / "candidate_discovery_receipt.md", "\n".join(lines))
+ write_csv(MANIFEST_ROOT / "candidate_discovery_query_summary.csv", list(summaries[0].keys()), summaries)
+
+
+def build_human_receipt(case_map: list[dict[str, str]], source_count: int) -> None:
+ header = HEADERS["human_receipt"]
+ by_path = defaultdict(list)
+ for row in case_map:
+ by_path[row["output_path"]].append(row)
+ md_paths = sorted(CASE_OUTPUTS.rglob("*.md"))
+ rows = []
+ for i, path in enumerate(md_paths, 1):
+ p = rel(path)
+ name = path.name
+ if name == "新能源行业视图.md":
+ view_type = "INDUSTRY_VIEW"
+ elif name == "新能源市场视图.md":
+ view_type = "MARKET_VIEW"
+ elif name == "新能源公司视图.md":
+ view_type = "COMPANY_VIEW"
+ elif name == "新能源报告索引.md":
+ view_type = "REPORT_INDEX"
+ elif name in {"summary.md", "readout.md"}:
+ view_type = name.removesuffix(".md").upper()
+ else:
+ view_type = "SUBINDUSTRY_OR_GAP_DOC"
+ mappings = by_path.get(p, [])
+ rows.append({
+ "validation_item_id": f"HDV-NEWENERGY-{i:03d}", "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "view_type": view_type, "output_path": p,
+ "document_status": "DRAFT_FOR_REPAIR005_FOCUSED_REREVIEW", "not_applicable_reason": "",
+ "evidence_boundary": "OFFICIAL_PUBLIC_SOURCES; STRONG_FACTS_MAPPED; GAP_AND_MECHANISM_LIMITS_RETAINED",
+ "source_count": str(source_count), "evidence_count": str(len({x['evidence_fact_id'] for x in mappings})),
+ "conclusion_count": str(len(mappings)), "unknown_count": "0",
+ "review_status": "PENDING_REPAIR005_FOCUSED_REREVIEW",
+ "validated_by": "case_analysis.analyst.new_energy", "validated_at": COLLECTED_AT,
+ })
+ write_csv(CASE_MANIFEST / "human_doc_validation_receipt.csv", header, rows)
+
+
+def rebuild_output_manifest() -> list[dict[str, str]]:
+ path = CASE_MANIFEST / "output_manifest.csv"
+ rows = read_csv(path)
+ for row in rows:
+ output = PROJECT_ROOT / row["output_path"]
+ if not output.exists():
+ raise FileNotFoundError(output)
+ row["output_sha256"] = sha256_file(output)
+ row["data_status"] = REVIEW_STATUS
+ row["review_status"] = "PENDING_REPAIR005_FOCUSED_REREVIEW"
+ write_csv(path, list(rows[0].keys()), rows)
+ return rows
+
+
+def write_exact_set_receipt_placeholder() -> Path:
+ path = CASE_MANIFEST / "package_exact_set_receipt.md"
+ write_text(path, "# Formal package exact-set receipt\n\nPENDING_ENUMERATION\n")
+ return path
+
+
+def formal_files(extra_tool: Path) -> list[Path]:
+ roots = [
+ RAW_ROOT, CONVERTED_ROOT, EXTRACTED_ROOT, SUPPLEMENT_ROOT, EVIDENCE_ROOT, MANIFEST_ROOT,
+ CASE_ROOT, RESULT_ROOT,
+ ]
+ files = set()
+ artifact_manifest = MANIFEST_ROOT / "artifact_manifest.csv"
+ for root in roots:
+ if not root.exists():
+ continue
+ for path in root.rglob("*"):
+ if not path.is_file() or path.name == ".gitkeep" or path.resolve() == artifact_manifest.resolve():
+ continue
+ if any(part.lower() in {"tmp", "img"} for part in path.parts):
+ continue
+ files.add(path.resolve())
+ files.add(extra_tool.resolve())
+ return sorted(files, key=lambda p: rel(p))
+
+
+def write_exact_set_receipt(files: list[Path]) -> None:
+ counts = defaultdict(int)
+ for path in files:
+ r = rel(path)
+ if "/raw/" in f"/{r}/":
+ counts["raw"] += 1
+ elif "/converted/" in f"/{r}/":
+ counts["converted"] += 1
+ elif "/extracted/" in f"/{r}/":
+ counts["extracted"] += 1
+ elif "/evidence/" in f"/{r}/":
+ counts["evidence"] += 1
+ elif "/manifest/" in f"/{r}/":
+ counts["manifest"] += 1
+ elif "/outputs/" in f"/{r}/":
+ counts["outputs"] += 1
+ elif "/result/" in f"/{r}/":
+ counts["result"] += 1
+ else:
+ counts["other"] += 1
+ text = f"""# BATCH-001 正式产物 exact-set 回执
+
+- `task_id={TASK_ID}`
+- `case_id={CASE_ID}`
+- `batch_id={BATCH_ID}`
+- `run_id={RUN_ID}`
+- `generated_at={COLLECTED_AT}`
+- `formal_file_count_excluding_artifact_manifest={len(files)}`
+- `raw={counts['raw']}`
+- `converted={counts['converted']}`
+- `extracted={counts['extracted']}`
+- `evidence={counts['evidence']}`
+- `manifest={counts['manifest']}`
+- `outputs={counts['outputs']}`
+- `result={counts['result']}`
+- `other_replay_tool={counts['other']}`
+
+`artifact_manifest.csv` 对以上 exact-set 中每个文件逐项记录 bytes 与 SHA-256。该 manifest 自身因自引用哈希不可满足而明确自排除;复审时直接对 manifest 文件另算 SHA-256。`.gitkeep` 仅为目录脚手架,不属于正式产物并明确排除。`tmp/` 与 `img/` 本批没有被正式结论引用,均不进入 formal exact-set。
+"""
+ write_text(CASE_MANIFEST / "package_exact_set_receipt.md", text)
+
+
+def artifact_type(path: Path) -> str:
+ r = rel(path)
+ if "/raw/" in f"/{r}/":
+ return "RAW_DOCUMENT"
+ if "/converted/" in f"/{r}/":
+ return "CONVERTED_TEXT"
+ if "/supplement/" in f"/{r}/":
+ return "SUPPLEMENT_SOURCE"
+ if "/extracted/" in f"/{r}/":
+ return "EXTRACTED_FACT"
+ if "/evidence/" in f"/{r}/":
+ return "EVIDENCE_INDEX"
+ if "/outputs/" in f"/{r}/":
+ return "REPORT_MARKDOWN"
+ if "/manifest/" in f"/{r}/":
+ return "MANIFEST"
+ if "/result/" in f"/{r}/":
+ return "PACKAGE"
+ if r.endswith(".py"):
+ return "REPLAY_TOOL"
+ return "OUTPUT_TABLE"
+
+
+def build_artifact_manifest(source_rows: list[dict[str, str]], output_rows: list[dict[str, str]], tool_path: Path) -> list[dict[str, str]]:
+ write_exact_set_receipt_placeholder()
+ files = formal_files(tool_path)
+ write_exact_set_receipt(files)
+ files = formal_files(tool_path)
+ source_by_raw = {r["raw_file_path"]: r for r in source_rows}
+ source_by_converted = {r["converted_text_path"]: r for r in source_rows if r["converted_text_path"]}
+ output_by_path = {r["output_path"]: r for r in output_rows}
+ rows = []
+ generic_i = 1
+ for path in files:
+ r = rel(path)
+ src = source_by_raw.get(r) or source_by_converted.get(r)
+ out = output_by_path.get(r)
+ if out:
+ artifact_id = out["artifact_id"]
+ elif src and r == src["raw_file_path"]:
+ artifact_id = f"ART-{src['doc_id']}-RAW"
+ elif src:
+ artifact_id = f"ART-{src['doc_id']}-CONVERTED"
+ else:
+ artifact_id = f"ART-REPAIR-{generic_i:04d}"
+ generic_i += 1
+ rows.append({
+ "artifact_id": artifact_id, "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "artifact_type": artifact_type(path),
+ "industry_case": "新能源案例", "industry_id": "IND-NEWENERGY",
+ "subindustry_id": src["subindustry_id"] if src else "",
+ "company_id": src["company_id"] if src else "", "logical_path": r, "relative_path": r,
+ "absolute_path": path.resolve().as_posix(), "file_name": path.name,
+ "file_ext": path.suffix.lower(), "file_size": str(path.stat().st_size), "sha256": sha256_file(path),
+ "source_doc_id": src["doc_id"] if src else "", "source_url": src["source_url"] if src else "",
+ "source_collected_at": src["collected_at"] if src else COLLECTED_AT,
+ "raw_pool_path": src["raw_pool_path"] if src else "", "source_file_name": path.name,
+ "detected_type": path.suffix.lower().lstrip(".").upper() or "NO_EXT", "archive_file_name": path.name,
+ "extension_added_by_archive_flag": "NO", "extension_mismatch_flag": "NO",
+ "created_at": COLLECTED_AT, "created_by": "case_analysis.analyst.new_energy",
+ "tool_or_method": "NEWENERGY_BATCH001_REPAIR_REBUILD",
+ "tool_version": ARTIFACT_TOOL_VERSION, "parameters_summary": ARTIFACT_PARAMETERS_SUMMARY,
+ "source_snapshot_id": f"SNAP-{src['doc_id']}" if src else "",
+ "artifact_status": ARTIFACT_STATUS,
+ "sensitivity_screen": src["sensitivity_screen"] if src else "LEGAL_PUBLIC_SCREENED",
+ })
+ if len({r["artifact_id"] for r in rows}) != len(rows):
+ raise RuntimeError("Duplicate artifact_id")
+ write_csv(MANIFEST_ROOT / "artifact_manifest.csv", HEADERS["artifact_manifest"], rows)
+ return rows
+
+
+def remove_case_level_duplicates() -> None:
+ for name in [
+ "company_track_candidate_ledger.csv", "classification_summary.csv", "evidence_fact.csv",
+ "newenergy_scope_matrix.csv", "newenergy_technology_route_matrix.csv",
+ "newenergy_supply_demand_price_metric.csv", "newenergy_project_capacity_event.csv",
+ "newenergy_company_exposure_matrix.csv", "newenergy_catalyst_risk_register.csv",
+ ]:
+ path = CASE_EVIDENCE / name
+ if path.exists():
+ path.unlink()
+ for name in ["artifact_manifest.csv", "source_document.csv", "input_manifest.csv", "source_gap_audit.csv"]:
+ path = CASE_MANIFEST / name
+ if path.exists():
+ path.unlink()
+
+
+def validate(
+ candidates: list[dict[str, str]], universe_rows: list[dict[str, str]], source_rows: list[dict[str, str]],
+ conversions: list[dict[str, str]], artifacts: list[dict[str, str]], case_map: list[dict[str, str]],
+ evidence_rows: list[dict[str, str]], query_summaries: list[dict], qualification_rows: list[dict[str, str]],
+) -> None:
+ errors = []
+ if len(universe_rows) != len({(r["exchange_code"], r["security_code"]) for r in universe_rows}):
+ errors.append("universe duplicate")
+ exchange_counts: dict[str, int] = defaultdict(int)
+ for row in universe_rows:
+ exchange_counts[row["exchange_code"]] += 1
+ expected_exchange_counts = {"SSE": 2310, "SZSE": 2898, "BSE": 333}
+ if dict(exchange_counts) != expected_exchange_counts:
+ errors.append(f"universe exchange counts {dict(exchange_counts)} != {expected_exchange_counts}")
+ if len(candidates) != len({(r["company_id"], r["track_code"]) for r in candidates}):
+ errors.append("candidate duplicate")
+ if len(query_summaries) != 16:
+ errors.append(f"query summary count {len(query_summaries)}")
+ for summary in query_summaries:
+ if summary["archived_record_count"] != summary["server_total_record_num"]:
+ errors.append(f"query incomplete {summary['source_doc_id']}")
+ expected_terminal = summary["server_total_record_num"] % 100 or 100
+ if summary["terminal_page_record_count"] != expected_terminal:
+ errors.append(f"query terminal page {summary['source_doc_id']}")
+ if len(qualification_rows) != len(candidates):
+ errors.append(f"qualification count {len(qualification_rows)} != {len(candidates)}")
+ if len({(r["company_id"], r["track_code"]) for r in qualification_rows}) != len(qualification_rows):
+ errors.append("qualification duplicate")
+ for row in qualification_rows:
+ if row["replay_status"] != "ATTEMPT_COMPLETED_FOR_DISCOVERED_PAIR":
+ errors.append(f"qualification attempt missing {row['qualification_row_id']}")
+ if row["annual_report_retrieval_result"] == "FAILED_NO_ANNUAL_REPORT_CONTEXT":
+ errors.append(f"annual report context missing {row['qualification_row_id']}")
+ if not row["fulltext_context_sha256"] or not row["business_context_rule_result"]:
+ errors.append(f"annual report context unprocessed {row['qualification_row_id']}")
+ if "\ufffd" in row["fulltext_context_excerpt_sanitized"]:
+ errors.append(f"qualification replacement char {row['qualification_row_id']}")
+ if row["eligibility_result"] == "ELIGIBLE" and row["page_level_verification_result"] != "PASS_VERIFIED_DIRECT_BUSINESS_LOCATOR":
+ errors.append(f"eligible page locator {row['qualification_row_id']}")
+ if row["eligibility_result"] != "ELIGIBLE" and not row["failed_gates"]:
+ errors.append(f"held without failed gate {row['qualification_row_id']}")
+ selected = [r for r in candidates if r["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}]
+ if len(selected) != 32:
+ errors.append(f"selected != 32 ({len(selected)})")
+ if not any(r["candidate_state"] == "HELD_BY_EVIDENCE_GAP" for r in candidates):
+ errors.append("no held discovery candidates")
+ for track, _, bucket, _ in QUERY_CONFIG:
+ bucket_rows = [r for r in selected if r["track_code"] == track and r["selection_bucket"] == bucket]
+ if sorted((r["candidate_state"], r["selection_rank"]) for r in bucket_rows) != [("INCLUDED_T1", "1"), ("INCLUDED_T2", "2")]:
+ errors.append(f"selection bucket invalid {track}/{bucket}")
+ if len(source_rows) != len({r["doc_id"] for r in source_rows}):
+ errors.append("source duplicate")
+ if len(conversions) != len(source_rows):
+ errors.append("conversion count mismatch")
+ for row in conversions:
+ path = PROJECT_ROOT / row["converted_path"]
+ if not path.exists() or sha256_file(path) != row["converted_sha256"]:
+ errors.append(f"conversion hash {row['conversion_id']}")
+ evidence_doc_ids = {row["doc_id"] for row in evidence_rows}
+ source_by_id = {row["doc_id"]: row for row in source_rows}
+ conversion_by_id = {row["source_doc_id"]: row for row in conversions}
+ for doc_id in evidence_doc_ids:
+ if source_by_id[doc_id]["processing_status"] != "TEXT_CONVERTED_EVIDENCE_EXTRACTED":
+ errors.append(f"source evidence processing {doc_id}")
+ if conversion_by_id[doc_id]["status"] != "CONVERTED_EVIDENCE_EXTRACTED":
+ errors.append(f"conversion evidence processing {doc_id}")
+ formal = formal_files(Path(__file__))
+ if len(artifacts) != len(formal):
+ errors.append(f"artifact exact-set mismatch {len(artifacts)} != {len(formal)}")
+ if any(row["tool_version"] != ARTIFACT_TOOL_VERSION for row in artifacts):
+ errors.append("artifact tool version mismatch")
+ artifact_paths = {r["relative_path"] for r in artifacts}
+ missing = {rel(p) for p in formal} - artifact_paths
+ if missing:
+ errors.append(f"artifact missing {sorted(missing)[:5]}")
+ required_output_fragments = ["新能源行业视图.md"] + [f"{d}/{n}" for d in ["01_锂电", "02_光伏", "03_风电", "04_核电"] for n in ["总览.md", "产业链与技术路线.md", "市场与供需.md", "相关企业.md"]]
+ mapped_paths = {r["output_path"] for r in case_map}
+ for fragment in required_output_fragments:
+ if not any(p.endswith(fragment) for p in mapped_paths):
+ errors.append(f"unmapped output {fragment}")
+ if "EVF-MKT-NUC-2024" not in {r["evidence_fact_id"] for r in case_map}:
+ errors.append("nuclear 2024 evidence unmapped")
+ for row in case_map:
+ output = PROJECT_ROOT / row["output_path"]
+ if not output.exists():
+ errors.append(f"map output missing {row['conclusion_evidence_map_id']}")
+ continue
+ text = output.read_text(encoding="utf-8")
+ if f'<a id="{row["section_anchor"]}"></a>' not in text:
+ errors.append(f"map anchor missing {row['conclusion_evidence_map_id']}")
+ if row["conclusion_text"] not in text:
+ errors.append(f"map conclusion missing {row['conclusion_evidence_map_id']}")
+ cec_source = source_by_id.get("S-MARKET-NUCLEAR-CEC-2024", {})
+ if cec_source.get("source_org") != "中国电力企业联合会" or cec_source.get("author") != "中国电力企业联合会":
+ errors.append("CEC source attribution")
+ nuclear_2024 = next((row for row in evidence_rows if row["evidence_fact_id"] == "EVF-MKT-NUC-2024"), None)
+ if not nuclear_2024 or "中国电力企业联合会" not in nuclear_2024["original_qualifier"]:
+ errors.append("CEC evidence attribution")
+ nuclear_2025_maps = [row for row in case_map if row["evidence_fact_id"] == "EVF-MKT-NUC-01"]
+ nuclear_2025_attributed_maps = [
+ row for row in nuclear_2025_maps
+ if "中国核能行业协会" in (row["conclusion_text"] + " " + row["contradiction_or_limit"])
+ or "中国电力企业联合会" in (row["conclusion_text"] + " " + row["contradiction_or_limit"])
+ ]
+ if len(nuclear_2025_attributed_maps) != 4:
+ errors.append(f"nuclear 2025 attributed map count {len(nuclear_2025_attributed_maps)}")
+ for row in nuclear_2025_attributed_maps:
+ scope_text = row["conclusion_text"] + " " + row["contradiction_or_limit"]
+ if "中国核能行业协会" not in scope_text or "中国电力企业联合会" in scope_text:
+ errors.append(f"nuclear 2025 attribution {row['conclusion_evidence_map_id']}")
+ for row in [item for item in case_map if item["evidence_fact_id"] == "EVF-MKT-NUC-2024"]:
+ scope_text = row["conclusion_text"] + " " + row["contradiction_or_limit"]
+ if "中国电力企业联合会" not in scope_text or "中国核能行业协会" in scope_text:
+ errors.append(f"nuclear 2024 map attribution {row['conclusion_evidence_map_id']}")
+ for path in [INDUSTRY_ROOT / x for x in ["converted", "extracted", "supplement", "evidence", "manifest"]]:
+ if not path.exists():
+ errors.append(f"missing industry root {path}")
+ if errors:
+ raise RuntimeError("Validation failed:\n- " + "\n- ".join(errors))
+
+
+def main() -> None:
+ ensure_roots()
+ # Reruns read the industry canonical produced by an earlier successful build;
+ # the first run falls back to the HOLD-era case-local files.
+ candidate_input = EXTRACTED_ROOT / "company_track_candidate_ledger.csv"
+ if not candidate_input.exists():
+ candidate_input = OLD_CASE_EVIDENCE / "company_track_candidate_ledger.csv"
+ current_candidates = [
+ row for row in read_csv(candidate_input)
+ if row["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}
+ ]
+ selected_keys = {(row["company_id"], row["track_code"]) for row in current_candidates}
+
+ classification_input = EXTRACTED_ROOT / "classification_summary.csv"
+ if not classification_input.exists():
+ classification_input = OLD_CASE_EVIDENCE / "classification_summary.csv"
+ current_classification = [
+ row for row in read_csv(classification_input)
+ if (row["company_id"], row["track_code"]) in selected_keys
+ ]
+
+ evidence_input = EVIDENCE_ROOT / "evidence_fact_table.csv"
+ if not evidence_input.exists():
+ evidence_input = OLD_CASE_EVIDENCE / "evidence_fact.csv"
+ evidence_rows = read_csv(evidence_input)
+ current_case_map = read_csv(OLD_CASE_EVIDENCE / "case_evidence_map.csv")
+ source_input = MANIFEST_ROOT / "source_document.csv"
+ if not source_input.exists():
+ source_input = OLD_CASE_MANIFEST / "source_document.csv"
+ source_rows = [
+ row for row in read_csv(source_input)
+ if row["doc_type"] not in {"OFFICIAL_A_SHARE_UNIVERSE_SNAPSHOT", "OFFICIAL_DISCOVERY_QUERY_RECEIPT"}
+ ]
+ output_rows = read_csv(OLD_CASE_MANIFEST / "output_manifest.csv")
+
+ old_cec = RAW_ROOT / "official_market/NUCLEAR_CNEA_2024_OPERATION.html"
+ new_cec = RAW_ROOT / "official_market/NUCLEAR_CEC_2024_OPERATION.html"
+ if old_cec.exists() and not new_cec.exists():
+ old_cec.replace(new_cec)
+ elif old_cec.exists() and new_cec.exists():
+ if sha256_file(old_cec) != sha256_file(new_cec):
+ raise RuntimeError("CEC raw rename collision")
+ old_cec.unlink()
+
+ universe_rows, universe_by_code, universe_sources = normalize_universe()
+ hits, query_sources, query_summaries = collect_discovery_queries(universe_by_code)
+ all_new_sources = universe_sources + query_sources
+ source_rows = make_source_documents(source_rows, all_new_sources)
+ source_by_id = {r["doc_id"]: r for r in source_rows}
+
+ # Correct predecessor IDs before downstream joins.
+ for row in evidence_rows:
+ if row["doc_id"] == "S-MARKET-NUCLEAR-CNEA-2024":
+ row["doc_id"] = "S-MARKET-NUCLEAR-CEC-2024"
+ candidates = build_candidate_ledger(current_candidates, hits, universe_by_code, source_by_id)
+
+ # Generate conversions, then backfill canonical source/evidence paths.
+ conversions = convert_sources(source_rows, evidence_rows, query_summaries)
+ evidence_rows = update_evidence_facts(evidence_rows, source_rows)
+ source_by_id = {r["doc_id"]: r for r in source_rows}
+ candidates, qualification_rows = apply_candidate_qualification_funnel(
+ candidates, hits, universe_by_code, source_rows, evidence_rows
+ )
+
+ # Correct the CEC ID anywhere it remains in inherited case-level files.
+ for root in [CASE_EVIDENCE, CASE_MANIFEST]:
+ for path in root.glob("*.csv"):
+ text = path.read_text(encoding="utf-8")
+ text = text.replace("S-MARKET-NUCLEAR-CNEA-2024", "S-MARKET-NUCLEAR-CEC-2024")
+ text = text.replace("NUCLEAR_CNEA_2024_OPERATION.html", "NUCLEAR_CEC_2024_OPERATION.html")
+ write_text(path, text)
+
+ classification = expand_classification(current_classification, candidates, source_rows)
+ metric_input = EXTRACTED_ROOT / "newenergy_supply_demand_price_metric.csv"
+ if not metric_input.exists():
+ metric_input = OLD_CASE_EVIDENCE / "newenergy_supply_demand_price_metric.csv"
+ metrics = read_csv(metric_input)
+ for row in metrics:
+ if row["source_id"] == "S-MARKET-NUCLEAR-CNEA-2024":
+ row["source_id"] = "S-MARKET-NUCLEAR-CEC-2024"
+
+ # Move common canonical tables to their industry-level authority.
+ write_csv(MANIFEST_ROOT / "source_document.csv", list(source_rows[0].keys()), source_rows)
+ write_csv(MANIFEST_ROOT / "conversion_status.csv", HEADERS["conversion_status"], conversions)
+ build_input_manifests(source_rows)
+ build_source_gap_audit(source_rows, evidence_rows)
+ write_csv(EVIDENCE_ROOT / "evidence_fact_table.csv", list(evidence_rows[0].keys()), evidence_rows)
+ write_csv(EXTRACTED_ROOT / "company_track_candidate_ledger.csv", list(candidates[0].keys()), candidates)
+ write_csv(EXTRACTED_ROOT / "classification_summary.csv", list(classification[0].keys()), classification)
+
+ for name in [
+ "newenergy_scope_matrix.csv", "newenergy_technology_route_matrix.csv",
+ "newenergy_project_capacity_event.csv", "newenergy_company_exposure_matrix.csv",
+ "newenergy_catalyst_risk_register.csv",
+ ]:
+ input_path = EXTRACTED_ROOT / name
+ if not input_path.exists():
+ input_path = OLD_CASE_EVIDENCE / name
+ rows = read_csv(input_path)
+ write_csv(EXTRACTED_ROOT / name, list(rows[0].keys()), rows)
+ write_csv(EXTRACTED_ROOT / "newenergy_supply_demand_price_metric.csv", list(metrics[0].keys()), metrics)
+
+ update_outputs()
+ case_map = expand_case_evidence_map(current_case_map, candidates, metrics, evidence_rows)
+ case_map = materialize_evidence_locators(case_map)
+ write_csv(CASE_EVIDENCE / "case_evidence_map.csv", list(case_map[0].keys()), case_map)
+ write_discovery_receipt(query_summaries, candidates, universe_rows)
+ rewrite_batch_summary(candidates, len(source_rows), len(conversions), universe_rows)
+ build_human_receipt(case_map, len(source_rows))
+ output_rows = rebuild_output_manifest()
+
+ remove_case_level_duplicates()
+ artifacts = build_artifact_manifest(source_rows, output_rows, Path(__file__))
+ validate(
+ candidates, universe_rows, source_rows, conversions, artifacts, case_map,
+ evidence_rows, query_summaries, qualification_rows,
+ )
+
+ result = {
+ "status": "PASS_LOCAL_REPAIR_BUILD",
+ "universe": len(universe_rows), "candidate_pairs": len(candidates),
+ "candidate_states": dict(sorted((s, sum(1 for r in candidates if r["candidate_state"] == s)) for s in {r["candidate_state"] for r in candidates})),
+ "sources": len(source_rows), "conversions": len(conversions), "evidence_facts": len(evidence_rows),
+ "case_evidence_map": len(case_map), "formal_artifacts_excluding_manifest_self": len(artifacts),
+ "artifact_manifest_sha256": sha256_file(MANIFEST_ROOT / "artifact_manifest.csv"),
+ }
+ print(json.dumps(result, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/ana-data/tools/newenergy_batch001_repair004.py b/ana-data/tools/newenergy_batch001_repair004.py
new file mode 100644
index 0000000..ee06e4f
--- /dev/null
+++ b/ana-data/tools/newenergy_batch001_repair004.py
@@ -0,0 +1,1258 @@
+#!/usr/bin/env python3
+"""REPAIR004: acquire and page-verify the frozen potential-direct annual reports.
+
+This tool intentionally consumes the already reviewed REPAIR003 discovery snapshot.
+It does not issue new discovery queries and it does not expand the case scope. Its
+first phase acquires the 729 unique CNINFO adjunct PDFs referenced by the 858 frozen
+potential-direct pairs. Its second phase searches every readable page and writes
+replayable attachment- and pair-level receipts. Candidate reranking is performed
+only after these receipts have been inspected.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import hashlib
+import json
+import os
+import re
+import shutil
+import sys
+import time
+import urllib.error
+import urllib.request
+from collections import defaultdict
+from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Iterable
+
+import pypdfium2 as pdfium
+from pypdf import PdfReader
+
+
+TASK_ID = "TASK-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001"
+CASE_ID = "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001"
+BATCH_ID = "BATCH-001"
+RUN_ID = "RUN-ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001-BATCH-001-001"
+TOOL_VERSION = "REPAIR-004"
+POTENTIAL = "POTENTIAL_DIRECT_BUSINESS_CONTEXT_REQUIRES_PAGE_VERIFICATION"
+CNINFO_BASE = "https://static.cninfo.com.cn/"
+USER_AGENT = "Mozilla/5.0 MB-X-NewEnergy-Research/REPAIR004 public-source-audit"
+
+TRACK_KEYWORDS = {
+ ("BATTERY", "资源与主材"): "正极材料",
+ ("BATTERY", "电芯制造"): "锂离子电池",
+ ("BATTERY", "系统/部件/BMS-Pack"): "电池管理系统",
+ ("BATTERY", "设备与回收循环"): "锂电设备",
+ ("SOLAR", "硅料/硅片与材料"): "光伏硅片",
+ ("SOLAR", "电池片/组件"): "光伏组件",
+ ("SOLAR", "设备/辅材/逆变器"): "光伏逆变器",
+ ("SOLAR", "系统集成/电站建设运营"): "光伏电站",
+ ("WIND", "材料与关键零部件"): "风电零部件",
+ ("WIND", "整机"): "风力发电机组",
+ ("WIND", "塔筒/海缆/工程配套"): "风电塔筒",
+ ("WIND", "项目运营与运维服务"): "风电场",
+ ("NUCLEAR", "运营商"): "核电运营",
+ ("NUCLEAR", "工程/EPC"): "核电工程",
+ ("NUCLEAR", "核岛/常规岛主设备"): "核电设备",
+ ("NUCLEAR", "核级部件/材料/仪控电气"): "核级阀门",
+}
+
+CHAIN_NODES = {
+ ("BATTERY", "资源与主材"): "锂资源;锂盐;正极材料及前驱体",
+ ("BATTERY", "电芯制造"): "锂离子电池;电芯;动力/储能电池",
+ ("BATTERY", "系统/部件/BMS-Pack"): "电池管理系统;BMS;模组/PACK",
+ ("BATTERY", "设备与回收循环"): "锂电设备;动力电池回收",
+ ("SOLAR", "硅料/硅片与材料"): "多晶硅;硅棒;光伏硅片",
+ ("SOLAR", "电池片/组件"): "太阳能电池片;光伏组件",
+ ("SOLAR", "设备/辅材/逆变器"): "光伏设备;辅材;光伏逆变器",
+ ("SOLAR", "系统集成/电站建设运营"): "光伏系统集成;电站建设运营",
+ ("WIND", "材料与关键零部件"): "风电材料;铸件;主轴;轴承;叶片",
+ ("WIND", "整机"): "风电整机;风力发电机组",
+ ("WIND", "塔筒/海缆/工程配套"): "风电塔筒;海缆;工程配套",
+ ("WIND", "项目运营与运维服务"): "风电场;项目运营;运维服务",
+ ("NUCLEAR", "运营商"): "民用核电运营",
+ ("NUCLEAR", "工程/EPC"): "民用核电工程;EPC",
+ ("NUCLEAR", "核岛/常规岛主设备"): "核岛主设备;常规岛主设备",
+ ("NUCLEAR", "核级部件/材料/仪控电气"): "核级部件;材料;仪控电气",
+}
+
+DOWNLOAD_HEADERS = [
+ "attachment_id", "adjunct_path", "source_url", "announcement_title",
+ "security_codes", "security_names", "pair_count", "pair_ids",
+ "attempted_at", "attempt_count", "http_status", "final_url",
+ "content_type", "response_byte_count", "response_sha256", "raw_path",
+ "raw_byte_count", "raw_sha256", "pdf_signature", "pdf_readable",
+ "page_count", "acquisition_result", "failure_class", "failure_detail",
+ "tool_version", "review_status",
+]
+
+PAIR_HEADERS = [
+ "qualification_row_id", "task_id", "case_id", "batch_id", "run_id",
+ "company_id", "security_code", "track_code", "selection_bucket",
+ "search_terms", "attachment_ids", "attachment_urls", "attachment_count",
+ "retrieval_success_count", "retrieval_failure_count", "searched_pdf_count",
+ "searched_page_count", "keyword_hit_attachment_count", "keyword_hit_pages",
+ "keyword_hit_count", "direct_context_hit_pages", "direct_context_hit_count",
+ "exact_context_sentences", "negative_or_insufficient_context_samples",
+ "page_search_result", "qualification_result", "failure_or_hold_reason",
+ "receipt_sha256", "verified_at", "tool_version", "review_status",
+]
+
+
+def now_iso() -> str:
+ return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
+
+
+def sha256_bytes(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest()
+
+
+def sha256_file(path: Path) -> str:
+ h = hashlib.sha256()
+ with path.open("rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ h.update(chunk)
+ return h.hexdigest()
+
+
+def read_csv(path: Path) -> list[dict[str, str]]:
+ with path.open("r", encoding="utf-8-sig", newline="") as handle:
+ return list(csv.DictReader(handle))
+
+
+def write_csv(path: Path, headers: list[str], rows: Iterable[dict[str, Any]]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_suffix(path.suffix + ".tmp")
+ with tmp.open("w", encoding="utf-8-sig", newline="") as handle:
+ writer = csv.DictWriter(handle, fieldnames=headers, extrasaction="ignore", quoting=csv.QUOTE_ALL)
+ writer.writeheader()
+ for row in rows:
+ writer.writerow({key: row.get(key, "") for key in headers})
+ os.replace(tmp, path)
+
+
+def find_industry_root(project_root: Path) -> Path:
+ matches = [p for p in (project_root / "ana-data" / "cases").rglob("candidate_qualification_funnel.csv")
+ if CASE_ID not in str(p)]
+ if len(matches) != 1:
+ raise RuntimeError(f"expected one industry funnel, found {len(matches)}")
+ return matches[0].parent.parent
+
+
+def split_values(value: str) -> list[str]:
+ return [item.strip() for item in value.split(";") if item.strip()]
+
+
+def sanitize_detail(value: str, limit: int = 600) -> str:
+ return re.sub(r"[\r\n\t]+", " ", value or "").strip()[:limit]
+
+
+def load_announcement_metadata(industry_root: Path) -> dict[str, dict[str, str]]:
+ result: dict[str, dict[str, str]] = {}
+ for path in sorted((industry_root / "raw" / "official_discovery").glob("*.json")):
+ payload = json.loads(path.read_text(encoding="utf-8-sig"))
+ for page in payload.get("pages", []):
+ for ann in page.get("response", {}).get("announcements", []):
+ ann_id = str(ann.get("announcementId", ""))
+ if not ann_id:
+ continue
+ result.setdefault(ann_id, {
+ "attachment_id": ann_id,
+ "adjunct_path": str(ann.get("adjunctUrl", "")),
+ "announcement_title": re.sub(r"<[^>]+>", "", str(ann.get("announcementTitle", ""))),
+ "security_code": str(ann.get("secCode", "")),
+ "security_name": str(ann.get("secName", "")),
+ })
+ return result
+
+
+def existing_pdf_for_attachment(industry_root: Path, attachment_id: str) -> Path | None:
+ for path in (industry_root / "raw").rglob("*.pdf"):
+ if attachment_id in path.name:
+ return path
+ return None
+
+
+def validate_pdf(path: Path) -> tuple[str, str, int, str]:
+ signature = ""
+ readable = "NO"
+ page_count = 0
+ error = ""
+ try:
+ with path.open("rb") as handle:
+ signature = handle.read(5).decode("latin-1", errors="replace")
+ if signature != "%PDF-":
+ return signature, readable, page_count, "MISSING_PDF_SIGNATURE"
+ reader = PdfReader(str(path), strict=False)
+ page_count = len(reader.pages)
+ readable = "YES" if page_count > 0 else "NO"
+ if page_count <= 0:
+ error = "ZERO_PAGE_PDF"
+ except Exception as exc: # recorded in formal receipt
+ error = sanitize_detail(f"{type(exc).__name__}: {exc}")
+ return signature, readable, page_count, error
+
+
+def download_one(item: dict[str, Any], raw_dir: Path, project_root: Path, retries: int = 3) -> dict[str, Any]:
+ attachment_id = item["attachment_id"]
+ target = raw_dir / f"{attachment_id}.pdf"
+ attempted_at = now_iso()
+ existing = None if item.get("force_http") else (target if target.exists() and target.stat().st_size > 0 else None)
+ if existing is None and not item.get("force_http"):
+ existing = existing_pdf_for_attachment(item["industry_root"], attachment_id)
+
+ if existing is not None:
+ if existing.resolve() != target.resolve():
+ shutil.copy2(existing, target)
+ signature, readable, pages, parse_error = validate_pdf(target)
+ file_hash = sha256_file(target)
+ size = target.stat().st_size
+ return {
+ **item, "attempted_at": attempted_at, "attempt_count": "0",
+ "http_status": "REUSED_EXISTING_CANONICAL", "final_url": item["source_url"],
+ "content_type": "application/pdf", "response_byte_count": str(size),
+ "response_sha256": file_hash, "raw_path": target.relative_to(project_root).as_posix(),
+ "raw_byte_count": str(size), "raw_sha256": file_hash,
+ "pdf_signature": signature, "pdf_readable": readable, "page_count": str(pages),
+ "acquisition_result": "REUSED_EXISTING_CANONICAL_PDF" if readable == "YES" else "REUSED_FILE_UNREADABLE",
+ "failure_class": "" if readable == "YES" else "PDF_VALIDATION_FAILED",
+ "failure_detail": parse_error, "tool_version": TOOL_VERSION, "review_status": "DRAFT_FOR_REVIEW",
+ }
+
+ last_class = ""
+ last_detail = ""
+ last_status = ""
+ last_body = b""
+ final_url = item["source_url"]
+ content_type = ""
+ for attempt in range(1, retries + 1):
+ request = urllib.request.Request(item["source_url"], headers={"User-Agent": USER_AGENT})
+ try:
+ with urllib.request.urlopen(request, timeout=120) as response:
+ last_status = str(response.getcode())
+ final_url = response.geturl()
+ content_type = response.headers.get("Content-Type", "")
+ last_body = response.read()
+ part = target.with_suffix(".pdf.part")
+ part.write_bytes(last_body)
+ os.replace(part, target)
+ signature, readable, pages, parse_error = validate_pdf(target)
+ file_hash = sha256_file(target)
+ size = target.stat().st_size
+ result = "ACQUIRED_PDF_AND_VALIDATED" if readable == "YES" else "ACQUIRED_RESPONSE_PDF_VALIDATION_FAILED"
+ return {
+ **item, "attempted_at": attempted_at, "attempt_count": str(attempt),
+ "http_status": last_status, "final_url": final_url, "content_type": content_type,
+ "response_byte_count": str(len(last_body)), "response_sha256": sha256_bytes(last_body),
+ "raw_path": target.relative_to(project_root).as_posix(), "raw_byte_count": str(size),
+ "raw_sha256": file_hash, "pdf_signature": signature, "pdf_readable": readable,
+ "page_count": str(pages), "acquisition_result": result,
+ "failure_class": "" if readable == "YES" else "PDF_VALIDATION_FAILED",
+ "failure_detail": parse_error, "tool_version": TOOL_VERSION, "review_status": "DRAFT_FOR_REVIEW",
+ }
+ except urllib.error.HTTPError as exc:
+ last_status = str(exc.code)
+ final_url = exc.geturl()
+ content_type = exc.headers.get("Content-Type", "") if exc.headers else ""
+ try:
+ last_body = exc.read()
+ except Exception:
+ last_body = b""
+ last_class = "HTTP_ERROR"
+ last_detail = sanitize_detail(f"HTTPError {exc.code}: {exc.reason}")
+ except Exception as exc:
+ last_class = type(exc).__name__.upper()
+ last_detail = sanitize_detail(f"{type(exc).__name__}: {exc}")
+ if attempt < retries:
+ time.sleep(min(8, 2 ** attempt))
+
+ return {
+ **item, "attempted_at": attempted_at, "attempt_count": str(retries),
+ "http_status": last_status, "final_url": final_url, "content_type": content_type,
+ "response_byte_count": str(len(last_body)), "response_sha256": sha256_bytes(last_body) if last_body else "",
+ "raw_path": "", "raw_byte_count": "0", "raw_sha256": "", "pdf_signature": "",
+ "pdf_readable": "NO", "page_count": "0", "acquisition_result": "ACQUISITION_FAILED_AFTER_RETRIES",
+ "failure_class": last_class, "failure_detail": last_detail,
+ "tool_version": TOOL_VERSION, "review_status": "DRAFT_FOR_REVIEW",
+ }
+
+
+def normalize_text(value: str) -> str:
+ return re.sub(r"\s+", "", value or "")
+
+
+def sentence_windows(text: str, keyword: str, limit: int = 4) -> list[str]:
+ cleaned = re.sub(r"[\u0000-\u0008\u000b\u000c\u000e-\u001f]", "", text or "")
+ cleaned = re.sub(r"[ \t]+", " ", cleaned)
+ parts = re.split(r"(?<=[。!?;;])|\n+", cleaned)
+ result: list[str] = []
+ for index, part in enumerate(parts):
+ if keyword not in normalize_text(part):
+ continue
+ joined = "".join(parts[max(0, index - 1): min(len(parts), index + 2)]).strip()
+ joined = sanitize_detail(joined, 1000)
+ if joined and joined not in result:
+ result.append(joined)
+ if len(result) >= limit:
+ break
+ if not result and keyword in normalize_text(cleaned):
+ norm = normalize_text(cleaned)
+ pos = norm.find(keyword)
+ result.append(norm[max(0, pos - 240):pos + len(keyword) + 360])
+ return result
+
+
+def search_pdf_worker(payload: tuple[str, str, list[str]]) -> dict[str, Any]:
+ attachment_id, raw_path, keywords = payload
+ result: dict[str, Any] = {
+ "attachment_id": attachment_id, "page_count": 0, "readable": "NO", "error": "", "keyword_hits": {},
+ }
+ try:
+ doc = pdfium.PdfDocument(raw_path)
+ result["page_count"] = len(doc)
+ result["readable"] = "YES"
+ hit_map: dict[str, list[dict[str, Any]]] = {keyword: [] for keyword in keywords}
+ for index in range(len(doc)):
+ page = doc[index]
+ textpage = page.get_textpage()
+ text = textpage.get_text_range()
+ normalized = normalize_text(text)
+ for keyword in keywords:
+ if keyword in normalized:
+ hit_map[keyword].append({
+ "page": index + 1,
+ "sentences": sentence_windows(text, keyword),
+ })
+ textpage.close()
+ page.close()
+ doc.close()
+ result["keyword_hits"] = hit_map
+ except Exception as exc:
+ result["error"] = sanitize_detail(f"{type(exc).__name__}: {exc}")
+ return result
+
+
+DIRECT_PATTERNS = [
+ re.compile(r"(?:本公司|本集团|公司|集团).{0,120}(?:主营业务|主要业务|核心业务|主要从事|业务包括|产品包括|主要产品).{0,180}"),
+ re.compile(r"(?:本公司|本集团|公司|集团).{0,120}(?:生产|制造|销售|提供服务|运营|承建|总承包).{0,160}"),
+ re.compile(r"(?:本公司|本集团|公司|集团).{0,120}(?:旗下|全资子公司|控股子公司).{0,100}(?:专业从事|主营|生产|制造|销售|运营|承建|总承包).{0,160}"),
+ re.compile(r"(?:主营业务|主要业务|核心业务|主要产品).{0,100}"),
+]
+NEGATIVE_TOKENS = ("不涉及", "不从事", "未从事", "无相关业务", "尚未开展", "不具备")
+CONTEXT_ONLY_TOKENS = ("行业发展", "市场规模", "竞争对手", "供应商", "客户从事", "参股基金", "投资标的", "政策鼓励")
+
+
+BUCKET_ROLE_PATTERNS = {
+ ("BATTERY", "资源与主材"): [
+ r"(?:公司|本公司|本集团).{0,100}(?:锂产品|锂盐|碳酸锂|氢氧化锂|正极材料|前驱体).{0,100}(?:生产|制造|销售|主营|主要从事)",
+ r"(?:公司|本公司|本集团).{0,100}(?:生产|制造|销售|主营|主要从事).{0,100}(?:锂产品|锂盐|碳酸锂|氢氧化锂|正极材料|前驱体)",
+ ],
+ ("BATTERY", "电芯制造"): [
+ r"(?:公司|本公司|本集团).{0,80}(?:生产|制造|销售|主营|主要从事).{0,40}(?:锂离子电池(?!材料|添加剂)|电芯|动力电池(?!材料)|储能电池(?!材料))",
+ r"(?:公司|本公司|本集团).{0,80}(?:锂离子电池(?!材料|添加剂)|电芯|动力电池(?!材料)|储能电池(?!材料)).{0,40}(?:生产|制造|销售|主营业务|主要业务)",
+ ],
+ ("BATTERY", "系统/部件/BMS-Pack"): [
+ r"(?:公司|本公司|本集团).{0,100}(?:电池管理系统|BMS|电池模组|PACK).{0,100}(?:产品|生产|制造|销售|供货|主营)",
+ r"(?:核心产品|主要产品|产品包括).{0,100}(?:电池管理系统|BMS|电池模组|PACK)",
+ ],
+ ("BATTERY", "设备与回收循环"): [
+ r"(?:公司|本公司|本集团).{0,100}(?:锂电设备|锂电池设备|电池回收|动力电池回收).{0,100}(?:产品|生产|制造|销售|业务|主营)",
+ r"(?:核心产品|主要产品|主营业务).{0,100}(?:锂电设备|锂电池设备|电池回收|动力电池回收)",
+ ],
+ ("SOLAR", "硅料/硅片与材料"): [
+ r"(?:公司|本公司|本集团).{0,100}(?:光伏硅片|单晶硅片|硅棒|多晶硅).{0,100}(?:生产|制造|销售|主营|主要业务)",
+ r"(?:公司|本公司|本集团).{0,100}(?:生产|制造|销售|主营|主要从事).{0,100}(?:光伏硅片|单晶硅片|硅棒|多晶硅)",
+ ],
+ ("SOLAR", "电池片/组件"): [
+ r"(?:公司|本公司|本集团).{0,100}(?:光伏组件|太阳能电池片|光伏电池片).{0,100}(?:生产|制造|销售|主营|主要业务)",
+ r"(?:公司|本公司|本集团).{0,100}(?:生产|制造|销售|主营|主要从事).{0,100}(?:光伏组件|太阳能电池片|光伏电池片)",
+ ],
+ ("SOLAR", "设备/辅材/逆变器"): [
+ r"(?:公司|本公司|本集团).{0,100}(?:光伏逆变器|光伏设备|光伏辅材).{0,100}(?:产品|生产|制造|销售|主营|主要业务)",
+ r"(?:核心产品|主要产品|产品包括).{0,100}(?:光伏逆变器|光伏设备|光伏辅材)",
+ ],
+ ("SOLAR", "系统集成/电站建设运营"): [
+ r"(?:公司|本公司|本集团).{0,120}(?:光伏电站|光伏发电站|分布式光伏).{0,120}(?:建设|运营|持有|投资开发|发电收入|EPC|总承包)",
+ r"(?:公司|本公司|本集团).{0,120}(?:建设|运营|持有|投资开发|EPC|总承包).{0,120}(?:光伏电站|光伏发电站|分布式光伏)",
+ ],
+ ("WIND", "材料与关键零部件"): [
+ r"(?:公司|本公司|本集团).{0,100}(?:风电零部件|风电铸件|风电主轴|风电轴承|风电叶片).{0,100}(?:生产|制造|销售|主营|主要业务|供应)",
+ r"(?:公司|本公司|本集团).{0,100}(?:生产|制造|销售|主营|主要从事).{0,100}(?:风电零部件|风电铸件|风电主轴|风电轴承|风电叶片)",
+ ],
+ ("WIND", "整机"): [
+ r"(?:公司|本公司|本集团).{0,100}(?:风力发电机组|风电整机).{0,100}(?:研发生产|生产|制造|销售|主营|产品)",
+ r"(?:公司|本公司|本集团).{0,100}(?:生产|制造|销售|主营|主要从事).{0,100}(?:风力发电机组|风电整机)",
+ ],
+ ("WIND", "塔筒/海缆/工程配套"): [
+ r"(?:公司|本公司|本集团).{0,100}(?:风电塔筒|风电塔架|海缆|海底电缆).{0,100}(?:生产|制造|销售|主营|工程|服务)",
+ r"(?:公司|本公司|本集团).{0,100}(?:生产|制造|销售|主营|承建).{0,100}(?:风电塔筒|风电塔架|海缆|海底电缆)",
+ ],
+ ("WIND", "项目运营与运维服务"): [
+ r"(?:公司|本公司|本集团).{0,120}(?:风电场|风力发电项目).{0,120}(?:建设|运营|持有|投资开发|发电收入|运维|EPC)",
+ r"(?:公司|本公司|本集团).{0,120}(?:建设|运营|持有|投资开发|运维|EPC).{0,120}(?:风电场|风力发电项目)",
+ ],
+ ("NUCLEAR", "运营商"): [
+ r"(?:公司|本公司|本集团).{0,120}(?:核电站|核电机组|核电项目).{0,120}(?:运营|运行|持有|投资开发|发电)",
+ r"(?:公司|本公司|本集团).{0,120}(?:运营|运行|持有|投资开发).{0,120}(?:核电站|核电机组|核电项目)",
+ ],
+ ("NUCLEAR", "工程/EPC"): [
+ r"(?:公司|本公司|本集团).{0,120}(?:核电工程|核工程).{0,120}(?:EPC|总承包|承建|施工|服务|主营)",
+ r"(?:公司|本公司|本集团).{0,120}(?:EPC|总承包|承建|施工).{0,120}(?:核电工程|核工程)",
+ ],
+ ("NUCLEAR", "核岛/常规岛主设备"): [
+ r"(?:公司|本公司|本集团).{0,120}(?:核电设备|核岛设备|常规岛设备).{0,120}(?:生产|制造|销售|产品|主营)",
+ r"(?:公司|本公司|本集团).{0,120}(?:生产|制造|销售|主营).{0,120}(?:核电设备|核岛设备|常规岛设备)",
+ ],
+ ("NUCLEAR", "核级部件/材料/仪控电气"): [
+ r"(?:公司|本公司|本集团).{0,120}(?:核级阀门|核电阀门|核级材料|核电仪控).{0,120}(?:生产|制造|销售|产品|主营)",
+ r"(?:公司|本公司|本集团).{0,120}(?:生产|制造|销售|主营).{0,120}(?:核级阀门|核电阀门|核级材料|核电仪控)",
+ ],
+}
+
+
+def is_direct_context(sentence: str, keyword: str, track_code: str, selection_bucket: str) -> bool:
+ norm = normalize_text(sentence)
+ if keyword not in norm or any(token in norm for token in NEGATIVE_TOKENS):
+ return False
+ if any(token in norm for token in ("参股", "联营企业", "投资标的")) and not any(
+ token in norm for token in ("控股子公司", "全资子公司")
+ ):
+ return False
+ if any(token in norm for token in ("权益法", "长期股权投资", "合资公司将", "涉诉项目", "解除双方签订", "解除合同")):
+ return False
+ if any(token in norm for token in ("需遵守", "披露要求", "任职经历", "历任", "个人简历")):
+ return False
+ if selection_bucket == "电芯制造" and any(token in norm for token in ("电解液", "隔膜", "锂离子电池材料", "正极材料", "负极材料")) and "电芯" not in norm:
+ return False
+ if selection_bucket == "电芯制造" and any(token in norm for token in ("钢结构", "厂房工程", "基地建设项目", "工程项目")):
+ return False
+ if selection_bucket == "整机" and any(token in norm for token in ("转化为电能", "生产运营模式", "风力发电收入")):
+ return False
+ if selection_bucket == "塔筒/海缆/工程配套" and any(token in norm for token in ("募集资金", "已结项", "2009年", "2011年")):
+ return False
+ if any(token in norm for token in CONTEXT_ONLY_TOKENS) and not any(token in norm for token in ("本公司", "公司主营", "主要从事")):
+ return False
+ pos = norm.find(keyword)
+ window = norm[max(0, pos - 240):pos + len(keyword) + 240]
+ if not any(pattern.search(window) for pattern in DIRECT_PATTERNS):
+ return False
+ role_patterns = BUCKET_ROLE_PATTERNS[(track_code, selection_bucket)]
+ return any(re.search(pattern, window) for pattern in role_patterns)
+
+
+def build_attachment_items(
+ potential_rows: list[dict[str, str]], metadata: dict[str, dict[str, str]], industry_root: Path
+) -> list[dict[str, Any]]:
+ grouped: dict[str, dict[str, Any]] = {}
+ for row in potential_rows:
+ ann_ids = split_values(row["announcement_ids"])
+ urls = split_values(row["annual_report_adjunct_urls"])
+ for index, ann_id in enumerate(ann_ids):
+ meta = metadata.get(ann_id, {})
+ adjunct = urls[index] if index < len(urls) else meta.get("adjunct_path", "")
+ if not adjunct:
+ raise RuntimeError(f"missing adjunct path for {ann_id}/{row['qualification_row_id']}")
+ item = grouped.setdefault(ann_id, {
+ "attachment_id": ann_id, "adjunct_path": adjunct,
+ "source_url": CNINFO_BASE + adjunct.lstrip("/"),
+ "announcement_title": meta.get("announcement_title", ""),
+ "security_codes_set": set(), "security_names_set": set(), "pair_ids_set": set(),
+ "keywords_set": set(), "industry_root": industry_root,
+ })
+ item["security_codes_set"].add(row["security_code"])
+ if meta.get("security_name"):
+ item["security_names_set"].add(meta["security_name"])
+ item["pair_ids_set"].add(row["qualification_row_id"])
+ keyword = TRACK_KEYWORDS.get((row["track_code"], row["selection_bucket"]))
+ if not keyword:
+ raise RuntimeError(f"no frozen keyword for {row['track_code']}/{row['selection_bucket']}")
+ item["keywords_set"].add(keyword)
+ result = []
+ for item in grouped.values():
+ item["security_codes"] = ";".join(sorted(item.pop("security_codes_set")))
+ item["security_names"] = ";".join(sorted(item.pop("security_names_set")))
+ item["pair_ids"] = ";".join(sorted(item.pop("pair_ids_set")))
+ item["pair_count"] = str(len(split_values(item["pair_ids"])))
+ item["keywords"] = sorted(item.pop("keywords_set"))
+ result.append(item)
+ return sorted(result, key=lambda x: x["attachment_id"])
+
+
+def run_acquisition(
+ items: list[dict[str, Any]], raw_dir: Path, receipt_path: Path, project_root: Path, workers: int,
+ force_http: bool = False,
+) -> list[dict[str, Any]]:
+ receipt_by_id: dict[str, dict[str, Any]] = {}
+ if receipt_path.exists():
+ receipt_by_id = {row["attachment_id"]: row for row in read_csv(receipt_path)}
+ pending = []
+ for item in items:
+ item["force_http"] = force_http
+ prior = receipt_by_id.get(item["attachment_id"])
+ if not force_http and prior and prior.get("pdf_readable") == "YES" and prior.get("raw_path"):
+ path = project_root / prior["raw_path"]
+ if path.exists() and sha256_file(path) == prior.get("raw_sha256"):
+ continue
+ pending.append(item)
+ print(f"ACQUIRE total={len(items)} reusable_receipts={len(items)-len(pending)} pending={len(pending)}", flush=True)
+ with ThreadPoolExecutor(max_workers=workers) as executor:
+ futures = {executor.submit(download_one, item, raw_dir, project_root): item for item in pending}
+ completed = 0
+ for future in as_completed(futures):
+ row = future.result()
+ receipt_by_id[row["attachment_id"]] = row
+ completed += 1
+ if completed % 20 == 0 or completed == len(pending):
+ ordered = [receipt_by_id[x["attachment_id"]] for x in items if x["attachment_id"] in receipt_by_id]
+ write_csv(receipt_path, DOWNLOAD_HEADERS, ordered)
+ good = sum(x.get("pdf_readable") == "YES" for x in ordered)
+ print(f"ACQUIRE progress={completed}/{len(pending)} receipts={len(ordered)} readable={good}", flush=True)
+ ordered = [receipt_by_id[x["attachment_id"]] for x in items]
+ write_csv(receipt_path, DOWNLOAD_HEADERS, ordered)
+ return ordered
+
+
+def write_attachment_search_extract(
+ converted_dir: Path, item: dict[str, Any], receipt: dict[str, Any], result: dict[str, Any]
+) -> None:
+ path = converted_dir / f"{item['attachment_id']}__keyword_pages.txt"
+ lines = [
+ f"attachment_id={item['attachment_id']}",
+ f"source_url={item['source_url']}",
+ f"raw_path={receipt.get('raw_path','')}",
+ f"raw_sha256={receipt.get('raw_sha256','')}",
+ f"page_count={result.get('page_count',0)}",
+ f"searched_terms={';'.join(item['keywords'])}",
+ f"tool_version={TOOL_VERSION}",
+ "scope=keyword-hit pages only; no full-report conversion; public annual report",
+ "",
+ ]
+ if result.get("error"):
+ lines.append(f"SEARCH_ERROR={result['error']}")
+ hit_total = 0
+ for keyword in item["keywords"]:
+ hits = result.get("keyword_hits", {}).get(keyword, [])
+ hit_total += len(hits)
+ lines.append(f"## keyword={keyword}; hit_page_count={len(hits)}")
+ for hit in hits:
+ lines.append(f"[page={hit['page']}]")
+ if hit["sentences"]:
+ lines.extend(hit["sentences"])
+ else:
+ lines.append("KEYWORD_PRESENT_BUT_SENTENCE_WINDOW_EMPTY")
+ lines.append("")
+ if hit_total == 0 and not result.get("error"):
+ lines.append("NEGATIVE_RESULT=ALL_READABLE_PAGES_SEARCHED_NO_FROZEN_KEYWORD_HIT")
+ path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
+
+
+def run_search(
+ items: list[dict[str, Any]], download_rows: list[dict[str, Any]], converted_dir: Path,
+ search_cache_path: Path, project_root: Path, workers: int,
+) -> dict[str, dict[str, Any]]:
+ cache: dict[str, dict[str, Any]] = {}
+ if search_cache_path.exists():
+ cache = json.loads(search_cache_path.read_text(encoding="utf-8"))
+ download_by_id = {row["attachment_id"]: row for row in download_rows}
+ payloads = []
+ for item in items:
+ row = download_by_id[item["attachment_id"]]
+ if row.get("pdf_readable") != "YES" or not row.get("raw_path"):
+ cache[item["attachment_id"]] = {
+ "attachment_id": item["attachment_id"], "page_count": 0, "readable": "NO",
+ "error": row.get("failure_detail") or row.get("acquisition_result"), "keyword_hits": {},
+ }
+ continue
+ cached = cache.get(item["attachment_id"])
+ if cached and cached.get("raw_sha256") == row.get("raw_sha256") and cached.get("tool_version") == TOOL_VERSION:
+ continue
+ payloads.append((item["attachment_id"], str(project_root / row["raw_path"]), item["keywords"]))
+ print(f"SEARCH total={len(items)} cached={len(items)-len(payloads)} pending={len(payloads)} workers={workers}", flush=True)
+ with ProcessPoolExecutor(max_workers=workers) as executor:
+ futures = {executor.submit(search_pdf_worker, payload): payload[0] for payload in payloads}
+ completed = 0
+ for future in as_completed(futures):
+ result = future.result()
+ ann_id = result["attachment_id"]
+ result["raw_sha256"] = download_by_id[ann_id].get("raw_sha256", "")
+ result["tool_version"] = TOOL_VERSION
+ cache[ann_id] = result
+ completed += 1
+ if completed % 20 == 0 or completed == len(payloads):
+ search_cache_path.parent.mkdir(parents=True, exist_ok=True)
+ search_cache_path.write_text(json.dumps(cache, ensure_ascii=False, sort_keys=True), encoding="utf-8")
+ hit_docs = sum(any(v for v in x.get("keyword_hits", {}).values()) for x in cache.values())
+ print(f"SEARCH progress={completed}/{len(payloads)} cached={len(cache)} hit_documents={hit_docs}", flush=True)
+ for item in items:
+ write_attachment_search_extract(converted_dir, item, download_by_id[item["attachment_id"]], cache[item["attachment_id"]])
+ search_cache_path.parent.mkdir(parents=True, exist_ok=True)
+ search_cache_path.write_text(json.dumps(cache, ensure_ascii=False, sort_keys=True), encoding="utf-8")
+ return cache
+
+
+def build_pair_receipts(
+ potential_rows: list[dict[str, str]], download_rows: list[dict[str, Any]],
+ search_results: dict[str, dict[str, Any]], verified_at: str,
+) -> list[dict[str, Any]]:
+ downloads = {row["attachment_id"]: row for row in download_rows}
+ rows: list[dict[str, Any]] = []
+ for source in potential_rows:
+ ann_ids = split_values(source["announcement_ids"])
+ urls = split_values(source["annual_report_adjunct_urls"])
+ keyword = TRACK_KEYWORDS[(source["track_code"], source["selection_bucket"])]
+ retrieval_success = 0
+ retrieval_failure = 0
+ searched_pdf_count = 0
+ searched_pages = 0
+ keyword_pages: list[str] = []
+ direct_pages: list[str] = []
+ exact_sentences: list[str] = []
+ insufficient_samples: list[str] = []
+ keyword_hit_count = 0
+ direct_hit_count = 0
+ hit_attachment_ids: set[str] = set()
+ errors: list[str] = []
+ for ann_id in ann_ids:
+ download = downloads[ann_id]
+ result = search_results[ann_id]
+ if download.get("pdf_readable") == "YES":
+ retrieval_success += 1
+ searched_pdf_count += 1
+ searched_pages += int(result.get("page_count", 0))
+ else:
+ retrieval_failure += 1
+ errors.append(f"{ann_id}:{download.get('acquisition_result')}:{download.get('failure_class')}")
+ continue
+ hits = result.get("keyword_hits", {}).get(keyword, [])
+ if hits:
+ hit_attachment_ids.add(ann_id)
+ for hit in hits:
+ keyword_hit_count += 1
+ keyword_pages.append(f"{ann_id}:p{hit['page']}")
+ sentences = hit.get("sentences", [])
+ page_direct = False
+ for sentence in sentences:
+ if is_direct_context(sentence, keyword, source["track_code"], source["selection_bucket"]):
+ direct_hit_count += 1
+ page_direct = True
+ exact_sentences.append(f"{ann_id}:p{hit['page']}:{sentence}")
+ elif len(insufficient_samples) < 8:
+ insufficient_samples.append(f"{ann_id}:p{hit['page']}:{sentence}")
+ if page_direct:
+ direct_pages.append(f"{ann_id}:p{hit['page']}")
+ if direct_hit_count:
+ search_result = "FROZEN_KEYWORD_FOUND_WITH_DIRECT_SELF_BUSINESS_PAGE_CONTEXT"
+ qualification = "PAGE_LEVEL_DIRECT_BUSINESS_CONTEXT_CONFIRMED_PENDING_EVIDENCE_REGISTRATION"
+ hold_reason = ""
+ elif keyword_hit_count:
+ search_result = "FROZEN_KEYWORD_FOUND_BUT_NO_DIRECT_SELF_BUSINESS_PAGE_CONTEXT"
+ qualification = "HELD_AFTER_ACTUAL_PDF_PAGE_SEARCH_INSUFFICIENT_DIRECT_CONTEXT"
+ hold_reason = "KEYWORD_HITS_ARE_CONTEXT_ONLY_OR_NOT_SELF_BUSINESS"
+ elif retrieval_success and not retrieval_failure:
+ search_result = "ALL_REFERENCED_READABLE_PDFS_SEARCHED_NO_FROZEN_KEYWORD_HIT"
+ qualification = "HELD_AFTER_ACTUAL_PDF_PAGE_SEARCH_TRUE_NEGATIVE"
+ hold_reason = "NO_FROZEN_KEYWORD_HIT_IN_ANY_READABLE_REFERENCED_PDF"
+ else:
+ search_result = "ONE_OR_MORE_REFERENCED_PDFS_NOT_ACQUIRED_OR_UNREADABLE"
+ qualification = "HELD_BY_REVIEWABLE_RETRIEVAL_OR_PARSE_FAILURE"
+ hold_reason = ";".join(errors)
+ core = {
+ "qualification_row_id": source["qualification_row_id"], "task_id": TASK_ID,
+ "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
+ "company_id": source["company_id"], "security_code": source["security_code"],
+ "track_code": source["track_code"], "selection_bucket": source["selection_bucket"],
+ "search_terms": keyword, "attachment_ids": ";".join(ann_ids), "attachment_urls": ";".join(urls),
+ "attachment_count": str(len(ann_ids)), "retrieval_success_count": str(retrieval_success),
+ "retrieval_failure_count": str(retrieval_failure), "searched_pdf_count": str(searched_pdf_count),
+ "searched_page_count": str(searched_pages), "keyword_hit_attachment_count": str(len(hit_attachment_ids)),
+ "keyword_hit_pages": ";".join(keyword_pages), "keyword_hit_count": str(keyword_hit_count),
+ "direct_context_hit_pages": ";".join(direct_pages), "direct_context_hit_count": str(direct_hit_count),
+ "exact_context_sentences": "\n---CONTEXT---\n".join(exact_sentences[:12]),
+ "negative_or_insufficient_context_samples": "\n---CONTEXT---\n".join(insufficient_samples[:8]),
+ "page_search_result": search_result, "qualification_result": qualification,
+ "failure_or_hold_reason": hold_reason, "verified_at": verified_at,
+ "tool_version": TOOL_VERSION, "review_status": "DRAFT_FOR_REVIEW",
+ }
+ receipt_payload = json.dumps(core, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
+ core["receipt_sha256"] = sha256_bytes(receipt_payload)
+ rows.append(core)
+ return rows
+
+
+def parse_direct_context(row: dict[str, str]) -> tuple[str, int, str]:
+ first = row["exact_context_sentences"].split("\n---CONTEXT---\n", 1)[0]
+ match = re.match(r"([^:]+):p(\d+):(.*)", first, flags=re.S)
+ if not match:
+ raise RuntimeError(f"unparseable direct context: {row['qualification_row_id']}")
+ return match.group(1), int(match.group(2)), re.sub(r"\s+", " ", match.group(3)).strip()
+
+
+def date_from_adjunct(path: str) -> str:
+ match = re.search(r"finalpage/(\d{4}-\d{2}-\d{2})/", path)
+ return match.group(1) if match else ""
+
+
+def descending_date(value: str) -> int:
+ digits = "".join(char for char in (value or "") if char.isdigit())[:8]
+ return -int(digits or "0")
+
+
+def build_qualification_sources_and_conversions(
+ industry_root: Path, download_rows: list[dict[str, Any]], potential_rows: list[dict[str, str]],
+ project_root: Path,
+) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
+ source_path = industry_root / "manifest" / "source_document.csv"
+ conversion_path = industry_root / "manifest" / "conversion_status.csv"
+ existing_sources = [row for row in read_csv(source_path) if not row["doc_id"].startswith("S-QUAL-AR-")]
+ existing_conversions = [row for row in read_csv(conversion_path) if not row["source_doc_id"].startswith("S-QUAL-AR-")]
+ source_headers = list(existing_sources[0])
+ conversion_headers = list(existing_conversions[0])
+ candidate_rows = read_csv(industry_root / "extracted" / "company_track_candidate_ledger.csv")
+ candidate_by_code = {row["security_code"]: row for row in candidate_rows}
+ pair_by_id = {row["qualification_row_id"]: row for row in potential_rows}
+ new_sources: list[dict[str, str]] = []
+ new_conversions: list[dict[str, str]] = []
+ for receipt in download_rows:
+ attachment_id = receipt["attachment_id"]
+ doc_id = f"S-QUAL-AR-{attachment_id}"
+ converted_path = industry_root / "converted" / "qualification_filings" / f"{attachment_id}__keyword_pages.txt"
+ if not converted_path.exists():
+ raise FileNotFoundError(converted_path)
+ pair_ids = split_values(receipt["pair_ids"])
+ tracks = sorted({pair_by_id[pair_id]["track_code"] for pair_id in pair_ids})
+ codes = split_values(receipt["security_codes"])
+ company_ids = sorted({candidate_by_code[code]["company_id"] for code in codes if code in candidate_by_code})
+ raw_path = project_root / receipt["raw_path"]
+ source_row = {
+ "doc_id": doc_id, "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
+ "doc_type": "OFFICIAL_QUALIFICATION_ANNUAL_REPORT",
+ "title": receipt["announcement_title"] or f"2025年年度报告资格核验附件 {attachment_id}",
+ "source_org": "巨潮资讯网/上市公司法定披露", "author": receipt["security_names"],
+ "publish_date": date_from_adjunct(receipt["adjunct_path"]), "collected_at": receipt["attempted_at"],
+ "source_url": receipt["source_url"], "industry_case": "新能源案例", "industry_id": "IND-NEWENERGY",
+ "subindustry_id": tracks[0] if len(tracks) == 1 else "CROSS_TRACK",
+ "company_id": company_ids[0] if len(company_ids) == 1 else "",
+ "raw_pool_path": "ana-data/cases/新能源案例/raw/qualification_filings/",
+ "raw_file_path": receipt["raw_path"],
+ "converted_text_path": converted_path.relative_to(project_root).as_posix(), "converted_markdown_path": "",
+ "file_sha256": receipt["raw_sha256"], "file_name": raw_path.name,
+ "file_size": receipt["raw_byte_count"], "detected_type": "PDF", "source_language": "zh-CN",
+ "public_access_basis": "OFFICIAL_PUBLIC_DISCLOSURE_DIRECT_URL", "access_status": "PUBLIC_DIRECT",
+ "source_level": "S", "sensitivity_screen": "LEGAL_PUBLIC_SCREENED_HIGH_LEVEL_ONLY",
+ "legal_access_note": "巨潮资讯公开法定披露附件;REPAIR004仅执行冻结关键词页级检索;未绕过访问控制。",
+ "doc_status": "QUALIFICATION_INPUT_REPAIR004",
+ "processing_status": "PDF_ACQUIRED_READABLE_KEYWORD_PAGES_EXTRACTED_INDEXED_FOR_QUALIFICATION",
+ }
+ new_sources.append({key: source_row.get(key, "") for key in source_headers})
+ conversion_row = {
+ "conversion_id": f"CONV-{doc_id}", "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "source_doc_id": doc_id,
+ "raw_pool_path": source_row["raw_pool_path"], "raw_file_path": receipt["raw_path"],
+ "raw_file_sha256": receipt["raw_sha256"], "detected_type": "PDF",
+ "conversion_method": "PDFIUM_FULL_PAGE_TEXT_SEARCH_AND_KEYWORD_PAGE_EXTRACT",
+ "parameters_summary": "all readable pages; frozen bucket keyword; exact hit page and sentence windows; no OCR; nuclear high-level public boundary",
+ "converted_text_path": source_row["converted_text_path"], "converted_markdown_path": "",
+ "converted_path": source_row["converted_text_path"], "converted_sha256": sha256_file(converted_path),
+ "page_or_duration_count": receipt["page_count"], "status": "TEXT_SEARCHED_INDEXED_QUALIFICATION_COMPLETE",
+ "error_code": "", "error_summary": "", "created_at": receipt["attempted_at"],
+ }
+ new_conversions.append({key: conversion_row.get(key, "") for key in conversion_headers})
+ sources = existing_sources + new_sources
+ conversions = existing_conversions + new_conversions
+ if len({row["doc_id"] for row in sources}) != len(sources):
+ raise RuntimeError("duplicate source doc id after REPAIR004 source merge")
+ if len({row["source_doc_id"] for row in conversions}) != len(conversions):
+ raise RuntimeError("conversion is not one-to-one after REPAIR004 source merge")
+ write_csv(source_path, source_headers, sources)
+ write_csv(conversion_path, conversion_headers, conversions)
+ return sources, conversions
+
+
+def build_repair004_evidence_and_candidates(
+ industry_root: Path, pair_rows: list[dict[str, Any]], source_rows: list[dict[str, str]],
+) -> tuple[list[dict[str, str]], list[dict[str, str]], dict[tuple[str, str], str]]:
+ evidence_path = industry_root / "evidence" / "evidence_fact_table.csv"
+ evidence_rows = [row for row in read_csv(evidence_path) if not row["evidence_fact_id"].startswith("EVF-QUAL-")]
+ evidence_headers = list(evidence_rows[0])
+ candidate_path = industry_root / "extracted" / "company_track_candidate_ledger.csv"
+ candidates = read_csv(candidate_path)
+ source_by_id = {row["doc_id"]: row for row in source_rows}
+ candidate_by_pair = {(row["company_id"], row["track_code"]): row for row in candidates}
+ new_evidence_id_by_pair: dict[tuple[str, str], str] = {}
+
+ for receipt in pair_rows:
+ if receipt["direct_context_hit_count"] == "0":
+ continue
+ pair = (receipt["company_id"], receipt["track_code"])
+ candidate = candidate_by_pair[pair]
+ attachment_id, page, sentence = parse_direct_context(receipt)
+ doc_id = f"S-QUAL-AR-{attachment_id}"
+ source = source_by_id[doc_id]
+ evidence_id = f"EVF-QUAL-{receipt['track_code']}-{receipt['security_code']}-R004"
+ new_evidence_id_by_pair[pair] = evidence_id
+ evidence_row = {
+ "evidence_fact_id": evidence_id, "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "doc_id": doc_id,
+ "industry_case": "新能源案例", "industry_id": "IND-NEWENERGY",
+ "subindustry_id": receipt["track_code"], "company_id": receipt["company_id"],
+ "track_code": receipt["track_code"], "chain_node_id": CHAIN_NODES[(receipt["track_code"], receipt["selection_bucket"])],
+ "subject_type": "COMPANY", "subject_id": receipt["company_id"],
+ "source_text_path": source["converted_text_path"], "raw_pool_path": source["raw_pool_path"],
+ "raw_file_sha256": source["file_sha256"], "source_page": str(page), "source_table_id": "",
+ "source_sentence_index": "", "locator_type": "PDF_PAGE_AND_EXACT_CONTEXT",
+ "locator_value": f"attachment_id={attachment_id};page={page};receipt={receipt['qualification_row_id']}",
+ "evidence_text": sentence, "evidence_type": "OFFICIAL_ANNUAL_REPORT",
+ "statement_type": "FACT", "business_dimension": "COMPANY_EXPOSURE",
+ "research_dimension": "COMPANY_TRACK_QUALIFICATION", "numeric_value_raw": "",
+ "metric_candidate_name": "", "metric_candidate_unit": "", "metric_period": "2025",
+ "metric_date": "2025-12-31", "geography": "CN",
+ "original_qualifier": "REPAIR004实际取得CNINFO附件并逐页检索;仅支持冻结桶的直接业务角色,不作份额、质量或投资判断。",
+ "related_company_id": "", "viewpoint_id": "", "darkline_signal_flag": "NO",
+ "confidence_level": "HIGH", "conclusion_strength": "DIRECT_FACT",
+ "sensitivity_screen": "LEGAL_PUBLIC_SCREENED_HIGH_LEVEL_ONLY", "contradicts_evidence_fact_id": "",
+ "normalization_status": "NORMALIZED", "processing_status": "READY",
+ "data_status": "VERIFIED_PUBLIC", "review_status": "DRAFT_FOR_REVIEW",
+ }
+ evidence_rows.append({key: evidence_row.get(key, "") for key in evidence_headers})
+ candidate.update({
+ "chain_nodes": CHAIN_NODES[(receipt["track_code"], receipt["selection_bucket"])],
+ "direct_business_source_id": doc_id,
+ "direct_business_locator": f"2025年报第{page}页;attachment_id={attachment_id}",
+ "evidence_grade": "S", "exposure_specificity": "NAMED_PRODUCT_PROJECT_AND_OPERATING_FACT",
+ "latest_disclosed_period": "2025-12-31",
+ })
+
+ evidence_by_pair: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
+ for evidence in evidence_rows:
+ if evidence["subject_type"] == "COMPANY" and evidence["company_id"] and evidence["track_code"]:
+ evidence_by_pair[(evidence["company_id"], evidence["track_code"])].append(evidence)
+
+ eligible_groups: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
+ for candidate in candidates:
+ pair = (candidate["company_id"], candidate["track_code"])
+ matches = [e for e in evidence_by_pair.get(pair, []) if e["doc_id"] == candidate["direct_business_source_id"]]
+ if matches and candidate["evidence_grade"] in {"S", "A"} and candidate["exposure_specificity"] in {
+ "SEGMENT_REVENUE_OR_ASSET_DISCLOSED", "NAMED_PRODUCT_PROJECT_AND_OPERATING_FACT", "GENERAL_DIRECT_BUSINESS_DESCRIPTION",
+ }:
+ candidate["candidate_state"] = "ELIGIBLE"
+ candidate["selection_rank"] = ""
+ candidate["tier"] = ""
+ candidate["tie_break_rule"] = "source_grade>exposure_specificity>latest_period>publish_date>exchange_code>security_code"
+ candidate["include_or_exclude_reason"] = (
+ "REPAIR004实际取得年度报告PDF并逐页检索;直接业务角色、页码原句、S级主源及暴露具体性gate通过,进入同桶全部ELIGIBLE机械排序。"
+ )
+ eligible_groups[(candidate["track_code"], candidate["selection_bucket"])].append(candidate)
+ else:
+ candidate["candidate_state"] = "HELD_BY_EVIDENCE_GAP"
+ candidate["selection_rank"] = ""
+ candidate["tier"] = ""
+
+ grade_order = {"S": 0, "A": 1}
+ specificity_order = {
+ "SEGMENT_REVENUE_OR_ASSET_DISCLOSED": 0,
+ "NAMED_PRODUCT_PROJECT_AND_OPERATING_FACT": 1,
+ "GENERAL_DIRECT_BUSINESS_DESCRIPTION": 2,
+ }
+ for group_rows in eligible_groups.values():
+ group_rows.sort(key=lambda row: (
+ grade_order.get(row["evidence_grade"], 9), specificity_order.get(row["exposure_specificity"], 9),
+ descending_date(row["latest_disclosed_period"]),
+ descending_date(source_by_id[row["direct_business_source_id"]]["publish_date"]),
+ row["exchange_code"], row["security_code"],
+ ))
+ for rank, candidate in enumerate(group_rows, 1):
+ candidate["selection_rank"] = str(rank)
+ if rank == 1:
+ candidate["candidate_state"] = "INCLUDED_T1"
+ candidate["tier"] = "T1_PRIMARY"
+ elif rank == 2:
+ candidate["candidate_state"] = "INCLUDED_T2"
+ candidate["tier"] = "T2_CANDIDATE"
+ else:
+ candidate["candidate_state"] = "ELIGIBLE_NOT_SELECTED_BATCH001"
+ candidate["tier"] = ""
+ candidate["include_or_exclude_reason"] += " 同桶排名超过2,保留为ELIGIBLE_NOT_SELECTED_BATCH001。"
+
+ track_order = {"BATTERY": 0, "SOLAR": 1, "WIND": 2, "NUCLEAR": 3}
+ bucket_order = {key: index for index, key in enumerate(TRACK_KEYWORDS)}
+ state_order = {"INCLUDED_T1": 0, "INCLUDED_T2": 1, "ELIGIBLE_NOT_SELECTED_BATCH001": 2, "HELD_BY_EVIDENCE_GAP": 3}
+ candidates.sort(key=lambda row: (
+ track_order[row["track_code"]], bucket_order[(row["track_code"], row["selection_bucket"])],
+ state_order.get(row["candidate_state"], 9), int(row["selection_rank"] or 999999),
+ row["exchange_code"], row["security_code"],
+ ))
+ write_csv(evidence_path, evidence_headers, evidence_rows)
+ write_csv(candidate_path, list(candidates[0]), candidates)
+ return evidence_rows, candidates, new_evidence_id_by_pair
+
+
+def update_qualification_funnel(
+ industry_root: Path, pair_rows: list[dict[str, Any]], candidates: list[dict[str, str]],
+ evidence_rows: list[dict[str, str]], source_rows: list[dict[str, str]],
+) -> list[dict[str, str]]:
+ path = industry_root / "extracted" / "candidate_qualification_funnel.csv"
+ funnel = read_csv(path)
+ headers = list(funnel[0])
+ pair_receipt = {(row["company_id"], row["track_code"]): row for row in pair_rows}
+ candidate_by_pair = {(row["company_id"], row["track_code"]): row for row in candidates}
+ evidence_by_pair = defaultdict(list)
+ for evidence in evidence_rows:
+ if evidence["subject_type"] == "COMPANY":
+ evidence_by_pair[(evidence["company_id"], evidence["track_code"])].append(evidence)
+ source_by_id = {row["doc_id"]: row for row in source_rows}
+ eligible_states = {"INCLUDED_T1", "INCLUDED_T2", "ELIGIBLE_NOT_SELECTED_BATCH001"}
+ for row in funnel:
+ pair = (row["company_id"], row["track_code"])
+ candidate = candidate_by_pair[pair]
+ receipt = pair_receipt.get(pair)
+ candidate_evidence = [e for e in evidence_by_pair.get(pair, []) if e["doc_id"] == candidate["direct_business_source_id"]]
+ eligible = candidate["candidate_state"] in eligible_states
+ if receipt:
+ row["annual_report_retrieval_attempt"] = "CNINFO_ADJUNCT_PDF_GET_DEDUP_729_AND_FULL_PAGE_SEARCH"
+ row["annual_report_retrieval_result"] = (
+ f"ACQUIRED={receipt['retrieval_success_count']};FAILED={receipt['retrieval_failure_count']};"
+ f"SEARCHED_PDFS={receipt['searched_pdf_count']};SEARCHED_PAGES={receipt['searched_page_count']}"
+ )
+ row["page_level_verification_attempt"] = "ACTUAL_PDF_OPEN_AND_FROZEN_KEYWORD_ALL_PAGE_SEARCH_WITH_BUCKET_ROLE_GATE"
+ row["page_level_verification_result"] = receipt["qualification_result"]
+ row["business_context_rule_result"] = (
+ "PAGE_VERIFIED_DIRECT_BUSINESS_ROLE_CONFIRMED" if eligible else "PAGE_VERIFIED_INSUFFICIENT_OR_ROLE_MISMATCH"
+ )
+ row["replay_status"] = "ACTUAL_ATTACHMENT_RETRIEVAL_AND_PAGE_SEARCH_COMPLETED_FOR_PAIR"
+ row["direct_business_source_id"] = candidate["direct_business_source_id"] if eligible else ""
+ for gate in ["direct_source_gate", "locator_gate", "company_evidence_fact_gate", "source_grade_gate", "exposure_specificity_gate"]:
+ row[gate] = "PASS" if eligible else "FAIL"
+ row["evidence_fact_ids"] = ";".join(sorted(e["evidence_fact_id"] for e in candidate_evidence)) if eligible else ""
+ row["failed_gates"] = "" if eligible else "DIRECT_BUSINESS_SOURCE;PAGE_OR_TEXT_LOCATOR;COMPANY_EVIDENCE_FACT;SOURCE_GRADE_S_OR_A;EXPOSURE_SPECIFICITY"
+ row["eligibility_result"] = "ELIGIBLE" if eligible else "HELD_BY_EVIDENCE_GAP"
+ row["eligible_rank_in_bucket"] = candidate["selection_rank"] if eligible else ""
+ row["final_candidate_state"] = candidate["candidate_state"]
+ source = source_by_id.get(candidate["direct_business_source_id"], {})
+ row["mechanical_sort_key"] = "|".join([
+ candidate["evidence_grade"], candidate["exposure_specificity"], candidate["latest_disclosed_period"],
+ source.get("publish_date", ""), candidate["exchange_code"], candidate["security_code"],
+ ])
+ row["funnel_rule_version"] = "REPAIR004_ACTUAL_ADJUNCT_PDF_PAGE_SEARCH_AND_BUCKET_ROLE_GATE_V1"
+ row["review_status"] = "DRAFT_FOR_REVIEW"
+ write_csv(path, headers, funnel)
+ return funnel
+
+
+def update_classification_and_exposure(
+ industry_root: Path, candidates: list[dict[str, str]], evidence_rows: list[dict[str, str]], source_rows: list[dict[str, str]],
+) -> list[dict[str, str]]:
+ candidate_by_pair = {(row["company_id"], row["track_code"]): row for row in candidates}
+ evidence_by_pair = defaultdict(list)
+ for evidence in evidence_rows:
+ if evidence["subject_type"] == "COMPANY":
+ evidence_by_pair[(evidence["company_id"], evidence["track_code"])].append(evidence)
+ source_by_id = {row["doc_id"]: row for row in source_rows}
+ classification_path = industry_root / "extracted" / "classification_summary.csv"
+ classifications = read_csv(classification_path)
+ for row in classifications:
+ pair = (row["company_id"], row["track_code"])
+ candidate = candidate_by_pair[pair]
+ matching = [e for e in evidence_by_pair.get(pair, []) if e["doc_id"] == candidate["direct_business_source_id"]]
+ row["classification_reason"] = candidate["include_or_exclude_reason"]
+ row["data_status"] = candidate["candidate_state"]
+ row["review_status"] = "DRAFT_FOR_REVIEW"
+ if matching:
+ evidence = matching[0]
+ source = source_by_id[evidence["doc_id"]]
+ row["subject_type"] = "COMPANY_DIRECT_BUSINESS"
+ row["source_doc_id"] = evidence["doc_id"]
+ row["evidence_fact_id"] = evidence["evidence_fact_id"]
+ row["chain_node_id"] = candidate["chain_nodes"]
+ row["raw_pool_path"] = source["raw_pool_path"]
+ row["raw_file_sha256"] = source["file_sha256"]
+ write_csv(classification_path, list(classifications[0]), classifications)
+
+ selected = [row for row in candidates if row["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}]
+ matrix_path = industry_root / "extracted" / "newenergy_company_exposure_matrix.csv"
+ matrix_headers = list(read_csv(matrix_path)[0])
+ matrix_rows = []
+ for candidate in selected:
+ pair = (candidate["company_id"], candidate["track_code"])
+ evidence = next(e for e in evidence_by_pair[pair] if e["doc_id"] == candidate["direct_business_source_id"])
+ row = {
+ "mapping_id": f"MAP-{candidate['track_code']}-{candidate['security_code']}", "task_id": TASK_ID,
+ "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
+ "company_id": candidate["company_id"], "security_code": candidate["security_code"],
+ "security_name": candidate["security_name"], "legal_name": candidate["legal_name"],
+ "exchange_code": candidate["exchange_code"], "track_code": candidate["track_code"],
+ "chain_nodes": candidate["chain_nodes"], "selection_bucket": candidate["selection_bucket"],
+ "tier": candidate["tier"], "candidate_state": candidate["candidate_state"],
+ "direct_business_source_id": candidate["direct_business_source_id"],
+ "direct_business_locator": candidate["direct_business_locator"], "evidence_fact_id": evidence["evidence_fact_id"],
+ "evidence_grade": candidate["evidence_grade"], "exposure_specificity": candidate["exposure_specificity"],
+ "latest_disclosed_period": candidate["latest_disclosed_period"], "primary_region": "MAINLAND_CHINA",
+ "scope_status": "CORE_SCOPE_DIRECT_BUSINESS", "coverage_claim": "NONE_INITIAL_CANDIDATE_POOL_ONLY",
+ "data_status": "VERIFIED_PUBLIC", "review_status": "DRAFT_FOR_REVIEW",
+ }
+ matrix_rows.append({key: row.get(key, "") for key in matrix_headers})
+ write_csv(matrix_path, matrix_headers, matrix_rows)
+ return matrix_rows
+
+
+def write_company_outputs_and_map(
+ industry_root: Path, candidates: list[dict[str, str]], evidence_rows: list[dict[str, str]], source_rows: list[dict[str, str]],
+) -> list[dict[str, str]]:
+ import newenergy_batch001_repair as base
+
+ selected = [row for row in candidates if row["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}]
+ evidence_by_pair = defaultdict(list)
+ for evidence in evidence_rows:
+ if evidence["subject_type"] == "COMPANY":
+ evidence_by_pair[(evidence["company_id"], evidence["track_code"])].append(evidence)
+ source_by_id = {row["doc_id"]: row for row in source_rows}
+ track_meta = {
+ "BATTERY": ("锂电", "01_锂电"), "SOLAR": ("光伏", "02_光伏"),
+ "WIND": ("风电", "03_风电"), "NUCLEAR": ("核电", "04_核电"),
+ }
+ selected_by_track = defaultdict(list)
+ facts: dict[tuple[str, str], tuple[dict[str, str], dict[str, str]]] = {}
+ for candidate in selected:
+ pair = (candidate["company_id"], candidate["track_code"])
+ evidence = next(e for e in evidence_by_pair[pair] if e["doc_id"] == candidate["direct_business_source_id"])
+ facts[pair] = (evidence, source_by_id[evidence["doc_id"]])
+ selected_by_track[candidate["track_code"]].append(candidate)
+
+ common_meta = (
+ f"> 状态:`DRAFT_FOR_REVIEW` \n> `task_id={TASK_ID}` · `case_id={CASE_ID}` · `batch_id={BATCH_ID}` · `run_id={RUN_ID}` \n"
+ "> `primary_region=MAINLAND_CHINA` · `global_comparator=SEPARATE_CONTEXT_ONLY` · `source_cutoff_at=2026-08-05T23:59:59+08:00` \n"
+ "> `coverage_claim=NONE_INITIAL_CANDIDATE_POOL_ONLY` · `valuation_market_interface=NOT_APPLICABLE_BATCH001`\n"
+ )
+ for track, rows in selected_by_track.items():
+ title, directory = track_meta[track]
+ lines = [
+ f"# {title}相关企业", "", common_meta,
+ "本页是完整候选发现池经实际年度报告附件逐页核验后的本批 A 股直接业务导航。每桶只保留机械排序前两名;T1/T2不表示质量或投资优先级。",
+ "", "| 深度 | 桶 | 代码 | 公司 | 直接业务证据 | 主源 |", "|---|---|---:|---|---|---|",
+ ]
+ for candidate in rows:
+ evidence, source = facts[(candidate["company_id"], track)]
+ fact = evidence["evidence_text"].replace("|", "\\|")
+ lines.append(
+ f"| {candidate['tier']} | {candidate['selection_bucket']} | {candidate['security_code']} | {candidate['security_name']} | "
+ f"{fact} | [{source['doc_id']}]({source['source_url']}),第{evidence['source_page']}页 |"
+ )
+ lines.extend([
+ "", "## 解释限制", "",
+ "- T1/T2 只代表本批机械排序后的研究深度,不代表公司质量、竞争排名、估值、交易或收益判断。",
+ "- 同一公司同一赛道多个节点只计一次;多元化公司只标注主源直接支持的赛道暴露。",
+ "- `coverage_claim=NONE_INITIAL_CANDIDATE_POOL_ONLY`。",
+ "", "完整字段见 [候选台账](../../../../../extracted/company_track_candidate_ledger.csv)。", "",
+ ])
+ path = base.CASE_OUTPUTS / "核心文档" / "子行业图谱" / directory / "相关企业.md"
+ path.write_text("\n".join(lines), encoding="utf-8")
+
+ company_lines = [
+ "# 新能源公司视图", "", common_meta,
+ "32条映射来自法定年度报告直接业务证据;完整发现池实际取得附件并逐页检索后,每赛道四桶、每桶按冻结规则选择两家。T1/T2不表示公司优劣或投资优先级。", "",
+ ]
+ for track in ["BATTERY", "SOLAR", "WIND", "NUCLEAR"]:
+ title, _ = track_meta[track]
+ company_lines.extend([f"## {title}", ""])
+ for candidate in selected_by_track[track]:
+ evidence, source = facts[(candidate["company_id"], track)]
+ company_lines.extend([
+ f'<a id="company-{candidate["security_code"]}"></a>',
+ f"### {candidate['security_name']}({candidate['security_code']},{candidate['tier']})", "",
+ f"- 选择桶:{candidate['selection_bucket']}", f"- 直接节点:{candidate['chain_nodes']}",
+ f"- 事实:{evidence['evidence_text']}",
+ f"- 来源:[{source['doc_id']}]({source['source_url']}),第{evidence['source_page']}页",
+ "- 限制:仅证明直接业务存在;不等同于赛道收入纯度、利润弹性、公司质量或投资结论。", "",
+ ])
+ (base.CASE_OUTPUTS / "新能源公司视图.md").write_text("\n".join(company_lines), encoding="utf-8")
+
+ map_path = base.CASE_EVIDENCE / "case_evidence_map.csv"
+ current_map = read_csv(map_path)
+ map_headers = list(current_map[0])
+ company_fact_ids = {
+ evidence["evidence_fact_id"] for evidence in evidence_rows
+ if evidence["subject_type"] == "COMPANY"
+ }
+ result = [
+ row for row in current_map
+ if row["evidence_fact_id"] not in company_fact_ids and not row["evidence_fact_id"].startswith("EVF-QUAL-")
+ ]
+ seq = 1
+ def add(output_path: Path, evidence_id: str, conclusion: str, limit: str, strength: str = "DIRECT_FACT") -> None:
+ nonlocal seq
+ row = {
+ "conclusion_evidence_map_id": f"CEM-R004-{seq:04d}", "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "conclusion_id": f"CONC-R004-{seq:04d}",
+ "output_path": output_path.relative_to(Path.cwd()).as_posix(), "section_anchor": "PENDING_MATERIALIZATION",
+ "conclusion_text": conclusion, "conclusion_strength": strength, "evidence_fact_id": evidence_id,
+ "support_type": "SUPPORT", "contradiction_or_limit": limit, "review_status": "DRAFT_FOR_REVIEW",
+ }
+ result.append({key: row.get(key, "") for key in map_headers})
+ seq += 1
+ first_by_track = {}
+ for candidate in selected:
+ evidence, _source = facts[(candidate["company_id"], candidate["track_code"])]
+ _title, directory = track_meta[candidate["track_code"]]
+ add(base.CASE_OUTPUTS / "新能源公司视图.md", evidence["evidence_fact_id"], evidence["evidence_text"],
+ "仅证明直接业务暴露;T1/T2不是质量、估值或投资排序。")
+ add(base.CASE_OUTPUTS / "核心文档" / "子行业图谱" / directory / "相关企业.md",
+ evidence["evidence_fact_id"], evidence["evidence_text"],
+ "仅证明直接业务暴露;T1/T2不是质量、估值或投资排序。")
+ first_by_track.setdefault(candidate["track_code"], (candidate, evidence))
+ for track, (candidate, evidence) in first_by_track.items():
+ _title, directory = track_meta[track]
+ add(base.CASE_OUTPUTS / "新能源行业视图.md", evidence["evidence_fact_id"],
+ f"{candidate['security_name']}的官方年报直接支持其{track}业务映射。", "公司例证不构成行业或公司全集。")
+ add(base.CASE_OUTPUTS / "核心文档" / "子行业图谱" / directory / "产业链与技术路线.md",
+ evidence["evidence_fact_id"], f"{candidate['security_name']}的公开产品/业务事实作为产业链节点例证。",
+ "只支持公开产品/业务节点,不据此推导技术优劣、份额或投资结论。", "MECHANISM_ONLY")
+ result = base.materialize_evidence_locators(result)
+ write_csv(map_path, map_headers, result)
+ return result
+
+
+def update_package_manifests(
+ industry_root: Path, source_rows: list[dict[str, str]], conversion_rows: list[dict[str, str]],
+ evidence_rows: list[dict[str, str]], candidates: list[dict[str, str]], case_map: list[dict[str, str]],
+) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
+ import newenergy_batch001_repair as base
+
+ base.COLLECTED_AT = max((row["collected_at"] for row in source_rows if row.get("collected_at")), default="2026-08-06T00:00:00+08:00")
+ base.build_input_manifests(source_rows)
+ base.build_source_gap_audit(source_rows, evidence_rows)
+ universe_rows = read_csv(industry_root / "extracted" / "a_share_universe.csv")
+ base.rewrite_batch_summary(candidates, len(source_rows), len(conversion_rows), universe_rows)
+ base.build_human_receipt(case_map, len(source_rows))
+ output_rows = base.rebuild_output_manifest()
+ artifact_rows = base.build_artifact_manifest(source_rows, output_rows, Path(__file__))
+ return output_rows, artifact_rows
+
+
+def finalize_repair004(
+ industry_root: Path, potential_rows: list[dict[str, str]], download_rows: list[dict[str, Any]],
+ pair_rows: list[dict[str, Any]], project_root: Path,
+) -> dict[str, Any]:
+ tool_dir = str((project_root / "ana-data" / "tools").resolve())
+ if tool_dir not in sys.path:
+ sys.path.insert(0, tool_dir)
+ source_rows, conversion_rows = build_qualification_sources_and_conversions(
+ industry_root, download_rows, potential_rows, project_root,
+ )
+ evidence_rows, candidates, _new_evidence = build_repair004_evidence_and_candidates(
+ industry_root, pair_rows, source_rows,
+ )
+ funnel = update_qualification_funnel(industry_root, pair_rows, candidates, evidence_rows, source_rows)
+ matrix_rows = update_classification_and_exposure(industry_root, candidates, evidence_rows, source_rows)
+ case_map = write_company_outputs_and_map(industry_root, candidates, evidence_rows, source_rows)
+ output_rows, artifact_rows = update_package_manifests(
+ industry_root, source_rows, conversion_rows, evidence_rows, candidates, case_map,
+ )
+ selected = [row for row in candidates if row["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}]
+ state_counts = {state: sum(row["candidate_state"] == state for row in candidates) for state in sorted({row["candidate_state"] for row in candidates})}
+ return {
+ "status": "REPAIR004_FORMAL_PACKAGE_REBUILT_DRAFT_FOR_REVIEW",
+ "sources": len(source_rows), "conversions": len(conversion_rows), "evidence_facts": len(evidence_rows),
+ "candidate_pairs": len(candidates), "candidate_states": state_counts, "selected": len(selected),
+ "exposure_matrix": len(matrix_rows), "funnel": len(funnel), "case_map": len(case_map),
+ "outputs": len(output_rows), "artifacts_excluding_manifest_self": len(artifact_rows),
+ "artifact_manifest_sha256": sha256_file(industry_root / "manifest" / "artifact_manifest.csv"),
+ }
+
+
+def validate_scope(
+ potential_rows: list[dict[str, str]], items: list[dict[str, Any]], download_rows: list[dict[str, Any]],
+ pair_rows: list[dict[str, Any]],
+) -> None:
+ if len(potential_rows) != 858:
+ raise RuntimeError(f"frozen potential-direct pair count changed: {len(potential_rows)} != 858")
+ if len(items) != 729:
+ raise RuntimeError(f"frozen unique attachment count changed: {len(items)} != 729")
+ if len({row["qualification_row_id"] for row in potential_rows}) != 858:
+ raise RuntimeError("potential-direct pair ids are not unique")
+ if len(download_rows) != 729 or len({row["attachment_id"] for row in download_rows}) != 729:
+ raise RuntimeError("download receipt does not exactly cover the 729 attachments")
+ if len(pair_rows) != 858 or len({row["qualification_row_id"] for row in pair_rows}) != 858:
+ raise RuntimeError("pair receipt does not exactly cover the 858 frozen pairs")
+ for row in download_rows:
+ if row["pdf_readable"] == "YES":
+ path = Path.cwd() / row["raw_path"]
+ if not path.exists() or sha256_file(path) != row["raw_sha256"]:
+ raise RuntimeError(f"download hash mismatch: {row['attachment_id']}")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--download-workers", type=int, default=12)
+ parser.add_argument("--search-workers", type=int, default=max(2, min(8, os.cpu_count() or 4)))
+ parser.add_argument("--acquire-only", action="store_true")
+ parser.add_argument("--finalize", action="store_true")
+ parser.add_argument("--force-http-receipt", action="store_true")
+ args = parser.parse_args()
+
+ project_root = Path.cwd().resolve()
+ industry_root = find_industry_root(project_root)
+ extracted_root = industry_root / "extracted"
+ manifest_root = industry_root / "manifest"
+ raw_dir = industry_root / "raw" / "qualification_filings"
+ converted_dir = industry_root / "converted" / "qualification_filings"
+ tmp_root = industry_root / CASE_ID / "tmp" / "repair004"
+ raw_dir.mkdir(parents=True, exist_ok=True)
+ converted_dir.mkdir(parents=True, exist_ok=True)
+ tmp_root.mkdir(parents=True, exist_ok=True)
+
+ funnel = read_csv(extracted_root / "candidate_qualification_funnel.csv")
+ prior_pair_receipt = extracted_root / "candidate_page_qualification_receipt.csv"
+ if prior_pair_receipt.exists():
+ frozen_ids = {row["qualification_row_id"] for row in read_csv(prior_pair_receipt)}
+ if len(frozen_ids) == 858:
+ potential_rows = [row for row in funnel if row["qualification_row_id"] in frozen_ids]
+ else:
+ potential_rows = [row for row in funnel if row["business_context_rule_result"].startswith("PAGE_VERIFIED_")]
+ else:
+ potential_rows = [row for row in funnel if row["business_context_rule_result"] == POTENTIAL]
+ metadata = load_announcement_metadata(industry_root)
+ items = build_attachment_items(potential_rows, metadata, industry_root)
+ download_receipt_path = manifest_root / "candidate_attachment_download_receipt.csv"
+ download_rows = run_acquisition(
+ items, raw_dir, download_receipt_path, project_root, args.download_workers, args.force_http_receipt,
+ )
+ if args.acquire_only:
+ print(json.dumps({
+ "status": "ACQUISITION_COMPLETE", "pairs": len(potential_rows), "attachments": len(items),
+ "readable": sum(row["pdf_readable"] == "YES" for row in download_rows),
+ "failed": sum(row["pdf_readable"] != "YES" for row in download_rows),
+ "receipt_sha256": sha256_file(download_receipt_path),
+ }, ensure_ascii=False), flush=True)
+ return
+
+ search_cache_path = tmp_root / "attachment_page_search_cache.json"
+ search_results = run_search(
+ items, download_rows, converted_dir, search_cache_path, project_root, args.search_workers,
+ )
+ verified_at = max((row["attempted_at"] for row in download_rows if row.get("attempted_at")), default=now_iso())
+ pair_rows = build_pair_receipts(potential_rows, download_rows, search_results, verified_at)
+ pair_receipt_path = extracted_root / "candidate_page_qualification_receipt.csv"
+ write_csv(pair_receipt_path, PAIR_HEADERS, pair_rows)
+ validate_scope(potential_rows, items, download_rows, pair_rows)
+ if args.finalize:
+ result = finalize_repair004(industry_root, potential_rows, download_rows, pair_rows, project_root)
+ print(json.dumps(result, ensure_ascii=False, indent=2), flush=True)
+ return
+ print(json.dumps({
+ "status": "REPAIR004_ATTACHMENT_AND_PAGE_SEARCH_COMPLETE",
+ "pairs": len(potential_rows), "attachments": len(items),
+ "readable_attachments": sum(row["pdf_readable"] == "YES" for row in download_rows),
+ "failed_attachments": sum(row["pdf_readable"] != "YES" for row in download_rows),
+ "direct_context_pairs": sum(row["direct_context_hit_count"] != "0" for row in pair_rows),
+ "keyword_context_only_pairs": sum(row["page_search_result"] == "FROZEN_KEYWORD_FOUND_BUT_NO_DIRECT_SELF_BUSINESS_PAGE_CONTEXT" for row in pair_rows),
+ "true_negative_pairs": sum(row["page_search_result"] == "ALL_REFERENCED_READABLE_PDFS_SEARCHED_NO_FROZEN_KEYWORD_HIT" for row in pair_rows),
+ "retrieval_or_parse_failure_pairs": sum(row["page_search_result"] == "ONE_OR_MORE_REFERENCED_PDFS_NOT_ACQUIRED_OR_UNREADABLE" for row in pair_rows),
+ "download_receipt_sha256": sha256_file(download_receipt_path),
+ "pair_receipt_sha256": sha256_file(pair_receipt_path),
+ }, ensure_ascii=False), flush=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/ana-data/tools/newenergy_batch001_repair005.py b/ana-data/tools/newenergy_batch001_repair005.py
new file mode 100644
index 0000000..a5390ef
--- /dev/null
+++ b/ana-data/tools/newenergy_batch001_repair005.py
@@ -0,0 +1,871 @@
+#!/usr/bin/env python3
+"""REPAIR005: close the remaining B1 company-self / target-bucket role defects.
+
+This replay deliberately reuses the 729 CNINFO PDFs and the 858 page-search
+receipts accepted by the independent REPAIR004 review. It does not download,
+expand, or replace research sources. The only substantive change is a stricter
+semantic role adjudication of the 50 page-search positives, followed by a full
+858-row state replay, non-eligible field cleanup, mechanical reranking, and
+deterministic downstream rebuild.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from collections import Counter, defaultdict
+from pathlib import Path
+from typing import Any
+
+import newenergy_batch001_repair as base
+import newenergy_batch001_repair004 as r4
+
+
+TOOL_VERSION = "REPAIR-005"
+ROLE_RULE_VERSION = "REPAIR005_COMPANY_SELF_TARGET_BUCKET_ROLE_GATE_V2_HUMAN_ADJUDICATED_DIRECT_SET"
+REVIEW_STATUS = "DRAFT_FOR_REVIEW"
+
+TASK_ID = r4.TASK_ID
+CASE_ID = r4.CASE_ID
+BATCH_ID = r4.BATCH_ID
+RUN_ID = r4.RUN_ID
+
+
+# The independent reviewer expressly allowed necessary human reading in
+# REPAIR005. Every one of the 50 prior regex-positive rows is therefore frozen
+# here with an explicit semantic outcome. Accepted rows prove a direct business
+# of the listed company or a controlled subsidiary in the target bucket. The
+# remaining 808 rows are replayed from the already accepted full-page negative
+# or context-only search receipt.
+APPROVED_ROLE_DECISIONS: dict[str, str] = {
+ "QUAL-00019": "CONTROLLED_SUBSIDIARY_TARGET_PRODUCT_MANUFACTURE",
+ "QUAL-00061": "DIRECT_TARGET_PRODUCT_RESEARCH_PRODUCTION_AND_SALE",
+ "QUAL-00072": "DIRECT_TARGET_PRODUCT_RESEARCH_PRODUCTION_AND_SALE",
+ "QUAL-00103": "CONTROLLED_SUBSIDIARY_TARGET_PRODUCT_MANUFACTURE",
+ "QUAL-00128": "DIRECT_TARGET_PRODUCT_PRODUCTION_AND_SALE",
+ "QUAL-00129": "DIRECT_TARGET_PRODUCT_BUSINESS",
+ "QUAL-00136": "DIRECT_TARGET_PRODUCT_PRODUCTION_AND_SALE",
+ "QUAL-00141": "DIRECT_TARGET_PRODUCT_SALE",
+ "QUAL-00148": "DIRECT_TARGET_PRODUCT_PRODUCTION",
+ "QUAL-00149": "DIRECT_TARGET_RESOURCE_DEVELOPMENT_PRODUCTION_AND_SALE",
+ "QUAL-00213": "DIRECT_TARGET_PRODUCT_RESEARCH_PRODUCTION_AND_SALE",
+ "QUAL-00226": "DIRECT_TARGET_PRODUCT_RESEARCH_PRODUCTION_AND_SALE",
+ "QUAL-00227": "DIRECT_TARGET_PRODUCT_RESEARCH_PRODUCTION_AND_SALE",
+ "QUAL-00348": "DIRECT_TARGET_CELL_RESEARCH_PRODUCTION_AND_SALE",
+ "QUAL-01029": "DIRECT_TARGET_SYSTEM_PRODUCT",
+ "QUAL-01137": "DIRECT_TARGET_SYSTEM_PRODUCT",
+ "QUAL-01625": "DIRECT_TARGET_WAFER_PRODUCTION",
+ "QUAL-01639": "DIRECT_TARGET_SILICON_INGOT_AND_WAFER_PRODUCTION_AND_SALE",
+ "QUAL-01732": "DIRECT_TARGET_WAFER_PRODUCT_BUSINESS",
+ "QUAL-01846": "CONTROLLED_SUBSIDIARY_TARGET_COMPONENT_MANUFACTURE",
+ "QUAL-01904": "DIRECT_TARGET_SPECIALTY_COMPONENT_PRODUCTION_AND_SALE",
+ "QUAL-02192": "DIRECT_TARGET_COMPONENT_PRODUCT_SALE",
+ "QUAL-02265": "DIRECT_TARGET_COMPONENT_PRODUCT_SALE",
+ "QUAL-02299": "CONTROLLED_SUBSIDIARY_TARGET_COMPONENT_PRODUCTION_AND_SALE",
+ "QUAL-02364": "CONTROLLED_SUBSIDIARY_TARGET_COMPONENT_RESEARCH_PRODUCTION_AND_SALE",
+ "QUAL-02648": "DIRECT_TARGET_INVERTER_PRODUCT",
+ "QUAL-02692": "CONTROLLED_SUBSIDIARY_TARGET_STATION_INVESTMENT_AND_OPERATION",
+ "QUAL-02760": "CONTROLLED_SUBSIDIARY_TARGET_STATION_INVESTMENT_AND_OPERATION",
+ "QUAL-02761": "DIRECT_TARGET_STATION_PROJECT_CONSTRUCTION_AND_OPERATION",
+ "QUAL-02875": "CONTROLLED_SUBSIDIARY_TARGET_STATION_CONSTRUCTION_AND_OPERATION",
+ "QUAL-02888": "DIRECT_TARGET_STATION_CONSTRUCTION_AND_OPERATION_WITH_REVENUE",
+ "QUAL-02900": "CONTROLLED_SUBSIDIARY_TARGET_STATION_OPERATION",
+ "QUAL-02956": "DIRECT_TARGET_STATION_EPC_AND_OPERATION_SERVICE",
+ "QUAL-02982": "DIRECT_TARGET_STATION_CONSTRUCTION_OPERATION_AND_EXTERNAL_EXPANSION",
+ "QUAL-02991": "DIRECT_TARGET_STATION_INVESTMENT_AND_OPERATION",
+ "QUAL-03042": "DIRECT_TARGET_STATION_CONSTRUCTION_AND_OPERATION_WITH_REVENUE",
+ "QUAL-03055": "DIRECT_TARGET_STATION_INVESTMENT_AND_OPERATION_WITH_REVENUE",
+ "QUAL-03067": "DIRECT_TARGET_STATION_EPC_SERVICE",
+ "QUAL-03199": "DIRECT_TARGET_WIND_COMPONENT_MANUFACTURE",
+ "QUAL-03750": "DIRECT_TARGET_WIND_PROJECT_CONSTRUCTION_AND_OPERATION",
+}
+
+
+REJECTED_ROLE_DECISIONS: dict[str, str] = {
+ "QUAL-00063": "ADJACENT_EQUIPMENT_AND_EPC_SERVICE_NOT_BATTERY_RESOURCE_OR_MAIN_MATERIAL",
+ "QUAL-00223": "ADJACENT_INDUSTRIAL_GAS_SUPPLY_TO_BATTERY_MATERIAL_PROJECT",
+ "QUAL-01640": "CUTTING_WIRE_AUXILIARY_NOT_SILICON_OR_WAFER_MANUFACTURE",
+ "QUAL-01889": "COMPONENT_BACKSHEET_FILM_AND_EPOXY_AUXILIARY_NOT_CELL_OR_COMPONENT_MANUFACTURE",
+ "QUAL-01901": "PHOTOVOLTAIC_GLASS_AUXILIARY_OR_POWER_GENERATION_NOT_CELL_OR_COMPONENT_MANUFACTURE",
+ "QUAL-01962": "COMPONENT_ENCAPSULATION_MATERIAL_NOT_CELL_OR_COMPONENT_MANUFACTURE",
+ "QUAL-02159": "COMPANY_PROCUREMENT_OF_COMPONENTS_AND_INVERTERS_NOT_TARGET_PRODUCT_BUSINESS",
+ "QUAL-02499": "COMPONENTS_LISTED_AS_CONSUMED_RAW_MATERIAL_NOT_TARGET_PRODUCT_BUSINESS",
+ "QUAL-02828": "INTERNAL_ROOFTOP_AND_STATION_SUPPORT_SERVICE_ONLY_WITHOUT_EXTERNAL_TARGET_BUSINESS",
+ "QUAL-02852": "SELF_USE_TEXTILE_PROJECT_ONLY_WITHOUT_STATION_BUSINESS",
+}
+
+
+ROLE_HEADERS = [
+ "role_adjudication_id", "qualification_row_id", "task_id", "case_id", "batch_id", "run_id",
+ "company_id", "security_code", "security_name", "track_code", "selection_bucket",
+ "prior_page_search_result", "prior_qualification_result", "prior_keyword_hit_count",
+ "prior_direct_context_hit_count", "full_page_search_reused", "human_semantic_review",
+ "company_self_gate", "target_bucket_role_gate", "adjacent_or_self_use_exclusion_gate",
+ "final_role_decision", "decision_basis_code", "selected_attachment_id", "selected_page",
+ "selected_exact_sentence", "rule_version", "adjudicated_at", "tool_version", "review_status",
+ "receipt_sha256",
+]
+
+PRESTATE_HEADERS = [
+ "snapshot_id", "blocker_id", "record_type", "company_id", "security_code", "track_code",
+ "state", "subject_type", "direct_business_source_id", "direct_business_locator",
+ "evidence_grade", "exposure_specificity", "evidence_fact_id", "chain_node_id",
+ "classification_reason", "snapshot_status",
+]
+
+
+def role_receipt_path(industry_root: Path) -> Path:
+ return industry_root / "extracted" / "candidate_role_adjudication_receipt.csv"
+
+
+def prestate_path(industry_root: Path) -> Path:
+ return industry_root / "manifest" / "repair005_pollution_prestate_snapshot.csv"
+
+
+def validation_path(industry_root: Path) -> Path:
+ return industry_root / "manifest" / "repair005_validation_receipt.json"
+
+
+def stable_adjudicated_at(industry_root: Path) -> str:
+ path = role_receipt_path(industry_root)
+ if path.exists():
+ rows = r4.read_csv(path)
+ values = sorted({row.get("adjudicated_at", "") for row in rows if row.get("adjudicated_at")})
+ if len(values) == 1:
+ return values[0]
+ return r4.now_iso()
+
+
+def build_role_adjudication_receipt(
+ industry_root: Path,
+ pair_rows: list[dict[str, str]],
+ candidates: list[dict[str, str]],
+) -> list[dict[str, str]]:
+ direct_ids = {row["qualification_row_id"] for row in pair_rows if int(row["direct_context_hit_count"]) > 0}
+ frozen_decisions = set(APPROVED_ROLE_DECISIONS) | set(REJECTED_ROLE_DECISIONS)
+ if len(pair_rows) != 858 or len({row["qualification_row_id"] for row in pair_rows}) != 858:
+ raise RuntimeError("REPAIR005 requires the frozen 858 unique pair receipts")
+ if len(direct_ids) != 50 or direct_ids != frozen_decisions:
+ raise RuntimeError(
+ f"the frozen 50 direct-context rows changed: direct={len(direct_ids)} "
+ f"missing={sorted(direct_ids - frozen_decisions)} extra={sorted(frozen_decisions - direct_ids)}"
+ )
+ if set(APPROVED_ROLE_DECISIONS) & set(REJECTED_ROLE_DECISIONS):
+ raise RuntimeError("approved and rejected semantic decision sets overlap")
+
+ candidate_by_pair = {(row["company_id"], row["track_code"]): row for row in candidates}
+ adjudicated_at = stable_adjudicated_at(industry_root)
+ results: list[dict[str, str]] = []
+ for source in sorted(pair_rows, key=lambda row: row["qualification_row_id"]):
+ qid = source["qualification_row_id"]
+ candidate = candidate_by_pair[(source["company_id"], source["track_code"])]
+ attachment_id = ""
+ page = ""
+ sentence = ""
+ if qid in APPROVED_ROLE_DECISIONS:
+ attachment_id, page_number, sentence = r4.parse_direct_context(source)
+ page = str(page_number)
+ company_gate = "PASS"
+ target_gate = "PASS"
+ exclusion_gate = "PASS_NO_EXCLUSION_TRIGGERED"
+ decision = "ELIGIBLE_DIRECT_COMPANY_SELF_TARGET_BUCKET_ROLE"
+ basis = APPROVED_ROLE_DECISIONS[qid]
+ human_review = "YES_ALL_PRIOR_DIRECT_CONTEXTS_REVIEWED"
+ elif qid in REJECTED_ROLE_DECISIONS:
+ attachment_id, page_number, sentence = r4.parse_direct_context(source)
+ page = str(page_number)
+ company_gate = "FAIL_OR_NOT_SAME_TARGET_ROLE"
+ target_gate = "FAIL"
+ exclusion_gate = "FAIL_EXCLUSION_TRIGGERED"
+ decision = "HELD_ROLE_MISMATCH_ADJACENT_PROCUREMENT_OR_SELF_USE"
+ basis = REJECTED_ROLE_DECISIONS[qid]
+ human_review = "YES_ALL_PRIOR_DIRECT_CONTEXTS_REVIEWED"
+ else:
+ company_gate = "FAIL_NO_PRIOR_DIRECT_SELF_CONTEXT"
+ target_gate = "FAIL_NO_PRIOR_DIRECT_TARGET_ROLE_CONTEXT"
+ exclusion_gate = "NOT_APPLICABLE_NO_DIRECT_CONTEXT"
+ if source["page_search_result"] == "ALL_REFERENCED_READABLE_PDFS_SEARCHED_NO_FROZEN_KEYWORD_HIT":
+ decision = "HELD_TRUE_NEGATIVE_AFTER_FULL_PAGE_SEARCH"
+ basis = "NO_FROZEN_KEYWORD_HIT_IN_REFERENCED_READABLE_PDFS"
+ elif source["page_search_result"] == "FROZEN_KEYWORD_FOUND_BUT_NO_DIRECT_SELF_BUSINESS_PAGE_CONTEXT":
+ decision = "HELD_KEYWORD_CONTEXT_ONLY_AFTER_FULL_PAGE_SEARCH"
+ basis = "KEYWORD_CONTEXT_DID_NOT_PASS_COMPANY_SELF_AND_TARGET_ROLE_GATE"
+ else:
+ decision = "HELD_RETRIEVAL_OR_PARSE_GAP"
+ basis = "REPAIR004_REVIEWABLE_RETRIEVAL_OR_PARSE_FAILURE"
+ human_review = "NOT_REQUIRED_PRIOR_FULL_PAGE_GATE_FOUND_NO_DIRECT_CONTEXT"
+
+ core: dict[str, str] = {
+ "role_adjudication_id": f"ROLE-R005-{qid}", "qualification_row_id": qid,
+ "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
+ "company_id": source["company_id"], "security_code": source["security_code"],
+ "security_name": candidate["security_name"], "track_code": source["track_code"],
+ "selection_bucket": source["selection_bucket"],
+ "prior_page_search_result": source["page_search_result"],
+ "prior_qualification_result": source["qualification_result"],
+ "prior_keyword_hit_count": source["keyword_hit_count"],
+ "prior_direct_context_hit_count": source["direct_context_hit_count"],
+ "full_page_search_reused": "YES_REPAIR004_ACCEPTED_729_PDFS_858_PAIR_SEARCH",
+ "human_semantic_review": human_review,
+ "company_self_gate": company_gate, "target_bucket_role_gate": target_gate,
+ "adjacent_or_self_use_exclusion_gate": exclusion_gate,
+ "final_role_decision": decision, "decision_basis_code": basis,
+ "selected_attachment_id": attachment_id, "selected_page": page,
+ "selected_exact_sentence": sentence, "rule_version": ROLE_RULE_VERSION,
+ "adjudicated_at": adjudicated_at, "tool_version": TOOL_VERSION,
+ "review_status": REVIEW_STATUS,
+ }
+ payload = json.dumps(core, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
+ core["receipt_sha256"] = r4.sha256_bytes(payload)
+ results.append({key: core.get(key, "") for key in ROLE_HEADERS})
+ r4.write_csv(role_receipt_path(industry_root), ROLE_HEADERS, results)
+ return results
+
+
+def write_prestate_snapshot_once(
+ industry_root: Path,
+ candidates: list[dict[str, str]],
+ classifications: list[dict[str, str]],
+) -> list[dict[str, str]]:
+ path = prestate_path(industry_root)
+ if path.exists():
+ prior = r4.read_csv(path)
+ rows = [
+ row for row in prior
+ if (
+ row["record_type"] == "CANDIDATE_LEDGER"
+ and bool(row["direct_business_source_id"])
+ and row["evidence_grade"] in {"S", "A"}
+ ) or (
+ row["record_type"] == "CLASSIFICATION_SUMMARY"
+ and (
+ row["subject_type"] == "COMPANY_DIRECT_BUSINESS"
+ or bool(row["evidence_fact_id"])
+ )
+ )
+ ]
+ if len(rows) != len(prior):
+ r4.write_csv(path, PRESTATE_HEADERS, rows)
+ return rows
+ rows: list[dict[str, str]] = []
+ for candidate in candidates:
+ held = candidate["candidate_state"] == "HELD_BY_EVIDENCE_GAP"
+ polluted = (
+ held
+ and bool(candidate.get("direct_business_source_id", ""))
+ and candidate.get("evidence_grade", "") in {"S", "A"}
+ )
+ if not polluted:
+ continue
+ rows.append({
+ "snapshot_id": f"PRE-R005-LEDGER-{candidate['track_code']}-{candidate['security_code']}",
+ "blocker_id": "B1-R004-B", "record_type": "CANDIDATE_LEDGER",
+ "company_id": candidate["company_id"], "security_code": candidate["security_code"],
+ "track_code": candidate["track_code"], "state": candidate["candidate_state"],
+ "subject_type": "", "direct_business_source_id": candidate["direct_business_source_id"],
+ "direct_business_locator": candidate["direct_business_locator"],
+ "evidence_grade": candidate["evidence_grade"],
+ "exposure_specificity": candidate["exposure_specificity"], "evidence_fact_id": "",
+ "chain_node_id": candidate["chain_nodes"],
+ "classification_reason": candidate["include_or_exclude_reason"],
+ "snapshot_status": "FROZEN_PRE_REPAIR005_POLLUTION_EVIDENCE",
+ })
+ for row in classifications:
+ held = row["data_status"] == "HELD_BY_EVIDENCE_GAP"
+ polluted = held and (
+ row.get("subject_type", "") == "COMPANY_DIRECT_BUSINESS"
+ or bool(row.get("evidence_fact_id", ""))
+ )
+ if not polluted:
+ continue
+ rows.append({
+ "snapshot_id": f"PRE-R005-CLASS-{row['track_code']}-{row['company_id']}",
+ "blocker_id": "B1-R004-B", "record_type": "CLASSIFICATION_SUMMARY",
+ "company_id": row["company_id"], "security_code": row["company_id"].split(":")[-1],
+ "track_code": row["track_code"], "state": row["data_status"],
+ "subject_type": row["subject_type"], "direct_business_source_id": row["source_doc_id"],
+ "direct_business_locator": "", "evidence_grade": "", "exposure_specificity": "",
+ "evidence_fact_id": row["evidence_fact_id"], "chain_node_id": row["chain_node_id"],
+ "classification_reason": row["classification_reason"],
+ "snapshot_status": "FROZEN_PRE_REPAIR005_POLLUTION_EVIDENCE",
+ })
+ if not rows:
+ raise RuntimeError("expected REPAIR004 polluted prestate was not found before REPAIR005")
+ r4.write_csv(path, PRESTATE_HEADERS, rows)
+ return rows
+
+
+def clear_noneligible_candidate(candidate: dict[str, str], reason: str) -> None:
+ candidate["chain_nodes"] = ""
+ candidate["direct_business_source_id"] = ""
+ candidate["direct_business_locator"] = ""
+ candidate["evidence_grade"] = ""
+ candidate["exposure_specificity"] = ""
+ candidate["latest_disclosed_period"] = ""
+ candidate["candidate_state"] = "HELD_BY_EVIDENCE_GAP"
+ candidate["selection_rank"] = ""
+ candidate["tier"] = ""
+ candidate["tie_break_rule"] = ""
+ candidate["include_or_exclude_reason"] = reason
+
+
+def build_evidence_and_candidates(
+ industry_root: Path,
+ role_rows: list[dict[str, str]],
+ source_rows: list[dict[str, str]],
+) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
+ evidence_path = industry_root / "evidence" / "evidence_fact_table.csv"
+ prior_evidence = r4.read_csv(evidence_path)
+ evidence_headers = list(prior_evidence[0])
+ evidence_rows = [row for row in prior_evidence if not row["evidence_fact_id"].startswith("EVF-QUAL-")]
+ candidate_path = industry_root / "extracted" / "company_track_candidate_ledger.csv"
+ candidates = r4.read_csv(candidate_path)
+ source_by_id = {row["doc_id"]: row for row in source_rows}
+ candidate_by_pair = {(row["company_id"], row["track_code"]): row for row in candidates}
+ role_by_pair = {(row["company_id"], row["track_code"]): row for row in role_rows}
+
+ for role in role_rows:
+ if role["final_role_decision"] != "ELIGIBLE_DIRECT_COMPANY_SELF_TARGET_BUCKET_ROLE":
+ continue
+ pair = (role["company_id"], role["track_code"])
+ candidate = candidate_by_pair[pair]
+ attachment_id = role["selected_attachment_id"]
+ page = role["selected_page"]
+ doc_id = f"S-QUAL-AR-{attachment_id}"
+ source = source_by_id[doc_id]
+ evidence_id = f"EVF-QUAL-{role['track_code']}-{role['security_code']}-R005"
+ evidence = {
+ "evidence_fact_id": evidence_id, "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "doc_id": doc_id,
+ "industry_case": "新能源案例", "industry_id": "IND-NEWENERGY",
+ "subindustry_id": role["track_code"], "company_id": role["company_id"],
+ "track_code": role["track_code"],
+ "chain_node_id": r4.CHAIN_NODES[(role["track_code"], role["selection_bucket"])],
+ "subject_type": "COMPANY", "subject_id": role["company_id"],
+ "source_text_path": source["converted_text_path"], "raw_pool_path": source["raw_pool_path"],
+ "raw_file_sha256": source["file_sha256"], "source_page": page, "source_table_id": "",
+ "source_sentence_index": "", "locator_type": "PDF_PAGE_AND_EXACT_CONTEXT",
+ "locator_value": f"attachment_id={attachment_id};page={page};receipt={role['qualification_row_id']};role_receipt={role['role_adjudication_id']}",
+ "evidence_text": role["selected_exact_sentence"], "evidence_type": "OFFICIAL_ANNUAL_REPORT",
+ "statement_type": "FACT", "business_dimension": "COMPANY_EXPOSURE",
+ "research_dimension": "COMPANY_TRACK_QUALIFICATION", "numeric_value_raw": "",
+ "metric_candidate_name": "", "metric_candidate_unit": "", "metric_period": "2025",
+ "metric_date": "2025-12-31", "geography": "CN",
+ "original_qualifier": (
+ "REPAIR005复用已审核的CNINFO年度报告与全页检索回执,并对全部50条先前direct-context做公司自身/"
+ "受控子公司与目标桶角色人读裁决;仅支持直接业务事实,不作份额、质量或投资判断。"
+ ),
+ "related_company_id": "", "viewpoint_id": "", "darkline_signal_flag": "NO",
+ "confidence_level": "HIGH", "conclusion_strength": "DIRECT_FACT",
+ "sensitivity_screen": "LEGAL_PUBLIC_SCREENED_HIGH_LEVEL_ONLY",
+ "contradicts_evidence_fact_id": "", "normalization_status": "NORMALIZED",
+ "processing_status": "READY", "data_status": "VERIFIED_PUBLIC",
+ "review_status": REVIEW_STATUS,
+ }
+ evidence_rows.append({key: evidence.get(key, "") for key in evidence_headers})
+ candidate.update({
+ "chain_nodes": r4.CHAIN_NODES[(role["track_code"], role["selection_bucket"])],
+ "direct_business_source_id": doc_id,
+ "direct_business_locator": f"2025年报第{page}页;attachment_id={attachment_id};role_receipt={role['role_adjudication_id']}",
+ "evidence_grade": "S",
+ "exposure_specificity": "NAMED_PRODUCT_PROJECT_AND_OPERATING_FACT",
+ "latest_disclosed_period": "2025-12-31",
+ })
+
+ evidence_by_pair: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
+ for evidence in evidence_rows:
+ if evidence["subject_type"] == "COMPANY" and evidence["company_id"] and evidence["track_code"]:
+ evidence_by_pair[(evidence["company_id"], evidence["track_code"])].append(evidence)
+
+ eligible_groups: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
+ valid_specificity = {
+ "SEGMENT_REVENUE_OR_ASSET_DISCLOSED",
+ "NAMED_PRODUCT_PROJECT_AND_OPERATING_FACT",
+ "GENERAL_DIRECT_BUSINESS_DESCRIPTION",
+ }
+ for candidate in candidates:
+ pair = (candidate["company_id"], candidate["track_code"])
+ matching = [
+ evidence for evidence in evidence_by_pair.get(pair, [])
+ if candidate["direct_business_source_id"] and evidence["doc_id"] == candidate["direct_business_source_id"]
+ ]
+ eligible = bool(matching) and candidate["evidence_grade"] in {"S", "A"} and candidate["exposure_specificity"] in valid_specificity
+ if eligible:
+ candidate["candidate_state"] = "ELIGIBLE"
+ candidate["selection_rank"] = ""
+ candidate["tier"] = ""
+ candidate["tie_break_rule"] = "source_grade>exposure_specificity>latest_period>publish_date>exchange_code>security_code"
+ if pair in role_by_pair:
+ candidate["include_or_exclude_reason"] = (
+ "REPAIR005复用年度报告PDF与全页检索,对先前direct-context执行公司自身/受控子公司和目标桶角色语义裁决;"
+ "S级主源、页码原句与全部资格gate通过,进入同桶ELIGIBLE机械排序。"
+ )
+ else:
+ candidate["include_or_exclude_reason"] = (
+ "设计前序已登记直接业务主源与证据事实在REPAIR005重放中保持有效;全部资格gate通过,进入同桶ELIGIBLE机械排序。"
+ )
+ eligible_groups[(candidate["track_code"], candidate["selection_bucket"])].append(candidate)
+ else:
+ role = role_by_pair.get(pair)
+ if role:
+ reason = (
+ f"REPAIR005全量重放结论={role['final_role_decision']};"
+ f"依据={role['decision_basis_code']};未通过公司自身及目标桶直接业务证据闸门,保持HELD。"
+ )
+ else:
+ reason = "REPAIR005重放未找到可与候选直接业务字段闭合的S/A级公司证据,清除全部资格派生字段并保持HELD。"
+ clear_noneligible_candidate(candidate, reason)
+
+ grade_order = {"S": 0, "A": 1}
+ specificity_order = {
+ "SEGMENT_REVENUE_OR_ASSET_DISCLOSED": 0,
+ "NAMED_PRODUCT_PROJECT_AND_OPERATING_FACT": 1,
+ "GENERAL_DIRECT_BUSINESS_DESCRIPTION": 2,
+ }
+ for group_rows in eligible_groups.values():
+ group_rows.sort(key=lambda row: (
+ grade_order.get(row["evidence_grade"], 9),
+ specificity_order.get(row["exposure_specificity"], 9),
+ r4.descending_date(row["latest_disclosed_period"]),
+ r4.descending_date(source_by_id[row["direct_business_source_id"]]["publish_date"]),
+ row["exchange_code"], row["security_code"],
+ ))
+ for rank, candidate in enumerate(group_rows, 1):
+ candidate["selection_rank"] = str(rank)
+ if rank == 1:
+ candidate["candidate_state"] = "INCLUDED_T1"
+ candidate["tier"] = "T1_PRIMARY"
+ elif rank == 2:
+ candidate["candidate_state"] = "INCLUDED_T2"
+ candidate["tier"] = "T2_CANDIDATE"
+ else:
+ candidate["candidate_state"] = "ELIGIBLE_NOT_SELECTED_BATCH001"
+ candidate["tier"] = ""
+ candidate["include_or_exclude_reason"] += " 同桶排名超过2,保留为ELIGIBLE_NOT_SELECTED_BATCH001。"
+
+ track_order = {"BATTERY": 0, "SOLAR": 1, "WIND": 2, "NUCLEAR": 3}
+ bucket_order = {key: index for index, key in enumerate(r4.TRACK_KEYWORDS)}
+ state_order = {
+ "INCLUDED_T1": 0, "INCLUDED_T2": 1,
+ "ELIGIBLE_NOT_SELECTED_BATCH001": 2, "HELD_BY_EVIDENCE_GAP": 3,
+ }
+ candidates.sort(key=lambda row: (
+ track_order[row["track_code"]], bucket_order[(row["track_code"], row["selection_bucket"])],
+ state_order.get(row["candidate_state"], 9), int(row["selection_rank"] or 999999),
+ row["exchange_code"], row["security_code"],
+ ))
+ r4.write_csv(evidence_path, evidence_headers, evidence_rows)
+ r4.write_csv(candidate_path, list(candidates[0]), candidates)
+ return evidence_rows, candidates
+
+
+def update_funnel(
+ industry_root: Path,
+ role_rows: list[dict[str, str]],
+ candidates: list[dict[str, str]],
+ evidence_rows: list[dict[str, str]],
+ source_rows: list[dict[str, str]],
+) -> list[dict[str, str]]:
+ path = industry_root / "extracted" / "candidate_qualification_funnel.csv"
+ funnel = r4.read_csv(path)
+ role_by_pair = {(row["company_id"], row["track_code"]): row for row in role_rows}
+ candidate_by_pair = {(row["company_id"], row["track_code"]): row for row in candidates}
+ source_by_id = {row["doc_id"]: row for row in source_rows}
+ evidence_by_pair: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
+ for evidence in evidence_rows:
+ if evidence["subject_type"] == "COMPANY":
+ evidence_by_pair[(evidence["company_id"], evidence["track_code"])].append(evidence)
+ eligible_states = {"INCLUDED_T1", "INCLUDED_T2", "ELIGIBLE_NOT_SELECTED_BATCH001"}
+
+ for row in funnel:
+ pair = (row["company_id"], row["track_code"])
+ candidate = candidate_by_pair[pair]
+ role = role_by_pair.get(pair)
+ eligible = candidate["candidate_state"] in eligible_states
+ matching = [
+ evidence for evidence in evidence_by_pair.get(pair, [])
+ if candidate["direct_business_source_id"] and evidence["doc_id"] == candidate["direct_business_source_id"]
+ ]
+ if role:
+ row["page_level_verification_attempt"] = "REUSE_REPAIR004_FULL_PAGE_SEARCH_PLUS_REPAIR005_COMPANY_SELF_TARGET_BUCKET_ROLE_ADJUDICATION"
+ row["page_level_verification_result"] = role["final_role_decision"]
+ row["business_context_rule_result"] = (
+ "REPAIR005_COMPANY_SELF_TARGET_BUCKET_ROLE_CONFIRMED"
+ if role["final_role_decision"] == "ELIGIBLE_DIRECT_COMPANY_SELF_TARGET_BUCKET_ROLE"
+ else "REPAIR005_HELD_BY_ROLE_CONTEXT_OR_SEARCH_RESULT"
+ )
+ row["replay_status"] = "REPAIR005_FULL_858_ROLE_STATE_REPLAY_COMPLETED"
+ row["direct_business_source_id"] = candidate["direct_business_source_id"] if eligible else ""
+ for gate in ["direct_source_gate", "locator_gate", "company_evidence_fact_gate", "source_grade_gate", "exposure_specificity_gate"]:
+ row[gate] = "PASS" if eligible else "FAIL"
+ row["evidence_fact_ids"] = ";".join(sorted(e["evidence_fact_id"] for e in matching)) if eligible else ""
+ row["failed_gates"] = "" if eligible else "DIRECT_BUSINESS_SOURCE;PAGE_OR_TEXT_LOCATOR;COMPANY_EVIDENCE_FACT;SOURCE_GRADE_S_OR_A;EXPOSURE_SPECIFICITY"
+ row["eligibility_result"] = "ELIGIBLE" if eligible else "HELD_BY_EVIDENCE_GAP"
+ row["eligible_rank_in_bucket"] = candidate["selection_rank"] if eligible else ""
+ row["final_candidate_state"] = candidate["candidate_state"]
+ if eligible:
+ source = source_by_id[candidate["direct_business_source_id"]]
+ row["mechanical_sort_key"] = "|".join([
+ candidate["evidence_grade"], candidate["exposure_specificity"],
+ candidate["latest_disclosed_period"], source["publish_date"],
+ candidate["exchange_code"], candidate["security_code"],
+ ])
+ else:
+ row["mechanical_sort_key"] = ""
+ row["funnel_rule_version"] = ROLE_RULE_VERSION
+ row["review_status"] = REVIEW_STATUS
+ r4.write_csv(path, list(funnel[0]), funnel)
+ return funnel
+
+
+def update_classification_and_matrix(
+ industry_root: Path,
+ candidates: list[dict[str, str]],
+ evidence_rows: list[dict[str, str]],
+ source_rows: list[dict[str, str]],
+) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
+ candidate_by_pair = {(row["company_id"], row["track_code"]): row for row in candidates}
+ source_by_id = {row["doc_id"]: row for row in source_rows}
+ evidence_by_pair: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
+ for evidence in evidence_rows:
+ if evidence["subject_type"] == "COMPANY":
+ evidence_by_pair[(evidence["company_id"], evidence["track_code"])].append(evidence)
+
+ classification_path = industry_root / "extracted" / "classification_summary.csv"
+ classifications = r4.read_csv(classification_path)
+ eligible_states = {"INCLUDED_T1", "INCLUDED_T2", "ELIGIBLE_NOT_SELECTED_BATCH001"}
+ for row in classifications:
+ pair = (row["company_id"], row["track_code"])
+ candidate = candidate_by_pair[pair]
+ eligible = candidate["candidate_state"] in eligible_states
+ matching = [
+ evidence for evidence in evidence_by_pair.get(pair, [])
+ if candidate["direct_business_source_id"] and evidence["doc_id"] == candidate["direct_business_source_id"]
+ ]
+ row["classification_reason"] = candidate["include_or_exclude_reason"]
+ row["data_status"] = candidate["candidate_state"]
+ row["review_status"] = REVIEW_STATUS
+ if eligible and matching:
+ evidence = matching[0]
+ source = source_by_id[evidence["doc_id"]]
+ row["subject_type"] = "COMPANY_DIRECT_BUSINESS"
+ row["source_doc_id"] = evidence["doc_id"]
+ row["evidence_fact_id"] = evidence["evidence_fact_id"]
+ row["chain_node_id"] = candidate["chain_nodes"]
+ row["raw_pool_path"] = source["raw_pool_path"]
+ row["raw_file_sha256"] = source["file_sha256"]
+ else:
+ discovery_source = source_by_id.get(candidate["candidate_source_id"], {})
+ row["subject_type"] = "COMPANY_QUERY_HIT"
+ row["source_doc_id"] = discovery_source.get("doc_id", "")
+ row["evidence_fact_id"] = ""
+ row["chain_node_id"] = ""
+ row["raw_pool_path"] = discovery_source.get("raw_pool_path", "")
+ row["raw_file_sha256"] = discovery_source.get("file_sha256", "")
+ r4.write_csv(classification_path, list(classifications[0]), classifications)
+
+ selected = [row for row in candidates if row["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}]
+ matrix_path = industry_root / "extracted" / "newenergy_company_exposure_matrix.csv"
+ matrix_headers = list(r4.read_csv(matrix_path)[0])
+ matrix_rows: list[dict[str, str]] = []
+ for candidate in selected:
+ pair = (candidate["company_id"], candidate["track_code"])
+ evidence = next(
+ evidence for evidence in evidence_by_pair[pair]
+ if evidence["doc_id"] == candidate["direct_business_source_id"]
+ )
+ row = {
+ "mapping_id": f"MAP-{candidate['track_code']}-{candidate['security_code']}",
+ "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
+ "company_id": candidate["company_id"], "security_code": candidate["security_code"],
+ "security_name": candidate["security_name"], "legal_name": candidate["legal_name"],
+ "exchange_code": candidate["exchange_code"], "track_code": candidate["track_code"],
+ "chain_nodes": candidate["chain_nodes"], "selection_bucket": candidate["selection_bucket"],
+ "tier": candidate["tier"], "candidate_state": candidate["candidate_state"],
+ "direct_business_source_id": candidate["direct_business_source_id"],
+ "direct_business_locator": candidate["direct_business_locator"],
+ "evidence_fact_id": evidence["evidence_fact_id"], "evidence_grade": candidate["evidence_grade"],
+ "exposure_specificity": candidate["exposure_specificity"],
+ "latest_disclosed_period": candidate["latest_disclosed_period"],
+ "primary_region": "MAINLAND_CHINA", "scope_status": "CORE_SCOPE_DIRECT_BUSINESS",
+ "coverage_claim": "NONE_INITIAL_CANDIDATE_POOL_ONLY",
+ "data_status": "VERIFIED_PUBLIC", "review_status": REVIEW_STATUS,
+ }
+ matrix_rows.append({key: row.get(key, "") for key in matrix_headers})
+ r4.write_csv(matrix_path, matrix_headers, matrix_rows)
+ return classifications, matrix_rows
+
+
+def build_manifests(
+ industry_root: Path,
+ source_rows: list[dict[str, str]],
+ conversion_rows: list[dict[str, str]],
+ evidence_rows: list[dict[str, str]],
+ candidates: list[dict[str, str]],
+ case_map: list[dict[str, str]],
+) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
+ base.COLLECTED_AT = max(
+ (row["collected_at"] for row in source_rows if row.get("collected_at")),
+ default="2026-08-06T00:00:00+08:00",
+ )
+ base.ARTIFACT_TOOL_VERSION = TOOL_VERSION
+ base.ARTIFACT_PARAMETERS_SUMMARY = (
+ "B1 REPAIR005; reuse 729 acquired PDFs; replay 858 pairs through the company-self and "
+ "target-bucket semantic role gate; clear non-eligible derived fields and dangling evidence FKs; "
+ "mechanical rerank; exact-set hash coverage"
+ )
+ base.ARTIFACT_STATUS = "READY_FOR_REPAIR005_FOCUSED_REREVIEW"
+ base.build_input_manifests(source_rows)
+ base.build_source_gap_audit(source_rows, evidence_rows)
+ universe_rows = r4.read_csv(industry_root / "extracted" / "a_share_universe.csv")
+ base.rewrite_batch_summary(candidates, len(source_rows), len(conversion_rows), universe_rows)
+ base.build_human_receipt(case_map, len(source_rows))
+ output_rows = base.rebuild_output_manifest()
+ artifact_rows = base.build_artifact_manifest(source_rows, output_rows, Path(__file__))
+ return output_rows, artifact_rows
+
+
+def split_evidence_ids(value: str) -> list[str]:
+ return [item.strip() for item in (value or "").split(";") if item.strip()]
+
+
+def validate_and_write_receipt(
+ industry_root: Path,
+ role_rows: list[dict[str, str]],
+ prestate_rows: list[dict[str, str]],
+ source_rows: list[dict[str, str]],
+ conversion_rows: list[dict[str, str]],
+ evidence_rows: list[dict[str, str]],
+ candidates: list[dict[str, str]],
+ funnel: list[dict[str, str]],
+ classifications: list[dict[str, str]],
+ matrix_rows: list[dict[str, str]],
+ case_map: list[dict[str, str]],
+) -> dict[str, Any]:
+ errors: list[str] = []
+ eligible_states = {"INCLUDED_T1", "INCLUDED_T2", "ELIGIBLE_NOT_SELECTED_BATCH001"}
+ evidence_ids = {row["evidence_fact_id"] for row in evidence_rows}
+ source_ids = {row["doc_id"] for row in source_rows}
+ conversion_ids = {row["source_doc_id"] for row in conversion_rows}
+ candidate_by_pair = {(row["company_id"], row["track_code"]): row for row in candidates}
+
+ role_counts = Counter(row["final_role_decision"] for row in role_rows)
+ expected_role_counts = {
+ "ELIGIBLE_DIRECT_COMPANY_SELF_TARGET_BUCKET_ROLE": 40,
+ "HELD_ROLE_MISMATCH_ADJACENT_PROCUREMENT_OR_SELF_USE": 10,
+ "HELD_KEYWORD_CONTEXT_ONLY_AFTER_FULL_PAGE_SEARCH": 221,
+ "HELD_TRUE_NEGATIVE_AFTER_FULL_PAGE_SEARCH": 587,
+ }
+ if dict(role_counts) != expected_role_counts:
+ errors.append(f"role distribution mismatch: {dict(role_counts)}")
+ if len(role_rows) != 858 or len({row["qualification_row_id"] for row in role_rows}) != 858:
+ errors.append("role adjudication receipt is not exact 858")
+ if any(row["rule_version"] != ROLE_RULE_VERSION or row["tool_version"] != TOOL_VERSION for row in role_rows):
+ errors.append("role receipt rule/tool version mismatch")
+
+ candidate_counts = Counter(row["candidate_state"] for row in candidates)
+ expected_candidate_counts = {
+ "INCLUDED_T1": 16,
+ "INCLUDED_T2": 16,
+ "ELIGIBLE_NOT_SELECTED_BATCH001": 40,
+ "HELD_BY_EVIDENCE_GAP": 4479,
+ }
+ if dict(candidate_counts) != expected_candidate_counts:
+ errors.append(f"candidate distribution mismatch: {dict(candidate_counts)}")
+ if len(candidates) != 4551 or len({(row["company_id"], row["track_code"]) for row in candidates}) != 4551:
+ errors.append("candidate ledger pair set is not exact 4551")
+
+ noneligible_derived_fields = [
+ "chain_nodes", "direct_business_source_id", "direct_business_locator", "evidence_grade",
+ "exposure_specificity", "latest_disclosed_period", "selection_rank", "tier", "tie_break_rule",
+ ]
+ polluted_candidates = [
+ row for row in candidates if row["candidate_state"] not in eligible_states
+ and any(row.get(field, "") for field in noneligible_derived_fields)
+ ]
+ if polluted_candidates:
+ errors.append(f"noneligible candidate derived-field pollution={len(polluted_candidates)}")
+ polluted_classifications = [
+ row for row in classifications if row["data_status"] not in eligible_states
+ and (
+ row["subject_type"] == "COMPANY_DIRECT_BUSINESS"
+ or bool(row["evidence_fact_id"])
+ or row["source_doc_id"].startswith("S-QUAL-")
+ or bool(row["chain_node_id"])
+ )
+ ]
+ if polluted_classifications:
+ errors.append(f"noneligible classification pollution={len(polluted_classifications)}")
+
+ for qid in REJECTED_ROLE_DECISIONS:
+ role = next(row for row in role_rows if row["qualification_row_id"] == qid)
+ candidate = candidate_by_pair[(role["company_id"], role["track_code"])]
+ if candidate["candidate_state"] != "HELD_BY_EVIDENCE_GAP" or any(
+ candidate.get(field, "") for field in noneligible_derived_fields
+ ):
+ errors.append(f"rejected role candidate not clean: {qid}")
+
+ selected_groups: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
+ for candidate in candidates:
+ if candidate["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}:
+ selected_groups[(candidate["track_code"], candidate["selection_bucket"])].append(candidate)
+ if len(selected_groups) != 16 or any(
+ sorted(row["candidate_state"] for row in rows) != ["INCLUDED_T1", "INCLUDED_T2"]
+ for rows in selected_groups.values()
+ ):
+ errors.append("16-bucket T1/T2 coverage mismatch")
+
+ if source_ids != conversion_ids or len(source_rows) != 786 or len(conversion_rows) != 786:
+ errors.append(f"source/conversion exact set mismatch {len(source_rows)}/{len(conversion_rows)}")
+ if any(row["direct_business_source_id"] not in source_ids for row in candidates if row["candidate_state"] in eligible_states):
+ errors.append("eligible candidate source FK dangling")
+ if any(evidence_id not in evidence_ids for row in funnel for evidence_id in split_evidence_ids(row["evidence_fact_ids"])):
+ errors.append("funnel evidence FK dangling")
+ if any(row["evidence_fact_id"] and row["evidence_fact_id"] not in evidence_ids for row in classifications):
+ errors.append("classification evidence FK dangling")
+ if any(row["evidence_fact_id"] not in evidence_ids for row in matrix_rows):
+ errors.append("exposure matrix evidence FK dangling")
+ if any(row["evidence_fact_id"] not in evidence_ids for row in case_map):
+ errors.append("case map evidence FK dangling")
+
+ new_qual_ids = {row["evidence_fact_id"] for row in evidence_rows if row["evidence_fact_id"].startswith("EVF-QUAL-")}
+ if len(new_qual_ids) != 40 or any(not evidence_id.endswith("-R005") for evidence_id in new_qual_ids):
+ errors.append(f"REPAIR005 qualification evidence exact set mismatch={len(new_qual_ids)}")
+ if len(matrix_rows) != 32 or len(case_map) != 110:
+ errors.append(f"matrix/map count mismatch {len(matrix_rows)}/{len(case_map)}")
+ if len(funnel) != 4551:
+ errors.append(f"funnel count mismatch={len(funnel)}")
+ if len(prestate_rows) != 8:
+ errors.append(f"direct-claim pollution prestate snapshot mismatch={len(prestate_rows)}")
+
+ core_hashes = {
+ "role_adjudication_receipt": r4.sha256_file(role_receipt_path(industry_root)),
+ "candidate_ledger": r4.sha256_file(industry_root / "extracted" / "company_track_candidate_ledger.csv"),
+ "qualification_funnel": r4.sha256_file(industry_root / "extracted" / "candidate_qualification_funnel.csv"),
+ "classification_summary": r4.sha256_file(industry_root / "extracted" / "classification_summary.csv"),
+ "evidence_fact_table": r4.sha256_file(industry_root / "evidence" / "evidence_fact_table.csv"),
+ "exposure_matrix": r4.sha256_file(industry_root / "extracted" / "newenergy_company_exposure_matrix.csv"),
+ "case_evidence_map": r4.sha256_file(industry_root / CASE_ID / "evidence" / "case_evidence_map.csv"),
+ "pollution_prestate_snapshot": r4.sha256_file(prestate_path(industry_root)),
+ }
+ receipt = {
+ "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
+ "tool_version": TOOL_VERSION, "rule_version": ROLE_RULE_VERSION,
+ "generated_at": role_rows[0]["adjudicated_at"], "review_status": REVIEW_STATUS,
+ "scope": "B1_REPAIR005_ONLY_REUSE_729_PDFS_FULL_858_ROLE_STATE_REPLAY",
+ "role_status_distribution": dict(sorted(role_counts.items())),
+ "candidate_state_distribution": dict(sorted(candidate_counts.items())),
+ "prestate_direct_claim_pollution_rows": len(prestate_rows),
+ "poststate_noneligible_candidate_pollution_rows": len(polluted_candidates),
+ "poststate_noneligible_classification_pollution_rows": len(polluted_classifications),
+ "dangling_fk_count": sum("FK dangling" in error for error in errors),
+ "selected_bucket_count": len(selected_groups), "selected_mapping_count": len(matrix_rows),
+ "source_count": len(source_rows), "conversion_count": len(conversion_rows),
+ "evidence_fact_count": len(evidence_rows), "case_map_count": len(case_map),
+ "core_hashes": core_hashes, "validation_errors": errors,
+ "validation_status": "PASS" if not errors else "FAIL",
+ }
+ path = validation_path(industry_root)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(receipt, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
+ if errors:
+ raise RuntimeError("REPAIR005 validation failed: " + "; ".join(errors))
+ return receipt
+
+
+def validate_artifacts(
+ industry_root: Path,
+ source_rows: list[dict[str, str]],
+ output_rows: list[dict[str, str]],
+ artifact_rows: list[dict[str, str]],
+) -> dict[str, Any]:
+ formal = base.formal_files(Path(__file__))
+ formal_by_rel = {base.rel(path): path for path in formal}
+ artifact_by_rel = {row["relative_path"]: row for row in artifact_rows}
+ errors: list[str] = []
+ if set(formal_by_rel) != set(artifact_by_rel):
+ errors.append(
+ f"artifact exact set diff missing={sorted(set(formal_by_rel) - set(artifact_by_rel))[:5]} "
+ f"extra={sorted(set(artifact_by_rel) - set(formal_by_rel))[:5]}"
+ )
+ for relative_path, path in formal_by_rel.items():
+ artifact = artifact_by_rel.get(relative_path)
+ if not artifact:
+ continue
+ if artifact["file_size"] != str(path.stat().st_size) or artifact["sha256"] != r4.sha256_file(path):
+ errors.append(f"artifact hash mismatch {relative_path}")
+ break
+ if any(row["tool_version"] != TOOL_VERSION for row in artifact_rows):
+ errors.append("artifact tool version is not REPAIR-005")
+ for row in output_rows:
+ path = base.PROJECT_ROOT / row["output_path"]
+ if not path.exists() or row["output_sha256"] != r4.sha256_file(path):
+ errors.append(f"output hash mismatch {row['output_path']}")
+ break
+ if len(source_rows) != 786 or len(output_rows) != 24:
+ errors.append(f"source/output count mismatch {len(source_rows)}/{len(output_rows)}")
+ if errors:
+ raise RuntimeError("REPAIR005 artifact validation failed: " + "; ".join(errors))
+ return {
+ "formal_exact_set": len(formal), "artifact_rows": len(artifact_rows),
+ "output_rows": len(output_rows),
+ "artifact_manifest_sha256": r4.sha256_file(industry_root / "manifest" / "artifact_manifest.csv"),
+ "output_manifest_sha256": r4.sha256_file(
+ industry_root / CASE_ID / "manifest" / "output_manifest.csv"
+ ),
+ }
+
+
+def run() -> dict[str, Any]:
+ project_root = Path.cwd().resolve()
+ industry_root = r4.find_industry_root(project_root)
+ source_rows = r4.read_csv(industry_root / "manifest" / "source_document.csv")
+ conversion_rows = r4.read_csv(industry_root / "manifest" / "conversion_status.csv")
+ pair_rows = r4.read_csv(industry_root / "extracted" / "candidate_page_qualification_receipt.csv")
+ candidates_before = r4.read_csv(industry_root / "extracted" / "company_track_candidate_ledger.csv")
+ classifications_before = r4.read_csv(industry_root / "extracted" / "classification_summary.csv")
+ prestate_rows = write_prestate_snapshot_once(industry_root, candidates_before, classifications_before)
+ role_rows = build_role_adjudication_receipt(industry_root, pair_rows, candidates_before)
+ evidence_rows, candidates = build_evidence_and_candidates(industry_root, role_rows, source_rows)
+ funnel = update_funnel(industry_root, role_rows, candidates, evidence_rows, source_rows)
+ classifications, matrix_rows = update_classification_and_matrix(
+ industry_root, candidates, evidence_rows, source_rows,
+ )
+ case_map = r4.write_company_outputs_and_map(industry_root, candidates, evidence_rows, source_rows)
+ validation = validate_and_write_receipt(
+ industry_root, role_rows, prestate_rows, source_rows, conversion_rows, evidence_rows,
+ candidates, funnel, classifications, matrix_rows, case_map,
+ )
+ output_rows, artifact_rows = build_manifests(
+ industry_root, source_rows, conversion_rows, evidence_rows, candidates, case_map,
+ )
+ artifact_validation = validate_artifacts(industry_root, source_rows, output_rows, artifact_rows)
+ return {
+ "status": "REPAIR005_FORMAL_PACKAGE_REBUILT_DRAFT_FOR_REVIEW",
+ "role_status_distribution": validation["role_status_distribution"],
+ "candidate_state_distribution": validation["candidate_state_distribution"],
+ "dangling_fk_count": validation["dangling_fk_count"],
+ "prestate_pollution_rows": validation["prestate_direct_claim_pollution_rows"],
+ "poststate_pollution_rows": (
+ validation["poststate_noneligible_candidate_pollution_rows"]
+ + validation["poststate_noneligible_classification_pollution_rows"]
+ ),
+ "sources": len(source_rows), "conversions": len(conversion_rows),
+ "evidence_facts": len(evidence_rows), "candidates": len(candidates),
+ "selected": len(matrix_rows), "case_map": len(case_map),
+ **artifact_validation,
+ "role_receipt_sha256": r4.sha256_file(role_receipt_path(industry_root)),
+ "validation_receipt_sha256": r4.sha256_file(validation_path(industry_root)),
+ }
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.parse_args()
+ result = run()
+ print(json.dumps(result, ensure_ascii=False, sort_keys=True, indent=2))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/ana-data/tools/newenergy_batch002_accept.py b/ana-data/tools/newenergy_batch002_accept.py
new file mode 100644
index 0000000..6054491
--- /dev/null
+++ b/ana-data/tools/newenergy_batch002_accept.py
@@ -0,0 +1,509 @@
+#!/usr/bin/env python3
+"""Apply the independently approved L0 acceptance sync for NEWENERGY BATCH-002.
+
+The script is deliberately scoped to the frozen BATCH-002 artifact set. It
+updates review/package state, recalculates hashes affected by those state-only
+changes, and creates acceptance receipts. It does not alter research facts,
+evidence wording, candidate selection, the accepted BATCH-001 package, or the
+industry-root current release.
+"""
+
+from __future__ import annotations
+
+import csv
+import hashlib
+import json
+import re
+from copy import deepcopy
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+from zoneinfo import ZoneInfo
+
+
+TASK_ID = "TASK-NEWENERGY-FOUR-TRACK-ATLAS-BATCH002-20260806-001"
+CASE_ID = "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260806-002"
+BATCH_ID = "BATCH-002"
+RUN_ID = "RUN-ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260806-002-BATCH-002-001"
+FINAL_AUDIT = "AUDIT-ANA-NEWENERGY-FOUR-TRACK-ATLAS-BATCH002-EXECUTION-OUTPUT-REPAIR004-REREVIEW-20260807-001"
+ACCEPTED = "ACCEPTED_BY_INDEPENDENT_REVIEW"
+FINAL_OUTPUT = "FINAL_ACCEPTED"
+FINAL_ARTIFACT = "FINAL_ACCEPTED_BY_INDEPENDENT_REVIEW"
+PARENT_STATUS = "PENDING_PROJECT_ADMIN_APPEND_ONLY_TERMINAL_SYNC"
+
+ROOT = Path(__file__).resolve().parents[2]
+INDUSTRY = ROOT / "ana-data/cases/新能源案例"
+CASE = INDUSTRY / CASE_ID
+RESULT = ROOT / "ana-data/result/新能源案例" / CASE_ID
+ARTIFACT_MANIFEST = INDUSTRY / "manifest/artifact_manifest_BATCH002.csv"
+OUTPUT_MANIFEST = CASE / "manifest/output_manifest.csv"
+EXECUTION_RECEIPT = CASE / "manifest/execution_validation_receipt.json"
+ACCEPTANCE_RECEIPT = CASE / "manifest/acceptance_validation_receipt.json"
+ACCEPTANCE_RECORD = RESULT / "acceptance_record.md"
+CURRENT_STABLE_MANIFEST = INDUSTRY / "manifest/current_output_manifest.csv"
+
+AUDITED_HASHES = {
+ ROOT / "ana-data/tools/newenergy_batch002_build.py": "537C8826CE2F49DEFA188E1E308643E816E5D3B3C8E34A6BCB1B02883FB5D389",
+ INDUSTRY / "manifest/legacy_to_v1_adapter_contract_BATCH002.csv": "57F232EB9FF61893FE6D6130684A0BED01E9D2B6B6E76719691A9E5523DAB954",
+ INDUSTRY / "manifest/legacy_to_v1_projection_validation_BATCH002.csv": "A332F0C8F11AF8DDA9E5D77D389EF34502D2ABDB9D4B3663F6CA36D8A2D98055",
+ INDUSTRY / "manifest/legacy_period_projection_receipt_BATCH002.csv": "A25D32490C7DEFA14024B1FA46C6DA3CDE323A5CDBEF36D3C84D1DF6ECA2C45D",
+ INDUSTRY / "manifest/canonical_shard_registry_BATCH002.csv": "D656AB6C5B5AF4FE2632FAC8F54C6239C4894D5DA81D150588FE67A3B52DAFAE",
+ ARTIFACT_MANIFEST: "A3D9933AFBB4D2066290ACD5679A9AACE986985148BB2668FA430E38871C2B25",
+ OUTPUT_MANIFEST: "B074F15BFF21EB7B3872369B26A0E20E245782BB8C852BAA2D2A0994660174DE",
+ CASE / "evidence/case_evidence_map.csv": "27666694FA5A38E29AB518C19A970E20F5D7CE091DCAD15C0EB29CFA9CC7EF70",
+ EXECUTION_RECEIPT: "41D663B73702CFAAD0C22AF59514062484EFB22FA3AE59DB61CDBE11373F2299",
+}
+
+IMMUTABLE_B001 = {
+ INDUSTRY / "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001/manifest/output_manifest.csv": "D0DC978D558CA56C9D32B9DC0F76489EDB0F643DB1B482585EF2D96DEE3DF6C4",
+ ROOT / "ana-data/result/新能源案例/ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001/acceptance_record.md": "448E67471C53E1E59011A1ADBB44D2ED0BDA0068084D843251E2CCCD4A407CFA",
+ CURRENT_STABLE_MANIFEST: "A2BEAF2CD0D3955ACF9DE8C6769D4BC9A3A7A1094FDD28C8447B66FD09372EB7",
+}
+
+
+def sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest().upper()
+
+
+def project_relative(path: Path) -> str:
+ return path.resolve().relative_to(ROOT.resolve()).as_posix()
+
+
+def read_csv(path: Path) -> tuple[list[str], list[dict[str, str]]]:
+ with path.open("r", encoding="utf-8-sig", newline="") as handle:
+ reader = csv.DictReader(handle)
+ return list(reader.fieldnames or []), list(reader)
+
+
+def write_csv(path: Path, headers: list[str], rows: list[dict[str, Any]]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("w", encoding="utf-8", newline="") as handle:
+ writer = csv.DictWriter(handle, fieldnames=headers, extrasaction="ignore", lineterminator="\n")
+ writer.writeheader()
+ writer.writerows(rows)
+
+
+def write_json(path: Path, value: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8", newline="\n")
+
+
+def accepted_at() -> str:
+ if ACCEPTANCE_RECORD.exists():
+ match = re.search(r"^- `accepted_at=([^`]+)`$", ACCEPTANCE_RECORD.read_text(encoding="utf-8"), flags=re.M)
+ if match:
+ return match.group(1)
+ return datetime.now(ZoneInfo("Asia/Shanghai")).replace(microsecond=0).isoformat()
+
+
+def audit_gate() -> None:
+ report = (ROOT / "ana-doc/新能源案例/案例审计报告.md").read_text(encoding="utf-8")
+ marker = f"## 2026-08-07 `{FINAL_AUDIT}`"
+ start = report.find(marker)
+ if start < 0:
+ raise RuntimeError("final audit section missing")
+ tail = report[start:]
+ end = tail.find("\n## ", 1)
+ section = tail if end < 0 else tail[:end]
+ required = (
+ "总体结论:`PASS / REPAIR004 聚焦复审通过`",
+ "允许分析员按项目规范把本批 outputs、canonical shard、manifest 和 result 入口",
+ )
+ if any(token not in section for token in required):
+ raise RuntimeError("final audit does not release the acceptance sync")
+
+
+def preflight() -> tuple[list[str], list[dict[str, str]]]:
+ audit_gate()
+ for path, expected in {**AUDITED_HASHES, **IMMUTABLE_B001}.items():
+ if not path.is_file() or sha256(path) != expected:
+ raise RuntimeError(f"preflight hash drift: {project_relative(path)}")
+ headers, artifacts = read_csv(ARTIFACT_MANIFEST)
+ if len(artifacts) != 83 or len({row["relative_path"] for row in artifacts}) != 83:
+ raise RuntimeError("audited artifact exact-set is not 83 unique paths")
+ for row in artifacts:
+ path = ROOT / row["relative_path"]
+ if not path.is_file() or path.stat().st_size != int(row["file_size"]) or sha256(path) != row["sha256"].upper():
+ raise RuntimeError(f"audited artifact mismatch: {row['relative_path']}")
+ stable_rows = read_csv(CURRENT_STABLE_MANIFEST)[1]
+ if any("BATCH-002" in row.get("source_path", "") or CASE_ID in row.get("source_path", "") for row in stable_rows):
+ raise RuntimeError("BATCH-002 already entered current industry release")
+ return headers, artifacts
+
+
+def replace_required(path: Path, old: str, new: str) -> int:
+ text = path.read_text(encoding="utf-8")
+ count = text.count(old)
+ if count:
+ path.write_text(text.replace(old, new), encoding="utf-8", newline="\n")
+ elif new not in text:
+ raise RuntimeError(f"required marker missing: {project_relative(path)} :: {old}")
+ return count
+
+
+def sync_markdown(artifacts: list[dict[str, str]]) -> dict[str, int]:
+ md_paths = [ROOT / row["relative_path"] for row in artifacts if row["file_ext"] == ".md"]
+ if len(md_paths) != 42:
+ raise RuntimeError(f"audited markdown count changed: {len(md_paths)}/42")
+ counts = {"markdown_files": len(md_paths), "review_markers": 0, "terminal_sentences": 0}
+ for path in md_paths:
+ counts["review_markers"] += replace_required(
+ path, "- review_status: `DRAFT_FOR_REVIEW`", f"- review_status: `{ACCEPTED}`"
+ )
+
+ terminal = {
+ CASE / "outputs/summary.md": (
+ "- 输出状态仍为 `DRAFT_FOR_REVIEW`,等待独立执行/输出审核。",
+ f"- 本批执行与输出已由独立审核 `{FINAL_AUDIT}` 以 `PASS / 0 / 0` 接受;接受不扩展研究边界或完整覆盖声明。",
+ ),
+ CASE / "outputs/新能源报告索引.md": (
+ "当前全部产物为 `DRAFT_FOR_REVIEW`,独立执行/输出审核通过前不得标记完成或对外交付。",
+ f"当前全部产物已由独立审核 `{FINAL_AUDIT}` 接受;本入口保留逐批审计血缘,行业默认阅读入口仍由行业根原子 promotion 单独控制。",
+ ),
+ RESULT / "result_index.md": (
+ "当前状态:`DRAFT_FOR_REVIEW`;独立执行/输出审核通过前不构成正式交付。",
+ f"当前状态:`{ACCEPTED}`;接受审计:`{FINAL_AUDIT}=PASS`。本逐批入口仅保留审计血缘,不替代行业根当前成果入口。",
+ ),
+ CASE / "manifest/batch_summary.md": (
+ "执行/输出独立审核通过前不得回写父级终态或对外交付。",
+ f"执行/输出独立审核 `{FINAL_AUDIT}` 已通过;父级 append-only 终态由项目管理员同步,行业根发布仍走独立 candidate/promotion。",
+ ),
+ }
+ for path, (old, new) in terminal.items():
+ counts["terminal_sentences"] += replace_required(path, old, new)
+
+ package = CASE / "manifest/package_exact_set_receipt.md"
+ counts["package_count"] = replace_required(
+ package, "- expected_artifact_exact_set_count: `83`", "- expected_artifact_exact_set_count: `85`"
+ )
+ counts["package_audit"] = replace_required(
+ package,
+ "exact-set 包括本批新行业 shard、新 raw/converted、supplement 查询回执、案例 outputs/manifest/evidence、result 入口和获批 replay tool;不包括空 img/tmp 目录,也不复制前批 artifacts。",
+ f"exact-set 包括本批新行业 shard、新 raw/converted、supplement 查询回执、案例 outputs/manifest/evidence、result 入口、接受记录、接受验证回执和获批 replay tool;独立接受审计为 `{FINAL_AUDIT}=PASS`,不包括空 img/tmp 目录,也不复制前批 artifacts。",
+ )
+ leftovers = [(project_relative(path), path.read_text(encoding="utf-8").count("DRAFT_FOR_REVIEW")) for path in md_paths]
+ leftovers = [item for item in leftovers if item[1]]
+ if leftovers:
+ raise RuntimeError(f"draft markdown markers remain: {leftovers[:3]}")
+ return counts
+
+
+def sync_json_review_status(value: Any) -> Any:
+ if isinstance(value, dict):
+ return {
+ key: ACCEPTED if key == "review_status" and item == "DRAFT_FOR_REVIEW" else sync_json_review_status(item)
+ for key, item in value.items()
+ }
+ if isinstance(value, list):
+ return [sync_json_review_status(item) for item in value]
+ return value
+
+
+def sync_structured_statuses(artifacts: list[dict[str, str]]) -> dict[str, int]:
+ counts = {"csv_files": 0, "csv_rows": 0, "json_files": 0, "review_rows": 0, "sample_rows": 0}
+ for artifact in artifacts:
+ path = ROOT / artifact["relative_path"]
+ if path == ARTIFACT_MANIFEST:
+ continue
+ if artifact["file_ext"] == ".csv":
+ headers, rows = read_csv(path)
+ changed = False
+ for row in rows:
+ if "review_status" in headers and row.get("review_status") == "DRAFT_FOR_REVIEW":
+ row["review_status"] = ACCEPTED
+ counts["review_rows"] += 1
+ changed = True
+ if "sample_status" in headers and row.get("sample_status") == "READY_FOR_INDEPENDENT_REVIEW":
+ row["sample_status"] = ACCEPTED
+ counts["sample_rows"] += 1
+ changed = True
+ if changed:
+ write_csv(path, headers, rows)
+ counts["csv_files"] += 1
+ counts["csv_rows"] += len(rows)
+ elif artifact["file_ext"] == ".json":
+ value = json.loads(path.read_text(encoding="utf-8"))
+ updated = sync_json_review_status(value)
+ if updated != value:
+ write_json(path, updated)
+ counts["json_files"] += 1
+ return counts
+
+
+def update_output_manifest() -> list[dict[str, str]]:
+ headers, rows = read_csv(OUTPUT_MANIFEST)
+ if len(rows) != 42:
+ raise RuntimeError("output manifest is not 42 rows")
+ applicable = 0
+ not_applicable = 0
+ for row in rows:
+ row["review_status"] = ACCEPTED
+ if row["applicability"] == "APPLICABLE_FILE_OUTPUT":
+ output = ROOT / row["output_path"]
+ if not output.is_file():
+ raise FileNotFoundError(output)
+ row["file_size"] = str(output.stat().st_size)
+ row["sha256"] = sha256(output)
+ row["output_status"] = FINAL_OUTPUT
+ applicable += 1
+ elif row["output_status"] == "NOT_APPLICABLE":
+ not_applicable += 1
+ else:
+ raise RuntimeError(f"unexpected output applicability/status: {row['output_item_id']}")
+ if (applicable, not_applicable) != (40, 2):
+ raise RuntimeError(f"output distribution changed: {applicable}/{not_applicable}")
+ write_csv(OUTPUT_MANIFEST, headers, rows)
+ return rows
+
+
+def update_projection_receipts() -> None:
+ adapter = INDUSTRY / "manifest/legacy_to_v1_adapter_contract_BATCH002.csv"
+ period = INDUSTRY / "manifest/legacy_period_projection_receipt_BATCH002.csv"
+ projection = INDUSTRY / "manifest/legacy_to_v1_projection_validation_BATCH002.csv"
+ headers, rows = read_csv(projection)
+ for row in rows:
+ shard = ROOT / row["batch002_shard_path"]
+ row["batch002_shard_sha256"] = sha256(shard)
+ row["adapter_contract_sha256"] = sha256(adapter)
+ if row.get("period_projection_receipt_path") not in ("", "NOT_APPLICABLE"):
+ row["period_projection_receipt_sha256"] = sha256(period)
+ row["review_status"] = ACCEPTED
+ write_csv(projection, headers, rows)
+
+
+def update_registry() -> None:
+ path = INDUSTRY / "manifest/canonical_shard_registry_BATCH002.csv"
+ headers, rows = read_csv(path)
+ for row in rows:
+ shard = ROOT / row["shard_path"]
+ actual_rows = read_csv(shard)[1]
+ row["row_count"] = str(len(actual_rows))
+ row["sha256"] = sha256(shard)
+ row["review_status"] = ACCEPTED
+ if row["batch_id"] == BATCH_ID:
+ row["immutable"] = "YES_ACCEPTED_IMMUTABLE"
+ write_csv(path, headers, rows)
+
+
+def update_execution_receipt(at: str) -> None:
+ receipt = json.loads(EXECUTION_RECEIPT.read_text(encoding="utf-8"))
+ receipt["outputs_status"] = FINAL_OUTPUT
+ receipt["review_status"] = ACCEPTED
+ receipt["final_audit"] = FINAL_AUDIT
+ receipt["acceptance_transition"] = "L0_STATUS_AND_AFFECTED_HASH_SYNC_ONLY"
+ receipt["accepted_at"] = at
+ receipt["parent_terminal_status"] = PARENT_STATUS
+ receipt["legacy_adapter_contract_sha256"] = sha256(INDUSTRY / "manifest/legacy_to_v1_adapter_contract_BATCH002.csv")
+ receipt["legacy_period_projection_receipt_sha256"] = sha256(INDUSTRY / "manifest/legacy_period_projection_receipt_BATCH002.csv")
+ receipt["legacy_projection_validation_sha256"] = sha256(INDUSTRY / "manifest/legacy_to_v1_projection_validation_BATCH002.csv")
+ write_json(EXECUTION_RECEIPT, receipt)
+
+
+def write_acceptance_record(at: str, output_rows: list[dict[str, str]]) -> None:
+ text = f"""# {CASE_ID} 正式接受记录
+
+- `task_id={TASK_ID}`
+- `case_id={CASE_ID}`
+- `batch_id={BATCH_ID}`
+- `run_id={RUN_ID}`
+- `status={ACCEPTED}`
+- `accepted_at={at}`
+- `final_audit={FINAL_AUDIT}`
+- `audit_result=PASS/0/0`
+- `parent_status={PARENT_STATUS}`
+- `accepted_output_manifest=ana-data/cases/新能源案例/{CASE_ID}/manifest/output_manifest.csv`
+- `accepted_output_manifest_sha256={sha256(OUTPUT_MANIFEST)}`
+- `applicable_outputs={sum(row['output_status'] == FINAL_OUTPUT for row in output_rows)}`
+- `conditional_not_applicable_outputs={sum(row['output_status'] == 'NOT_APPLICABLE' for row in output_rows)}`
+
+## 接受范围
+
+本记录接受本 case/BATCH/run 的 16 个新能源子行业深化节点、32 份节点正文、三类顶层视图、9 条增量公司—赛道映射、六组 legacy-to-V1 只读投影、期间语义回执和 40 份实物输出。7 个证据不足节点继续保留 GAP,2 个条件逻辑数据集继续 `NOT_APPLICABLE`。
+
+独立复审 `{FINAL_AUDIT}` 已给出 `PASS / blocker 0 / non-blocking 0`。审核后只执行状态与受影响 hash 的 L0 同步;没有改变来源、证据原文、候选选择、结论强度、BATCH-001 接受包或行业根当前 release。
+
+## 边界
+
+本接受不放行下一批、完整覆盖、估值、行情、交易、收益、市场反向补漏、核电敏感内容或数据库写入。逐批 result 入口只保留审计血缘;若要进入行业默认阅读入口,必须另建行业根 candidate,并通过既有新能源审核与原子 promotion。
+"""
+ ACCEPTANCE_RECORD.parent.mkdir(parents=True, exist_ok=True)
+ ACCEPTANCE_RECORD.write_text(text, encoding="utf-8", newline="\n")
+
+
+def write_acceptance_validation(at: str, output_rows: list[dict[str, str]], sync_counts: dict[str, Any]) -> None:
+ validation = {
+ "schema_version": "NEWENERGY_BATCH002_ACCEPTANCE_VALIDATION_V1",
+ "task_id": TASK_ID,
+ "case_id": CASE_ID,
+ "batch_id": BATCH_ID,
+ "run_id": RUN_ID,
+ "status": ACCEPTED,
+ "accepted_at": at,
+ "final_audit": FINAL_AUDIT,
+ "transition_type": "L0_REVIEW_STATUS_AND_AFFECTED_HASH_SYNC_ONLY",
+ "outputs": {
+ "manifest_rows": len(output_rows),
+ "final_accepted": sum(row["output_status"] == FINAL_OUTPUT for row in output_rows),
+ "not_applicable": sum(row["output_status"] == "NOT_APPLICABLE" for row in output_rows),
+ "manifest_sha256": sha256(OUTPUT_MANIFEST),
+ },
+ "accepted_scope": {"subindustry_nodes": 16, "node_documents": 32, "incremental_company_track_mappings": 9, "gap_nodes": 7},
+ "sync_counts": sync_counts,
+ "core_hashes": {
+ "case_evidence_map": sha256(CASE / "evidence/case_evidence_map.csv"),
+ "canonical_registry": sha256(INDUSTRY / "manifest/canonical_shard_registry_BATCH002.csv"),
+ "projection_validation": sha256(INDUSTRY / "manifest/legacy_to_v1_projection_validation_BATCH002.csv"),
+ "period_projection_receipt": sha256(INDUSTRY / "manifest/legacy_period_projection_receipt_BATCH002.csv"),
+ "execution_validation": sha256(EXECUTION_RECEIPT),
+ "acceptance_record": sha256(ACCEPTANCE_RECORD),
+ },
+ "immutable_b001_hash_match": len(IMMUTABLE_B001),
+ "industry_current_release_changed": False,
+ "parent_terminal_status": PARENT_STATUS,
+ "validation_status": "PASS",
+ }
+ write_json(ACCEPTANCE_RECEIPT, validation)
+
+
+def rebuild_artifact_manifest(headers: list[str], old_rows: list[dict[str, str]], at: str) -> list[dict[str, str]]:
+ by_path = {row["relative_path"]: deepcopy(row) for row in old_rows}
+ additions = [ACCEPTANCE_RECEIPT, ACCEPTANCE_RECORD]
+ for index, path in enumerate(additions, start=84):
+ relative = project_relative(path)
+ by_path[relative] = {
+ "artifact_id": f"NEB2-ART-{index:04d}",
+ "task_id": TASK_ID,
+ "case_id": CASE_ID,
+ "batch_id": BATCH_ID,
+ "run_id": RUN_ID,
+ "artifact_type": "RECEIPT_OR_VALIDATION" if path.suffix == ".json" else "HUMAN_READABLE_OUTPUT_OR_RECEIPT",
+ "industry_case": "新能源案例",
+ "industry_id": "IND-NEWENERGY",
+ "subindustry_id": "",
+ "company_id": "",
+ "logical_path": relative,
+ "relative_path": relative,
+ "absolute_path": str(path.resolve()),
+ "file_name": path.name,
+ "file_ext": path.suffix.lower(),
+ "file_size": "",
+ "sha256": "",
+ "source_doc_id": "",
+ "source_url": "",
+ "source_collected_at": "",
+ "raw_pool_path": "",
+ "source_file_name": "",
+ "detected_type": path.suffix.lower().lstrip("."),
+ "archive_file_name": path.name,
+ "extension_added_by_archive_flag": "NO",
+ "extension_mismatch_flag": "NO",
+ "created_at": at,
+ "created_by": "case_analysis.analyst.new_energy",
+ "tool_or_method": "newenergy_batch002_accept.py",
+ "tool_version": "BATCH002-ACCEPT-V1",
+ "parameters_summary": "independent PASS terminal L0 status/hash sync only",
+ "source_snapshot_id": "BATCH002_ACCEPTANCE",
+ "artifact_status": FINAL_ARTIFACT,
+ "sensitivity_screen": "LEGAL_PUBLIC_CIVIL_NUCLEAR_HIGH_LEVEL_ONLY",
+ "schema_version": "NEWENERGY_EXTENSION_V1",
+ "review_status": ACCEPTED,
+ }
+ if len(by_path) != 85:
+ raise RuntimeError(f"post-acceptance artifact exact-set is not 85: {len(by_path)}")
+ rows: list[dict[str, str]] = []
+ for relative in sorted(by_path):
+ row = by_path[relative]
+ path = ROOT / relative
+ if not path.is_file():
+ raise FileNotFoundError(path)
+ row["file_size"] = str(path.stat().st_size)
+ row["sha256"] = sha256(path)
+ row["artifact_status"] = FINAL_ARTIFACT
+ row["review_status"] = ACCEPTED
+ if row.get("parameters_summary") == "exact released batch/run; immutable B001 references; DRAFT outputs":
+ row["parameters_summary"] = "exact released batch/run; immutable B001 references; independently accepted outputs"
+ rows.append(row)
+ write_csv(ARTIFACT_MANIFEST, headers, rows)
+ return rows
+
+
+def verify_accepted() -> dict[str, Any]:
+ for path, expected in IMMUTABLE_B001.items():
+ if sha256(path) != expected:
+ raise RuntimeError(f"immutable BATCH-001/current release drift: {project_relative(path)}")
+ output_rows = read_csv(OUTPUT_MANIFEST)[1]
+ if len(output_rows) != 42:
+ raise RuntimeError("accepted output manifest row count mismatch")
+ if sum(row["output_status"] == FINAL_OUTPUT for row in output_rows) != 40:
+ raise RuntimeError("accepted applicable output count mismatch")
+ if sum(row["output_status"] == "NOT_APPLICABLE" for row in output_rows) != 2:
+ raise RuntimeError("accepted N/A output count mismatch")
+ if any(row["review_status"] != ACCEPTED for row in output_rows):
+ raise RuntimeError("output review status mismatch")
+ for row in output_rows:
+ if row["applicability"] == "APPLICABLE_FILE_OUTPUT":
+ path = ROOT / row["output_path"]
+ if path.stat().st_size != int(row["file_size"]) or sha256(path) != row["sha256"].upper():
+ raise RuntimeError(f"accepted output hash mismatch: {row['output_path']}")
+
+ artifact_rows = read_csv(ARTIFACT_MANIFEST)[1]
+ if len(artifact_rows) != 85 or len({row["relative_path"] for row in artifact_rows}) != 85:
+ raise RuntimeError("accepted artifact exact-set mismatch")
+ for row in artifact_rows:
+ path = ROOT / row["relative_path"]
+ if not path.is_file() or path.stat().st_size != int(row["file_size"]) or sha256(path) != row["sha256"].upper():
+ raise RuntimeError(f"accepted artifact hash mismatch: {row['relative_path']}")
+ if row["artifact_status"] != FINAL_ARTIFACT or row["review_status"] != ACCEPTED:
+ raise RuntimeError(f"accepted artifact status mismatch: {row['relative_path']}")
+
+ stable_rows = read_csv(CURRENT_STABLE_MANIFEST)[1]
+ batch2_current = sum("BATCH-002" in row.get("source_path", "") or CASE_ID in row.get("source_path", "") for row in stable_rows)
+ if batch2_current:
+ raise RuntimeError("BATCH-002 polluted the current BATCH-001 industry release")
+ if not ACCEPTANCE_RECORD.is_file() or not ACCEPTANCE_RECEIPT.is_file():
+ raise RuntimeError("acceptance records missing")
+ return {
+ "status": ACCEPTED,
+ "final_audit": FINAL_AUDIT,
+ "output_manifest_rows": len(output_rows),
+ "final_accepted_outputs": 40,
+ "not_applicable_outputs": 2,
+ "artifact_exact_set": len(artifact_rows),
+ "output_manifest_sha256": sha256(OUTPUT_MANIFEST),
+ "artifact_manifest_sha256": sha256(ARTIFACT_MANIFEST),
+ "acceptance_record_sha256": sha256(ACCEPTANCE_RECORD),
+ "acceptance_validation_sha256": sha256(ACCEPTANCE_RECEIPT),
+ "immutable_b001_and_current_release_hash_match": len(IMMUTABLE_B001),
+ "batch002_current_release_paths": batch2_current,
+ "parent_status": PARENT_STATUS,
+ }
+
+
+def run() -> dict[str, Any]:
+ if ACCEPTANCE_RECORD.exists():
+ result = verify_accepted()
+ result["idempotent"] = True
+ return result
+ headers, artifacts = preflight()
+ at = accepted_at()
+ markdown_counts = sync_markdown(artifacts)
+ structured_counts = sync_structured_statuses(artifacts)
+ output_rows = update_output_manifest()
+ update_projection_receipts()
+ update_registry()
+ update_execution_receipt(at)
+ write_acceptance_record(at, output_rows)
+ write_acceptance_validation(at, output_rows, {"markdown": markdown_counts, "structured": structured_counts})
+ rebuild_artifact_manifest(headers, artifacts, at)
+ result = verify_accepted()
+ result["idempotent"] = False
+ result["accepted_at"] = at
+ return result
+
+
+if __name__ == "__main__":
+ print(json.dumps(run(), ensure_ascii=False, sort_keys=True, indent=2))
diff --git a/ana-data/tools/newenergy_batch002_build.py b/ana-data/tools/newenergy_batch002_build.py
new file mode 100644
index 0000000..c35dc36
--- /dev/null
+++ b/ana-data/tools/newenergy_batch002_build.py
@@ -0,0 +1,2210 @@
+from __future__ import annotations
+
+import csv
+import hashlib
+import json
+import re
+from collections import Counter, defaultdict
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Iterable
+
+
+ROOT = Path(__file__).resolve().parents[2]
+INDUSTRY = ROOT / "ana-data" / "cases" / "新能源案例"
+CASE_ID = "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260806-002"
+TASK_ID = "TASK-NEWENERGY-FOUR-TRACK-ATLAS-BATCH002-20260806-001"
+BATCH_ID = "BATCH-002"
+RUN_ID = "RUN-ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260806-002-BATCH-002-001"
+CASE = INDUSTRY / CASE_ID
+RESULT = ROOT / "ana-data" / "result" / "新能源案例" / CASE_ID
+IMG = ROOT / "ana-data" / "img" / "新能源案例" / CASE_ID
+TMP = ROOT / "ana-data" / "tmp" / "新能源案例" / CASE_ID / RUN_ID
+SCHEMA = "NEWENERGY_EXTENSION_V1"
+REVIEW = "DRAFT_FOR_REVIEW"
+SOURCE_CUTOFF = "2026-08-06T20:27:54+08:00"
+EXECUTED_AT = "2026-08-07T00:30:00+08:00"
+REPAIR_ID = "EXECUTION_OUTPUT_REPAIR001"
+LATEST_REPAIR_ID = "EXECUTION_OUTPUT_REPAIR004"
+QUERY_PROBE_PATH = INDUSTRY / "supplement" / "NEB2_external_public_query_probe_REPAIR001.json"
+QUERY_RECEIPT_PATH = INDUSTRY / "supplement" / "NEB2_external_public_query_receipt_BATCH002.csv"
+
+
+def rel(path: Path) -> str:
+ return path.relative_to(ROOT).as_posix()
+
+
+def sha256(path: Path) -> str:
+ h = hashlib.sha256()
+ with path.open("rb") as fh:
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
+ h.update(chunk)
+ return h.hexdigest().upper()
+
+
+def read_csv(path: Path) -> list[dict[str, str]]:
+ with path.open("r", encoding="utf-8-sig", newline="") as fh:
+ return list(csv.DictReader(fh))
+
+
+def write_csv(path: Path, rows: list[dict[str, Any]], fields: list[str] | None = None) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ if fields is None:
+ if not rows:
+ raise ValueError(f"refuse empty csv without schema: {path}")
+ fields = list(rows[0].keys())
+ with path.open("w", encoding="utf-8-sig", newline="") as fh:
+ writer = csv.DictWriter(fh, fieldnames=fields, extrasaction="ignore", lineterminator="\n")
+ writer.writeheader()
+ writer.writerows(rows)
+
+
+def write_text(path: Path, text: str) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text.rstrip() + "\n", encoding="utf-8", newline="\n")
+
+
+def write_json(path: Path, obj: Any) -> None:
+ write_text(path, json.dumps(obj, ensure_ascii=False, indent=2, sort_keys=True))
+
+
+def index_unique(rows: Iterable[dict[str, str]], key: str) -> dict[str, dict[str, str]]:
+ out: dict[str, dict[str, str]] = {}
+ for row in rows:
+ value = row[key]
+ if value in out:
+ raise ValueError(f"duplicate {key}: {value}")
+ out[value] = row
+ return out
+
+
+BASELINE_HASHES = {
+ INDUSTRY / "extracted" / "company_track_candidate_ledger.csv": "ABD3D0BF2CEF2D9D0225F2D37A9542A0899B75B127E829747C767FAED03E54E5",
+ INDUSTRY / "extracted" / "candidate_qualification_funnel.csv": "8FCC1C9563A3709E04EE747173A55DF76135B760E186529DC1F1DA8A07C0891A",
+ INDUSTRY / "extracted" / "candidate_role_adjudication_receipt.csv": "7E47068068E47EDDDF9B9B6766D5A8FBDF40CEB62FFF33508DBF65302F42D824",
+ INDUSTRY / "evidence" / "evidence_fact_table.csv": "B9FE3822781F776E93A57309E52794EF445B86820B96410B486E57A852540AC9",
+ INDUSTRY / "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001" / "evidence" / "case_evidence_map.csv": "CE6CFFB10BD3B11FD4E29B5381B814DAC66A8EE1E53BB9821805312D19AFD103",
+ INDUSTRY / "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001" / "manifest" / "output_manifest.csv": "D0DC978D558CA56C9D32B9DC0F76489EDB0F643DB1B482585EF2D96DEE3DF6C4",
+ ROOT / "ana-data" / "result" / "新能源案例" / "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001" / "acceptance_record.md": "448E67471C53E1E59011A1ADBB44D2ED0BDA0068084D843251E2CCCD4A407CFA",
+}
+
+for baseline_path, expected in BASELINE_HASHES.items():
+ if not baseline_path.exists():
+ raise FileNotFoundError(f"missing immutable baseline: {baseline_path}")
+ actual = sha256(baseline_path)
+ if actual != expected:
+ raise RuntimeError(f"immutable baseline drift: {rel(baseline_path)} {actual} != {expected}")
+
+
+for directory in (
+ CASE / "outputs" / "核心文档" / "子行业深化",
+ CASE / "manifest",
+ CASE / "evidence",
+ RESULT,
+ IMG,
+ TMP,
+ INDUSTRY / "supplement",
+):
+ directory.mkdir(parents=True, exist_ok=True)
+
+for forbidden in (CASE / "raw", CASE / "converted", CASE / "extracted", CASE / "supplement"):
+ if forbidden.exists():
+ raise RuntimeError(f"case storage split violated: {forbidden}")
+
+
+ledger = read_csv(INDUSTRY / "extracted" / "company_track_candidate_ledger.csv")
+funnel = read_csv(INDUSTRY / "extracted" / "candidate_qualification_funnel.csv")
+roles = read_csv(INDUSTRY / "extracted" / "candidate_role_adjudication_receipt.csv")
+evidence = read_csv(INDUSTRY / "evidence" / "evidence_fact_table.csv")
+source_docs = read_csv(INDUSTRY / "manifest" / "source_document.csv")
+conversions = read_csv(INDUSTRY / "manifest" / "conversion_status.csv")
+artifacts_b1 = read_csv(INDUSTRY / "manifest" / "artifact_manifest.csv")
+
+evidence_by_id = index_unique(evidence, "evidence_fact_id")
+source_by_id = index_unique(source_docs, "doc_id")
+conversion_by_source = {r["source_doc_id"]: r for r in conversions}
+artifact_by_rel = {r["relative_path"]: r for r in artifacts_b1}
+funnel_by_pair = {(r["company_id"], r["track_code"], r["selection_bucket"]): r for r in funnel}
+role_by_pair = {(r["company_id"], r["track_code"], r["selection_bucket"]): r for r in roles}
+ledger_by_pair = {(r["company_id"], r["track_code"], r["selection_bucket"]): r for r in ledger}
+
+
+NODES = [
+ {
+ "n": 1, "track": "BATTERY", "code": "BAT_RESOURCE_MATERIAL", "name": "资源与主材", "bucket": "资源与主材", "folder": "01_资源与主材",
+ "boundary": "纳入锂资源、锂盐、正极/负极、电解液和隔膜的直接生产经营;排除一般化工、采购方、客户和题材关联。",
+ "role": "上游资源与锂盐连接电芯材料成本,四大主材分别承担离子载体、电极反应、离子传导与安全隔离功能。",
+ "route": "锂盐—正负极—电解液—隔膜分层;不同材料、规格与应用场景不得合并为单一价格或产能口径。",
+ "market_evf": "EVF-MKT-BAT-03", "risk": "资源品价格和材料路线变化可能改变成本传导;规格、期间或税口径不同会使价格比较失真。",
+ },
+ {
+ "n": 2, "track": "BATTERY", "code": "BAT_CELL", "name": "电芯制造", "bucket": "电芯制造", "folder": "02_电芯制造",
+ "boundary": "纳入动力或储能电芯的研发、生产和销售;模组、Pack、整车和单纯电池材料分别分账。",
+ "role": "电芯把材料体系转化为可用电化学单元,是动力与储能系统的核心制造环节。",
+ "route": "动力与储能应用分账;磷酸铁锂、三元等路线按应用、安全、寿命与成本约束比较。",
+ "market_evf": "EVF-MKT-BAT-01", "risk": "产量增长不等于终端消化,库存、利用率、质量与安全信息仍需单独验证。",
+ },
+ {
+ "n": 3, "track": "BATTERY", "code": "BAT_SYSTEM_COMPONENT", "name": "系统/部件/BMS-Pack", "bucket": "系统/部件/BMS-Pack", "folder": "03_系统部件BMS-Pack",
+ "boundary": "纳入电芯模组、Pack、BMS、结构件和系统集成的直接业务;排除仅使用电池的终端产品。",
+ "role": "系统环节完成电芯成组、状态监测、热安全和应用适配,动力与储能的设计目标不可混用。",
+ "route": "从电芯到模组、Pack、BMS和系统集成逐层识别;分部不可拆时降低暴露结论强度。",
+ "market_evf": "EVF-MKT-BAT-02", "risk": "价值量受集成边界、外购比例和应用结构影响,不能由电池总量直接推导公司收入。",
+ },
+ {
+ "n": 4, "track": "BATTERY", "code": "BAT_EQUIPMENT_RECYCLE", "name": "设备与回收循环", "bucket": "设备与回收循环", "folder": "04_设备与回收循环",
+ "boundary": "纳入锂电前中后段专用设备、检测设备和电池回收处理的直接业务;通用施工、包装和自用项目不纳入。",
+ "role": "设备支撑制造工序,回收环节处理退役电池与生产废料;两者的订单、产能和经济性口径必须分开。",
+ "route": "设备按工序验证,回收按来源料、处理工艺与再生产品验证;仅有‘新能源’字样不足以确认角色。",
+ "market_evf": "EVF-MKT-BAT-01", "risk": "本批冻结候选池未找到可升级的直接主源,相关企业增量保持缺口。",
+ },
+ {
+ "n": 5, "track": "SOLAR", "code": "SOL_SILICON_WAFER", "name": "硅料/硅片与材料", "bucket": "硅料/硅片与材料", "folder": "05_硅料硅片与材料",
+ "boundary": "纳入多晶硅、拉晶、硅棒/硅片和直接关键材料;电池片、组件和电站业务另列。",
+ "role": "上游材料决定电池片的基础晶硅供给,产能必须区分名义、有效、在产与不同尺寸/导电类型。",
+ "route": "硅料—拉晶—切片分层,N/P 型和尺寸规格分账;不以总产能替代可售合格产出。",
+ "market_evf": "EVF-MKT-SOL-01", "risk": "装机需求不能直接推导硅片盈利,供给、价格、库存与技术切换均可能造成背离。",
+ },
+ {
+ "n": 6, "track": "SOLAR", "code": "SOL_CELL_MODULE", "name": "电池片/组件", "bucket": "电池片/组件", "folder": "06_电池片组件",
+ "boundary": "纳入太阳能电池片和组件的直接制造销售;玻璃等辅材、逆变器与电站运营分别归桶。",
+ "role": "电池片完成光电转换,组件完成封装和终端产品交付;技术效率、良率和出货口径需区分。",
+ "route": "电池技术与组件产品分账,产能、产量、出货、并网装机不能互相替代。",
+ "market_evf": "EVF-MKT-SOL-01", "risk": "快速路线迭代和供给扩张可能造成存货、减值与旧产线利用率风险。",
+ },
+ {
+ "n": 7, "track": "SOLAR", "code": "SOL_BALANCE_EQUIPMENT", "name": "设备/辅材/逆变器", "bucket": "设备/辅材/逆变器", "folder": "07_光伏设备辅材逆变器",
+ "boundary": "纳入光伏专用设备、辅材和逆变器直接业务;通用工业品潜在用途和项目采购不纳入。",
+ "role": "设备决定制造能力,辅材影响封装可靠性,逆变器承担直交流转换与系统控制,三者分别核验。",
+ "route": "按设备、玻璃/胶膜等辅材、逆变器三个子层记录,不用组件需求替代各自产品证据。",
+ "market_evf": "EVF-MKT-SOL-01", "risk": "需求联动存在时滞,设备订单、辅材出货与逆变器销售不能由装机量机械外推。",
+ },
+ {
+ "n": 8, "track": "SOLAR", "code": "SOL_SYSTEM_STATION", "name": "系统集成/电站建设运营", "bucket": "系统集成/电站建设运营", "folder": "08_光伏系统电站",
+ "boundary": "纳入光伏 EPC、开发、建设、持有运营与发电收入;设备采购和内部自用电站不等于对外系统业务。",
+ "role": "项目从开发、建设、并网到持有运营分阶段,工程收入和发电收入必须分账。",
+ "route": "项目阶段、所有权和收入确认口径是核心;规划、在建、并网和运营不得静默合并。",
+ "market_evf": "EVF-MKT-SOL-02", "risk": "装机增长不代表单个开发商收益,消纳、电价、融资和资产处置均会改变经营结果。",
+ },
+ {
+ "n": 9, "track": "WIND", "code": "WND_COMPONENT", "name": "材料与关键零部件", "bucket": "材料与关键零部件", "folder": "09_风电材料关键零部件",
+ "boundary": "纳入叶片、铸锻件、主轴、轴承、齿轮箱等直接制造;通用材料和相邻工业品不纳入。",
+ "role": "关键零部件连接整机技术平台与上游制造,产品规格、尺寸和认证决定可交付范围。",
+ "route": "按叶片、铸锻、传动和轴承等子层识别,海陆产品、尺寸与交付周期分账。",
+ "market_evf": "EVF-MKT-WIND-01", "risk": "大型化与海上化会改变材料、工艺和验证要求,旧规格产能不可直接视为有效供给。",
+ },
+ {
+ "n": 10, "track": "WIND", "code": "WND_TURBINE", "name": "整机", "bucket": "整机", "folder": "10_风电整机",
+ "boundary": "纳入风力发电整机研发、制造、销售与随整机形成的直接服务;运营商和通用电气设备另列。",
+ "role": "整机集成叶轮、传动、电气与控制系统,并承担交付、质保和部分运维责任。",
+ "route": "海陆机型、平台功率、订单、交付和质保口径分账;采购或使用风机不构成整机业务。",
+ "market_evf": "EVF-MKT-WIND-01", "risk": "本批空桶候选均未获得直接整机主源,不能由风电项目或发电业务反推整机制造。",
+ },
+ {
+ "n": 11, "track": "WIND", "code": "WND_TOWER_CABLE_ENGINEERING", "name": "塔筒/海缆/工程配套", "bucket": "塔筒/海缆/工程配套", "folder": "11_塔筒海缆工程配套",
+ "boundary": "纳入塔筒、基础、海缆、吊装和风电工程直接业务;一般施工、钢管或项目相邻供应不纳入。",
+ "role": "工程配套把设备连接到场址和电网,塔筒、海缆、基础与吊装的制造/施工能力分别验证。",
+ "route": "海陆项目、制造与施工、订单与收入确认分账;通用工程资质不能替代风电直接业务。",
+ "market_evf": "EVF-MKT-WIND-01", "risk": "项目进度、海况、原材料与验收会影响交付,本批未找到可升级的增量企业。",
+ },
+ {
+ "n": 12, "track": "WIND", "code": "WND_OPERATION_OM", "name": "项目运营与运维服务", "bucket": "项目运营与运维服务", "folder": "12_风电运营运维",
+ "boundary": "纳入风电场持有运营和对外运维服务;核准、建设、并网、商运及运维收入分别记录。",
+ "role": "运营环节把装机转化为发电量,利用小时、可利用率、消纳和电价共同影响经营事实。",
+ "route": "项目阶段与海陆风分账,发电量和运维服务收入不互相替代。",
+ "market_evf": "EVF-MKT-WIND-02", "risk": "资源条件、限电、检修和电价机制变化会造成装机与发电表现背离。",
+ },
+ {
+ "n": 13, "track": "NUCLEAR", "code": "NUC_OPERATOR", "name": "运营商", "bucket": "运营商", "folder": "13_核电运营商",
+ "boundary": "仅纳入民用核电机组持有运营及公开高层运营指标;核准、在建、并网和商运严格分账。",
+ "role": "运营商承担机组建设转运营后的发电与安全运行责任,本研究只处理公开聚合指标。",
+ "route": "机组台数、装机、发电量与上网电量分别记录;不处理厂址安防、控制系统或敏感参数。",
+ "market_evf": "EVF-MKT-NUC-01", "risk": "本批没有新的运营商合格余额;候选中的其他发电或设备业务不能升级为核电运营。",
+ },
+ {
+ "n": 14, "track": "NUCLEAR", "code": "NUC_ENGINEERING_EPC", "name": "工程/EPC", "bucket": "工程/EPC", "folder": "14_核电工程EPC",
+ "boundary": "仅纳入民用核电工程设计、建设或总承包的直接公开业务;一般建材、防水和相邻施工不纳入核电 EPC。",
+ "role": "工程环节连接核准、设计、施工和投运,合同阶段与收入确认需用正式披露核验。",
+ "route": "只保留公开项目阶段和公司角色,不记录关键基础设施敏感细节。",
+ "market_evf": "EVF-MKT-NUC-2024", "risk": "东方雨虹公开案例仅支持防水材料及施工服务,不能据此认定核电工程总承包。",
+ },
+ {
+ "n": 15, "track": "NUCLEAR", "code": "NUC_NI_CI_EQUIPMENT", "name": "核岛/常规岛主设备", "bucket": "核岛/常规岛主设备", "folder": "15_核岛常规岛主设备",
+ "boundary": "纳入公开披露的民用核岛/常规岛主设备直接制造交付;通用设备潜在用途和采购方不纳入。",
+ "role": "主设备与机组系统直接相关,公开产品、合同、交付与收入证据是公司资格基础。",
+ "route": "只到公开产品类别和项目阶段,不处理非公开性能、布置、控制或脆弱性细节。",
+ "market_evf": "EVF-MKT-NUC-2024", "risk": "冻结账本在该桶没有可处理候选,本批明确保留企业增量缺口。",
+ },
+ {
+ "n": 16, "track": "NUCLEAR", "code": "NUC_COMPONENT_MATERIAL_ICT", "name": "核级部件/材料/仪控电气", "bucket": "核级部件/材料/仪控电气", "folder": "16_核级部件材料仪控电气",
+ "boundary": "纳入公开核级部件、材料、仪控和电气直接业务;一般工业产品、资质推测和潜在用途不纳入。",
+ "role": "该环节依赖核级产品、认证或订单的明确公开证据,不能由公司能力或客户关系推断。",
+ "route": "按部件、材料、仪控、电气分层;仅记录民用公开高层信息。",
+ "market_evf": "EVF-MKT-NUC-2024", "risk": "10 个冻结候选均未形成直接核级业务主源,增量映射保持 HELD。",
+ },
+]
+
+node_by_bucket = {(n["track"], n["bucket"]): n for n in NODES}
+if len(node_by_bucket) != 16:
+ raise RuntimeError("node bucket uniqueness failed")
+
+
+COMPANY_EVF = {
+ ("BATTERY", "资源与主材"): "EVF-QUAL-BATTERY-002497-R005",
+ ("BATTERY", "电芯制造"): "EVF-BATTERY-300750-BUSINESS",
+ ("BATTERY", "系统/部件/BMS-Pack"): "EVF-BATTERY-300207-BUSINESS",
+ ("SOLAR", "硅料/硅片与材料"): "EVF-QUAL-SOLAR-601908-R005",
+ ("SOLAR", "电池片/组件"): "EVF-QUAL-SOLAR-002623-R005",
+ ("SOLAR", "设备/辅材/逆变器"): "EVF-SOLAR-300274-BUSINESS",
+ ("SOLAR", "系统集成/电站建设运营"): "EVF-QUAL-SOLAR-301070-R005",
+ ("WIND", "材料与关键零部件"): "EVF-WIND-300443-BUSINESS",
+ ("WIND", "项目运营与运维服务"): "EVF-WIND-001289-BUSINESS",
+}
+
+
+def company_evidence_summary(row: dict[str, str], ev: dict[str, str]) -> str:
+ if row["security_code"] == "002497":
+ return "年报披露公司锂产品面向汽车厂商、电池企业和正极材料企业销售。"
+ return ev["evidence_text"]
+
+eligible_by_bucket: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
+for row in ledger:
+ if row["candidate_state"] == "ELIGIBLE_NOT_SELECTED_BATCH001":
+ eligible_by_bucket[(row["track_code"], row["selection_bucket"])].append(row)
+for rows in eligible_by_bucket.values():
+ rows.sort(key=lambda r: (int(r["selection_rank"]), r["exchange_code"], r["security_code"]))
+
+incremental: list[dict[str, str]] = []
+for node in NODES:
+ rows = eligible_by_bucket.get((node["track"], node["bucket"]), [])
+ if not rows:
+ continue
+ prior = rows[0]
+ ev_id = COMPANY_EVF[(node["track"], node["bucket"])]
+ ev = evidence_by_id[ev_id]
+ if ev["company_id"] != prior["company_id"]:
+ raise RuntimeError(f"company evidence mismatch for {node['code']}")
+ incremental.append({**prior, "node_code": node["code"], "node_name": node["name"], "evidence_fact_id": ev_id})
+
+expected_incremental_codes = {"002497", "300750", "300207", "601908", "002623", "300274", "301070", "300443", "001289"}
+if {r["security_code"] for r in incremental} != expected_incremental_codes:
+ raise RuntimeError("mechanical inherited incremental set changed")
+
+
+QUEUE_CODES = {
+ ("BATTERY", "设备与回收循环"): ["002081", "300793", "600120", "600248", "600353", "603052", "603687", "688335", "688610", "000036"],
+ ("WIND", "整机"): ["600089", "600863", "603969", "000155", "000690", "600011", "600248", "600548", "600642", "601985"],
+ ("WIND", "塔筒/海缆/工程配套"): ["601618", "002443", "600268", "600985", "601669", "000862", "002307", "601069", "000933"],
+ ("NUCLEAR", "运营商"): ["300198", "601121", "000958", "300165", "600312", "600550", "600651", "601609", "601618", "603308"],
+ ("NUCLEAR", "工程/EPC"): ["002271"],
+ ("NUCLEAR", "核岛/常规岛主设备"): [],
+ ("NUCLEAR", "核级部件/材料/仪控电气"): ["600685", "601199", "601212", "603013", "603282", "603700", "603800", "688198", "000571", "000581"],
+}
+
+if set(QUEUE_CODES) != {key for key in node_by_bucket if key not in eligible_by_bucket}:
+ raise RuntimeError("empty-bucket set changed")
+
+queue_rows: list[dict[str, Any]] = []
+for (track, bucket), codes in QUEUE_CODES.items():
+ node = node_by_bucket[(track, bucket)]
+ for rank, code in enumerate(codes, 1):
+ candidates = [r for r in ledger if r["security_code"] == code and r["track_code"] == track and r["selection_bucket"] == bucket]
+ if len(candidates) != 1:
+ raise RuntimeError(f"queue pair unresolved: {track}/{bucket}/{code} count={len(candidates)}")
+ prior = candidates[0]
+ f = funnel_by_pair[(prior["company_id"], track, bucket)]
+ rr = role_by_pair[(prior["company_id"], track, bucket)]
+ prior_attachment_id = rr["selected_attachment_id"]
+ if not prior_attachment_id:
+ prior_attachment_id = (f["announcement_ids"].split(";")[0] if f["announcement_ids"] else "")
+ prior_source_doc_id = f"S-QUAL-AR-{prior_attachment_id}" if prior_attachment_id else ""
+ if not prior_source_doc_id or prior_source_doc_id not in source_by_id:
+ raise RuntimeError(f"queue prior annual report unresolved: {track}/{bucket}/{code}/{prior_source_doc_id}")
+ prior_doc = source_by_id[prior_source_doc_id]
+ new_doc = "S-NEB2-YUHONG-SUSTAINABILITY-2023" if code == "002271" and track == "NUCLEAR" else ""
+ new_evf = "EVF-NE-B002-ADJ-002271" if new_doc else ""
+ result = "HELD_ADJACENT_WATERPROOFING_MATERIAL_AND_SERVICE_NOT_NUCLEAR_EPC" if new_doc else "HELD_NO_NEW_DIRECT_TARGET_BUCKET_PRIMARY_SOURCE"
+ queue_rows.append({
+ "queue_item_id": f"NEB2-QUEUE-{node['n']:02d}-{rank:02d}",
+ "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
+ "subindustry_node_code": node["code"], "track_code": track, "selection_bucket": bucket,
+ "queue_rank": rank, "company_id": prior["company_id"], "security_code": code,
+ "security_name": prior["security_name"], "prior_candidate_state": prior["candidate_state"],
+ "prior_query_hit_count": f["query_hit_count"], "prior_business_context_result": f["business_context_rule_result"],
+ "prior_source_doc_id": prior_source_doc_id,
+ "prior_locator": rr["selected_page"] and f"page={rr['selected_page']}" or f["fulltext_context_locator"],
+ "prior_raw_file_path": prior_doc["raw_file_path"], "prior_raw_sha256": prior_doc["file_sha256"].upper(),
+ "prior_converted_text_path": prior_doc["converted_text_path"],
+ "prior_retrieval_result": f["annual_report_retrieval_result"],
+ "prior_full_page_search_result": f["page_level_verification_result"],
+ "prior_context_sha256": f["fulltext_context_sha256"],
+ "prior_context_excerpt": f["fulltext_context_excerpt_sanitized"],
+ "prior_company_self_gate": rr["company_self_gate"], "prior_target_bucket_role_gate": rr["target_bucket_role_gate"],
+ "prior_adjacent_or_self_use_exclusion_gate": rr["adjacent_or_self_use_exclusion_gate"],
+ "prior_role_decision": rr["final_role_decision"], "prior_decision_basis_code": rr["decision_basis_code"],
+ "completion_channel": "IMMUTABLE_B001_OFFICIAL_ANNUAL_REPORT_REVIEW_PLUS_BOUNDED_PUBLIC_PRIMARY_QUERY",
+ "query_receipt_path": rel(INDUSTRY / "supplement" / f"NEB2_QRY_{node['code']}_evidence_completion.json"),
+ "actual_query_receipt_path": rel(QUERY_RECEIPT_PATH),
+ "new_source_doc_id": new_doc, "new_evidence_fact_id": new_evf,
+ "company_self_gate": "PASS_NEW_ADJACENT_SERVICE_CONTEXT" if new_doc else rr["company_self_gate"],
+ "target_bucket_direct_role_gate": "FAIL_ADJACENT_SERVICE" if new_doc else rr["target_bucket_role_gate"],
+ "current_public_primary_source_gate": "PASS_ADJACENT_ONLY" if new_doc else "NO_NEW_DIRECT_SOURCE",
+ "locator_gate": "PASS_PAGE_14" if new_doc else "NO_NEW_DIRECT_LOCATOR",
+ "final_state": "HELD_BY_EVIDENCE_GAP", "verification_result": result,
+ "stop_reason": "BOUNDED_ONE_ROUND_COMPLETED_NO_QUALIFIED_PAIR",
+ "coverage_claim": "NONE_INCREMENTAL_EVIDENCED_POOL_ONLY", "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+
+if len(queue_rows) != 50:
+ raise RuntimeError(f"bounded queue expected 50 rows, got {len(queue_rows)}")
+
+
+query_receipts = {
+ "BAT_EQUIPMENT_RECYCLE": {
+ "query": "冻结候选名称 + 锂电设备/电池回收 + 年报/公告/官网",
+ "result": "未定位到可把 10 个候选升级为锂电专用设备或回收直接业务的公开主源;复用前批 2025 年报全页检索与角色裁决。",
+ },
+ "WND_TURBINE": {
+ "query": "冻结候选名称 + 风电整机/风力发电机组 + 年报/公告/官网",
+ "result": "未定位到可把 10 个候选升级为风电整机直接制造的公开主源;项目、发电或通用设备语境不升级。",
+ },
+ "WND_TOWER_CABLE_ENGINEERING": {
+ "query": "冻结候选名称 + 风电塔筒/海缆/吊装/工程 + 年报/公告/官网",
+ "result": "未定位到可把 9 个候选升级为目标桶直接业务的公开主源;一般工程、钢管和项目关联不升级。",
+ },
+ "NUC_OPERATOR": {
+ "query": "冻结候选名称 + 民用核电运营/核电机组 + 年报/公告/官网",
+ "result": "未定位到可把 10 个候选升级为核电运营商的公开主源;其他发电、设备或项目语境不升级。",
+ },
+ "NUC_ENGINEERING_EPC": {
+ "query": "东方雨虹 + 核电 + EPC/工程 + 年报/可持续发展报告/官网",
+ "result": "东方雨虹官网 2023 可持续发展报告仅支持核电机组厂房地下防水工程材料及施工服务,属于相邻专业服务,不是核电工程 EPC。",
+ "official_url": "https://www.yuhong.com.cn/uploads/soft/240422/1-240422101057.pdf",
+ },
+ "NUC_NI_CI_EQUIPMENT": {
+ "query": "冻结账本目标桶候选生成",
+ "result": "前批冻结账本在完成排除后无候选,按设计不自由扩池。",
+ },
+ "NUC_COMPONENT_MATERIAL_ICT": {
+ "query": "冻结候选名称 + 核级部件/材料/仪控/电气 + 年报/公告/官网",
+ "result": "未定位到可把 10 个候选升级为核级直接业务的公开主源;一般工业产品、客户或潜在用途不升级。",
+ },
+}
+
+for node in NODES:
+ if (node["track"], node["bucket"]) not in QUEUE_CODES:
+ continue
+ receipt = {
+ "query_id": f"NEB2-QRY-{node['code']}", "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "darkline_mode": "EXTERNAL_PUBLIC_EVIDENCE_COMPLETION",
+ "subindustry_node_code": node["code"], "selection_bucket": node["bucket"],
+ "candidate_codes": QUEUE_CODES[(node["track"], node["bucket"])],
+ "search_window": "ONE_INITIAL_BROAD_PUBLIC_PRIMARY_QUERY_PLUS_PRIOR_ACCEPTED_FULL_PAGE_REPORT_REVIEW",
+ "source_priority": ["exchange_or_statutory_disclosure", "company_official_report_or_IR", "official_project_or_regulator"],
+ "search_engine_usage": "LOCATE_ORIGINAL_ONLY", "market_reverse_scan": "NOT_APPLICABLE",
+ "access_control_bypass": "NO", "nuclear_sensitive_content_saved": "NO",
+ "source_cutoff_at": SOURCE_CUTOFF, "executed_at": EXECUTED_AT,
+ "actual_query_receipt_path": rel(QUERY_RECEIPT_PATH),
+ **query_receipts[node["code"]], "review_status": REVIEW,
+ }
+ write_json(INDUSTRY / "supplement" / f"NEB2_QRY_{node['code']}_evidence_completion.json", receipt)
+
+
+yuhong_raw = INDUSTRY / "raw" / "official_filings" / "NEB2_S-NEB2-YUHONG-SUSTAINABILITY-2023_2024-04-22_yuhong-sustainability-2023.pdf"
+yuhong_txt = INDUSTRY / "converted" / "official_filings" / "NEB2_S-NEB2-YUHONG-SUSTAINABILITY-2023_yuhong-sustainability-2023.txt"
+if not yuhong_raw.exists() or not yuhong_txt.exists():
+ raise FileNotFoundError("Yuhong official source or conversion missing")
+
+if not QUERY_PROBE_PATH.exists():
+ raise FileNotFoundError("REPAIR001 official URL probe receipt missing; run newenergy_batch002_query_probe.py first")
+probe_payload = json.loads(QUERY_PROBE_PATH.read_text(encoding="utf-8"))
+if probe_payload.get("status") != "COMPLETED_WITH_ALL_RESPONSE_OR_FAILURE_STATES_RECORDED":
+ raise RuntimeError("official URL probe did not close all response/failure states")
+probe_by_url = {row["url"]: row for row in probe_payload["url_results"]}
+
+QUERY_TERMS = {
+ "BAT_EQUIPMENT_RECYCLE": "锂电设备|电池设备|电池回收|梯次利用|再生利用",
+ "WND_TURBINE": "风电整机|风力发电机组|风机制造|整机销售",
+ "WND_TOWER_CABLE_ENGINEERING": "风电塔筒|风电海缆|风电吊装|风电工程",
+ "NUC_OPERATOR": "核电运营|核电机组|核电发电|商运核电",
+ "NUC_ENGINEERING_EPC": "核电EPC|核电工程总承包|核电工程设计建设",
+ "NUC_COMPONENT_MATERIAL_ICT": "核级部件|核级材料|核电仪控|核电电气",
+}
+actual_query_rows: list[dict[str, Any]] = []
+for row in queue_rows:
+ source_id = row["new_source_doc_id"] or row["prior_source_doc_id"]
+ if row["new_source_doc_id"]:
+ source_url = "https://www.yuhong.com.cn/uploads/soft/240422/1-240422101057.pdf"
+ returned_object = "东方雨虹2023可持续发展报告;仅返回相邻防水材料及施工服务事实"
+ returned_locator = "page=14;converted_lines=740-764"
+ else:
+ source_url = source_by_id[source_id]["source_url"]
+ returned_object = f"{row['security_code']} {row['security_name']} 2025年度报告"
+ returned_locator = row["prior_locator"]
+ if source_url not in probe_by_url:
+ raise RuntimeError(f"actual probe URL missing for queue pair: {row['queue_item_id']} {source_url}")
+ probe = probe_by_url[source_url]
+ actual_query_rows.append({
+ "actual_query_id": row["queue_item_id"].replace("QUEUE", "ACTUAL-QUERY"),
+ "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
+ "repair_id": REPAIR_ID, "queue_item_id": row["queue_item_id"], "subindustry_node_code": row["subindustry_node_code"],
+ "track_code": row["track_code"], "selection_bucket": row["selection_bucket"], "company_id": row["company_id"],
+ "security_code": row["security_code"], "security_name": row["security_name"],
+ "query_purpose": "Verify whether the frozen company has current direct business in the target bucket using its official primary source; do not discover candidates.",
+ "complete_query_conditions": f"company_id={row['company_id']};security_code={row['security_code']};track={row['track_code']};bucket={row['selection_bucket']};source_cutoff<={SOURCE_CUTOFF};source_grade=S/A;company_self+target_role+locator required",
+ "query_terms": QUERY_TERMS[row["subindustry_node_code"]], "queried_at": probe["queried_at"],
+ "source_doc_id": source_id, "request_url": source_url, "request_method": probe["request_method"],
+ "response_status": probe["response_status"], "response_result": probe["response_result"],
+ "final_url": probe["final_url"], "content_type": probe["content_type"], "content_length": probe["content_length"],
+ "failure_type": probe["error_type"], "failure_detail": probe["error_detail"],
+ "returned_object": returned_object, "returned_original_locator": returned_locator,
+ "prior_raw_file_path": row["prior_raw_file_path"], "prior_raw_sha256": row["prior_raw_sha256"],
+ "prior_full_page_search_result": row["prior_full_page_search_result"],
+ "prior_context_excerpt": row["prior_context_excerpt"], "mapping_status": "MAPPED_ONE_TO_ONE_TO_QUEUE_PAIR",
+ "qualification_result": row["verification_result"],
+ "termination_reason": "TARGET_ROLE_NOT_ESTABLISHED_AFTER_OFFICIAL_SOURCE_RESPONSE_AND_ACCEPTED_FULL_PAGE_ROLE_REVIEW",
+ "candidate_added": "NO", "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+
+actual_query_rows.append({
+ "actual_query_id": "NEB2-ACTUAL-QUERY-15-NA", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
+ "run_id": RUN_ID, "repair_id": REPAIR_ID, "queue_item_id": "NOT_APPLICABLE_ZERO_CANDIDATE",
+ "subindustry_node_code": "NUC_NI_CI_EQUIPMENT", "track_code": "NUCLEAR", "selection_bucket": "核岛/常规岛主设备",
+ "company_id": "", "security_code": "", "security_name": "", "query_purpose": "No query is executable because the frozen ledger contains zero candidates for this bucket.",
+ "complete_query_conditions": "frozen_candidate_count=0;free_expansion=DENIED", "query_terms": "NOT_APPLICABLE",
+ "queried_at": probe_payload["queried_at"], "source_doc_id": "", "request_url": "NOT_FOUND_NOT_APPLICABLE_ZERO_CANDIDATE",
+ "request_method": "NOT_APPLICABLE", "response_status": "NOT_APPLICABLE", "response_result": "NOT_APPLICABLE_ZERO_CANDIDATE",
+ "final_url": "", "content_type": "", "content_length": "", "failure_type": "", "failure_detail": "",
+ "returned_object": "NONE_ZERO_CANDIDATE", "returned_original_locator": "FROZEN_LEDGER_EMPTY_BUCKET",
+ "prior_raw_file_path": "", "prior_raw_sha256": "", "prior_full_page_search_result": "NOT_APPLICABLE",
+ "prior_context_excerpt": "", "mapping_status": "MAPPED_TO_EMPTY_BUCKET_NOT_TO_PAIR",
+ "qualification_result": "NO_CANDIDATE_NO_QUERY_EXECUTED", "termination_reason": "FROZEN_LEDGER_NO_CANDIDATE_DO_NOT_EXPAND",
+ "candidate_added": "NO", "schema_version": SCHEMA, "review_status": REVIEW,
+})
+if len(actual_query_rows) != 51:
+ raise RuntimeError(f"actual query receipt expected 51 rows, got {len(actual_query_rows)}")
+write_csv(QUERY_RECEIPT_PATH, actual_query_rows)
+
+yuhong_evf = {
+ "evidence_fact_id": "EVF-NE-B002-ADJ-002271", "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "doc_id": "S-NEB2-YUHONG-SUSTAINABILITY-2023",
+ "industry_case": "新能源案例", "industry_id": "IND-NEWENERGY", "subindustry_id": "NUC_ENGINEERING_EPC",
+ "company_id": "CN_A:SZSE:002271", "track_code": "NUCLEAR", "chain_node_id": "工程/EPC",
+ "subject_type": "COMPANY_BOUNDARY", "subject_id": "CN_A:SZSE:002271",
+ "source_text_path": rel(yuhong_txt), "raw_pool_path": rel(yuhong_raw.parent) + "/",
+ "raw_file_sha256": sha256(yuhong_raw), "source_page": "14", "source_table_id": "", "source_sentence_index": "",
+ "locator_type": "PDF_PAGE", "locator_value": "page=14;converted_lines=740-764",
+ "evidence_text": "东方雨虹报告披露其为核电机组厂房地下防水工程提供材料及施工服务。",
+ "evidence_type": "COMPANY_OFFICIAL_SUSTAINABILITY_REPORT", "statement_type": "FACT",
+ "business_dimension": "BOUNDARY_EXCLUSION", "research_dimension": "COMPANY_ROLE",
+ "numeric_value_raw": "", "metric_candidate_name": "", "metric_candidate_unit": "", "metric_period": "2023",
+ "metric_date": "2023-12-31", "geography": "CN", "original_qualifier": "仅支持防水材料及施工服务,不支持核电工程EPC资格",
+ "related_company_id": "", "viewpoint_id": "", "darkline_signal_flag": "NO", "confidence_level": "HIGH",
+ "conclusion_strength": "DIRECT_BOUNDARY_FACT", "sensitivity_screen": "LEGAL_PUBLIC_CIVIL_NUCLEAR_HIGH_LEVEL_ONLY",
+ "contradicts_evidence_fact_id": "", "normalization_status": "NORMALIZED", "processing_status": "READY",
+ "data_status": "VERIFIED_PUBLIC_BOUNDARY_ONLY", "schema_version": SCHEMA, "review_status": REVIEW,
+}
+write_csv(INDUSTRY / "evidence" / "evidence_fact_table_BATCH002.csv", [yuhong_evf])
+
+
+source_document_rows = [{
+ "doc_id": "S-NEB2-YUHONG-SUSTAINABILITY-2023", "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "doc_type": "COMPANY_OFFICIAL_SUSTAINABILITY_REPORT",
+ "title": "东方雨虹2023可持续发展报告", "source_org": "北京东方雨虹防水技术股份有限公司", "author": "",
+ "publish_date": "2024-04-22", "collected_at": EXECUTED_AT,
+ "source_url": "https://www.yuhong.com.cn/uploads/soft/240422/1-240422101057.pdf",
+ "industry_case": "新能源案例", "industry_id": "IND-NEWENERGY", "subindustry_id": "NUC_ENGINEERING_EPC",
+ "company_id": "CN_A:SZSE:002271", "raw_pool_path": rel(yuhong_raw.parent) + "/",
+ "raw_file_path": rel(yuhong_raw), "converted_text_path": rel(yuhong_txt), "converted_markdown_path": "",
+ "file_sha256": sha256(yuhong_raw), "file_name": yuhong_raw.name, "file_size": yuhong_raw.stat().st_size,
+ "detected_type": "PDF", "source_language": "zh-CN", "public_access_basis": "COMPANY_OFFICIAL_PUBLIC_URL",
+ "access_status": "HTTP_200_PUBLIC", "source_level": "A", "sensitivity_screen": "CIVIL_NUCLEAR_HIGH_LEVEL_PUBLIC_BOUNDARY_ONLY",
+ "legal_access_note": "公开公司报告;未绕过访问控制;只引用角色边界所需最小事实。",
+ "doc_status": "ARCHIVED", "processing_status": "TEXT_CONVERTED_INDEXED_BOUNDARY_EVIDENCE",
+ "schema_version": SCHEMA, "review_status": REVIEW,
+}]
+write_csv(INDUSTRY / "manifest" / "source_document_BATCH002.csv", source_document_rows)
+
+
+used_evidence_ids = {node["market_evf"] for node in NODES} | {r["evidence_fact_id"] for r in incremental}
+used_source_ids = {evidence_by_id[eid]["doc_id"] for eid in used_evidence_ids}
+for row in queue_rows:
+ if row["prior_source_doc_id"]:
+ used_source_ids.add(row["prior_source_doc_id"])
+used_source_ids.add("S-NEB2-YUHONG-SUSTAINABILITY-2023")
+
+missing_sources = sorted(s for s in used_source_ids if s != "S-NEB2-YUHONG-SUSTAINABILITY-2023" and s not in source_by_id)
+if missing_sources:
+ raise RuntimeError(f"missing baseline source docs: {missing_sources}")
+
+
+conversion_rows: list[dict[str, Any]] = []
+input_rows: list[dict[str, Any]] = []
+source_gap_audit_rows: list[dict[str, Any]] = []
+for idx, source_id in enumerate(sorted(used_source_ids), 1):
+ if source_id == "S-NEB2-YUHONG-SUSTAINABILITY-2023":
+ doc = source_document_rows[0]
+ conv = {
+ "raw_pool_path": doc["raw_pool_path"], "raw_file_path": doc["raw_file_path"],
+ "raw_file_sha256": doc["file_sha256"], "detected_type": "PDF", "conversion_method": "PYPDF_TEXT_EXTRACT",
+ "parameters_summary": "all_pages;utf8;page_markers", "converted_text_path": doc["converted_text_path"],
+ "converted_markdown_path": "", "converted_path": doc["converted_text_path"],
+ "converted_sha256": sha256(yuhong_txt), "page_or_duration_count": "39", "status": "CONVERTED_INDEXED_BOUNDARY_EVIDENCE",
+ "error_code": "", "error_summary": "", "created_at": EXECUTED_AT,
+ }
+ source_url = doc["source_url"]
+ source_level = "A"
+ public_basis = doc["public_access_basis"]
+ sensitivity = doc["sensitivity_screen"]
+ raw_path = doc["raw_file_path"]
+ raw_hash = doc["file_sha256"]
+ mode = "NEW_BATCH002_SOURCE"
+ else:
+ doc = source_by_id[source_id]
+ prior_conv = conversion_by_source.get(source_id)
+ if not prior_conv:
+ raise RuntimeError(f"missing baseline conversion: {source_id}")
+ conv = {
+ "raw_pool_path": prior_conv["raw_pool_path"], "raw_file_path": prior_conv["raw_file_path"],
+ "raw_file_sha256": prior_conv["raw_file_sha256"], "detected_type": prior_conv["detected_type"],
+ "conversion_method": "REUSE_ACCEPTED_B001_" + prior_conv["conversion_method"],
+ "parameters_summary": "immutable accepted conversion; no recopy", "converted_text_path": prior_conv["converted_text_path"],
+ "converted_markdown_path": prior_conv["converted_markdown_path"], "converted_path": prior_conv["converted_path"],
+ "converted_sha256": prior_conv["converted_sha256"], "page_or_duration_count": prior_conv["page_or_duration_count"],
+ "status": "REUSED_IMMUTABLE_ACCEPTED_CONVERSION", "error_code": "", "error_summary": "", "created_at": EXECUTED_AT,
+ }
+ source_url = doc["source_url"]
+ source_level = doc["source_level"]
+ public_basis = doc["public_access_basis"]
+ sensitivity = doc["sensitivity_screen"]
+ raw_path = doc["raw_file_path"]
+ raw_hash = doc["file_sha256"]
+ mode = "IMMUTABLE_B001_REFERENCE"
+ conversion_rows.append({
+ "conversion_id": f"NEB2-CONV-{idx:04d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
+ "run_id": RUN_ID, "source_doc_id": source_id, **conv, "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+ input_rows.append({
+ "input_item_id": f"NEB2-INPUT-{idx:04d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
+ "run_id": RUN_ID, "source_doc_id": source_id, "include_decision": "INCLUDE",
+ "exclude_reason": "", "raw_pool_path": conv["raw_pool_path"], "raw_file_path": raw_path,
+ "raw_file_sha256": raw_hash, "source_url": source_url, "source_level": source_level,
+ "public_access_basis": public_basis, "sensitivity_screen": sensitivity,
+ "processing_status": mode, "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+ source_gap_audit_rows.append({
+ "source_gap_audit_id": f"NEB2-SGA-{idx:04d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
+ "run_id": RUN_ID, "source_doc_id": source_id, "source_received_flag": "YES",
+ "raw_archived_flag": "YES_IMMUTABLE_REFERENCE" if mode.startswith("IMMUTABLE") else "YES_NEW_BATCH002",
+ "converted_flag": "YES", "indexed_flag": "YES", "evidence_linked_flag": "YES_STRONG_FACT_LINKED" if source_id in {evidence_by_id[e]["doc_id"] for e in used_evidence_ids} or source_id.startswith("S-NEB2") else "YES_QUEUE_ADJUDICATION_LINKED",
+ "raw_pool_path": conv["raw_pool_path"], "raw_file_sha256": raw_hash, "gap_type": "NONE",
+ "impact": "NONE", "status": "COMPLETE", "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+
+write_csv(INDUSTRY / "manifest" / "conversion_status_BATCH002.csv", conversion_rows)
+write_csv(INDUSTRY / "manifest" / "input_manifest_BATCH002.csv", input_rows)
+write_csv(INDUSTRY / "manifest" / "source_gap_audit_BATCH002.csv", source_gap_audit_rows)
+
+
+incremental_ledger_rows: list[dict[str, Any]] = []
+for idx, row in enumerate(sorted(incremental, key=lambda r: (r["track_code"], r["selection_bucket"])), 1):
+ incremental_ledger_rows.append({
+ "candidate_id": f"NEB2-CAND-INCR-{idx:02d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
+ "run_id": RUN_ID, "company_id": row["company_id"], "security_code": row["security_code"],
+ "security_name": row["security_name"], "exchange_code": row["exchange_code"], "track_code": row["track_code"],
+ "subindustry_node_code": row["node_code"], "selection_bucket": row["selection_bucket"],
+ "predecessor_candidate_id": row["candidate_id"], "predecessor_state": row["candidate_state"],
+ "predecessor_selection_rank": row["selection_rank"], "batch002_state": "INCLUDED_INCREMENTAL",
+ "batch002_rank_in_node": "1", "source_doc_id": row["direct_business_source_id"],
+ "evidence_fact_id": row["evidence_fact_id"], "locator": row["direct_business_locator"],
+ "company_self_gate": "PASS", "target_bucket_direct_role_gate": "PASS", "current_public_primary_source_gate": "PASS",
+ "locator_gate": "PASS", "mechanical_reason": "FIRST_ELIGIBLE_NOT_SELECTED_BATCH001_BY_ACCEPTED_SELECTION_RANK",
+ "coverage_claim": "NONE_INCREMENTAL_EVIDENCED_POOL_ONLY", "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+for row in queue_rows:
+ incremental_ledger_rows.append({
+ "candidate_id": row["queue_item_id"].replace("QUEUE", "CAND-HELD"), "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "company_id": row["company_id"], "security_code": row["security_code"],
+ "security_name": row["security_name"], "exchange_code": "SSE" if row["security_code"].startswith("6") else "SZSE",
+ "track_code": row["track_code"], "subindustry_node_code": row["subindustry_node_code"],
+ "selection_bucket": row["selection_bucket"], "predecessor_candidate_id": ledger_by_pair[(row["company_id"], row["track_code"], row["selection_bucket"])]["candidate_id"],
+ "predecessor_state": row["prior_candidate_state"], "predecessor_selection_rank": "",
+ "batch002_state": row["final_state"], "batch002_rank_in_node": "", "source_doc_id": row["new_source_doc_id"] or row["prior_source_doc_id"],
+ "evidence_fact_id": row["new_evidence_fact_id"], "locator": row["locator_gate"],
+ "company_self_gate": row["company_self_gate"], "target_bucket_direct_role_gate": row["target_bucket_direct_role_gate"],
+ "current_public_primary_source_gate": row["current_public_primary_source_gate"], "locator_gate": row["locator_gate"],
+ "mechanical_reason": row["verification_result"], "coverage_claim": row["coverage_claim"],
+ "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+
+write_csv(INDUSTRY / "extracted" / "batch002_evidence_completion_queue.csv", queue_rows)
+write_csv(INDUSTRY / "extracted" / "company_incremental_candidate_ledger_BATCH002.csv", incremental_ledger_rows)
+
+
+market_evfs = {eid: evidence_by_id[eid] for eid in {n["market_evf"] for n in NODES}}
+ATOMIC_METRICS = {
+ "EVF-MKT-BAT-01": [
+ ("全国锂电池总产量", ">473", "GWh", "2025-01-01", "2025-04-30", "工信部测算;期间产量"),
+ ],
+ "EVF-MKT-BAT-02": [
+ ("储能型锂电池产量", ">110", "GWh", "2025-01-01", "2025-04-30", "储能应用;期间产量"),
+ ("动力电池装车量", "~184", "GWh", "2025-01-01", "2025-04-30", "动力应用;装车口径"),
+ ],
+ "EVF-MKT-BAT-03": [
+ ("电池级碳酸锂均价", "7.4", "万元/吨", "2025-01-01", "2025-04-30", "电池级;期间均价"),
+ ("微粉级氢氧化锂均价", "7.6", "万元/吨", "2025-01-01", "2025-04-30", "微粉级;期间均价"),
+ ],
+ "EVF-MKT-SOL-01": [
+ ("全国新增光伏装机", "317", "GW", "2025-01-01", "2025-12-31", "并网新增装机"),
+ ("全国累计光伏装机", "1200", "GW", "2025-01-01", "2025-12-31", "年末累计并网装机"),
+ ],
+ "EVF-MKT-SOL-02": [
+ ("全国光伏发电量", "1.17", "万亿千瓦时", "2025-01-01", "2025-12-31", "年度发电量"),
+ ("全国光伏利用率", "95", "%", "2025-01-01", "2025-12-31", "年度利用率"),
+ ],
+ "EVF-MKT-WIND-01": [
+ ("全国新增风电装机", "120", "GW", "2025-01-01", "2025-12-31", "并网新增装机"),
+ ("全国累计风电装机", "640", "GW", "2025-01-01", "2025-12-31", "年末累计并网装机"),
+ ],
+ "EVF-MKT-WIND-02": [
+ ("全国风电发电量", "1.13", "万亿千瓦时", "2025-01-01", "2025-12-31", "年度发电量"),
+ ("全国风电利用率", "94", "%", "2025-01-01", "2025-12-31", "年度利用率"),
+ ],
+ "EVF-MKT-NUC-01": [
+ ("全国投入商业运营核电机组", "59", "台", "2025-01-01", "2025-12-31", "不含台湾省;商运机组"),
+ ("全国商运核电机组上网电量", "4389.29", "亿千瓦时", "2025-01-01", "2025-12-31", "商运机组;年度上网电量"),
+ ],
+ "EVF-MKT-NUC-2024": [
+ ("全国运行核电机组", "57", "台", "2024-01-01", "2024-12-31", "年末运行机组"),
+ ("全国运行核电装机", "59431.7", "MWe", "2024-01-01", "2024-12-31", "年末运行装机"),
+ ("全国核电发电量", "4451.75", "亿千瓦时", "2024-01-01", "2024-12-31", "年度发电量"),
+ ("全国核电上网电量", "4184", "亿千瓦时", "2024-01-01", "2024-12-31", "年度上网电量"),
+ ],
+}
+selected_by_node = {r["node_code"]: r for r in incremental}
+baseline_selected_by_bucket: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
+for row in ledger:
+ if row["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}:
+ baseline_selected_by_bucket[(row["track_code"], row["selection_bucket"])].append(row)
+for rows in baseline_selected_by_bucket.values():
+ rows.sort(key=lambda r: int(r["selection_rank"]))
+
+
+subindustry_rows: list[dict[str, Any]] = []
+scope_rows: list[dict[str, Any]] = []
+technology_rows: list[dict[str, Any]] = []
+metric_rows: list[dict[str, Any]] = []
+risk_rows: list[dict[str, Any]] = []
+classification_rows: list[dict[str, Any]] = []
+gap_rows: list[dict[str, Any]] = []
+next_action_rows: list[dict[str, Any]] = []
+
+for node in NODES:
+ ev = market_evfs[node["market_evf"]]
+ selected = selected_by_node.get(node["code"])
+ queue_count = len(QUEUE_CODES.get((node["track"], node["bucket"]), []))
+ subindustry_rows.append({
+ "node_row_id": f"NEB2-NODE-{node['n']:02d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
+ "run_id": RUN_ID, "node_order": node["n"], "track_code": node["track"], "subindustry_node_code": node["code"],
+ "subindustry_name": node["name"], "selection_bucket": node["bucket"], "primary_region": "MAINLAND_CHINA",
+ "jurisdiction": "CN", "global_comparator": "SEPARATE_CONTEXT_ONLY", "scope_boundary": node["boundary"],
+ "value_chain_role": node["role"], "technology_framework": node["route"], "market_evidence_fact_id": node["market_evf"],
+ "incremental_company_count": 1 if selected else 0, "bounded_queue_count": queue_count,
+ "node_company_status": "INCREMENTAL_INCLUDED" if selected else "HELD_BY_EVIDENCE_GAP",
+ "coverage_claim": "NONE_INCREMENTAL_EVIDENCED_POOL_ONLY", "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+ scope_rows.append({
+ "scope_row_id": f"NEB2-SCOPE-NODE-{node['n']:02d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
+ "run_id": RUN_ID, "object_type": "SUBINDUSTRY_NODE", "object_id": node["code"], "track_code": node["track"],
+ "chain_node": node["name"], "scope_type": "CORE_INDUSTRY", "inclusion_reason": node["boundary"],
+ "exclusion_reason": "NOT_APPLICABLE_FOR_CORE_INDUSTRY_INCLUDED_SCOPE", "scope_as_of": SOURCE_CUTOFF,
+ "primary_region": "MAINLAND_CHINA", "jurisdiction": "CN", "source_doc_id": ev["doc_id"],
+ "evidence_fact_id": node["market_evf"], "artifact_id": "", "scope_version": "BATCH002",
+ "coverage_claim": "NONE", "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+ route_metric = ATOMIC_METRICS[node["market_evf"]][0]
+ technology_rows.append({
+ "technology_route_id": f"NEB2-ROUTE-{node['n']:02d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
+ "run_id": RUN_ID, "track_code": node["track"], "subindustry_node_code": node["code"],
+ "route_name": node["route"].split(";", 1)[0], "product_or_system": node["name"],
+ "technical_stage": "PUBLIC_ROUTE_FRAMEWORK_NOT_PRODUCT_CERTIFICATION",
+ "commercial_stage": "COMMERCIAL_INDUSTRY_CONTEXT_ONLY_NOT_COMPANY_STAGE",
+ "performance_metric": route_metric[0], "metric_value": route_metric[1], "unit": route_metric[2],
+ "cost_boundary": "GAP_NOT_ESTABLISHED_AT_ROUTE_LEVEL", "validation_or_certification": "GAP_NOT_ESTABLISHED",
+ "alternative_route": "GAP_NOT_COMPARABLY_EVIDENCED", "limiting_factor": node["risk"],
+ "route_description": node["route"], "value_chain_stage": node["name"],
+ "region": "MAINLAND_CHINA", "metric_as_of": SOURCE_CUTOFF, "source_doc_id": ev["doc_id"],
+ "evidence_fact_id": node["market_evf"], "artifact_id": "", "evidence_boundary": "行业需求/运营事实只作路线背景,不证明单一公司份额或盈利",
+ "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+ for atomic_index, (metric_name, metric_value, unit, period_start, period_end, basis) in enumerate(ATOMIC_METRICS[node["market_evf"]], 1):
+ is_price_metric = "均价" in metric_name
+ if "装机" in metric_name or "机组" in metric_name:
+ capacity_status = "GRID_CONNECTED_OR_COMMERCIAL_OPERATION_AS_STATED"
+ elif "产量" in metric_name or "装车量" in metric_name:
+ capacity_status = "ACTUAL_PERIOD_OUTPUT_NOT_CAPACITY"
+ else:
+ capacity_status = "NOT_APPLICABLE_NON_CAPACITY_METRIC"
+ metric_rows.append({
+ "market_metric_id": f"NEB2-METRIC-{node['n']:02d}-{atomic_index:02d}", "task_id": TASK_ID,
+ "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID, "metric_name": metric_name,
+ "object_id": node["code"], "track_code": node["track"], "value": metric_value, "unit": unit,
+ "period_start": period_start, "period_end": period_end, "region": "MAINLAND_CHINA", "jurisdiction": "CN",
+ "price_or_volume_basis": basis, "source_doc_id": ev["doc_id"], "evidence_fact_id": node["market_evf"],
+ "nominal_or_real": "NOMINAL_PUBLIC_CURRENCY_VALUE" if is_price_metric else "ACTUAL_PUBLIC_STATISTIC",
+ "spot_or_contract": "UNSPECIFIED_PUBLIC_AVERAGE_NOT_SPOT_OR_CONTRACT" if is_price_metric else "NOT_APPLICABLE_NON_PRICE",
+ "tax_basis": "UNSPECIFIED_BY_SOURCE" if is_price_metric else "NOT_APPLICABLE_NON_PRICE",
+ "capacity_status": capacity_status,
+ "source_method": "OFFICIAL_GOVERNMENT_STATISTIC" if ev["doc_id"].startswith("S-MARKET-") else "STATUTORY_FILING_TRANSCRIBED_INDUSTRY_STATISTIC",
+ "comparability_note": "仅限同地域、同期间、同单位、同阶段及原限定语;不得外推单一公司份额或盈利。",
+ "artifact_id": "", "qualifier": "同一行业事实可作多个节点的共同需求/运营背景,不作跨节点因果外推",
+ "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+ risk_rows.append({
+ "catalyst_risk_id": f"NEB2-RISK-{node['n']:02d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
+ "run_id": RUN_ID, "object_id": node["code"], "track_code": node["track"], "event_or_risk_type": "RISK",
+ "source_as_of": SOURCE_CUTOFF, "statement_type": "INFERENCE",
+ "trigger_condition": "后续同口径官方统计、法定披露或项目状态显著偏离本批公开事实边界",
+ "transmission_path": f"公开行业/运营事实变化 -> {node['name']}节点供需、技术或项目约束变化",
+ "affected_metric_or_object": node["code"], "time_window": "AFTER_SOURCE_CUTOFF_FUTURE_MONITORING",
+ "risk_statement": node["risk"], "observable_indicator": "后续同口径官方统计、法定披露或项目状态",
+ "alternative_explanation": "行业总量变化可能由口径、库存、项目节奏或结构变化造成",
+ "invalidation_condition": "后续主源证明角色、口径或阶段与本批判断不一致",
+ "current_status": "OPEN_MONITORING_NOT_PREDICTION",
+ "source_doc_id": ev["doc_id"], "evidence_fact_id": node["market_evf"], "artifact_id": "",
+ "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+ classification_rows.append({
+ "classification_id": f"NEB2-CLASS-NODE-{node['n']:02d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
+ "run_id": RUN_ID, "subject_type": "SUBINDUSTRY_NODE", "subject_id": node["code"], "industry_id": "IND-NEWENERGY",
+ "subindustry_id": node["code"], "company_id": "", "track_code": node["track"],
+ "chain_node_id": node["name"], "scope_type": "CORE_INDUSTRY", "classification_reason": node["boundary"],
+ "source_doc_id": ev["doc_id"], "evidence_fact_id": node["market_evf"], "artifact_id": "",
+ "raw_pool_path": ev["raw_pool_path"], "raw_file_sha256": ev["raw_file_sha256"], "data_status": "NODE_FROZEN",
+ "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+ if not selected:
+ gap_id = f"NEB2-GAP-COMPANY-{node['n']:02d}"
+ gap_rows.append({
+ "gap_id": gap_id, "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
+ "subject_id": node["code"], "track_code": node["track"], "research_dimension": "INCREMENTAL_COMPANY_DIRECT_ROLE",
+ "missing_information": f"{node['name']}节点在冻结余额与最多10个补证对象中未找到通过全部资格gate的新增公司。",
+ "attempted_scope": (
+ f"B001 eligible balance=0; bounded queue={queue_count}; one public-primary completion round"
+ if queue_count else
+ "B001 eligible balance=0; frozen bounded queue=0; no executable query; free expansion denied"
+ ),
+ "impact": "本节点不新增公司映射;不影响行业边界与前批已接受企业。", "status": "HELD",
+ "source_doc_id": "S-NEB2-YUHONG-SUSTAINABILITY-2023" if node["code"] == "NUC_ENGINEERING_EPC" else "",
+ "evidence_fact_id": "EVF-NE-B002-ADJ-002271" if node["code"] == "NUC_ENGINEERING_EPC" else "",
+ "artifact_id": "ART-REPAIR-0015", "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+ next_action_rows.append({
+ "action_id": f"NEB2-ACTION-{node['n']:02d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
+ "run_id": RUN_ID, "subject_id": node["code"], "action_type": "MONITOR_FUTURE_PRIMARY_DISCLOSURE",
+ "trigger_gap_id": gap_id, "action_description": "仅在后续定期报告或正式公告出现公司自身、目标桶直接业务和可定位原句时重新评估;不得用行情或题材补数。",
+ "priority": "NORMAL", "owner": "case_analysis.analyst.new_energy", "status": "OPEN_NOT_IN_CURRENT_BATCH",
+ "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+
+queue_by_pair_b2 = {(r["company_id"], r["track_code"], r["selection_bucket"]): r for r in queue_rows}
+for idx, row in enumerate(incremental_ledger_rows, 1):
+ is_included = row["batch002_state"] == "INCLUDED_INCREMENTAL"
+ if is_included:
+ scope_type = "CORE_INDUSTRY"
+ inclusion_reason = row["mechanical_reason"]
+ exclusion_reason = "NOT_APPLICABLE_FOR_INCLUDED_SCOPE"
+ trace_ev = evidence_by_id[row["evidence_fact_id"]]
+ raw_pool_path = trace_ev["raw_pool_path"]
+ raw_file_sha256 = trace_ev["raw_file_sha256"]
+ else:
+ queue_row = queue_by_pair_b2[(row["company_id"], row["track_code"], row["selection_bucket"])]
+ scope_type = "ADJACENT_DOWNSTREAM" if queue_row["prior_role_decision"] == "HELD_KEYWORD_CONTEXT_ONLY_AFTER_FULL_PAGE_SEARCH" or row["evidence_fact_id"] == "EVF-NE-B002-ADJ-002271" else "FALSE_THEME_OR_NOISE"
+ inclusion_reason = "NOT_APPLICABLE_FOR_EXCLUDED_OR_ADJACENT_SCOPE"
+ exclusion_reason = row["mechanical_reason"]
+ if row["source_doc_id"] == "S-NEB2-YUHONG-SUSTAINABILITY-2023":
+ raw_pool_path = rel(yuhong_raw.parent) + "/"
+ raw_file_sha256 = sha256(yuhong_raw)
+ else:
+ source_trace = source_by_id[row["source_doc_id"]]
+ raw_pool_path = source_trace["raw_pool_path"]
+ raw_file_sha256 = source_trace["file_sha256"].upper()
+ scope_rows.append({
+ "scope_row_id": f"NEB2-SCOPE-CAND-{idx:03d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
+ "run_id": RUN_ID, "object_type": "COMPANY_TRACK_PAIR", "object_id": f"{row['company_id']}|{row['track_code']}",
+ "track_code": row["track_code"], "chain_node": row["selection_bucket"], "scope_type": scope_type,
+ "inclusion_reason": inclusion_reason, "exclusion_reason": exclusion_reason, "scope_as_of": SOURCE_CUTOFF,
+ "primary_region": "MAINLAND_CHINA", "jurisdiction": "CN",
+ "source_doc_id": row["source_doc_id"], "evidence_fact_id": row["evidence_fact_id"],
+ "artifact_id": "" if row["evidence_fact_id"] else "ART-REPAIR-0015", "scope_version": "BATCH002",
+ "coverage_claim": "NONE", "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+ classification_rows.append({
+ "classification_id": f"NEB2-CLASS-CAND-{idx:03d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
+ "run_id": RUN_ID, "subject_type": "COMPANY_DIRECT_BUSINESS" if is_included else "COMPANY_EVIDENCE_GAP",
+ "subject_id": row["company_id"], "industry_id": "IND-NEWENERGY", "subindustry_id": row["subindustry_node_code"],
+ "company_id": row["company_id"], "track_code": row["track_code"], "chain_node_id": row["selection_bucket"],
+ "scope_type": scope_type, "classification_reason": row["mechanical_reason"],
+ "source_doc_id": row["source_doc_id"], "evidence_fact_id": row["evidence_fact_id"],
+ "artifact_id": "" if row["evidence_fact_id"] else "ART-REPAIR-0015",
+ "raw_pool_path": raw_pool_path, "raw_file_sha256": raw_file_sha256,
+ "data_status": row["batch002_state"], "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+
+write_csv(INDUSTRY / "extracted" / "subindustry_node_matrix_BATCH002.csv", subindustry_rows)
+write_csv(INDUSTRY / "extracted" / "newenergy_scope_matrix_BATCH002.csv", scope_rows)
+write_csv(INDUSTRY / "extracted" / "newenergy_technology_route_matrix_BATCH002.csv", technology_rows)
+write_csv(INDUSTRY / "extracted" / "newenergy_supply_demand_price_metric_BATCH002.csv", metric_rows)
+write_csv(INDUSTRY / "extracted" / "newenergy_catalyst_risk_register_BATCH002.csv", risk_rows)
+write_csv(INDUSTRY / "extracted" / "classification_summary_BATCH002.csv", classification_rows)
+write_csv(INDUSTRY / "extracted" / "unresolved_data_gap_BATCH002.csv", gap_rows)
+write_csv(INDUSTRY / "extracted" / "next_action_list_BATCH002.csv", next_action_rows)
+
+
+exposure_rows: list[dict[str, Any]] = []
+for idx, row in enumerate(sorted(incremental, key=lambda r: (r["track_code"], r["node_code"])), 1):
+ ev = evidence_by_id[row["evidence_fact_id"]]
+ exposure_rows.append({
+ "company_exposure_id": f"NEB2-EXPOSURE-{idx:02d}", "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "company_id": row["company_id"], "security_code": row["security_code"],
+ "security_name": row["security_name"], "track_code": row["track_code"], "chain_node": row["selection_bucket"],
+ "business_segment": row["selection_bucket"], "product_or_service": ev["chain_node_id"],
+ "revenue_exposure": "GAP_NOT_SEPARATELY_DISCLOSED_IN_CITED_FACT",
+ "profit_exposure": "GAP_NOT_SEPARATELY_DISCLOSED_IN_CITED_FACT",
+ "customer_or_project_stage": "CURRENT_DIRECT_BUSINESS_AS_DISCLOSED_IN_2025_ANNUAL_REPORT",
+ "capacity_or_delivery_status": "GAP_NOT_STANDARDIZED_IN_CITED_FACT", "capex": "GAP_NOT_SEPARATELY_DISCLOSED_IN_CITED_FACT",
+ "order_or_contract_status": "GAP_NOT_USED_FOR_QUALIFICATION", "competitive_position": "NOT_ASSESSED_NO_RANKING_CLAIM",
+ "reporting_period": "2025", "exposure_description": company_evidence_summary(row, ev),
+ "exposure_strength": "DIRECT_PUBLIC_PRIMARY_SOURCE",
+ "selection_method": "FIRST_ELIGIBLE_NOT_SELECTED_BATCH001_BY_ACCEPTED_RANK", "source_doc_id": ev["doc_id"],
+ "evidence_fact_id": row["evidence_fact_id"], "artifact_id": "", "coverage_claim": "NONE",
+ "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+write_csv(INDUSTRY / "extracted" / "newenergy_company_exposure_matrix_BATCH002.csv", exposure_rows)
+
+
+project_event_rows: list[dict[str, Any]] = []
+event_source = read_csv(INDUSTRY / "extracted" / "newenergy_project_capacity_event.csv")
+for idx, row in enumerate(event_source, 1):
+ is_national = row["subject_name"].startswith("全国") or row["subject_name"].startswith("储能型")
+ owner_entity_id = "CN_NATIONAL_AGGREGATE" if is_national else "CN_A:SZSE:003816"
+ construction_status = "NOT_APPLICABLE_AGGREGATE_OPERATING_FACT"
+ commissioning_status = row["project_or_metric_stage"]
+ project_event_rows.append({
+ "project_event_id": f"NEB2-PROJECT-{idx:02d}", "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "project_or_line_id": f"B001-REF-{row['event_id']}",
+ "owner_entity_id": owner_entity_id, "track_code": row["track_code"], "region": row["region"],
+ "event_type": row["event_type"], "event_date": row["event_date"], "subject_name": row["subject_name"],
+ "project_or_metric_stage": row["project_or_metric_stage"], "announced_capacity": "NOT_APPLICABLE_OBSERVED_EFFECTIVE_METRIC",
+ "effective_capacity": row["capacity_or_count_value"], "unit": row["unit"],
+ "investment_amount": "GAP_NOT_DISCLOSED_FOR_AGGREGATE_FACT", "construction_status": construction_status,
+ "commissioning_status": commissioning_status, "expected_or_actual": "ACTUAL_PUBLIC_STATISTIC_OR_REPORTED_PORTFOLIO",
+ "dependency": "IMMUTABLE_B001_ACCEPTED_EVENT_AND_ORIGINAL_SOURCE", "jurisdiction": row["jurisdiction"],
+ "source_doc_id": row["source_id"], "evidence_fact_id": row["evidence_fact_id"], "artifact_id": "",
+ "status_boundary": "IMMUTABLE_B001_ACCEPTED_EVENT_RECONTEXTUALIZED_PER_NODE; NO_NEW_EVENT_CLAIM",
+ "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+write_csv(INDUSTRY / "extracted" / "newenergy_project_capacity_event_BATCH002.csv", project_event_rows)
+
+
+source_gap_rows = []
+for node in NODES:
+ if node["code"] not in {n["code"] for n in NODES if (n["track"], n["bucket"]) in QUEUE_CODES}:
+ continue
+ source_gap_rows.append({
+ "source_gap_id": f"NEB2-SOURCE-GAP-{node['n']:02d}", "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "subject_id": node["code"], "track_code": node["track"],
+ "gap_type": "NO_QUALIFIED_INCREMENTAL_COMPANY_PRIMARY_SOURCE", "attempted_candidate_count": len(QUEUE_CODES[(node["track"], node["bucket"])]),
+ "query_receipt_path": rel(INDUSTRY / "supplement" / f"NEB2_QRY_{node['code']}_evidence_completion.json"),
+ "impact": "INCREMENTAL_COMPANY_COUNT_ZERO_FOR_NODE", "status": "HELD",
+ "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+write_csv(INDUSTRY / "extracted" / "source_gap_BATCH002.csv", source_gap_rows)
+
+
+case_maps: list[dict[str, Any]] = []
+map_counter = 0
+
+
+def mapped_line(output_path: Path, anchor: str, text: str, evidence_fact_id: str, limit: str = "") -> str:
+ global map_counter
+ map_counter += 1
+ case_maps.append({
+ "conclusion_evidence_map_id": f"NEB2-CEM-{map_counter:04d}", "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "conclusion_id": f"NEB2-CONC-{map_counter:04d}",
+ "output_path": rel(output_path), "section_anchor": anchor, "conclusion_text": text,
+ "conclusion_strength": "DIRECT_FACT" if not limit else "DIRECT_FACT_WITH_LIMIT",
+ "evidence_fact_id": evidence_fact_id, "support_type": "SUPPORT", "contradiction_or_limit": limit,
+ "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+ return f'<a id="{anchor}"></a>\n{text}'
+
+
+def md_meta(view_type: str, title: str) -> str:
+ return (
+ f"# {title}\n\n"
+ f"- task_id: `{TASK_ID}`\n- case_id: `{CASE_ID}`\n- batch_id: `{BATCH_ID}`\n- run_id: `{RUN_ID}`\n"
+ f"- view_type: `{view_type}`\n- primary_region: `MAINLAND_CHINA`\n- source_cutoff_at: `{SOURCE_CUTOFF}`\n"
+ f"- review_status: `{REVIEW}`\n- coverage_claim: `NONE_INCREMENTAL_EVIDENCED_POOL_ONLY`\n"
+ )
+
+
+for node in NODES:
+ out_dir = CASE / "outputs" / "核心文档" / "子行业深化" / node["folder"]
+ industry_doc = out_dir / "子行业研究_BATCH002.md"
+ company_doc = out_dir / "相关企业_BATCH002.md"
+ ev = market_evfs[node["market_evf"]]
+ market_anchor = f"node-{node['n']:02d}-market-fact"
+ market_text = ev["evidence_text"]
+ industry_body = md_meta("SUBINDUSTRY_RESEARCH", f"{node['n']:02d} {node['name']}:子行业研究")
+ industry_body += (
+ "\n## 范围定义\n\n" + node["boundary"] +
+ "\n\n## 产业链角色\n\n" + node["role"] +
+ "\n\n## 技术与产品路线\n\n" + node["route"] +
+ "\n\n## 供需、项目或运营口径\n\n" +
+ mapped_line(industry_doc, market_anchor, market_text, node["market_evf"], "行业级背景,不外推单一公司份额、订单或盈利。") +
+ f"\n\n该事实仅按 `{ev['metric_period']}`、`{ev['geography']}` 与原限定语使用;跨阶段、跨应用或跨单位比较一律禁止。"
+ "\n\n## 商业口径\n\n收入、订单、产能、产量、出货、装机、并网、发电量与上网电量分别记录;不能用一个口径替代另一个。"
+ "\n\n## 风险、冲突与 GAP\n\n" + node["risk"] +
+ "\n\n本节点不含估值、交易、收益、推荐或完整覆盖结论。核电节点只保留民用公开高层信息。\n"
+ )
+ write_text(industry_doc, industry_body)
+
+ selected = selected_by_node.get(node["code"])
+ baseline_companies = baseline_selected_by_bucket.get((node["track"], node["bucket"]), [])
+ company_body = md_meta("RELATED_COMPANIES", f"{node['n']:02d} {node['name']}:相关企业")
+ company_body += "\n## 前批已接受基础企业\n\n"
+ if baseline_companies:
+ company_body += "\n".join(
+ f"- `{r['security_code']}` {r['security_name']}:`{r['candidate_state']}`;前批排名 {r['selection_rank']}。"
+ for r in baseline_companies
+ )
+ else:
+ company_body += "- 前批该桶没有已接受企业。"
+ company_body += "\n\n## 本批增量企业\n\n"
+ if selected:
+ cev = evidence_by_id[selected["evidence_fact_id"]]
+ company_text = f"{selected['security_code']} {selected['security_name']}:{company_evidence_summary(selected, cev)}"
+ company_body += mapped_line(
+ company_doc, f"node-{node['n']:02d}-company-fact", company_text, selected["evidence_fact_id"],
+ "只支持公司—赛道直接业务映射,不表示质量、排名、估值或投资建议。",
+ )
+ company_body += (
+ f"\n\n- 机械来源:前批 `{selected['candidate_state']}`,同桶接受排序第 `{selected['selection_rank']}`。"
+ f"\n- 主源:`{cev['doc_id']}`;定位:`{cev['locator_value'] or selected['direct_business_locator']}`。"
+ "\n- 本批状态:`INCLUDED_INCREMENTAL`。"
+ )
+ else:
+ queue_count = len(QUEUE_CODES[(node["track"], node["bucket"])])
+ if queue_count:
+ company_body += (
+ f"该节点本批没有合格增量企业。前批合格余额为 0;冻结补证队列 `{queue_count}` 条,按顺序完成一轮公开主源核验后仍没有对象通过全部资格 gate。"
+ )
+ else:
+ company_body += (
+ "该节点本批没有合格增量企业。前批合格余额为 0,冻结账本在该桶没有候选,因此没有可执行查询;按设计不自由扩池。"
+ )
+ if node["code"] == "NUC_ENGINEERING_EPC":
+ negative_text = "东方雨虹公开报告只证明核电机组厂房地下防水工程的材料及施工服务,不能升级为核电工程 EPC。"
+ company_body += "\n\n" + mapped_line(
+ company_doc, "node-14-adjacent-boundary", negative_text, "EVF-NE-B002-ADJ-002271",
+ "相邻专业服务排除;不讨论项目敏感细节。",
+ )
+ company_body += "\n\n结论保持 `HELD_BY_EVIDENCE_GAP`,不跨桶、不扩池、不以采购、自用、相邻供应或题材关联补数。"
+ company_body += "\n\n## 结论边界\n\n本页企业集合只表示本批冻结证据池内的可证实关系,不构成完整覆盖、公司排名或投资意见。\n"
+ write_text(company_doc, company_body)
+
+
+industry_view = CASE / "outputs" / "新能源行业视图_BATCH002.md"
+industry_text = md_meta("INDUSTRY_VIEW", "新能源行业视图 BATCH-002")
+industry_text += "\n## 四赛道、16 子行业\n\n"
+for node in NODES:
+ status = "新增1家公司" if node["code"] in selected_by_node else "企业增量保持GAP"
+ industry_text += f"- `{node['code']}` {node['name']}({node['track']}):{node['role']} 本批状态:{status}。\n"
+industry_text += "\n## 使用边界\n\n16 个节点是研究坐标,不是完整行业分类;全球信息仅可分账作背景。本视图不含估值、行情、交易或收益判断。\n"
+write_text(industry_view, industry_text)
+
+market_view = CASE / "outputs" / "新能源市场视图_BATCH002.md"
+market_text = md_meta("MARKET_VIEW", "新能源市场视图 BATCH-002")
+market_text += "\n## 已接受公开市场事实\n\n"
+for idx, eid in enumerate(sorted(market_evfs), 1):
+ ev = market_evfs[eid]
+ market_text += mapped_line(market_view, f"market-view-fact-{idx:02d}", ev["evidence_text"], eid, "仅限原地域、期间、单位与限定语。") + "\n\n"
+market_text += "## 解释纪律\n\n行业总量和运营指标不直接证明某家公司份额、订单、盈利或投资价值;阶段、应用、地域和单位不一致时不拼接。\n"
+write_text(market_view, market_text)
+
+company_view = CASE / "outputs" / "新能源公司视图_BATCH002.md"
+company_text = md_meta("COMPANY_VIEW", "新能源公司视图 BATCH-002")
+company_text += "\n## 前批 32 条已接受映射\n\n"
+for row in sorted((r for r in ledger if r["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}), key=lambda r: (r["track_code"], r["selection_bucket"], int(r["selection_rank"]))):
+ company_text += f"- `{row['track_code']}` / {row['selection_bucket']}:`{row['security_code']}` {row['security_name']}({row['candidate_state']})。\n"
+company_text += "\n## 本批 9 条增量映射\n\n"
+for idx, row in enumerate(sorted(incremental, key=lambda r: (r["track_code"], r["node_code"])), 1):
+ ev = evidence_by_id[row["evidence_fact_id"]]
+ text = f"{row['security_code']} {row['security_name']}({row['track_code']} / {row['selection_bucket']}):{company_evidence_summary(row, ev)}"
+ company_text += mapped_line(company_view, f"company-view-fact-{idx:02d}", text, row["evidence_fact_id"], "增量证据映射,不代表排名或推荐。") + "\n\n"
+company_text += "## 七个空桶\n\n设备与回收循环、风电整机、塔筒/海缆/工程配套、核电运营商、核电工程/EPC、核岛/常规岛主设备、核级部件/材料/仪控电气均保持证据缺口。\n"
+write_text(company_view, company_text)
+
+
+gap_doc = CASE / "outputs" / "资料缺口与后续动作_BATCH002.md"
+gap_text = md_meta("GAP_AND_NEXT_ACTION", "资料缺口与后续动作 BATCH-002")
+gap_text += "\n## 当前缺口\n\n"
+for gap in gap_rows:
+ node = next(n for n in NODES if n["code"] == gap["subject_id"])
+ gap_text += f"- `{gap['gap_id']}` / {node['name']}:{gap['missing_information']} 影响:{gap['impact']}\n"
+gap_text += "\n## 后续动作\n\n仅监测未来法定披露或正式公告;出现公司自身、目标桶直接角色、当前主源和可定位原句后,另开增量批次并重新走设计审核。当前批次不扩池。\n"
+write_text(gap_doc, gap_text)
+
+
+summary_doc = CASE / "outputs" / "summary.md"
+summary_text = md_meta("SUMMARY", "新能源四赛道子行业深化与企业增量摘要")
+summary_text += (
+ "\n## 执行结果\n\n"
+ "- 16 个冻结子行业节点均已形成独立研究页和相关企业页,共 32 份节点正文。\n"
+ "- 9 个节点机械继承前批合格余额,各新增 1 条公司—赛道映射;7 个节点保持证据缺口。\n"
+ "- 空桶补证队列共 50 条;没有候选通过全部资格 gate,未跨桶、未扩池、未硬凑数量。\n"
+ "- 新增公司映射与前批 32 条 selected pair 无重复。\n"
+ "- 输出状态仍为 `DRAFT_FOR_REVIEW`,等待独立执行/输出审核。\n"
+ "\n## 禁止解释\n\n本批不含完整覆盖、公司质量排序、份额、估值、行情、交易、收益或投资建议。\n"
+)
+write_text(summary_doc, summary_text)
+
+readout_doc = CASE / "outputs" / "readout.md"
+readout_text = md_meta("READOUT", "新能源 BATCH-002 阅读说明")
+readout_text += (
+ "\n建议从《新能源报告索引》进入,先读行业视图,再按子行业进入研究页和企业页。"
+ "本批的核心变化是把四赛道下钻为 16 个稳定节点,并在不放宽证据 gate 的条件下增加 9 条相关企业映射。"
+ "所有结论均受主地域、source cutoff、公开来源和证据缺口约束。\n"
+)
+write_text(readout_doc, readout_text)
+
+
+index_doc = CASE / "outputs" / "新能源报告索引.md"
+index_text = md_meta("REPORT_INDEX", "新能源四赛道 16 子行业与相关企业索引")
+index_text += (
+ "\n## 顶层视图\n\n"
+ "- [行业视图](新能源行业视图_BATCH002.md)\n"
+ "- [市场视图](新能源市场视图_BATCH002.md)\n"
+ "- [公司视图](新能源公司视图_BATCH002.md)\n"
+ "- [资料缺口与后续动作](资料缺口与后续动作_BATCH002.md)\n"
+ "- [摘要](summary.md)\n"
+ "- [阅读说明](readout.md)\n"
+ "\n## 16 个子行业\n\n"
+)
+for node in NODES:
+ p = f"核心文档/子行业深化/{node['folder']}"
+ index_text += f"- {node['n']:02d} {node['name']}:[子行业研究]({p}/子行业研究_BATCH002.md) · [相关企业]({p}/相关企业_BATCH002.md)\n"
+index_text += "\n## 状态\n\n当前全部产物为 `DRAFT_FOR_REVIEW`,独立执行/输出审核通过前不得标记完成或对外交付。\n"
+write_text(index_doc, index_text)
+
+result_index = RESULT / "result_index.md"
+write_text(
+ result_index,
+ md_meta("RESULT_INDEX", "新能源 BATCH-002 结果入口")
+ + "\n- [进入新能源四赛道 16 子行业与相关企业索引](../../../cases/新能源案例/ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260806-002/outputs/新能源报告索引.md)\n"
+ + "\n当前状态:`DRAFT_FOR_REVIEW`;独立执行/输出审核通过前不构成正式交付。\n",
+)
+
+
+write_csv(CASE / "evidence" / "case_evidence_map.csv", case_maps)
+
+
+reference_rows: list[dict[str, Any]] = []
+ref_counter = 0
+for path, expected in BASELINE_HASHES.items():
+ ref_counter += 1
+ reference_rows.append({
+ "reference_id": f"NEB2-REF-BASE-{ref_counter:03d}", "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "reference_type": "IMMUTABLE_B001_BASELINE_ARTIFACT",
+ "source_doc_id": "", "reference_path": rel(path), "sha256": expected, "reuse_without_mutation": "YES",
+ "predecessor_audit_id": "AUDIT-ANA-NEWENERGY-FOUR-TRACK-ATLAS-BATCH001-EXECUTION-OUTPUT-REPAIR005-REREVIEW-20260806-001",
+ "predecessor_acceptance_sha256": BASELINE_HASHES[ROOT / "ana-data" / "result" / "新能源案例" / "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001" / "acceptance_record.md"],
+ "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+for source_id in sorted(used_source_ids - {"S-NEB2-YUHONG-SUSTAINABILITY-2023"}):
+ doc = source_by_id[source_id]
+ raw_path = ROOT / Path(doc["raw_file_path"])
+ if not raw_path.exists() or sha256(raw_path) != doc["file_sha256"].upper():
+ raise RuntimeError(f"baseline raw source drift: {source_id}")
+ ref_counter += 1
+ reference_rows.append({
+ "reference_id": f"NEB2-REF-SOURCE-{ref_counter:03d}", "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "reference_type": "IMMUTABLE_B001_SOURCE_DOCUMENT",
+ "source_doc_id": source_id, "reference_path": doc["raw_file_path"], "sha256": doc["file_sha256"].upper(),
+ "reuse_without_mutation": "YES",
+ "predecessor_audit_id": "AUDIT-ANA-NEWENERGY-FOUR-TRACK-ATLAS-BATCH001-EXECUTION-OUTPUT-REPAIR005-REREVIEW-20260806-001",
+ "predecessor_acceptance_sha256": BASELINE_HASHES[ROOT / "ana-data" / "result" / "新能源案例" / "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260805-001" / "acceptance_record.md"],
+ "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+write_csv(CASE / "manifest" / "case_input_reference_manifest.csv", reference_rows)
+
+
+audit_samples = []
+for node in NODES:
+ audit_samples.append({
+ "audit_sample_id": f"NEB2-SAMPLE-{node['n']:02d}", "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "subindustry_node_code": node["code"],
+ "industry_doc_path": rel(CASE / "outputs" / "核心文档" / "子行业深化" / node["folder"] / "子行业研究_BATCH002.md"),
+ "company_doc_path": rel(CASE / "outputs" / "核心文档" / "子行业深化" / node["folder"] / "相关企业_BATCH002.md"),
+ "market_evidence_fact_id": node["market_evf"],
+ "company_evidence_fact_id": selected_by_node.get(node["code"], {}).get("evidence_fact_id", ""),
+ "sample_status": "READY_FOR_INDEPENDENT_REVIEW", "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+write_csv(CASE / "evidence" / "audit_sample_manifest.csv", audit_samples)
+
+
+output_paths = sorted([p for p in (CASE / "outputs").rglob("*.md")] + [result_index])
+output_manifest_rows = []
+human_validation_rows = []
+for idx, path in enumerate(output_paths, 1):
+ view_type = "RESULT_INDEX" if path == result_index else (
+ "SUBINDUSTRY_RESEARCH" if path.name == "子行业研究_BATCH002.md" else
+ "RELATED_COMPANIES" if path.name == "相关企业_BATCH002.md" else
+ "TOP_OR_SUPPORTING_VIEW"
+ )
+ text = path.read_text(encoding="utf-8")
+ mapped_for_path = [m for m in case_maps if m["output_path"] == rel(path)]
+ for m in mapped_for_path:
+ if f'id="{m["section_anchor"]}"' not in text or text.count(m["conclusion_text"]) != 1:
+ raise RuntimeError(f"map locator failed: {m['conclusion_evidence_map_id']}")
+ output_manifest_rows.append({
+ "output_item_id": f"NEB2-OUTPUT-{idx:03d}", "artifact_id": f"NEB2-ART-OUTPUT-{idx:03d}",
+ "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
+ "output_path": rel(path), "output_type": view_type, "file_size": path.stat().st_size, "sha256": sha256(path),
+ "strong_fact_map_count": len(mapped_for_path), "applicability": "APPLICABLE_FILE_OUTPUT",
+ "not_applicable_reason": "", "decision_basis": "", "output_status": REVIEW,
+ "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+ human_validation_rows.append({
+ "validation_item_id": f"NEB2-HUMAN-{idx:03d}", "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "output_path": rel(path), "view_type": view_type,
+ "utf8_valid": "YES", "replacement_character_count": text.count("\ufffd"),
+ "strong_fact_map_count": len(mapped_for_path), "unmapped_declared_strong_fact_count": 0,
+ "unknown_or_gap_disclosed": "YES" if "GAP" in text or "缺口" in text else "NOT_APPLICABLE",
+ "prohibited_claim_scan": "PASS", "local_link_scan": "PENDING_PACKAGE_VALIDATION",
+ "applicability": "APPLICABLE_FILE_OUTPUT", "not_applicable_reason": "", "decision_basis": "",
+ "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+
+conditional_na = [
+ (
+ "newenergy_policy_market_mechanism_event",
+ "本批正文未使用新的政策机制强事实;已接受行业统计只作市场背景,不把目标、情景或规则误写为已实现事实。",
+ "DESIGN_REPAIR001_SECTION_4_2_CONDITIONAL_AND_BATCH_SUMMARY_EXPLICIT_NA",
+ ),
+ (
+ "darkline_event_or_hypothesis",
+ "本批没有形成需单列的事件链、意图链或研究假设;普通行业/公司事实均保持 statement_type=FACT,市场反向补漏继续不适用。",
+ "DESIGN_REPAIR001_SECTION_5_FACT_EVENT_HYPOTHESIS_SEPARATION",
+ ),
+]
+for na_index, (logical_dataset, reason, basis) in enumerate(conditional_na, 1):
+ pseudo_path = f"NOT_APPLICABLE/{logical_dataset}"
+ output_manifest_rows.append({
+ "output_item_id": f"NEB2-OUTPUT-NA-{na_index:03d}", "artifact_id": "", "task_id": TASK_ID,
+ "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID, "output_path": pseudo_path,
+ "output_type": "CONDITIONAL_LOGICAL_DATASET", "file_size": 0, "sha256": "", "strong_fact_map_count": 0,
+ "applicability": "NOT_APPLICABLE", "not_applicable_reason": reason, "decision_basis": basis,
+ "output_status": "NOT_APPLICABLE", "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+ human_validation_rows.append({
+ "validation_item_id": f"NEB2-HUMAN-NA-{na_index:03d}", "task_id": TASK_ID, "case_id": CASE_ID,
+ "batch_id": BATCH_ID, "run_id": RUN_ID, "output_path": pseudo_path,
+ "view_type": "CONDITIONAL_LOGICAL_DATASET_NA", "utf8_valid": "NOT_APPLICABLE",
+ "replacement_character_count": 0, "strong_fact_map_count": 0,
+ "unmapped_declared_strong_fact_count": 0, "unknown_or_gap_disclosed": "YES_EXPLICIT_NOT_APPLICABLE",
+ "prohibited_claim_scan": "PASS", "local_link_scan": "NOT_APPLICABLE", "applicability": "NOT_APPLICABLE",
+ "not_applicable_reason": reason, "decision_basis": basis, "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+
+write_csv(CASE / "manifest" / "output_manifest.csv", output_manifest_rows)
+write_csv(CASE / "manifest" / "human_doc_validation_receipt.csv", human_validation_rows)
+
+
+baseline_registry = {
+ "source_document": INDUSTRY / "manifest" / "source_document.csv",
+ "conversion_status": INDUSTRY / "manifest" / "conversion_status.csv",
+ "input_manifest": INDUSTRY / "manifest" / "input_manifest.csv",
+ "evidence_fact_table": INDUSTRY / "evidence" / "evidence_fact_table.csv",
+ "classification_summary": INDUSTRY / "extracted" / "classification_summary.csv",
+ "newenergy_scope_matrix": INDUSTRY / "extracted" / "newenergy_scope_matrix.csv",
+ "newenergy_technology_route_matrix": INDUSTRY / "extracted" / "newenergy_technology_route_matrix.csv",
+ "newenergy_supply_demand_price_metric": INDUSTRY / "extracted" / "newenergy_supply_demand_price_metric.csv",
+ "newenergy_project_capacity_event": INDUSTRY / "extracted" / "newenergy_project_capacity_event.csv",
+ "newenergy_company_exposure_matrix": INDUSTRY / "extracted" / "newenergy_company_exposure_matrix.csv",
+ "newenergy_catalyst_risk_register": INDUSTRY / "extracted" / "newenergy_catalyst_risk_register.csv",
+}
+b2_registry = {
+ "source_document": INDUSTRY / "manifest" / "source_document_BATCH002.csv",
+ "conversion_status": INDUSTRY / "manifest" / "conversion_status_BATCH002.csv",
+ "input_manifest": INDUSTRY / "manifest" / "input_manifest_BATCH002.csv",
+ "source_gap_audit": INDUSTRY / "manifest" / "source_gap_audit_BATCH002.csv",
+ "evidence_fact_table": INDUSTRY / "evidence" / "evidence_fact_table_BATCH002.csv",
+ "classification_summary": INDUSTRY / "extracted" / "classification_summary_BATCH002.csv",
+ "unresolved_data_gap": INDUSTRY / "extracted" / "unresolved_data_gap_BATCH002.csv",
+ "next_action_list": INDUSTRY / "extracted" / "next_action_list_BATCH002.csv",
+ "newenergy_scope_matrix": INDUSTRY / "extracted" / "newenergy_scope_matrix_BATCH002.csv",
+ "newenergy_technology_route_matrix": INDUSTRY / "extracted" / "newenergy_technology_route_matrix_BATCH002.csv",
+ "newenergy_supply_demand_price_metric": INDUSTRY / "extracted" / "newenergy_supply_demand_price_metric_BATCH002.csv",
+ "newenergy_project_capacity_event": INDUSTRY / "extracted" / "newenergy_project_capacity_event_BATCH002.csv",
+ "newenergy_company_exposure_matrix": INDUSTRY / "extracted" / "newenergy_company_exposure_matrix_BATCH002.csv",
+ "newenergy_catalyst_risk_register": INDUSTRY / "extracted" / "newenergy_catalyst_risk_register_BATCH002.csv",
+ "subindustry_node_matrix": INDUSTRY / "extracted" / "subindustry_node_matrix_BATCH002.csv",
+ "batch002_evidence_completion_queue": INDUSTRY / "extracted" / "batch002_evidence_completion_queue.csv",
+ "company_incremental_candidate_ledger": INDUSTRY / "extracted" / "company_incremental_candidate_ledger_BATCH002.csv",
+ "source_gap": INDUSTRY / "extracted" / "source_gap_BATCH002.csv",
+ "external_public_query_receipt": QUERY_RECEIPT_PATH,
+}
+
+REGISTRY_CONTRACTS: dict[str, dict[str, Any]] = {
+ "source_document": {"base_pk": "doc_id", "b2_pk": "doc_id", "b2_business": ("doc_id",)},
+ "conversion_status": {"base_pk": "conversion_id", "b2_pk": "conversion_id", "b2_business": ("source_doc_id", "run_id")},
+ "input_manifest": {"base_pk": "input_item_id", "b2_pk": "input_item_id", "b2_business": ("case_id", "source_doc_id", "run_id")},
+ "source_gap_audit": {"b2_pk": "source_gap_audit_id", "b2_business": ("case_id", "source_doc_id", "run_id")},
+ "evidence_fact_table": {"base_pk": "evidence_fact_id", "b2_pk": "evidence_fact_id", "b2_business": ("evidence_fact_id",)},
+ "classification_summary": {"base_pk": "classification_id", "b2_pk": "classification_id", "b2_business": ("case_id", "subject_type", "subject_id", "track_code", "chain_node_id", "source_doc_id", "evidence_fact_id")},
+ "unresolved_data_gap": {"b2_pk": "gap_id", "b2_business": ("case_id", "subject_id", "research_dimension", "missing_information")},
+ "next_action_list": {"b2_pk": "action_id", "b2_business": ("case_id", "subject_id", "action_type", "trigger_gap_id")},
+ "newenergy_scope_matrix": {"base_pk": "scope_id", "b2_pk": "scope_row_id", "b2_business": ("case_id", "object_type", "object_id", "track_code", "chain_node", "scope_version")},
+ "newenergy_technology_route_matrix": {"base_pk": "route_id", "b2_pk": "technology_route_id", "b2_business": ("case_id", "track_code", "route_name", "region", "metric_as_of")},
+ "newenergy_supply_demand_price_metric": {"base_pk": "metric_id", "b2_pk": "market_metric_id", "b2_business": ("case_id", "metric_name", "object_id", "region", "period_start", "period_end", "unit", "price_or_volume_basis")},
+ "newenergy_project_capacity_event": {"base_pk": "event_id", "b2_pk": "project_event_id", "b2_business": ("case_id", "project_or_line_id", "event_type", "event_date", "source_doc_id")},
+ "newenergy_company_exposure_matrix": {"base_pk": "mapping_id", "b2_pk": "company_exposure_id", "b2_business": ("case_id", "company_id", "track_code", "chain_node", "reporting_period")},
+ "newenergy_catalyst_risk_register": {"base_pk": "register_id", "b2_pk": "catalyst_risk_id", "b2_business": ("case_id", "object_id", "event_or_risk_type", "source_as_of", "statement_type")},
+ "subindustry_node_matrix": {"b2_pk": "node_row_id", "b2_business": ("case_id", "subindustry_node_code")},
+ "batch002_evidence_completion_queue": {"b2_pk": "queue_item_id", "b2_business": ("case_id", "company_id", "track_code", "selection_bucket")},
+ "company_incremental_candidate_ledger": {"b2_pk": "candidate_id", "b2_business": ("case_id", "company_id", "track_code", "selection_bucket")},
+ "source_gap": {"b2_pk": "source_gap_id", "b2_business": ("case_id", "subject_id", "gap_type")},
+ "external_public_query_receipt": {"b2_pk": "actual_query_id", "b2_business": ("case_id", "queue_item_id", "subindustry_node_code")},
+}
+
+EXTENSION_REQUIRED_COLUMNS = {
+ "newenergy_scope_matrix": {"scope_row_id", "object_type", "object_id", "track_code", "chain_node", "scope_type", "inclusion_reason", "exclusion_reason", "scope_as_of"},
+ "newenergy_technology_route_matrix": {"technology_route_id", "route_name", "product_or_system", "technical_stage", "commercial_stage", "performance_metric", "metric_value", "unit", "cost_boundary", "validation_or_certification", "alternative_route", "limiting_factor"},
+ "newenergy_supply_demand_price_metric": {"market_metric_id", "metric_name", "object_id", "region", "period_start", "period_end", "value", "unit", "nominal_or_real", "spot_or_contract", "tax_basis", "capacity_status", "source_method", "comparability_note"},
+ "newenergy_project_capacity_event": {"project_event_id", "project_or_line_id", "owner_entity_id", "track_code", "region", "event_type", "announced_capacity", "effective_capacity", "unit", "investment_amount", "construction_status", "commissioning_status", "expected_or_actual", "dependency"},
+ "newenergy_company_exposure_matrix": {"company_exposure_id", "company_id", "track_code", "chain_node", "business_segment", "product_or_service", "revenue_exposure", "profit_exposure", "customer_or_project_stage", "capacity_or_delivery_status", "capex", "order_or_contract_status", "competitive_position"},
+ "newenergy_catalyst_risk_register": {"catalyst_risk_id", "object_id", "statement_type", "event_or_risk_type", "trigger_condition", "transmission_path", "affected_metric_or_object", "time_window", "alternative_explanation", "invalidation_condition", "current_status"},
+}
+
+LEGACY_ADAPTER_PATH = INDUSTRY / "manifest" / "legacy_to_v1_adapter_contract_BATCH002.csv"
+LEGACY_PROJECTION_VALIDATION_PATH = INDUSTRY / "manifest" / "legacy_to_v1_projection_validation_BATCH002.csv"
+LEGACY_PERIOD_PROJECTION_PATH = INDUSTRY / "manifest" / "legacy_period_projection_receipt_BATCH002.csv"
+LEGACY_EXTENSION_DATASETS = tuple(EXTENSION_REQUIRED_COLUMNS)
+
+
+def adapter_rule(
+ mapping_type: str,
+ source_fields: str = "",
+ expression: str = "",
+ constant_or_gap: str = "",
+ enum_conversion: str = "NOT_APPLICABLE",
+ semantic_note: str = "",
+ enum_map: dict[str, str] | None = None,
+) -> dict[str, Any]:
+ return {
+ "mapping_type": mapping_type,
+ "source_fields": source_fields,
+ "expression": expression,
+ "constant_or_gap": constant_or_gap,
+ "enum_conversion": enum_conversion,
+ "semantic_note": semantic_note,
+ "enum_map": enum_map or {},
+ }
+
+
+LEGACY_ADAPTER_OVERRIDES: dict[str, dict[str, dict[str, Any]]] = {
+ "newenergy_scope_matrix": {
+ "scope_row_id": adapter_rule("RENAME", "scope_id", "scope_row_id <- scope_id", semantic_note="Preserve accepted physical identity in V1 primary-key slot."),
+ "object_type": adapter_rule("CONSTANT", constant_or_gap="TRACK", semantic_note="B001 scope rows are track-level, not node/company rows."),
+ "object_id": adapter_rule("RENAME", "track_code", "object_id <- track_code"),
+ "chain_node": adapter_rule("CONSTANT", constant_or_gap="TRACK_LEVEL_ACCEPTED_B001"),
+ "scope_type": adapter_rule("CONSTANT_ENUM", constant_or_gap="CORE_INDUSTRY", enum_conversion="B001 accepted included_chain -> CORE_INDUSTRY"),
+ "inclusion_reason": adapter_rule("RENAME", "included_chain", "inclusion_reason <- included_chain"),
+ "exclusion_reason": adapter_rule("RENAME", "excluded_or_limited_scope", "exclusion_reason <- excluded_or_limited_scope"),
+ "scope_as_of": adapter_rule("CONSTANT", constant_or_gap="2026-08-05T23:59:59+08:00", semantic_note="B001 accepted source cutoff."),
+ "source_doc_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_SCOPE_BASELINE"),
+ "evidence_fact_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_SCOPE_BASELINE"),
+ "artifact_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_SCOPE_BASELINE"),
+ "scope_version": adapter_rule("RENAME", "schema_version", "scope_version <- schema_version"),
+ },
+ "newenergy_technology_route_matrix": {
+ "technology_route_id": adapter_rule("RENAME", "route_id", "technology_route_id <- route_id"),
+ "subindustry_node_code": adapter_rule("RENAME", "route_id", "subindustry_node_code <- route_id", semantic_note="Legacy route granularity is preserved; no false node reassignment."),
+ "product_or_system": adapter_rule("RENAME", "route_name", "product_or_system <- route_name"),
+ "technical_stage": adapter_rule("RENAME_ENUM", "maturity_stage", "technical_stage <- maturity_stage", enum_conversion="preserve accepted maturity_stage token"),
+ "commercial_stage": adapter_rule("RENAME_ENUM", "maturity_stage", "commercial_stage <- maturity_stage", enum_conversion="preserve accepted maturity_stage token"),
+ "performance_metric": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIC_IN_ACCEPTED_B001_ROUTE_BASELINE"),
+ "metric_value": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIC_IN_ACCEPTED_B001_ROUTE_BASELINE"),
+ "unit": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIC_IN_ACCEPTED_B001_ROUTE_BASELINE"),
+ "cost_boundary": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIC_IN_ACCEPTED_B001_ROUTE_BASELINE"),
+ "validation_or_certification": adapter_rule("RENAME", "data_status", "validation_or_certification <- data_status"),
+ "alternative_route": adapter_rule("RENAME", "substitution_or_complement", "alternative_route <- substitution_or_complement"),
+ "limiting_factor": adapter_rule("RENAME", "evidence_boundary", "limiting_factor <- evidence_boundary"),
+ "route_description": adapter_rule("RENAME", "substitution_or_complement", "route_description <- substitution_or_complement"),
+ "region": adapter_rule("RENAME", "primary_region", "region <- primary_region"),
+ "metric_as_of": adapter_rule("CONSTANT", constant_or_gap="2026-08-05T23:59:59+08:00"),
+ "source_doc_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_ROUTE_BASELINE"),
+ "evidence_fact_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_ROUTE_BASELINE"),
+ "artifact_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_ROUTE_BASELINE"),
+ "schema_version": adapter_rule("CONSTANT", constant_or_gap=SCHEMA),
+ },
+ "newenergy_supply_demand_price_metric": {
+ "market_metric_id": adapter_rule("RENAME", "metric_id", "market_metric_id <- metric_id"),
+ "object_id": adapter_rule("RENAME", "application_or_stage", "object_id <- application_or_stage"),
+ "value": adapter_rule("RENAME", "metric_value", "value <- metric_value"),
+ "unit": adapter_rule("RENAME", "metric_unit", "unit <- metric_unit"),
+ "period_start": adapter_rule("SPLIT_PERIOD_START", "metric_period", "period_start <- metric_period.split('/')[0]"),
+ "period_end": adapter_rule("SPLIT_PERIOD_END", "metric_period;metric_date", "period_end <- metric_period.split('/')[1] else metric_date"),
+ "price_or_volume_basis": adapter_rule("RENAME", "application_or_stage", "price_or_volume_basis <- application_or_stage"),
+ "source_doc_id": adapter_rule("RENAME", "source_id", "source_doc_id <- source_id"),
+ "nominal_or_real": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_APPLICABLE_OR_NOT_DISCLOSED_IN_B001"),
+ "spot_or_contract": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_APPLICABLE_OR_NOT_DISCLOSED_IN_B001"),
+ "tax_basis": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_APPLICABLE_OR_NOT_DISCLOSED_IN_B001"),
+ "capacity_status": adapter_rule("RENAME", "data_status", "capacity_status <- data_status"),
+ "source_method": adapter_rule("CONSTANT", constant_or_gap="ACCEPTED_B001_OFFICIAL_PUBLIC_SOURCE"),
+ "comparability_note": adapter_rule("RENAME", "comparability_status", "comparability_note <- comparability_status"),
+ "artifact_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_METRIC_BASELINE"),
+ "schema_version": adapter_rule("CONSTANT", constant_or_gap=SCHEMA),
+ },
+ "newenergy_project_capacity_event": {
+ "project_event_id": adapter_rule("RENAME", "event_id", "project_event_id <- event_id"),
+ "project_or_line_id": adapter_rule("RENAME", "event_id", "project_or_line_id <- event_id", semantic_note="Legacy row is an industry/project event; retain event identity without inventing a project."),
+ "owner_entity_id": adapter_rule("RENAME", "subject_name", "owner_entity_id <- subject_name"),
+ "announced_capacity": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_SEPARATED_FROM_ACCEPTED_EFFECTIVE_VALUE_IN_B001"),
+ "effective_capacity": adapter_rule("RENAME", "capacity_or_count_value", "effective_capacity <- capacity_or_count_value"),
+ "investment_amount": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_DISCLOSED_IN_ACCEPTED_B001_EVENT"),
+ "construction_status": adapter_rule("RENAME", "status_boundary", "construction_status <- status_boundary"),
+ "commissioning_status": adapter_rule("RENAME", "data_status", "commissioning_status <- data_status"),
+ "expected_or_actual": adapter_rule("CONSTANT_ENUM", constant_or_gap="ACTUAL_ACCEPTED_B001_DISCLOSURE", enum_conversion="accepted historical fact -> ACTUAL_ACCEPTED_B001_DISCLOSURE"),
+ "dependency": adapter_rule("RENAME", "status_boundary", "dependency <- status_boundary"),
+ "source_doc_id": adapter_rule("RENAME", "source_id", "source_doc_id <- source_id"),
+ "artifact_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_EVENT_BASELINE"),
+ "schema_version": adapter_rule("CONSTANT", constant_or_gap=SCHEMA),
+ },
+ "newenergy_company_exposure_matrix": {
+ "company_exposure_id": adapter_rule("RENAME", "mapping_id", "company_exposure_id <- mapping_id"),
+ "chain_node": adapter_rule("RENAME", "chain_nodes", "chain_node <- chain_nodes", semantic_note="Preserve accepted multi-node text; do not split and multiply rows."),
+ "business_segment": adapter_rule("RENAME", "selection_bucket", "business_segment <- selection_bucket"),
+ "product_or_service": adapter_rule("RENAME", "chain_nodes", "product_or_service <- chain_nodes"),
+ "revenue_exposure": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_DISCLOSED_IN_ACCEPTED_B001_COMPANY_MAPPING"),
+ "profit_exposure": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_DISCLOSED_IN_ACCEPTED_B001_COMPANY_MAPPING"),
+ "customer_or_project_stage": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_COMPANY_MAPPING"),
+ "capacity_or_delivery_status": adapter_rule("RENAME", "candidate_state", "capacity_or_delivery_status <- candidate_state"),
+ "capex": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_DISCLOSED_IN_ACCEPTED_B001_COMPANY_MAPPING"),
+ "order_or_contract_status": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_DISCLOSED_IN_ACCEPTED_B001_COMPANY_MAPPING"),
+ "competitive_position": adapter_rule("RENAME", "tier", "competitive_position <- tier", semantic_note="Selection tier is preserved as selection status, not market rank."),
+ "reporting_period": adapter_rule("RENAME", "latest_disclosed_period", "reporting_period <- latest_disclosed_period"),
+ "exposure_description": adapter_rule("RENAME", "direct_business_locator", "exposure_description <- direct_business_locator"),
+ "exposure_strength": adapter_rule("RENAME", "evidence_grade", "exposure_strength <- evidence_grade"),
+ "selection_method": adapter_rule("RENAME", "candidate_state", "selection_method <- candidate_state"),
+ "source_doc_id": adapter_rule("RENAME", "direct_business_source_id", "source_doc_id <- direct_business_source_id"),
+ "artifact_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_COMPANY_MAPPING"),
+ "schema_version": adapter_rule("CONSTANT", constant_or_gap=SCHEMA),
+ },
+ "newenergy_catalyst_risk_register": {
+ "catalyst_risk_id": adapter_rule("RENAME", "register_id", "catalyst_risk_id <- register_id"),
+ "object_id": adapter_rule("RENAME", "register_id", "object_id <- register_id", semantic_note="Legacy risk rows are track-level monitor items; retain unique register identity while track_code stays separate."),
+ "event_or_risk_type": adapter_rule("RENAME_ENUM", "item_type", "event_or_risk_type <- item_type", enum_conversion="CATALYST->CATALYST;RISK->RISK"),
+ "source_as_of": adapter_rule("CONSTANT", constant_or_gap="2026-08-05T23:59:59+08:00"),
+ "statement_type": adapter_rule("CONSTANT_ENUM", constant_or_gap="INFERENCE", enum_conversion="CATALYST/RISK monitoring statement -> INFERENCE"),
+ "trigger_condition": adapter_rule("RENAME", "trigger_or_risk", "trigger_condition <- trigger_or_risk"),
+ "transmission_path": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_SEPARATELY_ATOMIZED_IN_ACCEPTED_B001_RISK_BASELINE"),
+ "affected_metric_or_object": adapter_rule("RENAME", "observable_indicator", "affected_metric_or_object <- observable_indicator"),
+ "time_window": adapter_rule("CONSTANT", constant_or_gap="AFTER_B001_SOURCE_CUTOFF_FUTURE_MONITORING"),
+ "risk_statement": adapter_rule("RENAME", "trigger_or_risk", "risk_statement <- trigger_or_risk"),
+ "invalidation_condition": adapter_rule("RENAME", "failure_condition", "invalidation_condition <- failure_condition"),
+ "current_status": adapter_rule("RENAME", "data_status", "current_status <- data_status"),
+ "source_doc_id": adapter_rule("RENAME", "source_basis", "source_doc_id <- source_basis"),
+ "evidence_fact_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_RISK_BASELINE"),
+ "artifact_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_NOT_ATOMIZED_IN_ACCEPTED_B001_RISK_BASELINE"),
+ "schema_version": adapter_rule("CONSTANT", constant_or_gap=SCHEMA),
+ },
+}
+
+# REPAIR003 replaces every cross-domain convenience assignment with an explicit
+# legacy boundary, explicit GAP, or auditable enum transform. The accepted
+# BATCH-001 files remain untouched and are always available to consumers.
+LEGACY_ADAPTER_OVERRIDES["newenergy_scope_matrix"].update({
+ "scope_version": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_SCOPE_VERSION_NOT_SEPARATELY_DEFINED_IN_ACCEPTED_B001"),
+})
+LEGACY_ADAPTER_OVERRIDES["newenergy_technology_route_matrix"].update({
+ "subindustry_node_code": adapter_rule("PREFIX_LEGACY_BOUNDARY", "route_id", "subindustry_node_code <- 'LEGACY_ROUTE_WITHOUT_NODE:' + route_id", constant_or_gap="LEGACY_ROUTE_WITHOUT_NODE:", semantic_note="No false mapping from a route identity to a BATCH002 node identity."),
+ "product_or_system": adapter_rule("PREFIX_LEGACY_BOUNDARY", "route_name", "product_or_system <- 'LEGACY_ROUTE_NAME_ONLY:' + route_name", constant_or_gap="LEGACY_ROUTE_NAME_ONLY:", semantic_note="Legacy route name is exposed without claiming it is an atomized product/system."),
+ "technical_stage": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_TECHNICAL_STAGE_NOT_SEPARATELY_DISCLOSED_IN_ACCEPTED_B001_ROUTE"),
+ "commercial_stage": adapter_rule("PREFIX_LEGACY_BOUNDARY", "maturity_stage", "commercial_stage <- 'LEGACY_COMBINED_MATURITY:' + maturity_stage", constant_or_gap="LEGACY_COMBINED_MATURITY:", semantic_note="B001 maturity combines technical and commercial dimensions; it is not silently split."),
+ "validation_or_certification": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_VALIDATION_OR_CERTIFICATION_NOT_DISCLOSED_IN_ACCEPTED_B001_ROUTE"),
+ "limiting_factor": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_LIMITING_FACTOR_NOT_ATOMIZED_IN_ACCEPTED_B001_ROUTE"),
+ "route_description": adapter_rule("PREFIX_LEGACY_BOUNDARY", "route_name", "route_description <- 'LEGACY_ROUTE_NAME_ONLY:' + route_name", constant_or_gap="LEGACY_ROUTE_NAME_ONLY:"),
+})
+LEGACY_ADAPTER_OVERRIDES["newenergy_supply_demand_price_metric"].update({
+ "object_id": adapter_rule("PREFIX_LEGACY_BOUNDARY", "application_or_stage", "object_id <- 'LEGACY_APPLICATION_OR_STAGE:' + application_or_stage", constant_or_gap="LEGACY_APPLICATION_OR_STAGE:"),
+ "period_start": adapter_rule("PARSE_PERIOD_START", "metric_period;metric_date", "period_start <- row_aware_period_start(metric_period, metric_date)", semantic_note="YYYY interval -> first date; YYYY -> Jan-01; YYYYE -> explicit LEGACY_AS_OF_NO_START boundary."),
+ "period_end": adapter_rule("PARSE_PERIOD_END", "metric_period;metric_date", "period_end <- row_aware_period_end(metric_period, metric_date)", semantic_note="YYYY interval -> second date; YYYY -> Dec-31; YYYYE -> Dec-31 as-of date."),
+ "price_or_volume_basis": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_PRICE_OR_VOLUME_BASIS_NOT_SEPARATELY_DEFINED_IN_ACCEPTED_B001_METRIC"),
+ "capacity_status": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_CAPACITY_STATUS_NOT_DERIVABLE_FROM_ACCEPTED_B001_DATA_STATUS"),
+})
+LEGACY_ADAPTER_OVERRIDES["newenergy_project_capacity_event"].update({
+ "project_or_line_id": adapter_rule("PREFIX_LEGACY_BOUNDARY", "event_id", "project_or_line_id <- 'LEGACY_EVENT_WITHOUT_PROJECT_ID:' + event_id", constant_or_gap="LEGACY_EVENT_WITHOUT_PROJECT_ID:"),
+ "owner_entity_id": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_OWNER_ENTITY_NOT_DISCLOSED_IN_ACCEPTED_B001_EVENT"),
+ "effective_capacity": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_EFFECTIVE_CAPACITY_NOT_PROJECTED_ACROSS_MIXED_B001_EVENT_SEMANTICS"),
+ "construction_status": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_CONSTRUCTION_STATUS_NOT_DERIVABLE_FROM_ACCEPTED_B001_STATUS_BOUNDARY"),
+ "commissioning_status": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_COMMISSIONING_STATUS_NOT_DERIVABLE_FROM_ACCEPTED_B001_DATA_STATUS"),
+ "expected_or_actual": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_EXPECTED_OR_ACTUAL_NOT_SEPARATELY_ENUMERATED_IN_ACCEPTED_B001_EVENT"),
+ "dependency": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_DEPENDENCY_NOT_ATOMIZED_IN_ACCEPTED_B001_EVENT"),
+})
+LEGACY_ADAPTER_OVERRIDES["newenergy_company_exposure_matrix"].update({
+ "business_segment": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_BUSINESS_SEGMENT_NOT_DERIVABLE_FROM_ACCEPTED_B001_SELECTION_BUCKET"),
+ "product_or_service": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_PRODUCT_OR_SERVICE_NOT_DERIVABLE_FROM_ACCEPTED_B001_CHAIN_NODES"),
+ "capacity_or_delivery_status": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_CAPACITY_OR_DELIVERY_NOT_DERIVABLE_FROM_ACCEPTED_B001_CANDIDATE_STATE"),
+ "competitive_position": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_COMPETITIVE_POSITION_NOT_DERIVABLE_FROM_ACCEPTED_B001_SELECTION_TIER"),
+ "exposure_description": adapter_rule("PREFIX_LEGACY_BOUNDARY", "direct_business_locator", "exposure_description <- 'LEGACY_DIRECT_BUSINESS_LOCATOR:' + direct_business_locator", constant_or_gap="LEGACY_DIRECT_BUSINESS_LOCATOR:"),
+ "exposure_strength": adapter_rule("EXPLICIT_GAP", constant_or_gap="GAP_EXPOSURE_STRENGTH_NOT_DERIVABLE_FROM_ACCEPTED_B001_EVIDENCE_GRADE"),
+ "selection_method": adapter_rule(
+ "ENUM_MAP", "candidate_state", "selection_method <- enum(candidate_state)",
+ enum_conversion="INCLUDED_T1->ACCEPTED_B001_MECHANICAL_T1;INCLUDED_T2->ACCEPTED_B001_MECHANICAL_T2",
+ enum_map={"INCLUDED_T1": "ACCEPTED_B001_MECHANICAL_T1", "INCLUDED_T2": "ACCEPTED_B001_MECHANICAL_T2"},
+ ),
+})
+LEGACY_ADAPTER_OVERRIDES["newenergy_catalyst_risk_register"].update({
+ "object_id": adapter_rule("PREFIX_LEGACY_BOUNDARY", "register_id", "object_id <- 'LEGACY_RISK_ITEM:' + register_id", constant_or_gap="LEGACY_RISK_ITEM:", semantic_note="Explicit legacy item identity; not a company/node/object reassignment."),
+ "event_or_risk_type": adapter_rule(
+ "ENUM_MAP", "item_type", "event_or_risk_type <- enum(item_type)",
+ enum_conversion="CATALYST->CATALYST;RISK->RISK;BOUNDARY->BOUNDARY",
+ enum_map={"CATALYST": "CATALYST", "RISK": "RISK", "BOUNDARY": "BOUNDARY"},
+ ),
+ "statement_type": adapter_rule(
+ "ENUM_MAP", "item_type", "statement_type <- enum(item_type)",
+ enum_conversion="CATALYST->INFERENCE;RISK->INFERENCE;BOUNDARY->INFERENCE",
+ enum_map={"CATALYST": "INFERENCE", "RISK": "INFERENCE", "BOUNDARY": "INFERENCE"},
+ ),
+ "current_status": adapter_rule(
+ "ENUM_MAP", "data_status", "current_status <- enum(data_status)",
+ enum_conversion="FACT_BOUNDARY_OR_MONITOR->LEGACY_FACT_BOUNDARY_OR_MONITOR",
+ enum_map={"FACT_BOUNDARY_OR_MONITOR": "LEGACY_FACT_BOUNDARY_OR_MONITOR"},
+ ),
+})
+
+SAFE_RENAME_SEMANTIC_DOMAINS: dict[tuple[str, str], str] = {
+ ("newenergy_scope_matrix", "scope_row_id"): "SCOPE_ROW_ID",
+ ("newenergy_scope_matrix", "object_id"): "TRACK_ID_AS_SCOPE_OBJECT_ID",
+ ("newenergy_scope_matrix", "inclusion_reason"): "SCOPE_INCLUSION_TEXT",
+ ("newenergy_scope_matrix", "exclusion_reason"): "SCOPE_EXCLUSION_TEXT",
+ ("newenergy_technology_route_matrix", "technology_route_id"): "TECHNOLOGY_ROUTE_ID",
+ ("newenergy_technology_route_matrix", "alternative_route"): "ROUTE_ALTERNATIVE_OR_COMPLEMENT_TEXT",
+ ("newenergy_technology_route_matrix", "region"): "PRIMARY_REGION",
+ ("newenergy_supply_demand_price_metric", "market_metric_id"): "MARKET_METRIC_ID",
+ ("newenergy_supply_demand_price_metric", "value"): "METRIC_VALUE",
+ ("newenergy_supply_demand_price_metric", "unit"): "METRIC_UNIT",
+ ("newenergy_supply_demand_price_metric", "source_doc_id"): "SOURCE_DOCUMENT_ID",
+ ("newenergy_supply_demand_price_metric", "comparability_note"): "COMPARABILITY_BOUNDARY",
+ ("newenergy_project_capacity_event", "project_event_id"): "PROJECT_OR_CAPACITY_EVENT_ID",
+ ("newenergy_project_capacity_event", "source_doc_id"): "SOURCE_DOCUMENT_ID",
+ ("newenergy_company_exposure_matrix", "company_exposure_id"): "COMPANY_EXPOSURE_MAPPING_ID",
+ ("newenergy_company_exposure_matrix", "chain_node"): "CHAIN_NODE_TEXT",
+ ("newenergy_company_exposure_matrix", "reporting_period"): "DISCLOSURE_PERIOD",
+ ("newenergy_company_exposure_matrix", "source_doc_id"): "SOURCE_DOCUMENT_ID",
+ ("newenergy_catalyst_risk_register", "catalyst_risk_id"): "CATALYST_RISK_RECORD_ID",
+ ("newenergy_catalyst_risk_register", "trigger_condition"): "TRIGGER_OR_RISK_TEXT",
+ ("newenergy_catalyst_risk_register", "affected_metric_or_object"): "OBSERVABLE_OR_AFFECTED_OBJECT_TEXT",
+ ("newenergy_catalyst_risk_register", "risk_statement"): "TRIGGER_OR_RISK_TEXT",
+ ("newenergy_catalyst_risk_register", "invalidation_condition"): "INVALIDATION_OR_FAILURE_CONDITION",
+ ("newenergy_catalyst_risk_register", "source_doc_id"): "SOURCE_DOCUMENT_ID",
+}
+
+
+def resolve_adapter_rule(logical: str, target_field: str, source_columns: set[str]) -> dict[str, Any]:
+ if target_field in LEGACY_ADAPTER_OVERRIDES[logical]:
+ rule = dict(LEGACY_ADAPTER_OVERRIDES[logical][target_field])
+ elif target_field in source_columns:
+ rule = adapter_rule("DIRECT", target_field, f"{target_field} <- {target_field}")
+ else:
+ rule = adapter_rule(
+ "EXPLICIT_GAP",
+ constant_or_gap=f"GAP_NO_ACCEPTED_B001_FIELD_FOR_{target_field.upper()}",
+ semantic_note="No silent blank or invented value; consumer receives an explicit legacy GAP token.",
+ )
+ mapping_type = rule["mapping_type"]
+ if mapping_type == "DIRECT":
+ policy, source_domain, target_domain = "IDENTITY", target_field.upper(), target_field.upper()
+ elif mapping_type == "RENAME" and (logical, target_field) in SAFE_RENAME_SEMANTIC_DOMAINS:
+ domain = SAFE_RENAME_SEMANTIC_DOMAINS[(logical, target_field)]
+ policy, source_domain, target_domain = "RENAME_SAME_DOMAIN", domain, domain
+ elif mapping_type == "EXPLICIT_GAP":
+ policy, source_domain, target_domain = "EXPLICIT_GAP", "LEGACY_NOT_PROJECTED", target_field.upper()
+ elif mapping_type == "PREFIX_LEGACY_BOUNDARY":
+ policy, source_domain, target_domain = "EXPLICIT_LEGACY_BOUNDARY", rule["source_fields"].upper(), target_field.upper()
+ elif mapping_type in {"ENUM_MAP", "CONSTANT_ENUM"}:
+ policy, source_domain, target_domain = "ENUM_TRANSFORM", rule["source_fields"].upper() or "DECLARED_LEGACY_CONTEXT", target_field.upper()
+ elif mapping_type in {"PARSE_PERIOD_START", "PARSE_PERIOD_END"}:
+ policy, source_domain, target_domain = "PARSE_TRANSFORM", "PERIOD_INTERVAL", "PERIOD_BOUND"
+ elif mapping_type == "CONSTANT":
+ policy, source_domain, target_domain = "DECLARED_CONSTANT", "DECLARED_LEGACY_CONTEXT", target_field.upper()
+ else:
+ policy, source_domain, target_domain = "SILENT_CROSS_DOMAIN_FORBIDDEN", rule["source_fields"].upper(), target_field.upper()
+ rule["semantic_policy"] = policy
+ rule["source_semantic_domain"] = source_domain
+ rule["target_semantic_domain"] = target_domain
+ return rule
+
+
+def parse_legacy_metric_period(metric_period: str, metric_date: str) -> tuple[str, str, str]:
+ interval_match = re.fullmatch(r"(\d{4}-\d{2}-\d{2})/(\d{4}-\d{2}-\d{2})", metric_period)
+ if interval_match:
+ return interval_match.group(1), interval_match.group(2), "ISO_DATE_INTERVAL"
+ year_match = re.fullmatch(r"(\d{4})", metric_period)
+ if year_match:
+ year = year_match.group(1)
+ return f"{year}-01-01", f"{year}-12-31", "CALENDAR_YEAR_EXPANSION"
+ year_end_match = re.fullmatch(r"(\d{4})YE", metric_period)
+ if year_end_match:
+ year = year_end_match.group(1)
+ return f"LEGACY_AS_OF_NO_START:{metric_period}", f"{year}-12-31", "YEAR_END_AS_OF_BOUNDARY"
+ if re.fullmatch(r"\d{4}-\d{2}-\d{2}", metric_date):
+ return f"LEGACY_AS_OF_NO_START:{metric_period or metric_date}", metric_date, "FALLBACK_EXPLICIT_AS_OF_BOUNDARY"
+ return f"GAP_UNPARSED_PERIOD_START:{metric_period}", f"GAP_UNPARSED_PERIOD_END:{metric_period}", "UNPARSED_EXPLICIT_GAP"
+
+
+def project_legacy_value(rule: dict[str, Any], row: dict[str, str]) -> str:
+ mapping_type = rule["mapping_type"]
+ if mapping_type in {"DIRECT", "RENAME"}:
+ source_field = rule["source_fields"].split(";", 1)[0]
+ value = row.get(source_field, "")
+ elif mapping_type == "ENUM_MAP":
+ source_field = rule["source_fields"].split(";", 1)[0]
+ value = rule["enum_map"].get(row.get(source_field, ""), "")
+ elif mapping_type == "PREFIX_LEGACY_BOUNDARY":
+ source_field = rule["source_fields"].split(";", 1)[0]
+ value = rule["constant_or_gap"] + row.get(source_field, "")
+ elif mapping_type in {"PARSE_PERIOD_START", "PARSE_PERIOD_END"}:
+ period_start, period_end, _ = parse_legacy_metric_period(row.get("metric_period", ""), row.get("metric_date", ""))
+ value = period_start if mapping_type == "PARSE_PERIOD_START" else period_end
+ else:
+ value = rule["constant_or_gap"]
+ return value if str(value).strip() else f"GAP_EMPTY_ACCEPTED_B001_SOURCE_{rule['source_fields'].upper()}"
+
+
+adapter_contract_rows: list[dict[str, Any]] = []
+projection_validation_rows: list[dict[str, Any]] = []
+period_projection_receipt_rows: list[dict[str, Any]] = []
+legacy_projection_by_dataset: dict[str, list[dict[str, str]]] = {}
+allowed_statement_types = {"FACT", "VIEWPOINT", "INFERENCE", "SCENARIO", "CONTRADICTION", "GAP"}
+allowed_scope_types = {"CORE_INDUSTRY", "ADJACENT_DOWNSTREAM", "FALSE_THEME_OR_NOISE"}
+
+for dataset_index, logical in enumerate(LEGACY_EXTENSION_DATASETS, 1):
+ baseline_path = baseline_registry[logical]
+ shard_path = b2_registry[logical]
+ baseline_rows = read_csv(baseline_path)
+ shard_rows = read_csv(shard_path)
+ source_columns = set(baseline_rows[0])
+ target_columns = list(shard_rows[0])
+ rules: dict[str, dict[str, Any]] = {}
+ for field_index, target_field in enumerate(target_columns, 1):
+ rule = resolve_adapter_rule(logical, target_field, source_columns)
+ rules[target_field] = rule
+ adapter_contract_rows.append({
+ "adapter_mapping_id": f"NEB2-ADAPTER-{dataset_index:02d}-{field_index:03d}",
+ "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
+ "repair_id": LATEST_REPAIR_ID, "logical_dataset": logical,
+ "baseline_path": rel(baseline_path), "baseline_sha256": sha256(baseline_path),
+ "baseline_schema_version": baseline_rows[0].get("schema_version", "ACCEPTED_B001_LEGACY"),
+ "target_schema_version": SCHEMA, "target_field": target_field,
+ "mapping_type": rule["mapping_type"], "source_fields": rule["source_fields"],
+ "projection_expression": rule["expression"], "constant_or_gap_default": rule["constant_or_gap"],
+ "enum_conversion": rule["enum_conversion"], "semantic_note": rule["semantic_note"],
+ "semantic_policy": rule["semantic_policy"],
+ "source_semantic_domain": rule["source_semantic_domain"],
+ "target_semantic_domain": rule["target_semantic_domain"],
+ "allowed_source_values": ";".join(sorted(rule["enum_map"])) if rule["enum_map"] else "ANY_NONEMPTY_OR_NOT_APPLICABLE",
+ "allowed_target_values": ";".join(sorted(set(rule["enum_map"].values()))) if rule["enum_map"] else (
+ rule["constant_or_gap"] if rule["mapping_type"] in {"EXPLICIT_GAP", "CONSTANT", "CONSTANT_ENUM"} else "POLICY_DERIVED"
+ ),
+ "target_primary_key_role": "YES" if target_field == REGISTRY_CONTRACTS[logical]["b2_pk"] else "NO",
+ "target_business_key_role": "YES" if target_field in REGISTRY_CONTRACTS[logical]["b2_business"] else "NO",
+ "read_order": "PROJECT_ACCEPTED_B001_THEN_APPEND_BATCH002", "materialized_union_copy": "NO",
+ "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+ projected_rows = [{field: project_legacy_value(rules[field], row) for field in target_columns} for row in baseline_rows]
+ legacy_projection_by_dataset[logical] = projected_rows
+ required_columns = EXTENSION_REQUIRED_COLUMNS[logical]
+ projected_required_missing = sum(not str(row.get(field, "")).strip() for row in projected_rows for field in required_columns)
+ shard_required_missing = sum(not str(row.get(field, "")).strip() for row in shard_rows for field in required_columns)
+ union_required_missing = projected_required_missing + shard_required_missing
+ projected_all_blank = sum(not str(row.get(field, "")).strip() for row in projected_rows for field in target_columns)
+ projected_pk = REGISTRY_CONTRACTS[logical]["b2_pk"]
+ business_fields = REGISTRY_CONTRACTS[logical]["b2_business"]
+ projected_pk_duplicates = len(projected_rows) - len({row[projected_pk] for row in projected_rows})
+ projected_business_duplicates = len(projected_rows) - len({tuple(row[field] for field in business_fields) for row in projected_rows})
+ shard_pk_duplicates = len(shard_rows) - len({row[projected_pk] for row in shard_rows})
+ shard_business_duplicates = len(shard_rows) - len({tuple(row[field] for field in business_fields) for row in shard_rows})
+ union_rows = projected_rows + shard_rows
+ union_pk_duplicates = len(union_rows) - len({row[projected_pk] for row in union_rows})
+ union_business_duplicates = len(union_rows) - len({tuple(row[field] for field in business_fields) for row in union_rows})
+ cross_shard_id_collision_count = len({row[projected_pk] for row in projected_rows} & {row[projected_pk] for row in shard_rows})
+ enum_errors = 0
+ if logical == "newenergy_scope_matrix":
+ enum_errors += sum(row["scope_type"] not in allowed_scope_types for row in union_rows)
+ if logical == "newenergy_catalyst_risk_register":
+ enum_errors += sum(row["statement_type"] not in allowed_statement_types for row in union_rows)
+ forbidden_policy_fields = [field for field, rule in rules.items() if rule["semantic_policy"] == "SILENT_CROSS_DOMAIN_FORBIDDEN"]
+ domain_mismatch_fields = [
+ field for field, rule in rules.items()
+ if rule["semantic_policy"] in {"IDENTITY", "RENAME_SAME_DOMAIN"}
+ and rule["source_semantic_domain"] != rule["target_semantic_domain"]
+ ]
+ explicit_gap_violation_count = sum(
+ not row[field].startswith("GAP_")
+ for field, rule in rules.items() if rule["semantic_policy"] == "EXPLICIT_GAP"
+ for row in projected_rows
+ )
+ legacy_boundary_violation_count = sum(
+ not row[field].startswith(rule["constant_or_gap"])
+ for field, rule in rules.items() if rule["semantic_policy"] == "EXPLICIT_LEGACY_BOUNDARY"
+ for row in projected_rows
+ )
+ enum_source_unmapped_count = 0
+ enum_target_invalid_count = 0
+ for field, rule in rules.items():
+ if rule["mapping_type"] != "ENUM_MAP":
+ continue
+ source_field = rule["source_fields"].split(";", 1)[0]
+ enum_source_unmapped_count += sum(row.get(source_field, "") not in rule["enum_map"] for row in baseline_rows)
+ allowed_targets = set(rule["enum_map"].values())
+ enum_target_invalid_count += sum(row[field] not in allowed_targets for row in projected_rows)
+ parse_source_unrecognized_count = 0
+ parse_output_format_violation_count = 0
+ parse_order_violation_count = 0
+ parse_as_of_handling_violation_count = 0
+ if {"period_start", "period_end"}.issubset(rules) and rules["period_start"]["semantic_policy"] == "PARSE_TRANSFORM":
+ for source_row, projected_row in zip(baseline_rows, projected_rows):
+ token = source_row.get("metric_period", "")
+ start, end = projected_row["period_start"], projected_row["period_end"]
+ _, _, parse_mode = parse_legacy_metric_period(token, source_row.get("metric_date", ""))
+ if parse_mode == "UNPARSED_EXPLICIT_GAP":
+ parse_source_unrecognized_count += 1
+ start_is_date = bool(re.fullmatch(r"\d{4}-\d{2}-\d{2}", start))
+ end_is_date = bool(re.fullmatch(r"\d{4}-\d{2}-\d{2}", end))
+ start_is_boundary = start.startswith("LEGACY_AS_OF_NO_START:") or start.startswith("GAP_UNPARSED_PERIOD_START:")
+ end_is_boundary = end.startswith("GAP_UNPARSED_PERIOD_END:")
+ format_ok = (start_is_date or start_is_boundary) and (end_is_date or end_is_boundary)
+ if not format_ok:
+ parse_output_format_violation_count += 1
+ order_ok = True
+ if start_is_date and end_is_date:
+ try:
+ if datetime.fromisoformat(start) > datetime.fromisoformat(end):
+ parse_order_violation_count += 1
+ order_ok = False
+ except ValueError:
+ parse_output_format_violation_count += 1
+ format_ok = False
+ order_ok = False
+ as_of_ok = True
+ year_end_match = re.fullmatch(r"(\d{4})YE", token)
+ if year_end_match and not (
+ start == f"LEGACY_AS_OF_NO_START:{token}" and end == f"{year_end_match.group(1)}-12-31"
+ ):
+ parse_as_of_handling_violation_count += 1
+ as_of_ok = False
+ year_match = re.fullmatch(r"(\d{4})", token)
+ if year_match and not (start == f"{year_match.group(1)}-01-01" and end == f"{year_match.group(1)}-12-31"):
+ parse_as_of_handling_violation_count += 1
+ as_of_ok = False
+ interval_match = re.fullmatch(r"(\d{4}-\d{2}-\d{2})/(\d{4}-\d{2}-\d{2})", token)
+ if interval_match and not (start == interval_match.group(1) and end == interval_match.group(2)):
+ parse_as_of_handling_violation_count += 1
+ as_of_ok = False
+ period_projection_receipt_rows.append({
+ "period_projection_id": f"NEB2-PERIOD-PROJECTION-{len(period_projection_receipt_rows)+1:03d}",
+ "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
+ "repair_id": LATEST_REPAIR_ID, "logical_dataset": logical,
+ "legacy_metric_id": source_row["metric_id"], "source_metric_period": token,
+ "source_metric_date": source_row.get("metric_date", ""), "parse_mode": parse_mode,
+ "projected_period_start": start, "projected_period_end": end,
+ "period_start_format": "ISO_DATE" if start_is_date else "LEGACY_AS_OF_BOUNDARY" if start.startswith("LEGACY_AS_OF_NO_START:") else "EXPLICIT_GAP",
+ "period_end_format": "ISO_DATE" if end_is_date else "EXPLICIT_GAP",
+ "format_validation": "PASS" if format_ok else "FAIL",
+ "date_order_validation": "PASS" if order_ok else "FAIL",
+ "as_of_handling_validation": "PASS" if as_of_ok else "FAIL",
+ "source_row_sha256": hashlib.sha256(json.dumps(source_row, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest().upper(),
+ "baseline_path": rel(baseline_path), "baseline_sha256": sha256(baseline_path),
+ "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+ declared_constant_violation_count = sum(
+ not str(rule["constant_or_gap"]).strip()
+ for rule in rules.values() if rule["semantic_policy"] in {"DECLARED_CONSTANT", "ENUM_TRANSFORM"}
+ and rule["mapping_type"] != "ENUM_MAP"
+ )
+ silent_coercion_count = (
+ len(forbidden_policy_fields) + len(domain_mismatch_fields) + explicit_gap_violation_count
+ + legacy_boundary_violation_count + enum_source_unmapped_count + enum_target_invalid_count
+ + declared_constant_violation_count + parse_source_unrecognized_count
+ + parse_output_format_violation_count + parse_order_violation_count + parse_as_of_handling_violation_count
+ )
+ semantic_boundary_check = "PASS_COMPUTED_MAPPING_POLICY" if silent_coercion_count == 0 else "FAIL_COMPUTED_MAPPING_POLICY"
+ if any((union_required_missing, projected_all_blank, projected_pk_duplicates, projected_business_duplicates, shard_pk_duplicates, shard_business_duplicates, union_pk_duplicates, union_business_duplicates, cross_shard_id_collision_count, enum_errors, silent_coercion_count)):
+ raise RuntimeError(
+ f"legacy adapter projection validation failed for {logical}: "
+ f"required={union_required_missing} blank={projected_all_blank} projected_pk={projected_pk_duplicates} "
+ f"projected_business={projected_business_duplicates} shard_pk={shard_pk_duplicates} "
+ f"shard_business={shard_business_duplicates} union_pk={union_pk_duplicates} "
+ f"union_business={union_business_duplicates} collision={cross_shard_id_collision_count} enum={enum_errors} "
+ f"silent={silent_coercion_count} forbidden={forbidden_policy_fields} domain={domain_mismatch_fields} "
+ f"gap={explicit_gap_violation_count} legacy={legacy_boundary_violation_count} "
+ f"enum_source={enum_source_unmapped_count} enum_target={enum_target_invalid_count} "
+ f"parse_source={parse_source_unrecognized_count} parse_format={parse_output_format_violation_count} "
+ f"parse_order={parse_order_violation_count} parse_asof={parse_as_of_handling_violation_count}"
+ )
+ projection_validation_rows.append({
+ "adapter_validation_id": f"NEB2-ADAPTER-VALIDATION-{dataset_index:02d}",
+ "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
+ "repair_id": LATEST_REPAIR_ID, "logical_dataset": logical,
+ "baseline_path": rel(baseline_path), "baseline_sha256": sha256(baseline_path),
+ "batch002_shard_path": rel(shard_path), "batch002_shard_sha256": sha256(shard_path),
+ "adapter_contract_path": rel(LEGACY_ADAPTER_PATH),
+ "baseline_row_count": len(baseline_rows), "projected_baseline_row_count": len(projected_rows),
+ "batch002_row_count": len(shard_rows), "projected_union_row_count": len(union_rows),
+ "target_column_count": len(target_columns), "mapping_rule_count": len(rules),
+ "semantic_policy_checked_count": len(rules),
+ "explicit_gap_rule_count": sum(r["semantic_policy"] == "EXPLICIT_GAP" for r in rules.values()),
+ "explicit_legacy_boundary_rule_count": sum(r["semantic_policy"] == "EXPLICIT_LEGACY_BOUNDARY" for r in rules.values()),
+ "enum_transform_rule_count": sum(r["semantic_policy"] == "ENUM_TRANSFORM" for r in rules.values()),
+ "projected_v1_required_field_missing_count": projected_required_missing,
+ "batch002_v1_required_field_missing_count": shard_required_missing,
+ "v1_required_field_missing_count": union_required_missing,
+ "projected_all_target_field_blank_count": projected_all_blank,
+ "projected_primary_key_duplicate_count": projected_pk_duplicates,
+ "projected_business_key_duplicate_count": projected_business_duplicates,
+ "batch002_primary_key_duplicate_count": shard_pk_duplicates,
+ "batch002_business_key_duplicate_count": shard_business_duplicates,
+ "union_primary_key_duplicate_count": union_pk_duplicates,
+ "union_business_key_duplicate_count": union_business_duplicates,
+ "cross_shard_id_collision_count": cross_shard_id_collision_count,
+ "enum_conversion_error_count": enum_errors + enum_source_unmapped_count + enum_target_invalid_count,
+ "forbidden_semantic_policy_count": len(forbidden_policy_fields),
+ "same_domain_mismatch_count": len(domain_mismatch_fields),
+ "explicit_gap_policy_violation_count": explicit_gap_violation_count,
+ "explicit_legacy_boundary_violation_count": legacy_boundary_violation_count,
+ "declared_constant_policy_violation_count": declared_constant_violation_count,
+ "parse_source_unrecognized_count": parse_source_unrecognized_count,
+ "parse_output_format_violation_count": parse_output_format_violation_count,
+ "parse_order_violation_count": parse_order_violation_count,
+ "parse_as_of_handling_violation_count": parse_as_of_handling_violation_count,
+ "silent_coercion_count": silent_coercion_count,
+ "semantic_boundary_check": semantic_boundary_check,
+ "materialized_union_copy": "NO", "projection_validation_status": "PASS",
+ "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+
+write_csv(LEGACY_ADAPTER_PATH, adapter_contract_rows)
+write_csv(LEGACY_PERIOD_PROJECTION_PATH, period_projection_receipt_rows)
+if len(period_projection_receipt_rows) != len(read_csv(baseline_registry["newenergy_supply_demand_price_metric"])) or any(
+ row["format_validation"] != "PASS" or row["date_order_validation"] != "PASS" or row["as_of_handling_validation"] != "PASS"
+ for row in period_projection_receipt_rows
+):
+ raise RuntimeError("legacy period row-level projection receipt failed")
+for row in projection_validation_rows:
+ row["adapter_contract_sha256"] = sha256(LEGACY_ADAPTER_PATH)
+ row["adapter_contract_row_count"] = len(adapter_contract_rows)
+ row["period_projection_receipt_path"] = rel(LEGACY_PERIOD_PROJECTION_PATH) if row["logical_dataset"] == "newenergy_supply_demand_price_metric" else "NOT_APPLICABLE"
+ row["period_projection_receipt_sha256"] = sha256(LEGACY_PERIOD_PROJECTION_PATH) if row["logical_dataset"] == "newenergy_supply_demand_price_metric" else "NOT_APPLICABLE"
+ row["period_projection_receipt_row_count"] = len(period_projection_receipt_rows) if row["logical_dataset"] == "newenergy_supply_demand_price_metric" else 0
+write_csv(LEGACY_PROJECTION_VALIDATION_PATH, projection_validation_rows)
+b2_registry["legacy_to_v1_adapter_contract"] = LEGACY_ADAPTER_PATH
+b2_registry["legacy_to_v1_projection_validation"] = LEGACY_PROJECTION_VALIDATION_PATH
+b2_registry["legacy_period_projection_receipt"] = LEGACY_PERIOD_PROJECTION_PATH
+REGISTRY_CONTRACTS["legacy_to_v1_adapter_contract"] = {
+ "b2_pk": "adapter_mapping_id", "b2_business": ("logical_dataset", "target_field")
+}
+REGISTRY_CONTRACTS["legacy_to_v1_projection_validation"] = {
+ "b2_pk": "adapter_validation_id", "b2_business": ("logical_dataset", "repair_id")
+}
+REGISTRY_CONTRACTS["legacy_period_projection_receipt"] = {
+ "b2_pk": "period_projection_id", "b2_business": ("logical_dataset", "legacy_metric_id", "repair_id")
+}
+
+def count_duplicate_keys(rows: list[dict[str, str]], fields: tuple[str, ...]) -> int:
+ keys = [tuple(row.get(field, "") for field in fields) for row in rows]
+ return len(keys) - len(set(keys))
+
+for logical, required in EXTENSION_REQUIRED_COLUMNS.items():
+ rows = read_csv(b2_registry[logical])
+ actual_columns = set(rows[0])
+ missing = sorted(required - actual_columns)
+ if missing:
+ raise RuntimeError(f"extension schema missing columns {logical}: {missing}")
+
+registry_rows: list[dict[str, Any]] = []
+reg_idx = 0
+for logical, path in baseline_registry.items():
+ rows = read_csv(path)
+ contract = REGISTRY_CONTRACTS[logical]
+ physical_pk = contract["base_pk"]
+ pk_duplicates = count_duplicate_keys(rows, (physical_pk,))
+ reg_idx += 1
+ registry_rows.append({
+ "registry_id": f"NEB2-REG-{reg_idx:03d}", "logical_dataset": logical, "shard_sequence": 1,
+ "batch_id": "BATCH-001", "shard_path": rel(path), "schema_version": SCHEMA,
+ "row_count": len(rows), "sha256": sha256(path), "write_mode": "IMMUTABLE_BASELINE_READ_FIRST",
+ "physical_primary_key": physical_pk,
+ "logical_business_key": (
+ ";".join(REGISTRY_CONTRACTS[logical]["b2_business"]) + ";AFTER_READ_ONLY_LEGACY_TO_V1_ADAPTER"
+ if logical in LEGACY_EXTENSION_DATASETS else "ACCEPTED_B001_CONTRACT"
+ ),
+ "primary_key_duplicate_count": pk_duplicates, "business_key_duplicate_count": 0,
+ "cross_shard_id_collision_count": 0,
+ "schema_compatibility": "PASS_EXECUTABLE_READ_ONLY_LEGACY_TO_V1_ADAPTER" if logical in LEGACY_EXTENSION_DATASETS else "ACCEPTED_B001_PARENT_CANONICAL",
+ "semantic_compatibility": (
+ "PASS_PROJECTED_BASELINE_TO_V1_UNION_VALIDATED;DIRECT_SELECTED_COMPANY_MAPPING_32_NOT_CANDIDATE_LEDGER"
+ if logical == "newenergy_company_exposure_matrix"
+ else "PASS_PROJECTED_BASELINE_TO_V1_UNION_VALIDATED" if logical in LEGACY_EXTENSION_DATASETS
+ else "PASS_ACCEPTED_BASELINE_SEMANTICS"
+ ),
+ "read_order": "1_BASELINE",
+ "immutable": "YES", "review_status": "ACCEPTED_BY_INDEPENDENT_REVIEW",
+ })
+for logical, path in b2_registry.items():
+ rows = read_csv(path)
+ contract = REGISTRY_CONTRACTS[logical]
+ physical_pk = contract["b2_pk"]
+ business_fields = contract["b2_business"]
+ pk_duplicates = count_duplicate_keys(rows, (physical_pk,))
+ business_duplicates = count_duplicate_keys(rows, business_fields)
+ collision_count = 0
+ if logical in baseline_registry:
+ baseline_rows_for_collision = read_csv(baseline_registry[logical])
+ baseline_pk = REGISTRY_CONTRACTS[logical]["base_pk"]
+ collision_count = len({r[baseline_pk] for r in baseline_rows_for_collision} & {r[physical_pk] for r in rows})
+ if pk_duplicates or business_duplicates or collision_count:
+ raise RuntimeError(f"registry key validation failed {logical}: pk={pk_duplicates} business={business_duplicates} collision={collision_count}")
+ reg_idx += 1
+ registry_rows.append({
+ "registry_id": f"NEB2-REG-{reg_idx:03d}", "logical_dataset": logical,
+ "shard_sequence": 2 if logical in baseline_registry else 1, "batch_id": BATCH_ID,
+ "shard_path": rel(path), "schema_version": SCHEMA, "row_count": len(rows), "sha256": sha256(path),
+ "write_mode": "APPEND_SHARD_READ_AFTER_BASELINE" if logical in baseline_registry else "BATCH002_ONLY_SHARD",
+ "physical_primary_key": physical_pk, "logical_business_key": ";".join(business_fields),
+ "primary_key_duplicate_count": pk_duplicates, "business_key_duplicate_count": business_duplicates,
+ "cross_shard_id_collision_count": collision_count,
+ "schema_compatibility": "PASS_NEWENERGY_EXTENSION_V1_REQUIRED_COLUMNS_AND_PROJECTED_UNION" if logical in EXTENSION_REQUIRED_COLUMNS else "PASS_BATCH002_PARENT_OR_AUXILIARY_CONTRACT",
+ "semantic_compatibility": (
+ "PASS_PROJECTED_UNION;PASS_DIRECT_INCREMENTAL_COMPANY_EXPOSURE_9"
+ if logical == "newenergy_company_exposure_matrix" else "PASS_PROJECTED_UNION" if logical in LEGACY_EXTENSION_DATASETS else "PASS"
+ ),
+ "read_order": "2_BATCH002" if logical in baseline_registry else "1_BATCH002_ONLY",
+ "immutable": "NO_PENDING_REVIEW", "review_status": REVIEW,
+ })
+write_csv(INDUSTRY / "manifest" / "canonical_shard_registry_BATCH002.csv", registry_rows)
+
+
+batch_summary = CASE / "manifest" / "batch_summary.md"
+write_text(
+ batch_summary,
+ f"""# BATCH-002 批次摘要
+
+- task_id: `{TASK_ID}`
+- case_id: `{CASE_ID}`
+- batch_id: `{BATCH_ID}`
+- run_id: `{RUN_ID}`
+- design_audit_id: `AUDIT-ANA-NEWENERGY-FOUR-TRACK-ATLAS-BATCH002-DESIGN-REPAIR001-REREVIEW-20260806-001`
+- review_status: `{REVIEW}`
+
+## 结果计数
+
+- 子行业节点:16;节点正文:32;顶层视图:3。
+- 增量企业映射:9;前批 selected 去重冲突:0。
+- 空桶:7;有限补证队列:50;补证后新增资格通过:0。
+- BATCH-002 新 source_document:1;BATCH-001 immutable source 引用:{len(used_source_ids) - 1}。
+- case evidence map:{len(case_maps)};人读输出(含 result index):{len(output_paths)};output manifest 另含 2 条条件逻辑数据集 N/A 登记。
+
+## 七组扩展数据集
+
+- `newenergy_scope_matrix`:REQUIRED / 已生成 / {len(scope_rows)} 行。
+- `newenergy_technology_route_matrix`:REQUIRED / 已生成 / {len(technology_rows)} 行。
+- `newenergy_supply_demand_price_metric`:CONDITIONAL_REQUIRED_PER_NODE / 已生成 / {len(metric_rows)} 行;仅复用可比、已接受的官方事实。
+- `newenergy_project_capacity_event`:CONDITIONAL_REQUIRED / 已生成 / {len(project_event_rows)} 行;只作前批事件再语境化,不新增项目事实。
+- `newenergy_policy_market_mechanism_event`:NOT_APPLICABLE;本批正文没有使用新的政策机制强事实,因此不创建空表;理由、判断依据和 review status 已登记 output manifest / human validation。
+- `newenergy_company_exposure_matrix`:REQUIRED / 已生成 / {len(exposure_rows)} 行,与 included incremental exact-match。
+- `newenergy_catalyst_risk_register`:REQUIRED / 已生成 / {len(risk_rows)} 行。
+
+## BATCH-001 legacy-to-V1 只读适配
+
+- 六个前序已接受 `newenergy_*` baseline 不改写、不复制合并;通过 `legacy_to_v1_adapter_contract_BATCH002.csv` 对每个 V1 目标字段冻结直接映射、重命名、常量/显式 GAP、枚举转换、主键和业务键投影。
+- `legacy_to_v1_projection_validation_BATCH002.csv` 实际投影后再与 BATCH-002 shard 做只读 union 校验:V1 必需字段、行数、主键/业务键重复、跨 shard ID 碰撞、枚举和语义边界均为 PASS;不生成物化 union 副本。
+- `legacy_period_projection_receipt_BATCH002.csv` 对 16 条前序指标逐行保存期间 token、解析模式和投影边界:ISO 区间原样保留,`YYYY` 展开为完整自然年,`YYYYYE` 以 `LEGACY_AS_OF_NO_START:*` + 年末日期分账;格式、顺序和 as-of 处理均计算验证。
+
+## darkline 执行与停止统计
+
+- 模式:`广撒网收集 / EXTERNAL_PUBLIC_EVIDENCE_COMPLETION`;输入严格限于 16 节点、9 条既有合格余额和 7 个空桶冻结队列。
+- REPAIR001 实际查询层:50 个 queue pair 逐行回指完整条件、查询时间、官方 URL、HTTP/失败状态、返回对象、原件定位和终止理由;49 个唯一官方 URL 均取得 HTTP 200。零候选桶另记 1 条 NOT_APPLICABLE,不宣称执行查询。
+- 查询摘要证据进入正式结论:0;所有正式强事实回到公开原件、接受证据或本批新归档原件。
+- 搜索引擎仅定位原始来源;访问控制绕过:0;行情/K线/市场反向补漏产物:0。
+- 核电敏感内容保存/输出:0;只保留民用公开高层角色边界。
+- `darkline_event_or_hypothesis_BATCH002`:NOT_APPLICABLE;未形成需单列的事件链或研究假设;理由、判断依据和 review status 已登记 output manifest / human validation。
+
+## 输出边界
+
+本批不声明完整覆盖、份额、公司质量排序、估值、交易、收益或投资建议。证据不足的 7 个节点保持 GAP。执行/输出独立审核通过前不得回写父级终态或对外交付。
+""",
+)
+
+
+def validate_links(paths: list[Path]) -> tuple[int, list[str]]:
+ link_pattern = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
+ checked = 0
+ broken: list[str] = []
+ for path in paths:
+ text = path.read_text(encoding="utf-8")
+ for target in link_pattern.findall(text):
+ if re.match(r"^[a-z]+://", target, re.I) or target.startswith("#"):
+ continue
+ checked += 1
+ actual = (path.parent / target.split("#", 1)[0]).resolve()
+ if not actual.exists():
+ broken.append(f"{rel(path)} -> {target}")
+ return checked, broken
+
+
+all_b2_text_paths = (
+ list(CASE.rglob("*.md"))
+ + [result_index]
+ + [p for p in (INDUSTRY / "supplement").glob("NEB2_*") if p.is_file()]
+ + list((INDUSTRY / "extracted").glob("*_BATCH002.csv"))
+ + [INDUSTRY / "extracted" / "batch002_evidence_completion_queue.csv"]
+ + list((INDUSTRY / "manifest").glob("*_BATCH002.csv"))
+ + [INDUSTRY / "evidence" / "evidence_fact_table_BATCH002.csv"]
+)
+replacement_count = 0
+utf8_errors: list[str] = []
+for path in all_b2_text_paths:
+ try:
+ content = path.read_text(encoding="utf-8-sig" if path.suffix == ".csv" else "utf-8")
+ replacement_count += content.count("\ufffd")
+ except UnicodeDecodeError:
+ utf8_errors.append(rel(path))
+
+link_count, broken_links = validate_links(output_paths + [batch_summary])
+
+included_pairs = {(r["company_id"], r["track_code"]) for r in incremental_ledger_rows if r["batch002_state"] == "INCLUDED_INCREMENTAL"}
+baseline_selected_pairs = {(r["company_id"], r["track_code"]) for r in ledger if r["candidate_state"] in {"INCLUDED_T1", "INCLUDED_T2"}}
+exposure_pairs = {(r["company_id"], r["track_code"]) for r in exposure_rows}
+if included_pairs != exposure_pairs or included_pairs & baseline_selected_pairs:
+ raise RuntimeError("incremental exact-match or predecessor de-dup failed")
+
+all_evidence_ids = set(evidence_by_id) | {yuhong_evf["evidence_fact_id"]}
+map_orphans = [m["conclusion_evidence_map_id"] for m in case_maps if m["evidence_fact_id"] not in all_evidence_ids]
+if map_orphans:
+ raise RuntimeError(f"case map evidence orphans: {map_orphans}")
+
+if utf8_errors or replacement_count or broken_links:
+ raise RuntimeError(f"text validation failed utf8={utf8_errors} u+fffd={replacement_count} links={broken_links}")
+
+cutoff_date = SOURCE_CUTOFF[:10]
+source_publish_after_cutoff = []
+for source_id in used_source_ids:
+ publish_date = (source_document_rows[0] if source_id == "S-NEB2-YUHONG-SUSTAINABILITY-2023" else source_by_id[source_id])["publish_date"][:10]
+ if publish_date and publish_date > cutoff_date:
+ source_publish_after_cutoff.append(source_id)
+if source_publish_after_cutoff:
+ raise RuntimeError(f"source cutoff violated: {source_publish_after_cutoff}")
+
+def duplicate_count(rows: list[dict[str, Any]], key_fields: tuple[str, ...]) -> int:
+ keys = [tuple(str(r.get(k, "")) for k in key_fields) for r in rows]
+ return len(keys) - len(set(keys))
+
+uniqueness_checks = {
+ "classification_business_key_duplicates": duplicate_count(
+ classification_rows,
+ ("case_id", "subject_type", "subject_id", "track_code", "chain_node_id", "source_doc_id", "evidence_fact_id"),
+ ),
+ "scope_business_key_duplicates": duplicate_count(
+ scope_rows, ("case_id", "object_type", "object_id", "track_code", "chain_node", "scope_version")
+ ),
+ "technology_business_key_duplicates": duplicate_count(
+ technology_rows, ("case_id", "track_code", "route_name", "region", "metric_as_of")
+ ),
+ "metric_business_key_duplicates": duplicate_count(
+ metric_rows, ("case_id", "metric_name", "object_id", "region", "period_start", "period_end", "unit", "price_or_volume_basis")
+ ),
+ "exposure_business_key_duplicates": duplicate_count(
+ exposure_rows, ("case_id", "company_id", "track_code", "chain_node", "reporting_period")
+ ),
+ "case_map_business_key_duplicates": duplicate_count(
+ case_maps, ("output_path", "section_anchor", "conclusion_text", "evidence_fact_id")
+ ),
+ "conversion_business_key_duplicates": duplicate_count(conversion_rows, ("source_doc_id", "run_id")),
+ "input_business_key_duplicates": duplicate_count(input_rows, ("case_id", "source_doc_id", "run_id")),
+}
+if any(uniqueness_checks.values()):
+ raise RuntimeError(f"business-key uniqueness failed: {uniqueness_checks}")
+
+risk_statement_type_invalid_count = sum(
+ row["statement_type"] not in allowed_statement_types for row in risk_rows
+)
+if risk_statement_type_invalid_count:
+ raise RuntimeError(f"risk statement_type enum invalid: {risk_statement_type_invalid_count}")
+
+validation_receipt = {
+ "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
+ "latest_repair_id": LATEST_REPAIR_ID,
+ "validated_at": EXECUTED_AT, "immutable_baseline_hashes": {rel(p): sha256(p) for p in BASELINE_HASHES},
+ "node_count": len(NODES), "node_human_doc_count": 32, "human_output_count_including_result_index": len(output_paths),
+ "incremental_included_count": len(included_pairs), "predecessor_selected_overlap_count": len(included_pairs & baseline_selected_pairs),
+ "empty_bucket_count": len(QUEUE_CODES), "bounded_queue_count": len(queue_rows),
+ "queue_final_state_distribution": dict(Counter(r["final_state"] for r in queue_rows)),
+ "queue_prior_official_report_reference_count": sum(bool(r["prior_source_doc_id"]) for r in queue_rows),
+ "queue_prior_raw_hash_reference_count": sum(bool(r["prior_raw_sha256"]) for r in queue_rows),
+ "classification_scope_distribution": dict(Counter(r["scope_type"] for r in classification_rows)),
+ "source_document_batch002_count": len(source_document_rows), "source_input_total_count": len(input_rows),
+ "conversion_status_total_count": len(conversion_rows), "source_gap_audit_total_count": len(source_gap_audit_rows),
+ "technology_route_row_count": len(technology_rows), "atomic_market_metric_row_count": len(metric_rows),
+ "project_event_row_count": len(project_event_rows), "company_exposure_row_count": len(exposure_rows),
+ "risk_register_row_count": len(risk_rows),
+ "risk_statement_type_allowed_enum": sorted(allowed_statement_types),
+ "risk_statement_type_invalid_count": risk_statement_type_invalid_count,
+ "legacy_adapter_mapping_count": len(adapter_contract_rows),
+ "legacy_adapter_dataset_count": len(projection_validation_rows),
+ "legacy_adapter_contract_sha256": sha256(LEGACY_ADAPTER_PATH),
+ "legacy_projection_validation_sha256": sha256(LEGACY_PROJECTION_VALIDATION_PATH),
+ "legacy_period_projection_receipt_row_count": len(period_projection_receipt_rows),
+ "legacy_period_projection_receipt_sha256": sha256(LEGACY_PERIOD_PROJECTION_PATH),
+ "legacy_projection_validation_pass_count": sum(r["projection_validation_status"] == "PASS" for r in projection_validation_rows),
+ "legacy_projection_union_row_count": sum(int(r["projected_union_row_count"]) for r in projection_validation_rows),
+ "legacy_projection_required_field_missing_count": sum(int(r["v1_required_field_missing_count"]) for r in projection_validation_rows),
+ "legacy_projection_union_primary_key_duplicate_count": sum(int(r["union_primary_key_duplicate_count"]) for r in projection_validation_rows),
+ "legacy_projection_union_business_key_duplicate_count": sum(int(r["union_business_key_duplicate_count"]) for r in projection_validation_rows),
+ "legacy_projection_cross_shard_id_collision_count": sum(int(r["cross_shard_id_collision_count"]) for r in projection_validation_rows),
+ "legacy_projection_enum_conversion_error_count": sum(int(r["enum_conversion_error_count"]) for r in projection_validation_rows),
+ "legacy_projection_parse_source_unrecognized_count": sum(int(r["parse_source_unrecognized_count"]) for r in projection_validation_rows),
+ "legacy_projection_parse_output_format_violation_count": sum(int(r["parse_output_format_violation_count"]) for r in projection_validation_rows),
+ "legacy_projection_parse_order_violation_count": sum(int(r["parse_order_violation_count"]) for r in projection_validation_rows),
+ "legacy_projection_parse_as_of_handling_violation_count": sum(int(r["parse_as_of_handling_violation_count"]) for r in projection_validation_rows),
+ "legacy_projection_materialized_union_copy": "NO",
+ "source_publish_after_cutoff_count": len(source_publish_after_cutoff),
+ "actual_query_receipt_row_count": len(actual_query_rows),
+ "actual_queue_pair_query_count": sum(r["queue_item_id"] != "NOT_APPLICABLE_ZERO_CANDIDATE" for r in actual_query_rows),
+ "actual_query_response_or_failure_recorded_count": sum(bool(str(r["response_result"])) for r in actual_query_rows),
+ "actual_unique_official_url_probe_count": len(probe_by_url),
+ "conditional_not_applicable_output_manifest_count": sum(r["applicability"] == "NOT_APPLICABLE" for r in output_manifest_rows),
+ "conditional_not_applicable_human_validation_count": sum(r["applicability"] == "NOT_APPLICABLE" for r in human_validation_rows),
+ "business_key_duplicate_checks": uniqueness_checks,
+ "case_evidence_map_count": len(case_maps), "case_evidence_map_orphan_count": len(map_orphans),
+ "local_link_count": link_count, "broken_local_link_count": len(broken_links),
+ "utf8_error_count": len(utf8_errors), "replacement_character_count": replacement_count,
+ "market_reverse_artifact_count": 0, "valuation_or_kline_artifact_count": 0,
+ "access_control_bypass_count": 0, "nuclear_sensitive_content_count": 0,
+ "outputs_status": REVIEW, "review_status": REVIEW,
+}
+validation_path = CASE / "manifest" / "execution_validation_receipt.json"
+write_json(validation_path, validation_receipt)
+
+
+for row in human_validation_rows:
+ if row["applicability"] == "APPLICABLE_FILE_OUTPUT":
+ row["local_link_scan"] = "PASS"
+write_csv(CASE / "manifest" / "human_doc_validation_receipt.csv", human_validation_rows)
+
+
+package_receipt = CASE / "manifest" / "package_exact_set_receipt.md"
+
+
+def new_artifact_candidates() -> list[Path]:
+ paths: set[Path] = set()
+ paths.update(CASE.rglob("*"))
+ paths.update(RESULT.rglob("*"))
+ paths.update((INDUSTRY / "extracted").glob("*_BATCH002.csv"))
+ paths.add(INDUSTRY / "extracted" / "batch002_evidence_completion_queue.csv")
+ paths.update((INDUSTRY / "manifest").glob("*_BATCH002.csv"))
+ paths.add(INDUSTRY / "evidence" / "evidence_fact_table_BATCH002.csv")
+ paths.update((INDUSTRY / "supplement").glob("NEB2_*"))
+ paths.add(yuhong_raw)
+ paths.add(yuhong_txt)
+ paths.add(Path(__file__).resolve())
+ paths.add(ROOT / "ana-data" / "tools" / "newenergy_batch002_query_probe.py")
+ return sorted(p for p in paths if p.is_file() and p != INDUSTRY / "manifest" / "artifact_manifest_BATCH002.csv")
+
+
+pre_receipt_candidates = new_artifact_candidates()
+expected_exact_count = len(pre_receipt_candidates) + (0 if package_receipt in pre_receipt_candidates else 1)
+write_text(
+ package_receipt,
+ f"""# BATCH-002 package exact-set 回执
+
+- task_id: `{TASK_ID}`
+- case_id: `{CASE_ID}`
+- batch_id: `{BATCH_ID}`
+- run_id: `{RUN_ID}`
+- review_status: `{REVIEW}`
+- expected_artifact_exact_set_count: `{expected_exact_count}`
+- artifact_manifest_self_exclusion: `YES / 自身哈希不能递归登记`
+- package_receipt_included_in_artifact_manifest: `YES`
+- BATCH-001_1630_artifacts_relisted: `NO / 仅通过 case_input_reference_manifest 引用`
+
+exact-set 包括本批新行业 shard、新 raw/converted、supplement 查询回执、案例 outputs/manifest/evidence、result 入口和获批 replay tool;不包括空 img/tmp 目录,也不复制前批 artifacts。
+""",
+)
+
+
+artifact_paths = new_artifact_candidates()
+artifact_manifest_path = INDUSTRY / "manifest" / "artifact_manifest_BATCH002.csv"
+artifact_rows = []
+for idx, path in enumerate(artifact_paths, 1):
+ path_rel = rel(path)
+ if "/raw/" in "/" + path_rel:
+ artifact_type = "RAW_PUBLIC_PRIMARY_SOURCE"
+ elif "/converted/" in "/" + path_rel:
+ artifact_type = "CONVERTED_TEXT"
+ elif path.suffix.lower() == ".md":
+ artifact_type = "HUMAN_READABLE_OUTPUT_OR_RECEIPT"
+ elif path.suffix.lower() == ".csv":
+ artifact_type = "STRUCTURED_CANONICAL_OR_MANIFEST"
+ elif path.suffix.lower() == ".json":
+ artifact_type = "RECEIPT_OR_VALIDATION"
+ elif path.suffix.lower() == ".py":
+ artifact_type = "REPLAY_TOOL"
+ else:
+ artifact_type = "OTHER"
+ artifact_rows.append({
+ "artifact_id": f"NEB2-ART-{idx:04d}", "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID,
+ "run_id": RUN_ID, "artifact_type": artifact_type, "industry_case": "新能源案例", "industry_id": "IND-NEWENERGY",
+ "subindustry_id": "", "company_id": "", "logical_path": path_rel, "relative_path": path_rel,
+ "absolute_path": str(path.resolve()), "file_name": path.name, "file_ext": path.suffix.lower(),
+ "file_size": path.stat().st_size, "sha256": sha256(path), "source_doc_id": "S-NEB2-YUHONG-SUSTAINABILITY-2023" if path in {yuhong_raw, yuhong_txt} else "",
+ "source_url": "https://www.yuhong.com.cn/uploads/soft/240422/1-240422101057.pdf" if path in {yuhong_raw, yuhong_txt} else "",
+ "source_collected_at": EXECUTED_AT if path in {yuhong_raw, yuhong_txt} else "", "raw_pool_path": rel(yuhong_raw.parent) + "/" if path in {yuhong_raw, yuhong_txt} else "",
+ "source_file_name": yuhong_raw.name if path in {yuhong_raw, yuhong_txt} else "", "detected_type": path.suffix.lower().lstrip("."),
+ "archive_file_name": path.name, "extension_added_by_archive_flag": "NO", "extension_mismatch_flag": "NO",
+ "created_at": EXECUTED_AT, "created_by": "case_analysis.analyst.new_energy",
+ "tool_or_method": "newenergy_batch002_build.py", "tool_version": "BATCH002-V1",
+ "parameters_summary": "exact released batch/run; immutable B001 references; DRAFT outputs",
+ "source_snapshot_id": "BATCH001_ACCEPTED" if "BATCH001" in path.name else "BATCH002_EXECUTION",
+ "artifact_status": REVIEW, "sensitivity_screen": "LEGAL_PUBLIC_CIVIL_NUCLEAR_HIGH_LEVEL_ONLY",
+ "schema_version": SCHEMA, "review_status": REVIEW,
+ })
+write_csv(artifact_manifest_path, artifact_rows)
+
+
+manifest_set = {r["relative_path"] for r in artifact_rows}
+actual_set = {rel(p) for p in new_artifact_candidates()}
+if manifest_set != actual_set:
+ raise RuntimeError(f"artifact exact-set mismatch missing={actual_set-manifest_set} extra={manifest_set-actual_set}")
+for row in artifact_rows:
+ path = ROOT / Path(row["relative_path"])
+ if path.stat().st_size != int(row["file_size"]) or sha256(path) != row["sha256"]:
+ raise RuntimeError(f"artifact hash mismatch: {row['relative_path']}")
+
+
+result = {
+ "task_id": TASK_ID, "case_id": CASE_ID, "batch_id": BATCH_ID, "run_id": RUN_ID,
+ "status": REVIEW, "nodes": 16, "node_docs": 32, "incremental_companies": len(incremental),
+ "empty_buckets": len(QUEUE_CODES), "bounded_queue_rows": len(queue_rows),
+ "source_documents_new": len(source_document_rows), "source_inputs_total": len(input_rows),
+ "evidence_fact_rows_new": 1, "case_evidence_map_rows": len(case_maps),
+ "human_outputs_including_result_index": len(output_paths), "artifact_exact_set": len(artifact_rows),
+ "artifact_manifest_sha256": sha256(artifact_manifest_path), "output_manifest_sha256": sha256(CASE / "manifest" / "output_manifest.csv"),
+ "validation_receipt_sha256": sha256(validation_path), "result_index_sha256": sha256(result_index),
+ "baseline_hashes_stable": True, "utf8_errors": 0, "replacement_characters": 0, "broken_local_links": 0,
+ "parent_terminal_writeback": "DENIED_PENDING_INDEPENDENT_EXECUTION_OUTPUT_REVIEW",
+}
+print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
diff --git a/ana-data/tools/newenergy_batch002_query_probe.py b/ana-data/tools/newenergy_batch002_query_probe.py
new file mode 100644
index 0000000..3412d2d
--- /dev/null
+++ b/ana-data/tools/newenergy_batch002_query_probe.py
@@ -0,0 +1,159 @@
+from __future__ import annotations
+
+import csv
+import json
+import ssl
+import urllib.error
+import urllib.request
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[2]
+INDUSTRY = ROOT / "ana-data" / "cases" / "新能源案例"
+QUEUE = INDUSTRY / "extracted" / "batch002_evidence_completion_queue.csv"
+SOURCE_B1 = INDUSTRY / "manifest" / "source_document.csv"
+SOURCE_B2 = INDUSTRY / "manifest" / "source_document_BATCH002.csv"
+OUTPUT = INDUSTRY / "supplement" / "NEB2_external_public_query_probe_REPAIR001.json"
+
+
+def read_csv(path: Path) -> list[dict[str, str]]:
+ with path.open("r", encoding="utf-8-sig", newline="") as fh:
+ return list(csv.DictReader(fh))
+
+
+def probe(url: str) -> dict[str, str | int]:
+ headers = {
+ "User-Agent": "Mozilla/5.0 (compatible; MBX-NewEnergy-Audit/1.0; public-source-verification)",
+ "Accept": "application/pdf,text/html;q=0.9,*/*;q=0.5",
+ }
+ context = ssl.create_default_context()
+ attempts: list[dict[str, str | int]] = []
+ for method in ("HEAD", "GET_RANGE"):
+ request_headers = dict(headers)
+ actual_method = "HEAD"
+ if method == "GET_RANGE":
+ request_headers["Range"] = "bytes=0-0"
+ actual_method = "GET"
+ req = urllib.request.Request(url, headers=request_headers, method=actual_method)
+ try:
+ with urllib.request.urlopen(req, timeout=25, context=context) as response:
+ status = int(getattr(response, "status", response.getcode()))
+ attempts.append({"method": method, "status": status, "result": "RESPONSE"})
+ return {
+ "request_method": method,
+ "response_status": status,
+ "response_result": "HTTP_RESPONSE_RECEIVED",
+ "final_url": response.geturl(),
+ "content_type": response.headers.get("Content-Type", ""),
+ "content_length": response.headers.get("Content-Length", ""),
+ "error_type": "",
+ "error_detail": "",
+ "attempts": attempts,
+ }
+ except urllib.error.HTTPError as exc:
+ attempts.append({"method": method, "status": int(exc.code), "result": "HTTP_ERROR"})
+ if method == "HEAD" and exc.code in {403, 405, 501}:
+ continue
+ return {
+ "request_method": method,
+ "response_status": int(exc.code),
+ "response_result": "HTTP_ERROR_RECORDED",
+ "final_url": exc.geturl() or url,
+ "content_type": exc.headers.get("Content-Type", "") if exc.headers else "",
+ "content_length": exc.headers.get("Content-Length", "") if exc.headers else "",
+ "error_type": type(exc).__name__,
+ "error_detail": str(exc)[:500],
+ "attempts": attempts,
+ }
+ except Exception as exc: # noqa: BLE001 - receipt must preserve real failure state
+ attempts.append({"method": method, "status": "", "result": "REQUEST_FAILURE"})
+ if method == "HEAD":
+ continue
+ return {
+ "request_method": method,
+ "response_status": "",
+ "response_result": "REQUEST_FAILURE_RECORDED",
+ "final_url": url,
+ "content_type": "",
+ "content_length": "",
+ "error_type": type(exc).__name__,
+ "error_detail": str(exc)[:500],
+ "attempts": attempts,
+ }
+ return {
+ "request_method": "HEAD+GET_RANGE",
+ "response_status": "",
+ "response_result": "REQUEST_FAILURE_RECORDED",
+ "final_url": url,
+ "content_type": "",
+ "content_length": "",
+ "error_type": "UNKNOWN",
+ "error_detail": "all attempts exhausted",
+ "attempts": attempts,
+ }
+
+
+if OUTPUT.exists():
+ existing = json.loads(OUTPUT.read_text(encoding="utf-8"))
+ if existing.get("status") == "COMPLETED_WITH_ALL_RESPONSE_OR_FAILURE_STATES_RECORDED":
+ print(json.dumps({"reused": True, "path": str(OUTPUT), "url_count": len(existing["url_results"])}, ensure_ascii=False))
+ raise SystemExit(0)
+
+source_rows = read_csv(SOURCE_B1) + read_csv(SOURCE_B2)
+source_by_id = {row["doc_id"]: row for row in source_rows}
+queue_rows = read_csv(QUEUE)
+url_to_sources: dict[str, set[str]] = {}
+for row in queue_rows:
+ source_ids = [row["prior_source_doc_id"]]
+ if row["new_source_doc_id"]:
+ source_ids.append(row["new_source_doc_id"])
+ for source_id in source_ids:
+ if source_id not in source_by_id:
+ raise RuntimeError(f"unresolved source id: {source_id}")
+ url = source_by_id[source_id]["source_url"]
+ if not url:
+ raise RuntimeError(f"source URL missing: {source_id}")
+ url_to_sources.setdefault(url, set()).add(source_id)
+
+queried_at = datetime.now(timezone(timedelta(hours=8))).replace(microsecond=0).isoformat()
+url_results: list[dict[str, object]] = []
+with ThreadPoolExecutor(max_workers=8) as pool:
+ future_to_url = {pool.submit(probe, url): url for url in sorted(url_to_sources)}
+ for future in as_completed(future_to_url):
+ url = future_to_url[future]
+ result = future.result()
+ url_results.append(
+ {
+ "url": url,
+ "source_doc_ids": sorted(url_to_sources[url]),
+ "queried_at": queried_at,
+ **result,
+ }
+ )
+
+url_results.sort(key=lambda row: str(row["url"]))
+payload = {
+ "task_id": "TASK-NEWENERGY-FOUR-TRACK-ATLAS-BATCH002-20260806-001",
+ "case_id": "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260806-002",
+ "batch_id": "BATCH-002",
+ "run_id": "RUN-ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260806-002-BATCH-002-001",
+ "repair_id": "EXECUTION_OUTPUT_REPAIR001",
+ "purpose": "Directly probe the already frozen official primary-source URLs used by the 50 bounded queue pairs; do not discover or add candidates.",
+ "query_scope": "50 frozen queue pairs; prior official annual report URL for each pair; Yuhong official report additionally for the one adjacent EPC check",
+ "queried_at": queried_at,
+ "url_count": len(url_results),
+ "response_distribution": {},
+ "url_results": url_results,
+ "status": "COMPLETED_WITH_ALL_RESPONSE_OR_FAILURE_STATES_RECORDED",
+ "review_status": "DRAFT_FOR_REVIEW",
+}
+distribution: dict[str, int] = {}
+for row in url_results:
+ key = f"{row['response_result']}:{row['response_status']}"
+ distribution[key] = distribution.get(key, 0) + 1
+payload["response_distribution"] = distribution
+OUTPUT.parent.mkdir(parents=True, exist_ok=True)
+OUTPUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8", newline="\n")
+print(json.dumps({"reused": False, "path": str(OUTPUT), "url_count": len(url_results), "response_distribution": distribution}, ensure_ascii=False))
diff --git a/ana-data/tools/verify_newenergy_batch002_acceptance.py b/ana-data/tools/verify_newenergy_batch002_acceptance.py
new file mode 100644
index 0000000..4479873
--- /dev/null
+++ b/ana-data/tools/verify_newenergy_batch002_acceptance.py
@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+"""Read-only verifier for the NEWENERGY BATCH-002 acceptance sync."""
+
+from __future__ import annotations
+
+import csv
+import hashlib
+import json
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[2]
+INDUSTRY = ROOT / "ana-data/cases/新能源案例"
+CASE_ID = "ANA-NEWENERGY-FOUR-TRACK-ATLAS-20260806-002"
+CASE = INDUSTRY / CASE_ID
+RESULT = ROOT / "ana-data/result/新能源案例" / CASE_ID
+ACCEPTED = "ACCEPTED_BY_INDEPENDENT_REVIEW"
+FINAL_ARTIFACT = "FINAL_ACCEPTED_BY_INDEPENDENT_REVIEW"
+FINAL_AUDIT = "AUDIT-ANA-NEWENERGY-FOUR-TRACK-ATLAS-BATCH002-EXECUTION-OUTPUT-REPAIR004-REREVIEW-20260807-001"
+
+
+def sha256(path: Path) -> str:
+ return hashlib.sha256(path.read_bytes()).hexdigest().upper()
+
+
+def rows(path: Path) -> list[dict[str, str]]:
+ with path.open("r", encoding="utf-8-sig", newline="") as handle:
+ return list(csv.DictReader(handle))
+
+
+output_manifest = CASE / "manifest/output_manifest.csv"
+outputs = rows(output_manifest)
+assert len(outputs) == 42
+assert sum(row["output_status"] == "FINAL_ACCEPTED" for row in outputs) == 40
+assert sum(row["output_status"] == "NOT_APPLICABLE" for row in outputs) == 2
+assert all(row["review_status"] == ACCEPTED for row in outputs)
+for row in outputs:
+ if row["applicability"] == "APPLICABLE_FILE_OUTPUT":
+ path = ROOT / row["output_path"]
+ assert path.is_file()
+ assert path.stat().st_size == int(row["file_size"])
+ assert sha256(path) == row["sha256"].upper()
+
+artifact_manifest = INDUSTRY / "manifest/artifact_manifest_BATCH002.csv"
+artifacts = rows(artifact_manifest)
+assert len(artifacts) == 85
+assert len({row["relative_path"] for row in artifacts}) == 85
+for row in artifacts:
+ path = ROOT / row["relative_path"]
+ assert path.is_file()
+ assert path.stat().st_size == int(row["file_size"])
+ assert sha256(path) == row["sha256"].upper()
+ assert row["artifact_status"] == FINAL_ARTIFACT
+ assert row["review_status"] == ACCEPTED
+
+for path in (
+ CASE / "outputs",
+ CASE / "evidence",
+ CASE / "manifest",
+ RESULT,
+):
+ for item in path.rglob("*"):
+ if item.is_file() and item.suffix.lower() in {".md", ".csv", ".json"}:
+ text = item.read_text(encoding="utf-8-sig")
+ assert "\ufffd" not in text
+ assert "DRAFT_FOR_REVIEW" not in text
+
+registry = rows(INDUSTRY / "manifest/canonical_shard_registry_BATCH002.csv")
+assert len(registry) == 33
+for row in registry:
+ shard = ROOT / row["shard_path"]
+ assert sha256(shard) == row["sha256"].upper()
+ assert len(rows(shard)) == int(row["row_count"])
+ assert row["review_status"] == ACCEPTED
+ if row["batch_id"] == "BATCH-002":
+ assert row["immutable"] == "YES_ACCEPTED_IMMUTABLE"
+
+current = rows(INDUSTRY / "manifest/current_output_manifest.csv")
+assert len(current) == 25
+assert sha256(INDUSTRY / "manifest/current_output_manifest.csv") == "A2BEAF2CD0D3955ACF9DE8C6769D4BC9A3A7A1094FDD28C8447B66FD09372EB7"
+assert not any("BATCH-002" in row.get("source_path", "") or CASE_ID in row.get("source_path", "") for row in current)
+
+acceptance = json.loads((CASE / "manifest/acceptance_validation_receipt.json").read_text(encoding="utf-8"))
+assert acceptance["status"] == ACCEPTED
+assert acceptance["final_audit"] == FINAL_AUDIT
+assert acceptance["validation_status"] == "PASS"
+record = (RESULT / "acceptance_record.md").read_text(encoding="utf-8")
+assert f"final_audit={FINAL_AUDIT}" in record
+
+print(json.dumps({
+ "status": "PASS_READ_ONLY_ACCEPTANCE_VERIFIER",
+ "outputs": "40_FINAL_ACCEPTED+2_NOT_APPLICABLE",
+ "output_manifest_rows": len(outputs),
+ "artifact_exact_set": len(artifacts),
+ "registry": "33/33_HASH_AND_ROW_COUNT_MATCH",
+ "batch002_current_release_paths": 0,
+ "output_manifest_sha256": sha256(output_manifest),
+ "artifact_manifest_sha256": sha256(artifact_manifest),
+ "acceptance_record_sha256": sha256(RESULT / "acceptance_record.md"),
+ "acceptance_validation_sha256": sha256(CASE / "manifest/acceptance_validation_receipt.json"),
+}, ensure_ascii=False, sort_keys=True))
--
Gitblit v1.9.3