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:
Jeffery
2026-08-13 10:18:58 +08:00
parent e6f25008d1
commit 611c9b400c
9 changed files with 483 additions and 6 deletions
+2 -1
View File
@@ -2,9 +2,10 @@ import { Module } from "@nestjs/common";
import { HealthController } from "./health/health.controller.js";
import { CharactersModule } from "./characters/characters.module.js";
import { MemoryModule } from "./memory/memory.module.js";
import { EmotionModule } from "./emotion/emotion.module.js";
@Module({
imports: [CharactersModule, MemoryModule],
imports: [CharactersModule, MemoryModule, EmotionModule],
controllers: [HealthController],
})
export class AppModule {}
@@ -0,0 +1,44 @@
import type { EmotionTag } from "@kokorone/shared";
import type { EmotionSignal } from "./emotion-tagger.js";
export interface TransitionContext {
threatConfirmedSafe?: boolean; // 警戒 --> 平靜:確認無威脅
conflictContinues?: boolean; // 警戒 --> 低落:衝突持續
comforted?: boolean; // 低落 --> 平靜:被安慰
placated?: boolean; // 彆扭 --> 平靜:被哄
}
// D-4 情緒狀態機:只實作 wiki 狀態圖 + 可愛度設計明訂的邊,不外推未定義的轉移,
// 避免無來源依據地讓「愉悅/低落/害羞」彼此互轉。
export function nextEmotionState(
current: EmotionTag,
signal: EmotionSignal,
context: TransitionContext = {},
): EmotionTag {
if (current === "ALERT") {
if (context.threatConfirmedSafe) {
return "CALM"; // 警戒 --> 平靜:確認無威脅
}
if (context.conflictContinues) {
return "SAD"; // 警戒 --> 低落:衝突持續
}
return current;
}
if (current === "GRUMPY") {
if (context.placated) {
return "CALM"; // 彆扭 --> 平靜:被哄
}
return current;
}
if (current === "SAD" && context.comforted) {
return "CALM"; // 低落 --> 平靜:被安慰
}
if (current === "CALM" && signal.tag !== "CALM") {
return signal.tag; // 平靜 --> 愉悅/低落/警戒/害羞/彆扭:對應觸發訊號
}
return current;
}
+68
View File
@@ -0,0 +1,68 @@
import { Injectable } from "@nestjs/common";
import type { EmotionTag } from "@kokorone/shared";
export interface EmotionTaggerInput {
text: string;
// 角色觸發閾值:預設 1,數值越低代表角色越敏感、越容易被觸發(由 G 群組依性格原型設定)。
triggerThreshold?: number;
}
export interface EmotionSignal {
tag: EmotionTag;
intensity: number; // 0~1
}
export interface EmotionTagger {
tag(input: EmotionTaggerInput): EmotionSignal;
}
// 依優先序排列:衝突語氣 > 稱讚(害羞)> 輕度負向(彆扭)> 一般負向 > 正向。
const CONFLICT_KEYWORDS = ["滾", "閉嘴", "白痴", "笨蛋", "憑什麼", "少管我", "去死", "揍你", "威脅"];
const PRAISE_KEYWORDS = ["好可愛", "好漂亮", "好帥", "喜歡你", "好厲害", "太可愛了"];
const MINOR_NEGATIVE_KEYWORDS = ["不理我", "都不陪我", "比較喜歡", "都在誇", "忽略我"];
const NEGATIVE_KEYWORDS = ["討厭", "難過", "失望", "傷心", "不喜歡", "無聊", "煩", "委屈"];
const POSITIVE_KEYWORDS = ["謝謝", "喜歡", "開心", "好棒", "加油", "辛苦了", "太好了", "愛你"];
function countHits(text: string, keywords: string[]): number {
return keywords.reduce((count, keyword) => (text.includes(keyword) ? count + 1 : count), 0);
}
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
// D-1 情緒標記器(規則版):關鍵字比對 + 角色觸發閾值,輸出情緒類型與強度分數。
@Injectable()
export class RuleBasedEmotionTagger implements EmotionTagger {
tag(input: EmotionTaggerInput): EmotionSignal {
const { text } = input;
const threshold = input.triggerThreshold ?? 1;
const conflictHits = countHits(text, CONFLICT_KEYWORDS);
if (conflictHits > 0) {
return { tag: "ALERT", intensity: clamp01((0.7 * conflictHits) / threshold) };
}
const praiseHits = countHits(text, PRAISE_KEYWORDS);
if (praiseHits > 0) {
return { tag: "SHY", intensity: clamp01((0.6 * praiseHits) / threshold) };
}
const minorNegativeHits = countHits(text, MINOR_NEGATIVE_KEYWORDS);
if (minorNegativeHits > 0) {
return { tag: "GRUMPY", intensity: clamp01((0.4 * minorNegativeHits) / threshold) };
}
const negativeHits = countHits(text, NEGATIVE_KEYWORDS);
if (negativeHits > 0) {
return { tag: "SAD", intensity: clamp01((0.5 * negativeHits) / threshold) };
}
const positiveHits = countHits(text, POSITIVE_KEYWORDS);
if (positiveHits > 0) {
return { tag: "JOY", intensity: clamp01((0.5 * positiveHits) / threshold) };
}
return { tag: "CALM", intensity: 0 };
}
}
@@ -0,0 +1,41 @@
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
import { EmotionService } from "./emotion.service.js";
interface ProcessInputBody {
text: string;
triggerThreshold?: number;
halfLifeMs?: number;
now?: string;
threatConfirmedSafe?: boolean;
conflictContinues?: boolean;
comforted?: boolean;
placated?: boolean;
}
@Controller("emotion")
export class EmotionController {
constructor(private readonly emotion: EmotionService) {}
@Get(":characterId")
async getState(@Param("characterId") characterId: string, @Query("now") now?: string) {
return this.emotion.getState(characterId, now ? new Date(now) : undefined);
}
@Get(":characterId/response-style")
async getResponseStyle(@Param("characterId") characterId: string, @Query("now") now?: string) {
return this.emotion.getResponseStyle(characterId, now ? new Date(now) : undefined);
}
@Post(":characterId/input")
async processInput(@Param("characterId") characterId: string, @Body() body: ProcessInputBody) {
return this.emotion.processInput(characterId, body.text, {
triggerThreshold: body.triggerThreshold,
halfLifeMs: body.halfLifeMs,
now: body.now ? new Date(body.now) : undefined,
threatConfirmedSafe: body.threatConfirmedSafe,
conflictContinues: body.conflictContinues,
comforted: body.comforted,
placated: body.placated,
});
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module.js";
import { RuleBasedEmotionTagger } from "./emotion-tagger.js";
import { EmotionService } from "./emotion.service.js";
import { EmotionController } from "./emotion.controller.js";
@Module({
imports: [PrismaModule],
controllers: [EmotionController],
providers: [RuleBasedEmotionTagger, EmotionService],
exports: [RuleBasedEmotionTagger, EmotionService],
})
export class EmotionModule {}
+140
View File
@@ -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 };
+21
View File
@@ -0,0 +1,21 @@
import type { EmotionTag } from "@kokorone/shared";
export interface ResponseStyle {
tone: string; // 語氣
sentenceLength: "short" | "medium" | "long"; // 句長
initiative: "low" | "medium" | "high"; // 主動性
}
// D-3 情緒→回應風格輸出:把當前情緒轉為 F 群組上下文組裝器可直接使用的回應參數。
const STYLE_BY_TAG: Record<EmotionTag, ResponseStyle> = {
CALM: { tone: "平穩自然", sentenceLength: "medium", initiative: "medium" },
JOY: { tone: "開朗雀躍、多話", sentenceLength: "long", initiative: "high" },
SAD: { tone: "低落安靜、簡短", sentenceLength: "short", initiative: "low" },
ALERT: { tone: "警戒防備", sentenceLength: "short", initiative: "low" },
SHY: { tone: "害羞結巴", sentenceLength: "short", initiative: "low" },
GRUMPY: { tone: "嘴硬彆扭", sentenceLength: "medium", initiative: "medium" },
};
export function toResponseStyle(tag: EmotionTag): ResponseStyle {
return STYLE_BY_TAG[tag];
}