feat: 完成 I 群組 — 生活作息與離線生活
- ScheduleService:I-1 平日/週末/假期時段表(可為角色寫入高中生預設作息)、 I-3 時間感知(Asia/Taipei 當地時間,不受伺服器時區影響)、剛醒/睡前偵測 - constants.ts:I-2 時段狀態 → 回應可用度對照(FREE/SEMI_BUSY/BUSY/SLEEP → NORMAL/BRIEF/DELAYED/NO_REPLY),沒有作息表的角色一律視為 NORMAL(相容既有角色) - ScheduleExceptionService:I-4 三種破例(深夜還陪你聊/上課偷回訊息/為你調整行程), 各自獨立冷卻期,觸發時記入關係帳本 - ScheduleEmotionBaselineService:I-5 作息驅動情緒基線(社團剛結束的疲憊、假日偏高心情), 只在本輪沒有明確情緒訊號時介入,串接進 EmotionService.processInput 的新增選項 - OfflineEventService:I-6 離線事件生成,依「今天已發生的固定行程」挑選對應模板, 一天只生成一次,寫入低權重情節記憶 - DialogueService 整合以上機制:離線事件生成 → 情緒基線標記 → 累積親密度 → 作息可用度判定 → 不符合則嘗試破例 → 依可用度決定回覆內容 - 修正真實 bug:G-5 角色建立流程誤用 source=OFFLINE_GENERATED 標記背景故事, 與 I-6「今天是否已生成離線事件」的判斷共用同一標記,已改為 SOURCE_EXTRACTION - ChatView.tsx 補上 NO_REPLY/context 為 null 的防呆(作息可用度整合後的必要調整) - scripts/smoke/I.mjs:以獨立測試角色(避免污染種子角色的排程狀態)驗證全部六項 npm run restart && npm run smoke -- I 皆通過(I-V),A~H 群組冒煙測試無回歸。
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service.js";
|
||||
import { WorkingMemoryService } from "../memory/working-memory.service.js";
|
||||
import { EmotionService } from "../emotion/emotion.service.js";
|
||||
import { EmotionService, dominantState } from "../emotion/emotion.service.js";
|
||||
import { RelationshipService } from "../relationship/relationship.service.js";
|
||||
import { ScheduleService } from "../schedule/schedule.service.js";
|
||||
import { ScheduleExceptionService } from "../schedule/schedule-exception.service.js";
|
||||
import { ScheduleEmotionBaselineService } from "../schedule/schedule-emotion-baseline.service.js";
|
||||
import { OfflineEventService } from "../schedule/offline-event.service.js";
|
||||
import { availabilityForStatus, type AvailabilityMode, type ExceptionType } from "../schedule/constants.js";
|
||||
import { LLM_PROVIDER, type LLMProvider } from "./llm-provider.js";
|
||||
import { ContextAssemblerService } from "./context-assembler.service.js";
|
||||
import { OutputFilterService } from "./output-filter.service.js";
|
||||
@@ -20,15 +25,19 @@ export interface HandleMessageResult {
|
||||
text: string;
|
||||
actions: string[];
|
||||
isFastChannel: boolean;
|
||||
context: GenerationContext;
|
||||
availability: AvailabilityMode;
|
||||
exceptionType: ExceptionType | null;
|
||||
context: GenerationContext | null;
|
||||
}
|
||||
|
||||
function fastChannelSituation(reason: string): string {
|
||||
return `fast-channel:${reason}`;
|
||||
}
|
||||
|
||||
const DELAYED_ACKNOWLEDGEMENT = "抱歉剛剛在忙,現在才看到——";
|
||||
|
||||
// F 群組整合入口:串起雙速通道判斷、上下文組裝、Provider 生成、輸出過濾與工作記憶寫入。
|
||||
// H-1 的 POST /chat/:characterId 會以此為基礎擴充(見 todo.md 實作記錄)。
|
||||
// I 群組擴充:作息可用度/破例規則/作息情緒基線/離線事件生成(見 todo.md 實作記錄)。
|
||||
@Injectable()
|
||||
export class DialogueService {
|
||||
constructor(
|
||||
@@ -39,6 +48,10 @@ export class DialogueService {
|
||||
private readonly workingMemory: WorkingMemoryService,
|
||||
private readonly emotion: EmotionService,
|
||||
private readonly relationship: RelationshipService,
|
||||
private readonly schedule: ScheduleService,
|
||||
private readonly scheduleException: ScheduleExceptionService,
|
||||
private readonly scheduleBaseline: ScheduleEmotionBaselineService,
|
||||
private readonly offlineEvent: OfflineEventService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@@ -52,14 +65,46 @@ export class DialogueService {
|
||||
const now = options.now ?? new Date();
|
||||
this.workingMemory.append(sessionId, { role: "user", content: userInput, timestamp: now });
|
||||
|
||||
// H-1:每輪對話先標記情緒、累積日常互動的親密度,讓後續組裝的上下文反映本輪的變化。
|
||||
await this.emotion.processInput(characterId, userInput, { now });
|
||||
// I-6:按需生成離線事件(今天只生成一次),成為話題來源。
|
||||
await this.offlineEvent.maybeGenerate(characterId, now);
|
||||
|
||||
// I-5:作息驅動的情緒基線,只在本輪沒有明確情緒訊號時才介入。
|
||||
const baselineSignal = await this.scheduleBaseline.computeBaseline(characterId, now);
|
||||
const emotionResult = await this.emotion.processInput(characterId, userInput, {
|
||||
now,
|
||||
baselineSignal: baselineSignal ?? undefined,
|
||||
});
|
||||
await this.relationship.recordInteraction(characterId, userId, now);
|
||||
|
||||
// I-2:目前時段狀態 → 回應可用度;I-4:條件符合且未在冷卻期時才破例。
|
||||
const scheduleInfo = await this.schedule.getInfo(characterId, now);
|
||||
const availability = availabilityForStatus(scheduleInfo.slot?.status ?? null);
|
||||
|
||||
let exceptionType: ExceptionType | null = null;
|
||||
let effectiveAvailability = availability;
|
||||
if (availability !== "NORMAL") {
|
||||
const { relationship } = await this.relationship.getState(characterId, userId, now);
|
||||
exceptionType = await this.scheduleException.tryApply(
|
||||
characterId,
|
||||
userId,
|
||||
availability,
|
||||
{ intimacy: relationship.intimacy, dominantEmotion: dominantState(emotionResult.state), userInput },
|
||||
now,
|
||||
);
|
||||
if (exceptionType) {
|
||||
effectiveAvailability = "NORMAL";
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveAvailability === "NO_REPLY") {
|
||||
return { text: "", actions: [], isFastChannel: false, availability: effectiveAvailability, exceptionType, context: null };
|
||||
}
|
||||
|
||||
const { isFastChannel, reason } = this.fastChannel.detect(userInput);
|
||||
const skipMemoryRetrieval = isFastChannel || effectiveAvailability === "BRIEF";
|
||||
|
||||
const context = await this.contextAssembler.assemble(characterId, userId, sessionId, userInput, {
|
||||
skipMemoryRetrieval: isFastChannel,
|
||||
skipMemoryRetrieval,
|
||||
seed: options.seed,
|
||||
now,
|
||||
});
|
||||
@@ -67,7 +112,10 @@ export class DialogueService {
|
||||
let text: string;
|
||||
let actions: string[];
|
||||
|
||||
if (isFastChannel && reason) {
|
||||
if (effectiveAvailability === "DELAYED" && !exceptionType) {
|
||||
text = DELAYED_ACKNOWLEDGEMENT;
|
||||
actions = [];
|
||||
} else if (isFastChannel && reason) {
|
||||
const rule = await this.prisma.client.proceduralRule.findFirst({
|
||||
where: { characterId, situation: fastChannelSituation(reason) },
|
||||
orderBy: { weight: "desc" },
|
||||
@@ -80,6 +128,11 @@ export class DialogueService {
|
||||
actions = generated.actions;
|
||||
}
|
||||
|
||||
// I-2 剛醒/睡前:以拖長語尾的簡單標記呈現迷糊演出(完整口吻交由模板庫日後擴充)。
|
||||
if (scheduleInfo.isWaking || scheduleInfo.isPreSleep) {
|
||||
text = `……${text}`;
|
||||
}
|
||||
|
||||
const forbiddenWords = getForbiddenWords(context.character.personalityArchetype);
|
||||
const filteredText = applySpeechQuirks(
|
||||
this.outputFilter.filter(text, forbiddenWords),
|
||||
@@ -87,6 +140,6 @@ export class DialogueService {
|
||||
);
|
||||
this.workingMemory.append(sessionId, { role: "character", content: filteredText, timestamp: now });
|
||||
|
||||
return { text: filteredText, actions, isFastChannel, context };
|
||||
return { text: filteredText, actions, isFastChannel, availability: effectiveAvailability, exceptionType, context };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { ScheduleModule } from "../schedule/schedule.module.js";
|
||||
import { MockProvider } from "./mock-provider.service.js";
|
||||
import { ClaudeProvider } from "./claude-provider.service.js";
|
||||
import { LLM_PROVIDER } from "./llm-provider.js";
|
||||
@@ -25,7 +26,7 @@ function llmProviderFactory(mock: MockProvider, claude: ClaudeProvider) {
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, EmotionModule, RelationshipModule, MemoryModule],
|
||||
imports: [PrismaModule, EmotionModule, RelationshipModule, MemoryModule, ScheduleModule],
|
||||
controllers: [DialogueController],
|
||||
providers: [
|
||||
MockProvider,
|
||||
|
||||
Reference in New Issue
Block a user