# -*- coding: utf-8 -*- """ 金融文档分析整理模块 在 document_parser 基础上构建结构化分析层: - 单文档深度分析(分类/元数据/章节/财务指标/风险因素/术语表) - 批量整理目录(扫描+逐份分析+分类聚合+索引+汇总) - 多文档并排对比(同维度提取+差异高亮) 零新外部依赖(复用 PyPDF2/python-docx/openpyxl,正则+启发式分类)。 """ import re import json import os as _os from pathlib import Path from typing import Dict, Any, List, Optional, Tuple from datetime import datetime try: from document_parser import parse_document, extract_financial_numbers except ImportError: def parse_document(file_path, **kw): return {"error": "document_parser not available"} def extract_financial_numbers(text): return {} SKILL_DATA_DIR = Path(__file__).parent.parent / "data" DOC_INDEX_DIR = SKILL_DATA_DIR / "document_index" # ==================== 文档分类 ==================== DOC_CATEGORIES = [ ("券商研报", ["研究报告", "评级推荐", "目标价", "分析师", "投资建议", "证券研究报告"], 10), ("公告", ["公告编号", "证券代码", "临时公告", "重大事项"], 9), ("年报", ["年度报告", "annual report", "年报摘要"], 8), ("半年报", ["半年度报告", "半年报", "中期报告"], 7), ("季报", ["季度报告", "一季报", "三季报"], 6), ("基金合同", ["基金合同", "基金托管协议", "基金管理人"], 5), ("招募说明书", ["招募说明书", "招募更新"], 4), ("财务报告", ["财务报告", "财务报表", "审计报告", "合并报表"], 3), ("通用金融文档", [], 0), ] SECTION_PATTERNS = { "h1": re.compile(r'第[一二三四五六七八九十百零\d]+[章节篇部]\s*[^\n]{0,80}'), "h2": re.compile(r'^[一二三四五六七八九十]+\s*[、..]\s*[^\n]{0,80}', re.MULTILINE), "h3": re.compile(r'^\d+[\..]\s*(?!\d)[^\n]{0,80}', re.MULTILINE), "h4": re.compile(r'^[(\(]\d+[)\)]\s*[^\n]{0,80}', re.MULTILINE), } FINANCIAL_INDICATOR_PATTERNS = { "营业收入": r'营业(?:总)?收入[((]?万元?[))]?\s*[::]?\s*([+-]?\d[\d,.]*)\s*(?:万?元?|亿?)', "净利润": r'(?:归属.*)?净利润[((]?万元?[))]?\s*[::]?\s*([+-]?\d[\d,.]*)\s*(?:万?元?|亿?)', "总资产": r'总资产[((]?万元?[))]?\s*[::]?\s*([+-]?\d[\d,.]*)\s*(?:万?元?|亿?)', "净资产": r'(?:归属.*)?净资产[((]?万元?[))]?\s*[::]?\s*([+-]?\d[\d,.]*)\s*(?:万?元?|亿?)', "每股收益_EPS": r'(?:基本)?每股收益[((]?EPS[))]?\s*[::]?\s*([+-]?\d+[\.]?\d*)', "市盈率_PE": r'市盈率[((]?P[Ee][))]?\s*[::]?\s*([+-]?\d+[\.]?\d*)', "市净率_PB": r'市净率[((]?P[Bb][))]?\s*[::]?\s*([+-]?\d+[\.]?\d*)', "净资产收益率_ROE": r'净资产收益率[((]?ROE[))]?\s*[::]?\s*([+-]?\d+[\.]?\d*)\s*%?', "毛利率": r'毛利率\s*[::]?\s*([+-]?\d+[\.]?\d*)\s*%', "资产负债率": r'资产负债率\s*[::]?\s*([+-]?\d+[\.]?\d*)\s*%', "同比增速": r'(?:营业[总收]入.*)?同比[增减增速]*[::]?\s*([+-]?\d+[\.]?\d*)\s*%', } # ==================== 辅助函数 ==================== def classify_document(text: str, file_path: str = "") -> Tuple[str, int]: """启发式文档分类。返回 (类别, 置信度 0-100)。""" text_head = (text or "")[:3000].lower() file_lower = Path(file_path).name.lower() if file_path else "" best_category = "通用金融文档" best_score = 0 max_possible = 60 for cat_name, keywords, priority in DOC_CATEGORIES: score = 0 for kw in keywords: kw_lower = kw.lower() if re.search(kw_lower, text_head): score += 15 if file_lower and re.search(kw_lower, file_lower): score += 20 if score > best_score: best_score = score best_category = cat_name confidence = min(100, int(best_score / max_possible * 100)) if max_possible > 0 else 10 return best_category, max(confidence, 10) def extract_metadata(text: str, file_path: str = "") -> Dict[str, str]: """从文本提取元数据:标题、机构、日期、股票代码。""" meta = {"title": "", "institution": "", "date": "", "stock_code": ""} text_head = (text or "")[:2000] lines = [l.strip() for l in text_head.split("\n") if l.strip()] if lines: meta["title"] = lines[0][:120] title_match = re.search(r'(?:标题|题目|报告名称)[::]\s*(.+?)(?:\n|$)', text_head) if title_match: meta["title"] = title_match.group(1).strip()[:120] inst_patterns = [ r'([\u4e00-\u9fff]{2,8}(?:证券|基金|期货|信托|银行|保险|资产管理|资本|投资|研究))(?:(?:股份)?有限(?:公司|责任公司))?', r'([\u4e00-\u9fff]{2,12}(?:股份)?有限(?:公司|责任公司))', r'(?:发布机构|研究机构|出处|来源|作者单位)[::]\s*([^\n]{2,40})', ] for pat in inst_patterns: m = re.search(pat, text_head) if m: inst = m.group(1).strip() if len(inst) >= 4: meta["institution"] = inst break date_patterns = [ r'(\d{4})\s*[年\-\/.]\s*(\d{1,2})\s*[月\-\/.]\s*(\d{1,2})\s*日?', r'(\d{4})-(\d{2})-(\d{2})', r'(?:报告日期|发布日期|公告日期|日期)[::]\s*(\d{4}[\-\/.]\d{1,2}[\-\/.]\d{1,2})', ] for pat in date_patterns: m = re.search(pat, text_head) if m: if len(m.groups()) >= 3: y, mo, d = m.group(1), m.group(2), m.group(3) try: meta["date"] = f"{int(y)}-{int(mo):02d}-{int(d):02d}" except ValueError: pass elif len(m.groups()) == 1: meta["date"] = m.group(1) break code_match = re.search(r'(?:股票代码|证券代码|标的代码|code)[::]\s*(\d{6})', text_head) if code_match: meta["stock_code"] = code_match.group(1) else: code_match2 = re.search(r'[\((](\d{6})[\._\s]?(?:SH|SZ|sh|sz|上证|深证)?[\))]', text_head) if code_match2: meta["stock_code"] = code_match2.group(1) return meta def extract_sections(text: str) -> List[Dict[str, Any]]: """提取文档章节结构。""" sections = [] seen_titles = set() lines = text.split("\n") for line_no, line in enumerate(lines): line = line.strip() if not line or len(line) < 3: continue if len(line) > 150: continue level = None title = None for lvl_name, pattern in SECTION_PATTERNS.items(): m = pattern.match(line) if m: title = m.group(0).strip() if title.replace(" ", "").replace(".", "").replace(".", "").isdigit(): continue level = int(lvl_name[1]) break if title and level: norm = re.sub(r'\s+', '', title) if norm in seen_titles: continue seen_titles.add(norm) preview_start = line_no + 1 preview_parts = [] for l in lines[preview_start:preview_start + 5]: if l.strip(): preview_parts.append(l.strip()) preview = " ".join(preview_parts)[:150] sections.append({ "title": title, "level": level, "preview": preview, "line": line_no + 1, }) return sections[:60] def extract_risk_factors(text: str) -> List[str]: """提取风险因素/风险提示章节条目。""" risks = [] risk_patterns = [ r'(?:风险因素|风险提示|风险分析|风险管理|风险警示)(?:.{0,500}?)', ] for pat in risk_patterns: for m in re.finditer(pat, text): start = m.start() chunk = text[start:start + 3000] bullet_patterns = [ r'[•·●◆■]\s*([^\n•·●◆■]{5,200})', r'(?:^|\n)\s*\d+[\.、.)]\s*([^\n]{5,200})', r'(?:^|\n)\s*[(\(]\d+[)\)]\s*([^\n]{5,200})', ] for bpat in bullet_patterns: for bm in re.finditer(bpat, chunk): item = bm.group(1).strip() if len(item) > 10 and item not in risks: risks.append(item[:200]) if risks: break if risks: break if risks: break return risks[:20] def extract_glossary(text: str) -> Dict[str, str]: """提取术语定义:匹配 "XX 指/系/是指/谓/意为" 模式。""" glossary = {} pattern = re.compile( r'([\u4e00-\u9fffA-Za-z]{2,15})\s*(?:指|系|是指|谓|意为|即|又称)\s*(.{5,120}?)(?:[。;;]|$|\n)' ) for m in re.finditer(pattern, text[:5000]): term = m.group(1).strip() definition = m.group(2).strip() if term and definition and term not in glossary: glossary[term] = definition[:120] return dict(list(glossary.items())[:30]) def extract_financial_indicators(text: str) -> Dict[str, Any]: """扩展的财务指标提取(比 extract_financial_numbers 更全面)。""" indicators = {} for key, pattern in FINANCIAL_INDICATOR_PATTERNS.items(): m = re.search(pattern, text) if m: try: val = m.group(1).replace(",", "") indicators[key] = float(val) if '.' in val else int(val) except (ValueError, AttributeError): pass return indicators # ==================== DocumentAnalyzer 类 ==================== class DocumentAnalyzer: """金融文档分析整理器。""" def __init__(self): self._cache = {} def analyze(self, file_path: str) -> Dict[str, Any]: """ 单文档深度分析。 返回:{file_path, file_type, doc_category, category_confidence, metadata, summary, sections, financial_indicators, risk_factors, glossary, stats} """ cache_key = _os.path.abspath(file_path) if cache_key in self._cache: return self._cache[cache_key] result = { "file_path": file_path, "file_type": Path(file_path).suffix.lower().lstrip("."), "doc_category": "通用金融文档", "category_confidence": 0, "metadata": {}, "summary": "", "sections": [], "financial_indicators": {}, "risk_factors": [], "glossary": {}, "stats": {}, } parsed = parse_document(file_path) if parsed.get("error"): result["error"] = parsed["error"] return result text_content = parsed.get("text_content", "") or "" if not text_content.strip(): result["error"] = "文档内容为空或无法提取文本" return result category, confidence = classify_document(text_content, file_path) result["doc_category"] = category result["category_confidence"] = confidence result["metadata"] = extract_metadata(text_content, file_path) result["sections"] = extract_sections(text_content) result["financial_indicators"] = extract_financial_indicators(text_content) # 合并 document_parser 的简单数字提取 simple_nums = extract_financial_numbers(text_content) for k, v in simple_nums.items(): if k not in result["financial_indicators"] and k not in ("amounts_yi", "total_yi", "percentages"): result["financial_indicators"][k] = v result["risk_factors"] = extract_risk_factors(text_content) result["glossary"] = extract_glossary(text_content) result["summary"] = text_content[:600].replace("\n", " ").strip() result["stats"] = { "char_count": len(text_content), "section_count": len(result["sections"]), "indicator_count": len(result["financial_indicators"]), "risk_count": len(result["risk_factors"]), "analyzed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), } self._cache[cache_key] = result return result def organize_directory(self, dir_path: str, recursive: bool = True) -> Dict[str, Any]: """ 批量整理文档目录。 返回:{total, categories, index_file, summary_file} """ dir_p = Path(dir_path) if not dir_p.is_dir(): return {"error": f"目录不存在: {dir_path}"} supported_ext = {".pdf", ".docx", ".doc", ".xlsx", ".xls", ".txt", ".md", ".csv"} files = [] if recursive: for f in dir_p.rglob("*"): if f.is_file() and f.suffix.lower() in supported_ext: files.append(f) else: for f in dir_p.iterdir(): if f.is_file() and f.suffix.lower() in supported_ext: files.append(f) if not files: return {"error": "目录中没有支持的文档文件", "total": 0, "categories": {}} categories: Dict[str, List[Dict]] = {} for fp in files: try: analysis = self.analyze(str(fp)) cat = analysis.get("doc_category", "通用金融文档") if cat not in categories: categories[cat] = [] categories[cat].append({ "file": fp.name, "path": str(fp), "type": analysis.get("file_type", ""), "metadata": analysis.get("metadata", {}), "confidence": analysis.get("category_confidence", 0), "indicator_count": len(analysis.get("financial_indicators", {})), }) except Exception as e: if "处理错误" not in categories: categories["处理错误"] = [] categories["处理错误"].append({"file": fp.name, "error": str(e)}) total = sum(len(v) for v in categories.values()) DOC_INDEX_DIR.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") index_file = DOC_INDEX_DIR / f"index_{timestamp}.json" summary_file = DOC_INDEX_DIR / f"summary_{timestamp}.md" index_data = { "source_dir": str(dir_p), "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "total_documents": total, "category_count": len(categories), "categories": {}, } for cat, items in categories.items(): index_data["categories"][cat] = {"count": len(items), "files": items} with open(index_file, "w", encoding="utf-8") as f: json.dump(index_data, f, ensure_ascii=False, indent=2) md_lines = [ "# 文档目录整理报告\n", f"- 来源目录: {dir_p}", f"- 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", f"- 文档总数: {total}", f"- 类别数: {len(categories)}\n", ] for cat, items in sorted(categories.items(), key=lambda x: -len(x[1])): md_lines.append(f"## {cat} ({len(items)}份)") for item in items[:50]: meta = item.get("metadata", {}) title = meta.get("title", item["file"])[:80] date_str = meta.get("date", "") inst = meta.get("institution", "") code = meta.get("stock_code", "") extras = " / ".join(filter(None, [date_str, inst, code])) line = f"- **{title}** ({item['type']})" if extras: line += f" — {extras}" md_lines.append(line) md_lines.append("") with open(summary_file, "w", encoding="utf-8") as f: f.write("\n".join(md_lines)) return { "total": total, "categories": {cat: len(items) for cat, items in categories.items()}, "index_file": str(index_file), "summary_file": str(summary_file), } def compare(self, file_paths: List[str]) -> Dict[str, Any]: """多文档并排对比。""" results = [] for fp in file_paths: r = self.analyze(fp) results.append(r) if not results: return {"compared": 0, "files": [], "dimensions": {}, "differences": []} dimensions = { "文档类型": [], "文件类型": [], "日期": [], "机构": [], "股票代码": [], "文档规模(字符数)": [], "章节数": [], "财务指标数": [], "风险因素数": [], "关键指标": [], } for r in results: dimensions["文档类型"].append(r.get("doc_category", "")) dimensions["文件类型"].append(r.get("file_type", "")) dimensions["日期"].append(r.get("metadata", {}).get("date", "")) dimensions["机构"].append(r.get("metadata", {}).get("institution", "")) dimensions["股票代码"].append(r.get("metadata", {}).get("stock_code", "")) dimensions["文档规模(字符数)"].append(r.get("stats", {}).get("char_count", 0)) dimensions["章节数"].append(r.get("stats", {}).get("section_count", 0)) dimensions["财务指标数"].append(r.get("stats", {}).get("indicator_count", 0)) dimensions["风险因素数"].append(r.get("stats", {}).get("risk_count", 0)) dimensions["关键指标"].append(r.get("financial_indicators", {})) differences = [] for dim, values in dimensions.items(): if dim == "关键指标": continue unique_vals = set(str(v) for v in values) if len(unique_vals) > 1: file_names = [Path(fp).name for fp in file_paths] labeled = [f"{file_names[i]}: {values[i]}" for i in range(len(values))] differences.append(f"[{dim}] {', '.join(labeled)}") return { "compared": len(results), "files": [ {"name": Path(fp).name, "path": fp, "category": r.get("doc_category", ""), "summary": r.get("summary", "")[:200]} for fp, r in zip(file_paths, results) ], "dimensions": dimensions, "differences": differences, } def to_markdown(self, analysis: Dict[str, Any]) -> str: """将 analyze() 结果转换为结构化 Markdown 报告。""" if analysis.get("error"): return f"## 文档分析失败\n\n错误: {analysis['error']}" lines = [ "# 文档分析报告\n", f"- **文件**: {Path(analysis['file_path']).name}", f"- **格式**: {analysis.get('file_type', '').upper()}", f"- **分类**: {analysis.get('doc_category', '未知')} (置信度: {analysis.get('category_confidence', 0)}%)", f"- **分析时间**: {analysis.get('stats', {}).get('analyzed_at', '')}\n", ] meta = analysis.get("metadata", {}) if any(meta.values()): lines.append("## 元数据\n") if meta.get("title"): lines.append(f"- **标题**: {meta['title']}") if meta.get("institution"): lines.append(f"- **机构**: {meta['institution']}") if meta.get("date"): lines.append(f"- **日期**: {meta['date']}") if meta.get("stock_code"): lines.append(f"- **股票代码**: {meta['stock_code']}") lines.append("") lines.append("## 内容摘要\n") lines.append(f"> {analysis.get('summary', '无法提取摘要')[:500]}\n") sections = analysis.get("sections", []) if sections: lines.append(f"## 章节结构 ({len(sections)} 个章节)\n") for sec in sections[:30]: indent = " " * (sec.get("level", 1) - 1) preview = sec.get('preview', '') lines.append(f"{indent}- **{sec['title']}** _{preview[:80].replace(chr(10), ' ')}_\n") lines.append("") indicators = analysis.get("financial_indicators", {}) if indicators: lines.append(f"## 财务指标 ({len(indicators)} 项)\n") for key, value in indicators.items(): if key not in ("amounts_yi", "total_yi", "percentages"): lines.append(f"- **{key}**: {value}") lines.append("") risks = analysis.get("risk_factors", []) if risks: lines.append(f"## 风险因素 ({len(risks)} 条)\n") for i, risk in enumerate(risks, 1): lines.append(f"{i}. {risk}") lines.append("") glossary = analysis.get("glossary", {}) if glossary: lines.append(f"## 关键术语 ({len(glossary)} 条)\n") for term, defn in list(glossary.items())[:15]: lines.append(f"- **{term}**: {defn}") stats = analysis.get("stats", {}) lines.append(f"\n---\n*文档字符数: {stats.get('char_count', 0):,} | " f"章节: {stats.get('section_count', 0)} | " f"指标: {stats.get('indicator_count', 0)} | " f"风险: {stats.get('risk_count', 0)}*") return "\n".join(lines) # ==================== CLI 入口 ==================== if __name__ == "__main__": import sys if len(sys.argv) < 3: print("用法: python document_analyzer.py <命令> <参数>") print(" analyze <文件路径> -- 深度分析单份文档") print(" organize <目录路径> -- 批量整理文档目录") print(" compare <文件1,文件2,...> -- 对比多份文档") print(" markdown <文件路径> -- 深度分析并以 Markdown 输出") sys.exit(1) cmd = sys.argv[1] target = sys.argv[2] analyzer = DocumentAnalyzer() if cmd == "analyze": result = analyzer.analyze(target) print(json.dumps(result, ensure_ascii=False, indent=2)) elif cmd == "organize": result = analyzer.organize_directory(target) print(json.dumps(result, ensure_ascii=False, indent=2)) elif cmd == "compare": paths = [p.strip() for p in target.split(",") if p.strip()] result = analyzer.compare(paths) print(json.dumps(result, ensure_ascii=False, indent=2)) elif cmd == "markdown": result = analyzer.analyze(target) print(analyzer.to_markdown(result)) else: print(f"未知命令: {cmd}") sys.exit(1)