Initial: integrated 2025 LawGraph (graphrag_pipeline) + 2026 kg_project

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 10:13:39 +08:00
commit 6c1a69af0d
83 changed files with 14295 additions and 0 deletions
+221
View File
@@ -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()