feat: 標註工具與試跑 (#5)

標籤白名單、標註指示、批次切分、合併與檢查、抽查清單,以及 100 句試跑結果。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jeffery (jiantw83)
2026-10-14 17:35:09 +00:00
co-authored by Claude Sonnet 5.5
parent 9cf8196a3e
commit c85e00a56e
6 changed files with 329 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""合併標註結果、檢查標籤白名單,並和規則判定比對。
用法:merge_labels.py <dialogues.jsonl> <標註輸出目錄> <合併後 jsonl> [--review-dir DIR]
- 標註的 speaker 只有 confidence=high 才採用;unknown、none(非說出口的話)與其他信心都視為未判定。
- 標籤不在 tags.json 白名單內的整筆丟棄並列出。
- 以規則判定為高信心的句子,和標註比對一致率,用來估計兩邊的可靠度。
- --review-dir:每個角色輸出 20 句抽查清單(markdown),供人工檢查歸屬。
"""
import argparse
import json
import random
from collections import Counter
from pathlib import Path
TAGS = json.loads(Path(__file__).with_name("tags.json").read_text(encoding="utf-8"))
KEYS = {"id", "speaker", "confidence", "function", "emotion", "evidence"}
def read_jsonl(path):
return [json.loads(line) for line in Path(path).read_text(encoding="utf-8").splitlines() if line.strip()]
def check(label):
errs = []
if set(label) != KEYS:
errs.append(f"欄位不符:{sorted(set(label) ^ KEYS)}")
if label.get("function") not in TAGS["functions"]:
errs.append(f"function 不在白名單:{label.get('function')}")
if label.get("emotion") not in TAGS["emotions"]:
errs.append(f"emotion 不在白名單:{label.get('emotion')}")
if label.get("confidence") not in ("high", "medium", "low"):
errs.append(f"confidence 無效:{label.get('confidence')}")
return errs
def main():
ap = argparse.ArgumentParser()
ap.add_argument("dialogues")
ap.add_argument("labels_dir")
ap.add_argument("out")
ap.add_argument("--review-dir")
a = ap.parse_args()
recs = {r["id"]: r for r in read_jsonl(a.dialogues)}
labels, rejected = {}, []
for f in sorted(Path(a.labels_dir).glob("*.jsonl")):
for lb in read_jsonl(f):
errs = check(lb)
if lb.get("id") not in recs:
errs.append("id 不存在")
if errs:
rejected.append((lb.get("id"), errs))
else:
labels[lb["id"]] = lb
merged = []
for rid, lb in labels.items():
r = recs[rid]
final = lb["speaker"] if lb["confidence"] == "high" and lb["speaker"] not in ("unknown", "none") else None
merged.append({**r, "label": lb, "final_speaker": final})
Path(a.out).write_text("".join(json.dumps(m, ensure_ascii=False) + "\n" for m in merged), encoding="utf-8")
agree = Counter()
for m in merged:
r = m
if r["confidence"] in ("high", "medium"):
agree[(r["confidence"], r["speaker"] == m["label"]["speaker"])] += 1
print(f"標註 {len(labels)} 句,丟棄 {len(rejected)} 筆")
for rid, errs in rejected[:10]:
print(" 丟棄", rid, errs)
print("標註採用(high 且非 unknown):", sum(1 for m in merged if m["final_speaker"]), "/", len(merged))
print("標註信心:", dict(Counter(m["label"]["confidence"] for m in merged)))
print("非說出口的「」(none):", sum(1 for m in merged if m["label"]["speaker"] == "none"))
for conf in ("high", "medium"):
ok, bad = agree[(conf, True)], agree[(conf, False)]
if ok + bad:
print(f"規則 {conf} 與標註一致:{ok}/{ok + bad}")
if a.review_dir:
out = Path(a.review_dir)
out.mkdir(parents=True, exist_ok=True)
rng = random.Random(11)
for who in ("asuna", "suguha"):
pool = [m for m in merged if m["final_speaker"] == who]
rows = rng.sample(pool, min(20, len(pool)))
lines = [f"# 抽查:{who}({len(pool)} 句採用,抽 {len(rows)})", "", "每句請看:說話者是否正確?不對的請在行尾標記。", ""]
for m in rows:
ctx = (m["before"] or m["prev_par"])[-60:]
lines += [f"- `{m['id']}`「{m['text'][:60]}」", f" - 前文:{ctx}", f" - 標註:{m['label']['function']}/{m['label']['emotion']};依據:{m['label']['evidence']}"]
(out / f"{who}.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
print("抽查清單 ->", out)
if __name__ == "__main__":
main()