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
@@ -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" },
});
}
}