build_pack.py 產生對白索引(放在使用者資料目錄);render_skill.py 把人格卡渲染成 角色 skill 並檢查精選對白逐字來自索引;插件改成執行期從資料目錄讀索引。 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
352ce7dcad
commit
697ed6a0b9
@@ -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)。
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user