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
+17
View File
@@ -88,3 +88,20 @@ python3 tools/corpus/merge_labels.py ~/anime-companion-data/sao/dialogues.jsonl
- 多個 agent 並行時共用暫存目錄會互相覆蓋腳本,所以 `verify_batches.py` 逐批比對 id;全部一致。
- wiki 的部分頁面有重複或錯置的段落,標註 agent 對這類句子多半降為 medium 或 low,不會進入採用的 2,340 句。
- `mode`(亞絲娜的「被囚禁/重逢」、直葉的「現實/莉法」)目前只有規則依別名推得的少數句子有值,大多數是空的;W5、W6 要另外依章節情境補上。
## 4. 角色包(W5、W6)
從合併後的標註產生每個角色的對白索引,再寫人格卡並渲染成角色 skill:
```bash
python3 tools/corpus/build_pack.py ~/anime-companion-data/sao/labeling/final.jsonl ~/.claude/anime-companion
# → ~/.claude/anime-companion/<角色代號>/index.jsonl(執行期檢索用)與 source.jsonl(寫人格卡用,附前文)
# 人格卡由 sub-agent 讀 source.jsonl 歸納,存到 ~/anime-companion-data/sao/personas/<角色代號>.md
python3 tools/corpus/render_skill.py ~/anime-companion-data/sao/personas/sao-asuna.md ~/.claude/anime-companion/sao-asuna/index.jsonl plugins/anime-companion/skills/sao-asuna/SKILL.md --code sao-asuna
```
- 入選條件:說話者明確、`confidence=high`、長度 4~80 字、非疑似重複、同句去重;`other` 且少於 6 字的丟掉。
- 模式標籤由前後文判定,沒判定出來的沿用同一章的多數模式。亞絲娜:`ingame`、`captive`、`reunion`、`real`;直葉:`leafa`(莉法)、`real`(直葉)。這是啟發式,不是人工標註。
- `render_skill.py` 會檢查人格卡「精選對白」的每一句都逐字來自索引,不一致就失敗。
- 索引在使用者資料目錄,不入 repo。人格卡(`SKILL.md`)入庫,內含各 38 句、每句不超過 80 字的精選對白。
- 目前結果:亞絲娜 255 句(ingame 189、reunion 35、captive 20、real 11),直葉 423 句(leafa 344、real 79)。
+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()
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""把人格卡渲染成角色 skill(SKILL.md),並檢查精選對白都逐字來自對白索引。
用法:render_skill.py <人格卡.md> <index.jsonl> <輸出 SKILL.md> --code sao-asuna
"""
import argparse
import json
import re
import sys
from pathlib import Path
CHARACTERS = {
"sao-asuna": {
"name": "亞絲娜", "address": "桐人",
"description": "啟動《刀劍神域》的亞絲娜作為動漫助理,用她的個性與語氣和使用者對話並協助做事。使用者要求切換成亞絲娜時使用。",
},
"sao-suguha": {
"name": "直葉", "address": "哥哥",
"description": "啟動《刀劍神域》的直葉(莉法)作為動漫助理,用她的個性與語氣和使用者對話並協助做事。使用者要求切換成直葉或莉法時使用。",
},
}
QUOTE_LINE = re.compile(r"^- \[(?P<id>v\d+c\d+-\d+)\] [^:]*:「(?P<text>.*)」\s*$")
def selected_quotes(card):
"""精選對白一節裡的 (id, 原句)。"""
section = card.split("## 精選對白", 1)[1] if "## 精選對白" in card else ""
return [(m["id"], m["text"]) for m in map(QUOTE_LINE.match, section.splitlines()) if m]
def check_quotes(card, index_rows):
by_id = {r["id"]: r["text"] for r in index_rows}
return [(i, t) for i, t in selected_quotes(card) if by_id.get(i) != t]
def render(card, code):
c = CHARACTERS[code]
body = re.sub(r"^## ", "### ", card.strip(), flags=re.M)
return f"""---
name: {code}
description: {c["description"]}
---
# {c["name"]}({code})
你現在以《刀劍神域》的「{c["name"]}」的個性與語氣和使用者對話,同時照常協助使用者做事。稱呼使用者為「{c["address"]}」,除非設定檔的「稱呼」另有指定。強度與氣泡、旁白的格式依系統提示中的規則為準。
- 角色語氣只套在對使用者說的話;程式碼、commit 與檔案內容保持中性。
- 錯誤、風險與需要使用者確認的事,先用一句清楚的話說完,再加角色語氣。
- 啟動時簡短告知角色已切換,不要念出下面的人格卡。
- 括號中的編號(例如 v1c10-0033)是素材編號,只供對照,不要向使用者提到。
- 下面的人格卡由小說譯本歸納而成;素材不足的地方不要自行發揮。
## 人格卡
{body}
"""
def main():
ap = argparse.ArgumentParser()
ap.add_argument("card")
ap.add_argument("index")
ap.add_argument("out")
ap.add_argument("--code", required=True, choices=sorted(CHARACTERS))
a = ap.parse_args()
card = Path(a.card).read_text(encoding="utf-8")
rows = [json.loads(l) for l in Path(a.index).read_text(encoding="utf-8").splitlines() if l.strip()]
bad = check_quotes(card, rows)
if bad:
print("精選對白不在索引中或與索引不一致:", bad, file=sys.stderr)
sys.exit(1)
Path(a.out).write_text(render(card, a.code), encoding="utf-8")
print(f"{a.code}: {len(selected_quotes(card))} 句精選對白 -> {a.out}")
if __name__ == "__main__":
main()
+54
View File
@@ -0,0 +1,54 @@
import unittest
import build_pack
def rec(i, speaker="asuna", text="今天也請多指教喔", conf="high", fn="greet", vol=1, ch=1, **kw):
base = {"id": i, "volume": vol, "chapter": ch, "text": text, "before": "", "after": "", "prev_par": "", "next_par": "",
"final_speaker": speaker, "suspect_duplicate": False,
"label": {"confidence": conf, "function": fn, "emotion": "neutral"}}
base.update(kw)
return base
class Usable(unittest.TestCase):
def test_keeps_high_confidence_of_the_speaker(self):
self.assertTrue(build_pack.usable(rec("a"), "asuna"))
def test_rejects_other_speaker_low_confidence_duplicates_and_bad_length(self):
self.assertFalse(build_pack.usable(rec("a", speaker="kirito"), "asuna"))
self.assertFalse(build_pack.usable(rec("a", conf="medium"), "asuna"))
self.assertFalse(build_pack.usable(rec("a", suspect_duplicate=True), "asuna"))
self.assertFalse(build_pack.usable(rec("a", text="嗯"), "asuna"))
self.assertFalse(build_pack.usable(rec("a", text="字" * 81), "asuna"))
def test_short_other_is_dropped(self):
self.assertFalse(build_pack.usable(rec("a", text="咦咦咦咦", fn="other"), "asuna"))
class Modes(unittest.TestCase):
def test_asuna_modes(self):
self.assertEqual(build_pack.mode_asuna(rec("a", vol=1)), "ingame")
self.assertEqual(build_pack.mode_asuna(rec("a", vol=4)), "reunion")
self.assertEqual(build_pack.mode_asuna(rec("a", vol=4, before="明日奈露出笑容")), "real")
self.assertEqual(build_pack.mode_asuna(rec("a", vol=4, prev_par="須鄉冷笑著")), "captive")
def test_suguha_modes(self):
self.assertEqual(build_pack.mode_suguha(rec("a", before="莉法拔出劍")), "leafa")
self.assertEqual(build_pack.mode_suguha(rec("a", before="直葉看著和人")), "real")
self.assertIsNone(build_pack.mode_suguha(rec("a")))
class Build(unittest.TestCase):
def test_dedupes_by_text_and_fills_mode_from_chapter_majority(self):
rows = build_pack.build([
rec("a", speaker="suguha", text="我一定會贏的喔", before="莉法握緊劍", vol=3, ch=2),
rec("b", speaker="suguha", text="我一定會贏的喔", vol=3, ch=2),
rec("c", speaker="suguha", text="那我們走吧", vol=3, ch=2),
], "suguha")
self.assertEqual([r["id"] for r in rows], ["a", "c"])
self.assertEqual(rows[1]["mode"], "leafa")
if __name__ == "__main__":
unittest.main()
+35
View File
@@ -0,0 +1,35 @@
import unittest
import render_skill
CARD = """## 語氣與口癖
- 例 (v1c01-0001)
## 精選對白
- [v1c01-0001] greet/joy:「好久不見了,艾基爾」
- [v1c01-0002] tease/neutral:「你穿得一身黑有什麼理由嗎?」
"""
ROWS = [{"id": "v1c01-0001", "text": "好久不見了,艾基爾"}, {"id": "v1c01-0002", "text": "你穿得一身黑有什麼理由嗎?"}]
class Render(unittest.TestCase):
def test_selected_quotes(self):
self.assertEqual(render_skill.selected_quotes(CARD)[0], ("v1c01-0001", "好久不見了,艾基爾"))
self.assertEqual(len(render_skill.selected_quotes(CARD)), 2)
def test_check_quotes_accepts_verbatim_and_flags_changes(self):
self.assertEqual(render_skill.check_quotes(CARD, ROWS), [])
self.assertEqual(len(render_skill.check_quotes(CARD, [ROWS[0], {"id": "v1c01-0002", "text": "改過的句子"}])), 1)
self.assertEqual(len(render_skill.check_quotes(CARD, ROWS[:1])), 1)
def test_render_has_frontmatter_demotes_headings_and_keeps_rules(self):
out = render_skill.render(CARD, "sao-suguha")
self.assertTrue(out.startswith("---\nname: sao-suguha\n"))
self.assertIn("### 語氣與口癖", out)
self.assertNotIn("\n## 精選對白", out)
self.assertIn("哥哥", out)
self.assertIn("先用一句清楚的話說完", out)
if __name__ == "__main__":
unittest.main()