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

190 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import argparse
import json
import re
import zipfile
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET
WORD_NS = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
ARTICLE_RE = re.compile(r"^第[一二三四五六七八九十百千万零〇两0-9]+条(?:之[一二三四五六七八九十百千万零〇两0-9]+)?")
DIVISION_RE = re.compile(r"^第[一二三四五六七八九十百千万零〇两0-9]+分编")
CHAPTER_RE = re.compile(r"^第[一二三四五六七八九十百千万零〇两0-9]+章")
SECTION_RE = re.compile(r"^第[一二三四五六七八九十百千万零〇两0-9]+节")
SOURCE_ORDER = [
"LAW-CIVIL-CODE-2020",
"LAW-E-SIGNATURE-2019",
"LAW-PIPL-2021",
"LAW-LABOR-CONTRACT-2012",
"LAW-DATA-SECURITY-2021",
"LAW-COMPANY-2023",
]
def read_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def parse_docx_paragraphs(path: Path) -> list[str]:
with zipfile.ZipFile(path) as archive:
root = ET.fromstring(archive.read("word/document.xml"))
paragraphs: list[str] = []
for node in root.findall(".//w:p", WORD_NS):
text = "".join((text_node.text or "") for text_node in node.findall(".//w:t", WORD_NS)).strip()
if text:
paragraphs.append(text)
return paragraphs
def build_catalog(paragraphs: list[str]) -> list[dict[str, str]]:
division = ""
chapter = ""
section = ""
catalog: list[dict[str, str]] = []
for text in paragraphs:
if DIVISION_RE.match(text):
division = text
chapter = ""
section = ""
continue
if CHAPTER_RE.match(text):
chapter = text
section = ""
continue
if SECTION_RE.match(text):
section = text
continue
if ARTICLE_RE.match(text):
article_no = ARTICLE_RE.match(text).group(0)
catalog.append(
{
"article_no": article_no,
"division": division,
"chapter_title": chapter,
"section_title": section,
}
)
return catalog
def latest_docx_by_source(artifact_root: Path) -> dict[str, Path]:
mapping: dict[str, Path] = {}
for path in artifact_root.glob("**/official-downloads/*/official.docx"):
source_id = path.parent.name
current = mapping.get(source_id)
if current is None or path.stat().st_mtime > current.stat().st_mtime:
mapping[source_id] = path
return mapping
def load_imported_articles(phase14_root: Path) -> tuple[dict[str, set[str]], list[Path]]:
manifest_paths = sorted(phase14_root.glob("legal-article*.json"))
incremental_root = phase14_root / "incremental-batches"
if incremental_root.exists():
manifest_paths.extend(sorted(incremental_root.glob("*.json")))
imported: dict[str, set[str]] = defaultdict(set)
for path in manifest_paths:
payload = read_json(path)
for source in payload.get("sources") or []:
for article in source.get("articles") or []:
imported[str(source["source_id"])].add(str(article["article_no"]))
return imported, manifest_paths
def write_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def write_md(path: Path, summary: dict[str, Any], per_source: list[dict[str, Any]]) -> None:
lines = [
"# Phase14 剩余未导条文目录",
"",
f"- 生成时间:`{summary['generated_at']}`",
f"- 基线 manifest:`{summary['baseline_manifest_count']}` 个",
f"- 官方 docx 来源:`{summary['docx_source_count']}` 个",
"",
"## 汇总",
"",
"| source_id | total | imported | remaining | latest_docx |",
"|---|---:|---:|---:|---|",
]
for item in per_source:
lines.append(
f"| {item['source_id']} | {item['catalog_count']} | {item['imported_count']} | {item['remaining_count']} | {item['docx_path']} |"
)
lines.extend(
[
"",
"## 剩余样例",
"",
]
)
for item in per_source:
lines.append(f"### {item['source_id']}")
lines.append("")
if not item["remaining"]:
lines.append("- 无剩余条文。")
lines.append("")
continue
lines.append("| article_no | division | chapter | section |")
lines.append("|---|---|---|---|")
for row in item["remaining"][:30]:
lines.append(
f"| {row['article_no']} | {row['division']} | {row['chapter_title']} | {row['section_title']} |"
)
lines.append("")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines), encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="Report remaining Phase14 legal articles not yet imported.")
parser.add_argument("--phase14-root", type=Path, required=True)
parser.add_argument("--artifact-root", type=Path, required=True)
parser.add_argument("--output-dir", type=Path, required=True)
args = parser.parse_args()
imported, manifest_paths = load_imported_articles(args.phase14_root)
docx_by_source = latest_docx_by_source(args.artifact_root)
per_source: list[dict[str, Any]] = []
for source_id in SOURCE_ORDER:
docx_path = docx_by_source.get(source_id)
if docx_path is None:
continue
catalog = build_catalog(parse_docx_paragraphs(docx_path))
imported_set = imported.get(source_id, set())
remaining = [row for row in catalog if row["article_no"] not in imported_set]
per_source.append(
{
"source_id": source_id,
"docx_path": str(docx_path),
"catalog_count": len(catalog),
"imported_count": len(imported_set),
"remaining_count": len(remaining),
"remaining": remaining,
}
)
summary = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"baseline_manifest_count": len(manifest_paths),
"docx_source_count": len(docx_by_source),
}
write_json(args.output_dir / "phase14-remaining-articles.json", {"summary": summary, "sources": per_source})
write_md(args.output_dir / "phase14-remaining-articles.md", summary, per_source)
print(json.dumps({"summary": summary, "sources": per_source}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())