feat: jsc-mod-companion 情緒 8 維度與舊存檔升級 #49
@@ -83,6 +83,16 @@
|
||||
/jsc-mod-companion:ingest chapter1.md 章節編號 1
|
||||
```
|
||||
|
||||
## 人格 18 參數(與第 9 章的差異)
|
||||
|
||||
人格參數依 wiki 9.2 實作於 `hooks/companion/core/params.ts`:
|
||||
|
||||
- 三層值:角色包基準(`core.json` 的 `params`)+存檔偏移(每次 ±1,上限 ±15)+今日偏移(N(0,5),上限 ±10),總值限制在 0–100。
|
||||
- 衍生值:有效心防、有效害羞、有效口是心非,公式同 9.2;信賴與親密以 0–100 輸入。
|
||||
- Big Five 保留為推導值:有 `params` 時,神經質(心情半衰期)與外向性(關心頻率)改由 18 參數推導,加權為設計啟發式;沒有 `params` 的舊角色沿用 `neuroticism`、`extraversion`。ingest 的 Big Five 推論與證據機制不變。
|
||||
- 姓名、性別、年齡、核心信念不可由 AI 改動,`filterAiPatch` 會剔除這些欄位。
|
||||
- 目前只提供計算與儲存欄位(`state.json` 的 `params`);每日抽樣、存檔偏移累積的觸發時機留待後續工作項目。
|
||||
|
||||
## 評估標籤
|
||||
|
||||
使用者每句話會對應到一個評估標籤,再由 `rules.ts` 的規則表換成心情增減量;模型只負責分類,不給數值。
|
||||
|
||||
@@ -6,6 +6,7 @@ import { applyEvent, baselineOf, decayEmotions, dominant, fromPad, gauge, ranked
|
||||
import type { Emotions } from './emotion'
|
||||
import { 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'
|
||||
@@ -93,7 +94,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
||||
if (!core || !st) return null
|
||||
const now = clock.now()
|
||||
const base = baseOf(core)
|
||||
const emotions = decayEmotions(st.emotion, base, now, core.neuroticism, tzMinutes((await getUser()).tz))
|
||||
const emotions = decayEmotions(st.emotion, base, now, neuroticismOf(core, st), tzMinutes((await getUser()).tz))
|
||||
const mood = toPad(emotions, base, core.baseline)
|
||||
const main = dominant(emotions)
|
||||
const aff = idleDecay(st.affinity, now)
|
||||
@@ -113,7 +114,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
||||
async function moodEvent(ev: MoodEvent) {
|
||||
const tz = (await getUser()).tz
|
||||
await mutate((core, st, now) => {
|
||||
const cur = decayEmotions(st.emotion, baseOf(core), now, core.neuroticism, tzMinutes(tz))
|
||||
const cur = decayEmotions(st.emotion, baseOf(core), now, neuroticismOf(core, st), tzMinutes(tz))
|
||||
const next = applyEvent(cur, st.habit, ev, now)
|
||||
return { ...st, emotion: { ...next.emotions, at: now }, habit: next.habit }
|
||||
})
|
||||
@@ -222,10 +223,11 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
|
||||
const v = await view()
|
||||
if (!v) return null
|
||||
const u = await getUser()
|
||||
const st = await getState(v.core.id)
|
||||
const qs = hourOf(u.quiet.start)
|
||||
const qe = hourOf(u.quiet.end)
|
||||
ctx = {
|
||||
profile: { attribute: v.core.attribute ?? 'gentle', extraversion: v.core.extraversion ?? 50, stage: CARE_STAGES[STAGES.indexOf(v.stage as (typeof STAGES)[number])] ?? 'stranger', nickname: u.nickname, freq: v.core.freq },
|
||||
profile: { attribute: v.core.attribute ?? 'gentle', extraversion: extraversionOf(v.core, st ?? undefined), stage: CARE_STAGES[STAGES.indexOf(v.stage as (typeof STAGES)[number])] ?? 'stranger', nickname: u.nickname, freq: v.core.freq },
|
||||
settings: { dailyCap: u.careLimit, tzOffsetMin: tzMinutes(u.tz), quietHours: qs !== null && qe !== null ? { start: qs, end: qe } : undefined },
|
||||
}
|
||||
if (bundle?.id !== v.core.id) {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, test } from 'claude-code/testing'
|
||||
import assert from '../assert'
|
||||
|
||||
import { applyInference, initPersonality } from './personality'
|
||||
import {
|
||||
baseFor, deriveBig5, effective, extraversionOf, filterAiPatch, initLayers, neuroticismOf, nudgeSave, PARAMS, rollToday, value, values,
|
||||
} from './params'
|
||||
|
||||
const near = (a: number, b: number, eps = 1) => assert.ok(Math.abs(a - b) <= eps, `${a} 應接近 ${b}`)
|
||||
|
||||
describe('18 參數三層值', () => {
|
||||
test('共 18 個參數,無屬性時基準為 50', () => {
|
||||
assert.equal(PARAMS.length, 18)
|
||||
assert.ok(Object.values(baseFor([])).every(v => v === 50))
|
||||
})
|
||||
|
||||
test('存檔偏移每次 ±1,上限 ±15', () => {
|
||||
let l = initLayers(baseFor([]))
|
||||
for (let i = 0; i < 40; i++) l = nudgeSave(l, 'shy', 1)
|
||||
assert.equal(l.save.shy, 15)
|
||||
assert.equal(value(l, 'shy'), 65)
|
||||
l = nudgeSave(l, 'shy', -1)
|
||||
assert.equal(l.save.shy, 14)
|
||||
for (let i = 0; i < 60; i++) l = nudgeSave(l, 'guard', -1)
|
||||
assert.equal(l.save.guard, -15)
|
||||
})
|
||||
|
||||
test('今日偏移限制在 ±10,且可由 rng 重現', () => {
|
||||
const seq = (s: number) => () => ((s = (s * 16807) % 2147483647) / 2147483647)
|
||||
const a = rollToday(initLayers(baseFor([])), seq(7))
|
||||
const b = rollToday(initLayers(baseFor([])), seq(7))
|
||||
assert.deepEqual(a.today, b.today)
|
||||
assert.ok(PARAMS.every(p => Math.abs(a.today[p]!) <= 10))
|
||||
// 極端抽樣也被截斷
|
||||
const c = rollToday(initLayers(baseFor([])), (() => { let n = 0; return () => (n++ % 2 === 0 ? 1e-12 : 0) })())
|
||||
assert.ok(PARAMS.every(p => Math.abs(c.today[p]!) <= 10))
|
||||
assert.ok(PARAMS.some(p => Math.abs(c.today[p]!) === 10))
|
||||
})
|
||||
|
||||
test('今日偏移的標準差約為 5', () => {
|
||||
let s = 12345
|
||||
const rng = () => ((s = (s * 16807) % 2147483647) / 2147483647)
|
||||
const xs: number[] = []
|
||||
for (let i = 0; i < 300; i++) xs.push(...Object.values(rollToday(initLayers(baseFor([])), rng).today) as number[])
|
||||
const mean = xs.reduce((a, b) => a + b, 0) / xs.length
|
||||
const sd = Math.sqrt(xs.reduce((a, b) => a + (b - mean) ** 2, 0) / xs.length)
|
||||
near(mean, 0, 0.3)
|
||||
near(sd, 4.7, 0.6) // 截斷在 ±2σ 使標準差略小於 5
|
||||
})
|
||||
|
||||
test('參數值 = 基準 + 存檔 + 今日,並限制在 0–100', () => {
|
||||
let l = initLayers({ ...baseFor([]), shy: 95 })
|
||||
for (let i = 0; i < 15; i++) l = nudgeSave(l, 'shy', 1)
|
||||
l = { ...l, today: { shy: 8 } }
|
||||
assert.equal(value(l, 'shy'), 100)
|
||||
assert.equal(values(l).blush, 50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('有效參數', () => {
|
||||
const l = initLayers({ ...baseFor([]), guard: 50, shy: 80, tsun: 70 })
|
||||
test('有效心防:心防 × (1 − 0.5 × 信賴),心防 50、信賴 90 約 28', () => {
|
||||
near(effective(l, 90, 0).guard, 27.5, 0.01)
|
||||
assert.equal(effective(l, 0, 0).guard, 50)
|
||||
})
|
||||
test('有效害羞:害羞 × (1 − 0.3 × 親密)', () => {
|
||||
near(effective(l, 0, 100).shy, 56, 0.01)
|
||||
})
|
||||
test('有效口是心非:口是心非 × (1 − 0.4 × 親密)', () => {
|
||||
near(effective(l, 0, 50).tsun, 56, 0.01)
|
||||
near(effective(l, 0, 100).tsun, 42, 0.01)
|
||||
})
|
||||
test('信賴與親密超出範圍時被截斷', () => {
|
||||
assert.equal(effective(l, 500, 500).guard, 25)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Big Five 推導值與 ingest 相容', () => {
|
||||
test('心防、忌妒越高,推導的神經質越高', () => {
|
||||
const calm = deriveBig5(values(initLayers(baseFor(['onee']))))
|
||||
const edgy = deriveBig5(values(initLayers(baseFor(['yandere']))))
|
||||
assert.ok(edgy.N > calm.N)
|
||||
})
|
||||
test('主動度與玩心越高,推導的外向性越高', () => {
|
||||
assert.ok(deriveBig5(values(initLayers(baseFor(['genki'])))).E > deriveBig5(values(initLayers(baseFor(['dandere'])))).E)
|
||||
})
|
||||
test('推導值落在 0–100', () => {
|
||||
for (const a of ['tsundere', 'genki', 'kuudere', 'dandere', 'yandere', 'onee']) {
|
||||
assert.ok(Object.values(deriveBig5(values(initLayers(baseFor([a]))))).every(x => x >= 0 && x <= 100))
|
||||
}
|
||||
})
|
||||
test('ingest 的 Big Five 推論機制不受影響', () => {
|
||||
const p = applyInference(initPersonality(['tsundere']), { N: { value: 99, evidence: [{ chapter: '1', quote: 'x' }] } })
|
||||
assert.equal(p.dims.N.value, 85)
|
||||
})
|
||||
})
|
||||
|
||||
describe('核心設定保護', () => {
|
||||
test('姓名、性別、年齡、核心信念不可由 AI 改動', () => {
|
||||
const r = filterAiPatch({ name: '新名', gender: 'm', age: 30, coreBelief: 'x', shy: 60 })
|
||||
assert.deepEqual(r.rejected.sort(), ['age', 'coreBelief', 'gender', 'name'])
|
||||
assert.deepEqual(r.allowed, { shy: 60 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Big Five 推導值接上半衰期與關心頻率', () => {
|
||||
const core = { neuroticism: 40, extraversion: 60 }
|
||||
test('沒有參數時沿用舊欄位', () => {
|
||||
assert.equal(neuroticismOf(core), 40)
|
||||
assert.equal(extraversionOf(core), 60)
|
||||
})
|
||||
test('有參數時改由三層值推導,存檔偏移會影響結果', () => {
|
||||
const params = baseFor(['tsundere'])
|
||||
const a = neuroticismOf({ ...core, params })
|
||||
const b = neuroticismOf({ ...core, params }, { params: { save: { guard: 15, jealous: 15 }, today: {} } })
|
||||
assert.notEqual(a, 40)
|
||||
assert.ok(b > a)
|
||||
assert.equal(extraversionOf({ ...core, params }), deriveBig5(params).E)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Vec } from './personality'
|
||||
|
||||
/** 第 9.2 章的 18 個人格參數,0–100,越高越多 */
|
||||
export const PARAMS = [
|
||||
'shy', 'blush', 'clingy', 'tsun', 'tone',
|
||||
'jealous', 'depend', 'guard', 'recover', 'miss',
|
||||
'initiative', 'assertive', 'playful', 'caring', 'gentle',
|
||||
'cute', 'health', 'imagine',
|
||||
] as const
|
||||
export type Param = (typeof PARAMS)[number]
|
||||
export type ParamVec = Record<Param, number>
|
||||
|
||||
export const PARAM_LABELS: Record<Param, string> = {
|
||||
shy: '害羞傾向', blush: '臉紅傾向', clingy: '撒嬌傾向', tsun: '口是心非度', tone: '語尾與顏文字使用度',
|
||||
jealous: '忌妒敏感度', depend: '依賴傾向', guard: '心防', recover: '釋懷速度', miss: '想念累積速度',
|
||||
initiative: '主動度', assertive: '自我主張', playful: '玩心', caring: '照顧欲', gentle: '溫柔度',
|
||||
cute: '服裝可愛偏好', health: '體質', imagine: '想像力',
|
||||
}
|
||||
|
||||
export const SAVE_STEP = 1
|
||||
export const SAVE_LIMIT = 15
|
||||
export const TODAY_SIGMA = 5
|
||||
export const TODAY_LIMIT = 10
|
||||
|
||||
/** 三層值:角色包基準(本機 core.json)、存檔偏移與今日偏移(state.json) */
|
||||
export type ParamLayers = { base: ParamVec; save: Partial<ParamVec>; today: Partial<ParamVec> }
|
||||
|
||||
const clamp = (x: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, x))
|
||||
|
||||
const NEUTRAL = Object.fromEntries(PARAMS.map(p => [p, 50])) as ParamVec
|
||||
|
||||
/** 屬性標籤對 18 參數的先驗(設計啟發式);未列出的參數維持 50 */
|
||||
export const ARCHETYPE_PARAMS: Record<string, Partial<ParamVec>> = {
|
||||
tsundere: { shy: 75, blush: 75, tsun: 85, guard: 65, assertive: 65, gentle: 35, jealous: 65, recover: 40 },
|
||||
genki: { shy: 25, tone: 80, initiative: 85, playful: 75, depend: 55, guard: 25, recover: 75, health: 75, gentle: 60 },
|
||||
kuudere: { shy: 30, blush: 25, tone: 20, initiative: 25, guard: 65, tsun: 40, playful: 25, gentle: 40 },
|
||||
dandere: { shy: 85, blush: 80, tone: 30, initiative: 20, guard: 70, depend: 60, assertive: 25, gentle: 65 },
|
||||
yandere: { jealous: 90, depend: 85, guard: 75, recover: 20, miss: 85, tsun: 35, caring: 75, assertive: 70 },
|
||||
onee: { gentle: 85, caring: 85, shy: 25, blush: 25, playful: 65, guard: 40, recover: 70, assertive: 60 },
|
||||
}
|
||||
|
||||
/** 以屬性標籤產生角色包基準:多個標籤取平均 */
|
||||
export function baseFor(archetypes: string[]): ParamVec {
|
||||
const known = archetypes.filter(a => ARCHETYPE_PARAMS[a])
|
||||
const out = { ...NEUTRAL }
|
||||
if (!known.length) return out
|
||||
for (const p of PARAMS) out[p] = known.reduce((s, a) => s + (ARCHETYPE_PARAMS[a]![p] ?? 50), 0) / known.length
|
||||
return out
|
||||
}
|
||||
|
||||
export function initLayers(base: ParamVec): ParamLayers {
|
||||
return { base: { ...base }, save: {}, today: {} }
|
||||
}
|
||||
|
||||
/** 存檔偏移:每次 ±1,上限 ±15 */
|
||||
export function nudgeSave(l: ParamLayers, p: Param, dir: 1 | -1): ParamLayers {
|
||||
const next = clamp((l.save[p] ?? 0) + dir * SAVE_STEP, -SAVE_LIMIT, SAVE_LIMIT)
|
||||
return { ...l, save: { ...l.save, [p]: next } }
|
||||
}
|
||||
|
||||
/** 每日抽樣今日偏移:N(0, 5),限制 ±10;rng 回傳 [0,1),可注入以便測試 */
|
||||
export function rollToday(l: ParamLayers, rng: () => number = Math.random): ParamLayers {
|
||||
const today: Partial<ParamVec> = {}
|
||||
for (const p of PARAMS) {
|
||||
const u1 = Math.max(rng(), 1e-12)
|
||||
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * rng())
|
||||
today[p] = clamp(z * TODAY_SIGMA, -TODAY_LIMIT, TODAY_LIMIT)
|
||||
}
|
||||
return { ...l, today }
|
||||
}
|
||||
|
||||
export function value(l: ParamLayers, p: Param): number {
|
||||
return clamp(l.base[p] + clamp(l.save[p] ?? 0, -SAVE_LIMIT, SAVE_LIMIT) + clamp(l.today[p] ?? 0, -TODAY_LIMIT, TODAY_LIMIT), 0, 100)
|
||||
}
|
||||
|
||||
export function values(l: ParamLayers): ParamVec {
|
||||
return Object.fromEntries(PARAMS.map(p => [p, value(l, p)])) as ParamVec
|
||||
}
|
||||
|
||||
/** 信賴與親密皆為 0–100 */
|
||||
export function effective(l: ParamLayers, trust: number, intimacy: number) {
|
||||
const t = clamp(trust, 0, 100) / 100
|
||||
const i = clamp(intimacy, 0, 100) / 100
|
||||
return {
|
||||
guard: value(l, 'guard') * (1 - 0.5 * t),
|
||||
shy: value(l, 'shy') * (1 - 0.3 * i),
|
||||
tsun: value(l, 'tsun') * (1 - 0.4 * i),
|
||||
}
|
||||
}
|
||||
|
||||
/** Big Five 推導值:沿用半衰期(N)與關心頻率(E);加權為設計啟發式 */
|
||||
export function deriveBig5(v: ParamVec): Vec {
|
||||
const inv = (x: number) => 100 - x
|
||||
const avg = (xs: [number, number][]) => xs.reduce((s, [x, w]) => s + x * w, 0)
|
||||
return {
|
||||
O: avg([[v.imagine, 0.5], [v.playful, 0.3], [v.initiative, 0.2]]),
|
||||
C: avg([[v.caring, 0.4], [v.assertive, 0.3], [inv(v.playful), 0.3]]),
|
||||
E: avg([[v.initiative, 0.35], [v.playful, 0.25], [v.tone, 0.2], [inv(v.shy), 0.2]]),
|
||||
A: avg([[v.gentle, 0.4], [v.caring, 0.3], [inv(v.assertive), 0.15], [inv(v.tsun), 0.15]]),
|
||||
N: avg([[v.guard, 0.25], [v.jealous, 0.25], [v.depend, 0.2], [v.shy, 0.15], [inv(v.recover), 0.15]]),
|
||||
}
|
||||
}
|
||||
|
||||
/** 核心設定:AI 不可改動 */
|
||||
export const LOCKED_FIELDS = ['name', 'gender', 'age', 'coreBelief'] as const
|
||||
|
||||
/** 套用 AI 的修改提案:鎖定欄位一律剔除並回報,其餘原樣放行 */
|
||||
export function filterAiPatch<T extends Record<string, unknown>>(patch: T): { allowed: Partial<T>; rejected: string[] } {
|
||||
const allowed: Record<string, unknown> = {}
|
||||
const rejected: string[] = []
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if ((LOCKED_FIELDS as readonly string[]).includes(k)) rejected.push(k)
|
||||
else allowed[k] = v
|
||||
}
|
||||
return { allowed: allowed as Partial<T>, rejected }
|
||||
}
|
||||
|
||||
type WithParams = { params?: ParamVec; neuroticism?: number; extraversion?: number }
|
||||
type StateParams = { params?: { save: Partial<ParamVec>; today: Partial<ParamVec> } }
|
||||
|
||||
/** 有角色包基準時,用三層值推導 Big Five;否則維持 core.json 的舊欄位 */
|
||||
function derived(core: WithParams, st?: StateParams) {
|
||||
if (!core.params) return null
|
||||
return deriveBig5(values({ base: core.params, save: st?.params?.save ?? {}, today: st?.params?.today ?? {} }))
|
||||
}
|
||||
export const neuroticismOf = (core: WithParams, st?: StateParams) => derived(core, st)?.N ?? core.neuroticism ?? 50
|
||||
export const extraversionOf = (core: WithParams, st?: StateParams) => derived(core, st)?.E ?? core.extraversion ?? 50
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Attribute, Trigger } from './care'
|
||||
import type { EmotionState, Emotions, Habit } from './emotion'
|
||||
import type { ParamVec } from './core/params'
|
||||
|
||||
export type Pad = { p: number; a: number; d: number }
|
||||
|
||||
@@ -24,6 +25,8 @@ export type CompanionCore = {
|
||||
freq?: Partial<Record<Trigger, number>>
|
||||
// 神經質 0~100,影響心情半衰期;個性推導屬於後續里程碑,先用預設值
|
||||
neuroticism: number
|
||||
// 人格 18 參數的角色包基準;有值時神經質改由參數推導
|
||||
params?: ParamVec
|
||||
baseline: Pad
|
||||
// 八維度個別基線(覆寫由 baseline 推得的值),角色專屬值放本機 core.json
|
||||
emotionBaseline?: Partial<Emotions>
|
||||
@@ -45,6 +48,8 @@ export type CompanionState = {
|
||||
habit: Habit
|
||||
affinity: Affinity
|
||||
todos: number
|
||||
// 人格 18 參數的存檔偏移與今日偏移(今日偏移於 dayKey 當天有效)
|
||||
params?: { save: Partial<ParamVec>; today: Partial<ParamVec>; dayKey: string }
|
||||
}
|
||||
|
||||
export type UserSettings = {
|
||||
|
||||
Reference in New Issue
Block a user