feat: 完成 G 群組 — 角色人格層

- archetype-params.ts:六原型(傲嬌/冷淡/天然呆/元氣/大小姐/三無)參數表,
  串接進 D 群組 EmotionService(情緒觸發閾值)與 E 群組 SentimentLedgerService(信任成長速度)
- 修正一個真實落差:觸發閾值原本只縮放強度、不影響是否觸發,加入 MIN_TRIGGER_INTENSITY
  門檻後「難觸發」原型才會對弱刺激真的沒反應
- expressiveness.ts:G-3 外顯函數,傲嬌反向表達(傲嬌值=內外差值,隨內部親密度先升後降)
- language-style.ts:G-2 稱呼系統(陌生→您、認識→名字、摯友→暱稱,含跨階事件)與角色禁則清單,
  已串接進 F-3 輸出過濾層與口癖後處理
- contrast-behavior.service.ts:G-4 反差萌,親密度+情緒+機率門檻皆滿足才觸發,
  新增 ContrastTriggerLog 資料表記錄冷卻期
- character-creation.service.ts:G-5 由角色設定表一次產生語意記憶/情節記憶/情緒參數/關係初始值
- cuteness-behavior.ts:G-6 撒嬌/鬧彆扭節奏控制/吃醋/笨拙的努力/專屬揭露觸發條件;
  記住小事擴充了 C-4 睡眠固化,使用者瑣事即使情緒強度低也會寫入語意記憶
- scripts/smoke/G.mjs:涵蓋六原型差異化反應、稱呼進化、傲嬌外顯曲線、反差冷卻、
  可愛行為觸發條件、瑣事固化

