feat: 亞絲娜與直葉的角色包 (#6, #7)

build_pack.py 產生對白索引(放在使用者資料目錄);render_skill.py 把人格卡渲染成
角色 skill 並檢查精選對白逐字來自索引;插件改成執行期從資料目錄讀索引。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jeffery (jiantw83)
2026-10-14 20:52:40 +00:00
co-authored by Claude Sonnet 5.5
parent 352ce7dcad
commit 697ed6a0b9
15 changed files with 587 additions and 24 deletions
+98
View File
@@ -0,0 +1,98 @@
#!/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()