199 lines
7.9 KiB
TypeScript
199 lines
7.9 KiB
TypeScript
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<EmotionTag, "CALM">[] = ["JOY", "SAD", "ALERT", "SHY", "GRUMPY"];
|
||
|
||
function fieldFor(tag: Exclude<EmotionTag, "CALM">): "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<EmotionState> {
|
||
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<ResponseStyle> {
|
||
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<void> {
|
||
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<void> {
|
||
await this.prisma.client.emotionState.update({
|
||
where: { characterId },
|
||
data: { ...dims, updatedAt: now },
|
||
});
|
||
await this.writeCache(characterId, dims, now);
|
||
}
|
||
}
|
||
|
||
export { dominantState };
|