import argparse import csv import hashlib from collections import Counter from datetime import datetime, timedelta, timezone from pathlib import Path def read_csv(path): with path.open("r", encoding="utf-8-sig", newline="") as handle: return list(csv.DictReader(handle)) def write_csv(path, fieldnames, rows): path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8-sig", newline="") as handle: writer = csv.DictWriter(handle, fieldnames=fieldnames) writer.writeheader() writer.writerows(rows) def sha256_file(path): return hashlib.sha256(path.read_bytes()).hexdigest() def classify_gap(row): value_status = row.get("value_consistency_result", "") access_status = row.get("source_access_status", "") source_name = row.get("source_name", "") if value_status == "NOT_VERIFIED_CANDIDATE_DATE_AMBIGUOUS": return { "gap_type": "REPORT_DATE_AND_TABLE_ROW_GAP", "gap_reason": "candidate fact uses relative date wording; source page is locatable but exact report week/date and original table row are unresolved", "required_fix": "resolve report publication date or report week, then map to source table/download row and compare value/unit", "queue_priority": "HIGH", "queue_status": "QUEUE_REPORT_DATE_RESOLUTION", "next_action": "resolve_report_date_then_map_source_row", } if value_status == "NOT_VERIFIED_SOURCE_ACCESS_HELD" or access_status == "HELD_BY_WEB_VERIFICATION_PAGE": return { "gap_type": "SOURCE_ACCESS_OR_ARCHIVE_GAP", "gap_reason": "source entry is official but current access is blocked by verification page or missing archived table/download", "required_fix": "retry source download/archive through approved path, capture table/download evidence, or replace with auditable equivalent source", "queue_priority": "HIGH", "queue_status": "QUEUE_SOURCE_ARCHIVE_OR_REPLACEMENT", "next_action": "retry_archive_or_find_auditable_replacement_source", } if value_status == "NOT_VERIFIED_VALUE_ACCESS_HELD" or access_status == "PARTIAL_ACCESS_VALUE_SIGNIN_REQUIRED": return { "gap_type": "VALUE_ACCESS_HELD_GAP", "gap_reason": "source page/specification is locatable but value requires login, screenshot, or replacement source", "required_fix": "obtain access confirmation, archive screenshot/table row, or replace with public auditable source", "queue_priority": "HIGH", "queue_status": "QUEUE_VALUE_ACCESS_CONFIRMATION", "next_action": "confirm_value_access_or_replace_source", } return { "gap_type": "SOURCE_VERIFY_REVIEW_GAP", "gap_reason": f"unhandled source verification state for {source_name}", "required_fix": "manual review required", "queue_priority": row.get("priority", "MEDIUM") or "MEDIUM", "queue_status": "QUEUE_MANUAL_REVIEW", "next_action": "manual_review_source_verify_result", } def main(): parser = argparse.ArgumentParser() parser.add_argument("--project-root", default=".") parser.add_argument("--verify-result-input", required=True) parser.add_argument("--output", required=True) parser.add_argument("--manifest", required=True) parser.add_argument("--summary", required=True) parser.add_argument("--run-id", default="RUN-ANA-YS-SOURCE-GAP-CONFLICT-011") args = parser.parse_args() project_root = Path(args.project_root).resolve() result_rows = read_csv(project_root / args.verify_result_input) created_at = datetime.now(timezone(timedelta(hours=8))).isoformat(timespec="seconds") rows = [] for row in result_rows: gap = classify_gap(row) rows.append( { "source_gap_conflict_id": f"YS-SOURCE-GAP-011-{len(rows) + 1:04d}", "case_id": row.get("case_id", ""), "batch_id": row.get("batch_id", ""), "run_id": args.run_id, "source_verify_result_id": row.get("source_verify_result_id", ""), "source_verify_id": row.get("source_verify_id", ""), "source_supplement_id": row.get("source_supplement_id", ""), "gap_priority_id": row.get("gap_priority_id", ""), "evidence_card_id": row.get("evidence_card_id", ""), "fact_id": row.get("fact_id", ""), "doc_id": row.get("doc_id", ""), "metal_tags": row.get("metal_tags", ""), "theme_tags": row.get("theme_tags", ""), "source_name": row.get("source_name", ""), "source_url": row.get("source_url", ""), "verified_source_url": row.get("verified_source_url", ""), "source_access_status": row.get("source_access_status", ""), "source_row_status": row.get("source_row_status", ""), "value_consistency_result": row.get("value_consistency_result", ""), "gap_type": gap["gap_type"], "gap_reason": gap["gap_reason"], "required_fix": gap["required_fix"], "queue_priority": gap["queue_priority"], "queue_status": gap["queue_status"], "value_unit_candidates": row.get("value_unit_candidates", ""), "date_candidates": row.get("date_candidates", ""), "next_action": gap["next_action"], "review_status": "DRAFT_FOR_REVIEW", "created_at": created_at, } ) output_path = project_root / args.output fields = list(rows[0].keys()) if rows else ["source_gap_conflict_id", "case_id", "run_id", "review_status"] write_csv(output_path, fields, rows) output_sha = sha256_file(output_path) manifest_path = project_root / args.manifest write_csv( manifest_path, ["case_id", "batch_id", "run_id", "artifact_type", "artifact_path", "row_count", "sha256", "review_status", "created_at"], [ { "case_id": "ANA-YS-INDUSTRY-001", "batch_id": "BATCH-001+BATCH-003", "run_id": args.run_id, "artifact_type": "source_gap_conflict_queue", "artifact_path": output_path.relative_to(project_root).as_posix(), "row_count": str(len(rows)), "sha256": output_sha, "review_status": "DRAFT_FOR_REVIEW", "created_at": created_at, } ], ) gap_counts = Counter(row["gap_type"] for row in rows) queue_counts = Counter(row["queue_status"] for row in rows) source_counts = Counter(row["source_name"] for row in rows) summary_path = project_root / args.summary lines = [ "# 来源缺口/冲突队列 PASS-011 摘要", "", "状态:DRAFT_FOR_REVIEW", f"生成时间:{created_at}", "", "## 输出", "", f"- 缺口/冲突队列:`{output_path.relative_to(project_root).as_posix()}`", f"- manifest:`{manifest_path.relative_to(project_root).as_posix()}`", f"- 记录数:{len(rows)}", f"- sha256:`{output_sha}`", "", "## 缺口类型", "", "| 类型 | 数量 |", "|---|---:|", ] for key, count in gap_counts.most_common(): lines.append(f"| {key} | {count} |") lines.extend(["", "## 队列状态", "", "| 状态 | 数量 |", "|---|---:|"]) for key, count in queue_counts.most_common(): lines.append(f"| {key} | {count} |") lines.extend(["", "## 来源分布", "", "| 来源 | 数量 |", "|---|---:|"]) for key, count in source_counts.most_common(): lines.append(f"| {key} | {count} |") lines.extend( [ "", "## 边界", "", "本轮只把 PASS-010 的 held 项拆成后续补证队列,未完成报告日期解析、原始表格行定位、访问归档或数值一致性核验;不作为正式指标、正式证据或正式结论。", ] ) summary_path.parent.mkdir(parents=True, exist_ok=True) summary_path.write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"rows={len(rows)}") print(dict(gap_counts)) print(dict(queue_counts)) print(f"output={output_path.relative_to(project_root).as_posix()}") print(f"manifest={manifest_path.relative_to(project_root).as_posix()}") print(f"summary={summary_path.relative_to(project_root).as_posix()}") if __name__ == "__main__": main()