import argparse
|
import csv
|
import hashlib
|
import re
|
from collections import Counter
|
from datetime import datetime, timezone, timedelta
|
from pathlib import Path
|
|
|
VALUE_UNIT_RE = re.compile(
|
r"([-+]?\d+(?:\.\d+)?)\s*(%|万吨|吨|亿元|元/吨|美元/吨|美元/盎司|万元/吨|万\w*吨|GWh|万吨/年|吨/年|万股|亿元/年|万吨金属量)"
|
)
|
DATE_RE = re.compile(r"(20\d{2}[年/-]\d{1,2}(?:[月/-]\d{1,2}日?)?|20\d{2}年|Q[1-4]|[一二三四]季度|本周|上周|本月)")
|
|
|
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 extract_values(text):
|
pairs = []
|
for value, unit in VALUE_UNIT_RE.findall(text or ""):
|
pairs.append(f"{value}{unit}")
|
return "|".join(pairs[:8])
|
|
|
def extract_dates(text):
|
return "|".join(DATE_RE.findall(text or "")[:6])
|
|
|
def evidence_kind(theme_tags):
|
themes = set((theme_tags or "").split("|"))
|
if "price" in themes:
|
return "PRICE_OR_MARKET_DATA"
|
if "inventory" in themes:
|
return "INVENTORY"
|
if "supply" in themes:
|
return "SUPPLY_CAPACITY_OR_OUTPUT"
|
if "demand" in themes:
|
return "DEMAND"
|
if "project" in themes:
|
return "PROJECT_OR_ASSET"
|
if "cost" in themes:
|
return "COST_OR_MARGIN"
|
if "policy" in themes:
|
return "POLICY"
|
return "FACT_CANDIDATE"
|
|
|
def next_action(row, values):
|
if not values:
|
return "NEEDS_MANUAL_NUMERIC_OR_TABLE_REVIEW"
|
if "price" in row.get("theme_tags", "") or "inventory" in row.get("theme_tags", ""):
|
return "ADD_TABLE_LOCATION_AND_EXTERNAL_CROSS_CHECK"
|
return "ADD_PARAGRAPH_LOCATION_AND_SOURCE_CONTEXT"
|
|
|
def main():
|
parser = argparse.ArgumentParser()
|
parser.add_argument("--project-root", default=".")
|
parser.add_argument("--review", required=True)
|
parser.add_argument("--cards-out", required=True)
|
parser.add_argument("--manifest-out", required=True)
|
parser.add_argument("--summary-out", required=True)
|
args = parser.parse_args()
|
|
project_root = Path(args.project_root).resolve()
|
rows = read_csv(project_root / args.review)
|
candidates = [
|
row
|
for row in rows
|
if row.get("evidence_upgrade_status") == "CANDIDATE_FOR_EVIDENCE_CARD"
|
and row.get("scope_gate") == "CORE_INDUSTRY_CANDIDATE"
|
]
|
created_at = datetime.now(timezone(timedelta(hours=8))).isoformat(timespec="seconds")
|
cards = []
|
for row in candidates:
|
text = row.get("evidence_text", "")
|
context = row.get("page_context", "")
|
values = extract_values(text + " " + context)
|
dates = extract_dates(text + " " + context)
|
cards.append(
|
{
|
"evidence_card_id": f"YS-FACT-CARD-002-{len(cards) + 1:04d}",
|
"case_id": row.get("case_id", "ANA-YS-INDUSTRY-001"),
|
"batch_id": row.get("batch_id", ""),
|
"run_id": "RUN-ANA-YS-FACT-CARD-002",
|
"fact_id": row.get("fact_id", ""),
|
"doc_id": row.get("doc_id", ""),
|
"metal_tags": row.get("metal_tags", ""),
|
"theme_tags": row.get("theme_tags", ""),
|
"evidence_kind": evidence_kind(row.get("theme_tags", "")),
|
"source_expression_type": row.get("source_expression_type", ""),
|
"declared_page_no": row.get("declared_page_no", ""),
|
"matched_page_no": row.get("matched_page_no", ""),
|
"converted_text_path": row.get("converted_text_path", ""),
|
"raw_file_path": row.get("raw_file_path", ""),
|
"raw_sha256": row.get("raw_sha256", ""),
|
"evidence_text": text,
|
"value_unit_candidates": values,
|
"date_candidates": dates,
|
"page_context": context[:700],
|
"table_location_status": "NEEDS_TABLE_OR_PARAGRAPH_LOCATION",
|
"external_cross_check_status": "NEEDS_EXTERNAL_CROSS_CHECK",
|
"next_action": next_action(row, values),
|
"review_status": "DRAFT_FOR_REVIEW",
|
"created_at": created_at,
|
}
|
)
|
|
cards_path = project_root / args.cards_out
|
fields = [
|
"evidence_card_id",
|
"case_id",
|
"batch_id",
|
"run_id",
|
"fact_id",
|
"doc_id",
|
"metal_tags",
|
"theme_tags",
|
"evidence_kind",
|
"source_expression_type",
|
"declared_page_no",
|
"matched_page_no",
|
"converted_text_path",
|
"raw_file_path",
|
"raw_sha256",
|
"evidence_text",
|
"value_unit_candidates",
|
"date_candidates",
|
"page_context",
|
"table_location_status",
|
"external_cross_check_status",
|
"next_action",
|
"review_status",
|
"created_at",
|
]
|
write_csv(cards_path, fields, cards)
|
|
manifest_path = project_root / args.manifest_out
|
manifest_rows = [
|
{
|
"case_id": "ANA-YS-INDUSTRY-001",
|
"batch_id": "BATCH-001+BATCH-003",
|
"run_id": "RUN-ANA-YS-FACT-CARD-002",
|
"artifact_type": "key_fact_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,
|
)
|
|
kind_counts = Counter(card["evidence_kind"] for card in cards)
|
action_counts = Counter(card["next_action"] for card in cards)
|
with_values = sum(1 for card in cards if card["value_unit_candidates"])
|
summary_path = project_root / args.summary_out
|
lines = [
|
"# 关键事实证据卡 PASS-002 摘要",
|
"",
|
"状态: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)}",
|
f"- 含数值/单位候选:{with_values}",
|
"",
|
"## 证据类型",
|
"",
|
"| 类型 | 数量 |",
|
"|---|---:|",
|
]
|
for key, count in kind_counts.most_common():
|
lines.append(f"| {key} | {count} |")
|
lines.extend(["", "## 下一步动作", "", "| 动作 | 数量 |", "|---|---:|"])
|
for key, count in action_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()
|