feat: 完成 I 群組 — 生活作息與離線生活

- ScheduleService:I-1 平日/週末/假期時段表(可為角色寫入高中生預設作息)、
  I-3 時間感知(Asia/Taipei 當地時間,不受伺服器時區影響)、剛醒/睡前偵測
- constants.ts:I-2 時段狀態 → 回應可用度對照(FREE/SEMI_BUSY/BUSY/SLEEP →
  NORMAL/BRIEF/DELAYED/NO_REPLY),沒有作息表的角色一律視為 NORMAL(相容既有角色)
- ScheduleExceptionService:I-4 三種破例(深夜還陪你聊/上課偷回訊息/為你調整行程),
  各自獨立冷卻期,觸發時記入關係帳本
- ScheduleEmotionBaselineService:I-5 作息驅動情緒基線(社團剛結束的疲憊、假日偏高心情),
  只在本輪沒有明確情緒訊號時介入,串接進 EmotionService.processInput 的新增選項
- OfflineEventService:I-6 離線事件生成,依「今天已發生的固定行程」挑選對應模板,
  一天只生成一次,寫入低權重情節記憶
- DialogueService 整合以上機制:離線事件生成 → 情緒基線標記 → 累積親密度 →
  作息可用度判定 → 不符合則嘗試破例 → 依可用度決定回覆內容
- 修正真實 bug:G-5 角色建立流程誤用 source=OFFLINE_GENERATED 標記背景故事,
  與 I-6「今天是否已生成離線事件」的判斷共用同一標記,已改為 SOURCE_EXTRACTION
- ChatView.tsx 補上 NO_REPLY/context 為 null 的防呆(作息可用度整合後的必要調整)
- scripts/smoke/I.mjs:以獨立測試角色(避免污染種子角色的排程狀態)驗證全部六項

