feat: W7 功能標籤分類、對白檢索與注入 (#8)

用 $.model.complete(Haiku)判斷回應的功能標籤,從角色索引取同標籤對白,
以只增不改的訊息附在使用者訊息之前;失敗一律退回不注入。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jeffery (jiantw83)
2026-10-14 17:44:09 +00:00
co-authored by Claude Sonnet 5.5
parent 1c4e0c5d98
commit d9e50d196b
12 changed files with 385 additions and 4 deletions
@@ -0,0 +1,62 @@
import { describe, expect, test } from 'claude-code/testing'
import { buildClassifyPrompt, classify, parseFunction, type Complete } from './classify'
import { FUNCTIONS } from './tags'
describe('parseFunction', () => {
test('白名單內的代號', () => {
expect(parseFunction('comfort')).toBe('comfort')
expect(parseFunction(' Report_Fail\n')).toBe('report_fail')
expect(parseFunction('tease。')).toBe('tease')
})
test('白名單外或空回覆視為失敗', () => {
expect(parseFunction('flirt')).toBeNull()
expect(parseFunction('')).toBeNull()
expect(parseFunction('我覺得是安慰')).toBeNull()
})
test('只認開頭第一個詞', () => {
expect(parseFunction('comfort or tease')).toBe('comfort')
expect(parseFunction('他說 comfort')).toBeNull()
})
})
describe('buildClassifyPrompt', () => {
test('列出所有功能代號(other 除外)並包住使用者的話', () => {
const p = buildClassifyPrompt('測試失敗了')
for (const key of Object.keys(FUNCTIONS).filter(k => k !== 'other')) expect(p).toContain(`${key}:`)
expect(p).toContain('<使用者的話>\n測試失敗了\n</使用者的話>')
})
test('過長的使用者輸入截斷', () => {
expect(buildClassifyPrompt('字'.repeat(5000)).length).toBeLessThan(1500)
})
})
describe('classify', () => {
const ok = (text: string): Complete => async () => ({ isAnswered: true, text })
test('成功回傳標籤,並用 haiku、低 effort、有逾時', async () => {
let seen: Parameters<Complete>[0] | undefined
const r = await classify(async req => ((seen = req), { isAnswered: true, text: 'comfort' }), '好累')
expect(r).toBe('comfort')
expect(seen?.model).toBe('haiku')
expect(seen?.effort).toBe('low')
expect(seen?.timeoutMs).toBeGreaterThan(0)
})
test('未回答、空回覆、白名單外都回傳 null', async () => {
expect(await classify(async () => ({ isAnswered: false }), 'x')).toBeNull()
expect(await classify(ok(''), 'x')).toBeNull()
expect(await classify(ok('flirt'), 'x')).toBeNull()
})
test('呼叫丟出例外也回傳 null', async () => {
expect(
await classify(async () => {
throw new Error('blocked model')
}, 'x'),
).toBeNull()
})
})
+51
View File
@@ -0,0 +1,51 @@
import { FUNCTIONS, isFunctionTag, type FunctionTag } from './tags'
export const CLASSIFY_TIMEOUT_MS = 3000
// 判斷的是「角色接下來最自然的回應」屬於哪種功能,不是使用者這句話本身的功能。
export function buildClassifyPrompt(userText: string): string {
const choices = Object.entries(FUNCTIONS)
.filter(([key]) => key !== 'other')
.map(([key, label]) => `${key}:${label}`)
.join('\n')
return [
'使用者正在請助理協助做事。判斷在他這句話之後,一位親切的角色最自然的第一句回應屬於下列哪一種功能。',
'只回答功能代號一個詞,不要解釋;都不適合就回答 other。',
'',
choices,
'',
'<使用者的話>',
userText.slice(0, 600),
'</使用者的話>',
].join('\n')
}
// 只認回覆開頭的第一個詞,且必須在白名單內;其餘一律視為判斷失敗。
export function parseFunction(reply: string): FunctionTag | null {
const word = reply.trim().toLowerCase().match(/^[a-z_]+/)?.[0]
return isFunctionTag(word) ? word : null
}
export type Complete = (request: {
model: string
prompt: string
maxTokens: number
effort: 'low'
timeoutMs: number
}) => Promise<{ isAnswered: boolean; text?: string }>
// 任何失敗(逾時、API 錯誤、空回覆、白名單外)都回傳 null,由呼叫端退回不注入。
export async function classify(complete: Complete, userText: string): Promise<FunctionTag | null> {
try {
const r = await complete({
model: 'haiku',
prompt: buildClassifyPrompt(userText),
maxTokens: 16,
effort: 'low',
timeoutMs: CLASSIFY_TIMEOUT_MS,
})
return r.isAnswered && r.text ? parseFunction(r.text) : null
} catch {
return null
}
}
@@ -0,0 +1,8 @@
import type { CharacterCode } from '../characters'
import type { IndexEntry } from '../retrieval'
// 各角色的對白索引。W5、W6 由建置流水線產生並填入;目前為空,空索引時 hook 不呼叫模型。
export const INDEXES: Readonly<Record<CharacterCode, readonly IndexEntry[]>> = {
'sao-asuna': [],
'sao-suguha': [],
}
+13
View File
@@ -0,0 +1,13 @@
import type { IndexEntry } from './retrieval'
export const MAX_LINE = 80
// 注入對話的語氣參考。明說只學語氣,不照抄,也不要提到這份參考。
export function formatExamples(name: string, entries: readonly IndexEntry[]): string | null {
if (entries.length === 0) return null
const lines = entries.map(e => `- 「${e.text.length > MAX_LINE ? e.text.slice(0, MAX_LINE) + '…' : e.text}」`)
return [
`以下是「${name}」在類似情境說過的話,只用來掌握她的語氣、用字與節奏。不要照抄,也不要向使用者提到這份參考。`,
...lines,
].join('\n')
}
@@ -0,0 +1,54 @@
import { describe, expect, test } from 'claude-code/testing'
import type { Complete } from './classify'
import { resolveConfig } from './config'
import { buildInjection } from './inject'
import type { IndexEntry } from './retrieval'
const entry = (id: string, fn: IndexEntry['function']): IndexEntry => ({ id, function: fn, emotion: 'neutral', mode: null, text: `句${id}` })
const indexes = {
'sao-asuna': [entry('a', 'comfort'), entry('b', 'comfort')],
'sao-suguha': [] as IndexEntry[],
}
const answer = (text: string): Complete => async () => ({ isAnswered: true, text })
const deps = (opts: Record<string, string>, complete: Complete, calls = { n: 0 }) => ({
config: resolveConfig(opts),
indexes,
complete: async (r: Parameters<Complete>[0]) => (calls.n++, complete(r)),
now: () => 0,
turn: 0,
})
describe('buildInjection', () => {
const on = { defaultCharacter: 'sao-asuna', intensity: '2' }
test('分類成功時回傳同標籤的對白', async () => {
const r = await buildInjection(deps(on, answer('comfort')), '測試失敗了')
expect(r?.fn).toBe('comfort')
expect(r?.text).toContain('句a')
})
test('沒選角色或強度 0 不呼叫模型', async () => {
const calls = { n: 0 }
expect(await buildInjection(deps({ intensity: '2' }, answer('comfort'), calls), 'x')).toBeNull()
expect(await buildInjection(deps({ ...on, intensity: '0' }, answer('comfort'), calls), 'x')).toBeNull()
expect(calls.n).toBe(0)
})
test('索引為空不呼叫模型', async () => {
const calls = { n: 0 }
expect(await buildInjection(deps({ defaultCharacter: 'sao-suguha', intensity: '2' }, answer('comfort'), calls), 'x')).toBeNull()
expect(calls.n).toBe(0)
})
test('分類失敗退回不注入,但仍回報耗時', async () => {
const r = await buildInjection(deps(on, async () => ({ isAnswered: false })), 'x')
expect(r).toEqual({ text: null, fn: null, ms: 0 })
})
test('該標籤沒有對白時不注入', async () => {
const r = await buildInjection(deps(on, answer('greet')), 'x')
expect(r?.fn).toBe('greet')
expect(r?.text).toBeNull()
})
})
+31
View File
@@ -0,0 +1,31 @@
import { CHARACTERS, type CharacterCode } from './characters'
import { classify, type Complete } from './classify'
import type { Config } from './config'
import { formatExamples } from './examples'
import { retrieve, type IndexEntry } from './retrieval'
export type InjectDeps = {
config: Config
indexes: Readonly<Record<CharacterCode, readonly IndexEntry[]>>
complete: Complete
append: (text: string) => Promise<unknown>
now: () => number
turn: number
}
// 回傳 null 表示這一輪不注入(沒選角色、強度 0、索引為空、分類失敗或沒有同標籤的對白)。
// 索引為空時不呼叫模型,省下一次無用的分類。
export async function buildInjection(
{ config, indexes, complete, now, turn }: Omit<InjectDeps, 'append'>,
userText: string,
): Promise<{ text: string | null; fn: string | null; ms: number } | null> {
const character = config.character
if (character === null || config.intensity === 0) return null
const index = indexes[character]
if (index.length === 0) return null
const started = now()
const fn = await classify(complete, userText)
const ms = now() - started
if (fn === null) return { text: null, fn: null, ms }
return { text: formatExamples(CHARACTERS[character].name, retrieve(index, fn, { turn })), fn, ms }
}
+19 -1
View File
@@ -1,15 +1,33 @@
import type { Register } from 'claude-code'
import { resolveConfig } from './config'
import { INDEXES } from './data/index'
import { buildInjection } from './inject'
import { buildRules, SECTION_ID } from './rules'
// 依強度與稱呼把規則段落加到系統提示最後;人格卡與檢索見 W5~W7。
// 規則段落(W8)加到系統提示最後;每則使用者訊息再依功能標籤檢索角色的對白當語氣參考(W7)。
export const register: Register = (on, options) => {
const config = resolveConfig(options)
const showTiming = options.showTiming === true
let turn = 0
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' }] }
})
on('prompt.submit', async ($, e, next) => {
if (e.command || e.isMeta) return next(e)
const r = await buildInjection(
{ config, indexes: INDEXES, complete: req => $.model.complete(req), now: Date.now, turn: turn++ },
e.text,
)
if (r === null) return next(e)
if (showTiming) $.ui.status(`anime-companion 分類 ${r.ms}ms → ${r.fn ?? '失敗'}`)
// 附加在使用者訊息之前,只增不改,不影響對話的快取。
if (r.text !== null) await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: r.text }] } })
return next(e)
})
}
@@ -0,0 +1,61 @@
import { describe, expect, test } from 'claude-code/testing'
import { formatExamples } from './examples'
import { retrieve, type IndexEntry } from './retrieval'
const e = (id: string, fn: IndexEntry['function'], mode: string | null = null): IndexEntry => ({
id,
function: fn,
emotion: 'neutral',
mode,
text: `句${id}`,
})
const INDEX = [e('c', 'comfort'), e('a', 'comfort'), e('b', 'comfort'), e('d', 'tease'), e('x', 'comfort', 'leafa')]
describe('retrieve', () => {
test('只取同功能標籤,依 id 排序', () => {
expect(retrieve(INDEX, 'comfort', { k: 10 }).map(r => r.id)).toEqual(['a', 'b', 'c', 'x'])
})
test('預設取 3 句', () => {
expect(retrieve(INDEX, 'comfort')).toHaveLength(3)
})
test('沒有同標籤的對白回傳空陣列', () => {
expect(retrieve(INDEX, 'greet')).toEqual([])
})
test('依模式過濾', () => {
expect(retrieve(INDEX, 'comfort', { mode: 'leafa' }).map(r => r.id)).toEqual(['x'])
})
test('同一輪結果固定,不同輪輪流取', () => {
const ids = (turn: number) => retrieve(INDEX, 'comfort', { k: 2, turn }).map(r => r.id)
expect(ids(0)).toEqual(ids(0))
expect(ids(0)).toEqual(['a', 'b'])
expect(ids(1)).toEqual(['b', 'c'])
expect(ids(3)).toEqual(['x', 'a'])
})
test('k 大於池子時不重複', () => {
expect(new Set(retrieve(INDEX, 'tease', { k: 5 }).map(r => r.id)).size).toBe(1)
})
})
describe('formatExamples', () => {
test('沒有對白不注入', () => {
expect(formatExamples('亞絲娜', [])).toBeNull()
})
test('列出對白並說明只學語氣', () => {
const text = formatExamples('亞絲娜', [e('a', 'comfort')])!
expect(text).toContain('句a')
expect(text).toContain('不要照抄')
expect(text).toContain('亞絲娜')
})
test('過長的句子截斷', () => {
const long = { ...e('a', 'comfort'), text: 'あ'.repeat(200) }
expect(formatExamples('亞絲娜', [long])!.length).toBeLessThan(300)
})
})
@@ -0,0 +1,28 @@
import type { FunctionTag } from './tags'
// 對白索引的一筆;由建置流水線產生,見 data/。text 不超過 80 字。
export type IndexEntry = {
id: string
function: FunctionTag
emotion: string
mode: string | null
text: string
}
export const DEFAULT_K = 3
// 取同功能標籤的對白 k 句。依 id 排序後從 turn 起輪流取,讓每一輪撈到的句子不同,
// 同一輪結果固定(便於測試)。沒有同標籤的對白就回傳空陣列,不退而求其次。
export function retrieve(
index: readonly IndexEntry[],
fn: FunctionTag,
{ k = DEFAULT_K, mode = null, turn = 0 }: { k?: number; mode?: string | null; turn?: number } = {},
): IndexEntry[] {
const pool = index
.filter(e => e.function === fn && (mode === null || e.mode === mode))
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
if (pool.length === 0) return []
const n = Math.min(k, pool.length)
const start = ((turn % pool.length) + pool.length) % pool.length
return Array.from({ length: n }, (_, i) => pool[(start + i) % pool.length]!)
}
+26
View File
@@ -0,0 +1,26 @@
// 溝通功能標籤白名單,與建置流水線的 tools/corpus/tags.json 的 functions 一致。
export const FUNCTIONS = {
greet: '打招呼、道別',
refuse: '拒絕、否定請求',
comfort: '安慰、安撫',
tease: '吐槽、調侃、挖苦',
celebrate: '慶祝、開心、稱讚',
report_fail: '報告失敗、壞消息、承認錯誤',
confirm_request: '請求確認、確認對方意願或事實',
shy_cover: '害羞掩飾、嘴硬、岔開話題',
explain: '說明、陳述事實或計畫',
ask: '發問',
agree: '同意、答應',
apologize: '道歉',
thank: '道謝',
encourage: '鼓勵、打氣',
worry: '擔心、關心',
urge: '催促、命令、警告',
other: '以上皆非',
} as const
export type FunctionTag = keyof typeof FUNCTIONS
export function isFunctionTag(value: unknown): value is FunctionTag {
return typeof value === 'string' && Object.hasOwn(FUNCTIONS, value)
}