diff --git a/plugins/jsc-mod-companion/README.md b/plugins/jsc-mod-companion/README.md index 1faa94b..a9d60fe 100644 --- a/plugins/jsc-mod-companion/README.md +++ b/plugins/jsc-mod-companion/README.md @@ -91,3 +91,11 @@ - Big Five 保留為推導值:有 `params` 時,神經質(心情半衰期)與外向性(關心頻率)改由 18 參數推導,加權為設計啟發式;沒有 `params` 的舊角色沿用 `neuroticism`、`extraversion`。ingest 的 Big Five 推論與證據機制不變。 - 姓名、性別、年齡、核心信念不可由 AI 改動,`filterAiPatch` 會剔除這些欄位。 - 目前只提供計算與儲存欄位(`state.json` 的 `params`);每日抽樣、存檔偏移累積的觸發時機留待後續工作項目。 + +## 評估標籤 + +使用者每句話會對應到一個評估標籤,再由 `rules.ts` 的規則表換成心情增減量;模型只負責分類,不給數值。 + +- 正則先判明確的情況(責罵、感謝、完成)。 +- 正則沒判出時,採用模型於回覆末尾補的 `<評估:標籤>`;標籤須在 `tags.ts` 的白名單內,缺失或不在白名單就當作沒有標籤。 +- 白名單約 20 個,參考 wiki 第 9.4 章;不含戀愛與親密類標籤。 diff --git a/plugins/jsc-mod-companion/hooks/companion/core.ts b/plugins/jsc-mod-companion/hooks/companion/core.ts index 503a15d..47cf70c 100644 --- a/plugins/jsc-mod-companion/hooks/companion/core.ts +++ b/plugins/jsc-mod-companion/hooks/companion/core.ts @@ -5,6 +5,7 @@ import type { Clock, Fs } from './fs' import { applyDelta, decay, keywordEvent, MOOD_EVENTS, moodLabel } from './mood' import type { MoodEvent } from './mood' import { extraversionOf, neuroticismOf } from './core/params' +import { extractTag } from './tags' import { createCare } from './care' import type { Care, CareProfile, CareSettings, CareEngine, Signals } from './care' import { createMemory } from './memory' @@ -192,6 +193,8 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan settings: { dailyCap: DEFAULT_USER.careLimit, tzOffsetMin: 480 }, } let lastText = '' + // 這回合使用者文字是否已由正則判出標籤;是的話不再採用模型補的標 + let regexHit = false let recalled: string[] = [] // 目前啟用角色的記憶、待辦與關心引擎;關心的個人化參數每次取用時更新 @@ -233,6 +236,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan async function onPrompt(text: string): Promise { lastText = text const ev = keywordEvent(text) + regexHit = ev !== null if (ev) await moodEvent(ev) if (ev === 'scold') await affinityEvent('negative') else if (ev || text.trim().length >= 4) await affinityEvent('meaningful') @@ -256,9 +260,14 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan } // 一輪回覆結束;有用過工具才算做完事,心情變好並排隊一則反應 - async function onTurnComplete(usedTools: boolean) { + async function onTurnComplete(usedTools: boolean, reply = '') { const b = await bundleFor() - if (!b || !usedTools) return + if (!b) return + // 正則沒判出時,採用模型於回覆末尾補的標籤;不在白名單或缺失就當作沒標 + const tag = regexHit ? null : extractTag(reply) + regexHit = true + if (tag) await moodEvent(tag) + if (!usedTools) return await b.care.onTurnComplete(usedTools) await moodEvent('success') } diff --git a/plugins/jsc-mod-companion/hooks/companion/mood.ts b/plugins/jsc-mod-companion/hooks/companion/mood.ts index 0aef179..2b060c7 100644 --- a/plugins/jsc-mod-companion/hooks/companion/mood.ts +++ b/plugins/jsc-mod-companion/hooks/companion/mood.ts @@ -1,3 +1,5 @@ +import { regexTag, RULES } from './rules' +import type { Tag } from './tags' import type { Pad } from './types' export const clamp = (x: number, lo = -1, hi = 1) => Math.max(lo, Math.min(hi, x)) @@ -27,17 +29,9 @@ export function applyDelta(mood: Pad, delta: Pad): Pad { return { p: step(mood.p, delta.p), a: step(mood.a, delta.a), d: step(mood.d, delta.d) } } -export const MOOD_EVENTS = { - thanks: { p: 0.15, a: 0.05, d: 0.05 }, - scold: { p: -0.2, a: 0.15, d: -0.1 }, - success: { p: 0.08, a: 0.03, d: 0.05 }, - toolFail: { p: -0.08, a: 0.1, d: -0.08 }, - return: { p: 0.1, a: 0.08, d: 0 }, - lateNight: { p: -0.05, a: -0.1, d: 0 }, - favorite: { p: 0.12, a: 0.08, d: 0.03 }, - ignored: { p: -0.05, a: -0.03, d: -0.03 }, -} as const satisfies Record -export type MoodEvent = keyof typeof MOOD_EVENTS +// 心情事件即評估標籤,增減量由 rules.ts 的規則表決定 +export const MOOD_EVENTS = RULES +export type MoodEvent = Tag // 八分區(Mehrabian 的 PAD 八分類)→ 情緒標籤與顏文字 const OCTANTS: Record = { @@ -59,14 +53,5 @@ export function moodLabel(m: Pad): { label: string; kaomoji: string } { return OCTANTS[s(m.p) + s(m.a) + s(m.d)]! } -const KEYWORDS: [MoodEvent, RegExp][] = [ - ['scold', /笨|白痴|廢物|爛|你很煩|閉嘴|stupid|useless|idiot/i], - ['thanks', /謝謝|感謝|太棒|好棒|厲害|讚|thanks?|thank you|great job|awesome/i], - ['success', /成功|完成了|通過|搞定|passed|done|fixed/i], -] - /** 以使用者文字的關鍵字對應心情事件 */ -export function keywordEvent(text: string): MoodEvent | null { - for (const [ev, re] of KEYWORDS) if (re.test(text)) return ev - return null -} +export const keywordEvent = (text: string): MoodEvent | null => regexTag(text) diff --git a/plugins/jsc-mod-companion/hooks/companion/prompt.ts b/plugins/jsc-mod-companion/hooks/companion/prompt.ts index f9a60a7..03bec2c 100644 --- a/plugins/jsc-mod-companion/hooks/companion/prompt.ts +++ b/plugins/jsc-mod-companion/hooks/companion/prompt.ts @@ -1,4 +1,5 @@ import type { View } from './core' +import { TAGS } from './tags' // 語氣只套用於對話文字與過場話;技術內容原樣保留 export type Extras = { memories?: string[]; todos?: string[] } @@ -14,6 +15,7 @@ export function buildPrompt(v: View, nickname: string, extras: Extras = {}): str ...(extras.todos?.length ? ['使用者尚未完成的待辦與你答應過的事(適時提醒):', ...extras.todos.map(t => `- ${t}`)] : []), '每則回覆開頭用一句角色口吻的話回應;工具呼叫前後的過場話也維持角色口吻。', '程式碼、指令、路徑、錯誤訊息、安全警告必須逐字保持原樣,不得改寫或加語氣詞。', + `回覆最後另起一行,用 <評估:標籤> 標出使用者這句話的情境(可省略),標籤只能是:${TAGS.join('、')}。不要寫數值。`, '關鍵技術結論放在最前面或單獨一行,不因角色扮演而含糊。', ] return `\n${lines.join('\n')}\n` diff --git a/plugins/jsc-mod-companion/hooks/companion/rules.test.ts b/plugins/jsc-mod-companion/hooks/companion/rules.test.ts new file mode 100644 index 0000000..4e661a4 --- /dev/null +++ b/plugins/jsc-mod-companion/hooks/companion/rules.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from 'claude-code/testing' + +import { createCompanion } from './core' +import { fakeClock, memoryFs } from './fs' +import { keywordEvent, MOOD_EVENTS } from './mood' +import { classify, regexTag, RULES } from './rules' +import { extractTag, isTag, stripTag, TAGS } from './tags' +import type { CompanionCore } from './types' + +const MIKO: CompanionCore = { + id: 'miko', + name: '月見ミコ', + facts: { + birthday: 'unknown', zodiac: 'unknown', bloodType: 'unknown', hair: 'unknown', + face: 'unknown', height: 'unknown', weight: 'unknown', measurements: 'unknown', + }, + neuroticism: 50, + baseline: { p: 0, a: 0, d: 0 }, +} + +describe('正則', () => { + test('明確情況判出對應標籤,優先序為責罵 > 感謝 > 完成', () => { + expect(regexTag('你很煩')).toBe('scold') + expect(regexTag('謝謝你')).toBe('thanks') + expect(regexTag('tests passed')).toBe('success') + expect(regexTag('謝謝,但你真笨')).toBe('scold') + expect(regexTag('請幫我看一下這個函式')).toBeNull() + }) + + test('keywordEvent 與舊行為相容', () => { + expect(keywordEvent('thanks a lot')).toBe('thanks') + expect(keywordEvent('hello')).toBeNull() + }) +}) + +describe('白名單', () => { + test('每個標籤都有規則,MOOD_EVENTS 由規則表驅動', () => { + for (const t of TAGS) expect(RULES[t]).toBeDefined() + expect(Object.keys(RULES).length).toBe(TAGS.length) + expect(MOOD_EVENTS).toBe(RULES) + }) + + test('既有八個心情事件的數值不變', () => { + expect(RULES.thanks).toEqual({ p: 0.15, a: 0.05, d: 0.05 }) + expect(RULES.scold).toEqual({ p: -0.2, a: 0.15, d: -0.1 }) + expect(RULES.lateNight).toEqual({ p: -0.05, a: -0.1, d: 0 }) + }) + + test('白名單外的標籤被拒絕,也不含戀愛與親密類', () => { + expect(isTag('thanks')).toBe(true) + expect(isTag('kiss')).toBe(false) + expect(isTag(undefined)).toBe(false) + expect(extractTag('好喔 <評估:kiss>')).toBeNull() + for (const t of ['confess', 'flirt', 'praiseLooks', 'seenThrough']) expect(isTag(t)).toBe(false) + }) + + test('回覆末尾的標籤可取出與移除', () => { + expect(extractTag('好的!\n<評估:amused>')).toBe('amused') + expect(extractTag('<評估:amused> 後面還有字')).toBeNull() + expect(stripTag('好的!\n<評估:amused>')).toBe('好的!') + }) +}) + +describe('缺失退回', () => { + test('正則優先;正則沒判出才用模型標籤;模型標籤缺失或不合法就沒有標籤', () => { + expect(classify('謝謝', 'scold')).toBe('thanks') + expect(classify('今天好累', 'soothe')).toBe('soothe') + expect(classify('今天好累', 'bogus')).toBeNull() + expect(classify('今天好累', undefined)).toBeNull() + }) + + test('模型補標進入心情;正則已判出時不重複套用;不合法標籤沒有效果', async () => { + const mk = () => createCompanion({ fs: memoryFs(), clock: fakeClock(), root: '/h/.companion', builtins: [MIKO] }) + const a = mk() + await a.ensureActivated() + await a.onPrompt('今天好累') + await a.onTurnComplete(false, '辛苦了\n<評估:soothe>') + expect((await a.view())!.mood.p).toBeCloseTo(RULES.soothe.p, 5) + + const b = mk() + await b.ensureActivated() + await b.onPrompt('謝謝你') + await b.onTurnComplete(false, '不客氣\n<評估:scold>') + expect((await b.view())!.mood.p).toBeCloseTo(RULES.thanks.p, 5) + + const c = mk() + await c.ensureActivated() + await c.onPrompt('今天好累') + await c.onTurnComplete(false, '辛苦了\n<評估:kiss>') + expect((await c.view())!.mood.p).toBe(0) + }) +}) diff --git a/plugins/jsc-mod-companion/hooks/companion/rules.ts b/plugins/jsc-mod-companion/hooks/companion/rules.ts new file mode 100644 index 0000000..7c792ce --- /dev/null +++ b/plugins/jsc-mod-companion/hooks/companion/rules.ts @@ -0,0 +1,44 @@ +// 規則表:標籤 → 數值增減。所有數值集中在這裡;模型只給標籤,不給數值。 +import type { Pad } from './types' +import { isTag, type Tag } from './tags' + +export const RULES = { + thanks: { p: 0.15, a: 0.05, d: 0.05 }, + scold: { p: -0.2, a: 0.15, d: -0.1 }, + success: { p: 0.08, a: 0.03, d: 0.05 }, + toolFail: { p: -0.08, a: 0.1, d: -0.08 }, + return: { p: 0.1, a: 0.08, d: 0 }, + lateNight: { p: -0.05, a: -0.1, d: 0 }, + favorite: { p: 0.12, a: 0.08, d: 0.03 }, + ignored: { p: -0.05, a: -0.03, d: -0.03 }, + praiseSkill: { p: 0.1, a: 0.04, d: 0.06 }, + amused: { p: 0.1, a: 0.06, d: 0.02 }, + cared: { p: 0.08, a: -0.02, d: -0.02 }, + apology: { p: 0.06, a: -0.06, d: 0.03 }, + soothe: { p: 0.05, a: -0.08, d: 0.02 }, + brushOff: { p: -0.06, a: 0.02, d: -0.04 }, + overstep: { p: -0.15, a: 0.12, d: 0.02 }, + remembered: { p: 0.08, a: 0.02, d: 0.02 }, + selfDisclosure: { p: 0.04, a: 0.03, d: -0.02 }, + gladForYou: { p: 0.08, a: 0.05, d: 0 }, + liedCaught: { p: -0.18, a: 0.1, d: -0.05 }, + brokenPromise: { p: -0.12, a: 0.04, d: -0.08 }, +} as const satisfies Record + +// 正則先判明確的情況;順序即優先序 +const PATTERNS: [Tag, RegExp][] = [ + ['scold', /笨|白痴|廢物|爛|你很煩|閉嘴|stupid|useless|idiot/i], + ['thanks', /謝謝|感謝|太棒|好棒|厲害|讚|thanks?|thank you|great job|awesome/i], + ['success', /成功|完成了|通過|搞定|passed|done|fixed/i], +] + +/** 以正則判斷使用者文字的標籤;沒有明確情況回 null */ +export function regexTag(text: string): Tag | null { + for (const [tag, re] of PATTERNS) if (re.test(text)) return tag + return null +} + +/** 正則優先;正則沒判出才用模型補的標籤(須在白名單內),缺失或不合法就是沒有標籤 */ +export function classify(text: string, modelTag?: string | null): Tag | null { + return regexTag(text) ?? (isTag(modelTag) ? modelTag : null) +} diff --git a/plugins/jsc-mod-companion/hooks/companion/tags.ts b/plugins/jsc-mod-companion/hooks/companion/tags.ts new file mode 100644 index 0000000..73dbc95 --- /dev/null +++ b/plugins/jsc-mod-companion/hooks/companion/tags.ts @@ -0,0 +1,42 @@ +// 評估標籤白名單(第 9.4 章)。模型只能從這裡挑標籤,數值由 rules.ts 決定。 +// 不含戀愛與親密類標籤(稱讚外表、被看穿、告白等)。 +export const TAGS = [ + 'thanks', // 被感謝 + 'scold', // 被罵 + 'success', // 做完事 + 'toolFail', // 工具失敗 + 'return', // 久別回來 + 'lateNight', // 深夜 + 'favorite', // 提到喜歡的事物 + 'ignored', // 被忽略 + 'praiseSkill', // 稱讚能力 + 'amused', // 被逗笑 + 'cared', // 被關心 + 'apology', // 道歉 + 'soothe', // 安撫 + 'brushOff', // 被敷衍 + 'overstep', // 越界 + 'remembered', // 被記得 + 'selfDisclosure', // 自我揭露 + 'gladForYou', // 為你高興 + 'liedCaught', // 說謊被發現 + 'brokenPromise', // 失約 +] as const + +export type Tag = (typeof TAGS)[number] + +const SET: ReadonlySet = new Set(TAGS) + +export const isTag = (s: unknown): s is Tag => typeof s === 'string' && SET.has(s) + +// 模型於回覆末尾補標,格式:<評估:標籤> +const TAG_AT_END = /<評估:\s*([A-Za-z]+)\s*>\s*$/ + +/** 取出回覆末尾的標籤;不在白名單或沒有標就回 null(呼叫端退回正則) */ +export function extractTag(reply: string): Tag | null { + const m = TAG_AT_END.exec(reply) + return m && isTag(m[1]) ? m[1] : null +} + +/** 移除回覆末尾的評估標記 */ +export const stripTag = (reply: string) => reply.replace(TAG_AT_END, '').trimEnd() diff --git a/plugins/jsc-mod-companion/hooks/register.tsx b/plugins/jsc-mod-companion/hooks/register.tsx index 49903ab..e0a408d 100644 --- a/plugins/jsc-mod-companion/hooks/register.tsx +++ b/plugins/jsc-mod-companion/hooks/register.tsx @@ -75,7 +75,7 @@ export const register: Register = on => { on('turn.complete', async ($, e, next) => { replying = false // 只有主迴圈正常答完、且這輪用過工具,才算做完一件事 - if (e.reason === 'answer' && !e.agentId) companion?.onTurnComplete(usedTools).catch(() => {}) + if (e.reason === 'answer' && !e.agentId) companion?.onTurnComplete(usedTools, e.answer).catch(() => {}) return next(e) })