import argparse
|
import csv
|
import hashlib
|
from collections import Counter, defaultdict
|
from datetime import datetime, timezone, timedelta
|
from pathlib import Path
|
|
|
CN_TZ = timezone(timedelta(hours=8))
|
|
|
def read_csv(path: Path):
|
with path.open("r", encoding="utf-8-sig", newline="") as f:
|
return list(csv.DictReader(f))
|
|
|
def write_csv(path: Path, fieldnames, rows):
|
path.parent.mkdir(parents=True, exist_ok=True)
|
with path.open("w", encoding="utf-8-sig", newline="") as f:
|
writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
|
writer.writeheader()
|
writer.writerows(rows)
|
|
|
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 split_values(value: str):
|
return [item.strip() for item in (value or "").split("|") if item.strip()]
|
|
|
def first_non_empty(values):
|
for value in values:
|
if value:
|
return value
|
return ""
|
|
|
def build_rows(company_cards, official_sources, recovery_rows, broker_rows, run_id, created_at):
|
official_by_company = defaultdict(list)
|
recovery_by_company = defaultdict(list)
|
broker_by_company = defaultdict(list)
|
for row in official_sources:
|
official_by_company[row.get("company_name", "")].append(row)
|
for row in recovery_rows:
|
recovery_by_company[row.get("company_name", "")].append(row)
|
for row in broker_rows:
|
broker_by_company[row.get("company_name_fragment", "")].append(row)
|
|
rows = []
|
for idx, card in enumerate(company_cards, start=1):
|
company = card.get("company_name", "")
|
official = official_by_company.get(company, [])
|
recovery = recovery_by_company.get(company, [])
|
broker = broker_by_company.get(company, [])
|
source_types = sorted({r.get("source_type", "") for r in official if r.get("source_type", "")})
|
recovery_types = sorted({r.get("archive_recovery_type", "") for r in recovery if r.get("archive_recovery_type", "")})
|
value_candidates = []
|
for row in official:
|
value_candidates.extend(split_values(row.get("value_unit_candidates", "")))
|
value_candidates = sorted(set(value_candidates))
|
has_official = bool(official)
|
has_recovery = bool(recovery)
|
has_broker_split = bool(broker)
|
rows.append(
|
{
|
"structured_company_card_id": f"YS-COMP-STRUCT-017-{idx:04d}",
|
"case_id": card.get("case_id", "ANA-YS-INDUSTRY-001"),
|
"batch_id": "BATCH-003",
|
"run_id": run_id,
|
"source_company_card_id": card.get("company_card_id", ""),
|
"company_name": company,
|
"mention_count": card.get("mention_count", ""),
|
"metal_tags": card.get("metal_tags_in_context", ""),
|
"stock_code": first_non_empty(row.get("stock_code", "") for row in official),
|
"broker_phrase_split_status": "SPLIT_LEDGER_LINKED" if has_broker_split else "NO_SPLIT_LEDGER_MATCH",
|
"broker_phrase_split_count": str(len(broker)),
|
"sample_context_policy": "DO_NOT_USE_AS_ANALYST_CONCLUSION" if has_broker_split else "RAW_SAMPLE_REVIEW_REQUIRED",
|
"official_source_status": "OFFICIAL_SOURCE_ENTRY_DRAFT" if has_official else "NEEDS_OFFICIAL_SOURCE",
|
"official_source_count": str(len(official)),
|
"official_source_types": "|".join(source_types),
|
"archive_recovery_status": "ARCHIVE_RECOVERY_QUEUE_LINKED" if has_recovery else "NO_ARCHIVE_RECOVERY_QUEUE",
|
"archive_recovery_count": str(len(recovery)),
|
"archive_recovery_types": "|".join(recovery_types),
|
"business_exposure_field": "TO_BE_EXTRACTED_FROM_OFFICIAL_SOURCE",
|
"resource_or_capacity_field": "TO_BE_EXTRACTED_FROM_OFFICIAL_SOURCE",
|
"profit_sensitivity_field": "TO_BE_MODELED_AFTER_VALUE_VERIFICATION",
|
"trigger_condition_field": "TO_BE_FILLED_AFTER_EVIDENCE_REVIEW",
|
"failure_condition_field": "TO_BE_FILLED_AFTER_EVIDENCE_REVIEW",
|
"risk_field": "TO_BE_FILLED_AFTER_EVIDENCE_REVIEW",
|
"value_unit_candidates": "|".join(value_candidates),
|
"official_archive_blockers": "archive_path_or_hash_missing; original_line_or_table_row_missing; value_consistency_not_verified" if has_recovery else "official_source_missing_or_not_prioritized",
|
"analyst_conclusion_status": "NOT_GENERATED_DRAFT_INPUT_ONLY",
|
"evidence_status": "STRUCTURED_DRAFT_FIELD_SHELL",
|
"next_action": "fill_official_archive_and_original_line_then_value_consistency" if has_official else "find_company_official_or_exchange_source",
|
"review_status": "DRAFT_FOR_REVIEW",
|
"created_at": created_at,
|
}
|
)
|
return rows
|
|
|
def write_summary(path: Path, output_path: Path, manifest_path: Path, rows, digest: str, created_at: str):
|
by_official = Counter(r["official_source_status"] for r in rows)
|
by_split = Counter(r["broker_phrase_split_status"] for r in rows)
|
lines = [
|
"# 公司证据卡字段化 PASS-017 摘要",
|
"",
|
"状态:DRAFT_FOR_REVIEW",
|
f"生成时间:{created_at}",
|
"",
|
"## 输出",
|
"",
|
f"- 字段化公司证据卡:`{output_path.as_posix()}`",
|
f"- manifest:`{manifest_path.as_posix()}`",
|
f"- 记录数:{len(rows)}",
|
f"- sha256:`{digest}`",
|
"",
|
"## 官方源状态",
|
"",
|
"| 状态 | 数量 |",
|
"|---|---:|",
|
]
|
for key, count in by_official.items():
|
lines.append(f"| {key} | {count} |")
|
lines += ["", "## 券商原文分账链接", "", "| 状态 | 数量 |", "|---|---:|"]
|
for key, count in by_split.items():
|
lines.append(f"| {key} | {count} |")
|
lines += [
|
"",
|
"## 边界",
|
"",
|
"本轮只把公司候选卡字段化为证据卡草稿壳,并链接 PASS-016 分账清单、PASS-012 官方源入口和 PASS-014 归档恢复队列。所有公司字段仍为 DRAFT,不构成正式公司证据、正式投资读法、交易指令或收益承诺。",
|
"",
|
]
|
path.parent.mkdir(parents=True, exist_ok=True)
|
path.write_text("\n".join(lines), encoding="utf-8")
|
|
|
def main():
|
parser = argparse.ArgumentParser()
|
parser.add_argument("--company-cards", required=True)
|
parser.add_argument("--official-sources", required=True)
|
parser.add_argument("--archive-recovery", required=True)
|
parser.add_argument("--broker-split", 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", required=True)
|
args = parser.parse_args()
|
created_at = datetime.now(CN_TZ).isoformat(timespec="seconds")
|
output_path = Path(args.output)
|
manifest_path = Path(args.manifest)
|
rows = build_rows(
|
read_csv(Path(args.company_cards)),
|
read_csv(Path(args.official_sources)),
|
read_csv(Path(args.archive_recovery)),
|
read_csv(Path(args.broker_split)),
|
args.run_id,
|
created_at,
|
)
|
fieldnames = list(rows[0].keys()) if rows else ["structured_company_card_id", "review_status"]
|
write_csv(output_path, fieldnames, rows)
|
digest = sha256_file(output_path)
|
manifest_rows = [
|
{
|
"case_id": "ANA-YS-INDUSTRY-001",
|
"batch_id": "BATCH-003",
|
"run_id": args.run_id,
|
"artifact_type": "structured_company_evidence_card",
|
"artifact_path": output_path.as_posix(),
|
"row_count": str(len(rows)),
|
"sha256": digest,
|
"review_status": "DRAFT_FOR_REVIEW",
|
"created_at": created_at,
|
}
|
]
|
write_csv(manifest_path, list(manifest_rows[0].keys()), manifest_rows)
|
write_summary(Path(args.summary), output_path, manifest_path, rows, digest, created_at)
|
print(f"OK rows={len(rows)} output={output_path} sha256={digest}")
|
|
|
if __name__ == "__main__":
|
main()
|