Initial: integrated 2025 LawGraph (graphrag_pipeline) + 2026 kg_project
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Word文档读取器 - 读取城市更新法规数据库中所有.docx文件
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any
|
||||
|
||||
try:
|
||||
from docx import Document
|
||||
except ImportError:
|
||||
print("错误: 请先安装 python-docx")
|
||||
print("运行: pip install python-docx")
|
||||
raise
|
||||
|
||||
|
||||
class DocxReader:
|
||||
"""Word文档读取器"""
|
||||
|
||||
# 目录类别映射
|
||||
CATEGORY_MAP = {
|
||||
'0知识图谱目录': {'id': 0, 'type': 'Index', 'name': '知识图谱目录'},
|
||||
'1法律': {'id': 1, 'type': 'Law', 'name': '法律'},
|
||||
'2行政法规': {'id': 2, 'type': 'AdministrativeRegulation', 'name': '行政法规'},
|
||||
'3部门规章': {'id': 3, 'type': 'DepartmentalRule', 'name': '部门规章'},
|
||||
'4党中央国务院文件': {'id': 4, 'type': 'PolicyDocument', 'name': '党中央国务院文件'},
|
||||
'5国家主管部门文件': {'id': 5, 'type': 'PolicyDocument', 'name': '国家主管部门文件'},
|
||||
'6主要技术标准': {'id': 6, 'type': 'PolicyDocument', 'name': '主要技术标准'},
|
||||
}
|
||||
|
||||
def __init__(self, source_dir: str):
|
||||
self.source_dir = Path(source_dir)
|
||||
self.logger = self._setup_logger()
|
||||
self.documents = []
|
||||
|
||||
def _setup_logger(self):
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
def _extract_text_from_docx(self, filepath: Path) -> str:
|
||||
"""从Word文档提取纯文本"""
|
||||
doc = Document(str(filepath))
|
||||
paragraphs = []
|
||||
for para in doc.paragraphs:
|
||||
text = para.text.strip()
|
||||
if text:
|
||||
paragraphs.append(text)
|
||||
|
||||
# 也提取表格中的文本
|
||||
for table in doc.tables:
|
||||
for row in table.rows:
|
||||
row_text = []
|
||||
for cell in row.cells:
|
||||
cell_text = cell.text.strip()
|
||||
if cell_text:
|
||||
row_text.append(cell_text)
|
||||
if row_text:
|
||||
paragraphs.append(' | '.join(row_text))
|
||||
|
||||
return '\n'.join(paragraphs)
|
||||
|
||||
def _parse_filename_info(self, filename: str) -> Dict[str, Any]:
|
||||
"""从文件名解析基本信息"""
|
||||
info = {
|
||||
'original_filename': filename,
|
||||
'document_number': None,
|
||||
'document_name': None,
|
||||
'is_draft': False,
|
||||
}
|
||||
|
||||
# 去掉扩展名
|
||||
name_without_ext = Path(filename).stem
|
||||
|
||||
# 检测是否为草案/征求意见稿
|
||||
if '草案' in name_without_ext or '征求意见稿' in name_without_ext:
|
||||
info['is_draft'] = True
|
||||
|
||||
# 尝试提取编号前缀 (如 "3-中华人民共和国城乡规划法")
|
||||
match = re.match(r'^(\d+)[-—]\s*(.+)$', name_without_ext)
|
||||
if match:
|
||||
info['file_number'] = int(match.group(1))
|
||||
name_part = match.group(2)
|
||||
else:
|
||||
name_part = name_without_ext
|
||||
|
||||
# 提取书名号中的名称
|
||||
title_match = re.search(r'《(.+?)》', name_part)
|
||||
if title_match:
|
||||
info['document_name'] = title_match.group(1)
|
||||
else:
|
||||
# 去掉常见前缀
|
||||
cleaned = re.sub(r'^(中华人民共和国|国务院|国土资源部|建设部|住房城乡建设部|自然资源部)\s*', '', name_part)
|
||||
info['document_name'] = cleaned if cleaned else name_part
|
||||
|
||||
return info
|
||||
|
||||
def read_all_documents(self) -> List[Dict[str, Any]]:
|
||||
"""读取所有Word文档"""
|
||||
self.logger.info(f"开始读取文档目录: {self.source_dir}")
|
||||
self.documents = []
|
||||
errors = []
|
||||
|
||||
for subdir_name, category_info in self.CATEGORY_MAP.items():
|
||||
subdir = self.source_dir / subdir_name
|
||||
if not subdir.exists():
|
||||
self.logger.warning(f"子目录不存在: {subdir}")
|
||||
continue
|
||||
|
||||
docx_files = sorted(subdir.glob('*.docx'))
|
||||
self.logger.info(f" {subdir_name}: 发现 {len(docx_files)} 个文件")
|
||||
|
||||
for docx_file in docx_files:
|
||||
try:
|
||||
text = self._extract_text_from_docx(docx_file)
|
||||
filename_info = self._parse_filename_info(docx_file.name)
|
||||
|
||||
doc_record = {
|
||||
'doc_id': f"{category_info['type'][:3].upper()}-{len(self.documents):04d}",
|
||||
'filename': docx_file.name,
|
||||
'category_id': category_info['id'],
|
||||
'category_name': category_info['name'],
|
||||
'document_type': category_info['type'],
|
||||
'subdirectory': subdir_name,
|
||||
'raw_text': text,
|
||||
'text_length': len(text),
|
||||
'conversion_timestamp': datetime.now().isoformat(),
|
||||
**filename_info,
|
||||
}
|
||||
|
||||
self.documents.append(doc_record)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"读取失败: {docx_file.name} - {str(e)}"
|
||||
self.logger.error(error_msg)
|
||||
errors.append({
|
||||
'filename': docx_file.name,
|
||||
'subdirectory': subdir_name,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
self.logger.info(f"读取完成: {len(self.documents)} 个文档, {len(errors)} 个错误")
|
||||
return self.documents
|
||||
|
||||
def save_to_json(self, output_path: str):
|
||||
"""保存到JSON文件"""
|
||||
output = Path(output_path)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
result = {
|
||||
'metadata': {
|
||||
'total_documents': len(self.documents),
|
||||
'source_dir': str(self.source_dir),
|
||||
'created_at': datetime.now().isoformat(),
|
||||
'category_distribution': {},
|
||||
},
|
||||
'documents': self.documents,
|
||||
}
|
||||
|
||||
# 统计分类分布
|
||||
for doc in self.documents:
|
||||
cat = doc['category_name']
|
||||
result['metadata']['category_distribution'][cat] = \
|
||||
result['metadata']['category_distribution'].get(cat, 0) + 1
|
||||
|
||||
with open(output, 'w', encoding='utf-8') as f:
|
||||
json.dump(result, f, indent=2, ensure_ascii=False)
|
||||
|
||||
self.logger.info(f"已保存到: {output}")
|
||||
return result
|
||||
|
||||
def print_summary(self):
|
||||
"""打印摘要"""
|
||||
print("\n" + "=" * 60)
|
||||
print("文档读取摘要")
|
||||
print("=" * 60)
|
||||
print(f"总文档数: {len(self.documents)}")
|
||||
|
||||
# 按类别统计
|
||||
category_counts = {}
|
||||
total_chars = 0
|
||||
for doc in self.documents:
|
||||
cat = doc['category_name']
|
||||
category_counts[cat] = category_counts.get(cat, 0) + 1
|
||||
total_chars += doc['text_length']
|
||||
|
||||
print("\n类别分布:")
|
||||
for cat, count in sorted(category_counts.items()):
|
||||
print(f" {cat}: {count} 个")
|
||||
|
||||
print(f"\n总字符数: {total_chars:,}")
|
||||
print(f"平均字符数: {total_chars // max(len(self.documents), 1):,}")
|
||||
|
||||
# 文本长度分布
|
||||
lengths = [doc['text_length'] for doc in self.documents]
|
||||
if lengths:
|
||||
print(f"\n文本长度:")
|
||||
print(f" 最短: {min(lengths):,} 字符")
|
||||
print(f" 最长: {max(lengths):,} 字符")
|
||||
print(f" 中位数: {sorted(lengths)[len(lengths)//2]:,} 字符")
|
||||
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def main():
|
||||
source_dir = r"E:\Project\SI\2026_KG_PlanningLaw\data\城市规划法律法规\城市更新法规数据库"
|
||||
output_path = r"E:\Project\SI\2026_KG_PlanningLaw\dofile\kg_project\output\all_documents.json"
|
||||
|
||||
reader = DocxReader(source_dir)
|
||||
reader.read_all_documents()
|
||||
reader.save_to_json(output_path)
|
||||
reader.print_summary()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
从结构化法规数据生成知识图谱CSV文件(nodes.csv + rels.csv)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import pandas as pd
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any, Tuple
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 节点类型到ID前缀的映射
|
||||
TYPE_PREFIX = {
|
||||
'Law': 'LAW',
|
||||
'AdministrativeRegulation': 'REG',
|
||||
'DepartmentalRule': 'RULE',
|
||||
'PolicyDocument': 'DOC',
|
||||
'Index': 'DOC',
|
||||
'Chapter': 'CH',
|
||||
'Section': 'SEC',
|
||||
'Article': 'ART',
|
||||
'GovernmentBody': 'GOV',
|
||||
}
|
||||
|
||||
|
||||
def generate_id(prefix: str, text: str) -> str:
|
||||
"""根据文本哈希生成ID"""
|
||||
return f"{prefix}-{abs(hash(text)) % 100000:05d}"
|
||||
|
||||
|
||||
def build_document_nodes(doc: Dict[str, Any], doc_index: int) -> Tuple[List[Dict], List[Dict]]:
|
||||
"""从单个文档构建节点和关系"""
|
||||
nodes = []
|
||||
rels = []
|
||||
rel_id = 0
|
||||
|
||||
# 文档类型映射
|
||||
doc_type = doc['document_type']
|
||||
if doc_type == 'Index':
|
||||
doc_type = 'PolicyDocument'
|
||||
|
||||
# 1. 创建文档节点
|
||||
doc_id = generate_id(TYPE_PREFIX.get(doc_type, 'DOC'), doc['title'])
|
||||
|
||||
doc_node = {
|
||||
'id': doc_id,
|
||||
'label': doc['title'],
|
||||
'type': doc_type,
|
||||
'properties': json.dumps({
|
||||
'full_title': doc.get('document_name', ''),
|
||||
'promulgation_date': doc.get('promulgation_date'),
|
||||
'effective_date': doc.get('effective_date'),
|
||||
'document_number': doc.get('document_number'),
|
||||
'is_draft': doc.get('is_draft', False),
|
||||
'category': doc.get('category_name', ''),
|
||||
'filename': doc.get('filename', ''),
|
||||
'text_length': doc.get('text_length', 0),
|
||||
'article_count': doc.get('article_count', 0),
|
||||
}, ensure_ascii=False)
|
||||
}
|
||||
nodes.append(doc_node)
|
||||
|
||||
# 2. 创建发布机关节点和关系
|
||||
if doc.get('issuing_body'):
|
||||
body_name = doc['issuing_body']
|
||||
body_id = generate_id('GOV', body_name)
|
||||
|
||||
# 检查是否已存在
|
||||
body_node = {
|
||||
'id': body_id,
|
||||
'label': body_name,
|
||||
'type': 'GovernmentBody',
|
||||
'properties': json.dumps({}, ensure_ascii=False)
|
||||
}
|
||||
nodes.append(body_node)
|
||||
|
||||
rel_id += 1
|
||||
rels.append({
|
||||
'source': doc_id,
|
||||
'target': body_id,
|
||||
'type': 'ISSUED_BY',
|
||||
'properties': json.dumps({}, ensure_ascii=False)
|
||||
})
|
||||
|
||||
# 3. 创建章节条文节点和层次关系
|
||||
for ch_idx, chapter in enumerate(doc.get('chapters', [])):
|
||||
ch_title = chapter.get('title', f'第{ch_idx+1}章')
|
||||
ch_id = generate_id('CH', f"{doc_id}_{ch_title}")
|
||||
|
||||
ch_node = {
|
||||
'id': ch_id,
|
||||
'label': ch_title,
|
||||
'type': 'Chapter',
|
||||
'properties': json.dumps({
|
||||
'name': chapter.get('name', ''),
|
||||
'number': str(ch_idx + 1),
|
||||
}, ensure_ascii=False)
|
||||
}
|
||||
nodes.append(ch_node)
|
||||
|
||||
# 文档→章
|
||||
rel_id += 1
|
||||
rels.append({
|
||||
'source': doc_id,
|
||||
'target': ch_id,
|
||||
'type': 'HAS_CHAPTER',
|
||||
'properties': json.dumps({'order_number': ch_idx + 1}, ensure_ascii=False)
|
||||
})
|
||||
|
||||
# 章下的条文
|
||||
for art_idx, article in enumerate(chapter.get('articles', [])):
|
||||
art_number = article.get('number', str(art_idx + 1))
|
||||
art_text = article.get('text', '')
|
||||
art_id = generate_id('ART', f"{doc_id}_{art_number}")
|
||||
|
||||
art_node = {
|
||||
'id': art_id,
|
||||
'label': f"第{art_number}条",
|
||||
'type': 'Article',
|
||||
'properties': json.dumps({
|
||||
'number': art_number,
|
||||
'text': art_text[:500],
|
||||
'article_type': article.get('article_type', ''),
|
||||
}, ensure_ascii=False)
|
||||
}
|
||||
nodes.append(art_node)
|
||||
|
||||
# 章→条
|
||||
rel_id += 1
|
||||
rels.append({
|
||||
'source': ch_id,
|
||||
'target': art_id,
|
||||
'type': 'CONTAINS_ARTICLE',
|
||||
'properties': json.dumps({'order_number': art_idx + 1}, ensure_ascii=False)
|
||||
})
|
||||
|
||||
# 条→文档
|
||||
rel_id += 1
|
||||
rels.append({
|
||||
'source': art_id,
|
||||
'target': doc_id,
|
||||
'type': 'ARTICLE_IN_DOCUMENT',
|
||||
'properties': json.dumps({}, ensure_ascii=False)
|
||||
})
|
||||
|
||||
# 节
|
||||
for sec_idx, section in enumerate(chapter.get('sections', [])):
|
||||
sec_title = section.get('title', f'第{sec_idx+1}节')
|
||||
sec_id = generate_id('SEC', f"{doc_id}_{ch_title}_{sec_title}")
|
||||
|
||||
sec_node = {
|
||||
'id': sec_id,
|
||||
'label': sec_title,
|
||||
'type': 'Section',
|
||||
'properties': json.dumps({
|
||||
'name': section.get('name', ''),
|
||||
'number': str(sec_idx + 1),
|
||||
}, ensure_ascii=False)
|
||||
}
|
||||
nodes.append(sec_node)
|
||||
|
||||
# 章→节
|
||||
rel_id += 1
|
||||
rels.append({
|
||||
'source': ch_id,
|
||||
'target': sec_id,
|
||||
'type': 'HAS_SECTION',
|
||||
'properties': json.dumps({'order_number': sec_idx + 1}, ensure_ascii=False)
|
||||
})
|
||||
|
||||
# 节下的条文
|
||||
for art_idx, article in enumerate(section.get('articles', [])):
|
||||
art_number = article.get('number', str(art_idx + 1))
|
||||
art_text = article.get('text', '')
|
||||
art_id = generate_id('ART', f"{doc_id}_{art_number}")
|
||||
|
||||
art_node = {
|
||||
'id': art_id,
|
||||
'label': f"第{art_number}条",
|
||||
'type': 'Article',
|
||||
'properties': json.dumps({
|
||||
'number': art_number,
|
||||
'text': art_text[:500],
|
||||
'article_type': article.get('article_type', ''),
|
||||
}, ensure_ascii=False)
|
||||
}
|
||||
nodes.append(art_node)
|
||||
|
||||
# 节→条
|
||||
rel_id += 1
|
||||
rels.append({
|
||||
'source': sec_id,
|
||||
'target': art_id,
|
||||
'type': 'CONTAINS_ARTICLE',
|
||||
'properties': json.dumps({'order_number': art_idx + 1}, ensure_ascii=False)
|
||||
})
|
||||
|
||||
# 条→文档
|
||||
rel_id += 1
|
||||
rels.append({
|
||||
'source': art_id,
|
||||
'target': doc_id,
|
||||
'type': 'ARTICLE_IN_DOCUMENT',
|
||||
'properties': json.dumps({}, ensure_ascii=False)
|
||||
})
|
||||
|
||||
return nodes, rels
|
||||
|
||||
|
||||
def deduplicate_nodes(nodes: List[Dict]) -> List[Dict]:
|
||||
"""去重节点(基于id)"""
|
||||
seen = {}
|
||||
for node in nodes:
|
||||
if node['id'] not in seen:
|
||||
seen[node['id']] = node
|
||||
else:
|
||||
# 合并properties
|
||||
existing = seen[node['id']]
|
||||
if existing['properties'] == '{}' and node['properties'] != '{}':
|
||||
existing['properties'] = node['properties']
|
||||
return list(seen.values())
|
||||
|
||||
|
||||
def generate_report(nodes_df: pd.DataFrame, rels_df: pd.DataFrame, output_dir: Path):
|
||||
"""生成统计报告"""
|
||||
lines = ["# 结构化CSV提取报告\n"]
|
||||
lines.append(f"生成时间: {pd.Timestamp.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
|
||||
|
||||
# 节点统计
|
||||
lines.append("## 节点统计\n\n")
|
||||
lines.append(f"**节点总数**: {len(nodes_df)}\n\n")
|
||||
|
||||
node_type_counts = nodes_df['type'].value_counts().sort_index()
|
||||
lines.append("| 节点类型 | 数量 | 占比 |\n")
|
||||
lines.append("|---------|------|------|\n")
|
||||
for nt, count in node_type_counts.items():
|
||||
lines.append(f"| {nt} | {count} | {count/len(nodes_df)*100:.1f}% |\n")
|
||||
|
||||
# 关系统计
|
||||
lines.append("\n## 关系统计\n\n")
|
||||
lines.append(f"**关系总数**: {len(rels_df)}\n\n")
|
||||
|
||||
rel_type_counts = rels_df['type'].value_counts().sort_index()
|
||||
lines.append("| 关系类型 | 数量 | 占比 |\n")
|
||||
lines.append("|---------|------|------|\n")
|
||||
for rt, count in rel_type_counts.items():
|
||||
lines.append(f"| {rt} | {count} | {count/len(rels_df)*100:.1f}% |\n")
|
||||
|
||||
report_path = output_dir / 'extraction_report_structured.md'
|
||||
with open(report_path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
print(f"\n报告已保存: {report_path}")
|
||||
|
||||
|
||||
def main():
|
||||
input_path = Path(r"E:\Project\SI\2026_KG_PlanningLaw\dofile\kg_project\output\structured_documents.json")
|
||||
output_dir = Path(r"E:\Project\SI\2026_KG_PlanningLaw\dofile\kg_project\output")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info(f"读取结构化文档: {input_path}")
|
||||
with open(input_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
documents = data['documents']
|
||||
logger.info(f"共 {len(documents)} 个文档")
|
||||
|
||||
all_nodes = []
|
||||
all_rels = []
|
||||
|
||||
for i, doc in enumerate(documents):
|
||||
nodes, rels = build_document_nodes(doc, i)
|
||||
all_nodes.extend(nodes)
|
||||
all_rels.extend(rels)
|
||||
|
||||
if (i + 1) % 50 == 0:
|
||||
logger.info(f" 已处理 {i + 1}/{len(documents)}")
|
||||
|
||||
# 去重
|
||||
all_nodes = deduplicate_nodes(all_nodes)
|
||||
|
||||
# 保存
|
||||
nodes_df = pd.DataFrame(all_nodes)
|
||||
rels_df = pd.DataFrame(all_rels)
|
||||
|
||||
nodes_path = output_dir / 'nodes_structured.csv'
|
||||
rels_path = output_dir / 'rels_structured.csv'
|
||||
|
||||
nodes_df.to_csv(nodes_path, index=False, encoding='utf-8-sig')
|
||||
rels_df.to_csv(rels_path, index=False, encoding='utf-8-sig')
|
||||
|
||||
logger.info(f"节点已保存: {nodes_path} ({len(nodes_df)} 行)")
|
||||
logger.info(f"关系已保存: {rels_path} ({len(rels_df)} 行)")
|
||||
|
||||
# 统计
|
||||
print(f"\n{'='*60}")
|
||||
print("结构化CSV提取完成")
|
||||
print(f"{'='*60}")
|
||||
print(f"节点总数: {len(nodes_df)}")
|
||||
for nt, count in nodes_df['type'].value_counts().sort_index().items():
|
||||
print(f" - {nt}: {count}")
|
||||
print(f"关系总数: {len(rels_df)}")
|
||||
for rt, count in rels_df['type'].value_counts().sort_index().items():
|
||||
print(f" - {rt}: {count}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
generate_report(nodes_df, rels_df, output_dir)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
法规元数据解析器 - 从文档文本中解析章节结构、发布机关、日期等
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
|
||||
class LegalMetadataExtractor:
|
||||
"""法规元数据解析器"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = self._setup_logger()
|
||||
|
||||
def _setup_logger(self):
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
def _extract_document_title(self, text: str, filename: str) -> str:
|
||||
"""提取文档标题"""
|
||||
# 尝试从书名号中提取
|
||||
match = re.search(r'《(.+?)》', text[:2000])
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# 尝试从前几行中提取
|
||||
lines = text[:500].split('\n')
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# 过滤掉编号前缀
|
||||
cleaned = re.sub(r'^\d+[-—]\s*', '', line)
|
||||
if len(cleaned) > 4 and len(cleaned) < 100:
|
||||
return cleaned
|
||||
|
||||
# 从文件名提取
|
||||
name = Path(filename).stem
|
||||
name = re.sub(r'^\d+[-—]\s*', '', name)
|
||||
return name
|
||||
|
||||
def _extract_issuing_body(self, text: str) -> Optional[str]:
|
||||
"""提取发布机关"""
|
||||
# 常见模式:在文末签名块
|
||||
patterns = [
|
||||
r'(?:发布|公布|印发)[^\n]*?[机关部院会委局厅处]\s*[::]\s*(.+?)(?:\n|$)',
|
||||
r'^[\s]*(.{4,20}(?:部|委员会|院|局|厅|处|办公室|小组))\s*$',
|
||||
]
|
||||
|
||||
# 从后往前搜索(发布机关通常在文末)
|
||||
text_end = text[-3000:] if len(text) > 3000 else text
|
||||
lines = text_end.split('\n')
|
||||
|
||||
for line in reversed(lines):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# 匹配机关名称
|
||||
if re.match(r'^.{2,15}(?:部|委员会|院|局|厅|处|办公室|小组|政府|大会|人大常委会)$', line):
|
||||
return line
|
||||
|
||||
# 从标题区域搜索
|
||||
text_start = text[:1000]
|
||||
body_patterns = [
|
||||
r'((?:全国人民代表大会(?:常务委员会)?|国务院|.{2,10}部|.{2,10}委员会|.{2,10}局|.{2,10}厅))\s*(?:令|公告|通知|制定)',
|
||||
r'(.{2,10}(?:部|委员会|局|厅))\s*(?:令|公告|通知|印发)',
|
||||
]
|
||||
for pattern in body_patterns:
|
||||
match = re.search(pattern, text_start)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
return None
|
||||
|
||||
def _extract_dates(self, text: str) -> Dict[str, Optional[str]]:
|
||||
"""提取日期信息"""
|
||||
dates = {'promulgation_date': None, 'effective_date': None}
|
||||
|
||||
# 搜索发布日期
|
||||
date_patterns = [
|
||||
r'(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日\s*(?:起?\s*)?(?:施行|实施|生效|执行)',
|
||||
r'自\s*(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日\s*起?\s*(?:施行|实施|生效|执行)',
|
||||
]
|
||||
for pattern in date_patterns:
|
||||
match = re.search(pattern, text)
|
||||
if match:
|
||||
dates['effective_date'] = f"{match.group(1)}-{match.group(2).zfill(2)}-{match.group(3).zfill(2)}"
|
||||
break
|
||||
|
||||
# 搜索公布日期(通常在文末)
|
||||
pub_patterns = [
|
||||
r'(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日\s*(?:公布|发布|印发)',
|
||||
r'(?:公布|发布|印发)\s*[::]*\s*(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日',
|
||||
]
|
||||
text_end = text[-2000:] if len(text) > 2000 else text
|
||||
for pattern in pub_patterns:
|
||||
match = re.search(pattern, text_end)
|
||||
if match:
|
||||
dates['promulgation_date'] = f"{match.group(1)}-{match.group(2).zfill(2)}-{match.group(3).zfill(2)}"
|
||||
break
|
||||
|
||||
# 如果没有明确日期,尝试从文末找任意日期
|
||||
if not dates['promulgation_date']:
|
||||
match = re.findall(r'(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日', text_end)
|
||||
if match:
|
||||
last_date = match[-1]
|
||||
dates['promulgation_date'] = f"{last_date[0]}-{last_date[1].zfill(2)}-{last_date[2].zfill(2)}"
|
||||
|
||||
return dates
|
||||
|
||||
def _extract_document_number(self, text: str) -> Optional[str]:
|
||||
"""提取文号"""
|
||||
patterns = [
|
||||
r'[((]\s*(\d{4})\s*[))]\s*[^\s]*?\s*第?\s*(\d+)\s*号',
|
||||
r'第?\s*(\d+)\s*号\s*[令公告通知]',
|
||||
r'([A-Za-z一-鿿]+[〔(]\d{4}[))][^\s]*?号)',
|
||||
r'([国发|国办发|建发|自然资发|国土资发|建城|建规|住建|建住房|建村|办发|发改委][〔(]\d{4}[))][^\s]*?号)',
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text[:2000])
|
||||
if match:
|
||||
return match.group(0).strip()
|
||||
return None
|
||||
|
||||
def _parse_chapter_structure(self, text: str) -> List[Dict[str, Any]]:
|
||||
"""解析章节结构"""
|
||||
chapters = []
|
||||
current_chapter = None
|
||||
current_section = None
|
||||
current_articles = []
|
||||
|
||||
# 按行处理
|
||||
lines = text.split('\n')
|
||||
|
||||
def flush_articles():
|
||||
nonlocal current_articles
|
||||
result = current_articles
|
||||
current_articles = []
|
||||
return result
|
||||
|
||||
for line in lines:
|
||||
line_stripped = line.strip()
|
||||
if not line_stripped:
|
||||
continue
|
||||
|
||||
# 检测章标题
|
||||
ch_match = re.match(r'^第[一二三四五六七八九十百]+[章节部分]\s*(.*)$', line_stripped)
|
||||
if ch_match:
|
||||
# 先保存之前的章/节的文章
|
||||
if current_section:
|
||||
current_section['articles'] = flush_articles()
|
||||
elif current_chapter:
|
||||
current_chapter['articles'].extend(flush_articles())
|
||||
|
||||
current_chapter = {
|
||||
'title': line_stripped,
|
||||
'name': ch_match.group(1).strip() if ch_match.group(1) else line_stripped,
|
||||
'sections': [],
|
||||
'articles': [],
|
||||
}
|
||||
chapters.append(current_chapter)
|
||||
current_section = None
|
||||
continue
|
||||
|
||||
# 检测节标题
|
||||
sec_match = re.match(r'^第[一二三四五六七八九十百]+节\s*(.*)$', line_stripped)
|
||||
if sec_match and current_chapter:
|
||||
if current_section:
|
||||
current_section['articles'] = flush_articles()
|
||||
|
||||
current_section = {
|
||||
'title': line_stripped,
|
||||
'name': sec_match.group(1).strip() if sec_match.group(1) else line_stripped,
|
||||
'articles': [],
|
||||
}
|
||||
current_chapter['sections'].append(current_section)
|
||||
continue
|
||||
|
||||
# 检测条文
|
||||
art_match = re.match(r'^第[一二三四五六七八九十百零千]+条\s*(.*)$', line_stripped)
|
||||
if art_match:
|
||||
article = {
|
||||
'number': line_stripped.split('条')[0].replace('第', ''),
|
||||
'text': art_match.group(1).strip(),
|
||||
'full_text': line_stripped,
|
||||
}
|
||||
current_articles.append(article)
|
||||
continue
|
||||
|
||||
# 续接上一条
|
||||
if current_articles and line_stripped:
|
||||
current_articles[-1]['text'] += ' ' + line_stripped
|
||||
current_articles[-1]['full_text'] += ' ' + line_stripped
|
||||
|
||||
# 刷新最后的文章
|
||||
if current_section:
|
||||
current_section['articles'].extend(flush_articles())
|
||||
elif current_chapter:
|
||||
current_chapter['articles'].extend(flush_articles())
|
||||
|
||||
return chapters
|
||||
|
||||
def _classify_article_type(self, text: str) -> str:
|
||||
"""分类条文类型"""
|
||||
if re.search(r'不得|禁止|严禁', text):
|
||||
return '禁止性'
|
||||
elif re.search(r'可以|有权|依法享有', text):
|
||||
return '授权性'
|
||||
elif re.search(r'应当|必须|须|应当依法', text):
|
||||
return '管理性'
|
||||
elif re.search(r'申请|审批|备案|登记|许可|核准', text):
|
||||
return '程序性'
|
||||
elif re.search(r'罚款|责令|没收|吊销|刑事|处分', text):
|
||||
return '处罚性'
|
||||
else:
|
||||
return '定义性'
|
||||
|
||||
def process_document(self, doc: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""处理单个文档"""
|
||||
text = doc.get('raw_text', '')
|
||||
filename = doc.get('filename', '')
|
||||
|
||||
# 提取元数据
|
||||
title = self._extract_document_title(text, filename)
|
||||
issuing_body = self._extract_issuing_body(text)
|
||||
dates = self._extract_dates(text)
|
||||
doc_number = self._extract_document_number(text)
|
||||
|
||||
# 解析章节结构
|
||||
chapters = self._parse_chapter_structure(text)
|
||||
|
||||
# 统计条文数
|
||||
total_articles = 0
|
||||
for ch in chapters:
|
||||
total_articles += len(ch.get('articles', []))
|
||||
for sec in ch.get('sections', []):
|
||||
total_articles += len(sec.get('articles', []))
|
||||
|
||||
# 分类条文
|
||||
for ch in chapters:
|
||||
for art in ch.get('articles', []):
|
||||
art['article_type'] = self._classify_article_type(art['text'])
|
||||
for sec in ch.get('sections', []):
|
||||
for art in sec.get('articles', []):
|
||||
art['article_type'] = self._classify_article_type(art['text'])
|
||||
|
||||
result = {
|
||||
'doc_id': doc['doc_id'],
|
||||
'document_type': doc['document_type'],
|
||||
'category_name': doc['category_name'],
|
||||
'title': title,
|
||||
'document_name': doc.get('document_name', title),
|
||||
'issuing_body': issuing_body,
|
||||
'promulgation_date': dates['promulgation_date'],
|
||||
'effective_date': dates['effective_date'],
|
||||
'document_number': doc_number,
|
||||
'is_draft': doc.get('is_draft', False),
|
||||
'text_length': doc['text_length'],
|
||||
'filename': filename,
|
||||
'chapter_count': len(chapters),
|
||||
'article_count': total_articles,
|
||||
'chapters': chapters,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def process_all(self, input_path: str, output_path: str) -> List[Dict[str, Any]]:
|
||||
"""处理所有文档"""
|
||||
self.logger.info(f"读取文档数据: {input_path}")
|
||||
|
||||
with open(input_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
documents = data['documents']
|
||||
self.logger.info(f"共 {len(documents)} 个文档待处理")
|
||||
|
||||
results = []
|
||||
for i, doc in enumerate(documents):
|
||||
if doc['text_length'] == 0:
|
||||
self.logger.warning(f"跳过空文档: {doc['filename']}")
|
||||
continue
|
||||
|
||||
try:
|
||||
result = self.process_document(doc)
|
||||
results.append(result)
|
||||
|
||||
if (i + 1) % 50 == 0:
|
||||
self.logger.info(f" 已处理 {i + 1}/{len(documents)}")
|
||||
except Exception as e:
|
||||
self.logger.error(f"处理失败: {doc['filename']} - {str(e)}")
|
||||
|
||||
# 保存
|
||||
output = Path(output_path)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
save_data = {
|
||||
'metadata': {
|
||||
'total_documents': len(results),
|
||||
'processed_at': datetime.now().isoformat(),
|
||||
},
|
||||
'documents': results,
|
||||
}
|
||||
|
||||
with open(output, 'w', encoding='utf-8') as f:
|
||||
json.dump(save_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
self.logger.info(f"已保存到: {output}")
|
||||
self._print_summary(results)
|
||||
|
||||
return results
|
||||
|
||||
def _print_summary(self, results: List[Dict]):
|
||||
"""打印摘要"""
|
||||
total_articles = sum(r['article_count'] for r in results)
|
||||
total_chapters = sum(r['chapter_count'] for r in results)
|
||||
docs_with_body = sum(1 for r in results if r['issuing_body'])
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("法规元数据解析摘要")
|
||||
print(f"{'='*60}")
|
||||
print(f"处理文档数: {len(results)}")
|
||||
print(f"解析出章数: {total_chapters}")
|
||||
print(f"解析出条文数: {total_articles}")
|
||||
print(f"提取发布机关: {docs_with_body}/{len(results)} ({docs_with_body/max(len(results),1)*100:.1f}%)")
|
||||
|
||||
# 按类型统计
|
||||
type_counts = {}
|
||||
for r in results:
|
||||
t = r['document_type']
|
||||
type_counts[t] = type_counts.get(t, 0) + 1
|
||||
print("\n按类型统计:")
|
||||
for t, c in sorted(type_counts.items()):
|
||||
print(f" {t}: {c} 个")
|
||||
|
||||
print(f"{'='*60}")
|
||||
|
||||
|
||||
def main():
|
||||
input_path = r"E:\Project\SI\2026_KG_PlanningLaw\dofile\kg_project\output\all_documents.json"
|
||||
output_path = r"E:\Project\SI\2026_KG_PlanningLaw\dofile\kg_project\output\structured_documents.json"
|
||||
|
||||
extractor = LegalMetadataExtractor()
|
||||
extractor.process_all(input_path, output_path)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
法规知识图谱可视化工具
|
||||
使用networkx和matplotlib绘制知识图谱
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import networkx as nx
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.patches as mpatches
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
import json
|
||||
|
||||
|
||||
def setup_chinese_font():
|
||||
"""设置中文字体"""
|
||||
for font in ['Microsoft YaHei', 'SimHei', 'SimSun', 'KaiTi']:
|
||||
try:
|
||||
plt.rcParams['font.sans-serif'] = [font]
|
||||
plt.rcParams['axes.unicode_minus'] = False
|
||||
return
|
||||
except:
|
||||
continue
|
||||
|
||||
|
||||
# 节点类型颜色
|
||||
NODE_COLORS = {
|
||||
'Law': '#E74C3C',
|
||||
'AdministrativeRegulation': '#E67E22',
|
||||
'DepartmentalRule': '#F1C40F',
|
||||
'PolicyDocument': '#2ECC71',
|
||||
'Chapter': '#3498DB',
|
||||
'Section': '#9B59B6',
|
||||
'Article': '#1ABC9C',
|
||||
'GovernmentBody': '#8E44AD',
|
||||
'LegalSubject': '#E91E63',
|
||||
'SpatialConcept': '#00BCD4',
|
||||
'AdministrativeProcedure': '#FF9800',
|
||||
'Obligation': '#F44336',
|
||||
'Penalty': '#795548',
|
||||
'TimePoint': '#607D8B',
|
||||
'Region': '#4CAF50',
|
||||
}
|
||||
|
||||
# 节点类型大小
|
||||
NODE_SIZES = {
|
||||
'Law': 600,
|
||||
'AdministrativeRegulation': 500,
|
||||
'DepartmentalRule': 400,
|
||||
'PolicyDocument': 350,
|
||||
'Chapter': 250,
|
||||
'Section': 200,
|
||||
'Article': 100,
|
||||
'GovernmentBody': 400,
|
||||
'LegalSubject': 300,
|
||||
'SpatialConcept': 250,
|
||||
'AdministrativeProcedure': 200,
|
||||
'Obligation': 150,
|
||||
'Penalty': 150,
|
||||
'TimePoint': 100,
|
||||
'Region': 150,
|
||||
}
|
||||
|
||||
|
||||
def load_data(nodes_csv, rels_csv):
|
||||
"""加载数据"""
|
||||
nodes_df = pd.read_csv(nodes_csv, encoding='utf-8-sig')
|
||||
rels_df = pd.read_csv(rels_csv, encoding='utf-8-sig')
|
||||
print(f"加载节点: {len(nodes_df)}, 关系: {len(rels_df)}")
|
||||
return nodes_df, rels_df
|
||||
|
||||
|
||||
def build_graph(nodes_df, rels_df):
|
||||
"""构建NetworkX图"""
|
||||
G = nx.DiGraph()
|
||||
|
||||
for _, row in nodes_df.iterrows():
|
||||
node_id = row['id']
|
||||
label = str(row['label'])
|
||||
node_type = row['type']
|
||||
|
||||
display_label = label[:12] + '...' if len(label) > 12 else label
|
||||
|
||||
G.add_node(node_id,
|
||||
label=display_label,
|
||||
full_label=label,
|
||||
node_type=node_type,
|
||||
color=NODE_COLORS.get(node_type, '#CCCCCC'),
|
||||
size=NODE_SIZES.get(node_type, 150))
|
||||
|
||||
for _, row in rels_df.iterrows():
|
||||
source = row['source']
|
||||
target = row['target']
|
||||
rel_type = row['type']
|
||||
if source in G.nodes() and target in G.nodes():
|
||||
G.add_edge(source, target, rel_type=rel_type)
|
||||
|
||||
print(f"图: {G.number_of_nodes()} 节点, {G.number_of_edges()} 边")
|
||||
return G
|
||||
|
||||
|
||||
def draw_graph(G, output_path, title="法规知识图谱", max_nodes=300):
|
||||
"""绘制图谱"""
|
||||
if G.number_of_nodes() > max_nodes:
|
||||
degrees = dict(G.degree())
|
||||
top_nodes = sorted(degrees, key=degrees.get, reverse=True)[:max_nodes]
|
||||
G = G.subgraph(top_nodes).copy()
|
||||
|
||||
plt.figure(figsize=(24, 18))
|
||||
pos = nx.spring_layout(G, k=2.5, iterations=50, seed=42)
|
||||
|
||||
# 边
|
||||
nx.draw_networkx_edges(G, pos, alpha=0.2, width=0.5, edge_color='gray',
|
||||
arrows=True, arrowsize=8)
|
||||
|
||||
# 按类型绘制节点
|
||||
node_types = {}
|
||||
for node, data in G.nodes(data=True):
|
||||
nt = data.get('node_type', 'Unknown')
|
||||
node_types.setdefault(nt, []).append(node)
|
||||
|
||||
for nt, nodes in node_types.items():
|
||||
color = NODE_COLORS.get(nt, '#CCCCCC')
|
||||
size = NODE_SIZES.get(nt, 150)
|
||||
nx.draw_networkx_nodes(G, pos, nodelist=nodes, node_color=color,
|
||||
node_size=size, alpha=0.8, edgecolors='white', linewidths=1)
|
||||
|
||||
# 标签(只显示非Article类型)
|
||||
labels = {n: d['label'] for n, d in G.nodes(data=True)
|
||||
if d.get('node_type') != 'Article'}
|
||||
if len(labels) <= 150:
|
||||
nx.draw_networkx_labels(G, pos, labels=labels, font_size=7, font_weight='bold')
|
||||
|
||||
# 图例
|
||||
patches = [mpatches.Patch(color=NODE_COLORS[nt], label=nt)
|
||||
for nt in node_types if nt in NODE_COLORS]
|
||||
plt.legend(handles=patches, loc='upper right', fontsize=10, framealpha=0.9)
|
||||
|
||||
plt.title(title, fontsize=16, fontweight='bold', pad=20)
|
||||
plt.axis('off')
|
||||
plt.tight_layout()
|
||||
plt.savefig(output_path, dpi=150, bbox_inches='tight')
|
||||
plt.close()
|
||||
print(f"保存: {output_path}")
|
||||
|
||||
|
||||
def draw_subgraphs(G, output_dir):
|
||||
"""绘制子图"""
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 1. 文档层次图
|
||||
doc_types = ['Law', 'AdministrativeRegulation', 'DepartmentalRule', 'PolicyDocument', 'Chapter']
|
||||
G1_nodes = [n for n, d in G.nodes(data=True) if d.get('node_type') in doc_types]
|
||||
if G1_nodes:
|
||||
G1 = G.subgraph(G1_nodes).copy()
|
||||
draw_graph(G1, output_dir / 'kg_document_hierarchy.png', '法规文档层次结构')
|
||||
|
||||
# 2. 引用网络
|
||||
ref_rels = ['CITES', 'IMPLEMENTS', 'AMENDS', 'REPLACES', 'SUPPLEMENTS']
|
||||
ref_edges = [(u, v) for u, v, d in G.edges(data=True) if d.get('rel_type') in ref_rels]
|
||||
if ref_edges:
|
||||
G2 = G.edge_subgraph(ref_edges).copy()
|
||||
draw_graph(G2, output_dir / 'kg_citation_network.png', '法规引用网络')
|
||||
|
||||
# 3. 主题规制图
|
||||
subject_types = ['LegalSubject', 'SpatialConcept']
|
||||
subject_nodes = [n for n, d in G.nodes(data=True) if d.get('node_type') in subject_types]
|
||||
if subject_nodes:
|
||||
neighbors = set(subject_nodes)
|
||||
for node in subject_nodes:
|
||||
neighbors.update(G.predecessors(node))
|
||||
neighbors.update(G.successors(node))
|
||||
G3 = G.subgraph(neighbors).copy()
|
||||
draw_graph(G3, output_dir / 'kg_subject_regulation.png', '法规主题规制关系')
|
||||
|
||||
# 4. 机构关系图
|
||||
gov_nodes = [n for n, d in G.nodes(data=True) if d.get('node_type') == 'GovernmentBody']
|
||||
if gov_nodes:
|
||||
neighbors = set(gov_nodes)
|
||||
for node in gov_nodes:
|
||||
neighbors.update(G.predecessors(node))
|
||||
neighbors.update(G.successors(node))
|
||||
G4 = G.subgraph(neighbors).copy()
|
||||
draw_graph(G4, output_dir / 'kg_government_body.png', '政府机构关系')
|
||||
|
||||
|
||||
def print_statistics(G):
|
||||
"""打印统计"""
|
||||
print(f"\n{'='*60}")
|
||||
print("图谱统计")
|
||||
print(f"{'='*60}")
|
||||
print(f"节点: {G.number_of_nodes()}, 边: {G.number_of_edges()}")
|
||||
|
||||
type_counts = {}
|
||||
for _, d in G.nodes(data=True):
|
||||
nt = d.get('node_type', 'Unknown')
|
||||
type_counts[nt] = type_counts.get(nt, 0) + 1
|
||||
|
||||
print("\n节点类型:")
|
||||
for nt, c in sorted(type_counts.items()):
|
||||
print(f" {nt}: {c} ({c/G.number_of_nodes()*100:.1f}%)")
|
||||
|
||||
rel_counts = {}
|
||||
for _, _, d in G.edges(data=True):
|
||||
rt = d.get('rel_type', 'Unknown')
|
||||
rel_counts[rt] = rel_counts.get(rt, 0) + 1
|
||||
|
||||
print("\n关系类型:")
|
||||
for rt, c in sorted(rel_counts.items()):
|
||||
print(f" {rt}: {c}")
|
||||
|
||||
degrees = [d for _, d in G.degree()]
|
||||
print(f"\n平均连接度: {np.mean(degrees):.2f}")
|
||||
top = sorted(G.degree(), key=lambda x: x[1], reverse=True)[:10]
|
||||
print("连接度最高:")
|
||||
for node, deg in top:
|
||||
data = G.nodes[node]
|
||||
print(f" [{data.get('node_type')}] {data.get('full_label', node)}: {deg}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
|
||||
def main():
|
||||
setup_chinese_font()
|
||||
|
||||
base = Path(r"E:\Project\SI\2026_KG_PlanningLaw\dofile\kg_project\output")
|
||||
|
||||
# 优先使用合并后的文件
|
||||
nodes_csv = base / 'nodes_merged.csv' if (base / 'nodes_merged.csv').exists() else base / 'nodes_structured.csv'
|
||||
rels_csv = base / 'rels_merged.csv' if (base / 'rels_merged.csv').exists() else base / 'rels_structured.csv'
|
||||
|
||||
nodes_df, rels_df = load_data(nodes_csv, rels_csv)
|
||||
G = build_graph(nodes_df, rels_df)
|
||||
print_statistics(G)
|
||||
draw_subgraphs(G, base / 'visualizations')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user