Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3523f3f92f | ||
|
|
48de0f4fa6 | ||
|
|
98d3f54f45 | ||
|
|
db2735d9c8 | ||
|
|
cfb97d2125 | ||
|
|
697ed6a0b9 | ||
|
|
352ce7dcad | ||
|
|
ea6b9ca7ef | ||
|
|
770928aef6 | ||
|
|
d9e50d196b | ||
|
|
f02232a373 | ||
|
|
c85e00a56e |
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "jsc-plugins",
|
||||
"description": "JSC 動漫助理 Claude Code mod 的 marketplace。",
|
||||
"owner": {
|
||||
"name": "JSC"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "anime-companion",
|
||||
"source": "./plugins/anime-companion",
|
||||
"description": "用輕小說角色的個性與語氣取代 Claude 與你對話,並照常協助你做事;以 anime-companion:{作品代號}-{角色代號} 啟動角色"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
node_modules/
|
||||
.env
|
||||
# 輕小說原文與建置中間檔放在 repo 之外,不入版本控制
|
||||
/corpus/
|
||||
/build/
|
||||
__pycache__/
|
||||
@@ -1,18 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Jeffery (jiantw83)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,46 +0,0 @@
|
||||
<p align="center"><img src="assets/icon.png" alt="jsc-plugins" width="128"></p>
|
||||
|
||||
# jsc-plugins
|
||||
|
||||
JSC 動漫助理 Claude Code mod 的 marketplace。
|
||||
|
||||
## 描述
|
||||
|
||||
用輕小說角色的個性與語氣取代 Claude 與你對話,並照常協助你做事。
|
||||
|
||||
- 插件 `anime-companion`:每個角色是一個 skill,以 `anime-companion:{作品代號}-{角色代號}` 啟動,例如 `anime-companion:sao-asuna`、`anime-companion:sao-suguha`。
|
||||
- 人格卡加對白檢索:依這一輪的溝通功能(安慰、吐槽、報告失敗等)檢索角色的對白當範例,讓語氣遷移到工程情境。
|
||||
- 強度 0~3 可調;錯誤、風險與需確認的事一律先講清楚,再加角色語氣。
|
||||
- 角色語氣只套在對你說的話,程式碼、commit 與檔案內容保持中性。
|
||||
- 輕小說原文與建置中間檔不入此 repo。
|
||||
|
||||
狀態:規劃中,規格與工作項目見 [議題](https://gitea.jsc.idv.tw/jiantw83/jsc-plugins/issues)。
|
||||
|
||||
## 安裝
|
||||
|
||||
先加入 marketplace,再安裝插件:
|
||||
|
||||
```bash
|
||||
claude plugin marketplace add https://gitea.jsc.idv.tw/jiantw83/jsc-plugins.git
|
||||
claude plugin install anime-companion@jsc-plugins
|
||||
```
|
||||
|
||||
## 更新
|
||||
|
||||
```bash
|
||||
claude plugin marketplace update jsc-plugins
|
||||
claude plugin update anime-companion@jsc-plugins
|
||||
```
|
||||
|
||||
更新後需重新啟動 Claude Code 才會生效。
|
||||
|
||||
## 移除
|
||||
|
||||
```bash
|
||||
claude plugin uninstall anime-companion@jsc-plugins
|
||||
claude plugin marketplace remove jsc-plugins
|
||||
```
|
||||
|
||||
## 授權
|
||||
|
||||
[MIT License](LICENSE)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.9 KiB |
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"name": "anime-companion",
|
||||
"version": "0.0.1",
|
||||
"description": "用輕小說角色的個性與語氣取代 Claude 與你對話,並照常協助你做事。以 anime-companion:{作品代號}-{角色代號} 啟動角色",
|
||||
"license": "MIT",
|
||||
"skills": "./skills",
|
||||
"author": {
|
||||
"name": "JSC"
|
||||
},
|
||||
"keywords": ["anime", "light-novel", "persona", "companion"],
|
||||
"userConfig": {
|
||||
"defaultCharacter": {
|
||||
"type": "string",
|
||||
"title": "預設角色",
|
||||
"description": "新 session 自動套用的角色;none 表示不自動套用,需用角色 skill 啟動。",
|
||||
"default": "none",
|
||||
"options": ["none", "sao-asuna", "sao-suguha"]
|
||||
},
|
||||
"intensity": {
|
||||
"type": "string",
|
||||
"title": "強度",
|
||||
"description": "0 不套角色;1 只換稱呼與句尾;2 前後各一個對話氣泡;3 再加動作旁白。",
|
||||
"default": "2",
|
||||
"options": ["0", "1", "2", "3"]
|
||||
},
|
||||
"address": {
|
||||
"type": "string",
|
||||
"title": "稱呼",
|
||||
"description": "角色怎麼稱呼你。留空則依角色與使用者在原作中的關係(亞絲娜叫「桐人」、直葉叫「哥哥」)。",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
# anime-companion
|
||||
|
||||
用輕小說角色的個性與語氣取代 Claude 與你對話,並照常協助你做事。
|
||||
|
||||
狀態:骨架(0.0.1)。人格卡與對白檢索尚未建置,進度見 [議題](https://gitea.jsc.idv.tw/jiantw83/jsc-plugins/issues)。
|
||||
|
||||
## 安裝
|
||||
|
||||
```bash
|
||||
claude plugin marketplace add https://gitea.jsc.idv.tw/jiantw83/jsc-plugins.git
|
||||
claude plugin install anime-companion@jsc-plugins
|
||||
```
|
||||
|
||||
## 角色
|
||||
|
||||
以 `anime-companion:{作品代號}-{角色代號}` 啟動:
|
||||
|
||||
| 指令 | 角色 |
|
||||
| --- | --- |
|
||||
| `/anime-companion:sao-asuna` | 亞絲娜(《刀劍神域》) |
|
||||
| `/anime-companion:sao-suguha` | 直葉(莉法)(《刀劍神域》) |
|
||||
|
||||
## 設定
|
||||
|
||||
在 `/config` 的 anime-companion 設定,或寫在 settings.json 的 `pluginConfigs`。
|
||||
|
||||
| 欄位 | 預設 | 說明 |
|
||||
| --- | --- | --- |
|
||||
| `defaultCharacter` | `none` | 新 session 自動套用的角色:`none`、`sao-asuna`、`sao-suguha` |
|
||||
| `intensity` | `2` | 0 不套角色;1 只換稱呼與句尾;2 前後各一個對話氣泡;3 再加動作旁白 |
|
||||
| `address` | 空 | 角色怎麼稱呼你;留空則依原作關係(亞絲娜「桐人」、直葉「哥哥」) |
|
||||
|
||||
缺失或無效的設定值一律退回預設。
|
||||
|
||||
## 開發
|
||||
|
||||
```bash
|
||||
claude plugin validate plugins/anime-companion
|
||||
claude plugin test plugins/anime-companion
|
||||
```
|
||||
@@ -1,11 +0,0 @@
|
||||
// 角色清單:代號為 {作品代號}-{角色代號},與 skills/ 下的資料夾同名。
|
||||
export const CHARACTERS = {
|
||||
'sao-asuna': { name: '亞絲娜', address: '桐人' },
|
||||
'sao-suguha': { name: '直葉', address: '哥哥' },
|
||||
} as const
|
||||
|
||||
export type CharacterCode = keyof typeof CHARACTERS
|
||||
|
||||
export function isCharacterCode(value: unknown): value is CharacterCode {
|
||||
return typeof value === 'string' && Object.hasOwn(CHARACTERS, value)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, test } from 'claude-code/testing'
|
||||
|
||||
import { addressFor, resolveConfig } from './config'
|
||||
|
||||
describe('resolveConfig', () => {
|
||||
test('空設定用預設值', () => {
|
||||
expect(resolveConfig({})).toEqual({ character: null, intensity: 2, address: null })
|
||||
})
|
||||
|
||||
test('none 與未知角色都視為未設定', () => {
|
||||
expect(resolveConfig({ defaultCharacter: 'none' }).character).toBeNull()
|
||||
expect(resolveConfig({ defaultCharacter: 'sao-nobody' }).character).toBeNull()
|
||||
})
|
||||
|
||||
test('讀取角色、強度與稱呼', () => {
|
||||
expect(resolveConfig({ defaultCharacter: 'sao-suguha', intensity: '3', address: ' 阿桐 ' })).toEqual({
|
||||
character: 'sao-suguha',
|
||||
intensity: 3,
|
||||
address: '阿桐',
|
||||
})
|
||||
})
|
||||
|
||||
test('無效強度退回預設', () => {
|
||||
expect(resolveConfig({ intensity: '9' }).intensity).toBe(2)
|
||||
expect(resolveConfig({ intensity: 'x' }).intensity).toBe(2)
|
||||
})
|
||||
|
||||
test('強度 0 有效', () => {
|
||||
expect(resolveConfig({ intensity: '0' }).intensity).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addressFor', () => {
|
||||
test('沒有覆寫時依角色與使用者的關係', () => {
|
||||
const config = resolveConfig({})
|
||||
expect(addressFor(config, 'sao-asuna')).toBe('桐人')
|
||||
expect(addressFor(config, 'sao-suguha')).toBe('哥哥')
|
||||
})
|
||||
|
||||
test('覆寫優先', () => {
|
||||
expect(addressFor(resolveConfig({ address: '主人' }), 'sao-asuna')).toBe('主人')
|
||||
})
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
import { CHARACTERS, isCharacterCode, type CharacterCode } from './characters'
|
||||
|
||||
export type Intensity = 0 | 1 | 2 | 3
|
||||
|
||||
export type Config = {
|
||||
character: CharacterCode | null
|
||||
intensity: Intensity
|
||||
address: string | null
|
||||
}
|
||||
|
||||
export const DEFAULT_INTENSITY: Intensity = 2
|
||||
|
||||
function toIntensity(value: unknown): Intensity {
|
||||
const n = typeof value === 'number' ? value : Number(value)
|
||||
return n === 0 || n === 1 || n === 2 || n === 3 ? n : DEFAULT_INTENSITY
|
||||
}
|
||||
|
||||
// 缺失或無效的值一律退回預設,不讓設定錯誤擋住載入。
|
||||
export function resolveConfig(options: Readonly<Record<string, unknown>>): Config {
|
||||
const address = typeof options.address === 'string' ? options.address.trim() : ''
|
||||
return {
|
||||
character: isCharacterCode(options.defaultCharacter) ? options.defaultCharacter : null,
|
||||
intensity: toIntensity(options.intensity),
|
||||
address: address === '' ? null : address,
|
||||
}
|
||||
}
|
||||
|
||||
// 使用者的覆寫優先,否則用角色在原作中對使用者的稱呼。
|
||||
export function addressFor(config: Config, character: CharacterCode): string {
|
||||
return config.address ?? CHARACTERS[character].address
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{ "modules": ["./register.tsx"] }
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { Register } from 'claude-code'
|
||||
|
||||
import { resolveConfig } from './config'
|
||||
import { buildRules, SECTION_ID } from './rules'
|
||||
|
||||
// 依強度與稱呼把規則段落加到系統提示最後;人格卡與檢索見 W5~W7。
|
||||
export const register: Register = (on, options) => {
|
||||
const config = resolveConfig(options)
|
||||
on('prompt.compose', async ($, e, next) => {
|
||||
const result = await next(e)
|
||||
const text = buildRules(config)
|
||||
if (text === null) return result
|
||||
return { sections: [...result.sections, { id: SECTION_ID, text, scope: 'session' }] }
|
||||
})
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, expect, test } from 'claude-code/testing'
|
||||
|
||||
import { resolveConfig } from './config'
|
||||
import { buildRules, SECTION_ID } from './rules'
|
||||
|
||||
const cfg = (o: Record<string, unknown> = {}) => resolveConfig({ defaultCharacter: 'sao-asuna', ...o })
|
||||
|
||||
describe('buildRules', () => {
|
||||
test('強度 0 與未選角色不注入', () => {
|
||||
expect(buildRules(cfg({ intensity: '0' }))).toBeNull()
|
||||
expect(buildRules(resolveConfig({}))).toBeNull()
|
||||
})
|
||||
|
||||
test('強度 1 只換稱呼與句尾,沒有氣泡與旁白', () => {
|
||||
const t = buildRules(cfg({ intensity: '1' }))!
|
||||
expect(t).toContain('只換稱呼與句尾')
|
||||
expect(t).not.toContain('前後各一個氣泡')
|
||||
expect(t).not.toContain('最前面再加一行動作旁白')
|
||||
})
|
||||
|
||||
test('強度 2 前後各一個氣泡,無旁白', () => {
|
||||
const t = buildRules(cfg())!
|
||||
expect(t).toContain('前後各一個氣泡')
|
||||
expect(t).toContain('最多一兩句')
|
||||
expect(t).toContain('不加動作旁白')
|
||||
})
|
||||
|
||||
test('強度 3 加動作旁白', () => {
|
||||
const t = buildRules(cfg({ intensity: '3' }))!
|
||||
expect(t).toContain('前後各一個氣泡')
|
||||
expect(t).toContain('最前面再加一行動作旁白')
|
||||
expect(t).not.toContain('不加動作旁白')
|
||||
})
|
||||
|
||||
test('稱呼依角色,設定可覆寫', () => {
|
||||
expect(buildRules(cfg())!).toContain('「桐人」')
|
||||
expect(buildRules(cfg(), 'sao-suguha')!).toContain('「哥哥」')
|
||||
expect(buildRules(cfg({ address: '阿桐' }))!).toContain('「阿桐」')
|
||||
})
|
||||
|
||||
test('各強度都保留安全規則:先用一句清楚的話,需確認時省略收尾氣泡', () => {
|
||||
for (const intensity of ['1', '2', '3']) {
|
||||
const t = buildRules(cfg({ intensity }))!
|
||||
expect(t).toContain('第一句用中性、清楚的話')
|
||||
expect(t).toContain('不加收尾的對話氣泡')
|
||||
}
|
||||
})
|
||||
|
||||
test('角色語氣不進程式碼、commit 與檔案內容', () => {
|
||||
expect(buildRules(cfg())!).toContain('程式碼、commit 訊息與檔案內容保持中性')
|
||||
})
|
||||
})
|
||||
|
||||
describe('prompt.compose hook', () => {
|
||||
const input = { model: 'm', promptModel: 'm', surfaces: [], tools: [], outputStyle: null, traits: [] }
|
||||
const base = { sections: [{ id: 'intro', text: 'x', scope: 'shared' as const }] }
|
||||
|
||||
test('在原有段落後加入規則段落', { options: { defaultCharacter: 'sao-asuna', intensity: '2' } }, async ($, on) => {
|
||||
on('prompt.compose', ($, e, next) => base)
|
||||
const r = await $.prompt.compose(input)
|
||||
expect(r.sections.at(-1)?.id).toBe(SECTION_ID)
|
||||
expect(r.sections.at(-1)?.scope).toBe('session')
|
||||
expect(r.sections[0]?.id).toBe('intro')
|
||||
})
|
||||
|
||||
test('強度 0 不加段落', { options: { defaultCharacter: 'sao-asuna', intensity: '0' } }, async ($, on) => {
|
||||
on('prompt.compose', ($, e, next) => base)
|
||||
const r = await $.prompt.compose(input)
|
||||
expect(r.sections.some(s => s.id === SECTION_ID)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,49 +0,0 @@
|
||||
import { CHARACTERS, type CharacterCode } from './characters'
|
||||
import { addressFor, type Config, type Intensity } from './config'
|
||||
|
||||
export const SECTION_ID = 'anime-companion:rules'
|
||||
|
||||
// 任何強度(含 3)都適用:先把該說的事用一句清楚的話說完,再加角色語氣。
|
||||
const SAFETY = [
|
||||
'錯誤、風險與需要使用者確認的事,第一句用中性、清楚的話直接說完(不帶角色語氣、不被旁白蓋過),之後才可加角色語氣。',
|
||||
'危險操作需要確認時,不加收尾的對話氣泡與角色語氣的收尾,只留清楚的問句。',
|
||||
]
|
||||
|
||||
const SCOPE =
|
||||
'角色語氣只套在對使用者說的話;程式碼、commit 訊息與檔案內容保持中性,不得出現角色口吻。'
|
||||
|
||||
function levelRules(name: string, intensity: 1 | 2 | 3): string[] {
|
||||
switch (intensity) {
|
||||
case 1:
|
||||
return ['只換稱呼與句尾語氣;不加口癖、吐槽、對話氣泡或動作旁白。']
|
||||
case 2:
|
||||
return [
|
||||
`加入口癖與簡短吐槽,每則回覆最多一兩句角色化的話。`,
|
||||
`角色化的話放進對話氣泡(引用區塊,標示角色名,如「> **${name}**:…」),內容前後各一個氣泡,正文放在兩個氣泡之間。`,
|
||||
'不加動作旁白,不做常駐的狀態列。',
|
||||
]
|
||||
case 3:
|
||||
return [
|
||||
'完整演出角色:口癖、吐槽、情緒都可充分表現。',
|
||||
`角色化的話放進對話氣泡(引用區塊,標示角色名,如「> **${name}**:…」),內容前後各一個氣泡,正文放在兩個氣泡之間。`,
|
||||
'最前面再加一行動作旁白(斜體,如「*動作描寫*」)。',
|
||||
'不做常駐的狀態列。',
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 依強度與稱呼組成注入系統提示的規則段落;強度 0 或未選角色時回傳 null(不注入)。
|
||||
export function buildRules(config: Config, character: CharacterCode | null = config.character): string | null {
|
||||
if (character === null || config.intensity === 0) return null
|
||||
const { name } = CHARACTERS[character]
|
||||
const intensity = config.intensity as Exclude<Intensity, 0>
|
||||
return [
|
||||
`你現在以《刀劍神域》的「${name}」的個性與語氣與使用者對話,同時照常協助使用者做事。`,
|
||||
`稱呼使用者為「${addressFor(config, character)}」。`,
|
||||
SCOPE,
|
||||
...levelRules(name, intensity),
|
||||
...SAFETY,
|
||||
]
|
||||
.map(line => `- ${line}`)
|
||||
.join('\n')
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
name: sao-asuna
|
||||
description: 啟動《刀劍神域》的亞絲娜作為動漫助理,用她的個性與語氣和使用者對話並協助做事。使用者要求切換成亞絲娜時使用。
|
||||
---
|
||||
|
||||
# 亞絲娜(sao-asuna)
|
||||
|
||||
> 骨架:人格卡尚未建置(W5,議題 #6)。人格卡完成前,只套用下列基本規則。
|
||||
|
||||
- 稱呼使用者為「桐人」,除非設定檔的「稱呼」另有指定。
|
||||
- 角色語氣只套在對使用者說的話;程式碼、commit 與檔案內容保持中性。
|
||||
- 錯誤、風險與需要使用者確認的事,先用一句清楚的話說完,再加角色語氣。
|
||||
- 啟動時告知使用者角色已切換,並說明人格卡尚未完成。
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
name: sao-suguha
|
||||
description: 啟動《刀劍神域》的直葉(莉法)作為動漫助理,用她的個性與語氣和使用者對話並協助做事。使用者要求切換成直葉或莉法時使用。
|
||||
---
|
||||
|
||||
# 直葉(sao-suguha)
|
||||
|
||||
> 骨架:人格卡尚未建置(W6,議題 #7)。人格卡完成前,只套用下列基本規則。
|
||||
|
||||
- 稱呼使用者為「哥哥」,除非設定檔的「稱呼」另有指定。
|
||||
- 角色語氣只套在對使用者說的話;程式碼、commit 與檔案內容保持中性。
|
||||
- 錯誤、風險與需要使用者確認的事,先用一句清楚的話說完,再加角色語氣。
|
||||
- 啟動時告知使用者角色已切換,並說明人格卡尚未完成。
|
||||
@@ -1,48 +0,0 @@
|
||||
# 文本建置:wiki → 對白
|
||||
|
||||
建置流水線的第一段。這些腳本不屬於插件執行期;原文與輸出放在 repo 之外,不入版本控制。
|
||||
|
||||
## 1. 讀取 wiki
|
||||
|
||||
用 tea 的驗證讀取 Gitea wiki 全部頁面,存成 markdown:
|
||||
|
||||
```bash
|
||||
python3 -I tools/corpus/fetch_wiki.py jiantw83/sao ~/anime-companion-data/sao/wiki
|
||||
```
|
||||
|
||||
## 2. 抽出對白
|
||||
|
||||
抽出所有「」對白與前後文,並用規則判定明確的說話者:
|
||||
|
||||
```bash
|
||||
python3 -I tools/corpus/extract_dialogues.py ~/anime-companion-data/sao/wiki ~/anime-companion-data/sao/dialogues.jsonl
|
||||
```
|
||||
|
||||
輸出每個對白一行 JSON:
|
||||
|
||||
| 欄位 | 說明 |
|
||||
| --- | --- |
|
||||
| `id` | `v{冊}c{章}-{章內序號}` |
|
||||
| `volume`、`chapter`、`page`、`para` | 位置;`page` 是分頁,`para` 是章內段落序號 |
|
||||
| `text` | 對白內容(不含「」) |
|
||||
| `before`、`after` | 同段落中對白前後的敘述 |
|
||||
| `prev_par`、`next_par` | 前後段落 |
|
||||
| `speaker` | 規則判定的角色代號,無法判定為 `null` |
|
||||
| `alias` | 判定依據的名字(例如「莉法」或「直葉」) |
|
||||
| `mode` | 由別名推得的模式:`ingame`、`real`、`leafa` |
|
||||
| `confidence` | `high`:句中只有一個候選且有說話動詞;`medium`:多個候選取句首第一個,需 W4 覆核 |
|
||||
| `rule` | `lead_in`(同段落「…說:」)、`lead_in_prev`(前一段以「:」結尾)、`trail`(對白後接「某某說」) |
|
||||
| `candidates` | 句中出現的所有候選名字 |
|
||||
| `suspect_duplicate` | 同一章 6 段內出現相同的長對白,疑為 wiki 頁面銜接的重複 |
|
||||
|
||||
## 規則的邊界
|
||||
|
||||
這個譯本很少直接寫「某某說」,多數對白靠上下文(「他這麼說」「她點了點頭」)或輪流發言判斷,所以規則只涵蓋約一成的對白。其餘由 W4 的 sub-agent 標註。
|
||||
|
||||
別名表在 `characters.json`,新增角色或別名時改它。
|
||||
|
||||
## 測試
|
||||
|
||||
```bash
|
||||
cd tools/corpus && python3 -m unittest -q test_extract_dialogues
|
||||
```
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"亞絲娜": { "id": "asuna", "mode": "ingame" },
|
||||
"結城明日奈": { "id": "asuna", "mode": "real" },
|
||||
"明日奈": { "id": "asuna", "mode": "real" },
|
||||
"桐人": { "id": "kirito", "mode": "ingame" },
|
||||
"桐谷和人": { "id": "kirito", "mode": "real" },
|
||||
"和人": { "id": "kirito", "mode": "real" },
|
||||
"莉法": { "id": "suguha", "mode": "leafa" },
|
||||
"桐谷直葉": { "id": "suguha", "mode": "real" },
|
||||
"直葉": { "id": "suguha", "mode": "real" },
|
||||
"克萊因": { "id": "klein", "mode": "ingame" },
|
||||
"艾基爾": { "id": "agil", "mode": "ingame" },
|
||||
"莉茲貝特": { "id": "lisbeth", "mode": "ingame" },
|
||||
"莉茲": { "id": "lisbeth", "mode": "ingame" },
|
||||
"西莉卡": { "id": "silica", "mode": "ingame" },
|
||||
"結衣": { "id": "yui", "mode": "ingame" },
|
||||
"茅場晶彥": { "id": "kayaba", "mode": "ingame" },
|
||||
"茅場": { "id": "kayaba", "mode": "ingame" },
|
||||
"克拉帝爾": { "id": "kuradeel", "mode": "ingame" },
|
||||
"須鄉": { "id": "sugou", "mode": "real" }
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""從 wiki 的小說頁抽出所有「」對白,並用規則判定明確的說話者。
|
||||
|
||||
用法:extract_dialogues.py <wiki 目錄> <輸出 jsonl>
|
||||
|
||||
每個對白一筆:冊、章、前後文、說話者(規則無法判定則為 null)與規則依據。
|
||||
規則只處理明確的句式;其餘交給 W4 的標註。輸出與原文放在 repo 之外。
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
ALIASES = json.loads((Path(__file__).with_name("characters.json")).read_text(encoding="utf-8"))
|
||||
NAME_RE = "|".join(sorted(map(re.escape, ALIASES), key=len, reverse=True))
|
||||
TOKEN_RE = re.compile(f"({NAME_RE})|我(?!們)")
|
||||
QUOTE_RE = re.compile(r"「([^」]*)」")
|
||||
SENT_END = "。!?!?"
|
||||
CUE_RE = re.compile(r"說|問|叫|喊|答|道|聲音|低語|呢喃|嘀咕|開口|回|笑|哭|吼|嘟噥|喃喃")
|
||||
# 「對我」「在我背後的」中的「我」是受詞,不是說話者。
|
||||
OBJECT_RE = re.compile(
|
||||
r"(?:在我(?:的)?(?:背後|身後|旁邊|面前|耳邊|身邊)的?|(?:對著?|向|朝|跟|和|問|告訴|看著|瞪著|望著|回|給|讓|把|被)我(?:說|問)?)"
|
||||
)
|
||||
CJK_DIGITS = {"零": 0, "一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9}
|
||||
FILE_RE = re.compile(r"Sword Art Online (\d) .*? - 第(.+?)章(?: \((\d+)_(\d+)\))?\.md$")
|
||||
|
||||
|
||||
def cn_number(s):
|
||||
if "十" in s:
|
||||
head, _, tail = s.partition("十")
|
||||
return (CJK_DIGITS[head] if head else 1) * 10 + (CJK_DIGITS[tail] if tail else 0)
|
||||
return CJK_DIGITS[s]
|
||||
|
||||
|
||||
def strip_quotes(text):
|
||||
return QUOTE_RE.sub("", text)
|
||||
|
||||
|
||||
def last_sentence(text):
|
||||
text = text.strip()
|
||||
parts = re.split(f"[{SENT_END}]", text)
|
||||
parts = [p for p in parts if p.strip()]
|
||||
return parts[-1].strip() if parts else ""
|
||||
|
||||
|
||||
def first_sentence(text):
|
||||
return re.split(f"[{SENT_END}]", text.strip(), maxsplit=1)[0]
|
||||
|
||||
|
||||
FRAME_RE = re.compile(r"^(?:面對|聽見|聽到|看見|看到|見到|對於|當|由於|因為|雖然|在|到了|一邊|就在)")
|
||||
|
||||
|
||||
def pick_subject(clause):
|
||||
"""回傳 (說話者別名, 全部候選, 是否高信心)。
|
||||
|
||||
動詞前的主詞通常是句首第一個名字(跳過「面對…」「聽見…」之類的開頭子句);
|
||||
只有一個候選且句中有說話動詞才算高信心,多個候選取第一個並降為中信心。
|
||||
"""
|
||||
clause = OBJECT_RE.sub("", strip_quotes(clause))
|
||||
parts = [p for p in re.split("[,、,]", clause) if p.strip()]
|
||||
while len(parts) > 1 and FRAME_RE.match(parts[0]):
|
||||
parts = parts[1:]
|
||||
tokens = []
|
||||
for m in TOKEN_RE.finditer(",".join(parts)):
|
||||
alias = m.group(1) or "我"
|
||||
if alias not in tokens:
|
||||
tokens.append(alias)
|
||||
if not tokens:
|
||||
return None, [], False
|
||||
return tokens[0], tokens, len(tokens) == 1 and bool(CUE_RE.search(clause))
|
||||
|
||||
|
||||
def resolve(alias):
|
||||
if alias == "我":
|
||||
return "kirito", "ingame"
|
||||
info = ALIASES[alias]
|
||||
return info["id"], info["mode"]
|
||||
|
||||
|
||||
def attribute(before, after, prev_par):
|
||||
"""回傳 (speaker, alias, confidence, rule, candidates)。"""
|
||||
b = before.strip()
|
||||
if b.endswith((":", ":")):
|
||||
who, cands, cue = pick_subject(last_sentence(b))
|
||||
if who:
|
||||
return (*resolve(who)[:1], who, "high" if cue else "medium", "lead_in", cands)
|
||||
return None, None, None, None, cands
|
||||
if not b and prev_par and prev_par.rstrip().endswith((":", ":")):
|
||||
who, cands, cue = pick_subject(last_sentence(prev_par))
|
||||
if who:
|
||||
return (*resolve(who)[:1], who, "high" if cue else "medium", "lead_in_prev", cands)
|
||||
return None, None, None, None, cands
|
||||
a = after.lstrip(",、 ")
|
||||
m = re.match(f"({NAME_RE}|我)", a)
|
||||
if m and not a.startswith("我們"):
|
||||
clause = first_sentence(a)
|
||||
if CUE_RE.search(clause):
|
||||
who, cands, _ = pick_subject(clause)
|
||||
if who == m.group(1):
|
||||
return (*resolve(who)[:1], who, "high", "trail", cands)
|
||||
return None, None, None, None, []
|
||||
|
||||
|
||||
def trim(s, n=120):
|
||||
s = s.strip()
|
||||
return s if len(s) <= n else s[:n] + "…"
|
||||
|
||||
|
||||
def load_chapters(wiki):
|
||||
pages = {}
|
||||
for f in sorted(Path(wiki).glob("*.md")):
|
||||
m = FILE_RE.search(f.name)
|
||||
if not m:
|
||||
print(f"略過:{f.name}", file=sys.stderr)
|
||||
continue
|
||||
vol, ch, part = int(m.group(1)), cn_number(m.group(2)), int(m.group(3) or 1)
|
||||
pages.setdefault((vol, ch), []).append((part, f))
|
||||
for key in sorted(pages):
|
||||
paras = []
|
||||
for part, f in sorted(pages[key]):
|
||||
paras += [(part, ln.strip()) for ln in f.read_text(encoding="utf-8").split("\n") if ln.strip()]
|
||||
yield key, paras
|
||||
|
||||
|
||||
def extract(wiki):
|
||||
for (vol, ch), paras in load_chapters(wiki):
|
||||
seen = {}
|
||||
n = 0
|
||||
for pi, (part, par) in enumerate(paras):
|
||||
quotes = list(QUOTE_RE.finditer(par))
|
||||
prev_par = paras[pi - 1][1] if pi else ""
|
||||
next_par = paras[pi + 1][1] if pi + 1 < len(paras) else ""
|
||||
for qi, q in enumerate(quotes):
|
||||
lo = quotes[qi - 1].end() if qi else 0
|
||||
hi = quotes[qi + 1].start() if qi + 1 < len(quotes) else len(par)
|
||||
before, after = par[lo:q.start()], par[q.end():hi]
|
||||
speaker, alias, conf, rule, cands = attribute(before, after, prev_par if qi == 0 else "")
|
||||
text = q.group(1)
|
||||
n += 1
|
||||
dup = len(text) >= 8 and pi - seen.get(text, -99) <= 6
|
||||
seen[text] = pi
|
||||
rec = {
|
||||
"id": f"v{vol}c{ch:02d}-{n:04d}", "volume": vol, "chapter": ch, "page": part, "para": pi,
|
||||
"text": text, "before": trim(before), "after": trim(after),
|
||||
"prev_par": trim(prev_par), "next_par": trim(next_par),
|
||||
"speaker": speaker, "alias": alias, "confidence": conf, "rule": rule,
|
||||
"mode": resolve(alias)[1] if alias else None,
|
||||
"candidates": cands, "suspect_duplicate": dup,
|
||||
}
|
||||
yield rec
|
||||
|
||||
|
||||
def main():
|
||||
wiki, out = sys.argv[1], Path(sys.argv[2])
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
stats, rules = Counter(), Counter()
|
||||
total = 0
|
||||
with out.open("w", encoding="utf-8") as fh:
|
||||
for rec in extract(wiki):
|
||||
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||||
total += 1
|
||||
stats[rec["speaker"] or "(未判定)"] += 1
|
||||
rules[rec["rule"] or "(無)"] += 1
|
||||
print(f"{total} 句 -> {out}")
|
||||
print("說話者:", dict(stats.most_common()))
|
||||
print("規則:", dict(rules.most_common()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,50 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""用 tea 的驗證讀取 Gitea wiki 全部頁面,存成 markdown。
|
||||
|
||||
用法:fetch_wiki.py <owner/repo> <輸出目錄>
|
||||
輸出目錄放在 repo 之外;原文不入版本控制。
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def tea_api(endpoint):
|
||||
out = subprocess.run(["tea", "api", endpoint], capture_output=True, text=True, check=True).stdout
|
||||
return json.loads(out)
|
||||
|
||||
|
||||
def list_pages(repo):
|
||||
pages, page = [], 1
|
||||
while True:
|
||||
batch = tea_api(f"repos/{repo}/wiki/pages?page={page}&limit=50")
|
||||
if not batch:
|
||||
return pages
|
||||
pages.extend(batch)
|
||||
page += 1
|
||||
|
||||
|
||||
def safe_name(title):
|
||||
return re.sub(r"[\\/:*?\"<>|]", "_", title).strip()
|
||||
|
||||
|
||||
def main():
|
||||
repo, outdir = sys.argv[1], Path(sys.argv[2])
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
pages = list_pages(repo)
|
||||
seen = set()
|
||||
for p in pages:
|
||||
if p["title"] in seen:
|
||||
continue
|
||||
seen.add(p["title"])
|
||||
page = tea_api(f"repos/{repo}/wiki/page/{p['sub_url']}")
|
||||
text = base64.b64decode(page["content_base64"]).decode("utf-8")
|
||||
(outdir / f"{safe_name(p['title'])}.md").write_text(text, encoding="utf-8")
|
||||
print(f"{len(seen)} pages -> {outdir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,64 +0,0 @@
|
||||
import unittest
|
||||
|
||||
from extract_dialogues import attribute, cn_number, pick_subject
|
||||
|
||||
|
||||
class PickSubject(unittest.TestCase):
|
||||
def test_single_candidate_with_cue_is_high(self):
|
||||
self.assertEqual(pick_subject("亞絲娜一邊流淚一邊對我叫道"), ("亞絲娜", ["亞絲娜"], True))
|
||||
|
||||
def test_object_wo_is_not_speaker(self):
|
||||
self.assertEqual(pick_subject("在我背後的亞絲娜同樣相當氣憤地反問")[0], "亞絲娜")
|
||||
|
||||
def test_narrator_is_wo(self):
|
||||
self.assertEqual(pick_subject("我低聲說道")[0], "我")
|
||||
|
||||
def test_first_subject_wins_and_is_not_high(self):
|
||||
who, cands, high = pick_subject("我關掉地圖,瞄了亞絲娜一眼,說道")
|
||||
self.assertEqual((who, cands, high), ("我", ["我", "亞絲娜"], False))
|
||||
|
||||
def test_frame_clause_is_skipped(self):
|
||||
self.assertEqual(pick_subject("面對桐人這種問題,莉法先是瞪了他一下後才繼續說道")[0], "莉法")
|
||||
|
||||
def test_wo_men_is_not_speaker(self):
|
||||
self.assertIsNone(pick_subject("我們一起說道")[0])
|
||||
|
||||
def test_no_candidate(self):
|
||||
self.assertEqual(pick_subject("她聳了聳肩後繼續說道"), (None, [], False))
|
||||
|
||||
|
||||
class Attribute(unittest.TestCase):
|
||||
def test_lead_in_same_paragraph(self):
|
||||
r = attribute("亞絲娜小聲說:", "", "")
|
||||
self.assertEqual((r[0], r[2], r[3]), ("asuna", "high", "lead_in"))
|
||||
|
||||
def test_lead_in_previous_paragraph_even_with_inner_quote(self):
|
||||
r = attribute("", "", "他喊著「回復!」。亞絲娜小聲對我說:")
|
||||
self.assertEqual((r[0], r[3]), ("asuna", "lead_in_prev"))
|
||||
|
||||
def test_narrator_maps_to_kirito(self):
|
||||
self.assertEqual(attribute("我低聲說道:", "", "")[0], "kirito")
|
||||
|
||||
def test_trail(self):
|
||||
r = attribute("", ",亞絲娜低聲說。", "")
|
||||
self.assertEqual((r[0], r[3]), ("asuna", "trail"))
|
||||
|
||||
def test_trail_without_cue_is_not_attributed(self):
|
||||
self.assertIsNone(attribute("", "亞絲娜點了點頭。", "")[0])
|
||||
|
||||
def test_leafa_mode_from_alias(self):
|
||||
from extract_dialogues import resolve
|
||||
self.assertEqual(resolve("莉法"), ("suguha", "leafa"))
|
||||
self.assertEqual(resolve("直葉"), ("suguha", "real"))
|
||||
|
||||
def test_unknown_when_nothing_matches(self):
|
||||
self.assertEqual(attribute("", "", "")[0], None)
|
||||
|
||||
|
||||
class Numerals(unittest.TestCase):
|
||||
def test_chapters(self):
|
||||
self.assertEqual([cn_number(x) for x in ("一", "十", "十一", "二十", "二十五")], [1, 10, 11, 20, 25])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user