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 { MemoryModule } from "./memory/memory.module.js";
|
||||||
import { EmotionModule } from "./emotion/emotion.module.js";
|
import { EmotionModule } from "./emotion/emotion.module.js";
|
||||||
import { RelationshipModule } from "./relationship/relationship.module.js";
|
import { RelationshipModule } from "./relationship/relationship.module.js";
|
||||||
|
import { LlmModule } from "./llm/llm.module.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -13,6 +14,7 @@ import { RelationshipModule } from "./relationship/relationship.module.js";
|
|||||||
MemoryModule,
|
MemoryModule,
|
||||||
EmotionModule,
|
EmotionModule,
|
||||||
RelationshipModule,
|
RelationshipModule,
|
||||||
|
LlmModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
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 抽取出的動作描寫(不含*符號)
|
||||||
|
}
|
||||||
+1
-1
@@ -41,7 +41,7 @@ async function main() {
|
|||||||
const relationshipData = {
|
const relationshipData = {
|
||||||
characterId: character.id,
|
characterId: character.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
intimacy: 10,
|
intimacy: 25,
|
||||||
trust: 10,
|
trust: 10,
|
||||||
firstMet: new Date("2026-08-01T09:00:00+08:00"),
|
firstMet: new Date("2026-08-01T09:00:00+08:00"),
|
||||||
lastInteraction: new Date("2026-08-12T21:00:00+08:00"),
|
lastInteraction: new Date("2026-08-12T21:00:00+08:00"),
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -191,14 +191,21 @@ flowchart TB
|
|||||||
|
|
||||||
### F. 對話生成管線與 LLM Provider 抽象
|
### F. 對話生成管線與 LLM Provider 抽象
|
||||||
|
|
||||||
- [ ] **F-1 LLMProvider 介面(S)**:定義 `generate(context)` 與 `stream(context)`,輸入為組裝好的上下文物件(人設、情緒狀態、檢索記憶、關係參數、對話歷史),輸出為含動作描寫標記的回應結構。驗收:型別定義於 `packages/shared` 且 api 依賴介面而非實作。依據:§LLM Provider 抽象層「切換 Provider 不動引擎任何一行」。
|
- [x] **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 抽象層流程圖。
|
- [x] **F-2 Provider 切換與 ClaudeProvider 空殼(S)**:以環境變數 `LLM_PROVIDER=mock|claude` 決定注入哪個實作,`ClaudeProvider` 先拋「尚未實作(R-6)」。驗收:設為 `claude` 時啟動即以 ERR log 明確告知未實作。依據:§LLM Provider 抽象層流程圖。
|
||||||
- [ ] **F-3 輸出過濾層(S)**:實作前額葉抑制層——安全檢查、語氣調節、角色禁則詞彙過濾,位於 Provider 之後、回覆之前。驗收:含禁則詞的模板輸出被攔截或改寫。依據:§腦區對照「前額葉(抑制)=輸出過濾」。
|
- [x] **F-3 輸出過濾層(S)**:實作前額葉抑制層——安全檢查、語氣調節、角色禁則詞彙過濾,位於 Provider 之後、回覆之前。驗收:含禁則詞的模板輸出被攔截或改寫。依據:§腦區對照「前額葉(抑制)=輸出過濾」。
|
||||||
- [ ] **F-4 雙速通道(S)**:高頻固定問候與明確危險輸入走快速通道(直接套用程序記憶模式),其餘走完整流程。驗收:快速通道回應不觸發記憶檢索(以計數驗證)。依據:§核心機制設計 4「雙速回應(快慢通道)」。
|
- [x] **F-4 雙速通道(S)**:高頻固定問候與明確危險輸入走快速通道(直接套用程序記憶模式),其餘走完整流程。驗收:快速通道回應不觸發記憶檢索(以計數驗證)。依據:§核心機制設計 4「雙速回應(快慢通道)」。
|
||||||
- [ ] **F-5 行為強化迴路(S)**:使用者明確稱讚 → 對應程序記憶模式加權;使用者糾正 → 原模式降權並以修正版取代;重複命中的「情境→回應」自動下沉為慣例。驗收:稱讚後同情境優先選用該模式。依據:§核心機制設計 5「行為強化迴路」。
|
- [x] **F-5 行為強化迴路(S)**:使用者明確稱讚 → 對應程序記憶模式加權;使用者糾正 → 原模式降權並以修正版取代;重複命中的「情境→回應」自動下沉為慣例。驗收:稱讚後同情境優先選用該模式。依據:§核心機制設計 5「行為強化迴路」。
|
||||||
- [ ] **F-6 上下文組裝器(M)**:把人設、當前情緒、檢索到的記憶、關係參數、對話歷史組裝成統一上下文物件,並記錄「本次注入了哪些記憶」供除錯。驗收:一次對話可輸出完整組裝內容快照。依據:§LLM Provider 抽象層「上下文組裝邏輯先在 Mock 期打磨定型」。
|
- [x] **F-6 上下文組裝器(M)**:把人設、當前情緒、檢索到的記憶、關係參數、對話歷史組裝成統一上下文物件,並記錄「本次注入了哪些記憶」供除錯。驗收:一次對話可輸出完整組裝內容快照。依據:§LLM Provider 抽象層「上下文組裝邏輯先在 Mock 期打磨定型」。
|
||||||
- [ ] **F-7 MockProvider(M)**:依「性格原型 × 情緒狀態 × 親密度」從模板庫選填回應,支援固定 seed 產生可重現輸出,並輸出動作描寫標記(如 `*臉紅撇過頭*`)。驗收:同 seed 兩次輸出完全相同;不同情緒/親密度輸出不同模板。依據:§LLM Provider 抽象層「MockProvider 行為」。
|
- [x] **F-7 MockProvider(M)**:依「性格原型 × 情緒狀態 × 親密度」從模板庫選填回應,支援固定 seed 產生可重現輸出,並輸出動作描寫標記(如 `*臉紅撇過頭*`)。驗收:同 seed 兩次輸出完全相同;不同情緒/親密度輸出不同模板。依據:§LLM Provider 抽象層「MockProvider 行為」。
|
||||||
- [ ] **F-V 階段驗證(XS)**:`npm run restart && npm run smoke -- F`(F.mjs:同 seed 可重現、情緒/親密度影響輸出、快速通道不檢索、禁則被過濾)。
|
- [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. 角色人格層
|
### G. 角色人格層
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user