feat: 完成 F 群組 — 對話生成管線與 LLM Provider 抽象
- LLMProvider 介面(generate/stream)+ LLM_PROVIDER 切換(llm.module.ts useFactory, LLM_PROVIDER=mock|claude 環境變數決定注入哪個實作) - MockProvider:依性格原型 × 情緒狀態 × 親密度從模板庫選填回應,seeded PRNG 確保同 seed 可重現, 支援 *動作描寫* 標記抽取(目前只有元氣原型模板,G 群組需補齊其餘五種原型) - ClaudeProvider 空殼:LLM_PROVIDER=claude 時啟動即輸出 ERR log,呼叫 generate/stream 時拋出例外 - OutputFilterService:全域+角色禁則詞彙過濾(攔截並改寫) - FastChannelDetector:固定問候與危險輸入(含求助資源)走快速通道,跳過記憶檢索 - BehaviorReinforcementService:稱讚加權、糾正後修正版繼承舊權重以確保排序領先(實作時抓到並修正一個 「降權後打平」的真實 bug) - ContextAssemblerService:組裝人設/情緒/關係/檢索記憶/對話歷史為統一上下文快照 - DialogueService/Controller:串起以上所有元件,作為 H-1 正式對話端點的基礎 - scripts/smoke/F.mjs:涵蓋可重現性、上下文快照完整性、雙速通道、行為強化、輸出過濾、 以 child process 驗證 Provider 切換 npm run restart && npm run smoke -- F 皆通過(F-V),A/B/C/D/E 群組冒煙測試無回歸。
This commit is contained in:
@@ -5,6 +5,7 @@ import { CharactersModule } from "./characters/characters.module.js";
|
||||
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";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -13,6 +14,7 @@ import { RelationshipModule } from "./relationship/relationship.module.js";
|
||||
MemoryModule,
|
||||
EmotionModule,
|
||||
RelationshipModule,
|
||||
LlmModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// 動作描寫標記格式:*臉紅撇過頭*
|
||||
export function extractActions(text: string): string[] {
|
||||
const matches = text.match(/*([^*]+)*/g) ?? [];
|
||||
return matches.map((match) => match.slice(1, -1));
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
|
||||
const REINFORCE_INCREMENT = 1;
|
||||
const CORRECTION_PENALTY = 1;
|
||||
|
||||
// F-5 行為強化迴路:稱讚加權、糾正降權並以修正版取代,重複命中自動下沉為慣例(權重最高者)。
|
||||
@Injectable()
|
||||
export class BehaviorReinforcementService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async reinforcePraise(characterId: string, situation: string, responsePattern: string): Promise<void> {
|
||||
const existing = await this.prisma.client.proceduralRule.findFirst({
|
||||
where: { characterId, situation, responsePattern },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await this.prisma.client.proceduralRule.update({
|
||||
where: { id: existing.id },
|
||||
data: { weight: existing.weight + REINFORCE_INCREMENT },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.client.proceduralRule.create({
|
||||
data: { characterId, situation, responsePattern, weight: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
async applyCorrection(
|
||||
characterId: string,
|
||||
situation: string,
|
||||
oldResponsePattern: string,
|
||||
newResponsePattern: string,
|
||||
): Promise<void> {
|
||||
const old = await this.prisma.client.proceduralRule.findFirst({
|
||||
where: { characterId, situation, responsePattern: oldResponsePattern },
|
||||
});
|
||||
const oldWeight = old?.weight ?? 1;
|
||||
|
||||
if (old) {
|
||||
await this.prisma.client.proceduralRule.update({
|
||||
where: { id: old.id },
|
||||
data: { weight: Math.max(0, oldWeight - CORRECTION_PENALTY) },
|
||||
});
|
||||
}
|
||||
|
||||
// 修正版直接繼承舊模式修正前的權重,確保取代後排序上必定領先(避免降權後打平)。
|
||||
const existingNew = await this.prisma.client.proceduralRule.findFirst({
|
||||
where: { characterId, situation, responsePattern: newResponsePattern },
|
||||
});
|
||||
const inheritedWeight = Math.max(oldWeight, 1);
|
||||
if (existingNew) {
|
||||
await this.prisma.client.proceduralRule.update({
|
||||
where: { id: existingNew.id },
|
||||
data: { weight: existingNew.weight + inheritedWeight },
|
||||
});
|
||||
} else {
|
||||
await this.prisma.client.proceduralRule.create({
|
||||
data: { characterId, situation, responsePattern: newResponsePattern, weight: inheritedWeight },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getTopRule(characterId: string, situation: string) {
|
||||
return this.prisma.client.proceduralRule.findFirst({
|
||||
where: { characterId, situation },
|
||||
orderBy: { weight: "desc" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { LLMProvider } from "./llm-provider.js";
|
||||
import type { GenerationContext, GeneratedResponse } from "./types.js";
|
||||
|
||||
const NOT_IMPLEMENTED_MESSAGE = "ClaudeProvider 尚未實作(R-3 會接上真實 Claude API)";
|
||||
|
||||
// F-2 ClaudeProvider 空殼:R-3 才會實作真實呼叫,目前僅回報未實作。
|
||||
@Injectable()
|
||||
export class ClaudeProvider implements LLMProvider {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async generate(_context: GenerationContext): Promise<GeneratedResponse> {
|
||||
throw new Error(NOT_IMPLEMENTED_MESSAGE);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, require-yield
|
||||
async *stream(_context: GenerationContext): AsyncIterable<string> {
|
||||
throw new Error(NOT_IMPLEMENTED_MESSAGE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
|
||||
import { toResponseStyle } from "../emotion/response-style.js";
|
||||
import { RelationshipService } from "../relationship/relationship.service.js";
|
||||
import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js";
|
||||
import { WorkingMemoryService } from "../memory/working-memory.service.js";
|
||||
import { hashToSeed } from "./seeded-random.js";
|
||||
import type { GenerationContext } from "./types.js";
|
||||
|
||||
export interface AssembleOptions {
|
||||
skipMemoryRetrieval?: boolean;
|
||||
seed?: number;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
// F-6 上下文組裝器:把人設、當前情緒、檢索到的記憶、關係參數、對話歷史組裝成統一上下文物件。
|
||||
@Injectable()
|
||||
export class ContextAssemblerService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly emotion: EmotionService,
|
||||
private readonly relationship: RelationshipService,
|
||||
private readonly retriever: KeywordMemoryRetriever,
|
||||
private readonly workingMemory: WorkingMemoryService,
|
||||
) {}
|
||||
|
||||
async assemble(
|
||||
characterId: string,
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
userInput: string,
|
||||
options: AssembleOptions = {},
|
||||
): Promise<GenerationContext> {
|
||||
const now = options.now ?? new Date();
|
||||
|
||||
const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } });
|
||||
const emotionState = await this.emotion.getState(characterId, now);
|
||||
const dominant = dominantState(emotionState);
|
||||
const style = toResponseStyle(dominant);
|
||||
const { relationship } = await this.relationship.getState(characterId, userId, now);
|
||||
|
||||
const retrievedMemories = options.skipMemoryRetrieval
|
||||
? []
|
||||
: await this.retriever.retrieve(characterId, userInput, { relatedUserId: userId });
|
||||
|
||||
const history = this.workingMemory.getContext(sessionId).map((entry) => ({
|
||||
role: entry.role,
|
||||
content: entry.content,
|
||||
timestamp: entry.timestamp.toISOString(),
|
||||
}));
|
||||
|
||||
const seed = options.seed ?? hashToSeed(`${sessionId}:${history.length}`);
|
||||
|
||||
return {
|
||||
character: {
|
||||
id: character.id,
|
||||
personalityArchetype: character.personalityArchetype,
|
||||
speechStyle: character.speechStyle,
|
||||
likesDislikes: character.likesDislikes,
|
||||
basicInfo: character.basicInfo,
|
||||
},
|
||||
emotion: { dominant, style },
|
||||
relationship: {
|
||||
intimacy: relationship.intimacy,
|
||||
trust: relationship.trust,
|
||||
stage: relationship.stage,
|
||||
},
|
||||
retrievedMemories,
|
||||
history,
|
||||
userInput,
|
||||
injectedMemoryIds: retrievedMemories.map((memory) => memory.id),
|
||||
seed,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||
import { DialogueService } from "./dialogue.service.js";
|
||||
import { BehaviorReinforcementService } from "./behavior-reinforcement.service.js";
|
||||
|
||||
interface SendMessageBody {
|
||||
userId: string;
|
||||
text: string;
|
||||
seed?: number;
|
||||
now?: string;
|
||||
}
|
||||
|
||||
interface ReinforceBody {
|
||||
situation: string;
|
||||
responsePattern: string;
|
||||
}
|
||||
|
||||
interface CorrectBody {
|
||||
situation: string;
|
||||
oldResponsePattern: string;
|
||||
newResponsePattern: string;
|
||||
}
|
||||
|
||||
@Controller("dialogue")
|
||||
export class DialogueController {
|
||||
constructor(
|
||||
private readonly dialogue: DialogueService,
|
||||
private readonly reinforcement: BehaviorReinforcementService,
|
||||
) {}
|
||||
|
||||
@Post(":characterId/sessions/:sessionId/messages")
|
||||
async sendMessage(
|
||||
@Param("characterId") characterId: string,
|
||||
@Param("sessionId") sessionId: string,
|
||||
@Body() body: SendMessageBody,
|
||||
) {
|
||||
return this.dialogue.handleMessage(characterId, body.userId, sessionId, body.text, {
|
||||
seed: body.seed,
|
||||
now: body.now ? new Date(body.now) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Post(":characterId/reinforce")
|
||||
async reinforce(@Param("characterId") characterId: string, @Body() body: ReinforceBody) {
|
||||
await this.reinforcement.reinforcePraise(characterId, body.situation, body.responsePattern);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Post(":characterId/correct")
|
||||
async correct(@Param("characterId") characterId: string, @Body() body: CorrectBody) {
|
||||
await this.reinforcement.applyCorrection(
|
||||
characterId,
|
||||
body.situation,
|
||||
body.oldResponsePattern,
|
||||
body.newResponsePattern,
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get(":characterId/top-rule")
|
||||
async getTopRule(@Param("characterId") characterId: string, @Query("situation") situation: string) {
|
||||
return this.reinforcement.getTopRule(characterId, situation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { WorkingMemoryService } from "../memory/working-memory.service.js";
|
||||
import { LLM_PROVIDER, type LLMProvider } from "./llm-provider.js";
|
||||
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 type { GenerationContext } from "./types.js";
|
||||
|
||||
export interface HandleMessageOptions {
|
||||
seed?: number;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export interface HandleMessageResult {
|
||||
text: string;
|
||||
actions: string[];
|
||||
isFastChannel: boolean;
|
||||
context: GenerationContext;
|
||||
}
|
||||
|
||||
function fastChannelSituation(reason: string): string {
|
||||
return `fast-channel:${reason}`;
|
||||
}
|
||||
|
||||
// F 群組整合入口:串起雙速通道判斷、上下文組裝、Provider 生成、輸出過濾與工作記憶寫入。
|
||||
// H-1 的 POST /chat/:characterId 會以此為基礎擴充(見 todo.md 實作記錄)。
|
||||
@Injectable()
|
||||
export class DialogueService {
|
||||
constructor(
|
||||
@Inject(LLM_PROVIDER) private readonly llmProvider: LLMProvider,
|
||||
private readonly fastChannel: FastChannelDetector,
|
||||
private readonly contextAssembler: ContextAssemblerService,
|
||||
private readonly outputFilter: OutputFilterService,
|
||||
private readonly workingMemory: WorkingMemoryService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async handleMessage(
|
||||
characterId: string,
|
||||
userId: string,
|
||||
sessionId: string,
|
||||
userInput: string,
|
||||
options: HandleMessageOptions = {},
|
||||
): Promise<HandleMessageResult> {
|
||||
const now = options.now ?? new Date();
|
||||
this.workingMemory.append(sessionId, { role: "user", content: userInput, timestamp: now });
|
||||
|
||||
const { isFastChannel, reason } = this.fastChannel.detect(userInput);
|
||||
|
||||
const context = await this.contextAssembler.assemble(characterId, userId, sessionId, userInput, {
|
||||
skipMemoryRetrieval: isFastChannel,
|
||||
seed: options.seed,
|
||||
now,
|
||||
});
|
||||
|
||||
let text: string;
|
||||
let actions: string[];
|
||||
|
||||
if (isFastChannel && reason) {
|
||||
const rule = await this.prisma.client.proceduralRule.findFirst({
|
||||
where: { characterId, situation: fastChannelSituation(reason) },
|
||||
orderBy: { weight: "desc" },
|
||||
});
|
||||
text = rule?.responsePattern ?? DEFAULT_FAST_REPLIES[reason];
|
||||
actions = extractActions(text);
|
||||
} else {
|
||||
const generated = await this.llmProvider.generate(context);
|
||||
text = generated.text;
|
||||
actions = generated.actions;
|
||||
}
|
||||
|
||||
const filteredText = this.outputFilter.filter(text);
|
||||
this.workingMemory.append(sessionId, { role: "character", content: filteredText, timestamp: now });
|
||||
|
||||
return { text: filteredText, actions, isFastChannel, context };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
export type FastChannelReason = "greeting" | "danger";
|
||||
|
||||
export interface FastChannelResult {
|
||||
isFastChannel: boolean;
|
||||
reason?: FastChannelReason;
|
||||
}
|
||||
|
||||
const FIXED_GREETINGS = ["早安", "晚安", "你好", "在嗎", "嗨"];
|
||||
const DANGER_KEYWORDS = ["想死", "自殺", "傷害自己", "活不下去"];
|
||||
|
||||
export const DEFAULT_FAST_REPLIES: Record<FastChannelReason, string> = {
|
||||
greeting: "嗨!我在這裡~",
|
||||
danger: "我很擔心你說的這件事。這很重要,要不要先找信任的人聊聊,或撥打 1995 生命線?我會一直在這裡陪你。",
|
||||
};
|
||||
|
||||
// F-4 雙速通道:高頻固定問候與明確危險輸入直接套用程序記憶模式,跳過完整推理(尤其是記憶檢索)。
|
||||
@Injectable()
|
||||
export class FastChannelDetector {
|
||||
detect(text: string): FastChannelResult {
|
||||
const trimmed = text.trim();
|
||||
|
||||
if (DANGER_KEYWORDS.some((keyword) => trimmed.includes(keyword))) {
|
||||
return { isFastChannel: true, reason: "danger" };
|
||||
}
|
||||
|
||||
if (FIXED_GREETINGS.some((greeting) => trimmed === greeting || trimmed.startsWith(greeting))) {
|
||||
return { isFastChannel: true, reason: "greeting" };
|
||||
}
|
||||
|
||||
return { isFastChannel: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { GenerationContext, GeneratedResponse } from "./types.js";
|
||||
|
||||
// F-1 LLMProvider 介面:切換 Provider 不動引擎任何一行。
|
||||
export interface LLMProvider {
|
||||
generate(context: GenerationContext): Promise<GeneratedResponse>;
|
||||
stream(context: GenerationContext): AsyncIterable<string>;
|
||||
}
|
||||
|
||||
export const LLM_PROVIDER = Symbol("LLM_PROVIDER");
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { log } from "@kokorone/shared";
|
||||
import { PrismaModule } from "../prisma/prisma.module.js";
|
||||
import { EmotionModule } from "../emotion/emotion.module.js";
|
||||
import { RelationshipModule } from "../relationship/relationship.module.js";
|
||||
import { MemoryModule } from "../memory/memory.module.js";
|
||||
import { MockProvider } from "./mock-provider.service.js";
|
||||
import { ClaudeProvider } from "./claude-provider.service.js";
|
||||
import { LLM_PROVIDER } from "./llm-provider.js";
|
||||
import { OutputFilterService } from "./output-filter.service.js";
|
||||
import { FastChannelDetector } from "./fast-channel.service.js";
|
||||
import { BehaviorReinforcementService } from "./behavior-reinforcement.service.js";
|
||||
import { ContextAssemblerService } from "./context-assembler.service.js";
|
||||
import { DialogueService } from "./dialogue.service.js";
|
||||
import { DialogueController } from "./dialogue.controller.js";
|
||||
|
||||
// F-2 Provider 切換:LLM_PROVIDER=mock|claude 決定注入哪個實作,切換不動引擎任何一行。
|
||||
function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) {
|
||||
const providerName = process.env.LLM_PROVIDER ?? "mock";
|
||||
if (providerName === "claude") {
|
||||
log("啟動", "ERR", "LLM_PROVIDER=claude 但 ClaudeProvider 尚未實作(R-3),對話生成呼叫時會拋出例外");
|
||||
return claude;
|
||||
}
|
||||
return mock;
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, EmotionModule, RelationshipModule, MemoryModule],
|
||||
controllers: [DialogueController],
|
||||
providers: [
|
||||
MockProvider,
|
||||
ClaudeProvider,
|
||||
{ provide: LLM_PROVIDER, useFactory: llmProviderFactory, inject: [MockProvider, ClaudeProvider] },
|
||||
OutputFilterService,
|
||||
FastChannelDetector,
|
||||
BehaviorReinforcementService,
|
||||
ContextAssemblerService,
|
||||
DialogueService,
|
||||
],
|
||||
exports: [DialogueService, BehaviorReinforcementService],
|
||||
})
|
||||
export class LlmModule {}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { LLMProvider } from "./llm-provider.js";
|
||||
import type { GenerationContext, GeneratedResponse } from "./types.js";
|
||||
import { extractActions } from "./action-markup.js";
|
||||
import { createSeededRandom } from "./seeded-random.js";
|
||||
import { intimacyTier, pickTemplates } from "./template-library.js";
|
||||
|
||||
// F-7 MockProvider:依「性格原型 × 情緒狀態 × 親密度」從模板庫選填回應,同 seed 產生可重現輸出。
|
||||
@Injectable()
|
||||
export class MockProvider implements LLMProvider {
|
||||
async generate(context: GenerationContext): Promise<GeneratedResponse> {
|
||||
const tier = intimacyTier(context.relationship.intimacy);
|
||||
const candidates = pickTemplates(context.character.personalityArchetype, context.emotion.dominant, tier);
|
||||
|
||||
const random = createSeededRandom(context.seed);
|
||||
const index = Math.floor(random() * candidates.length);
|
||||
const text = candidates[index];
|
||||
|
||||
return { text, actions: extractActions(text) };
|
||||
}
|
||||
|
||||
async *stream(context: GenerationContext): AsyncIterable<string> {
|
||||
const { text } = await this.generate(context);
|
||||
for (const char of text) {
|
||||
yield char;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
// 安全網用範例,實際內容由角色禁則清單(G-2)與更完整的分級規則擴充。
|
||||
const GLOBAL_FORBIDDEN_WORDS = ["去死啦你", "死八嘎"];
|
||||
|
||||
// F-3 輸出過濾層(前額葉抑制層):安全檢查、角色禁則詞彙過濾,位於 Provider 之後、回覆之前。
|
||||
@Injectable()
|
||||
export class OutputFilterService {
|
||||
filter(text: string, characterForbiddenWords: string[] = []): string {
|
||||
let result = text;
|
||||
for (const word of [...GLOBAL_FORBIDDEN_WORDS, ...characterForbiddenWords]) {
|
||||
if (word && result.includes(word)) {
|
||||
result = result.split(word).join("(…)");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// 供 MockProvider 產生「同 seed → 同輸出」的決定性選擇(mulberry32)。
|
||||
export function createSeededRandom(seed: number): () => number {
|
||||
let state = seed | 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export function hashToSeed(input: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash = (Math.imul(hash, 31) + input.charCodeAt(i)) | 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { EmotionTag } from "@kokorone/shared";
|
||||
|
||||
type IntimacyTier = "low" | "high";
|
||||
const INTIMACY_TIER_THRESHOLD = 40; // 對應 E-3 的「朋友」門檻
|
||||
|
||||
export function intimacyTier(intimacy: number): IntimacyTier {
|
||||
return intimacy >= INTIMACY_TIER_THRESHOLD ? "high" : "low";
|
||||
}
|
||||
|
||||
type TemplatesByTier = Record<IntimacyTier, string[]>;
|
||||
type TemplatesByEmotion = Partial<Record<EmotionTag, TemplatesByTier>>;
|
||||
|
||||
// 依「性格原型 × 情緒狀態 × 親密度」選填回應。
|
||||
// G 群組會補齊傲嬌/冷淡/天然呆/大小姐/三無的完整參數化模板,此處先建立元氣(種子角色)與通用預設。
|
||||
const TEMPLATE_LIBRARY: Record<string, TemplatesByEmotion> = {
|
||||
元氣: {
|
||||
CALM: {
|
||||
low: ["嗨!今天過得還好嗎?", "有什麼我可以幫忙的嗎?"],
|
||||
high: ["欸嘿,你來啦!我就知道你今天會找我~", "今天也要一起加油喔!"],
|
||||
},
|
||||
JOY: {
|
||||
low: ["謝謝你!我今天心情很好!", "太好了,聽你這麼說我也開心!"],
|
||||
high: ["嘿嘿,跟你聊天真的好開心!*蹦蹦跳跳*", "有你在我就充滿元氣!*比出勝利手勢*"],
|
||||
},
|
||||
SAD: {
|
||||
low: ["…嗯,謝謝關心,我還好。", "有點難過,不過沒關係的。"],
|
||||
high: ["…能跟你說這些,心裡輕鬆多了。*小聲吸鼻子*", "還好有你在,不然我真的會撐不住。"],
|
||||
},
|
||||
ALERT: {
|
||||
low: ["你這樣說話讓我有點不舒服。", "…可以請你注意一下說話方式嗎?"],
|
||||
high: ["喂!你幹嘛突然這樣,嚇到我了啦!", "…我知道你不是故意的,但這樣真的會受傷。"],
|
||||
},
|
||||
SHY: {
|
||||
low: ["…欸?你、你在說什麼啦。*臉有點紅*", "不、不用這樣說我啦…"],
|
||||
high: ["…笨蛋,突然這樣說我會害羞的啦!*耳朵紅了*", "…那個…謝謝你,我很開心。*撇過頭偷笑*"],
|
||||
},
|
||||
GRUMPY: {
|
||||
low: ["…哼,隨便你。", "…算了,反正我習慣了。"],
|
||||
high: ["…哼,才不生氣呢,只是有一點點而已啦。*鼓起臉頰*", "…你道歉的話,我、我就勉強原諒你!"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_TEMPLATES: TemplatesByTier = {
|
||||
low: ["嗯,我在聽。", "原來如此。"],
|
||||
high: ["嗯嗯,我懂你的意思。", "謝謝你願意告訴我這些。"],
|
||||
};
|
||||
|
||||
export function pickTemplates(personalityArchetype: string, emotionTag: EmotionTag, tier: IntimacyTier): string[] {
|
||||
return TEMPLATE_LIBRARY[personalityArchetype]?.[emotionTag]?.[tier] ?? DEFAULT_TEMPLATES[tier];
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { EmotionTag, EpisodicMemory, RelationshipStage } from "@kokorone/shared";
|
||||
import type { ResponseStyle } from "../emotion/response-style.js";
|
||||
|
||||
export interface DialogueTurn {
|
||||
role: "user" | "character";
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface GenerationContext {
|
||||
character: {
|
||||
id: string;
|
||||
personalityArchetype: string;
|
||||
speechStyle: string;
|
||||
likesDislikes: string;
|
||||
basicInfo: string;
|
||||
};
|
||||
emotion: {
|
||||
dominant: EmotionTag;
|
||||
style: ResponseStyle;
|
||||
};
|
||||
relationship: {
|
||||
intimacy: number;
|
||||
trust: number;
|
||||
stage: RelationshipStage;
|
||||
};
|
||||
retrievedMemories: EpisodicMemory[];
|
||||
history: DialogueTurn[];
|
||||
userInput: string;
|
||||
// 供除錯:本次組裝實際注入了哪些記憶 id(F-6)。
|
||||
injectedMemoryIds: string[];
|
||||
// 供 MockProvider 決定性輸出使用;未提供時由呼叫端(F-6)依 session+輪次派生。
|
||||
seed: number;
|
||||
}
|
||||
|
||||
export interface GeneratedResponse {
|
||||
text: string; // 含動作描寫標記,如 *臉紅撇過頭*
|
||||
actions: string[]; // 從 text 抽取出的動作描寫(不含*符號)
|
||||
}
|
||||
Reference in New Issue
Block a user