npm run restart && npm run smoke -- I 皆通過(I-V),A~H 群組冒煙測試無回歸。
This commit is contained in:
Jeffery
2026-08-13 14:08:24 +08:00
parent 9e5036f0a0
commit d82b834e47
20 changed files with 840 additions and 22 deletions
+162
View File
@@ -0,0 +1,162 @@
import { prisma } from "@kokorone/db";
const API_PORT = process.env.PORT_API ?? "3001";
const CHARACTER_ID = "smoke-i-character";
const USER_ID = "seed-user-primary";
// 固定測試時間軸(皆為 UTC,對應 Asia/Taipei 當地時間,註解為當地時間):
const WEEKDAY_CLASS_TIME = "2026-08-13T02:00:00.000Z"; // 週四 10:00(上課,BUSY)
const WEEKDAY_FREE_TIME = "2026-08-13T14:00:00.000Z"; // 週四 22:00(自由時間,FREE)
const WEEKDAY_SLEEP_TIME = "2026-08-12T18:00:00.000Z"; // 週四 02:00(睡眠,SLEEP)
const WEEKDAY_WAKING_TIME = "2026-08-12T22:40:00.000Z"; // 週四 06:40(睡眠剛結束 10 分鐘)
async function post(path, body) {
const res = await fetch(`http://localhost:${API_PORT}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}),
});
if (!res.ok) {
throw new Error(`POST ${path} 回傳 ${res.status}:${await res.text()}`);
}
return res.json();
}
async function get(path) {
const res = await fetch(`http://localhost:${API_PORT}${path}`);
if (!res.ok) {
throw new Error(`GET ${path} 回傳 ${res.status}`);
}
return res.json();
}
export default async function smokeI() {
await prisma.character.deleteMany({ where: { id: CHARACTER_ID } });
await post("/personality/characters", {
id: CHARACTER_ID,
source: "ORIGINAL",
buildStatus: "BUILT",
formalName: "作息測試角色",
basicInfo: "作息測試角色,17 歲高中生",
backgroundStory: "普通的高中生活",
personalityArchetype: "元氣",
likesDislikes: "喜歡:運動;討厭:早起",
goalsObsessions: "測試用",
speechStyle: "第一人稱「我」",
initialRelationship: { userId: USER_ID, intimacy: 25, trust: 10 },
});
// I-1 作息表資料結構:可為角色寫入一份高中生作息表
await post(`/schedule/${CHARACTER_ID}/seed-default`, {});
const slotCount = await prisma.scheduleSlot.count({ where: { characterId: CHARACTER_ID } });
if (slotCount === 0) {
throw new Error("seed-default 未寫入任何作息時段");
}
// I-2/I-3 時段狀態 → 回應可用度 + 時間感知(星期幾/幾點)
const busyInfo = await get(`/schedule/${CHARACTER_ID}/info?now=${encodeURIComponent(WEEKDAY_CLASS_TIME)}`);
if (busyInfo.availability !== "DELAYED") {
throw new Error(`上課時段應對應 DELAYED,實際為 ${busyInfo.availability}`);
}
const freeInfo = await get(`/schedule/${CHARACTER_ID}/info?now=${encodeURIComponent(WEEKDAY_FREE_TIME)}`);
if (freeInfo.availability !== "NORMAL") {
throw new Error(`自由時間應對應 NORMAL,實際為 ${freeInfo.availability}`);
}
const sleepInfo = await get(`/schedule/${CHARACTER_ID}/info?now=${encodeURIComponent(WEEKDAY_SLEEP_TIME)}`);
if (sleepInfo.availability !== "NO_REPLY") {
throw new Error(`睡眠時段應對應 NO_REPLY,實際為 ${sleepInfo.availability}`);
}
const wakingInfo = await get(`/schedule/${CHARACTER_ID}/info?now=${encodeURIComponent(WEEKDAY_WAKING_TIME)}`);
if (!wakingInfo.isWaking) {
throw new Error("剛醒時間點應標記 isWaking");
}
// I-4 破例規則:睡眠時段一般不回應,且不應無條件破例
const sleepSessionA = "smoke-i-sleep-a-" + Date.now();
const noExceptionResult = await post(`/chat/${CHARACTER_ID}`, {
userId: USER_ID,
sessionId: sleepSessionA,
text: "在嗎",
now: WEEKDAY_SLEEP_TIME,
});
if (noExceptionResult.text !== "" || noExceptionResult.availability !== "NO_REPLY") {
throw new Error("條件不足時睡眠時段不應破例回應");
}
// 條件滿足(親密度高+使用者情緒低落)時應觸發「深夜還陪你聊」破例
await prisma.relationship.update({
where: { characterId_userId: { characterId: CHARACTER_ID, userId: USER_ID } },
data: { intimacy: 70 },
});
await prisma.emotionState.update({
where: { characterId: CHARACTER_ID },
data: { calm: 100, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0, updatedAt: new Date(WEEKDAY_SLEEP_TIME) },
});
const sleepSessionB = "smoke-i-sleep-b-" + Date.now();
const exceptionResult = await post(`/chat/${CHARACTER_ID}`, {
userId: USER_ID,
sessionId: sleepSessionB,
text: "我今天過得好難過,很失望",
now: WEEKDAY_SLEEP_TIME,
});
if (exceptionResult.exceptionType !== "LATE_NIGHT_CHAT" || !exceptionResult.text) {
throw new Error("條件滿足時應觸發「深夜還陪你聊」破例並正常生成回應");
}
// 冷卻期內同類破例不應再次觸發
const sleepSessionC = "smoke-i-sleep-c-" + Date.now();
const cooldownResult = await post(`/chat/${CHARACTER_ID}`, {
userId: USER_ID,
sessionId: sleepSessionC,
text: "我今天過得好難過,很失望",
now: WEEKDAY_SLEEP_TIME,
});
if (cooldownResult.exceptionType !== null || cooldownResult.availability !== "NO_REPLY") {
throw new Error("冷卻期內同類破例不應再次觸發");
}
// I-5 作息驅動情緒基線:同一輸入在不同時段得到不同情緒起點
const afterClub = await get(`/schedule/${CHARACTER_ID}/baseline?now=${encodeURIComponent("2026-08-13T10:15:00.000Z")}`);
if (afterClub.baseline?.tag !== "SAD") {
throw new Error("社團剛結束應有偏低落的情緒基線");
}
const weekendBaseline = await get(`/schedule/${CHARACTER_ID}/baseline?now=${encodeURIComponent("2026-08-15T06:00:00.000Z")}`);
if (weekendBaseline.baseline?.tag !== "JOY") {
throw new Error("放假日應有偏愉悅的情緒基線");
}
const normalBaseline = await get(`/schedule/${CHARACTER_ID}/baseline?now=${encodeURIComponent(WEEKDAY_FREE_TIME)}`);
if (normalBaseline.baseline !== null) {
throw new Error("一般時段不應有特殊情緒基線");
}
// I-6 離線事件生成:依作息生成的事件必須符合角色作息表(不可生成沒有的活動)
// 先清掉前面 I-4 測試(在 02:00 觸發對話)順帶生成的「今天」離線事件,確保本節從乾淨狀態開始。
await prisma.episodicMemory.deleteMany({ where: { characterId: CHARACTER_ID, source: "OFFLINE_GENERATED" } });
await prisma.emotionState.update({
where: { characterId: CHARACTER_ID },
data: { calm: 100, joy: 0, sad: 0, alert: 0, shy: 0, grumpy: 0, updatedAt: new Date() },
});
const offlineEventTime = "2026-08-13T10:15:00.000Z"; // 週四 18:15,社團活動(16:00-18:00)剛結束
await post(`/schedule/${CHARACTER_ID}/offline-event`, { now: offlineEventTime });
const offlineMemories = await prisma.episodicMemory.findMany({
where: { characterId: CHARACTER_ID, source: "OFFLINE_GENERATED" },
});
if (offlineMemories.length !== 1) {
throw new Error(`應生成恰好一筆離線事件,實際為 ${offlineMemories.length} 筆`);
}
if (!offlineMemories[0].content.includes("社團")) {
throw new Error("離線事件內容應符合當天已發生的固定行程(社團活動)");
}
// 同一天再次觸發不應重複生成
await post(`/schedule/${CHARACTER_ID}/offline-event`, { now: "2026-08-13T11:00:00.000Z" });
const offlineMemoriesAgain = await prisma.episodicMemory.count({
where: { characterId: CHARACTER_ID, source: "OFFLINE_GENERATED" },
});
if (offlineMemoriesAgain !== 1) {
throw new Error("同一天不應重複生成離線事件");
}
// 清理本次測試建立的角色(cascade 會一併清掉作息表、破例紀錄、記憶等)
await prisma.character.deleteMany({ where: { id: CHARACTER_ID } });
}