feat: companion 核心的人設注入、語氣、心情標記與互動記憶

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:27:34 +08:00
co-authored by Claude Sonnet 5.5
parent 030c599208
commit 83fd180872
10 changed files with 638 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
import assert from 'node:assert/strict'
import { describe, test } from 'node:test'
import { createCompanion } from './core.ts'
import type { Card } from './core.ts'
import { estimateTokens } from './inject.ts'
import { parseMoodTag } from './mood.ts'
import { redactSecrets } from './secrets.ts'
import type { Fs } from './types.ts'
// 記憶體檔案系統與假時鐘
function memFs(): Fs & { files: Map<string, string> } {
const files = new Map<string, string>()
return { files, read: async p => files.get(p) ?? null, write: async (p, d) => void files.set(p, d) }
}
const T0 = Date.parse('2026-10-12T10:00:00Z')
function clock(t = T0) {
return { now: () => t, set: (x: number) => (t = x), advance: (ms: number) => (t += ms) }
}
// 原創範例角色
const CARD: Card = {
name: '小澄',
lines: ['個性:開朗、愛照顧人', '口癖:句尾常說「喔」', '喜歡:紅茶'],
}
const KEY = 'sk-abcdefghijklmnop1234567890'
async function setup() {
const fs = memFs()
const clk = clock()
fs.files.set('/c/sumi/card.json', JSON.stringify(CARD))
const c = createCompanion({ fs, clock: clk, root: '/c', id: 'sumi' })
return { fs, clk, c }
}
describe('心情標記', () => {
test('解析並移除末尾標記', () => {
const r = parseMoodTag('好的喔!\n<!--mood p=0.1 a=-0.05 d=0-->')
assert.equal(r.text, '好的喔!')
assert.deepEqual(r.delta, { p: 0.1, a: -0.05, d: 0 })
})
test('格式錯誤時移除標記且無 delta', () => {
const r = parseMoodTag('好的喔!<!--mood p=abc-->')
assert.equal(r.text, '好的喔!')
assert.equal(r.delta, null)
})
test('缺失標記退回關鍵字規則', async () => {
const { c } = await setup()
await c.userTurn('謝謝你,太棒了')
const shown = await c.assistantReply('不客氣喔')
assert.equal(shown, '不客氣喔')
assert.ok((await c.mood()).pad.p > 0)
})
test('標記有效時採用標記', async () => {
const { c } = await setup()
await c.userTurn('隨便聊聊')
await c.assistantReply('嗯喔<!--mood p=-0.2 a=0 d=0-->')
assert.ok((await c.mood()).pad.p < 0)
})
})
describe('心情上限與事件', () => {
test('單軸每回合變化不超過 0.3', async () => {
const { c } = await setup()
await c.userTurn('你很笨', ['scold', 'scold', 'scold'])
await c.assistantReply('嗚<!--mood p=-0.9 a=0.9 d=-0.9-->')
const { pad } = await c.mood()
assert.ok(Math.abs(pad.p) <= 0.3 + 1e-9 && Math.abs(pad.a) <= 0.3 + 1e-9 && Math.abs(pad.d) <= 0.3 + 1e-9)
assert.ok(pad.p < 0)
})
test('事件表:稱讚提升愉悅', async () => {
const { c } = await setup()
await c.userTurn('做得好', ['praise'])
assert.ok(Math.abs((await c.mood()).pad.p - 0.15) < 1e-9)
})
})
describe('好感度', () => {
test('有意義互動 +0.01,每日上限 +0.05', async () => {
const { c } = await setup()
await c.userTurn('今天的進度如何')
assert.ok(Math.abs((await c.affinity()).value - 0.01) < 1e-9)
for (let i = 0; i < 20; i++) await c.userTurn('繼續處理這個問題吧')
assert.ok(Math.abs((await c.affinity()).value - 0.05) < 1e-9)
})
test('負面事件 −0.03', async () => {
const { c } = await setup()
await c.userTurn('隨便', ['scold'])
assert.ok(Math.abs((await c.affinity()).value + 0.03) < 1e-9)
})
test('離開超過 7 天後回落', async () => {
const { c, clk } = await setup()
for (let i = 0; i < 5; i++) await c.userTurn('我們繼續吧', [])
const before = (await c.affinity()).value
clk.advance(37 * 86_400_000)
assert.ok(Math.abs((await c.affinity()).value - before / 2) < 1e-9)
})
})
describe('互動記憶', () => {
test('與原著記憶分開存放,且只存對話文字', async () => {
const { c, fs } = await setup()
await c.userTurn('幫我看看這段')
await c.assistantReply('好喔\n```ts\nconst a = 1\n```\n看完了<!--mood p=0 a=0 d=0-->')
const key = [...fs.files.keys()].find(k => k.includes('/interaction/'))!
assert.ok(key.startsWith('/c/sumi/interaction/'))
const stored = fs.files.get(key)!
assert.ok(!stored.includes('const a = 1'))
assert.ok(!stored.includes('mood'))
})
test('金鑰在寫入前被過濾', async () => {
const { c, fs } = await setup()
await c.userTurn(`我的金鑰是 ${KEY} 請記住`)
for (const v of fs.files.values()) assert.ok(!v.includes(KEY))
})
test('累積後壓縮成摘要', async () => {
const { c, fs } = await setup()
for (let i = 0; i < 35; i++) await c.userTurn(`第 ${i} 則訊息內容`)
const m = JSON.parse(fs.files.get('/c/sumi/interaction/memory.json')!)
assert.ok(m.recent.length <= 30)
assert.ok(m.summary.length > 0)
})
})
describe('人設注入', () => {
test('角色卡在前、心情與記憶在後', async () => {
const { c } = await setup()
await c.userTurn('我喜歡紅茶')
const out = await c.buildInjection()
assert.ok(out.indexOf('## 角色') < out.indexOf('## 互動記憶'))
assert.ok(out.indexOf('## 互動記憶') < out.indexOf('## 目前心情'))
})
test('不含疑似金鑰與工具輸出', async () => {
const { c } = await setup()
await c.userTurn(`token=${KEY}`)
await c.assistantReply(`好的 ${KEY}<!--mood p=0 a=0 d=0-->`)
const out = await c.buildInjection({ care: [`提醒 ${KEY}`], lore: [{ text: `設定 ${KEY}`, score: 1 }] })
assert.ok(!out.includes(KEY))
assert.equal(redactSecrets(out), out)
})
test('超出預算時截斷低分項目', async () => {
const { c } = await setup()
for (let i = 0; i < 30; i++) await c.userTurn(`這是一則相當長的訊息,用來撐滿預算,編號 ${i},${'字'.repeat(60)}`)
const out = await c.buildInjection()
const mem = out.split('## 互動記憶\n')[1]!.split('\n\n')[0]!
assert.ok(estimateTokens(mem) <= 800)
assert.ok(mem.includes('編號 29'))
assert.ok(!mem.includes('編號 0,'))
assert.ok(estimateTokens(out) <= 1600)
})
test('tone off 時不注入語氣規則', async () => {
const { c } = await setup()
assert.ok((await c.buildInjection()).includes('逐字保持原樣'))
assert.equal(await c.command('tone off'), '語氣已關閉')
assert.ok(!(await c.buildInjection()).includes('逐字保持原樣'))
await c.command('tone on')
assert.ok((await c.buildInjection()).includes('逐字保持原樣'))
})
})