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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user