Merge pull request 'feat: jsc-mod-companion 人格 18 參數與三層值' (#48) from issue-43 into main

Reviewed-on: https://gitea.jsc.idv.tw/jiantw83/jsc-marketplace/pulls/48
This commit was merged in pull request #48.
This commit is contained in:
2026-10-14 08:01:00 +00:00
6 changed files with 268 additions and 4 deletions
@@ -1,6 +1,6 @@
{ {
"name": "jsc-mod-companion", "name": "jsc-mod-companion",
"version": "0.0.1", "version": "0.0.2",
"description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色", "description": "小說角色陪伴:人設注入、語氣、心情與好感、互動記憶、主動關心與待辦,並可用 ingest skill 解析小說建立角色",
"license": "MIT", "license": "MIT",
"author": { "author": {
+10
View File
@@ -82,6 +82,16 @@
/jsc-mod-companion:ingest chapter1.md 章節編號 1 /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` 的規則表換成心情增減量;模型只負責分類,不給數值。 使用者每句話會對應到一個評估標籤,再由 `rules.ts` 的規則表換成心情增減量;模型只負責分類,不給數值。
@@ -4,6 +4,7 @@ import { readJson, writeJson } from './fs'
import type { Clock, Fs } from './fs' import type { Clock, Fs } from './fs'
import { applyDelta, decay, keywordEvent, MOOD_EVENTS, moodLabel } from './mood' import { applyDelta, decay, keywordEvent, MOOD_EVENTS, moodLabel } from './mood'
import type { MoodEvent } from './mood' import type { MoodEvent } from './mood'
import { extraversionOf, neuroticismOf } from './core/params'
import { extractTag } from './tags' import { extractTag } from './tags'
import { createCare } from './care' import { createCare } from './care'
import type { Care, CareProfile, CareSettings, CareEngine, Signals } from './care' import type { Care, CareProfile, CareSettings, CareEngine, Signals } from './care'
@@ -79,7 +80,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
const st = await getState(active) const st = await getState(active)
if (!core || !st) return null if (!core || !st) return null
const now = clock.now() const now = clock.now()
const mood = decay(st.mood, core.baseline, now - st.mood.at, core.neuroticism) const mood = decay(st.mood, core.baseline, now - st.mood.at, neuroticismOf(core, st))
const aff = idleDecay(st.affinity, now) const aff = idleDecay(st.affinity, now)
const todos = (await readJson<{ done: boolean }[]>(fs, `${dir(active)}/todos.json`))?.filter(t => !t.done).length ?? 0 const todos = (await readJson<{ done: boolean }[]>(fs, `${dir(active)}/todos.json`))?.filter(t => !t.done).length ?? 0
return { core, mood, ...moodLabel(mood), affinity: aff.value, stage: STAGES[aff.stage]!, todos } return { core, mood, ...moodLabel(mood), affinity: aff.value, stage: STAGES[aff.stage]!, todos }
@@ -96,7 +97,7 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
async function moodEvent(ev: MoodEvent) { async function moodEvent(ev: MoodEvent) {
await mutate((core, st, now) => { await mutate((core, st, now) => {
const cur = decay(st.mood, core.baseline, now - st.mood.at, core.neuroticism) const cur = decay(st.mood, core.baseline, now - st.mood.at, neuroticismOf(core, st))
return { ...st, mood: { ...applyDelta(cur, MOOD_EVENTS[ev]), at: now } } return { ...st, mood: { ...applyDelta(cur, MOOD_EVENTS[ev]), at: now } }
}) })
} }
@@ -201,10 +202,11 @@ export function createCompanion({ fs, clock, root, builtins = [FUBUKI] }: Compan
const v = await view() const v = await view()
if (!v) return null if (!v) return null
const u = await getUser() const u = await getUser()
const st = await getState(v.core.id)
const qs = hourOf(u.quiet.start) const qs = hourOf(u.quiet.start)
const qe = hourOf(u.quiet.end) const qe = hourOf(u.quiet.end)
ctx = { 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 }, settings: { dailyCap: u.careLimit, tzOffsetMin: tzMinutes(u.tz), quietHours: qs !== null && qe !== null ? { start: qs, end: qe } : undefined },
} }
if (bundle?.id !== v.core.id) { 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,4 +1,5 @@
import type { Attribute, Trigger } from './care' import type { Attribute, Trigger } from './care'
import type { ParamVec } from './core/params'
export type Pad = { p: number; a: number; d: number } export type Pad = { p: number; a: number; d: number }
@@ -23,6 +24,8 @@ export type CompanionCore = {
freq?: Partial<Record<Trigger, number>> freq?: Partial<Record<Trigger, number>>
// 神經質 0~100,影響心情半衰期;個性推導屬於後續里程碑,先用預設值 // 神經質 0~100,影響心情半衰期;個性推導屬於後續里程碑,先用預設值
neuroticism: number neuroticism: number
// 人格 18 參數的角色包基準;有值時神經質改由參數推導
params?: ParamVec
baseline: Pad baseline: Pad
} }
@@ -41,6 +44,8 @@ export type CompanionState = {
mood: Pad & { at: number } mood: Pad & { at: number }
affinity: Affinity affinity: Affinity
todos: number todos: number
// 人格 18 參數的存檔偏移與今日偏移(今日偏移於 dayKey 當天有效)
params?: { save: Partial<ParamVec>; today: Partial<ParamVec>; dayKey: string }
} }
export type UserSettings = { export type UserSettings = {