#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 全文抽取 第 1 步:建卡片 ========================= 把 870 件釋憲案的官網原文全部區塊(含相關文件/聲請書)做成標註卡片, 並依字數做平衡分批。 python _pipeline\\全文抽取_建卡片.py 輸出於 60_全文抽取/: 卡片/<釋憲案>.txt 標註時實際閱讀的全文(不含任何既有碼) 工作檔.json 每件的 meta 與字數 批次.json 平衡分批結果 進度.csv 逐件進度追蹤 """ import csv, json, sys, collections from pathlib import Path ROOT = Path(__file__).resolve().parent.parent PIPE = ROOT / "_pipeline" OUT = ROOT / "60_全文抽取" CARD = OUT / "卡片" # 全部區塊都要,順序固定,讓卡片在各件間長得一樣 ORDER = ["解釋文", "主文", "理由書", "理由", "相關文件", "判決摘要", "主筆大法官記載", "大法官就主文所採立場表"] # 憲判字的署名列不在官網頁面的區塊結構內(落在最末一段編號之後), # 建庫時被解析器丟掉。以 60_全文抽取/補收/署名列_彙整.json 補回。 # 釋字的署名列在「理由書」末段,不受影響。 ROSTER = OUT / "補收" / "署名列_彙整.json" # 意見書全文合計 1,941 萬字,是本文的 4.5 倍,不放進卡片; # 但標題本身載明「某大法官提出、某大法官加入」,是意見結盟的直接證據, # 且僅約 1,680 行,故以清單形式收進卡片。 TARGET = 55_000 # 每批目標字數 def main(): try: sys.stdout.reconfigure(encoding="utf-8") except Exception: pass dbp = PIPE / "資料庫.json" if not dbp.exists(): print("找不到", dbp) sys.exit(1) recs = json.load(open(dbp, encoding="utf-8")) roster = json.load(open(ROSTER, encoding="utf-8")) if ROSTER.exists() else {} OUT.mkdir(exist_ok=True) CARD.mkdir(exist_ok=True) work = [] for r in recs: secs = {lab: ps for lab, ps in r["s"]} parts = [] for lab in ORDER: if lab not in secs: continue body = "\n".join((f"({n})" if n else "") + t for n, t in secs[lab]) parts.append(f"【{lab}】\n{body}") # 補收之署名列(僅憲判字需要) rr = roster.get(r["k"]) if rr and rr.get("署名列原文"): parts.append("【署名列(自官網補收)】\n" + rr["署名列原文"] + "\n※本區塊係建庫後自官網原文補收," "經 markdown 轉換,空白排版與官網 HTML 未必逐字相同;" "姓名與順序為逐字照錄。" + (f"\n※迴避記載:{rr['迴避原文']}" if rr.get("迴避原文") else "")) # 意見書清單(僅標題,全文不入卡片) ops = r.get("o") or [] if ops: lines = "\n".join(f"({i}){x.get('t','')}" for i, x in enumerate(ops, 1)) parts.append(f"【意見書清單】({len(ops)} 篇,僅列標題,全文不入卡片)\n" + lines) extra = [lab for lab in secs if lab not in ORDER] for lab in extra: body = "\n".join((f"({n})" if n else "") + t for n, t in secs[lab]) parts.append(f"【{lab}】\n{body}") text = "\n\n".join(parts) head = (f"釋憲案 {r['k']} {r.get('z','')}" + (f"【{r.get('n')}】" if r.get("n") else "") + f"\n類型 {r.get('ty','')} 公布 {r.get('roc') or r.get('dt','')}" + (f" 令號 {r.get('decree')}" if r.get("decree") else "") + f"\n官網原文 {r.get('u','')}" + f"\n本卡片區塊 { '、'.join(lab for lab in ORDER if lab in secs) }" + (" +署名列(補收)" if roster.get(r["k"]) else "") + (f" +意見書清單({len(r.get('o') or [])}篇)" if r.get("o") else "") + (f"(另有:{'、'.join(extra)})" if extra else "") + f"\n相關法條(官網欄位,供參) {'、'.join(r.get('l') or [])[:400]}" + "\n" + "=" * 78 + "\n") (CARD / f"{r['k']}.txt").write_text(head + text + "\n", encoding="utf-8") work.append({"k": r["k"], "z": r.get("z", ""), "n": r.get("n", ""), "ty": r.get("ty", ""), "u": r.get("u", ""), "roc": r.get("roc") or r.get("dt", ""), "區塊": [lab for lab in ORDER if lab in secs] + extra, "有相關文件": "相關文件" in secs, "有署名列補收": bool(roster.get(r["k"])), "意見書篇數": len(r.get("o") or []), "chars": len(text)}) json.dump(work, open(OUT / "工作檔.json", "w", encoding="utf-8"), ensure_ascii=False) # 平衡分批:先降冪,再貪心裝箱 order = sorted(work, key=lambda w: -w["chars"]) bins = [] for w in order: b = min(bins, key=lambda x: x["c"]) if bins else None if b is None or (b["c"] + w["chars"] > TARGET and len(bins) < 400): bins.append({"ks": [w["k"]], "c": w["chars"]}) else: b["ks"].append(w["k"]) b["c"] += w["chars"] bins.sort(key=lambda x: -x["c"]) json.dump([b["ks"] for b in bins], open(OUT / "批次.json", "w", encoding="utf-8"), ensure_ascii=False) with open(OUT / "進度.csv", "w", encoding="utf-8-sig", newline="") as fh: w = csv.writer(fh) w.writerow(["批次", "釋憲案", "字數", "有相關文件", "狀態"]) for i, b in enumerate(bins, 1): for k in b["ks"]: it = next(x for x in work if x["k"] == k) w.writerow([f"B{i:03d}", k, it["chars"], int(it["有相關文件"]), "未標註"]) ch = sorted(x["chars"] for x in work) print(f"卡片 {len(work)} 件,合計 {sum(ch):,} 字") print(f"字數 中位 {ch[len(ch)//2]:,}/最長 {ch[-1]:,}/最短 {ch[0]:,}") print(f"有相關文件(聲請書)者 {sum(1 for x in work if x['有相關文件'])} 件") print(f"補收署名列 {sum(1 for x in work if x['有署名列補收'])} 件;" f"意見書清單 {sum(1 for x in work if x['意見書篇數'])} 件/" f"{sum(x['意見書篇數'] for x in work)} 篇") print(f"分批 {len(bins)} 批,每批目標 {TARGET:,} 字," f"實際最大 {bins[0]['c']:,}/最小 {bins[-1]['c']:,}") print("卡片在", CARD) if __name__ == "__main__": main()