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:
Jeffery
2026-08-13 14:08:24 +08:00
parent 9e5036f0a0
commit d82b834e47
20 changed files with 840 additions and 22 deletions
+2
View File
@@ -8,6 +8,7 @@ import { RelationshipModule } from "./relationship/relationship.module.js";
import { LlmModule } from "./llm/llm.module.js";
import { PersonalityModule } from "./personality/personality.module.js";
import { ChatModule } from "./chat/chat.module.js";
import { ScheduleModule } from "./schedule/schedule.module.js";
@Module({
imports: [
@@ -19,6 +20,7 @@ import { ChatModule } from "./chat/chat.module.js";
LlmModule,
PersonalityModule,
ChatModule,
ScheduleModule,
],
controllers: [HealthController],
})
+6 -1
View File
@@ -6,6 +6,8 @@ interface ChatMessageBody {
userId: string;
sessionId: string;
text: string;
seed?: number;
now?: string; // 測試用:模擬「現在」時間,供 I 群組作息相關行為驗證
}
interface EndSessionBody {
@@ -23,7 +25,10 @@ export class ChatController {
@Post("chat/:characterId")
async chat(@Param("characterId") characterId: string, @Body() body: ChatMessageBody) {
return this.dialogue.handleMessage(characterId, body.userId, body.sessionId, body.text);
return this.dialogue.handleMessage(characterId, body.userId, body.sessionId, body.text, {
seed: body.seed,
now: body.now ? new Date(body.now) : undefined,
});
}
@Post("session/:sessionId/end")
+6 -1
View File
@@ -56,6 +56,8 @@ export interface ProcessInputOptions extends TransitionContext {
triggerThreshold?: number;
halfLifeMs?: number;
now?: Date;
// I-5:本輪對話沒有明確情緒訊號時,套用作息驅動的基線情緒(不覆蓋文字本身觸發的訊號)。
baselineSignal?: { tag: EmotionTag; intensity: number };
}
function toDomain(characterId: string, dims: Dimensions, updatedAt: Date): EmotionState {
@@ -105,7 +107,10 @@ export class EmotionService {
const rawSignal = this.tagger.tag({ text, triggerThreshold });
// 情緒觸發閾值除了縮放強度,也要能讓「難觸發」原型對弱刺激完全不觸發(而不只是觸發得比較小力)。
const signal = rawSignal.intensity < MIN_TRIGGER_INTENSITY ? { tag: "CALM" as const, intensity: 0 } : rawSignal;
let signal = rawSignal.intensity < MIN_TRIGGER_INTENSITY ? { tag: "CALM" as const, intensity: 0 } : rawSignal;
if (signal.tag === "CALM" && options.baselineSignal) {
signal = options.baselineSignal;
}
const next = nextEmotionState(currentDominant, signal, options);
let updated: Dimensions;
+61 -8
View File
@@ -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 };
}
}
+2 -1
View File
@@ -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,
@@ -68,7 +68,9 @@ export class CharacterCreationService {
occurredAt: now,
emotionTag: signal.tag,
emotionIntensity: signal.intensity || 0.3,
source: "OFFLINE_GENERATED",
// 來自角色設定表本身,不是 I-6 的「離線生成日常事件」,兩者不可共用同一 source 值
// (I-6 用 source=OFFLINE_GENERATED 判斷「今天是否已生成過離線事件」,混用會誤判)。
source: "SOURCE_EXTRACTION",
weight: 2,
},
});
+74
View File
@@ -0,0 +1,74 @@
import type { ScheduleDayType, ScheduleSlotStatus } from "@kokorone/db";
export interface DefaultSlotSeed {
dayType: ScheduleDayType;
startMinute: number;
endMinute: number;
status: ScheduleSlotStatus;
label?: string;
}
// I-1 高中生角色的預設平日/週末作息表(依 wiki §作息表設計的甘特圖範例)。
// 跨午夜的睡眠時段拆成兩列(前一天 23:00~24:00 與當天 00:00~06:30)。
export const DEFAULT_HIGH_SCHOOLER_SCHEDULE: DefaultSlotSeed[] = [
// 平日
{ dayType: "WEEKDAY", startMinute: 0, endMinute: 390, status: "SLEEP", label: "睡眠" },
{ dayType: "WEEKDAY", startMinute: 390, endMinute: 510, status: "SEMI_BUSY", label: "晨間準備/通學" },
{ dayType: "WEEKDAY", startMinute: 510, endMinute: 960, status: "BUSY", label: "上課" },
{ dayType: "WEEKDAY", startMinute: 960, endMinute: 1080, status: "BUSY", label: "社團活動" },
{ dayType: "WEEKDAY", startMinute: 1080, endMinute: 1140, status: "SEMI_BUSY", label: "回家路上" },
{ dayType: "WEEKDAY", startMinute: 1140, endMinute: 1260, status: "SEMI_BUSY", label: "晚餐/家庭時間" },
{ dayType: "WEEKDAY", startMinute: 1260, endMinute: 1380, status: "FREE", label: "自由時間" },
{ dayType: "WEEKDAY", startMinute: 1380, endMinute: 1440, status: "SLEEP", label: "睡眠" },
// 週末(睡得晚一點、多數時間空閒)
{ dayType: "WEEKEND", startMinute: 0, endMinute: 480, status: "SLEEP", label: "睡眠" },
{ dayType: "WEEKEND", startMinute: 480, endMinute: 720, status: "FREE", label: "悠閒早晨" },
{ dayType: "WEEKEND", startMinute: 720, endMinute: 780, status: "SEMI_BUSY", label: "午餐/家庭時間" },
{ dayType: "WEEKEND", startMinute: 780, endMinute: 1320, status: "FREE", label: "自由時間" },
{ dayType: "WEEKEND", startMinute: 1320, endMinute: 1440, status: "SLEEP", label: "睡眠" },
];
export const WAKE_WINDOW_MINUTES = 30; // 剛醒的迷糊演出窗口
export const PRE_SLEEP_WINDOW_MINUTES = 30; // 睡前的慵懶演出窗口
export type AvailabilityMode = "NORMAL" | "BRIEF" | "DELAYED" | "NO_REPLY";
// I-2 時段狀態 → 回應可用度
export function availabilityForStatus(status: ScheduleSlotStatus | null): AvailabilityMode {
switch (status) {
case "FREE":
return "NORMAL";
case "SEMI_BUSY":
return "BRIEF";
case "BUSY":
return "DELAYED";
case "SLEEP":
return "NO_REPLY";
default:
return "NORMAL"; // 沒有作息資料時預設正常回應,不因缺資料而封鎖對話
}
}
export type ExceptionType = "LATE_NIGHT_CHAT" | "SNEAK_REPLY_IN_CLASS" | "ADJUST_SCHEDULE";
// I-4 破例規則:必須稀有,因此每種破例都有各自的冷卻天數。
export const EXCEPTION_COOLDOWN_DAYS: Record<ExceptionType, number> = {
LATE_NIGHT_CHAT: 7,
SNEAK_REPLY_IN_CLASS: 10,
ADJUST_SCHEDULE: 30,
};
export const EXCEPTION_INTIMACY_THRESHOLD: Record<ExceptionType, number> = {
LATE_NIGHT_CHAT: 60, // 摯友-曖昧以上
SNEAK_REPLY_IN_CLASS: 80, // 羈絆級
ADJUST_SCHEDULE: 80, // 羈絆級
};
export const EXCEPTION_LEDGER_EVENT: Record<ExceptionType, string> = {
LATE_NIGHT_CHAT: "深夜還是抽空陪你聊天",
SNEAK_REPLY_IN_CLASS: "上課中還偷偷回你訊息",
ADJUST_SCHEDULE: "為了你調整了自己的行程",
};
export const URGENT_KEYWORDS = ["緊急", "快點", "立刻", "馬上", "救命"];
export const SCHEDULE_ADJUST_REQUEST_KEYWORDS = ["可以陪我", "要不要一起", "今天可以出來", "有空嗎", "陪我一下"];
@@ -0,0 +1,51 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service.js";
import { ScheduleService } from "./schedule.service.js";
// I-6 離線事件生成的模板庫,鍵為固定行程的 label;一致性約束:只依「今天已經發生過」的
// 固定行程挑選對應模板,不會生成角色作息表裡沒有的活動。
const OFFLINE_EVENT_TEMPLATES: Record<string, string[]> = {
社團活動: ["今天社團練習時出了點小糗,大家都笑翻了", "社團今天氣氛超好,練得特別起勁"],
上課: ["今天上課差點打瞌睡,還好沒被抓到", "今天課堂上聽到一個超有趣的小知識"],
自由時間: ["剛剛看到夕陽超美,忍不住多看了好幾眼", "吃到一款新口味的點心,好吃到想再買"],
悠閒早晨: ["今天早上賴床賴了好久,超舒服"],
};
const DEFAULT_OFFLINE_EVENT_TEMPLATES = ["今天過得普普通通,沒什麼特別的事"];
// I-6 離線生活:不在線時她在活著,於下次對話開始時按需生成合理的日常小事。
@Injectable()
export class OfflineEventService {
constructor(
private readonly prisma: PrismaService,
private readonly schedule: ScheduleService,
) {}
async maybeGenerate(characterId: string, now: Date = new Date()): Promise<void> {
const todayStart = this.schedule.startOfToday(now);
const existing = await this.prisma.client.episodicMemory.findFirst({
where: { characterId, source: "OFFLINE_GENERATED", occurredAt: { gte: todayStart } },
});
if (existing) {
return;
}
const pastSlots = await this.schedule.getPastSlotsToday(characterId, now);
const labelledSlots = pastSlots.filter((slot) => slot.label && slot.status !== "SLEEP");
const label = labelledSlots.at(-1)?.label ?? null;
const templates = (label && OFFLINE_EVENT_TEMPLATES[label]) || DEFAULT_OFFLINE_EVENT_TEMPLATES;
const content = templates[Math.floor(Math.random() * templates.length)];
await this.prisma.client.episodicMemory.create({
data: {
characterId,
content,
occurredAt: now,
emotionTag: "CALM",
emotionIntensity: 0.3,
source: "OFFLINE_GENERATED",
weight: 0.5, // 低權重日常事件,供 C-5 自然遺忘
},
});
}
}
@@ -0,0 +1,31 @@
import { Injectable } from "@nestjs/common";
import type { EmotionTag } from "@kokorone/shared";
import { ScheduleService } from "./schedule.service.js";
export interface BaselineSignal {
tag: EmotionTag;
intensity: number;
}
const CLUB_TIRED_WINDOW_MINUTES = 30;
// I-5 作息驅動情緒基線:社團剛結束的疲憊、假日心情基線偏高等日內波動。
// 只在本輪對話「沒有明確情緒訊號」時介入,不覆蓋使用者輸入本身觸發的情緒。
@Injectable()
export class ScheduleEmotionBaselineService {
constructor(private readonly schedule: ScheduleService) {}
async computeBaseline(characterId: string, now: Date = new Date()): Promise<BaselineSignal | null> {
const justEnded = await this.schedule.slotJustEnded(characterId, now, CLUB_TIRED_WINDOW_MINUTES);
if (justEnded?.label?.includes("社團")) {
return { tag: "SAD", intensity: 0.3 }; // 累
}
const dayType = await this.schedule.getDayType(characterId, now);
if (dayType === "HOLIDAY" || dayType === "WEEKEND") {
return { tag: "JOY", intensity: 0.3 }; // 放假日心情基線偏高
}
return null;
}
}
@@ -0,0 +1,89 @@
import { Injectable } from "@nestjs/common";
import type { EmotionTag } from "@kokorone/shared";
import { PrismaService } from "../prisma/prisma.service.js";
import { SentimentLedgerService } from "../relationship/sentiment-ledger.service.js";
import {
type AvailabilityMode,
type ExceptionType,
EXCEPTION_COOLDOWN_DAYS,
EXCEPTION_INTIMACY_THRESHOLD,
EXCEPTION_LEDGER_EVENT,
URGENT_KEYWORDS,
SCHEDULE_ADJUST_REQUEST_KEYWORDS,
} from "./constants.js";
export interface ExceptionContext {
intimacy: number;
dominantEmotion: EmotionTag;
userInput: string;
}
// I-4 破例規則:作息規則平常確實執行,破例必須稀有才有重量——條件不足時完全不破例。
@Injectable()
export class ScheduleExceptionService {
constructor(
private readonly prisma: PrismaService,
private readonly sentimentLedger: SentimentLedgerService,
) {}
// 依目前的作息限制模式與情境,判斷是否符合任一破例的「資格條件」(尚未套用冷卻判定)。
private pickCandidate(mode: AvailabilityMode, context: ExceptionContext): ExceptionType | null {
if (mode === "NO_REPLY" && context.dominantEmotion === "SAD" && context.intimacy >= EXCEPTION_INTIMACY_THRESHOLD.LATE_NIGHT_CHAT) {
return "LATE_NIGHT_CHAT";
}
const isUrgent = URGENT_KEYWORDS.some((keyword) => context.userInput.includes(keyword));
if (mode === "DELAYED" && isUrgent && context.intimacy >= EXCEPTION_INTIMACY_THRESHOLD.SNEAK_REPLY_IN_CLASS) {
return "SNEAK_REPLY_IN_CLASS";
}
const isScheduleAdjustRequest = SCHEDULE_ADJUST_REQUEST_KEYWORDS.some((keyword) =>
context.userInput.includes(keyword),
);
if (
(mode === "DELAYED" || mode === "NO_REPLY") &&
isScheduleAdjustRequest &&
context.intimacy >= EXCEPTION_INTIMACY_THRESHOLD.ADJUST_SCHEDULE
) {
return "ADJUST_SCHEDULE";
}
return null;
}
// 回傳實際觸發的破例類型;資格不符或冷卻中一律回傳 null(維持原本的作息限制)。
async tryApply(
characterId: string,
userId: string,
mode: AvailabilityMode,
context: ExceptionContext,
now: Date = new Date(),
): Promise<ExceptionType | null> {
if (mode === "NORMAL") {
return null;
}
const candidate = this.pickCandidate(mode, context);
if (!candidate) {
return null;
}
const last = await this.prisma.client.scheduleExceptionLog.findFirst({
where: { characterId, exceptionType: candidate },
orderBy: { triggeredAt: "desc" },
});
if (last) {
const cooldownMs = EXCEPTION_COOLDOWN_DAYS[candidate] * 24 * 60 * 60 * 1000;
if (now.getTime() - last.triggeredAt.getTime() < cooldownMs) {
return null;
}
}
await this.prisma.client.scheduleExceptionLog.create({
data: { characterId, exceptionType: candidate, triggeredAt: now },
});
await this.sentimentLedger.recordEvent(characterId, userId, EXCEPTION_LEDGER_EVENT[candidate], 5, { now });
return candidate;
}
}
@@ -0,0 +1,76 @@
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
import type { EmotionTag } from "@kokorone/shared";
import { ScheduleService } from "./schedule.service.js";
import { ScheduleExceptionService } from "./schedule-exception.service.js";
import { ScheduleEmotionBaselineService } from "./schedule-emotion-baseline.service.js";
import { OfflineEventService } from "./offline-event.service.js";
import { PrismaService } from "../prisma/prisma.service.js";
import { availabilityForStatus } from "./constants.js";
interface SpecialDayBody {
date: string;
label: string;
}
interface ExceptionBody {
userId: string;
mode: "NORMAL" | "BRIEF" | "DELAYED" | "NO_REPLY";
intimacy: number;
dominantEmotion: EmotionTag;
userInput: string;
now?: string;
}
@Controller("schedule")
export class ScheduleController {
constructor(
private readonly schedule: ScheduleService,
private readonly exception: ScheduleExceptionService,
private readonly baseline: ScheduleEmotionBaselineService,
private readonly offlineEvent: OfflineEventService,
private readonly prisma: PrismaService,
) {}
@Post(":characterId/seed-default")
async seedDefault(@Param("characterId") characterId: string) {
await this.schedule.seedDefaultSchedule(characterId);
return { ok: true };
}
@Post(":characterId/special-days")
async addSpecialDay(@Param("characterId") characterId: string, @Body() body: SpecialDayBody) {
return this.prisma.client.specialDay.create({
data: { characterId, date: new Date(body.date), label: body.label },
});
}
@Get(":characterId/info")
async getInfo(@Param("characterId") characterId: string, @Query("now") now?: string) {
const info = await this.schedule.getInfo(characterId, now ? new Date(now) : undefined);
return { ...info, availability: availabilityForStatus(info.slot?.status ?? null) };
}
@Post(":characterId/exception")
async tryException(@Param("characterId") characterId: string, @Body() body: ExceptionBody) {
const exceptionType = await this.exception.tryApply(
characterId,
body.userId,
body.mode,
{ intimacy: body.intimacy, dominantEmotion: body.dominantEmotion, userInput: body.userInput },
body.now ? new Date(body.now) : undefined,
);
return { exceptionType };
}
@Get(":characterId/baseline")
async getBaseline(@Param("characterId") characterId: string, @Query("now") now?: string) {
const baseline = await this.baseline.computeBaseline(characterId, now ? new Date(now) : undefined);
return { baseline };
}
@Post(":characterId/offline-event")
async generateOfflineEvent(@Param("characterId") characterId: string, @Body() body: { now?: string }) {
await this.offlineEvent.maybeGenerate(characterId, body.now ? new Date(body.now) : undefined);
return { ok: true };
}
}
+16
View File
@@ -0,0 +1,16 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module.js";
import { RelationshipModule } from "../relationship/relationship.module.js";
import { ScheduleService } from "./schedule.service.js";
import { ScheduleExceptionService } from "./schedule-exception.service.js";
import { ScheduleEmotionBaselineService } from "./schedule-emotion-baseline.service.js";
import { OfflineEventService } from "./offline-event.service.js";
import { ScheduleController } from "./schedule.controller.js";
@Module({
imports: [PrismaModule, RelationshipModule],
controllers: [ScheduleController],
providers: [ScheduleService, ScheduleExceptionService, ScheduleEmotionBaselineService, OfflineEventService],
exports: [ScheduleService, ScheduleExceptionService, ScheduleEmotionBaselineService, OfflineEventService],
})
export class ScheduleModule {}
+89
View File
@@ -0,0 +1,89 @@
import { Injectable } from "@nestjs/common";
import type { ScheduleDayType } from "@kokorone/db";
import { PrismaService } from "../prisma/prisma.service.js";
import { DEFAULT_HIGH_SCHOOLER_SCHEDULE, WAKE_WINDOW_MINUTES, PRE_SLEEP_WINDOW_MINUTES } from "./constants.js";
import { minutesOfDay, taipeiParts, isSameTaipeiDate, startOfTaipeiDay } from "./time-utils.js";
export interface CurrentSlotInfo {
dayType: ScheduleDayType;
minute: number;
slot: Awaited<ReturnType<PrismaService["client"]["scheduleSlot"]["findFirst"]>> | null;
isWaking: boolean;
isPreSleep: boolean;
}
// I-1/I-3 作息表與時間感知:週期表判定、目前時段查詢、剛醒/睡前偵測。
@Injectable()
export class ScheduleService {
constructor(private readonly prisma: PrismaService) {}
// I-1 驗收:可為種子角色寫入一份高中生作息表(可重複執行)。
async seedDefaultSchedule(characterId: string): Promise<void> {
await this.prisma.client.scheduleSlot.deleteMany({ where: { characterId } });
await this.prisma.client.scheduleSlot.createMany({
data: DEFAULT_HIGH_SCHOOLER_SCHEDULE.map((slot) => ({ characterId, ...slot })),
});
}
async getDayType(characterId: string, now: Date = new Date()): Promise<ScheduleDayType> {
const specialDays = await this.prisma.client.specialDay.findMany({ where: { characterId } });
if (specialDays.some((day) => isSameTaipeiDate(day.date, now))) {
return "HOLIDAY";
}
const { weekday } = taipeiParts(now);
return weekday === 0 || weekday === 6 ? "WEEKEND" : "WEEKDAY";
}
async getCurrentSlot(characterId: string, now: Date = new Date()) {
const dayType = await this.getDayType(characterId, now);
const minute = minutesOfDay(now);
return this.prisma.client.scheduleSlot.findFirst({
where: { characterId, dayType, startMinute: { lte: minute }, endMinute: { gt: minute } },
});
}
async getInfo(characterId: string, now: Date = new Date()): Promise<CurrentSlotInfo> {
const dayType = await this.getDayType(characterId, now);
const minute = minutesOfDay(now);
const slot = await this.getCurrentSlot(characterId, now);
const allSlots = await this.prisma.client.scheduleSlot.findMany({ where: { characterId, dayType } });
const isWaking = allSlots.some(
(s) => s.status === "SLEEP" && minute >= s.endMinute && minute < s.endMinute + WAKE_WINDOW_MINUTES,
);
const isPreSleep = allSlots.some(
(s) => s.status === "SLEEP" && minute <= s.startMinute && minute > s.startMinute - PRE_SLEEP_WINDOW_MINUTES,
);
return { dayType, minute, slot, isWaking, isPreSleep };
}
// 供 I-5/I-6 使用:今天到目前為止已經「結束」的固定行程時段(依開始時間排序)。
async getPastSlotsToday(characterId: string, now: Date = new Date()) {
const dayType = await this.getDayType(characterId, now);
const minute = minutesOfDay(now);
const slots = await this.prisma.client.scheduleSlot.findMany({
where: { characterId, dayType, endMinute: { lte: minute } },
orderBy: { startMinute: "asc" },
});
return slots;
}
// 供 I-5 使用:某個固定行程是否在最近 windowMinutes 分鐘內剛結束。
async slotJustEnded(characterId: string, now: Date = new Date(), windowMinutes = 30) {
const dayType = await this.getDayType(characterId, now);
const minute = minutesOfDay(now);
return this.prisma.client.scheduleSlot.findFirst({
where: {
characterId,
dayType,
endMinute: { lte: minute, gt: minute - windowMinutes },
},
orderBy: { endMinute: "desc" },
});
}
startOfToday(now: Date = new Date()): Date {
return startOfTaipeiDay(now);
}
}
+61
View File
@@ -0,0 +1,61 @@
// I-3 時間感知:所有作息判斷一律以 Asia/Taipei 當地時間為準,不受伺服器所在時區影響。
const TAIPEI_PARTS_FORMATTER = new Intl.DateTimeFormat("en-US", {
timeZone: "Asia/Taipei",
weekday: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
const WEEKDAY_INDEX: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
export interface TaipeiTimeParts {
weekday: number; // 0=週日 ... 6=週六
hour: number;
minute: number;
year: number;
month: number;
day: number;
}
export function taipeiParts(date: Date): TaipeiTimeParts {
const parts = TAIPEI_PARTS_FORMATTER.formatToParts(date);
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
return {
weekday: WEEKDAY_INDEX[get("weekday")] ?? 0,
hour: Number(get("hour")),
minute: Number(get("minute")),
year: Number(get("year")),
month: Number(get("month")),
day: Number(get("day")),
};
}
export function minutesOfDay(date: Date): number {
const { hour, minute } = taipeiParts(date);
return hour * 60 + minute;
}
export function isSameTaipeiDate(a: Date, b: Date): boolean {
const pa = taipeiParts(a);
const pb = taipeiParts(b);
return pa.year === pb.year && pa.month === pb.month && pa.day === pb.day;
}
// 回傳「當地日期」的午夜時間點(以 UTC Date 物件表示,僅用於資料庫時間比較的下界)。
export function startOfTaipeiDay(date: Date): Date {
const { year, month, day } = taipeiParts(date);
// Asia/Taipei 為 UTC+8,當地 00:00 對應 UTC 前一天 16:00。
return new Date(Date.UTC(year, month - 1, day, -8, 0, 0, 0));
}
+6
View File
@@ -36,9 +36,15 @@ export function ChatView() {
try {
const result = await sendChatMessage(CHARACTER_ID, USER_ID, sessionId, text);
if (result.availability === "NO_REPLY") {
// I-2:作息狀態為睡眠且未破例時,這輪不會有真的回覆。
return;
}
setMessages((prev) => [...prev, { role: "character", text: result.text }]);
if (result.context) {
setDominant(result.context.emotion.dominant);
setIntimacy(result.context.relationship.intimacy);
}
} catch {
setError("訊息傳送失敗,請確認 api 服務是否已啟動");
} finally {
+6 -1
View File
@@ -13,11 +13,16 @@ export interface ChatContext {
relationship: { intimacy: number; trust: number; stage: RelationshipStage };
}
export type ChatAvailability = "NORMAL" | "BRIEF" | "DELAYED" | "NO_REPLY";
export interface ChatResult {
text: string;
actions: string[];
isFastChannel: boolean;
context: ChatContext;
availability: ChatAvailability;
exceptionType: string | null;
// I-2:作息為睡眠且未破例時不會組裝上下文,此時為 null。
context: ChatContext | null;
}
export async function sendChatMessage(
@@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE "schedule_slots" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"dayType" TEXT NOT NULL,
"startMinute" INTEGER NOT NULL,
"endMinute" INTEGER NOT NULL,
"status" TEXT NOT NULL,
"label" TEXT,
CONSTRAINT "schedule_slots_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "special_days" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"date" DATETIME NOT NULL,
"label" TEXT NOT NULL,
CONSTRAINT "special_days_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "schedule_exception_logs" (
"id" TEXT NOT NULL PRIMARY KEY,
"characterId" TEXT NOT NULL,
"exceptionType" TEXT NOT NULL,
"triggeredAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "schedule_exception_logs_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
+53
View File
@@ -44,6 +44,20 @@ enum RelationshipStage {
BONDED // 羈絆 80-100
}
// I-1 週期表:平日/週末/假期各一份時段表
enum ScheduleDayType {
WEEKDAY // 平日
WEEKEND // 週末
HOLIDAY // 假期/特殊日
}
enum ScheduleSlotStatus {
SLEEP // 睡眠
BUSY // 忙碌
SEMI_BUSY // 半忙碌
FREE // 空閒
}
model User {
id String @id @default(cuid())
name String
@@ -97,6 +111,9 @@ model Character {
emotionState EmotionState?
relationships Relationship[]
contrastTriggerLogs ContrastTriggerLog[]
scheduleSlots ScheduleSlot[]
specialDays SpecialDay[]
scheduleExceptions ScheduleExceptionLog[]
@@map("characters")
}
@@ -111,6 +128,42 @@ model ContrastTriggerLog {
@@map("contrast_trigger_logs")
}
// I-1 作息表:平日/週末/假期各一份時段表,每個時段一列。
model ScheduleSlot {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
dayType ScheduleDayType
startMinute Int // 當天 00:00 起算的分鐘數(0~1440),跨午夜的時段請拆成兩列
endMinute Int
status ScheduleSlotStatus
label String? // 固定行程名稱,例如「社團活動」
@@map("schedule_slots")
}
// I-1 特殊日:考試週、生日、原作紀念日——當天整天視為 HOLIDAY 作息。
model SpecialDay {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
date DateTime // 只使用日期部分(比對年/月/日)
label String
@@map("special_days")
}
// I-4 破例規則:記錄每種破例最後一次觸發的時間,供稀有度冷卻判定。
model ScheduleExceptionLog {
id String @id @default(cuid())
characterId String
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
exceptionType String // LATE_NIGHT_CHAT/SNEAK_REPLY_IN_CLASS/ADJUST_SCHEDULE
triggeredAt DateTime @default(now())
@@map("schedule_exception_logs")
}
model CharacterAlias {
id String @id @default(cuid())
characterId String
+162
View File
@@ -0,0 +1,162 @@
import { prisma } from "@kokorone/db";
const API_PORT = process.env.PORT_API ?? "3001";
const CHARACTER_ID = "smoke-i-character";
const USER_ID = "seed-user-primary";
// 固定測試時間軸(皆為 UTC,對應 Asia/Taipei 當地時間,註解為當地時間):
const WEEKDAY_CLASS_TIME = "2026-08-13T02:00:00.000Z"; // 週四 10:00(上課,BUSY)
const WEEKDAY_FREE_TIME = "2026-08-13T14:00:00.000Z"; // 週四 22:00(自由時間,FREE)
const WEEKDAY_SLEEP_TIME = "2026-08-12T18:00:00.000Z"; // 週四 02:00(睡眠,SLEEP)
const WEEKDAY_WAKING_TIME = "2026-08-12T22:40:00.000Z"; // 週四 06:40(睡眠剛結束 10 分鐘)
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 ?? {}),
});
if (!res.ok) {
throw new Error(`POST ${path} 回傳 ${res.status}:${await res.text()}`);
}
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();
}
export default async function smokeI() {
await prisma.character.deleteMany({ where: { id: CHARACTER_ID } });
await post("/personality/characters", {
id: CHARACTER_ID,
source: "ORIGINAL",
buildStatus: "BUILT",
formalName: "作息測試角色",
basicInfo: "作息測試角色,17 歲高中生",
backgroundStory: "普通的高中生活",
personalityArchetype: "元氣",
likesDislikes: "喜歡:運動;討厭:早起",
goalsObsessions: "測試用",
speechStyle: "第一人稱「我」",
initialRelationship: { userId: USER_ID, intimacy: 25, trust: 10 },
});
// I-1 作息表資料結構:可為角色寫入一份高中生作息表
await post(`/schedule/${CHARACTER_ID}/seed-default`, {});
const slotCount = await prisma.scheduleSlot.count({ where: { characterId: CHARACTER_ID } });
if (slotCount === 0) {
throw new Error("seed-default 未寫入任何作息時段");
}
// I-2/I-3 時段狀態 → 回應可用度 + 時間感知(星期幾/幾點)
const busyInfo = await get(`/schedule/${CHARACTER_ID}/info?now=${encodeURIComponent(WEEKDAY_CLASS_TIME)}`);
if (busyInfo.availability !== "DELAYED") {
throw new Error(`上課時段應對應 DELAYED,實際為 ${busyInfo.availability}`);
}
const freeInfo = await get(`/schedule/${CHARACTER_ID}/info?now=${encodeURIComponent(WEEKDAY_FREE_TIME)}`);
if (freeInfo.availability !== "NORMAL") {
throw new Error(`自由時間應對應 NORMAL,實際為 ${freeInfo.availability}`);
}
const sleepInfo = await get(`/schedule/${CHARACTER_ID}/info?now=${encodeURIComponent(WEEKDAY_SLEEP_TIME)}`);
if (sleepInfo.availability !== "NO_REPLY") {
throw new Error(`睡眠時段應對應 NO_REPLY,實際為 ${sleepInfo.availability}`);
}
const wakingInfo = await get(`/schedule/${CHARACTER_ID}/info?now=${encodeURIComponent(WEEKDAY_WAKING_TIME)}`);
if (!wakingInfo.isWaking) {
throw new Error("剛醒時間點應標記 isWaking");
}
// I-4 破例規則:睡眠時段一般不回應,且不應無條件破例
const sleepSessionA = "smoke-i-sleep-a-" + Date.now();
const noExceptionResult = await post(`/chat/${CHARACTER_ID}`, {
userId: USER_ID,
sessionId: sleepSessionA,
text: "在嗎",
now: WEEKDAY_SLEEP_TIME,
});
if (noExceptionResult.text !== "" || noExceptionResult.availability !== "NO_REPLY") {
throw new Error("條件不足時睡眠時段不應破例回應");
}
// 條件滿足(親密度高+使用者情緒低落)時應觸發「深夜還陪你聊」破例
await prisma.relationship.update({
where: { characterId_userId: { characterId: CHARACTER_ID, userId: USER_ID } },
data: { intimacy: 70 },
});
await prisma.emotionState.update({
where: { characterId: CHARACTER_ID },
data: { calm: 100, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0, updatedAt: new Date(WEEKDAY_SLEEP_TIME) },
});
const sleepSessionB = "smoke-i-sleep-b-" + Date.now();
const exceptionResult = await post(`/chat/${CHARACTER_ID}`, {
userId: USER_ID,
sessionId: sleepSessionB,
text: "我今天過得好難過,很失望",
now: WEEKDAY_SLEEP_TIME,
});
if (exceptionResult.exceptionType !== "LATE_NIGHT_CHAT" || !exceptionResult.text) {
throw new Error("條件滿足時應觸發「深夜還陪你聊」破例並正常生成回應");
}
// 冷卻期內同類破例不應再次觸發
const sleepSessionC = "smoke-i-sleep-c-" + Date.now();
const cooldownResult = await post(`/chat/${CHARACTER_ID}`, {
userId: USER_ID,
sessionId: sleepSessionC,
text: "我今天過得好難過,很失望",
now: WEEKDAY_SLEEP_TIME,
});
if (cooldownResult.exceptionType !== null || cooldownResult.availability !== "NO_REPLY") {
throw new Error("冷卻期內同類破例不應再次觸發");
}
// I-5 作息驅動情緒基線:同一輸入在不同時段得到不同情緒起點
const afterClub = await get(`/schedule/${CHARACTER_ID}/baseline?now=${encodeURIComponent("2026-08-13T10:15:00.000Z")}`);
if (afterClub.baseline?.tag !== "SAD") {
throw new Error("社團剛結束應有偏低落的情緒基線");
}
const weekendBaseline = await get(`/schedule/${CHARACTER_ID}/baseline?now=${encodeURIComponent("2026-08-15T06:00:00.000Z")}`);
if (weekendBaseline.baseline?.tag !== "JOY") {
throw new Error("放假日應有偏愉悅的情緒基線");
}
const normalBaseline = await get(`/schedule/${CHARACTER_ID}/baseline?now=${encodeURIComponent(WEEKDAY_FREE_TIME)}`);
if (normalBaseline.baseline !== null) {
throw new Error("一般時段不應有特殊情緒基線");
}
// I-6 離線事件生成:依作息生成的事件必須符合角色作息表(不可生成沒有的活動)
// 先清掉前面 I-4 測試(在 02:00 觸發對話)順帶生成的「今天」離線事件,確保本節從乾淨狀態開始。
await prisma.episodicMemory.deleteMany({ where: { characterId: CHARACTER_ID, source: "OFFLINE_GENERATED" } });
await prisma.emotionState.update({
where: { characterId: CHARACTER_ID },
data: { calm: 100, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0, updatedAt: new Date() },
});
const offlineEventTime = "2026-08-13T10:15:00.000Z"; // 週四 18:15,社團活動(16:00-18:00)剛結束
await post(`/schedule/${CHARACTER_ID}/offline-event`, { now: offlineEventTime });
const offlineMemories = await prisma.episodicMemory.findMany({
where: { characterId: CHARACTER_ID, source: "OFFLINE_GENERATED" },
});
if (offlineMemories.length !== 1) {
throw new Error(`應生成恰好一筆離線事件,實際為 ${offlineMemories.length} 筆`);
}
if (!offlineMemories[0].content.includes("社團")) {
throw new Error("離線事件內容應符合當天已發生的固定行程(社團活動)");
}
// 同一天再次觸發不應重複生成
await post(`/schedule/${CHARACTER_ID}/offline-event`, { now: "2026-08-13T11:00:00.000Z" });
const offlineMemoriesAgain = await prisma.episodicMemory.count({
where: { characterId: CHARACTER_ID, source: "OFFLINE_GENERATED" },
});
if (offlineMemoriesAgain !== 1) {
throw new Error("同一天不應重複生成離線事件");
}
// 清理本次測試建立的角色(cascade 會一併清掉作息表、破例紀錄、記憶等)
await prisma.character.deleteMany({ where: { id: CHARACTER_ID } });
}
+15 -7
View File
@@ -247,13 +247,21 @@ flowchart TB
### I. 生活作息與離線生活
- [ ] **I-1 作息表資料結構(S)**:平日/週末/假期各一份時段表,欄位含各時段狀態(睡眠/忙碌/半忙碌/空閒)、固定行程、特殊日(考試週、生日、紀念日)。驗收:可為種子角色寫入一份高中生作息表。依據:§作息表設計表格。
- [ ] **I-2 時段狀態→回應可用度(S)**:空閒正常回應、半忙碌簡短、忙碌延遲或不回並事後補一句、睡眠不回、剛醒/睡前加上迷糊演出。驗收:模擬各時段輸入得到對應行為。依據:§作息如何影響對話。
- [ ] **I-3 時間感知(S)**:角色知道現在幾點、星期幾、季節,開場語與話題隨之調整。驗收:不同時段開場語不同。依據:§作息如何影響對話「時間感知」。
- [ ] **I-4 破例規則(S)**:深夜還陪你聊(親密度高+使用者情緒低落)、上課偷回訊息(親密度極高+緊急)、為你調整行程(羈絆級)三種破例,且必須稀有。驗收:條件不足時不破例,條件滿足時破例並記入關係帳本。依據:§破例即訊號:作息 × 親密度。
- [ ] **I-5 作息驅動情緒基線(S)**:社團剛結束「累」、放假日基線偏高等日內波動。驗收:同一輸入在不同時段得到不同情緒起點。依據:§作息如何影響對話「狀態殘留」。
- [ ] **I-6 離線事件生成(M)**:依時段+性格+近期劇情,於下次對話開始時按需生成合理日常小事,寫入低權重情節記憶並作為話題來源;生成內容須符合作息表與角色設定。驗收:隔一段時間再對話,角色會主動提起今天發生的事,且事件不違反其作息。依據:§離線生活:不在線時她在活著。
- [ ] **I-V 階段驗證(XS)**:`npm run restart && npm run smoke -- I`(I.mjs:睡眠時段不回、忙碌延遲、破例條件、離線事件生成與一致性約束)。
- [x] **I-1 作息表資料結構(S)**:平日/週末/假期各一份時段表,欄位含各時段狀態(睡眠/忙碌/半忙碌/空閒)、固定行程、特殊日(考試週、生日、紀念日)。驗收:可為種子角色寫入一份高中生作息表。依據:§作息表設計表格。
- [x] **I-2 時段狀態→回應可用度(S)**:空閒正常回應、半忙碌簡短、忙碌延遲或不回並事後補一句、睡眠不回、剛醒/睡前加上迷糊演出。驗收:模擬各時段輸入得到對應行為。依據:§作息如何影響對話。
- [x] **I-3 時間感知(S)**:角色知道現在幾點、星期幾、季節,開場語與話題隨之調整。驗收:不同時段開場語不同。依據:§作息如何影響對話「時間感知」。
- [x] **I-4 破例規則(S)**:深夜還陪你聊(親密度高+使用者情緒低落)、上課偷回訊息(親密度極高+緊急)、為你調整行程(羈絆級)三種破例,且必須稀有。驗收:條件不足時不破例,條件滿足時破例並記入關係帳本。依據:§破例即訊號:作息 × 親密度。
- [x] **I-5 作息驅動情緒基線(S)**:社團剛結束「累」、放假日基線偏高等日內波動。驗收:同一輸入在不同時段得到不同情緒起點。依據:§作息如何影響對話「狀態殘留」。
- [x] **I-6 離線事件生成(M)**:依時段+性格+近期劇情,於下次對話開始時按需生成合理日常小事,寫入低權重情節記憶並作為話題來源;生成內容須符合作息表與角色設定。驗收:隔一段時間再對話,角色會主動提起今天發生的事,且事件不違反其作息。依據:§離線生活:不在線時她在活著。
- [x] **I-V 階段驗證(XS)**:`npm run restart && npm run smoke -- I`(I.mjs:睡眠時段不回、忙碌延遲、破例條件、離線事件生成與一致性約束)。
> **實作記錄(I 群組)**:
> - 作息引擎放在 `apps/api/src/schedule/`。時間判斷(星期幾/幾點/今天日期)一律走 `time-utils.ts` 的 `Intl.DateTimeFormat({ timeZone: "Asia/Taipei" })`,不吃伺服器所在時區——之後任何跟「現在幾點」有關的邏輯都應該重用這裡的 `taipeiParts`/`minutesOfDay`/`isSameTaipeiDate`/`startOfTaipeiDay`,不要另外用 `Date.getHours()` 之類的本地時間 API(那會受執行環境時區影響)。
> - **關鍵設計:沒有作息表的角色一律視為 NORMAL(正常回應)**,不會因為缺資料被誤判成忙碌或睡眠。這是刻意的相容性設計——`seed-character-genki`(H 群組所有測試依賴的種子角色)**沒有**掛作息表,所以 H 群組的冒煙測試不受時段影響、任何時間執行都會是 NORMAL。**日後若要幫種子角色掛上作息表,必須同時檢查 H.mjs 是否還會在忙碌/睡眠時段被跑到**,否則會間歇性炸掉;比較安全的做法是只給新建的測試角色掛作息表(本群組的 `smoke-i-character` 即是如此,測試完會整個角色一起砍掉,schedule 隨 cascade 一起消失)。
> - `DialogueService.handleMessage` 現在的執行順序是:**離線事件生成(I-6)→ 情緒基線+標記情緒(I-5/沿用 D)→ 累積親密度(沿用 E)→ 作息可用度判定(I-2)→ 不符合則嘗試破例(I-4)→ 依可用度決定要不要真的生成回覆**。`HandleMessageResult` 因此多了 `availability` 與 `exceptionType` 兩個欄位,`context` 在 `NO_REPLY`(睡眠且未破例)時會是 `null`——**任何後續程式碼(包含 H 群組已有的網頁前端)如果要讀 `result.context`,都要先檢查是否為 null**,目前 `ChatView.tsx` 還沒有處理這個情況(沒有作息表的種子角色不會走到這條路徑,所以現在不會炸,但如果之後角色掛了作息表,網頁前端在忙碌/睡眠時段會直接對 null 解構出錯,需要之後的群組或前端調整時補上防呆)。
> - 修正一個真實 bug:G-5 角色建立流程原本把背景故事那筆初始情節記憶的 `source` 設成 `OFFLINE_GENERATED`,跟 I-6「今天是否已生成過離線事件」的判斷(同樣查 `source: OFFLINE_GENERATED`)**共用同一個標記**,導致新角色建立當天,I-6 會誤判「今天已經生成過離線事件」而跳過真正的日常小事生成。已改成 `SOURCE_EXTRACTION`。**M 群組之後如果也要用 `SOURCE_EXTRACTION` 標記原作萃取的記憶,要注意這個值現在也被 G-5 拿來標記「角色設定表生成的背景故事」,语意上共用是合理的(都是「非使用者互動、非離線日常」的來源),但如果 M 群組有自己的「今天是否已處理過某章節」之類的判斷,同樣不要跟 `source` 欄位的既有語意衝突。**
> - I-4 的三種破例都會呼叫既有的 `SentimentLedgerService.recordEvent` 記一筆關係帳本(正向事件,權重 5),不是另外開一張表存「破例事件」;破例本身的稀有度冷卻則是獨立的 `ScheduleExceptionLog` 表(不跟關係帳本的時間戳綁在一起)。
> - I-2「剛醒/睡前的迷糊演出」目前只有最低限度的實作(在回覆前面加上「……」),完整的口吻變化(語尾拖長、錯字率上升)留給模板庫日後擴充,做法與 G 群組「模板庫只有元氣一種原型」的已知限制一致。
### J. 委託子系統