Files
ai-contract-platform/backend/tools/legal_source_diff_report.py
T

607 lines
27 KiB
Python

from __future__ import annotations
import argparse
import csv
import gzip
import json
import tarfile
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
def read_json(path: Path | None) -> dict[str, Any]:
if not path or not path.exists():
return {}
return json.loads(path.read_text(encoding="utf-8-sig"))
def latest_file(directory: Path, pattern: str) -> Path | None:
files = sorted(directory.glob(pattern), key=lambda item: item.stat().st_mtime, reverse=True)
return files[0] if files else None
def find_laws_archive(laws_backup_dir: Path) -> Path:
if laws_backup_dir.is_file() and laws_backup_dir.name.endswith(".tar.gz"):
return laws_backup_dir
archive = latest_file(laws_backup_dir, "*.tar.gz")
if archive is None:
raise FileNotFoundError(f"cn-laws-download archive not found under {laws_backup_dir}")
return archive
def normalize_text(value: Any) -> str:
return str(value or "").strip()
def normalize_source_id(value: Any) -> str:
source_id = normalize_text(value)
if source_id.upper().startswith("FLK-"):
return source_id[4:]
return source_id
def metadata_parent(member_name: str) -> str:
return str(Path(member_name).parent).replace("\\", "/")
def pick_source_id(payload: dict[str, Any], member_name: str) -> str:
row = payload.get("row") or {}
detail_data = ((payload.get("detail") or {}).get("data") or {})
source_id = normalize_text(payload.get("bbbs") or row.get("bbbs") or detail_data.get("bbbs"))
if source_id:
return source_id
parts = Path(member_name).parts
if len(parts) >= 3 and parts[-1] == "metadata.json":
return parts[-2]
return ""
def scan_backup_sources(laws_backup_dir: Path) -> dict[str, Any]:
archive = find_laws_archive(laws_backup_dir)
sources_by_id: dict[str, dict[str, Any]] = {}
duplicate_ids: list[dict[str, Any]] = []
fulltext_parents: set[str] = set()
with tarfile.open(archive, "r:gz") as tar:
for member in tar:
if member.isfile() and member.name.endswith("/fulltext.txt"):
fulltext_parents.add(metadata_parent(member.name))
continue
if not (member.isfile() and member.name.endswith("/metadata.json")):
continue
file_obj = tar.extractfile(member)
if file_obj is None:
continue
try:
payload = json.loads(file_obj.read().decode("utf-8"))
except Exception as exc: # noqa: BLE001
duplicate_ids.append({"source_id": "", "metadata_path": member.name, "error": str(exc)})
continue
row = payload.get("row") or {}
detail_data = ((payload.get("detail") or {}).get("data") or {})
source_id = pick_source_id(payload, member.name)
title = normalize_text(row.get("title") or detail_data.get("title"))
source = {
"source_id": source_id,
"canonical_source_id": normalize_source_id(source_id),
"source_title": title,
"source_type": normalize_text(row.get("flxz") or detail_data.get("flxz") or "未知"),
"authority": normalize_text(row.get("zdjgName") or detail_data.get("zdjgName")),
"effective_date": normalize_text(row.get("sxrq") or detail_data.get("sxrq")),
"publish_date": normalize_text(row.get("gbrq") or detail_data.get("gbrq")),
"validity_status": normalize_text(row.get("sxx") or detail_data.get("sxx")),
"source_page": payload.get("source_page"),
"metadata_path": member.name,
"source_directory": metadata_parent(member.name),
"fulltext_length": int(payload.get("fulltext_length") or 0),
"has_reader_file": bool(payload.get("reader_file")),
"has_ofd_path": bool(payload.get("ofd_path")),
"saved_at": normalize_text(payload.get("saved_at")),
}
if not source_id:
duplicate_ids.append(source | {"error": "missing_source_id"})
continue
if source_id in sources_by_id:
duplicate_ids.append(source | {"error": "duplicate_source_id"})
continue
sources_by_id[source_id] = source
for source in sources_by_id.values():
source["has_fulltext"] = source["source_directory"] in fulltext_parents
return {
"archive": str(archive),
"sources": sources_by_id,
"duplicate_or_invalid_metadata": duplicate_ids,
"fulltext_documents": len(fulltext_parents),
}
def scan_prepared_source_inventory(prepare_summary_json: Path | None) -> dict[str, Any]:
summary = read_json(prepare_summary_json)
jsonl_path = Path(summary.get("jsonl_path") or "")
result: dict[str, Any] = {
"jsonl_path": str(jsonl_path) if jsonl_path else "",
"sources": {},
"error": "",
}
if not jsonl_path or not jsonl_path.exists():
result["error"] = "prepared_jsonl_not_found"
return result
sources_by_id: dict[str, dict[str, Any]] = {}
with gzip.open(jsonl_path, "rt", encoding="utf-8") as file_obj:
for line in file_obj:
if not line.strip():
continue
row = json.loads(line)
source_id = normalize_text(row.get("source_id"))
canonical_source_id = normalize_source_id(source_id)
if not canonical_source_id:
continue
source = sources_by_id.setdefault(
canonical_source_id,
{
"source_id": source_id,
"canonical_source_id": canonical_source_id,
"source_title": normalize_text(row.get("source_title")),
"source_type": normalize_text(row.get("source_type") or "未知"),
"authority": normalize_text(row.get("authority")),
"effective_date": normalize_text(row.get("effective_date")),
"region": normalize_text(row.get("region") or "CN"),
"tags_json": row.get("tags_json") or [],
"article_count": 0,
"source_checksum_values": set(),
"text_checksum_values": set(),
"missing_authority_rows": 0,
"missing_effective_date_rows": 0,
"missing_region_rows": 0,
"missing_tags_rows": 0,
},
)
source["article_count"] += 1
if not source["source_title"]:
source["source_title"] = normalize_text(row.get("source_title"))
if not source["authority"]:
source["authority"] = normalize_text(row.get("authority"))
if not source["effective_date"]:
source["effective_date"] = normalize_text(row.get("effective_date"))
if not source["region"]:
source["region"] = normalize_text(row.get("region") or "CN")
tags_json = row.get("tags_json") or []
if tags_json:
source["tags_json"] = tags_json
if not normalize_text(row.get("authority")):
source["missing_authority_rows"] += 1
if not normalize_text(row.get("effective_date")):
source["missing_effective_date_rows"] += 1
if not normalize_text(row.get("region")):
source["missing_region_rows"] += 1
if not tags_json:
source["missing_tags_rows"] += 1
checksum = normalize_text(row.get("source_checksum"))
if checksum:
source["source_checksum_values"].add(checksum)
text_checksum = normalize_text(row.get("text_checksum"))
if text_checksum:
source["text_checksum_values"].add(text_checksum)
for source in sources_by_id.values():
source_checksum_values = sorted(source.pop("source_checksum_values"))
text_checksum_values = sorted(source.pop("text_checksum_values"))
source["source_checksum"] = source_checksum_values[0] if len(source_checksum_values) == 1 else ";".join(source_checksum_values)
source["source_checksum_count"] = len(source_checksum_values)
source["text_checksum_count"] = len(text_checksum_values)
source["missing_fields"] = [
field
for field, missing_rows in [
("authority", source["missing_authority_rows"]),
("effective_date", source["missing_effective_date_rows"]),
("region", source["missing_region_rows"]),
("tags", source["missing_tags_rows"]),
]
if missing_rows > 0
]
result["sources"] = sources_by_id
return result
def build_title_index(sources: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
title_index: dict[str, list[dict[str, Any]]] = defaultdict(list)
for source in sources:
title = normalize_text(source.get("source_title"))
if title:
title_index[title].append(source)
return title_index
def build_source_comparison(
source: dict[str, Any],
remote_sources_by_id: dict[str, dict[str, Any]],
remote_title_index: dict[str, list[dict[str, Any]]],
) -> dict[str, Any]:
canonical_source_id = normalize_source_id(source.get("source_id"))
title = normalize_text(source.get("source_title"))
remote_exact = remote_sources_by_id.get(canonical_source_id)
title_matches = remote_title_index.get(title, [])
if remote_exact is not None:
classification = "imported_exact_source_id"
remote_matches = [remote_exact]
action_suggestion = "已入库;source_id 与远程运行库对齐。"
elif title_matches:
classification = "covered_by_remote_same_title"
remote_matches = title_matches
action_suggestion = "暂不重复导入;同标题内容已在运行库中覆盖,后续只需抽样核验。"
else:
classification = "needs_incremental_review"
remote_matches = []
action_suggestion = "需要进入增量补抓/补导入复核队列。"
remote_source_ids_same_title = [normalize_text(item.get("source_id")) for item in remote_matches if normalize_text(item.get("source_id"))]
remote_source_checksums = [normalize_text(item.get("source_checksum")) for item in remote_matches if normalize_text(item.get("source_checksum"))]
remote_same_title_article_count = sum(int(item.get("article_count") or 0) for item in remote_matches)
remote_same_title_count = len(remote_matches)
remote_source_checksum = normalize_text(remote_exact.get("source_checksum")) if remote_exact else ""
remote_source_checksum_count = int(remote_exact.get("source_checksum_count") or 0) if remote_exact else 0
checksum_mismatch = False
checksum_reason = ""
source_checksum = normalize_text(source.get("source_checksum"))
if remote_exact is not None:
if remote_source_checksum_count > 1:
checksum_mismatch = True
checksum_reason = "remote_source_checksum_inconsistent"
elif source_checksum and remote_source_checksum and source_checksum != remote_source_checksum:
checksum_mismatch = True
checksum_reason = "source_checksum_differs"
elif source_checksum and not remote_source_checksum:
checksum_mismatch = True
checksum_reason = "remote_source_checksum_missing"
elif remote_source_checksums:
remote_unique_checksum = remote_source_checksums[0] if len(set(remote_source_checksums)) == 1 else ""
if source_checksum and remote_unique_checksum and source_checksum != remote_unique_checksum:
checksum_mismatch = True
checksum_reason = "same_title_checksum_differs"
metadata_gaps = []
for field in ("authority", "effective_date", "region"):
if not normalize_text(source.get(field)):
metadata_gaps.append(field)
tags = source.get("tags_json") or []
if not tags:
metadata_gaps.append("tags")
return {
**source,
"canonical_source_id": canonical_source_id,
"classification": classification,
"remote_source_ids_same_title": ";".join(remote_source_ids_same_title),
"remote_same_title_count": remote_same_title_count,
"remote_same_title_article_count": remote_same_title_article_count,
"remote_source_checksum": remote_source_checksum,
"remote_source_checksum_count": remote_source_checksum_count,
"checksum_mismatch": checksum_mismatch,
"checksum_mismatch_reason": checksum_reason,
"metadata_gaps": ";".join(metadata_gaps),
"metadata_gap_fields": metadata_gaps,
"action_suggestion": action_suggestion,
}
def classify_missing_source(source: dict[str, Any], remote_title_index: dict[str, list[dict[str, Any]]]) -> dict[str, Any]:
title = normalize_text(source.get("source_title"))
title_matches = remote_title_index.get(title, [])
if title_matches:
remote_article_count = sum(int(item.get("article_count") or 0) for item in title_matches)
remote_source_ids = [item.get("source_id") for item in title_matches]
return {
**source,
"classification": "covered_by_remote_same_title",
"remote_source_ids_same_title": ";".join(remote_source_ids),
"remote_same_title_count": len(title_matches),
"remote_same_title_article_count": remote_article_count,
"action_suggestion": "暂不重复导入;同标题内容已在运行库中覆盖,后续只需抽样核验。",
}
return {
**source,
"classification": "needs_incremental_review",
"remote_source_ids_same_title": "",
"remote_same_title_count": 0,
"remote_same_title_article_count": 0,
"action_suggestion": "需要进入增量补抓/补导入复核队列。",
}
def write_csv(path: Path, rows: list[dict[str, Any]], fieldnames: list[str]) -> None:
with path.open("w", encoding="utf-8-sig", newline="") as file_obj:
writer = csv.DictWriter(file_obj, fieldnames=fieldnames, extrasaction="ignore")
writer.writeheader()
writer.writerows(rows)
def markdown_table(rows: list[list[Any]]) -> str:
if not rows:
return ""
lines = [
"| " + " | ".join(str(item) for item in rows[0]) + " |",
"| " + " | ".join("---" for _ in rows[0]) + " |",
]
for row in rows[1:]:
lines.append("| " + " | ".join(str(item).replace("\n", " ") for item in row) + " |")
return "\n".join(lines)
def build_diff_report(
*,
laws_backup_dir: Path,
remote_source_json: Path,
prepare_summary_json: Path | None,
import_summary_json: Path | None,
output_dir: Path,
tenant_id: str,
) -> dict[str, Any]:
output_dir.mkdir(parents=True, exist_ok=True)
backup_scan = scan_backup_sources(laws_backup_dir)
remote_payload = read_json(remote_source_json)
prepare_summary = read_json(prepare_summary_json)
import_summary = read_json(import_summary_json)
prepared_inventory = scan_prepared_source_inventory(prepare_summary_json)
backup_archive_sources_by_id = backup_scan["sources"]
prepared_sources_by_id = prepared_inventory["sources"]
remote_sources = remote_payload.get("sources") or []
remote_sources_by_id = {
normalize_source_id(item.get("source_id")): item
for item in remote_sources
if normalize_source_id(item.get("source_id"))
}
backup_sources_by_canonical_id = {}
for source_id, source in backup_archive_sources_by_id.items():
canonical_source_id = normalize_source_id(source_id)
if not canonical_source_id:
continue
merged_source = dict(source)
prepared_source = prepared_sources_by_id.get(canonical_source_id) or {}
merged_source.update(prepared_source)
merged_source["source_id"] = source_id
merged_source["canonical_source_id"] = canonical_source_id
backup_sources_by_canonical_id[canonical_source_id] = merged_source
remote_title_index = build_title_index(remote_sources)
backup_ids = set(backup_sources_by_canonical_id)
remote_ids = set(remote_sources_by_id)
comparison_rows = [
build_source_comparison(backup_sources_by_canonical_id[source_id], remote_sources_by_id, remote_title_index)
for source_id in sorted(backup_ids)
]
imported_sources = [
item for item in comparison_rows if item["classification"] in {"imported_exact_source_id", "covered_by_remote_same_title"}
]
missing_sources = [item for item in comparison_rows if item["classification"] == "needs_incremental_review"]
checksum_mismatch_sources = [item for item in comparison_rows if item["checksum_mismatch"]]
metadata_gap_sources = [item for item in comparison_rows if item["metadata_gaps"]]
extra_remote_sources = [
remote_sources_by_id[source_id]
for source_id in sorted(remote_ids - backup_ids)
]
classification_counts = Counter(item["classification"] for item in comparison_rows)
source_type_counts = Counter(normalize_text(item.get("source_type") or "未知") for item in missing_sources)
remote_duplicate_titles = [
{
"source_title": title,
"remote_source_count": len(items),
"remote_source_ids": ";".join(str(item.get("source_id")) for item in items),
"remote_article_count": sum(int(item.get("article_count") or 0) for item in items),
}
for title, items in remote_title_index.items()
if len(items) > 1
]
backup_title_index = build_title_index(list(backup_sources_by_canonical_id.values()))
backup_duplicate_titles = [
{
"source_title": title,
"backup_source_count": len(items),
"backup_source_ids": ";".join(str(item.get("source_id")) for item in items),
}
for title, items in backup_title_index.items()
if len(items) > 1
]
needs_incremental_review = missing_sources
covered_by_title = [item for item in imported_sources if item["classification"] == "covered_by_remote_same_title"]
imported_exact = [item for item in imported_sources if item["classification"] == "imported_exact_source_id"]
status = "PASS" if not needs_incremental_review else "WARN"
summary = {
"status": status,
"generated_at": datetime.now(timezone.utc).isoformat(),
"tenant_id": tenant_id,
"laws_backup_dir": str(laws_backup_dir),
"archive": backup_scan["archive"],
"remote_source_json": str(remote_source_json),
"prepare_summary_json": str(prepare_summary_json) if prepare_summary_json else None,
"import_summary_json": str(import_summary_json) if import_summary_json else None,
"counts": {
"backup_sources": len(backup_sources_by_canonical_id),
"backup_fulltext_documents": backup_scan["fulltext_documents"],
"remote_sources": len(remote_sources_by_id),
"remote_legal_articles": int(remote_payload.get("legal_article_count") or 0),
"imported_sources": len(imported_sources),
"missing_sources_in_remote": len(missing_sources),
"covered_by_remote_same_title": len(covered_by_title),
"needs_incremental_review": len(needs_incremental_review),
"checksum_mismatch_sources": len(checksum_mismatch_sources),
"metadata_gap_sources": len(metadata_gap_sources),
"extra_remote_sources": len(extra_remote_sources),
"backup_duplicate_title_groups": len(backup_duplicate_titles),
"remote_duplicate_title_groups": len(remote_duplicate_titles),
"invalid_or_duplicate_metadata": len(backup_scan["duplicate_or_invalid_metadata"]),
"prepared_article_rows": int(prepare_summary.get("rows") or 0),
"import_inserted": int(import_summary.get("inserted") or 0),
"import_skipped_existing_duplicates": int(import_summary.get("skipped_existing_duplicates") or 0),
},
"classification_counts": dict(classification_counts),
"missing_source_type_counts": dict(source_type_counts.most_common()),
"imported_sources": imported_sources,
"missing_sources": missing_sources,
"checksum_mismatch_sources": checksum_mismatch_sources,
"metadata_gap_sources": metadata_gap_sources,
"extra_remote_sources": extra_remote_sources,
"backup_duplicate_titles": backup_duplicate_titles[:200],
"remote_duplicate_titles": remote_duplicate_titles[:200],
"invalid_or_duplicate_metadata": backup_scan["duplicate_or_invalid_metadata"][:200],
"conclusion": (
"本轮没有发现必须立即补导入的来源;缺失 source_id 均可被远程同标题来源覆盖。"
if not needs_incremental_review
else "存在远程同标题无法覆盖的来源,需要进入增量补抓/补导入复核队列。"
),
}
json_path = output_dir / "legal-source-diff-report.json"
md_path = output_dir / "legal-source-diff-report.md"
missing_csv = output_dir / "missing-sources.csv"
extra_csv = output_dir / "extra-remote-sources.csv"
covered_csv = output_dir / "covered-by-remote-title.csv"
json_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
common_fields = [
"source_id",
"canonical_source_id",
"source_title",
"source_type",
"authority",
"effective_date",
"region",
"tags_json",
"source_checksum",
"remote_source_checksum",
"remote_source_checksum_count",
"checksum_mismatch",
"checksum_mismatch_reason",
"metadata_gaps",
"publish_date",
"classification",
"remote_source_ids_same_title",
"remote_same_title_count",
"remote_same_title_article_count",
"action_suggestion",
"metadata_path",
]
imported_csv = output_dir / "imported-sources.csv"
write_csv(missing_csv, missing_sources, common_fields)
write_csv(imported_csv, imported_sources, common_fields)
write_csv(covered_csv, covered_by_title, common_fields)
write_csv(output_dir / "checksum-mismatch-sources.csv", checksum_mismatch_sources, common_fields)
write_csv(output_dir / "metadata-gap-sources.csv", metadata_gap_sources, common_fields)
write_csv(
extra_csv,
extra_remote_sources,
["source_id", "source_title", "source_type", "authority", "effective_date", "article_count"],
)
write_markdown_report(md_path, summary, comparison_rows)
return summary
def write_markdown_report(path: Path, summary: dict[str, Any], comparison_rows: list[dict[str, Any]]) -> None:
counts = summary["counts"]
sample_rows = [["source_id", "标题", "分类", "建议"]]
for item in comparison_rows[:20]:
sample_rows.append(
[
item.get("source_id"),
item.get("source_title"),
item.get("classification"),
item.get("action_suggestion"),
]
)
lines = [
"# 法律库来源差异复核报告",
"",
f"- 状态:`{summary['status']}`",
f"- 生成时间:{summary['generated_at']}",
f"- 租户:`{summary['tenant_id']}`",
f"- 备份包:`{summary['archive']}`",
"- 说明:对比时已规范化远程 `source_id`,自动剥离 `FLK-` 前缀后再做差异判断。",
"",
"## 结论",
"",
f"- {summary['conclusion']}",
f"- 备份包来源数:`{counts['backup_sources']}`;远程运行库来源数:`{counts['remote_sources']}`。",
f"- 远程 `legal_articles` 条文数:`{counts['remote_legal_articles']}`。",
f"- 已入库来源:`{counts['imported_sources']}`。",
f"- 备份包有但远程 source_id 没有的来源:`{counts['missing_sources_in_remote']}`。",
f"- 其中同标题已覆盖:`{counts['covered_by_remote_same_title']}`;需要增量复核:`{counts['needs_incremental_review']}`。",
f"- checksum 不一致:`{counts['checksum_mismatch_sources']}`。",
f"- 元数据缺口:`{counts['metadata_gap_sources']}`。",
f"- 远程额外来源:`{counts['extra_remote_sources']}`。",
"",
"## 关键统计",
"",
markdown_table(
[
["指标", "数量"],
["备份来源", counts["backup_sources"]],
["备份 fulltext 文档", counts["backup_fulltext_documents"]],
["远程来源", counts["remote_sources"]],
["远程 legal_articles", counts["remote_legal_articles"]],
["已入库来源", counts["imported_sources"]],
["缺失 source_id", counts["missing_sources_in_remote"]],
["同标题覆盖", counts["covered_by_remote_same_title"]],
["需增量复核", counts["needs_incremental_review"]],
["checksum 不一致", counts["checksum_mismatch_sources"]],
["元数据缺口", counts["metadata_gap_sources"]],
["远程额外来源", counts["extra_remote_sources"]],
["备份同名标题组", counts["backup_duplicate_title_groups"]],
["远程同名标题组", counts["remote_duplicate_title_groups"]],
]
),
"",
"## 来源样例",
"",
markdown_table(sample_rows) if len(sample_rows) > 1 else "无。",
"",
"## 输出文件",
"",
"- `legal-source-diff-report.json`",
"- `imported-sources.csv`",
"- `missing-sources.csv`",
"- `checksum-mismatch-sources.csv`",
"- `metadata-gap-sources.csv`",
"- `covered-by-remote-title.csv`",
"- `extra-remote-sources.csv`",
"",
]
path.write_text("\n".join(lines), encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="Compare cn-laws-download source metadata with remote legal_articles sources.")
parser.add_argument("--laws-backup-dir", type=Path, required=True)
parser.add_argument("--remote-source-json", type=Path, required=True)
parser.add_argument("--prepare-summary-json", type=Path)
parser.add_argument("--import-summary-json", type=Path)
parser.add_argument("--output-dir", type=Path, required=True)
parser.add_argument("--tenant-id", default="tenant-demo-001")
args = parser.parse_args()
summary = build_diff_report(
laws_backup_dir=args.laws_backup_dir,
remote_source_json=args.remote_source_json,
prepare_summary_json=args.prepare_summary_json,
import_summary_json=args.import_summary_json,
output_dir=args.output_dir,
tenant_id=args.tenant_id,
)
marker = "PASS" if summary["status"] == "PASS" else "WARN"
print(f"LEGAL_SOURCE_DIFF_REPORT_{marker} {args.output_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())