Download scripts/audit_bootstrap_dataset.py from haidang2405/telen: direct link, hf CLI and curl.
- Browser
- Download file 12.7 kB
-
https://huggingface.co/haidang2405/telen/resolve/main/scripts/audit_bootstrap_dataset.py
- Command line
-
hf download hf://haidang2405/telen/scripts/audit_bootstrap_dataset.py
-
curl -L -o audit_bootstrap_dataset.py https://huggingface.co/haidang2405/telen/resolve/main/scripts/audit_bootstrap_dataset.py
12.7 kB
| #!/usr/bin/env python3 | |
| """Audit and canonicalize the pinned Vietnamese legal-document snapshot.""" | |
| from __future__ import annotations | |
| import argparse | |
| import hashlib | |
| import json | |
| import sys | |
| from collections import Counter | |
| from pathlib import Path | |
| import pandas as pd | |
| import pyarrow.parquet as pq | |
| REPO_ROOT = Path(__file__).resolve().parents[1] | |
| sys.path.insert(0, str(REPO_ROOT / "src")) | |
| from telen.bootstrap import ( # noqa: E402 | |
| RAW_RELATION_RULES, | |
| canonicalize_relationship_frame, | |
| ) | |
| PRIMARY_RELATIONS = ("REPLACES", "REPEALS", "AMENDS") | |
| TITLE_PATTERNS = { | |
| "REPLACES": r"\bthay thế\b", | |
| "REPEALS": r"\bbãi bỏ\b|\bhết hiệu lực\b", | |
| "AMENDS": r"\bsửa đổi\b|\bbổ sung\b", | |
| } | |
| def sha256_file(path: Path) -> str: | |
| digest = hashlib.sha256() | |
| with path.open("rb") as handle: | |
| for chunk in iter(lambda: handle.read(1024 * 1024), b""): | |
| digest.update(chunk) | |
| return digest.hexdigest() | |
| def parse_vietnamese_date(series: pd.Series) -> pd.Series: | |
| return pd.to_datetime(series, format="%d/%m/%Y", errors="coerce") | |
| def _json_counts(series: pd.Series) -> dict[str, int]: | |
| values = series.fillna("<NULL>").astype(str).value_counts() | |
| return {str(key): int(value) for key, value in values.items()} | |
| def _document_url(doc_id: str) -> str: | |
| return ( | |
| f"https://vbpl.vn/TW/Pages/vbpq-toanvan.aspx?ItemID={doc_id}" | |
| if str(doc_id).isdigit() | |
| else "" | |
| ) | |
| def select_discovery_candidates( | |
| metadata: pd.DataFrame, | |
| canonical: pd.DataFrame, | |
| content_ids: set[str], | |
| *, | |
| per_relation: int, | |
| as_of: pd.Timestamp, | |
| ) -> pd.DataFrame: | |
| """Select deterministic, diverse discovery pairs; these are not gold edges.""" | |
| meta = metadata.copy() | |
| meta["document_date"] = parse_vietnamese_date(meta["ngay_co_hieu_luc"]).fillna( | |
| parse_vietnamese_date(meta["ngay_ban_hanh"]) | |
| ) | |
| source = meta.add_prefix("event_") | |
| target = meta.add_prefix("affected_") | |
| pairs = canonical[canonical["relation"].isin(PRIMARY_RELATIONS)].merge( | |
| source, | |
| left_on="event_doc_id", | |
| right_on="event_id", | |
| how="inner", | |
| ) | |
| pairs = pairs.merge( | |
| target, | |
| left_on="affected_doc_id", | |
| right_on="affected_id", | |
| how="inner", | |
| ) | |
| pairs["chronology_days"] = ( | |
| pairs["event_document_date"] - pairs["affected_document_date"] | |
| ).dt.days | |
| pairs = pairs[ | |
| pairs["event_doc_id"].isin(content_ids) | |
| & pairs["affected_doc_id"].isin(content_ids) | |
| & pairs["event_document_date"].notna() | |
| & pairs["affected_document_date"].notna() | |
| & pairs["chronology_days"].gt(0) | |
| & pairs["event_document_date"].le(as_of) | |
| & pairs["event_pham_vi"].isin({"Trung ương", "Toàn quốc"}) | |
| ].copy() | |
| pairs["event_year"] = pairs["event_document_date"].dt.year | |
| pairs = pairs[pairs["event_year"].between(2015, 2026)] | |
| pairs["year_band"] = pd.cut( | |
| pairs["event_year"], | |
| bins=[2014, 2018, 2022, 2026], | |
| labels=["2015-2018", "2019-2022", "2023-2026"], | |
| include_lowest=True, | |
| ).astype("string") | |
| pairs["domain"] = ( | |
| pairs["event_nganh"] | |
| .fillna(pairs["event_linh_vuc"]) | |
| .fillna("Chưa phân loại") | |
| .astype(str) | |
| ) | |
| pairs["title_signal"] = False | |
| for relation, pattern in TITLE_PATTERNS.items(): | |
| mask = pairs["relation"].eq(relation) | |
| pairs.loc[mask, "title_signal"] = pairs.loc[mask, "event_title"].str.contains( | |
| pattern, case=False, regex=True, na=False | |
| ) | |
| pairs["target_status_signal"] = pairs["affected_tinh_trang_hieu_luc"].isin( | |
| {"Hết hiệu lực toàn bộ", "Hết hiệu lực một phần", "Ngưng hiệu lực"} | |
| ) | |
| pairs = pairs.sort_values( | |
| [ | |
| "relation", | |
| "title_signal", | |
| "target_status_signal", | |
| "evidence_count", | |
| "event_document_date", | |
| "chronology_days", | |
| "event_doc_id", | |
| ], | |
| ascending=[True, False, False, False, False, True, True], | |
| ) | |
| selected_rows = [] | |
| for relation in PRIMARY_RELATIONS: | |
| group = pairs[pairs["relation"].eq(relation)] | |
| domain_counts: Counter[str] = Counter() | |
| event_ids: set[str] = set() | |
| base_quota = per_relation // 3 | |
| quotas = { | |
| "2015-2018": base_quota, | |
| "2019-2022": base_quota, | |
| "2023-2026": base_quota + per_relation % 3, | |
| } | |
| def consider(row) -> bool: | |
| if row.event_doc_id in event_ids or domain_counts[row.domain] >= 2: | |
| return False | |
| selected_rows.append(row._asdict()) | |
| event_ids.add(row.event_doc_id) | |
| domain_counts[row.domain] += 1 | |
| return True | |
| for band, quota in quotas.items(): | |
| added = 0 | |
| for row in group[group["year_band"].eq(band)].itertuples(index=False): | |
| if consider(row): | |
| added += 1 | |
| if added >= quota: | |
| break | |
| if len(event_ids) < per_relation: | |
| for row in group.itertuples(index=False): | |
| consider(row) | |
| if len(event_ids) >= per_relation: | |
| break | |
| selected = pd.DataFrame(selected_rows) | |
| if selected.empty: | |
| return selected | |
| selected.insert( | |
| 0, | |
| "candidate_id", | |
| [ | |
| f"{relation.lower()}:{event_id}:{affected_id}" | |
| for relation, event_id, affected_id in zip( | |
| selected["relation"], | |
| selected["event_doc_id"], | |
| selected["affected_doc_id"], | |
| strict=True, | |
| ) | |
| ], | |
| ) | |
| selected["event_source_url"] = selected["event_doc_id"].map(_document_url) | |
| selected["affected_source_url"] = selected["affected_doc_id"].map(_document_url) | |
| selected["verification_status"] = "unverified_discovery" | |
| keep = [ | |
| "candidate_id", | |
| "relation", | |
| "scope", | |
| "event_doc_id", | |
| "event_so_ky_hieu", | |
| "event_title", | |
| "event_loai_van_ban", | |
| "event_co_quan_ban_hanh", | |
| "event_ngay_ban_hanh", | |
| "event_ngay_co_hieu_luc", | |
| "year_band", | |
| "domain", | |
| "affected_doc_id", | |
| "affected_so_ky_hieu", | |
| "affected_title", | |
| "affected_loai_van_ban", | |
| "affected_ngay_ban_hanh", | |
| "affected_ngay_co_hieu_luc", | |
| "affected_ngay_het_hieu_luc", | |
| "affected_tinh_trang_hieu_luc", | |
| "chronology_days", | |
| "raw_labels", | |
| "direction_rules", | |
| "evidence_count", | |
| "title_signal", | |
| "target_status_signal", | |
| "event_source_url", | |
| "affected_source_url", | |
| "verification_status", | |
| ] | |
| return selected.loc[:, keep] | |
| def audit(args: argparse.Namespace) -> dict: | |
| metadata_path = args.metadata.resolve() | |
| relationships_path = args.relationships.resolve() | |
| content_path = args.content.resolve() | |
| metadata = pd.read_parquet(metadata_path) | |
| relationships = pd.read_parquet(relationships_path) | |
| canonical = canonicalize_relationship_frame(relationships) | |
| content_ids = set( | |
| pq.read_table(content_path, columns=["id"])["id"].to_pylist() | |
| ) | |
| metadata_dates = metadata.copy() | |
| metadata_dates["document_date"] = parse_vietnamese_date( | |
| metadata_dates["ngay_co_hieu_luc"] | |
| ).fillna(parse_vietnamese_date(metadata_dates["ngay_ban_hanh"])) | |
| event_dates = metadata_dates.loc[:, ["id", "document_date"]].rename( | |
| columns={"id": "event_doc_id", "document_date": "event_date"} | |
| ) | |
| affected_dates = metadata_dates.loc[:, ["id", "document_date"]].rename( | |
| columns={"id": "affected_doc_id", "document_date": "affected_date"} | |
| ) | |
| chronology = canonical.merge(event_dates, on="event_doc_id", how="left").merge( | |
| affected_dates, on="affected_doc_id", how="left" | |
| ) | |
| chronology["delta_days"] = ( | |
| chronology["event_date"] - chronology["affected_date"] | |
| ).dt.days | |
| chronology_stats = {} | |
| for relation, group in chronology.groupby("relation"): | |
| values = group["delta_days"].dropna() | |
| chronology_stats[str(relation)] = { | |
| "edges": int(len(group)), | |
| "dated_edges": int(len(values)), | |
| "event_later_fraction": float((values > 0).mean()) if len(values) else None, | |
| "same_day_fraction": float((values == 0).mean()) if len(values) else None, | |
| "median_delta_days": float(values.median()) if len(values) else None, | |
| } | |
| candidates = select_discovery_candidates( | |
| metadata, | |
| canonical, | |
| content_ids, | |
| per_relation=args.per_relation, | |
| as_of=pd.Timestamp(args.as_of), | |
| ) | |
| args.output_dir.mkdir(parents=True, exist_ok=True) | |
| canonical_path = args.output_dir / "canonical_relationships.parquet" | |
| candidates_path = args.output_dir / "discovery_candidates.csv" | |
| canonical.to_parquet(canonical_path, index=False, compression="zstd") | |
| candidates.to_csv(candidates_path, index=False) | |
| mapped_raw = relationships["relationship"].isin(RAW_RELATION_RULES) | |
| result = { | |
| "schema_version": 1, | |
| "artifact_type": "bootstrap_dataset_audit", | |
| "snapshot": { | |
| "repository": "th1nhng0/vietnamese-legal-documents", | |
| "revision": args.revision, | |
| "metadata_sha256": sha256_file(metadata_path), | |
| "relationships_sha256": sha256_file(relationships_path), | |
| "content_sha256": sha256_file(content_path), | |
| }, | |
| "metadata": { | |
| "rows": int(len(metadata)), | |
| "unique_ids": int(metadata["id"].nunique()), | |
| "content_ids": int(len(content_ids)), | |
| "metadata_without_content": int((~metadata["id"].isin(content_ids)).sum()), | |
| "effective_date_non_null": int(metadata["ngay_co_hieu_luc"].notna().sum()), | |
| "expiry_date_non_null": int(metadata["ngay_het_hieu_luc"].notna().sum()), | |
| "status_counts": _json_counts(metadata["tinh_trang_hieu_luc"]), | |
| "scope_counts": _json_counts(metadata["pham_vi"]), | |
| }, | |
| "relationships": { | |
| "raw_rows": int(len(relationships)), | |
| "raw_label_counts": _json_counts(relationships["relationship"]), | |
| "mapped_raw_rows": int(mapped_raw.sum()), | |
| "unmapped_raw_rows": int((~mapped_raw).sum()), | |
| "canonical_rows": int(len(canonical)), | |
| "canonical_relation_counts": _json_counts(canonical["relation"]), | |
| "multi_evidence_rows": int(canonical["evidence_count"].gt(1).sum()), | |
| "chronology": chronology_stats, | |
| "warning": ( | |
| "Canonical edges are discovery evidence only. Raw active/passive labels " | |
| "and chronology do not replace official/manual direction verification." | |
| ), | |
| }, | |
| "discovery_candidates": { | |
| "rows": int(len(candidates)), | |
| "relation_counts": _json_counts(candidates["relation"]) | |
| if not candidates.empty | |
| else {}, | |
| "path": str(candidates_path), | |
| "verification_status": "unverified_discovery", | |
| "as_of": args.as_of, | |
| }, | |
| "generated": { | |
| "canonical_relationships": str(canonical_path), | |
| "discovery_candidates": str(candidates_path), | |
| }, | |
| } | |
| output_json = args.output_dir / "audit.json" | |
| output_json.write_text( | |
| json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" | |
| ) | |
| return result | |
| def parse_args() -> argparse.Namespace: | |
| default_root = REPO_ROOT / "tmp/deep-research/telen/vn-legal-data/data" | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--metadata", type=Path, default=default_root / "metadata.parquet") | |
| parser.add_argument( | |
| "--relationships", type=Path, default=default_root / "relationships.parquet" | |
| ) | |
| parser.add_argument("--content", type=Path, default=default_root / "content.parquet") | |
| parser.add_argument( | |
| "--output-dir", | |
| type=Path, | |
| default=REPO_ROOT / "tmp/telen/bootstrap-audit", | |
| ) | |
| parser.add_argument( | |
| "--revision", default="8977887f17be2defae4c5171d55562e1cde7d695" | |
| ) | |
| parser.add_argument("--per-relation", type=int, default=10) | |
| parser.add_argument("--as-of", default="2026-08-29") | |
| return parser.parse_args() | |
| def main() -> None: | |
| args = parse_args() | |
| if args.per_relation <= 0: | |
| raise ValueError("--per-relation must be positive") | |
| result = audit(args) | |
| print(json.dumps(result, ensure_ascii=False, indent=2)) | |
| if __name__ == "__main__": | |
| main() | |