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:
@@ -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,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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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. 委託子系統
|
||||
|
||||
|
||||
Reference in New Issue
Block a user