#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 聲請人歸屬標註 第 2 步:彙整與 QC ================================== 把 50_聲請人標註/全量/*.json 併成一張表,逐筆驗證證據句可逐字還原, 輸出可併檔的 CSV 與樣本流程檔。 python _pipeline\\聲請人_彙整.py 輸出於 50_聲請人標註/: 聲請人歸屬.csv 一筆確定終局裁判一列,可用「釋憲案+案號」與 40_案件歷程 併檔 聲請人歸屬.json 同上,機器可讀 sample_flow.json 樣本流程(各步驟之 n 與排除理由) QC報告.md 驗證結果 """ import csv, json, glob, os, re, sys, collections, unicodedata from pathlib import Path ROOT = Path(__file__).resolve().parent.parent OUT = ROOT / "50_聲請人標註" S_OK = {"S0", "S1", "S2", "S3", "S4"} D_OK = {"D0a", "D0b", "D1", "D2", "D3", "D4", "D5"} SN = {"S0": "不明", "S1": "自然人", "S2": "法人或團體", "S3": "法院或法官", "S4": "機關"} DN = {"D1": "直接指名", "D2": "兩段式對應", "D3": "單一聲請人當然歸屬", "D4": "群組已定個別未定", "D5": "原文明示非本件確定終局裁判", "D0a": "待查—原文未提及", "D0b": "待查—提及但不能確定"} def norm(s): return re.sub(r"[\s ]", "", unicodedata.normalize("NFKC", s or "")) def main(): try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass W = {w["k"]: w for w in json.load(open(OUT / "工作檔.json", encoding="utf-8"))} files = sorted(glob.glob(str(OUT / "全量" / "*.json"))) done = {os.path.basename(f)[:-5] for f in files} missing = sorted(set(W) - done) rows, bad = [], [] for f in files: k = os.path.basename(f)[:-5] rs = json.load(open(f, encoding="utf-8")) body = (OUT / "卡片" / f"{k}.txt").read_text(encoding="utf-8").split("=" * 78, 1)[-1] cn = norm(body) want = [c["案號"] for c in W[k]["cases"]] if len(rs) != len(want): bad.append(f"{k}:筆數 {len(rs)} 與清單 {len(want)} 不符") for i, r in enumerate(rs): r["釋憲案"] = k r["釋憲字號"] = W[k]["z"] rows.append(r) if i < len(want) and r.get("案號") != want[i]: bad.append(f"{k} idx{r.get('idx')}:案號與清單不符") if r.get("S") not in S_OK: bad.append(f"{k} idx{r.get('idx')}:非法 S 碼 {r.get('S')}") if r.get("D") not in D_OK: bad.append(f"{k} idx{r.get('idx')}:非法 D 碼 {r.get('D')}") if not r.get("證據句1"): bad.append(f"{k} idx{r.get('idx')}:無證據句") if r.get("D") != "D0a": for fld in ("證據句1", "證據句2"): ev = r.get(fld) or "" if not ev: continue parts = [p for p in re.split(r"…+|\.{3,}|⋯+", ev) if len(norm(p)) >= 8] if [p for p in parts if norm(p) not in cn]: bad.append(f"{k} idx{r.get('idx')}:{fld} 無法在原文逐字還原") with open(OUT / "聲請人歸屬.csv", "w", encoding="utf-8-sig", newline="") as fh: w = csv.writer(fh) w.writerow(["釋憲案", "釋憲字號", "確定終局裁判", "S碼", "聲請人身分別", "D碼", "歸屬確定度", "聲請人序號", "聲請人稱謂原文", "多人合稱", "D信心", "S信心", "證據句1", "證據句2", "註記"]) for r in rows: w.writerow([r["釋憲案"], r["釋憲字號"], r.get("案號", ""), r.get("S", ""), SN.get(r.get("S"), ""), r.get("D", ""), DN.get(r.get("D"), ""), r.get("聲請人序號", ""), r.get("聲請人稱謂原文", ""), r.get("多人合稱", ""), r.get("信心", ""), r.get("S信心", ""), r.get("證據句1", ""), r.get("證據句2", ""), r.get("註記", "")]) json.dump(rows, open(OUT / "聲請人歸屬.json", "w", encoding="utf-8"), ensure_ascii=False) dcnt = collections.Counter(r["D"] for r in rows) scnt = collections.Counter(r["S"] for r in rows) ided = sum(v for k, v in dcnt.items() if k in ("D1", "D2", "D3")) flow = { "釋憲案總數": 870, "官網可取得確定終局裁判字號者_件": len(W), "標註母體_裁判筆數": sum(len(w["cases"]) for w in W.values()), "已標註_筆數": len(rows), "未標註之釋憲案": missing, "排除_未取得裁判字號_件": 769, "D碼分布": dict(dcnt.most_common()), "S碼分布": dict(scnt.most_common()), "可個別歸屬_D1D2D3": ided, "群組層級_D4": dcnt.get("D4", 0), "非本件確定終局裁判_D5": dcnt.get("D5", 0), "待查_D0a_D0b": dcnt.get("D0a", 0) + dcnt.get("D0b", 0), "D信心低": sum(1 for r in rows if r.get("信心") == "低"), "S信心低": sum(1 for r in rows if r.get("S信心") == "低"), } json.dump(flow, open(OUT / "sample_flow.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1) rep = ["# 聲請人歸屬標註 QC 報告", "", f"- 標註件數:{len(done)} / {len(W)}", f"- 標註筆數:{len(rows)} / {flow['標註母體_裁判筆數']}", f"- 未標註之釋憲案:{missing or '無'}", f"- **QC 問題:{len(bad)} 項**", ""] if bad: rep += [f"- {b}" for b in bad[:60]] rep += ["", "## D 碼分布(歸屬確定度)", "", "| 碼 | 名稱 | 筆數 | 占比 |", "|---|---|---|---|"] for d, n in sorted(dcnt.items()): rep.append(f"| {d} | {DN[d]} | {n} | {n/len(rows):.1%} |") rep += ["", "## S 碼分布(聲請人身分別)", "", "| 碼 | 名稱 | 筆數 | 占比 |", "|---|---|---|---|"] for s, n in sorted(scnt.items()): rep.append(f"| {s} | {SN[s]} | {n} | {n/len(rows):.1%} |") rep += ["", "## 交叉表 S × D", "", "| S\D | " + " | ".join(sorted(dcnt)) + " |", "|---" * (len(dcnt) + 1) + "|"] cross = collections.Counter((r["S"], r["D"]) for r in rows) for s in sorted(scnt): rep.append(f"| {s} {SN[s]} | " + " | ".join(str(cross.get((s, d), 0)) for d in sorted(dcnt)) + " |") rep += ["", "## 驗證方法", "", "1. 筆數與 `確定終局裁判.csv` 清單逐案比對,案號逐字比對。", "2. S/D 碼值域檢查。", "3. 每則證據句以「……」切段後,逐段確認為官網原文(解釋文/主文+理由書/理由)之連續子字串。", " D0a 之證據句為固定語句,免驗。", "4. 標註卡片不含任何既有碼,標註者無從確認前次結果。"] (OUT / "QC報告.md").write_text("\n".join(rep) + "\n", encoding="utf-8") print(f"標註 {len(done)}/{len(W)} 件、{len(rows)}/{flow['標註母體_裁判筆數']} 筆") print(f"QC 問題:{len(bad)} 項") print("D 碼:", dict(dcnt.most_common())) print("S 碼:", dict(scnt.most_common())) print("輸出:", OUT / "聲請人歸屬.csv") if __name__ == "__main__": main()