#!/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()
|