build_pack.py 產生對白索引(放在使用者資料目錄);render_skill.py 把人格卡渲染成 角色 skill 並檢查精選對白逐字來自索引;插件改成執行期從資料目錄讀索引。 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
99 lines
3.6 KiB
Python
99 lines
3.6 KiB
Python
#!/usr/bin/env python3
|
||
"""從合併後的標註產生各角色的對白索引與人格卡的素材。
|
||
|
||
用法:build_pack.py <final.jsonl> <資料目錄>
|
||
|
||
每個角色輸出到 <資料目錄>/<角色代號>/:
|
||
- index.jsonl:執行期檢索用,一行一句 {id, function, emotion, mode, text}。
|
||
- source.jsonl:撰寫人格卡用,同上再附前文與冊章。
|
||
|
||
資料目錄放在 repo 之外(預設 ~/.claude/anime-companion);原文不入版本控制。
|
||
"""
|
||
import json
|
||
import sys
|
||
from collections import Counter, defaultdict
|
||
from pathlib import Path
|
||
|
||
CHARACTERS = {"sao-asuna": "asuna", "sao-suguha": "suguha"}
|
||
MIN_LEN, MAX_LEN = 4, 80
|
||
CAPTIVE_WORDS = ("提泰妮亞", "蒂塔妮亞", "提坦妮亞", "須鄉", "奧伯龍", "鳥籠", "世界樹")
|
||
REAL_SUGUHA = ("直葉", "小直", "和人", "哥哥")
|
||
|
||
|
||
def context(rec):
|
||
return "".join((rec["before"], rec["after"], rec["prev_par"], rec["next_par"]))
|
||
|
||
|
||
def mode_asuna(rec):
|
||
ctx = context(rec)
|
||
if "明日奈" in ctx:
|
||
return "real"
|
||
if any(w in ctx for w in CAPTIVE_WORDS):
|
||
return "captive"
|
||
return {1: "ingame", 3: "captive", 4: "reunion"}.get(rec["volume"])
|
||
|
||
|
||
def mode_suguha(rec):
|
||
ctx = context(rec)
|
||
leafa, real = ctx.count("莉法"), sum(ctx.count(w) for w in REAL_SUGUHA)
|
||
if leafa == 0 and real == 0:
|
||
return None
|
||
return "leafa" if leafa >= real else "real"
|
||
|
||
|
||
MODE_FUNCS = {"asuna": mode_asuna, "suguha": mode_suguha}
|
||
|
||
|
||
def usable(rec, speaker):
|
||
text = rec["text"]
|
||
return (
|
||
rec["final_speaker"] == speaker
|
||
and rec["label"]["confidence"] == "high"
|
||
and MIN_LEN <= len(text) <= MAX_LEN
|
||
and not rec["suspect_duplicate"]
|
||
and not (rec["label"]["function"] == "other" and len(text) < 6)
|
||
)
|
||
|
||
|
||
def build(records, speaker):
|
||
seen, rows = set(), []
|
||
for rec in records:
|
||
if not usable(rec, speaker) or rec["text"] in seen:
|
||
continue
|
||
seen.add(rec["text"])
|
||
rows.append(rec)
|
||
# 沒判斷出模式的句子,沿用同一章其他句子的多數模式
|
||
modes = [MODE_FUNCS[speaker](r) for r in rows]
|
||
by_chapter = defaultdict(Counter)
|
||
for r, m in zip(rows, modes):
|
||
if m:
|
||
by_chapter[(r["volume"], r["chapter"])][m] += 1
|
||
out = []
|
||
for r, m in zip(rows, modes):
|
||
if m is None and by_chapter[(r["volume"], r["chapter"])]:
|
||
m = by_chapter[(r["volume"], r["chapter"])].most_common(1)[0][0]
|
||
lb = r["label"]
|
||
out.append({"id": r["id"], "function": lb["function"], "emotion": lb["emotion"], "mode": m, "text": r["text"],
|
||
"_ctx": {"before": r["before"], "prev_par": r["prev_par"], "volume": r["volume"], "chapter": r["chapter"]}})
|
||
return out
|
||
|
||
|
||
def write_pack(rows, folder):
|
||
folder.mkdir(parents=True, exist_ok=True)
|
||
dump = lambda items: "".join(json.dumps(i, ensure_ascii=False) + "\n" for i in items)
|
||
(folder / "index.jsonl").write_text(dump({k: v for k, v in r.items() if k != "_ctx"} for r in rows), encoding="utf-8")
|
||
(folder / "source.jsonl").write_text(dump({**{k: v for k, v in r.items() if k != "_ctx"}, **r["_ctx"]} for r in rows), encoding="utf-8")
|
||
|
||
|
||
def main():
|
||
final, data = Path(sys.argv[1]), Path(sys.argv[2])
|
||
records = [json.loads(line) for line in final.read_text(encoding="utf-8").splitlines()]
|
||
for code, speaker in CHARACTERS.items():
|
||
rows = build(records, speaker)
|
||
write_pack(rows, data / code)
|
||
print(f"{code}: {len(rows)} 句;功能 {dict(Counter(r['function'] for r in rows).most_common(5))};模式 {dict(Counter(r['mode'] for r in rows))}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|