From d82b834e47bda4c429d73059dba6f86f123c6de3 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 13 Aug 2026 14:08:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20I=20=E7=BE=A4?= =?UTF-8?q?=E7=B5=84=20=E2=80=94=20=E7=94=9F=E6=B4=BB=E4=BD=9C=E6=81=AF?= =?UTF-8?q?=E8=88=87=E9=9B=A2=E7=B7=9A=E7=94=9F=E6=B4=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 群組冒煙測試無回歸。 --- apps/api/src/app.module.ts | 2 + apps/api/src/chat/chat.controller.ts | 7 +- apps/api/src/emotion/emotion.service.ts | 7 +- apps/api/src/llm/dialogue.service.ts | 69 +++++++- apps/api/src/llm/llm.module.ts | 3 +- .../personality/character-creation.service.ts | 4 +- apps/api/src/schedule/constants.ts | 74 ++++++++ .../api/src/schedule/offline-event.service.ts | 51 ++++++ .../schedule-emotion-baseline.service.ts | 31 ++++ .../schedule/schedule-exception.service.ts | 89 ++++++++++ apps/api/src/schedule/schedule.controller.ts | 76 ++++++++ apps/api/src/schedule/schedule.module.ts | 16 ++ apps/api/src/schedule/schedule.service.ts | 89 ++++++++++ apps/api/src/schedule/time-utils.ts | 61 +++++++ apps/web/app/chat/ChatView.tsx | 10 +- apps/web/lib/api.ts | 7 +- .../migration.sql | 29 ++++ prisma/schema.prisma | 53 ++++++ scripts/smoke/I.mjs | 162 ++++++++++++++++++ todo.md | 22 ++- 20 files changed, 840 insertions(+), 22 deletions(-) create mode 100644 apps/api/src/schedule/constants.ts create mode 100644 apps/api/src/schedule/offline-event.service.ts create mode 100644 apps/api/src/schedule/schedule-emotion-baseline.service.ts create mode 100644 apps/api/src/schedule/schedule-exception.service.ts create mode 100644 apps/api/src/schedule/schedule.controller.ts create mode 100644 apps/api/src/schedule/schedule.module.ts create mode 100644 apps/api/src/schedule/schedule.service.ts create mode 100644 apps/api/src/schedule/time-utils.ts create mode 100644 prisma/migrations/20260813053821_add_schedule_tables/migration.sql create mode 100644 scripts/smoke/I.mjs diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 9f4598e..5e03c3d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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], }) diff --git a/apps/api/src/chat/chat.controller.ts b/apps/api/src/chat/chat.controller.ts index 1c219ae..b97a2b4 100644 --- a/apps/api/src/chat/chat.controller.ts +++ b/apps/api/src/chat/chat.controller.ts @@ -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") diff --git a/apps/api/src/emotion/emotion.service.ts b/apps/api/src/emotion/emotion.service.ts index c7eda9e..e674d9f 100644 --- a/apps/api/src/emotion/emotion.service.ts +++ b/apps/api/src/emotion/emotion.service.ts @@ -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; diff --git a/apps/api/src/llm/dialogue.service.ts b/apps/api/src/llm/dialogue.service.ts index 821c4b6..8800a65 100644 --- a/apps/api/src/llm/dialogue.service.ts +++ b/apps/api/src/llm/dialogue.service.ts @@ -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 }; } } diff --git a/apps/api/src/llm/llm.module.ts b/apps/api/src/llm/llm.module.ts index 50bf70d..b332fd1 100644 --- a/apps/api/src/llm/llm.module.ts +++ b/apps/api/src/llm/llm.module.ts @@ -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, diff --git a/apps/api/src/personality/character-creation.service.ts b/apps/api/src/personality/character-creation.service.ts index e153ee4..2d472a1 100644 --- a/apps/api/src/personality/character-creation.service.ts +++ b/apps/api/src/personality/character-creation.service.ts @@ -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, }, }); diff --git a/apps/api/src/schedule/constants.ts b/apps/api/src/schedule/constants.ts new file mode 100644 index 0000000..361690d --- /dev/null +++ b/apps/api/src/schedule/constants.ts @@ -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 = { + LATE_NIGHT_CHAT: 7, + SNEAK_REPLY_IN_CLASS: 10, + ADJUST_SCHEDULE: 30, +}; + +export const EXCEPTION_INTIMACY_THRESHOLD: Record = { + LATE_NIGHT_CHAT: 60, // 摯友-曖昧以上 + SNEAK_REPLY_IN_CLASS: 80, // 羈絆級 + ADJUST_SCHEDULE: 80, // 羈絆級 +}; + +export const EXCEPTION_LEDGER_EVENT: Record = { + LATE_NIGHT_CHAT: "深夜還是抽空陪你聊天", + SNEAK_REPLY_IN_CLASS: "上課中還偷偷回你訊息", + ADJUST_SCHEDULE: "為了你調整了自己的行程", +}; + +export const URGENT_KEYWORDS = ["緊急", "快點", "立刻", "馬上", "救命"]; +export const SCHEDULE_ADJUST_REQUEST_KEYWORDS = ["可以陪我", "要不要一起", "今天可以出來", "有空嗎", "陪我一下"]; diff --git a/apps/api/src/schedule/offline-event.service.ts b/apps/api/src/schedule/offline-event.service.ts new file mode 100644 index 0000000..899531e --- /dev/null +++ b/apps/api/src/schedule/offline-event.service.ts @@ -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 = { + 社團活動: ["今天社團練習時出了點小糗,大家都笑翻了", "社團今天氣氛超好,練得特別起勁"], + 上課: ["今天上課差點打瞌睡,還好沒被抓到", "今天課堂上聽到一個超有趣的小知識"], + 自由時間: ["剛剛看到夕陽超美,忍不住多看了好幾眼", "吃到一款新口味的點心,好吃到想再買"], + 悠閒早晨: ["今天早上賴床賴了好久,超舒服"], +}; +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 { + 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 自然遺忘 + }, + }); + } +} diff --git a/apps/api/src/schedule/schedule-emotion-baseline.service.ts b/apps/api/src/schedule/schedule-emotion-baseline.service.ts new file mode 100644 index 0000000..608d526 --- /dev/null +++ b/apps/api/src/schedule/schedule-emotion-baseline.service.ts @@ -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 { + 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; + } +} diff --git a/apps/api/src/schedule/schedule-exception.service.ts b/apps/api/src/schedule/schedule-exception.service.ts new file mode 100644 index 0000000..9fbeca3 --- /dev/null +++ b/apps/api/src/schedule/schedule-exception.service.ts @@ -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 { + 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; + } +} diff --git a/apps/api/src/schedule/schedule.controller.ts b/apps/api/src/schedule/schedule.controller.ts new file mode 100644 index 0000000..33c72d4 --- /dev/null +++ b/apps/api/src/schedule/schedule.controller.ts @@ -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 }; + } +} diff --git a/apps/api/src/schedule/schedule.module.ts b/apps/api/src/schedule/schedule.module.ts new file mode 100644 index 0000000..77e0154 --- /dev/null +++ b/apps/api/src/schedule/schedule.module.ts @@ -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 {} diff --git a/apps/api/src/schedule/schedule.service.ts b/apps/api/src/schedule/schedule.service.ts new file mode 100644 index 0000000..ffae233 --- /dev/null +++ b/apps/api/src/schedule/schedule.service.ts @@ -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> | 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 { + 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 { + 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 { + 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); + } +} diff --git a/apps/api/src/schedule/time-utils.ts b/apps/api/src/schedule/time-utils.ts new file mode 100644 index 0000000..e8eb35c --- /dev/null +++ b/apps/api/src/schedule/time-utils.ts @@ -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 = { + 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)); +} diff --git a/apps/web/app/chat/ChatView.tsx b/apps/web/app/chat/ChatView.tsx index 6913a73..f82d836 100644 --- a/apps/web/app/chat/ChatView.tsx +++ b/apps/web/app/chat/ChatView.tsx @@ -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 }]); - setDominant(result.context.emotion.dominant); - setIntimacy(result.context.relationship.intimacy); + if (result.context) { + setDominant(result.context.emotion.dominant); + setIntimacy(result.context.relationship.intimacy); + } } catch { setError("訊息傳送失敗,請確認 api 服務是否已啟動"); } finally { diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 983e16b..7a12da8 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -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( diff --git a/prisma/migrations/20260813053821_add_schedule_tables/migration.sql b/prisma/migrations/20260813053821_add_schedule_tables/migration.sql new file mode 100644 index 0000000..d886565 --- /dev/null +++ b/prisma/migrations/20260813053821_add_schedule_tables/migration.sql @@ -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 +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 34ac019..83260ab 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/scripts/smoke/I.mjs b/scripts/smoke/I.mjs new file mode 100644 index 0000000..a433eeb --- /dev/null +++ b/scripts/smoke/I.mjs @@ -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 } }); +} diff --git a/todo.md b/todo.md index 2aafe2b..30468d2 100644 --- a/todo.md +++ b/todo.md @@ -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. 委託子系統