import argparse import csv import hashlib import re from collections import Counter, defaultdict from datetime import datetime, timezone, timedelta from pathlib import Path STOPWORDS = { "有色金属", "能源金属", "工业金属", "基本金属", "贵金属", "小金属", "金属新材料", "中重稀土", "矿产资源", "自然资源", "稀土资源", "国家能源", "中国新能源", "基础化工", "稀土冶炼", "年全球稀土", "全球稀土", "COMEX黄金", "LME铜", "LME铝", } NOISE_PATTERNS = ["本报告", "所有材料", "免责声明", "证券研究报告", "请务必阅读", "评级", "走势图"] OPINION_WORDS = ["推荐", "建议关注", "相关标的", "有望", "预计", "看好", "维持"] 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 split_tags(value): return [tag for tag in (value or "").split("|") if tag] def clean_text(value): return re.sub(r"\s+", " ", (value or "").strip()) def is_company_candidate(name, context): if not name or name in STOPWORDS or name.isdigit() or len(name) > 14: return False if any(pattern in name for pattern in NOISE_PATTERNS): return False if any(pattern in context for pattern in ["版权均属", "不得以任何形式", "目录", "分析师承诺"]): return False return True def expression_type(text): if any(word in text for word in OPINION_WORDS): return "BROKER_VIEW_OR_ORIGINAL_RECOMMENDATION" if re.search(r"\d", text): return "NUMERIC_OR_FACT_CONTEXT" return "TEXT_CONTEXT" def card_priority(count, metals, examples): score = count score += len(metals) * 3 if any(re.search(r"\d", row.get("context_snippet", "")) for row in examples): score += 5 if any("BROKER_VIEW" in expression_type(row.get("context_snippet", "")) for row in examples): score += 1 return score def sha256_file(path): return hashlib.sha256(path.read_bytes()).hexdigest() def main(): parser = argparse.ArgumentParser() parser.add_argument("--project-root", default=".") parser.add_argument("--company-files", nargs="+", required=True) parser.add_argument("--cards-out", required=True) parser.add_argument("--manifest-out", required=True) parser.add_argument("--summary-out", required=True) parser.add_argument("--limit", type=int, default=80) args = parser.parse_args() project_root = Path(args.project_root).resolve() rows = [] for file_name in args.company_files: path = project_root / file_name if path.exists(): rows.extend(read_csv(path)) grouped = defaultdict(list) for row in rows: name = row.get("company_name", "") context = clean_text(row.get("context_snippet", "")) if is_company_candidate(name, context): grouped[name].append(row) ranked = [] for name, examples in grouped.items(): metal_counter = Counter() for row in examples: metal_counter.update(split_tags(row.get("metal_tags"))) ranked.append((card_priority(len(examples), metal_counter, examples), name, examples, metal_counter)) ranked.sort(reverse=True) created_at = datetime.now(timezone(timedelta(hours=8))).isoformat(timespec="seconds") cards = [] for _, name, examples, metal_counter in ranked[: args.limit]: example = examples[0] numeric_example = next((row for row in examples if re.search(r"\d", row.get("context_snippet", ""))), example) broker_flag = any(expression_type(row.get("context_snippet", "")) == "BROKER_VIEW_OR_ORIGINAL_RECOMMENDATION" for row in examples[:10]) cards.append( { "company_card_id": f"YS-COMP-CARD-001-{len(cards) + 1:04d}", "case_id": "ANA-YS-INDUSTRY-001", "batch_id": "BATCH-001+BATCH-003", "run_id": "RUN-ANA-YS-COMPANY-CARD-001", "company_name": name, "mention_count": len(examples), "metal_tags_in_context": "|".join([metal for metal, _ in metal_counter.most_common(8)]), "source_expression_type": "HAS_BROKER_VIEW_OR_ORIGINAL_RECOMMENDATION" if broker_flag else expression_type(numeric_example.get("context_snippet", "")), "sample_doc_id": numeric_example.get("doc_id", ""), "sample_page_no": numeric_example.get("page_no", ""), "sample_source_location": numeric_example.get("source_location", ""), "sample_converted_text_path": numeric_example.get("converted_text_path", ""), "sample_raw_file_path": numeric_example.get("raw_file_path", ""), "sample_context": clean_text(numeric_example.get("context_snippet", ""))[:500], "business_exposure_status": "NEEDS_EXTERNAL_COMPANY_SOURCE", "resource_or_capacity_status": "NEEDS_EXTERNAL_COMPANY_SOURCE", "profit_sensitivity_status": "NEEDS_MODEL_OR_DISCLOSURE", "trigger_condition_status": "DRAFT_TO_BE_FILLED", "failure_condition_status": "DRAFT_TO_BE_FILLED", "risk_status": "DRAFT_TO_BE_FILLED", "next_action": "BUILD_COMPANY_SOURCE_CARD_FROM_REPORT_AND_ANNOUNCEMENT", "review_status": "DRAFT_FOR_REVIEW", "created_at": created_at, } ) cards_path = project_root / args.cards_out manifest_path = project_root / args.manifest_out summary_path = project_root / args.summary_out fields = [ "company_card_id", "case_id", "batch_id", "run_id", "company_name", "mention_count", "metal_tags_in_context", "source_expression_type", "sample_doc_id", "sample_page_no", "sample_source_location", "sample_converted_text_path", "sample_raw_file_path", "sample_context", "business_exposure_status", "resource_or_capacity_status", "profit_sensitivity_status", "trigger_condition_status", "failure_condition_status", "risk_status", "next_action", "review_status", "created_at", ] write_csv(cards_path, fields, cards) manifest_rows = [ { "case_id": "ANA-YS-INDUSTRY-001", "batch_id": "BATCH-001+BATCH-003", "run_id": "RUN-ANA-YS-COMPANY-CARD-001", "artifact_type": "company_evidence_card", "artifact_path": cards_path.relative_to(project_root).as_posix(), "row_count": len(cards), "sha256": sha256_file(cards_path), "review_status": "DRAFT_FOR_REVIEW", "created_at": created_at, } ] write_csv( manifest_path, ["case_id", "batch_id", "run_id", "artifact_type", "artifact_path", "row_count", "sha256", "review_status", "created_at"], manifest_rows, ) expression_counts = Counter(row["source_expression_type"] for row in cards) metal_counts = Counter() for row in cards: metal_counts.update(split_tags(row.get("metal_tags_in_context"))) lines = [ "# 公司证据卡候选 PASS-001 摘要", "", "状态:DRAFT_FOR_REVIEW", f"生成时间:{created_at}", "", "## 输出", "", f"- 公司证据卡:`{cards_path.relative_to(project_root).as_posix()}`", f"- manifest:`{manifest_path.relative_to(project_root).as_posix()}`", f"- 公司卡数量:{len(cards)}", "", "## 表达类型", "", "| 类型 | 数量 |", "|---|---:|", ] for key, count in expression_counts.most_common(): lines.append(f"| {key} | {count} |") lines.extend(["", "## 金属上下文覆盖", "", "| 金属 | 数量 |", "|---|---:|"]) for key, count in metal_counts.most_common(): lines.append(f"| {key} | {count} |") lines.extend( [ "", "## 边界", "", "本轮公司卡仅为候选证据卡。券商原文中的“推荐关注”“相关标的”等表达已保留为原文/研报观点标记,不代表分析员推荐。业务占比、资源产能、利润弹性、触发/失效条件和风险仍需外部公告、年报或公司来源补证。", ] ) summary_path.parent.mkdir(parents=True, exist_ok=True) summary_path.write_text("\n".join(lines) + "\n", encoding="utf-8") print(f"cards={len(cards)}") print(f"cards_out={cards_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()