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:
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,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
|
||||
);
|
||||
@@ -96,10 +96,21 @@ model Character {
|
||||
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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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. 網頁對話介面(第一個可實際使用的垂直切片)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user