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:
@@ -2,9 +2,10 @@ import { Module } from "@nestjs/common";
|
|||||||
import { HealthController } from "./health/health.controller.js";
|
import { HealthController } from "./health/health.controller.js";
|
||||||
import { CharactersModule } from "./characters/characters.module.js";
|
import { CharactersModule } from "./characters/characters.module.js";
|
||||||
import { MemoryModule } from "./memory/memory.module.js";
|
import { MemoryModule } from "./memory/memory.module.js";
|
||||||
|
import { EmotionModule } from "./emotion/emotion.module.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CharactersModule, MemoryModule],
|
imports: [CharactersModule, MemoryModule, EmotionModule],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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 };
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { prisma } from "@kokorone/db";
|
||||||
|
|
||||||
|
const API_PORT = process.env.PORT_API ?? "3001";
|
||||||
|
const CHARACTER_ID = "seed-character-genki";
|
||||||
|
const ORIGINAL_EMOTION = { calm: 70, joy: 20, sad: 0, alert: 0, shy: 0, grumpy: 0 };
|
||||||
|
const BASELINE = { calm: 100, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0 };
|
||||||
|
|
||||||
|
async function resetEmotion(overrides = BASELINE) {
|
||||||
|
await prisma.emotionState.upsert({
|
||||||
|
where: { characterId: CHARACTER_ID },
|
||||||
|
update: { ...overrides, updatedAt: new Date() },
|
||||||
|
create: { characterId: CHARACTER_ID, ...overrides },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postInput(body) {
|
||||||
|
const res = await fetch(`http://localhost:${API_PORT}/emotion/${CHARACTER_ID}/input`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`POST /emotion/${CHARACTER_ID}/input 回傳 ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getState(now) {
|
||||||
|
const url = `http://localhost:${API_PORT}/emotion/${CHARACTER_ID}${now ? `?now=${encodeURIComponent(now)}` : ""}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`GET /emotion/${CHARACTER_ID} 回傳 ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dominantOf(state) {
|
||||||
|
const candidates = [
|
||||||
|
["JOY", state.joy],
|
||||||
|
["SAD", state.sad],
|
||||||
|
["ALERT", state.alert],
|
||||||
|
["SHY", state.shy],
|
||||||
|
["GRUMPY", state.grumpy],
|
||||||
|
];
|
||||||
|
const [tag, value] = candidates.reduce((best, cur) => (cur[1] > best[1] ? cur : best), ["CALM", 5]);
|
||||||
|
return value > 5 ? tag : "CALM";
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDominant(state, expected, label) {
|
||||||
|
const actual = dominantOf(state);
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`${label}:預期主導情緒為 ${expected},實際為 ${actual}(${JSON.stringify(state)})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function smokeD() {
|
||||||
|
// D-1 情緒標記器:正向/負向/衝突語氣輸入分別得到對應標記
|
||||||
|
await resetEmotion();
|
||||||
|
const positive = await postInput({ text: "謝謝你,我好開心!" });
|
||||||
|
assertDominant(positive.state, "JOY", "正向輸入");
|
||||||
|
|
||||||
|
await resetEmotion();
|
||||||
|
const negative = await postInput({ text: "今天過得好難過,很失望" });
|
||||||
|
assertDominant(negative.state, "SAD", "負向輸入");
|
||||||
|
|
||||||
|
await resetEmotion();
|
||||||
|
const conflict = await postInput({ text: "你這個笨蛋,滾開!" });
|
||||||
|
assertDominant(conflict.state, "ALERT", "衝突語氣輸入");
|
||||||
|
if (conflict.style.sentenceLength !== "short" || conflict.style.initiative !== "low") {
|
||||||
|
throw new Error("D-3 警戒狀態的回應風格參數不符預期");
|
||||||
|
}
|
||||||
|
|
||||||
|
// D-4 情緒狀態機:連續負向(衝突持續)輸入 → 警戒繼續惡化為低落
|
||||||
|
const stillConflicted = await postInput({ text: "我還是很生氣", conflictContinues: true });
|
||||||
|
assertDominant(stillConflicted.state, "SAD", "警戒 --> 低落(衝突持續)");
|
||||||
|
|
||||||
|
// 警戒 --> 平靜(確認無威脅)
|
||||||
|
await resetEmotion({ calm: 0, joy: 0, sad: 0, alert: 100, shy: 0, grumpy: 0 });
|
||||||
|
const safeAgain = await postInput({ text: "沒事了", threatConfirmedSafe: true });
|
||||||
|
assertDominant(safeAgain.state, "CALM", "警戒 --> 平靜(確認無威脅)");
|
||||||
|
|
||||||
|
// 低落 --> 平靜(被安慰)
|
||||||
|
await resetEmotion({ calm: 50, joy: 0, sad: 50, alert: 0, shy: 0, grumpy: 0 });
|
||||||
|
const comforted = await postInput({ text: "謝謝你安慰我", comforted: true });
|
||||||
|
assertDominant(comforted.state, "CALM", "低落 --> 平靜(被安慰)");
|
||||||
|
|
||||||
|
// 平靜 --> 害羞(被稱讚)
|
||||||
|
await resetEmotion();
|
||||||
|
const praised = await postInput({ text: "你今天好可愛喔" });
|
||||||
|
assertDominant(praised.state, "SHY", "平靜 --> 害羞(被稱讚)");
|
||||||
|
|
||||||
|
// 平靜 --> 彆扭(小型負向事件)→ 被哄後快速恢復平靜
|
||||||
|
await resetEmotion();
|
||||||
|
const grumpy = await postInput({ text: "你都不理我" });
|
||||||
|
assertDominant(grumpy.state, "GRUMPY", "平靜 --> 彆扭(小型負向事件)");
|
||||||
|
const placated = await postInput({ text: "好啦好啦", placated: true });
|
||||||
|
assertDominant(placated.state, "CALM", "彆扭 --> 平靜(被哄)");
|
||||||
|
|
||||||
|
// D-3 情緒→回應風格輸出:愉悅指向多話/高主動,低落指向簡短/低主動
|
||||||
|
await resetEmotion();
|
||||||
|
const joyful = await postInput({ text: "謝謝你,我好開心!" });
|
||||||
|
if (joyful.style.sentenceLength !== "long" || joyful.style.initiative !== "high") {
|
||||||
|
throw new Error("D-3 愉悅狀態的回應風格參數不符預期");
|
||||||
|
}
|
||||||
|
|
||||||
|
await resetEmotion();
|
||||||
|
const sadResult = await postInput({ text: "今天過得好難過,很失望" });
|
||||||
|
if (sadResult.style.sentenceLength !== "short" || sadResult.style.initiative !== "low") {
|
||||||
|
throw new Error("D-3 低落狀態的回應風格參數不符預期");
|
||||||
|
}
|
||||||
|
|
||||||
|
// D-2 時間衰減:模擬經過數小時後情緒值回落;跨 session(重新讀取)仍可讀到殘留
|
||||||
|
await resetEmotion({ calm: 0, joy: 100, sad: 0, alert: 0, shy: 0, grumpy: 0 });
|
||||||
|
const immediate = await getState();
|
||||||
|
if (immediate.joy < 90) {
|
||||||
|
throw new Error("尚未經過時間就已衰減,時間衰減函數計算有誤");
|
||||||
|
}
|
||||||
|
|
||||||
|
const threeHoursLater = new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString();
|
||||||
|
const decayed = await getState(threeHoursLater);
|
||||||
|
if (decayed.joy >= immediate.joy) {
|
||||||
|
throw new Error("模擬時間推進後情緒值未回落");
|
||||||
|
}
|
||||||
|
if (decayed.joy <= 0) {
|
||||||
|
throw new Error("時間推進後情緒殘留應仍大於 0(尚未完全衰減)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跨 session:不重啟服務也不清狀態,重新 GET 一次應讀到同一份殘留(持久化於資料庫,而非行程記憶體)
|
||||||
|
const reread = await getState(threeHoursLater);
|
||||||
|
if (Math.abs(reread.joy - decayed.joy) > 0.01) {
|
||||||
|
throw new Error("重新讀取時殘留情緒值不一致,情緒狀態未正確持久化");
|
||||||
|
}
|
||||||
|
|
||||||
|
const twentyHoursLater = new Date(Date.now() + 20 * 60 * 60 * 1000).toISOString();
|
||||||
|
const fullyDecayed = await getState(twentyHoursLater);
|
||||||
|
if (dominantOf(fullyDecayed) !== "CALM") {
|
||||||
|
throw new Error("長時間衰減後應回歸平靜");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 還原種子角色原始展示用情緒值,避免影響其他群組或人工檢視
|
||||||
|
await resetEmotion(ORIGINAL_EMOTION);
|
||||||
|
}
|
||||||
@@ -159,11 +159,18 @@ flowchart TB
|
|||||||
|
|
||||||
### D. 情緒子系統
|
### D. 情緒子系統
|
||||||
|
|
||||||
- [ ] **D-1 情緒標記器(S)**:實作 `EmotionTagger` 介面與規則版實作(關鍵字+輸入特徵+角色觸發閾值),輸出情緒類型與強度分數。驗收:正向/負向/衝突語氣輸入分別得到對應標記。依據:§聊天系統架構圖「情緒標記器:為輸入評估情緒強度」。
|
- [x] **D-1 情緒標記器(S)**:實作 `EmotionTagger` 介面與規則版實作(關鍵字+輸入特徵+角色觸發閾值),輸出情緒類型與強度分數。驗收:正向/負向/衝突語氣輸入分別得到對應標記。依據:§聊天系統架構圖「情緒標記器:為輸入評估情緒強度」。
|
||||||
- [ ] **D-2 時間衰減(S)**:以半衰期函數隨時間回歸平靜,衰減速度由角色參數決定;跨 session 保留殘留情緒。驗收:模擬經過數小時後情緒值回落,重新開啟 session 仍讀得到殘留。依據:§情緒狀態機「跨 session 保留情緒殘留……但隨時間衰減」。
|
- [x] **D-2 時間衰減(S)**:以半衰期函數隨時間回歸平靜,衰減速度由角色參數決定;跨 session 保留殘留情緒。驗收:模擬經過數小時後情緒值回落,重新開啟 session 仍讀得到殘留。依據:§情緒狀態機「跨 session 保留情緒殘留……但隨時間衰減」。
|
||||||
- [ ] **D-3 情緒→回應風格輸出(S)**:把當前情緒轉為回應參數(語氣、句長、主動性),提供給 F 群組的上下文組裝器。驗收:愉悅時參數指向「多話」、低落時指向「簡短」。依據:§情緒狀態機「情緒狀態影響:回覆語氣、用詞選擇、主動性」。
|
- [x] **D-3 情緒→回應風格輸出(S)**:把當前情緒轉為回應參數(語氣、句長、主動性),提供給 F 群組的上下文組裝器。驗收:愉悅時參數指向「多話」、低落時指向「簡短」。依據:§情緒狀態機「情緒狀態影響:回覆語氣、用詞選擇、主動性」。
|
||||||
- [ ] **D-4 情緒狀態機(M)**:實作平靜/愉悅/低落/警戒的轉移規則(正向互動、負向事件、偵測衝突語氣、確認無威脅、衝突持續),並加入害羞與彆扭兩個狀態(供 G-4 可愛度行為使用)。驗收:依 wiki 狀態圖的每條轉移邊都有對應測試通過。依據:§情緒狀態機 stateDiagram、§可愛度設計「情緒引擎新增『羞』狀態」。
|
- [x] **D-4 情緒狀態機(M)**:實作平靜/愉悅/低落/警戒的轉移規則(正向互動、負向事件、偵測衝突語氣、確認無威脅、衝突持續),並加入害羞與彆扭兩個狀態(供 G-4 可愛度行為使用)。驗收:依 wiki 狀態圖的每條轉移邊都有對應測試通過。依據:§情緒狀態機 stateDiagram、§可愛度設計「情緒引擎新增『羞』狀態」。
|
||||||
- [ ] **D-V 階段驗證(XS)**:`npm run restart && npm run smoke -- D`(D.mjs:連續負向輸入→進警戒;模擬時間推進→回平靜;跨 session 讀回殘留)。
|
- [x] **D-V 階段驗證(XS)**:`npm run restart && npm run smoke -- D`(D.mjs:連續負向輸入→進警戒;模擬時間推進→回平靜;跨 session 讀回殘留)。
|
||||||
|
|
||||||
|
> **實作記錄(D 群組)**:
|
||||||
|
> - 情緒引擎放在 `apps/api/src/emotion/`。`EmotionState` 的 6 個浮點欄位(calm/joy/sad/alert/shy/grumpy)**不是各自獨立累積**,而是「當下只有一個主導情緒+其強度」:`dominantState()` 取非平靜維度中數值最高且超過門檻(5)者,狀態轉移發生時會把其餘維度清零、只把目標維度設成新強度。這是刻意的設計取捨——避免連續多種訊號各自累加造成無來源依據的隱性轉移,讓 D-4 的邊全部可預期、可測試。之後 G 群組若要疊加「性格參數影響情緒外顯度」,建議在讀出 `dominantState` 之後的顯示層做縮放,不要改動這裡的儲存邏輯。
|
||||||
|
> - D-4 狀態機**只實作 wiki 狀態圖 + 可愛度設計明訂的邊**(`apps/api/src/emotion/emotion-state-machine.ts`),刻意不外推「愉悅/低落/害羞」之間的直接互轉——這些狀態目前只能先衰減回平靜,才能被新訊號帶往別的狀態。害羞(被稱讚觸發)與彆扭(被忽略/比較等小型負向觸發,被哄後快速恢復)是本群組依「可愛度設計」章節新增,wiki 原始狀態圖沒有畫出來。
|
||||||
|
> - 時間衰減用半衰期公式(預設 2 小時,`processInput`/`getState` 皆可傳 `halfLifeMs` 覆寫),衰減與情緒讀寫都走資料庫的 `EmotionState.updatedAt`,天生跨 session/跨行程持久,不需要额外的殘留儲存機制。
|
||||||
|
> - `GET/POST /emotion/:characterId`、`.../input`、`.../response-style` 同樣是**工程內部驗證用端點**,性質與 C 群組的 `/memory/*` 一致,等 F/H 群組整合對話管線時再決定去留。
|
||||||
|
> - D-1 的關鍵字表與觸發優先序(衝突 > 稱讚/害羞 > 輕度負向/彆扭 > 一般負向 > 正向)、D-3 的回應風格對照表都只是 Mock 階段的規則版本;R-3 接上真實 LLM 後,這兩塊要嘛保留作為安全網、要嘛被模型自身的語氣調節取代,屆時再一併決定。
|
||||||
|
|
||||||
### E. 關係子系統
|
### E. 關係子系統
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user