From 503024c92ff29c827aff7c319e36f13e0f0b2e14 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 13 Aug 2026 11:26:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20G=20=E7=BE=A4?= =?UTF-8?q?=E7=B5=84=20=E2=80=94=20=E8=A7=92=E8=89=B2=E4=BA=BA=E6=A0=BC?= =?UTF-8?q?=E5=B1=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 群組冒煙測試無回歸。 --- apps/api/src/app.module.ts | 2 + apps/api/src/emotion/emotion.service.ts | 12 +- apps/api/src/llm/dialogue.service.ts | 7 +- .../memory/memory-consolidation.service.ts | 7 + apps/api/src/personality/archetype-params.ts | 67 ++++++ .../personality/character-creation.service.ts | 96 +++++++++ .../personality/contrast-behavior.service.ts | 63 ++++++ apps/api/src/personality/cuteness-behavior.ts | 44 ++++ apps/api/src/personality/expressiveness.ts | 29 +++ apps/api/src/personality/language-style.ts | 79 +++++++ .../src/personality/personality.controller.ts | 95 +++++++++ .../api/src/personality/personality.module.ts | 14 ++ .../relationship/sentiment-ledger.service.ts | 7 +- .../migration.sql | 7 + prisma/schema.prisma | 23 +- scripts/smoke/G.mjs | 198 ++++++++++++++++++ todo.md | 23 +- 17 files changed, 757 insertions(+), 16 deletions(-) create mode 100644 apps/api/src/personality/archetype-params.ts create mode 100644 apps/api/src/personality/character-creation.service.ts create mode 100644 apps/api/src/personality/contrast-behavior.service.ts create mode 100644 apps/api/src/personality/cuteness-behavior.ts create mode 100644 apps/api/src/personality/expressiveness.ts create mode 100644 apps/api/src/personality/language-style.ts create mode 100644 apps/api/src/personality/personality.controller.ts create mode 100644 apps/api/src/personality/personality.module.ts create mode 100644 prisma/migrations/20260813031615_add_contrast_trigger_log/migration.sql create mode 100644 scripts/smoke/G.mjs diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index aee4f75..5b825fe 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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], }) diff --git a/apps/api/src/emotion/emotion.service.ts b/apps/api/src/emotion/emotion.service.ts index eaef92c..c7eda9e 100644 --- a/apps/api/src/emotion/emotion.service.ts +++ b/apps/api/src/emotion/emotion.service.ts @@ -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; diff --git a/apps/api/src/llm/dialogue.service.ts b/apps/api/src/llm/dialogue.service.ts index c6283dc..3e0d255 100644 --- a/apps/api/src/llm/dialogue.service.ts +++ b/apps/api/src/llm/dialogue.service.ts @@ -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 }; diff --git a/apps/api/src/memory/memory-consolidation.service.ts b/apps/api/src/memory/memory-consolidation.service.ts index 1abcddb..5e48e82 100644 --- a/apps/api/src/memory/memory-consolidation.service.ts +++ b/apps/api/src/memory/memory-consolidation.service.ts @@ -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; } diff --git a/apps/api/src/personality/archetype-params.ts b/apps/api/src/personality/archetype-params.ts new file mode 100644 index 0000000..844d5d1 --- /dev/null +++ b/apps/api/src/personality/archetype-params.ts @@ -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 = { + 傲嬌: { + 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; +} diff --git a/apps/api/src/personality/character-creation.service.ts b/apps/api/src/personality/character-creation.service.ts new file mode 100644 index 0000000..e153ee4 --- /dev/null +++ b/apps/api/src/personality/character-creation.service.ts @@ -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; + } +} diff --git a/apps/api/src/personality/contrast-behavior.service.ts b/apps/api/src/personality/contrast-behavior.service.ts new file mode 100644 index 0000000..6637425 --- /dev/null +++ b/apps/api/src/personality/contrast-behavior.service.ts @@ -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 = { + 傲嬌: { 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 { + 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; + } +} diff --git a/apps/api/src/personality/cuteness-behavior.ts b/apps/api/src/personality/cuteness-behavior.ts new file mode 100644 index 0000000..cf39213 --- /dev/null +++ b/apps/api/src/personality/cuteness-behavior.ts @@ -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; +} diff --git a/apps/api/src/personality/expressiveness.ts b/apps/api/src/personality/expressiveness.ts new file mode 100644 index 0000000..1b73684 --- /dev/null +++ b/apps/api/src/personality/expressiveness.ts @@ -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 }; +} diff --git a/apps/api/src/personality/language-style.ts b/apps/api/src/personality/language-style.ts new file mode 100644 index 0000000..5a75382 --- /dev/null +++ b/apps/api/src/personality/language-style.ts @@ -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 = { + 傲嬌: { 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; +} diff --git a/apps/api/src/personality/personality.controller.ts b/apps/api/src/personality/personality.controller.ts new file mode 100644 index 0000000..563b865 --- /dev/null +++ b/apps/api/src/personality/personality.controller.ts @@ -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)) }; + } +} diff --git a/apps/api/src/personality/personality.module.ts b/apps/api/src/personality/personality.module.ts new file mode 100644 index 0000000..175b3d7 --- /dev/null +++ b/apps/api/src/personality/personality.module.ts @@ -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 {} diff --git a/apps/api/src/relationship/sentiment-ledger.service.ts b/apps/api/src/relationship/sentiment-ledger.service.ts index cfd9406..90774b0 100644 --- a/apps/api/src/relationship/sentiment-ledger.service.ts +++ b/apps/api/src/relationship/sentiment-ledger.service.ts @@ -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 }, diff --git a/prisma/migrations/20260813031615_add_contrast_trigger_log/migration.sql b/prisma/migrations/20260813031615_add_contrast_trigger_log/migration.sql new file mode 100644 index 0000000..2ceb06c --- /dev/null +++ b/prisma/migrations/20260813031615_add_contrast_trigger_log/migration.sql @@ -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 +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4cf240e..34ac019 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/scripts/smoke/G.mjs b/scripts/smoke/G.mjs new file mode 100644 index 0000000..462be1d --- /dev/null +++ b/scripts/smoke/G.mjs @@ -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¤t=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 }); +} diff --git a/todo.md b/todo.md index 08af7ee..9a418bc 100644 --- a/todo.md +++ b/todo.md @@ -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. 網頁對話介面(第一個可實際使用的垂直切片)