import { Injectable } from "@nestjs/common"; import type { EmotionTag, EmotionState } from "@kokorone/shared"; import { PrismaService } from "../prisma/prisma.service.js"; import { RuleBasedEmotionTagger } from "./emotion-tagger.js"; import { nextEmotionState, type TransitionContext } from "./emotion-state-machine.js"; import { toResponseStyle, type ResponseStyle } from "./response-style.js"; import { getArchetypeParams } from "../personality/archetype-params.js"; import { getRedisClient } from "../cache/redis-client.js"; const REDIS_KEY_PREFIX = "kokorone:emotion-state:"; // D-2 時間衰減:預設半衰期 2 小時,實際速度由角色參數(G 群組)覆寫。 const DEFAULT_HALF_LIFE_MS = 2 * 60 * 60 * 1000; // 低於此值視為「沒有明顯情緒」,dominantState 回報平靜。 const DOMINANT_EPSILON = 5; // 標記強度低於此值視為刺激太弱,不足以真正觸發情緒轉移(高情緒觸發閾值的原型因此「難觸發」)。 const MIN_TRIGGER_INTENSITY = 0.25; interface Dimensions { calm: number; joy: number; sad: number; alert: number; shy: number; grumpy: number; } const NON_CALM_TAGS: Exclude[] = ["JOY", "SAD", "ALERT", "SHY", "GRUMPY"]; function fieldFor(tag: Exclude): "joy" | "sad" | "alert" | "shy" | "grumpy" { return tag.toLowerCase() as "joy" | "sad" | "alert" | "shy" | "grumpy"; } function dominantState(dims: Dimensions): EmotionTag { let best: EmotionTag = "CALM"; let bestValue = DOMINANT_EPSILON; for (const tag of NON_CALM_TAGS) { const value = dims[fieldFor(tag)]; if (value > bestValue) { best = tag; bestValue = value; } } return best; } function decayDimensions(dims: Dimensions, elapsedMs: number, halfLifeMs: number): Dimensions { const factor = elapsedMs > 0 ? Math.pow(0.5, elapsedMs / halfLifeMs) : 1; const joy = dims.joy * factor; const sad = dims.sad * factor; const alert = dims.alert * factor; const shy = dims.shy * factor; const grumpy = dims.grumpy * factor; const calm = Math.max(0, Math.min(100, 100 - (joy + sad + alert + shy + grumpy))); return { calm, joy, sad, alert, shy, grumpy }; } export interface ProcessInputOptions extends TransitionContext { triggerThreshold?: number; halfLifeMs?: number; now?: Date; // I-5:本輪對話沒有明確情緒訊號時,套用作息驅動的基線情緒(不覆蓋文字本身觸發的訊號)。 baselineSignal?: { tag: EmotionTag; intensity: number }; // P-5:語音輸入的副語言特徵(語速/音量/顫抖/停頓)分析出的訊號——文字說「沒事」但聲音在抖, // 優先於作息基線(副語言是這一輪輸入本身的訊號,比時段基線更直接),但仍不覆蓋文字本身觸發的訊號。 paralinguisticSignal?: { tag: EmotionTag; intensity: number }; } function toDomain(characterId: string, dims: Dimensions, updatedAt: Date): EmotionState { return { characterId, calm: dims.calm, joy: dims.joy, sad: dims.sad, alert: dims.alert, shy: dims.shy, grumpy: dims.grumpy, updatedAt: updatedAt.toISOString(), }; } @Injectable() export class EmotionService { constructor( private readonly prisma: PrismaService, private readonly tagger: RuleBasedEmotionTagger, ) {} async getState(characterId: string, now: Date = new Date()): Promise { const { dims, updatedAt } = await this.decayedDimensions(characterId, now); await this.persist(characterId, dims, now); return toDomain(characterId, dims, now); } async getResponseStyle(characterId: string, now: Date = new Date()): Promise { const state = await this.getState(characterId, now); return toResponseStyle(dominantState(state)); } async processInput( characterId: string, text: string, options: ProcessInputOptions = {}, ): Promise<{ state: EmotionState; style: ResponseStyle }> { const now = options.now ?? new Date(); const { dims: decayed } = await this.decayedDimensions(characterId, now, options.halfLifeMs); const currentDominant = dominantState(decayed); // G-1:情緒觸發閾值預設由角色性格原型決定,呼叫端仍可明確覆寫。 const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } }); const archetypeParams = getArchetypeParams(character.personalityArchetype); const triggerThreshold = options.triggerThreshold ?? archetypeParams.emotionTriggerThreshold; const rawSignal = this.tagger.tag({ text, triggerThreshold }); // 情緒觸發閾值除了縮放強度,也要能讓「難觸發」原型對弱刺激完全不觸發(而不只是觸發得比較小力)。 let signal = rawSignal.intensity < MIN_TRIGGER_INTENSITY ? { tag: "CALM" as const, intensity: 0 } : rawSignal; if (signal.tag === "CALM" && options.paralinguisticSignal) { signal = options.paralinguisticSignal; } else if (signal.tag === "CALM" && options.baselineSignal) { signal = options.baselineSignal; } const next = nextEmotionState(currentDominant, signal, options); let updated: Dimensions; if (next === currentDominant) { updated = decayed; } else if (next === "CALM") { updated = { calm: 100, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0 }; } else { const intensityValue = Math.max(20, signal.intensity * 100); updated = { calm: Math.max(0, 100 - intensityValue), joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0 }; updated[fieldFor(next)] = intensityValue; } await this.persist(characterId, updated, now); return { state: toDomain(characterId, updated, now), style: toResponseStyle(next) }; } // R-2:情緒狀態機的即時讀寫改走 Redis 當熱路徑快取,Prisma/SQLite(未來 Postgres) // 仍是唯一的持久真實來源——快取只省掉命中時的資料庫往返,Redis 不可用或未命中都直接退回原本 // 讀 Prisma 的行為,因此「重啟 api 後情緒狀態不遺失」這件事完全不受快取層是否存在影響。 private async readCached(characterId: string): Promise<{ dims: Dimensions; updatedAt: Date } | null> { try { const raw = await getRedisClient().get(REDIS_KEY_PREFIX + characterId); if (!raw) return null; const parsed = JSON.parse(raw) as Dimensions & { updatedAt: string }; const { updatedAt, ...dims } = parsed; return { dims, updatedAt: new Date(updatedAt) }; } catch { return null; } } private async writeCache(characterId: string, dims: Dimensions, updatedAt: Date): Promise { try { await getRedisClient().set( REDIS_KEY_PREFIX + characterId, JSON.stringify({ ...dims, updatedAt: updatedAt.toISOString() }), "EX", 60 * 60 * 24, ); } catch { // Redis 不可用時,Prisma 仍是持久真實來源,快取寫入失敗不影響正確性。 } } private async decayedDimensions( characterId: string, now: Date, halfLifeMs = DEFAULT_HALF_LIFE_MS, ): Promise<{ dims: Dimensions; updatedAt: Date }> { const cached = await this.readCached(characterId); if (cached) { const elapsedMs = now.getTime() - cached.updatedAt.getTime(); return { dims: decayDimensions(cached.dims, elapsedMs, halfLifeMs), updatedAt: cached.updatedAt }; } const row = await this.prisma.client.emotionState.upsert({ where: { characterId }, update: {}, create: { characterId }, }); const elapsedMs = now.getTime() - row.updatedAt.getTime(); const dims = decayDimensions(row, elapsedMs, halfLifeMs); return { dims, updatedAt: row.updatedAt }; } private async persist(characterId: string, dims: Dimensions, now: Date): Promise { await this.prisma.client.emotionState.update({ where: { characterId }, data: { ...dims, updatedAt: now }, }); await this.writeCache(characterId, dims, now); } } export { dominantState };