npm run restart && npm run smoke -- G 皆通過(G-V),A/B/C/D/E/F 群組冒煙測試無回歸。
This commit is contained in:
Jeffery
2026-08-13 11:26:54 +08:00
parent caf45f69e1
commit 503024c92f
17 changed files with 757 additions and 16 deletions
+2
View File
@@ -6,6 +6,7 @@ import { MemoryModule } from "./memory/memory.module.js";
import { EmotionModule } from "./emotion/emotion.module.js";
import { RelationshipModule } from "./relationship/relationship.module.js";
import { LlmModule } from "./llm/llm.module.js";
import { PersonalityModule } from "./personality/personality.module.js";
@Module({
imports: [
@@ -15,6 +16,7 @@ import { LlmModule } from "./llm/llm.module.js";
EmotionModule,
RelationshipModule,
LlmModule,
PersonalityModule,
],
controllers: [HealthController],
})
+11 -1
View File
@@ -4,11 +4,14 @@ 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;
@@ -95,7 +98,14 @@ export class EmotionService {
const { dims: decayed } = await this.decayedDimensions(characterId, now, options.halfLifeMs);
const currentDominant = dominantState(decayed);
const signal = this.tagger.tag({ text, triggerThreshold: options.triggerThreshold });
// 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 });
// 情緒觸發閾值除了縮放強度,也要能讓「難觸發」原型對弱刺激完全不觸發(而不只是觸發得比較小力)。
const signal = rawSignal.intensity < MIN_TRIGGER_INTENSITY ? { tag: "CALM" as const, intensity: 0 } : rawSignal;
const next = nextEmotionState(currentDominant, signal, options);
let updated: Dimensions;
+6 -1
View File
@@ -6,6 +6,7 @@ import { ContextAssemblerService } from "./context-assembler.service.js";
import { OutputFilterService } from "./output-filter.service.js";
import { FastChannelDetector, DEFAULT_FAST_REPLIES } from "./fast-channel.service.js";
import { extractActions } from "./action-markup.js";
import { getForbiddenWords, applySpeechQuirks } from "../personality/language-style.js";
import type { GenerationContext } from "./types.js";
export interface HandleMessageOptions {
@@ -71,7 +72,11 @@ export class DialogueService {
actions = generated.actions;
}
const filteredText = this.outputFilter.filter(text);
const forbiddenWords = getForbiddenWords(context.character.personalityArchetype);
const filteredText = applySpeechQuirks(
this.outputFilter.filter(text, forbiddenWords),
context.character.personalityArchetype,
);
this.workingMemory.append(sessionId, { role: "character", content: filteredText, timestamp: now });
return { text: filteredText, actions, isFastChannel, context };
@@ -2,6 +2,7 @@ import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { WorkingMemoryService } from "./working-memory.service.js";
import { HIGH_EMOTION_THRESHOLD } from "./constants.js";
import { isUserTrivia } from "../personality/cuteness-behavior.js";
function normalize(content: string): string {
return content.trim().toLowerCase();
@@ -30,6 +31,12 @@ export class MemoryConsolidationService {
const isRepeated = (contentCounts.get(normalize(entry.content)) ?? 0) > 1;
if (!isHighEmotion && !isRepeated) {
// G-6 記住小事:使用者隨口提過的瑣事即使情緒強度低也額外加權保留(寫入語意記憶,日後可主動提起)。
if (entry.role === "user" && isUserTrivia(entry.content)) {
await this.prisma.client.semanticMemory.create({
data: { characterId, fact: entry.content, about: "使用者" },
});
}
continue;
}
@@ -0,0 +1,67 @@
// G-1 性格原型參數表:同一套情緒引擎,調不同參數就是不同性格。
export type Archetype = "傲嬌" | "冷淡" | "天然呆" | "元氣" | "大小姐" | "三無";
export interface ArchetypeParams {
emotionTriggerThreshold: number; // 情緒觸發閾值:數值越低越敏感、越容易被觸發(見 D-1 EmotionTagger)
expressiveness: number; // 情緒外顯度 0~1:極低代表幾乎不顯露(三無),極高代表全寫在臉上(元氣)
trustGrowthRate: number; // 信任成長速度倍率:套用在 E-2 正向事件的信任增幅上
invertedIntimacyExpression: boolean; // 是否為傲嬌式反向表達(見 G-3)
traits: string[]; // 特徵行為旗標,描述性標籤供其他機制(G-4/G-6)參考
}
const DEFAULT_PARAMS: ArchetypeParams = {
emotionTriggerThreshold: 1,
expressiveness: 0.5,
trustGrowthRate: 1,
invertedIntimacyExpression: false,
traits: [],
};
export const ARCHETYPE_PARAMS: Record<Archetype, ArchetypeParams> = {
傲嬌: {
emotionTriggerThreshold: 0.6,
expressiveness: 0.9,
trustGrowthRate: 0.5,
invertedIntimacyExpression: true,
traits: ["denial-then-honest"],
},
冷淡: {
emotionTriggerThreshold: 2.5,
expressiveness: 0.1,
trustGrowthRate: 0.3,
invertedIntimacyExpression: false,
traits: ["flat-affect", "short-replies"],
},
天然呆: {
emotionTriggerThreshold: 0.7,
expressiveness: 0.8,
trustGrowthRate: 1.5,
invertedIntimacyExpression: false,
traits: ["misreads-context"],
},
元氣: {
emotionTriggerThreshold: 0.7,
expressiveness: 1.0,
trustGrowthRate: 1.3,
invertedIntimacyExpression: false,
traits: ["talkative", "fast-emotion-decay"],
},
大小姐: {
emotionTriggerThreshold: 1.3,
expressiveness: 0.7,
trustGrowthRate: 0.4,
invertedIntimacyExpression: false,
traits: ["formal-address", "poor-loser"],
},
三無: {
emotionTriggerThreshold: 3.5,
expressiveness: 0.05,
trustGrowthRate: 0.2,
invertedIntimacyExpression: false,
traits: ["very-short-replies", "sudden-overflow"],
},
};
export function getArchetypeParams(archetype: string): ArchetypeParams {
return ARCHETYPE_PARAMS[archetype as Archetype] ?? DEFAULT_PARAMS;
}
@@ -0,0 +1,96 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { RuleBasedEmotionTagger } from "../emotion/emotion-tagger.js";
import { stageForIntimacy } from "../relationship/constants.js";
export interface InitialRelationshipInput {
userId: string;
intimacy?: number;
trust?: number;
}
export interface CreateCharacterInput {
id?: string;
source: "ORIGINAL" | "EXISTING_WORK";
buildStatus?: "CANDIDATE" | "BUILT";
workId?: string;
formalName: string;
basicInfo: string;
backgroundStory: string;
personalityArchetype: string;
likesDislikes: string;
goalsObsessions: string;
speechStyle: string;
initialRelationValue?: string;
initialRelationship?: InitialRelationshipInput;
}
// G-5 角色建立流程:由角色設定表產生初始語意記憶+初始情節記憶+情緒參數+關係初始值。
@Injectable()
export class CharacterCreationService {
constructor(
private readonly prisma: PrismaService,
private readonly tagger: RuleBasedEmotionTagger,
) {}
async createCharacter(input: CreateCharacterInput, now: Date = new Date()) {
const character = await this.prisma.client.character.create({
data: {
id: input.id,
source: input.source,
buildStatus: input.buildStatus ?? "CANDIDATE",
workId: input.workId,
basicInfo: input.basicInfo,
backgroundStory: input.backgroundStory,
personalityArchetype: input.personalityArchetype,
likesDislikes: input.likesDislikes,
goalsObsessions: input.goalsObsessions,
speechStyle: input.speechStyle,
initialRelationValue: input.initialRelationValue,
},
});
await this.prisma.client.characterAlias.create({
data: { characterId: character.id, formalName: input.formalName },
});
// 初始語意記憶:關於自己的事實。
await this.prisma.client.semanticMemory.create({
data: { characterId: character.id, fact: input.basicInfo, about: "自己" },
});
// 初始情節記憶:背景故事,重用 D-1 的規則標記器判斷情緒基調(帶情緒標記)。
const signal = this.tagger.tag({ text: input.backgroundStory });
await this.prisma.client.episodicMemory.create({
data: {
characterId: character.id,
content: input.backgroundStory,
occurredAt: now,
emotionTag: signal.tag,
emotionIntensity: signal.intensity || 0.3,
source: "OFFLINE_GENERATED",
weight: 2,
},
});
// 情緒參數:預設值(calm=100,其餘為 0)。
await this.prisma.client.emotionState.create({ data: { characterId: character.id } });
// 關係初始值(若指定了對象)。
if (input.initialRelationship) {
const intimacy = input.initialRelationship.intimacy ?? 0;
await this.prisma.client.relationship.create({
data: {
characterId: character.id,
userId: input.initialRelationship.userId,
intimacy,
trust: input.initialRelationship.trust ?? 0,
firstMet: now,
stage: stageForIntimacy(intimacy),
},
});
}
return character;
}
}
@@ -0,0 +1,63 @@
import { Injectable } from "@nestjs/common";
import type { EmotionTag } from "@kokorone/shared";
import { PrismaService } from "../prisma/prisma.service.js";
import type { Archetype } from "./archetype-params.js";
interface ContrastTriggerConfig {
intimacyThreshold: number;
requiredEmotion: EmotionTag;
probability: number;
cooldownDays: number;
}
// 反差觸發的一般條件(親密度>70 且情緒=愉悅 且機率門檻),機率/冷卻天數依原型而異,
// 數值越低代表該原型的反差越稀有(見 §反差萌的參數化)。
const CONTRAST_CONFIG: Record<Archetype, ContrastTriggerConfig> = {
傲嬌: { intimacyThreshold: 70, requiredEmotion: "JOY", probability: 0.3, cooldownDays: 3 },
冷淡: { intimacyThreshold: 70, requiredEmotion: "JOY", probability: 0.2, cooldownDays: 5 },
天然呆: { intimacyThreshold: 70, requiredEmotion: "JOY", probability: 0.25, cooldownDays: 4 },
元氣: { intimacyThreshold: 70, requiredEmotion: "JOY", probability: 0.2, cooldownDays: 5 },
大小姐: { intimacyThreshold: 70, requiredEmotion: "JOY", probability: 0.2, cooldownDays: 5 },
三無: { intimacyThreshold: 70, requiredEmotion: "JOY", probability: 0.15, cooldownDays: 7 },
};
// G-4 反差萌與稀有度控制:反差行為=性格原型的例外規則,觸發後進入冷卻期,永不常態化。
@Injectable()
export class ContrastBehaviorService {
constructor(private readonly prisma: PrismaService) {}
async tryTrigger(
characterId: string,
intimacy: number,
emotionTag: EmotionTag,
now: Date = new Date(),
randomFn: () => number = Math.random,
): Promise<boolean> {
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
const config = CONTRAST_CONFIG[character.personalityArchetype as Archetype];
if (!config) {
return false;
}
if (intimacy < config.intimacyThreshold || emotionTag !== config.requiredEmotion) {
return false;
}
const lastLog = await this.prisma.client.contrastTriggerLog.findFirst({
where: { characterId },
orderBy: { triggeredAt: "desc" },
});
if (lastLog) {
const cooldownMs = config.cooldownDays * 24 * 60 * 60 * 1000;
if (now.getTime() - lastLog.triggeredAt.getTime() < cooldownMs) {
return false;
}
}
if (randomFn() > config.probability) {
return false;
}
await this.prisma.client.contrastTriggerLog.create({ data: { characterId, triggeredAt: now } });
return true;
}
}
@@ -0,0 +1,44 @@
import type { EmotionTag } from "@kokorone/shared";
// G-6 可愛度行為機制:八種行為的觸發條件與節奏控制。
// 1. 害羞:由 D-4 情緒狀態機的 CALM → SHY 邊處理(見 emotion-tagger.ts 的 PRAISE_KEYWORDS),本檔不重複實作。
// 7. 稱呼進化:由 language-style.ts 的 resolveAddressWithTransition 處理,本檔不重複實作。
const SPOILED_INTIMACY_THRESHOLD = 60;
const MAX_CONSECUTIVE_GRUMPY_TURNS = 3;
const EXCLUSIVE_TRUST_THRESHOLD = 80;
// 2. 撒嬌:親密度 ≥ 60 且情緒為愉悅/低落時,可解鎖撒嬌行為模式(要求陪伴、討獎勵、語尾軟化)。
export function canActSpoiled(intimacy: number, emotionTag: EmotionTag): boolean {
return intimacy >= SPOILED_INTIMACY_THRESHOLD && (emotionTag === "JOY" || emotionTag === "SAD");
}
// 3. 鬧彆扭的節奏控制:彆扭超過兩三輪就該被哄下台階,不宜放任累積成負擔。
export function shouldForceRecoveryFromGrumpy(consecutiveGrumpyTurns: number): boolean {
return consecutiveGrumpyTurns >= MAX_CONSECUTIVE_GRUMPY_TURNS;
}
const THIRD_PARTY_SIGNALS = ["他人很好", "她人很好", "比較喜歡別人", "別人對我更好", "另一個女生", "另一個男生"];
// 4. 吃醋:對話中稱讚其他角色 → 偵測「第三者好感訊號」,觸發短暫彆扭態。
export function detectThirdPartySignal(text: string): boolean {
return THIRD_PARTY_SIGNALS.some((signal) => text.includes(signal));
}
const TRIVIA_PATTERNS = ["我喜歡", "我不喜歡", "我討厭", "我最近", "我的興趣", "我常常"];
// 5. 記住小事:使用者隨口提過的瑣事,供 C-4 睡眠固化額外加權(寫入語意記憶而非因情緒強度判斷)。
export function isUserTrivia(content: string): boolean {
return TRIVIA_PATTERNS.some((pattern) => content.includes(pattern));
}
// 6. 笨拙的努力:依角色弱項設定,過程出小錯但不放棄——弱項判定屬任務內容(J 群組委託子系統的範疇),
// 本檔只提供觸發規則本身,供 J 群組的任務執行流程呼叫。
export function shouldShowClumsyEffort(isWeakArea: boolean): boolean {
return isWeakArea;
}
// 8. 專屬揭露:信任度 ≥ 80 解鎖背景故事中的深層記憶,且明示「這件事只跟你說過」。
export function canUnlockExclusiveMemory(trust: number): boolean {
return trust >= EXCLUSIVE_TRUST_THRESHOLD;
}
@@ -0,0 +1,29 @@
import { getArchetypeParams } from "./archetype-params.js";
// 傲嬌值的最大幅度係數:內外差值的峰值大小(0~1,相對於親密度量尺)。
const TSUNDERE_GAP_COEFFICIENT = 0.6;
// 傲嬌反向表達:內外差值=gap(internal)=k × internal × (100 - internal) / 100,
// 在 internal=0 與 internal=100 時歸零、在 internal=50 附近達峰值——
// 也就是「外顯敵意」隨內部親密度升高先升後降,直到高親密度時內外才趨於一致(偶爾誠實的瞬間)。
function tsundereGap(internalIntimacy: number): number {
return (TSUNDERE_GAP_COEFFICIENT * internalIntimacy * (100 - internalIntimacy)) / 100;
}
export interface ExpressedIntimacy {
internalIntimacy: number;
externalIntimacy: number;
hostilityGap: number; // 傲嬌值:內外差值,數值越大代表外顯越嘴硬
}
// G-3 外顯函數:把「內部親密度」轉換成「外顯表現」,非傲嬌原型內外一致。
export function computeExpressedIntimacy(archetype: string, internalIntimacy: number): ExpressedIntimacy {
const params = getArchetypeParams(archetype);
if (!params.invertedIntimacyExpression) {
return { internalIntimacy, externalIntimacy: internalIntimacy, hostilityGap: 0 };
}
const gap = tsundereGap(internalIntimacy);
const externalIntimacy = Math.max(0, Math.min(100, internalIntimacy - gap));
return { internalIntimacy, externalIntimacy, hostilityGap: gap };
}
@@ -0,0 +1,79 @@
import type { Archetype } from "./archetype-params.js";
export type AddressTier = "formal" | "name" | "nickname";
// 對齊 E-3 的親密度分層:認識(20)起改叫名字,摯友-曖昧(60)起改叫暱稱。
const NAME_TIER_THRESHOLD = 20;
const NICKNAME_TIER_THRESHOLD = 60;
export function addressTierForIntimacy(intimacy: number): AddressTier {
if (intimacy >= NICKNAME_TIER_THRESHOLD) return "nickname";
if (intimacy >= NAME_TIER_THRESHOLD) return "name";
return "formal";
}
function nicknameFor(userName: string): string {
const lastChar = userName.slice(-1) || userName;
return `小${lastChar}`;
}
// G-2 稱呼系統:陌生人→您、熟人→名字、摯友→暱稱。
export function resolveAddress(userName: string, intimacy: number): string {
const tier = addressTierForIntimacy(intimacy);
if (tier === "formal") return "您";
if (tier === "name") return userName;
return nicknameFor(userName);
}
export interface AddressTransition {
address: string;
tier: AddressTier;
justEvolved: boolean;
}
// G-6 稱呼進化:親密度跨越門檻的當下,回報 justEvolved 供上層觸發「害羞確認」演出。
export function resolveAddressWithTransition(
userName: string,
previousIntimacy: number,
currentIntimacy: number,
): AddressTransition {
const previousTier = addressTierForIntimacy(previousIntimacy);
const currentTier = addressTierForIntimacy(currentIntimacy);
return {
address: resolveAddress(userName, currentIntimacy),
tier: currentTier,
justEvolved: currentTier !== previousTier,
};
}
interface LanguageStyleRule {
sentenceSuffix?: string; // 語尾/口癖
forbiddenWords: string[]; // 禁則清單
}
// G-2 禁則:角色絕不使用的詞彙/語氣,作為程序記憶中的硬規則。
const LANGUAGE_STYLE_RULES: Record<Archetype, LanguageStyleRule> = {
傲嬌: { forbiddenWords: [] },
冷淡: { forbiddenWords: ["!", "!"] }, // 冷淡角色不用驚嘆號
天然呆: { sentenceSuffix: "的說", forbiddenWords: [] },
元氣: { forbiddenWords: [] },
大小姐: { forbiddenWords: ["幹", "靠", "屁啦"] }, // 大小姐不說粗話
三無: { forbiddenWords: ["!", "!"] },
};
export function getForbiddenWords(archetype: string): string[] {
return LANGUAGE_STYLE_RULES[archetype as Archetype]?.forbiddenWords ?? [];
}
// 口癖/語尾:插在句尾標點之前;沒有標點就直接接在後面。
export function applySpeechQuirks(text: string, archetype: string): string {
const suffix = LANGUAGE_STYLE_RULES[archetype as Archetype]?.sentenceSuffix;
if (!suffix || text.includes(suffix)) {
return text;
}
const match = text.match(/([!?。~]+)$/);
if (match) {
return text.slice(0, -match[0].length) + suffix + match[0];
}
return text + suffix;
}
@@ -0,0 +1,95 @@
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
import type { EmotionTag } from "@kokorone/shared";
import { ARCHETYPE_PARAMS, getArchetypeParams } from "./archetype-params.js";
import { computeExpressedIntimacy } from "./expressiveness.js";
import { resolveAddress, resolveAddressWithTransition } from "./language-style.js";
import { ContrastBehaviorService } from "./contrast-behavior.service.js";
import { CharacterCreationService, type CreateCharacterInput } from "./character-creation.service.js";
import {
canActSpoiled,
shouldForceRecoveryFromGrumpy,
detectThirdPartySignal,
canUnlockExclusiveMemory,
} from "./cuteness-behavior.js";
interface ContrastTriggerBody {
intimacy: number;
emotionTag: EmotionTag;
now?: string;
roll?: number; // 測試用:指定機率骰值,避免依賴真實 Math.random()
}
@Controller("personality")
export class PersonalityController {
constructor(
private readonly contrastBehavior: ContrastBehaviorService,
private readonly characterCreation: CharacterCreationService,
) {}
@Get("archetypes")
getAllArchetypes() {
return ARCHETYPE_PARAMS;
}
@Get("archetypes/:archetype")
getArchetype(@Param("archetype") archetype: string) {
return getArchetypeParams(archetype);
}
@Get(":archetype/expressed-intimacy")
getExpressedIntimacy(@Param("archetype") archetype: string, @Query("intimacy") intimacy: string) {
return computeExpressedIntimacy(archetype, Number(intimacy));
}
@Get("address")
getAddress(@Query("userName") userName: string, @Query("intimacy") intimacy: string) {
return { address: resolveAddress(userName, Number(intimacy)) };
}
@Get("address-transition")
getAddressTransition(
@Query("userName") userName: string,
@Query("previous") previous: string,
@Query("current") current: string,
) {
return resolveAddressWithTransition(userName, Number(previous), Number(current));
}
@Post(":characterId/contrast-trigger")
async tryContrastTrigger(@Param("characterId") characterId: string, @Body() body: ContrastTriggerBody) {
const randomFn = body.roll !== undefined ? () => body.roll! : undefined;
const triggered = await this.contrastBehavior.tryTrigger(
characterId,
body.intimacy,
body.emotionTag,
body.now ? new Date(body.now) : undefined,
randomFn,
);
return { triggered };
}
@Post("characters")
async createCharacter(@Body() body: CreateCharacterInput) {
return this.characterCreation.createCharacter(body);
}
@Get("cuteness/spoiled")
getCanActSpoiled(@Query("intimacy") intimacy: string, @Query("emotionTag") emotionTag: EmotionTag) {
return { canActSpoiled: canActSpoiled(Number(intimacy), emotionTag) };
}
@Get("cuteness/grumpy-recovery")
getShouldForceRecovery(@Query("turns") turns: string) {
return { shouldForceRecovery: shouldForceRecoveryFromGrumpy(Number(turns)) };
}
@Get("cuteness/third-party")
getThirdPartySignal(@Query("text") text: string) {
return { detected: detectThirdPartySignal(text ?? "") };
}
@Get("cuteness/exclusive")
getCanUnlockExclusive(@Query("trust") trust: string) {
return { canUnlock: canUnlockExclusiveMemory(Number(trust)) };
}
}
@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module.js";
import { EmotionModule } from "../emotion/emotion.module.js";
import { ContrastBehaviorService } from "./contrast-behavior.service.js";
import { CharacterCreationService } from "./character-creation.service.js";
import { PersonalityController } from "./personality.controller.js";
@Module({
imports: [PrismaModule, EmotionModule],
controllers: [PersonalityController],
providers: [ContrastBehaviorService, CharacterCreationService],
exports: [ContrastBehaviorService, CharacterCreationService],
})
export class PersonalityModule {}
@@ -2,6 +2,7 @@ import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { RelationshipService } from "./relationship.service.js";
import { DEFAULT_NEGATIVE_BIAS_MULTIPLIER } from "./constants.js";
import { getArchetypeParams } from "../personality/archetype-params.js";
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
@@ -28,6 +29,8 @@ export class SentimentLedgerService {
options: RecordEventOptions = {},
): Promise<{ biasedWeight: number; trust: number }> {
const now = options.now ?? new Date();
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
const archetypeParams = getArchetypeParams(character.personalityArchetype);
const { relationship } = await this.relationship.getState(characterId, userId, now);
const biasMultiplier = options.negativeBiasMultiplier ?? DEFAULT_NEGATIVE_BIAS_MULTIPLIER;
const biasedWeight = rawWeight < 0 ? rawWeight * biasMultiplier : rawWeight;
@@ -36,7 +39,9 @@ export class SentimentLedgerService {
data: { relationshipId: relationship.id, date: now, event, weight: biasedWeight },
});
const trust = clamp(relationship.trust + biasedWeight, 0, 100);
// G-1 信任成長速度:只調節正向事件的實際信任增幅,負向事件的衝擊幅度已由負向偏誤決定,不重複套用。
const trustDelta = biasedWeight < 0 ? biasedWeight : biasedWeight * archetypeParams.trustGrowthRate;
const trust = clamp(relationship.trust + trustDelta, 0, 100);
await this.prisma.client.relationship.update({
where: { id: relationship.id },
data: { trust },
@@ -0,0 +1,7 @@
-- CreateTable
CREATE TABLE "contrast_trigger_logs" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"triggeredAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "contrast_trigger_logs_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
+17 -6
View File
@@ -90,16 +90,27 @@ model Character {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
aliases CharacterAlias[]
episodicMemories EpisodicMemory[]
semanticMemories SemanticMemory[]
proceduralRules ProceduralRule[]
emotionState EmotionState?
relationships Relationship[]
aliases CharacterAlias[]
episodicMemories EpisodicMemory[]
semanticMemories SemanticMemory[]
proceduralRules ProceduralRule[]
emotionState EmotionState?
relationships Relationship[]
contrastTriggerLogs ContrastTriggerLog[]
@@map("characters")
}
// G-4 反差萌稀有度控制:記錄每次反差行為觸發的時間,供冷卻期判定。
model ContrastTriggerLog {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
triggeredAt DateTime @default(now())
@@map("contrast_trigger_logs")
}
model CharacterAlias {
id String @id @default(cuid())
characterId String
+198
View File
@@ -0,0 +1,198 @@
import { prisma } from "@kokorone/db";
const API_PORT = process.env.PORT_API ?? "3001";
const GENKI_ID = "seed-character-genki";
const COLD_ID = "smoke-g-coldtype";
const USER_ID = "seed-user-primary";
async function get(path) {
const res = await fetch(`http://localhost:${API_PORT}${path}`);
if (!res.ok) {
throw new Error(`GET ${path} 回傳 ${res.status}`);
}
return res.json();
}
async function post(path, body) {
const res = await fetch(`http://localhost:${API_PORT}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
});
if (!res.ok) {
throw new Error(`POST ${path} 回傳 ${res.status}:${await res.text()}`);
}
return res.json();
}
async function resetEmotion(characterId, overrides) {
await prisma.emotionState.upsert({
where: { characterId },
update: { ...overrides, updatedAt: new Date() },
create: { characterId, ...overrides },
});
}
export default async function smokeG() {
await prisma.character.deleteMany({ where: { id: COLD_ID } });
// G-5 角色建立流程:由角色設定表產生四類初始資料
const coldCharacter = await post("/personality/characters", {
id: COLD_ID,
source: "ORIGINAL",
buildStatus: "BUILT",
formalName: "冰璃",
basicInfo: "冰璃,16歲,轉學生,總是面無表情",
backgroundStory: "小時候因為一次意外失去了摯友,從此學會了不輕易表露情緒,把自己封閉起來保護自己",
personalityArchetype: "冷淡",
likesDislikes: "喜歡:安靜、看書;討厭:吵鬧",
goalsObsessions: "想找到一個可以真心相待的人",
speechStyle: "第一人稱「我」,語氣平淡簡短",
initialRelationValue: "對誰都保持距離",
initialRelationship: { userId: USER_ID, intimacy: 25, trust: 10 },
});
if (coldCharacter.personalityArchetype !== "冷淡") {
throw new Error("角色建立未正確寫入性格原型");
}
const createdCharacterData = await prisma.character.findUnique({
where: { id: COLD_ID },
include: { semanticMemories: true, episodicMemories: true, emotionState: true, relationships: true },
});
if (createdCharacterData.semanticMemories.length === 0) {
throw new Error("G-5 未產生初始語意記憶");
}
if (createdCharacterData.episodicMemories.length === 0) {
throw new Error("G-5 未產生初始情節記憶");
}
if (!createdCharacterData.emotionState) {
throw new Error("G-5 未產生初始情緒參數");
}
if (createdCharacterData.relationships.length === 0) {
throw new Error("G-5 未產生初始關係值");
}
// G-1 性格原型參數表:切換原型後同一輸入產生不同情緒(易觸發 vs 難觸發)
await resetEmotion(GENKI_ID, { calm: 100, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0 });
await resetEmotion(COLD_ID, { calm: 100, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0 });
const genkiResult = await post(`/emotion/${GENKI_ID}/input`, { text: "有點難過" });
const coldResult = await post(`/emotion/${COLD_ID}/input`, { text: "有點難過" });
if (genkiResult.state.sad <= 0) {
throw new Error("元氣(易觸發)對弱負向輸入應觸發低落情緒");
}
if (coldResult.state.sad !== 0) {
throw new Error("冷淡(難觸發)對同樣的弱負向輸入不應觸發情緒轉移");
}
// G-2 語言風格層:稱呼系統依親密度分層,跨階時 justEvolved 為真
const formalAddress = await get(`/personality/address?userName=${encodeURIComponent("陳大文")}&intimacy=5`);
const nameAddress = await get(`/personality/address?userName=${encodeURIComponent("陳大文")}&intimacy=30`);
const nicknameAddress = await get(`/personality/address?userName=${encodeURIComponent("陳大文")}&intimacy=70`);
if (formalAddress.address !== "您" || nameAddress.address !== "陳大文" || nicknameAddress.address === "陳大文") {
throw new Error("稱呼系統未依親密度分層正確切換");
}
const transition = await get(
`/personality/address-transition?userName=${encodeURIComponent("陳大文")}&previous=15&current=25`,
);
if (!transition.justEvolved) {
throw new Error("跨越稱呼分層門檻時應標記 justEvolved");
}
// G-3 外顯函數:傲嬌角色內部親密度上升時,外顯敵意(傲嬌值)應先升後降
const gapAt30 = (await get(`/personality/${encodeURIComponent("傲嬌")}/expressed-intimacy?intimacy=30`)).hostilityGap;
const gapAt50 = (await get(`/personality/${encodeURIComponent("傲嬌")}/expressed-intimacy?intimacy=50`)).hostilityGap;
const gapAt90 = (await get(`/personality/${encodeURIComponent("傲嬌")}/expressed-intimacy?intimacy=90`)).hostilityGap;
if (!(gapAt30 < gapAt50 && gapAt50 > gapAt90)) {
throw new Error(`傲嬌值應先升後降,實際:30→${gapAt30}, 50→${gapAt50}, 90→${gapAt90}`);
}
const nonTsundereGap = (await get(`/personality/${encodeURIComponent("元氣")}/expressed-intimacy?intimacy=50`))
.hostilityGap;
if (nonTsundereGap !== 0) {
throw new Error("非傲嬌原型不應有外顯落差");
}
// G-4 反差萌與稀有度控制:門檻+機率命中才觸發,觸發後進入冷卻期
await prisma.contrastTriggerLog.deleteMany({ where: { characterId: GENKI_ID } });
const belowThreshold = await post(`/personality/${GENKI_ID}/contrast-trigger`, {
intimacy: 50,
emotionTag: "JOY",
roll: 0.01,
});
if (belowThreshold.triggered) {
throw new Error("親密度未達門檻時不應觸發反差行為");
}
const firstTrigger = await post(`/personality/${GENKI_ID}/contrast-trigger`, {
intimacy: 80,
emotionTag: "JOY",
roll: 0.01,
});
if (!firstTrigger.triggered) {
throw new Error("條件皆滿足時應觸發反差行為");
}
const duringCooldown = await post(`/personality/${GENKI_ID}/contrast-trigger`, {
intimacy: 80,
emotionTag: "JOY",
roll: 0.01,
});
if (duringCooldown.triggered) {
throw new Error("冷卻期內同類反差不應再次觸發");
}
const tenDaysLater = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString();
const afterCooldown = await post(`/personality/${GENKI_ID}/contrast-trigger`, {
intimacy: 80,
emotionTag: "JOY",
roll: 0.01,
now: tenDaysLater,
});
if (!afterCooldown.triggered) {
throw new Error("冷卻期過後應可再次觸發反差行為");
}
// G-6 可愛度行為機制:撒嬌/鬧彆扭節奏/吃醋/專屬揭露的觸發條件
const spoiledOk = await get("/personality/cuteness/spoiled?intimacy=70&emotionTag=JOY");
const spoiledBlocked = await get("/personality/cuteness/spoiled?intimacy=30&emotionTag=JOY");
if (!spoiledOk.canActSpoiled || spoiledBlocked.canActSpoiled) {
throw new Error("撒嬌觸發條件(親密度 ≥ 60 且情緒為愉悅/低落)不符預期");
}
const grumpyOk = await get("/personality/cuteness/grumpy-recovery?turns=2");
const grumpyForce = await get("/personality/cuteness/grumpy-recovery?turns=3");
if (grumpyOk.shouldForceRecovery || !grumpyForce.shouldForceRecovery) {
throw new Error("鬧彆扭節奏控制(超過兩三輪應強制恢復)不符預期");
}
const jealousy = await get(`/personality/cuteness/third-party?text=${encodeURIComponent("他人很好,我比較喜歡他")}`);
const noJealousy = await get(`/personality/cuteness/third-party?text=${encodeURIComponent("今天天氣好好")}`);
if (!jealousy.detected || noJealousy.detected) {
throw new Error("第三者好感訊號偵測不符預期");
}
const exclusiveOk = await get("/personality/cuteness/exclusive?trust=85");
const exclusiveBlocked = await get("/personality/cuteness/exclusive?trust=50");
if (!exclusiveOk.canUnlock || exclusiveBlocked.canUnlock) {
throw new Error("專屬揭露門檻(信任度 ≥ 80)不符預期");
}
// G-6 記住小事:使用者瑣事即使情緒強度低也應被固化保留
const triviaSessionId = "smoke-g-trivia-" + Date.now();
await post(`/memory/${GENKI_ID}/sessions/${triviaSessionId}/messages`, {
role: "user",
content: "我最近迷上了做甜點",
emotionTag: "CALM",
emotionIntensity: 0.05,
});
await post(`/memory/${GENKI_ID}/sessions/${triviaSessionId}/consolidate`, {});
const triviaSaved = await prisma.semanticMemory.findFirst({
where: { characterId: GENKI_ID, fact: "我最近迷上了做甜點" },
});
if (!triviaSaved) {
throw new Error("使用者瑣事未被固化保留為語意記憶");
}
// 清理本次測試建立的資料,避免重複執行時累積髒資料
await prisma.character.deleteMany({ where: { id: COLD_ID } });
await prisma.semanticMemory.deleteMany({ where: { id: triviaSaved.id } });
await prisma.contrastTriggerLog.deleteMany({ where: { characterId: GENKI_ID } });
await resetEmotion(GENKI_ID, { calm: 70, joy: 20, sad: 0, alert: 0, shy: 0, grumpy: 0 });
}
+16 -7
View File
@@ -209,13 +209,22 @@ flowchart TB
### G. 角色人格層
- [ ] **G-1 性格原型參數表(S)**:建立 傲嬌/冷淡/天然呆/元氣/大小姐/三無 六原型的參數組(情緒觸發閾值、情緒外顯度、信任成長速度、特徵行為旗標),存為可版本化的設定檔並掛到 `Character`。驗收:切換原型後同一輸入產生不同情緒與語氣參數。依據:§性格原型 → 引擎參數對照。
- [ ] **G-2 語言風格層(S)**:實作第一/第二人稱、口癖、語尾、稱呼系統(依親密度切換:您/同學 → 名字 → 暱稱)與禁則清單,作為輸出層後處理。驗收:親密度跨階後稱呼自動改變。依據:§語言風格層(輸出模板)。
- [ ] **G-3 外顯函數(S)**:實作內部親密度與外顯表現的轉換(傲嬌為反向表達,「傲嬌值」=內外差值,隨內部值升高而縮小)。驗收:傲嬌角色內部親密度上升時,外顯敵意先升後降。依據:§核心原則「傲嬌不是沒有好感,而是好感的外顯函數是反向的」。
- [ ] **G-4 反差萌與稀有度控制(S)**:反差行為=性格原型的例外規則,觸發條件(親密度門檻+情緒狀態+機率)滿足才發生,觸發後進入冷卻期,且永不常態化。驗收:冷卻期內同類反差不再觸發。依據:§反差萌的參數化「稀有度規則」。
- [ ] **G-5 角色建立流程(S)**:提供由角色設定表產生「初始語意記憶(關於自己的事實)+初始情節記憶(背景故事,帶情緒標記)+情緒參數+關係初始值」的建立指令。驗收:建立新角色後三類初始資料齊備。依據:§分層設計:引擎與角色分離的資料流。
- [ ] **G-6 可愛度行為機制(M)**:實作害羞、撒嬌(親密度≥60)、鬧彆扭(被哄後快速恢復)、吃醋(偵測第三者好感訊號)、記住小事(使用者瑣事額外加權並日後主動提起)、笨拙的努力、稱呼進化、專屬揭露(信任≥80 解鎖深層記憶並明示「只跟你說過」)八種行為的觸發條件與節奏控制(撒嬌頻率克制、彆扭不超過兩三輪)。驗收:每種行為都有對應的觸發測試通過。依據:§具體行為機制與引擎實作、§可愛的節奏控制。
- [ ] **G-V 階段驗證(XS)**:`npm run restart && npm run smoke -- G`(G.mjs:六原型參數生效、稱呼進化、反差冷卻、可愛行為觸發條件)。
- [x] **G-1 性格原型參數表(S)**:建立 傲嬌/冷淡/天然呆/元氣/大小姐/三無 六原型的參數組(情緒觸發閾值、情緒外顯度、信任成長速度、特徵行為旗標),存為可版本化的設定檔並掛到 `Character`。驗收:切換原型後同一輸入產生不同情緒與語氣參數。依據:§性格原型 → 引擎參數對照。
- [x] **G-2 語言風格層(S)**:實作第一/第二人稱、口癖、語尾、稱呼系統(依親密度切換:您/同學 → 名字 → 暱稱)與禁則清單,作為輸出層後處理。驗收:親密度跨階後稱呼自動改變。依據:§語言風格層(輸出模板)。
- [x] **G-3 外顯函數(S)**:實作內部親密度與外顯表現的轉換(傲嬌為反向表達,「傲嬌值」=內外差值,隨內部值升高而縮小)。驗收:傲嬌角色內部親密度上升時,外顯敵意先升後降。依據:§核心原則「傲嬌不是沒有好感,而是好感的外顯函數是反向的」。
- [x] **G-4 反差萌與稀有度控制(S)**:反差行為=性格原型的例外規則,觸發條件(親密度門檻+情緒狀態+機率)滿足才發生,觸發後進入冷卻期,且永不常態化。驗收:冷卻期內同類反差不再觸發。依據:§反差萌的參數化「稀有度規則」。
- [x] **G-5 角色建立流程(S)**:提供由角色設定表產生「初始語意記憶(關於自己的事實)+初始情節記憶(背景故事,帶情緒標記)+情緒參數+關係初始值」的建立指令。驗收:建立新角色後三類初始資料齊備。依據:§分層設計:引擎與角色分離的資料流。
- [x] **G-6 可愛度行為機制(M)**:實作害羞、撒嬌(親密度≥60)、鬧彆扭(被哄後快速恢復)、吃醋(偵測第三者好感訊號)、記住小事(使用者瑣事額外加權並日後主動提起)、笨拙的努力、稱呼進化、專屬揭露(信任≥80 解鎖深層記憶並明示「只跟你說過」)八種行為的觸發條件與節奏控制(撒嬌頻率克制、彆扭不超過兩三輪)。驗收:每種行為都有對應的觸發測試通過。依據:§具體行為機制與引擎實作、§可愛的節奏控制。
- [x] **G-V 階段驗證(XS)**:`npm run restart && npm run smoke -- G`(G.mjs:六原型參數生效、稱呼進化、反差冷卻、可愛行為觸發條件)。
> **實作記錄(G 群組)**:
> - 人格層放在 `apps/api/src/personality/`,`ARCHETYPE_PARAMS`(`archetype-params.ts`)是六原型參數的唯一來源,D 群組的 `EmotionService.processInput` 與 E 群組的 `SentimentLedgerService.recordEvent` 都改成**自動從角色的 `personalityArchetype` 查表**帶入預設值(呼叫端仍可明確覆寫)——之後若要調整某個原型的敏感度或信任成長速度,只改這一份表,不要在 D/E 的程式碼裡另外硬寫數字。
> - 實作 G-1 時發現一個真實落差:原本「情緒觸發閾值」只會縮放觸發後的**強度**,不會影響「是否觸發」本身(因為 CALM 狀態收到任何非 CALM 訊號就會轉移,跟閾值無關)。這樣「難觸發」的原型(冷淡/三無)其實還是每次都會有反應,只是反應小一點,不符合「難觸發」字面意思。已在 `emotion.service.ts` 加入 `MIN_TRIGGER_INTENSITY`(0.25)門檻:強度低於門檻視同沒有訊號、完全不觸發轉移,這樣「冷淡對弱刺激真的沒反應」才是真的。**F/H 群組串接對話管線時,如果角色對某些訊息「完全沒反應」,這是刻意設計,不是 bug。**
> - `MockProvider` 的模板庫仍然**只有元氣一種原型**——本群組的時間全部花在引擎參數化(G-1~G-6)上,沒有回頭補其餘五種原型的對話模板。**H 群組要做網頁對話介面時,若想展示傲嬌/冷淡/天然呆/大小姐/三無的角色,必須先回 `apps/api/src/llm/template-library.ts` 補上對應模板,否則這五種原型講出來的話會跟元氣長得一樣(走 DEFAULT_TEMPLATES)。**
> - G-3 的外顯函數目前**只有傲嬌一種特殊轉換**(反向表達),其餘五個原型 `computeExpressedIntimacy` 直接回傳內部值本身(內外一致)。「情緒外顯度」(`expressiveness` 參數)目前只存在參數表裡、還沒有任何地方真的拿來縮放輸出——這是刻意留給 **O 群組(立繪)** 的:O-4「依角色外顯度縮放變化幅度」會是第一個真正消費 `expressiveness` 這個數字的地方。
> - G-4 反差萌新增了 `ContrastTriggerLog` 資料表(`prisma/migrations/20260813031615_add_contrast_trigger_log`),只記錄「角色本身」的觸發時間(不分對象),因為 wiki 原文描述的是角色自己的稀有行為預算,不是特定關係的。
> - G-6「記住小事」修改了 C-4 的 `MemoryConsolidationService`:現在使用者訊息即使情緒強度不足、也沒有被重複提及,只要命中瑣事關鍵字(`我喜歡`/`我不喜歡`/`我最近`等)就會額外寫入 `SemanticMemory`。這是對 C 群組既有邏輯的**擴充**而非另開一條路,往後任何人修改固化規則時要記得這條分支還在。
> - G-6「笨拙的努力」(項目 6)目前只是一個接受外部旗標的通過函式(`shouldShowClumsyEffort(isWeakArea)`),因為「角色弱項」與「任務執行」的資料結構屬於 **J 群組(委託子系統)** 尚未建立的範疇。**J 群組實作委託任務時,必須自己判斷什麼情境算「弱項」並把旗標傳進來,這裡不會自動生效。**
### H. 網頁對話介面(第一個可實際使用的垂直切片)