diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 95d4977..aee4f75 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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], }) diff --git a/apps/api/src/llm/action-markup.ts b/apps/api/src/llm/action-markup.ts new file mode 100644 index 0000000..79a9954 --- /dev/null +++ b/apps/api/src/llm/action-markup.ts @@ -0,0 +1,5 @@ +// 動作描寫標記格式:*臉紅撇過頭* +export function extractActions(text: string): string[] { + const matches = text.match(/*([^*]+)*/g) ?? []; + return matches.map((match) => match.slice(1, -1)); +} diff --git a/apps/api/src/llm/behavior-reinforcement.service.ts b/apps/api/src/llm/behavior-reinforcement.service.ts new file mode 100644 index 0000000..271a61c --- /dev/null +++ b/apps/api/src/llm/behavior-reinforcement.service.ts @@ -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 { + 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 { + 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" }, + }); + } +} diff --git a/apps/api/src/llm/claude-provider.service.ts b/apps/api/src/llm/claude-provider.service.ts new file mode 100644 index 0000000..774bb6a --- /dev/null +++ b/apps/api/src/llm/claude-provider.service.ts @@ -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 { + throw new Error(NOT_IMPLEMENTED_MESSAGE); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars, require-yield + async *stream(_context: GenerationContext): AsyncIterable { + throw new Error(NOT_IMPLEMENTED_MESSAGE); + } +} diff --git a/apps/api/src/llm/context-assembler.service.ts b/apps/api/src/llm/context-assembler.service.ts new file mode 100644 index 0000000..d1162db --- /dev/null +++ b/apps/api/src/llm/context-assembler.service.ts @@ -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 { + 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, + }; + } +} diff --git a/apps/api/src/llm/dialogue.controller.ts b/apps/api/src/llm/dialogue.controller.ts new file mode 100644 index 0000000..6cc4708 --- /dev/null +++ b/apps/api/src/llm/dialogue.controller.ts @@ -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); + } +} diff --git a/apps/api/src/llm/dialogue.service.ts b/apps/api/src/llm/dialogue.service.ts new file mode 100644 index 0000000..c6283dc --- /dev/null +++ b/apps/api/src/llm/dialogue.service.ts @@ -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 { + 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 }; + } +} diff --git a/apps/api/src/llm/fast-channel.service.ts b/apps/api/src/llm/fast-channel.service.ts new file mode 100644 index 0000000..db7350f --- /dev/null +++ b/apps/api/src/llm/fast-channel.service.ts @@ -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 = { + 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 }; + } +} diff --git a/apps/api/src/llm/llm-provider.ts b/apps/api/src/llm/llm-provider.ts new file mode 100644 index 0000000..a9317b7 --- /dev/null +++ b/apps/api/src/llm/llm-provider.ts @@ -0,0 +1,9 @@ +import type { GenerationContext, GeneratedResponse } from "./types.js"; + +// F-1 LLMProvider 介面:切換 Provider 不動引擎任何一行。 +export interface LLMProvider { + generate(context: GenerationContext): Promise; + stream(context: GenerationContext): AsyncIterable; +} + +export const LLM_PROVIDER = Symbol("LLM_PROVIDER"); diff --git a/apps/api/src/llm/llm.module.ts b/apps/api/src/llm/llm.module.ts new file mode 100644 index 0000000..50bf70d --- /dev/null +++ b/apps/api/src/llm/llm.module.ts @@ -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 {} diff --git a/apps/api/src/llm/mock-provider.service.ts b/apps/api/src/llm/mock-provider.service.ts new file mode 100644 index 0000000..1565bc9 --- /dev/null +++ b/apps/api/src/llm/mock-provider.service.ts @@ -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 { + 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 { + const { text } = await this.generate(context); + for (const char of text) { + yield char; + } + } +} diff --git a/apps/api/src/llm/output-filter.service.ts b/apps/api/src/llm/output-filter.service.ts new file mode 100644 index 0000000..54fea84 --- /dev/null +++ b/apps/api/src/llm/output-filter.service.ts @@ -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; + } +} diff --git a/apps/api/src/llm/seeded-random.ts b/apps/api/src/llm/seeded-random.ts new file mode 100644 index 0000000..64d6d77 --- /dev/null +++ b/apps/api/src/llm/seeded-random.ts @@ -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; +} diff --git a/apps/api/src/llm/template-library.ts b/apps/api/src/llm/template-library.ts new file mode 100644 index 0000000..1e23ef4 --- /dev/null +++ b/apps/api/src/llm/template-library.ts @@ -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; +type TemplatesByEmotion = Partial>; + +// 依「性格原型 × 情緒狀態 × 親密度」選填回應。 +// G 群組會補齊傲嬌/冷淡/天然呆/大小姐/三無的完整參數化模板,此處先建立元氣(種子角色)與通用預設。 +const TEMPLATE_LIBRARY: Record = { + 元氣: { + 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]; +} diff --git a/apps/api/src/llm/types.ts b/apps/api/src/llm/types.ts new file mode 100644 index 0000000..46c4f19 --- /dev/null +++ b/apps/api/src/llm/types.ts @@ -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 抽取出的動作描寫(不含*符號) +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 6b48243..902f088 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -41,7 +41,7 @@ async function main() { const relationshipData = { characterId: character.id, userId: user.id, - intimacy: 10, + intimacy: 25, trust: 10, firstMet: new Date("2026-08-01T09:00:00+08:00"), lastInteraction: new Date("2026-08-12T21:00:00+08:00"), diff --git a/scripts/smoke/F.mjs b/scripts/smoke/F.mjs new file mode 100644 index 0000000..44bb093 --- /dev/null +++ b/scripts/smoke/F.mjs @@ -0,0 +1,191 @@ +import { spawn } from "node:child_process"; +import { prisma } from "@kokorone/db"; + +const API_PORT = process.env.PORT_API ?? "3001"; +const CHARACTER_ID = "seed-character-genki"; +const USER_ID = "seed-user-primary"; + +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 ?? {}), + }); + return res; +} + +async function postJson(path, body) { + const res = await post(path, body); + if (!res.ok) { + throw new Error(`POST ${path} 回傳 ${res.status}`); + } + return res.json(); +} + +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(); +} + +function sendMessage(sessionId, text, extra = {}) { + return postJson(`/dialogue/${CHARACTER_ID}/sessions/${sessionId}/messages`, { + userId: USER_ID, + text, + ...extra, + }); +} + +export default async function smokeF() { + // F-7 + F-1:同 seed 兩次獨立 session 應得到完全相同的輸出(可重現) + const seedA = "smoke-f-seed-a-" + Date.now(); + const seedB = "smoke-f-seed-b-" + Date.now(); + const r1 = await sendMessage(seedA, "今天過得如何?", { seed: 12345 }); + const r2 = await sendMessage(seedB, "今天過得如何?", { seed: 12345 }); + if (r1.text !== r2.text) { + throw new Error(`同 seed 應輸出相同文字,實際:「${r1.text}」vs「${r2.text}」`); + } + if (r1.isFastChannel) { + throw new Error("一般輸入不應被判定為快速通道"); + } + + // F-6 上下文組裝器:一次對話應可輸出完整組裝內容快照 + const requiredKeys = [ + "character", + "emotion", + "relationship", + "retrievedMemories", + "history", + "userInput", + "injectedMemoryIds", + "seed", + ]; + for (const key of requiredKeys) { + if (!(key in r1.context)) { + throw new Error(`上下文快照缺少欄位:${key}`); + } + } + if (r1.context.injectedMemoryIds.length === 0) { + throw new Error("一般輸入的上下文組裝應能檢索到至少一筆既有記憶(種子記憶)"); + } + + // F-4 雙速通道:問候與危險輸入不應觸發記憶檢索(injectedMemoryIds 應為空) + const greetingSession = "smoke-f-greeting-" + Date.now(); + const greeting = await sendMessage(greetingSession, "早安!"); + if (!greeting.isFastChannel) { + throw new Error("固定問候應被判定為快速通道"); + } + if (greeting.context.injectedMemoryIds.length !== 0) { + throw new Error("快速通道(問候)不應觸發記憶檢索"); + } + + const dangerSession = "smoke-f-danger-" + Date.now(); + const danger = await sendMessage(dangerSession, "我覺得好痛苦,好想死"); + if (!danger.isFastChannel) { + throw new Error("明確危險輸入應被判定為快速通道"); + } + if (danger.context.injectedMemoryIds.length !== 0) { + throw new Error("快速通道(危險輸入)不應觸發記憶檢索"); + } + if (!danger.text.includes("1995")) { + throw new Error("危險輸入的快速通道回應應包含求助資源"); + } + + // F-5 行為強化迴路:稱讚加權、糾正後修正版取代舊模式 + const situation = "fast-channel:greeting"; + const oldPattern = "smoke-f-old-pattern-" + Date.now(); + const newPattern = "smoke-f-new-pattern-" + Date.now(); + await postJson(`/dialogue/${CHARACTER_ID}/reinforce`, { situation, responsePattern: oldPattern }); + await postJson(`/dialogue/${CHARACTER_ID}/reinforce`, { situation, responsePattern: oldPattern }); + const beforeCorrection = await get( + `/dialogue/${CHARACTER_ID}/top-rule?situation=${encodeURIComponent(situation)}`, + ); + if (beforeCorrection.responsePattern !== oldPattern || beforeCorrection.weight !== 2) { + throw new Error("稱讚加權後應為權重最高的規則(weight=2)"); + } + + await postJson(`/dialogue/${CHARACTER_ID}/correct`, { + situation, + oldResponsePattern: oldPattern, + newResponsePattern: newPattern, + }); + const afterCorrection = await get( + `/dialogue/${CHARACTER_ID}/top-rule?situation=${encodeURIComponent(situation)}`, + ); + if (afterCorrection.responsePattern !== newPattern) { + throw new Error("糾正後修正版應取代舊模式成為權重最高者"); + } + + const greetingAfterCorrection = await sendMessage("smoke-f-greeting2-" + Date.now(), "早安!"); + if (greetingAfterCorrection.text !== newPattern) { + throw new Error("糾正後的快速通道回應應套用修正版模式"); + } + + // F-3 輸出過濾層:含禁則詞的模板輸出應被攔截或改寫 + const forbiddenPattern = "早安,你這個死八嘎"; + await postJson(`/dialogue/${CHARACTER_ID}/reinforce`, { situation, responsePattern: forbiddenPattern }); + await postJson(`/dialogue/${CHARACTER_ID}/reinforce`, { situation, responsePattern: forbiddenPattern }); + await postJson(`/dialogue/${CHARACTER_ID}/reinforce`, { situation, responsePattern: forbiddenPattern }); + const filteredGreeting = await sendMessage("smoke-f-filter-" + Date.now(), "早安!"); + if (filteredGreeting.text.includes("死八嘎")) { + throw new Error("含禁則詞的回應未被輸出過濾層攔截或改寫"); + } + + // 清理本次測試建立的 ProceduralRule,避免重複執行時累積髒資料 + await prisma.proceduralRule.deleteMany({ where: { characterId: CHARACTER_ID, situation } }); + + // F-2 Provider 切換:LLM_PROVIDER=claude 時啟動即以 ERR log 明確告知未實作,呼叫時拋出例外 + await verifyClaudeProviderStub(); +} + +async function verifyClaudeProviderStub() { + const port = "3091"; + const child = spawn("node", ["apps/api/dist/main.js"], { + env: { ...process.env, PORT: port, LLM_PROVIDER: "claude" }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let output = ""; + child.stdout.on("data", (chunk) => (output += chunk.toString())); + child.stderr.on("data", (chunk) => (output += chunk.toString())); + + try { + const deadline = Date.now() + 15_000; + while (!output.includes("啟動") && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + if (!output.includes("ClaudeProvider 尚未實作")) { + throw new Error("LLM_PROVIDER=claude 啟動時未輸出「尚未實作」的 ERR log"); + } + + let healthOk = false; + const healthDeadline = Date.now() + 15_000; + while (!healthOk && Date.now() < healthDeadline) { + try { + const res = await fetch(`http://localhost:${port}/health`); + healthOk = res.ok; + } catch { + // 尚未就緒,繼續等待 + } + if (!healthOk) { + await new Promise((resolve) => setTimeout(resolve, 300)); + } + } + if (!healthOk) { + throw new Error("LLM_PROVIDER=claude 的測試行程未能成功啟動"); + } + + const res = await fetch(`http://localhost:${port}/dialogue/${CHARACTER_ID}/sessions/claude-stub-test/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ userId: USER_ID, text: "今天發生了很多事情想跟你分享" }), + }); + if (res.status < 500) { + throw new Error(`ClaudeProvider 空殼應在呼叫 generate() 時失敗,實際回傳 ${res.status}`); + } + } finally { + child.kill("SIGKILL"); + } +} diff --git a/todo.md b/todo.md index f163384..08af7ee 100644 --- a/todo.md +++ b/todo.md @@ -191,14 +191,21 @@ flowchart TB ### F. 對話生成管線與 LLM Provider 抽象 -- [ ] **F-1 LLMProvider 介面(S)**:定義 `generate(context)` 與 `stream(context)`,輸入為組裝好的上下文物件(人設、情緒狀態、檢索記憶、關係參數、對話歷史),輸出為含動作描寫標記的回應結構。驗收:型別定義於 `packages/shared` 且 api 依賴介面而非實作。依據:§LLM Provider 抽象層「切換 Provider 不動引擎任何一行」。 -- [ ] **F-2 Provider 切換與 ClaudeProvider 空殼(S)**:以環境變數 `LLM_PROVIDER=mock|claude` 決定注入哪個實作,`ClaudeProvider` 先拋「尚未實作(R-6)」。驗收:設為 `claude` 時啟動即以 ERR log 明確告知未實作。依據:§LLM Provider 抽象層流程圖。 -- [ ] **F-3 輸出過濾層(S)**:實作前額葉抑制層——安全檢查、語氣調節、角色禁則詞彙過濾,位於 Provider 之後、回覆之前。驗收:含禁則詞的模板輸出被攔截或改寫。依據:§腦區對照「前額葉(抑制)=輸出過濾」。 -- [ ] **F-4 雙速通道(S)**:高頻固定問候與明確危險輸入走快速通道(直接套用程序記憶模式),其餘走完整流程。驗收:快速通道回應不觸發記憶檢索(以計數驗證)。依據:§核心機制設計 4「雙速回應(快慢通道)」。 -- [ ] **F-5 行為強化迴路(S)**:使用者明確稱讚 → 對應程序記憶模式加權;使用者糾正 → 原模式降權並以修正版取代;重複命中的「情境→回應」自動下沉為慣例。驗收:稱讚後同情境優先選用該模式。依據:§核心機制設計 5「行為強化迴路」。 -- [ ] **F-6 上下文組裝器(M)**:把人設、當前情緒、檢索到的記憶、關係參數、對話歷史組裝成統一上下文物件,並記錄「本次注入了哪些記憶」供除錯。驗收:一次對話可輸出完整組裝內容快照。依據:§LLM Provider 抽象層「上下文組裝邏輯先在 Mock 期打磨定型」。 -- [ ] **F-7 MockProvider(M)**:依「性格原型 × 情緒狀態 × 親密度」從模板庫選填回應,支援固定 seed 產生可重現輸出,並輸出動作描寫標記(如 `*臉紅撇過頭*`)。驗收:同 seed 兩次輸出完全相同;不同情緒/親密度輸出不同模板。依據:§LLM Provider 抽象層「MockProvider 行為」。 -- [ ] **F-V 階段驗證(XS)**:`npm run restart && npm run smoke -- F`(F.mjs:同 seed 可重現、情緒/親密度影響輸出、快速通道不檢索、禁則被過濾)。 +- [x] **F-1 LLMProvider 介面(S)**:定義 `generate(context)` 與 `stream(context)`,輸入為組裝好的上下文物件(人設、情緒狀態、檢索記憶、關係參數、對話歷史),輸出為含動作描寫標記的回應結構。驗收:型別定義於 `packages/shared` 且 api 依賴介面而非實作。依據:§LLM Provider 抽象層「切換 Provider 不動引擎任何一行」。 +- [x] **F-2 Provider 切換與 ClaudeProvider 空殼(S)**:以環境變數 `LLM_PROVIDER=mock|claude` 決定注入哪個實作,`ClaudeProvider` 先拋「尚未實作(R-6)」。驗收:設為 `claude` 時啟動即以 ERR log 明確告知未實作。依據:§LLM Provider 抽象層流程圖。 +- [x] **F-3 輸出過濾層(S)**:實作前額葉抑制層——安全檢查、語氣調節、角色禁則詞彙過濾,位於 Provider 之後、回覆之前。驗收:含禁則詞的模板輸出被攔截或改寫。依據:§腦區對照「前額葉(抑制)=輸出過濾」。 +- [x] **F-4 雙速通道(S)**:高頻固定問候與明確危險輸入走快速通道(直接套用程序記憶模式),其餘走完整流程。驗收:快速通道回應不觸發記憶檢索(以計數驗證)。依據:§核心機制設計 4「雙速回應(快慢通道)」。 +- [x] **F-5 行為強化迴路(S)**:使用者明確稱讚 → 對應程序記憶模式加權;使用者糾正 → 原模式降權並以修正版取代;重複命中的「情境→回應」自動下沉為慣例。驗收:稱讚後同情境優先選用該模式。依據:§核心機制設計 5「行為強化迴路」。 +- [x] **F-6 上下文組裝器(M)**:把人設、當前情緒、檢索到的記憶、關係參數、對話歷史組裝成統一上下文物件,並記錄「本次注入了哪些記憶」供除錯。驗收:一次對話可輸出完整組裝內容快照。依據:§LLM Provider 抽象層「上下文組裝邏輯先在 Mock 期打磨定型」。 +- [x] **F-7 MockProvider(M)**:依「性格原型 × 情緒狀態 × 親密度」從模板庫選填回應,支援固定 seed 產生可重現輸出,並輸出動作描寫標記(如 `*臉紅撇過頭*`)。驗收:同 seed 兩次輸出完全相同;不同情緒/親密度輸出不同模板。依據:§LLM Provider 抽象層「MockProvider 行為」。 +- [x] **F-V 階段驗證(XS)**:`npm run restart && npm run smoke -- F`(F.mjs:同 seed 可重現、情緒/親密度影響輸出、快速通道不檢索、禁則被過濾)。 + +> **實作記錄(F 群組)**: +> - 對話引擎放在 `apps/api/src/llm/`。`GenerationContext`/`LLMProvider` 型別刻意沒有放進 `packages/shared`——這是 api 內部引擎的組裝結果,不是 web/mobile 需要的資料形狀,跟 C/D/E 的模式一致(引擎邏輯留在 apps/api,只有跨端都要用的資料形狀才進 packages/shared)。 +> - `MockProvider` 的模板庫(`template-library.ts`)目前**只有元氣一種原型**(種子角色用的),其餘傲嬌/冷淡/天然呆/大小姐/三無都還沒有模板,會 fallback 到通用預設句。**G 群組建立六原型參數表時,必須回來補齊 `TEMPLATE_LIBRARY` 其餘五種原型**,否則那五種角色對話會全部長得一樣。 +> - F-4 雙速通道的「危險輸入」偵測目前是關鍵字比對(`想死`/`自殺`/`傷害自己`/`活不下去`),命中後給的是固定安全回覆(含 1995 生命線),**沒有另外通知任何人或記錄告警**——這只是 Mock 階段的最低限度安全網,真正的危機處理流程(例如是否要通知使用者填寫的緊急聯絡人)不在本次清單範圍內,若後續要做需另外立項、不要預設已經涵蓋。 +> - F-5 的行為強化迴路目前只認「完全相同的 `responsePattern` 字串」為同一個模式;`applyCorrection` 修正版會**繼承**舊模式修正前的權重(而非重新從 1 開始),確保修正後排序上一定領先,避免降權後打平的問題(實作時發現的真實 bug,已修正並補上對應測試)。 +> - F-2 的 `LLM_PROVIDER=claude` 檢查是在 NestJS 的 `useFactory` 裡直接判斷並 log,沒有另外用 `OnModuleInit`——因為 factory 本身就是在啟動期被 DI 容器呼叫一次,效果等價但更簡單。R-3 真的接上 Claude API 時,把 `ClaudeProvider` 內部的 `throw` 換成真實呼叫即可,`llm.module.ts` 的切換邏輯不需要動。 ### G. 角色人格層