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 }]);
|
||||
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 {
|
||||
|
||||
+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(
|
||||
|
||||
Reference in New Issue
Block a user