feat: 完成 D 群組 — 情緒子系統
- RuleBasedEmotionTagger:關鍵字比對輸出情緒類型與強度(衝突/稱讚/輕度負向/一般負向/正向五級優先序) - EmotionService:以資料庫 EmotionState 為單一事實來源,dominantState 取非平靜維度最高者, 半衰期時間衰減(預設 2 小時,可覆寫),狀態轉移時清零其餘維度只設定目標維度 - emotion-state-machine:只實作 wiki 狀態圖與可愛度設計明訂的邊(平靜/愉悅/低落/警戒/害羞/彆扭) - response-style:情緒 → 語氣/句長/主動性,供 F 群組上下文組裝器使用 - apps/api 新增 /emotion/* 端點作為引擎驗證介面 - scripts/smoke/D.mjs:涵蓋 D-1~D-4 全部轉移邊、回應風格對應、時間衰減與跨 session 殘留驗證 npm run restart && npm run smoke -- D 皆通過(D-V),A/B/C 群組冒煙測試無回歸。
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
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";
|
||||
|
||||
// D-2 時間衰減:預設半衰期 2 小時,實際速度由角色參數(G 群組)覆寫。
|
||||
const DEFAULT_HALF_LIFE_MS = 2 * 60 * 60 * 1000;
|
||||
// 低於此值視為「沒有明顯情緒」,dominantState 回報平靜。
|
||||
const DOMINANT_EPSILON = 5;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const signal = this.tagger.tag({ text, triggerThreshold: options.triggerThreshold });
|
||||
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<void> {
|
||||
await this.prisma.client.emotionState.update({
|
||||
where: { characterId },
|
||||
data: { ...dims, updatedAt: now },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export { dominantState };
|
||||
Reference in New Issue
Block a user