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:
Jeffery
2026-08-13 10:56:45 +08:00
parent 767b17e257
commit caf45f69e1
18 changed files with 761 additions and 9 deletions
+2
View File
@@ -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],
})
+5
View File
@@ -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,
};
}
}
+63
View File
@@ -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);
}
}
+79
View File
@@ -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 };
}
}
+34
View File
@@ -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 };
}
}
+9
View File
@@ -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");
+42
View File
@@ -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 {}
+28
View File
@@ -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;
}
}
}
+18
View File
@@ -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;
}
}
+18
View File
@@ -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;
}
+51
View File
@@ -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];
}
+39
View File
@@ -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 抽取出的動作描寫(不含*符號)
}