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"; // 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 }; } 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.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) }; } private async decayedDimensions( characterId: string, now: Date, halfLifeMs = DEFAULT_HALF_LIFE_MS, ): Promise<{ dims: Dimensions; updatedAt: Date }> { 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 }, }); } } export { dominantState };