import argparse
|
import csv
|
import hashlib
|
import re
|
from collections import Counter, defaultdict
|
from datetime import datetime, timezone, timedelta
|
from pathlib import Path
|
|
|
METAL_ORDER = ["铜", "铝", "金", "银", "锂", "镍", "钴", "稀土", "锡", "钨", "钼", "锑"]
|
THEME_PRIORITY = ["price", "inventory", "supply", "demand", "cost", "project", "policy", "risk", "company"]
|
OPINION_WORDS = ["推荐", "建议关注", "相关标的", "有望", "预计", "或将", "看好", "维持", "评级"]
|
TABLE_WORDS = ["图", "表", "数据来源", "来源:", "根据", "库存", "价格", "产量", "产能", "同比", "环比"]
|
ADJACENT_OR_NOISE_WORDS = [
|
"凤凰光学",
|
"骏鼎达",
|
"宏达电子",
|
"银禧科技",
|
"一汽解放",
|
"佰维存储",
|
"纳芯微",
|
"AI服务器",
|
"光通信",
|
"半固态电池",
|
"智能物联",
|
]
|
|
|
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 short_hash(path):
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
def score_fact(row):
|
metals = split_tags(row.get("metal_tags"))
|
themes = split_tags(row.get("theme_tags"))
|
text = row.get("evidence_text", "")
|
score = 0
|
if row.get("fact_type") == "indicator_sentence":
|
score += 6
|
if row.get("fact_type") == "risk_sentence":
|
score += 3
|
if re.search(r"\d", text):
|
score += 4
|
for theme in themes:
|
if theme in THEME_PRIORITY:
|
score += max(1, 10 - THEME_PRIORITY.index(theme))
|
for metal in metals:
|
if metal in METAL_ORDER:
|
score += 2
|
if any(word in text for word in TABLE_WORDS):
|
score += 2
|
if len(text) < 25:
|
score -= 2
|
return score
|
|
|
def load_pages(path):
|
if not path.exists():
|
return {}
|
pages = defaultdict(list)
|
current_page = "unknown"
|
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
match = re.match(r"\[\[PAGE\s+(\d+)\]\]", line.strip())
|
if match:
|
current_page = match.group(1)
|
continue
|
cleaned = clean_text(line)
|
if cleaned:
|
pages[current_page].append(cleaned)
|
return {page: " ".join(lines) for page, lines in pages.items()}
|
|
|
def find_context(row, project_root):
|
converted = project_root / row.get("converted_text_path", "")
|
pages = load_pages(converted)
|
if not pages:
|
return "NO_TEXT_FILE", "", ""
|
page_no = row.get("page_no") or "unknown"
|
text = clean_text(row.get("evidence_text", ""))
|
candidates = []
|
if page_no in pages:
|
candidates.append((page_no, pages[page_no]))
|
candidates.extend((page, body) for page, body in pages.items() if page != page_no)
|
needle = text[:80]
|
for page, body in candidates:
|
idx = body.find(needle)
|
if idx >= 0:
|
start = max(0, idx - 180)
|
end = min(len(body), idx + len(text) + 220)
|
status = "TEXT_MATCH_CONTEXT_OK" if page == page_no else "TEXT_MATCH_DIFFERENT_PAGE"
|
return status, page, body[start:end]
|
tokens = [token for token in re.split(r"\W+", text) if len(token) >= 2]
|
hit_count = sum(1 for token in tokens[:12] if token in pages.get(page_no, ""))
|
if hit_count >= max(2, min(5, len(tokens) // 2)):
|
return "TEXT_MATCH_PARTIAL_CONTEXT", page_no, pages.get(page_no, "")[:700]
|
return "NO_TEXT_MATCH", page_no, pages.get(page_no, "")[:700]
|
|
|
def source_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_FACT_CANDIDATE"
|
return "TEXT_FACT_OR_VIEW_CANDIDATE"
|
|
|
def scope_gate(text):
|
compact = clean_text(text)
|
if "........" in compact or re.search(r"^\d+(?:\.\d+)+\s+.*\.{5,}\s*\d+", compact):
|
return "TOC_OR_INDEX_NOISE"
|
if "公司公告" in compact or any(word in compact for word in ADJACENT_OR_NOISE_WORDS):
|
return "MARKET_NEWS_OR_ADJACENT_REVIEW"
|
return "CORE_INDUSTRY_CANDIDATE"
|
|
|
def next_action(row, review_status, expression_type):
|
text = row.get("evidence_text", "")
|
scope = scope_gate(text)
|
if scope == "TOC_OR_INDEX_NOISE":
|
return "EXCLUDE_FROM_CORE_EVIDENCE_AS_TOC_NOISE"
|
if scope == "MARKET_NEWS_OR_ADJACENT_REVIEW":
|
return "ROUTE_TO_MARKET_OR_ADJACENT_REVIEW_BEFORE_USE"
|
themes = set(split_tags(row.get("theme_tags")))
|
if review_status in {"NO_TEXT_FILE", "NO_TEXT_MATCH"}:
|
return "CHECK_CONVERTED_OR_RAW_SOURCE"
|
if expression_type == "BROKER_VIEW_OR_ORIGINAL_RECOMMENDATION":
|
return "KEEP_AS_BROKER_VIEW_DO_NOT_REWRITE_AS_ANALYST_RECOMMENDATION"
|
if {"price", "inventory", "supply", "demand", "cost", "project"} & themes or re.search(r"\d", text):
|
return "ADD_PARAGRAPH_TABLE_OR_EXTERNAL_CROSS_CHECK"
|
return "KEEP_DRAFT_OR_USE_AS_CONTEXT"
|
|
|
def upgrade_status(row, review_status, expression_type):
|
scope = scope_gate(row.get("evidence_text", ""))
|
if scope == "TOC_OR_INDEX_NOISE":
|
return "EXCLUDE_FROM_CORE_EVIDENCE"
|
if scope == "MARKET_NEWS_OR_ADJACENT_REVIEW":
|
return "KEEP_DRAFT_SCOPE_REVIEW"
|
if review_status not in {"TEXT_MATCH_CONTEXT_OK", "TEXT_MATCH_PARTIAL_CONTEXT", "TEXT_MATCH_DIFFERENT_PAGE"}:
|
return "DATA_GAP_REVIEW"
|
if expression_type == "BROKER_VIEW_OR_ORIGINAL_RECOMMENDATION":
|
return "KEEP_DRAFT_BROKER_VIEW"
|
if row.get("fact_type") == "indicator_sentence":
|
return "CANDIDATE_FOR_EVIDENCE_CARD"
|
return "KEEP_DRAFT"
|
|
|
def main():
|
parser = argparse.ArgumentParser()
|
parser.add_argument("--project-root", default=".")
|
parser.add_argument("--fact-files", nargs="+", required=True)
|
parser.add_argument("--review-out", required=True)
|
parser.add_argument("--manifest-out", required=True)
|
parser.add_argument("--summary-out", required=True)
|
parser.add_argument("--per-metal", type=int, default=8)
|
parser.add_argument("--per-theme", type=int, default=12)
|
args = parser.parse_args()
|
|
project_root = Path(args.project_root).resolve()
|
facts = []
|
for fact_file in args.fact_files:
|
path = project_root / fact_file
|
if path.exists():
|
facts.extend(read_csv(path))
|
|
selected = {}
|
for metal in METAL_ORDER:
|
rows = [row for row in facts if metal in split_tags(row.get("metal_tags"))]
|
for row in sorted(rows, key=score_fact, reverse=True)[: args.per_metal]:
|
selected[row.get("fact_id")] = row
|
for theme in THEME_PRIORITY:
|
rows = [row for row in facts if theme in split_tags(row.get("theme_tags"))]
|
for row in sorted(rows, key=score_fact, reverse=True)[: args.per_theme]:
|
selected[row.get("fact_id")] = row
|
|
selected_rows = sorted(selected.values(), key=score_fact, reverse=True)
|
created_at = datetime.now(timezone(timedelta(hours=8))).isoformat(timespec="seconds")
|
review_rows = []
|
for row in selected_rows:
|
review_status, matched_page, context = find_context(row, project_root)
|
expression_type = source_expression_type(row.get("evidence_text", ""))
|
scope = scope_gate(row.get("evidence_text", ""))
|
review_rows.append(
|
{
|
"review_id": f"YS-FACT-REV-001-{len(review_rows) + 1:04d}",
|
"case_id": row.get("case_id", "ANA-YS-INDUSTRY-001"),
|
"batch_id": row.get("batch_id", ""),
|
"sub_batch_id": row.get("sub_batch_id", ""),
|
"run_id": "RUN-ANA-YS-FACT-REVIEW-001",
|
"fact_id": row.get("fact_id", ""),
|
"doc_id": row.get("doc_id", ""),
|
"fact_type": row.get("fact_type", ""),
|
"metal_tags": row.get("metal_tags", ""),
|
"theme_tags": row.get("theme_tags", ""),
|
"evidence_text": row.get("evidence_text", ""),
|
"declared_page_no": row.get("page_no", ""),
|
"matched_page_no": matched_page,
|
"source_location": row.get("source_location", ""),
|
"converted_text_path": row.get("converted_text_path", ""),
|
"raw_file_path": row.get("raw_file_path", ""),
|
"raw_sha256": row.get("raw_sha256", ""),
|
"source_expression_type": expression_type,
|
"scope_gate": scope,
|
"analyst_review_status": review_status,
|
"evidence_upgrade_status": upgrade_status(row, review_status, expression_type),
|
"next_action": next_action(row, review_status, expression_type),
|
"page_context": context[:900],
|
"review_status": "DRAFT_FOR_REVIEW",
|
"created_at": created_at,
|
}
|
)
|
|
review_path = project_root / args.review_out
|
manifest_path = project_root / args.manifest_out
|
summary_path = project_root / args.summary_out
|
fields = [
|
"review_id",
|
"case_id",
|
"batch_id",
|
"sub_batch_id",
|
"run_id",
|
"fact_id",
|
"doc_id",
|
"fact_type",
|
"metal_tags",
|
"theme_tags",
|
"evidence_text",
|
"declared_page_no",
|
"matched_page_no",
|
"source_location",
|
"converted_text_path",
|
"raw_file_path",
|
"raw_sha256",
|
"source_expression_type",
|
"scope_gate",
|
"analyst_review_status",
|
"evidence_upgrade_status",
|
"next_action",
|
"page_context",
|
"review_status",
|
"created_at",
|
]
|
write_csv(review_path, fields, review_rows)
|
|
manifest_rows = [
|
{
|
"case_id": "ANA-YS-INDUSTRY-001",
|
"batch_id": "BATCH-001+BATCH-003",
|
"run_id": "RUN-ANA-YS-FACT-REVIEW-001",
|
"artifact_type": "key_fact_review",
|
"artifact_path": review_path.relative_to(project_root).as_posix(),
|
"row_count": len(review_rows),
|
"sha256": short_hash(review_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,
|
)
|
|
metal_counts = Counter()
|
theme_counts = Counter()
|
review_counts = Counter()
|
upgrade_counts = Counter()
|
action_counts = Counter()
|
scope_counts = Counter()
|
for row in review_rows:
|
for metal in split_tags(row.get("metal_tags")):
|
metal_counts[metal] += 1
|
for theme in split_tags(row.get("theme_tags")):
|
theme_counts[theme] += 1
|
review_counts[row.get("analyst_review_status")] += 1
|
upgrade_counts[row.get("evidence_upgrade_status")] += 1
|
action_counts[row.get("next_action")] += 1
|
scope_counts[row.get("scope_gate")] += 1
|
|
lines = [
|
"# 关键事实复核包 PASS-001 摘要",
|
"",
|
"状态:DRAFT_FOR_REVIEW",
|
f"生成时间:{created_at}",
|
"",
|
"## 输入",
|
"",
|
"- BATCH-001 事实句草表",
|
"- BATCH-003 事实句草表",
|
"- converted text 页码标记和上下文",
|
"",
|
"## 输出",
|
"",
|
f"- 复核表:`{review_path.relative_to(project_root).as_posix()}`",
|
f"- manifest:`{manifest_path.relative_to(project_root).as_posix()}`",
|
f"- 复核记录数:{len(review_rows)}",
|
"",
|
"## 复核状态",
|
"",
|
"| 状态 | 数量 |",
|
"|---|---:|",
|
]
|
for key, count in review_counts.most_common():
|
lines.append(f"| {key} | {count} |")
|
lines.extend(["", "## 证据升级状态", "", "| 状态 | 数量 |", "|---|---:|"])
|
for key, count in upgrade_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(["", "## 范围闸门", "", "| 范围 | 数量 |", "|---|---:|"])
|
for key, count in scope_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(["", "## 主题覆盖", "", "| 主题 | 数量 |", "|---|---:|"])
|
for key, count in theme_counts.most_common():
|
lines.append(f"| {key} | {count} |")
|
lines.extend(
|
[
|
"",
|
"## 边界",
|
"",
|
"本轮只做 converted text 页码上下文核对和证据升级候选标注。`TEXT_MATCH_CONTEXT_OK` 不等于正式证据通过;关键指标仍需段落、表格定位和必要外部交叉验证。",
|
]
|
)
|
summary_path.parent.mkdir(parents=True, exist_ok=True)
|
summary_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
print(f"review_rows={len(review_rows)}")
|
print(f"review={review_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()
|