From 3503cc0be6dbb8cf7407fa9647098b9c1dd509b0 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 13 Aug 2026 14:35:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=90=20J=20=E7=BE=A4?= =?UTF-8?q?=E7=B5=84=20=E2=80=94=20=E5=A7=94=E8=A8=97=E5=AD=90=E7=B3=BB?= =?UTF-8?q?=E7=B5=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 資料表與自然語句解析(單次提醒)、依原型的接案/觸發/逾期追擊演出、 委託與關係帳本/情緒的回饋迴路(含默契旗標)、主動關照(待辦偵測+作息交叉比對, 稀有度節流不重複台詞)、排程器可靠性(服務重啟後重新掃描 PENDING 任務掛回 JobQueue,逾期立即補觸發)。J.mjs 以 spawnSync 實際重啟服務驗證 J-7。 Co-Authored-By: Claude Sonnet 5 --- apps/api/src/app.module.ts | 2 + apps/api/src/task/constants.ts | 28 ++ apps/api/src/task/proactive-care.service.ts | 71 +++++ apps/api/src/task/task-feedback.service.ts | 70 +++++ apps/api/src/task/task-overdue.service.ts | 46 ++++ apps/api/src/task/task-parser.ts | 112 ++++++++ apps/api/src/task/task-performance.ts | 106 ++++++++ apps/api/src/task/task-scheduler.service.ts | 30 ++ apps/api/src/task/task-trigger.service.ts | 51 ++++ apps/api/src/task/task.controller.ts | 103 +++++++ apps/api/src/task/task.module.ts | 28 ++ apps/api/src/task/task.service.ts | 101 +++++++ .../migration.sql | 32 +++ prisma/schema.prisma | 63 +++++ scripts/smoke/J.mjs | 256 ++++++++++++++++++ todo.md | 26 +- 16 files changed, 1117 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/task/constants.ts create mode 100644 apps/api/src/task/proactive-care.service.ts create mode 100644 apps/api/src/task/task-feedback.service.ts create mode 100644 apps/api/src/task/task-overdue.service.ts create mode 100644 apps/api/src/task/task-parser.ts create mode 100644 apps/api/src/task/task-performance.ts create mode 100644 apps/api/src/task/task-scheduler.service.ts create mode 100644 apps/api/src/task/task-trigger.service.ts create mode 100644 apps/api/src/task/task.controller.ts create mode 100644 apps/api/src/task/task.module.ts create mode 100644 apps/api/src/task/task.service.ts create mode 100644 prisma/migrations/20260813061952_add_task_tables/migration.sql create mode 100644 scripts/smoke/J.mjs diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 5e03c3d..c6677dc 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -9,6 +9,7 @@ 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"; +import { TaskModule } from "./task/task.module.js"; @Module({ imports: [ @@ -21,6 +22,7 @@ import { ScheduleModule } from "./schedule/schedule.module.js"; PersonalityModule, ChatModule, ScheduleModule, + TaskModule, ], controllers: [HealthController], }) diff --git a/apps/api/src/task/constants.ts b/apps/api/src/task/constants.ts new file mode 100644 index 0000000..de7fadc --- /dev/null +++ b/apps/api/src/task/constants.ts @@ -0,0 +1,28 @@ +// J-5 任務/關係帳本事件:委託建立與完成都會寫入正向事件;失敗與被忽視則為負向事件 +// (由 E-2 的負向偏誤機制放大,一次失約可以抵銷多次守信)。 +export const TASK_LEDGER_EVENT = { + ACCEPTED: "TASK_ACCEPTED", + COMPLETED: "TASK_COMPLETED", + FAILED: "TASK_FAILED", + IGNORED: "TASK_IGNORED", +} as const; + +export const TASK_LEDGER_WEIGHT = { + ACCEPTED: 1, + COMPLETED: 3, + FAILED: -3, + IGNORED: -1, +} as const; + +// 同角色/同使用者/同類型/同內容的委託累積達到此次數(含本次),視為形成「默契」。 +export const RAPPORT_THRESHOLD = 2; + +// J-4 逾期追擊:觸發後多久沒有互動視為逾期,以及每次追擊的最短間隔。 +export const OVERDUE_GRACE_MINUTES = 10; +export const OVERDUE_NUDGE_INTERVAL_MINUTES = 10; +export const OVERDUE_IGNORED_NUDGE_COUNT = 3; // 累積追擊次數達此值仍無回應 → 觸發「被忽視」的低落情緒 + +// J-6 主動關照:稀有度節流(同角色/同使用者的主動關照,冷卻期內不可再次觸發)。 +export const PROACTIVE_CARE_COOLDOWN_HOURS = 12; +export const TODO_SIGNAL_KEYWORDS = ["要交", "報告", "考試", "作業", "還沒做", "要完成", "截止"]; +export const LATE_NIGHT_PATTERN_KEYWORDS = ["熬夜", "還沒睡", "沒睡"]; diff --git a/apps/api/src/task/proactive-care.service.ts b/apps/api/src/task/proactive-care.service.ts new file mode 100644 index 0000000..870baff --- /dev/null +++ b/apps/api/src/task/proactive-care.service.ts @@ -0,0 +1,71 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service.js"; +import { ScheduleService } from "../schedule/schedule.service.js"; +import { proactiveCareLine, bedtimeCareLine } from "./task-performance.js"; +import { PROACTIVE_CARE_COOLDOWN_HOURS, TODO_SIGNAL_KEYWORDS, LATE_NIGHT_PATTERN_KEYWORDS } from "./constants.js"; + +const HOUR_MS = 60 * 60 * 1000; + +// J-6 主動關照:從記憶偵測使用者提過的待辦、交叉比對作息型態,主動關心——但必須稀有且不重複台詞。 +@Injectable() +export class ProactiveCareService { + constructor( + private readonly prisma: PrismaService, + private readonly schedule: ScheduleService, + ) {} + + async maybeGenerate(characterId: string, userId: string, now: Date = new Date()): Promise { + const lastLog = await this.prisma.client.proactiveCareLog.findFirst({ + where: { characterId, userId }, + orderBy: { triggeredAt: "desc" }, + }); + if (lastLog && now.getTime() - lastLog.triggeredAt.getTime() < PROACTIVE_CARE_COOLDOWN_HOURS * HOUR_MS) { + return null; + } + + const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } }); + const priorLogs = await this.prisma.client.proactiveCareLog.findMany({ where: { characterId, userId } }); + const usedMessages = new Set(priorLogs.map((log) => log.message)); + + const todoMemories = await this.prisma.client.episodicMemory.findMany({ + where: { + characterId, + relatedUserId: userId, + OR: TODO_SIGNAL_KEYWORDS.map((keyword) => ({ content: { contains: keyword } })), + }, + orderBy: { occurredAt: "desc" }, + }); + for (const memory of todoMemories) { + const line = proactiveCareLine(character.personalityArchetype, memory.content); + if (!usedMessages.has(line)) { + await this.log(characterId, userId, line, now); + return line; + } + } + + // 交叉比對作息型態:使用者常提到熬夜,且目前正值睡前時段 → 主動叮嚀早點休息。 + const info = await this.schedule.getInfo(characterId, now); + if (info.isPreSleep) { + const lateNightMemory = await this.prisma.client.episodicMemory.findFirst({ + where: { + characterId, + relatedUserId: userId, + OR: LATE_NIGHT_PATTERN_KEYWORDS.map((keyword) => ({ content: { contains: keyword } })), + }, + }); + if (lateNightMemory) { + const line = bedtimeCareLine(character.personalityArchetype); + if (!usedMessages.has(line)) { + await this.log(characterId, userId, line, now); + return line; + } + } + } + + return null; + } + + private async log(characterId: string, userId: string, message: string, now: Date): Promise { + await this.prisma.client.proactiveCareLog.create({ data: { characterId, userId, message, triggeredAt: now } }); + } +} diff --git a/apps/api/src/task/task-feedback.service.ts b/apps/api/src/task/task-feedback.service.ts new file mode 100644 index 0000000..9596892 --- /dev/null +++ b/apps/api/src/task/task-feedback.service.ts @@ -0,0 +1,70 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service.js"; +import { SentimentLedgerService } from "../relationship/sentiment-ledger.service.js"; +import { EmotionService } from "../emotion/emotion.service.js"; +import { TASK_LEDGER_EVENT, TASK_LEDGER_WEIGHT } from "./constants.js"; + +// J-5 任務與記憶、關係的迴路:完成/失敗誠實回報(引擎層),但角色演出可以是遺憾/得意的語氣。 +@Injectable() +export class TaskFeedbackService { + constructor( + private readonly prisma: PrismaService, + private readonly sentimentLedger: SentimentLedgerService, + private readonly emotion: EmotionService, + ) {} + + async complete(taskId: string, now: Date = new Date()) { + const task = await this.prisma.client.task.findUnique({ where: { id: taskId } }); + if (!task) { + throw new NotFoundException(`任務不存在:${taskId}`); + } + await this.prisma.client.task.update({ + where: { id: taskId }, + data: { status: "COMPLETED", completedAt: now }, + }); + await this.sentimentLedger.recordEvent( + task.characterId, + task.userId, + TASK_LEDGER_EVENT.COMPLETED, + TASK_LEDGER_WEIGHT.COMPLETED, + { now }, + ); + return this.prisma.client.task.findUnique({ where: { id: taskId } }); + } + + async fail(taskId: string, now: Date = new Date()) { + const task = await this.prisma.client.task.findUnique({ where: { id: taskId } }); + if (!task) { + throw new NotFoundException(`任務不存在:${taskId}`); + } + await this.prisma.client.task.update({ where: { id: taskId }, data: { status: "FAILED" } }); + // 誠實回報失敗(引擎層行為固定),負向偏誤已由 E-2 機制放大,不需要在這裡重複加權。 + await this.sentimentLedger.recordEvent( + task.characterId, + task.userId, + TASK_LEDGER_EVENT.FAILED, + TASK_LEDGER_WEIGHT.FAILED, + { now }, + ); + return this.prisma.client.task.findUnique({ where: { id: taskId } }); + } + + // 觸發後被忽視(追擊次數已達門檻仍無回應):輕微失落,且如實記錄一筆負向事件。 + async markIgnored(taskId: string, now: Date = new Date()) { + const task = await this.prisma.client.task.findUnique({ where: { id: taskId } }); + if (!task) { + throw new NotFoundException(`任務不存在:${taskId}`); + } + await this.sentimentLedger.recordEvent( + task.characterId, + task.userId, + TASK_LEDGER_EVENT.IGNORED, + TASK_LEDGER_WEIGHT.IGNORED, + { now }, + ); + await this.emotion.processInput(task.characterId, "", { + now, + baselineSignal: { tag: "SAD", intensity: 0.3 }, + }); + } +} diff --git a/apps/api/src/task/task-overdue.service.ts b/apps/api/src/task/task-overdue.service.ts new file mode 100644 index 0000000..4c60ae4 --- /dev/null +++ b/apps/api/src/task/task-overdue.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service.js"; +import { TaskFeedbackService } from "./task-feedback.service.js"; +import { overdueNudgeLine } from "./task-performance.js"; +import { OVERDUE_GRACE_MINUTES, OVERDUE_NUDGE_INTERVAL_MINUTES, OVERDUE_IGNORED_NUDGE_COUNT } from "./constants.js"; + +const MINUTE_MS = 60 * 1000; + +// J-4 逾期追擊:已觸發但遲遲沒有下文的委託,依原型節奏產生追擊訊息;追擊到一定次數仍無回應 +// 視為「被忽視」,交給 J-5 的回饋迴路記一筆輕微失落。 +@Injectable() +export class TaskOverdueService { + constructor( + private readonly prisma: PrismaService, + private readonly feedback: TaskFeedbackService, + ) {} + + async checkAndNudge(taskId: string, now: Date = new Date()): Promise { + const task = await this.prisma.client.task.findUnique({ where: { id: taskId } }); + if (!task || task.status !== "TRIGGERED" || !task.triggeredAt) { + return null; + } + + const since = task.lastNudgeAt ?? task.triggeredAt; + const graceMs = + (task.lastNudgeAt ? OVERDUE_NUDGE_INTERVAL_MINUTES : OVERDUE_GRACE_MINUTES) * MINUTE_MS; + if (now.getTime() - since.getTime() < graceMs) { + return null; + } + + const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: task.characterId } }); + const nudgeIndex = task.overdueNudgeCount; + const message = overdueNudgeLine(character.personalityArchetype, nudgeIndex); + + await this.prisma.client.task.update({ + where: { id: taskId }, + data: { overdueNudgeCount: { increment: 1 }, lastNudgeAt: now }, + }); + + if (nudgeIndex + 1 >= OVERDUE_IGNORED_NUDGE_COUNT) { + await this.feedback.markIgnored(taskId, now); + } + + return message; + } +} diff --git a/apps/api/src/task/task-parser.ts b/apps/api/src/task/task-parser.ts new file mode 100644 index 0000000..6107cef --- /dev/null +++ b/apps/api/src/task/task-parser.ts @@ -0,0 +1,112 @@ +import { taipeiParts } from "../schedule/time-utils.js"; + +export interface ParsedReminderRequest { + content: string; // 去除時間片語與「提醒我」之後的委託內容 + triggerAt: Date; +} + +// J-1 自然語句解析:中文數字/阿拉伯數字時刻皆可辨識。 +const CHINESE_HOUR_WORDS: [string, number][] = [ + ["十二", 12], + ["十一", 11], + ["十", 10], + ["九", 9], + ["八", 8], + ["七", 7], + ["六", 6], + ["五", 5], + ["四", 4], + ["三", 3], + ["兩", 2], + ["二", 2], + ["一", 1], +]; + +function parseHour(segment: string): number | null { + const arabic = segment.match(/(\d{1,2})\s*[點点:時]/); + if (arabic) { + return Number(arabic[1]); + } + for (const [word, value] of CHINESE_HOUR_WORDS) { + if (segment.includes(`${word}點`) || segment.includes(`${word}点`)) { + return value; + } + } + return null; +} + +function parseMinute(segment: string): number { + if (segment.includes("半")) { + return 30; + } + const explicit = segment.match(/[點点](\d{1,2})分/); + if (explicit) { + return Number(explicit[1]); + } + const colon = segment.match(/:(\d{1,2})/); + if (colon) { + return Number(colon[1]); + } + return 0; +} + +// 依 24 小時制/上下午標記/模糊小時(1~12,取最接近的未來時刻)解析出候選小時(依時間先後排序)。 +function resolveHourCandidates(segment: string, hour: number): number[] { + const isPM = /下午|晚上|傍晚|中午(?!.*上午)/.test(segment) && hour !== 12; + const isAM = /早上|上午|凌晨/.test(segment); + if (isPM) { + return [hour === 12 ? 12 : hour + 12]; + } + if (isAM) { + return [hour === 12 ? 0 : hour]; + } + if (hour >= 13 || hour === 0) { + return [hour]; + } + if (hour === 12) { + return [12, 0]; + } + return [hour, hour + 12]; +} + +function buildTaipeiDateTime(now: Date, hour: number, minute: number): Date { + const { year, month, day } = taipeiParts(now); + // Asia/Taipei 為 UTC+8:當地 HH:MM 對應 UTC (HH-8):MM(JS Date.UTC 會自動處理跨日進位/退位)。 + return new Date(Date.UTC(year, month - 1, day, hour - 8, minute, 0, 0)); +} + +function nearestFutureOccurrence(now: Date, candidateHours: number[], minute: number): Date { + for (const hour of candidateHours) { + const candidate = buildTaipeiDateTime(now, hour, minute); + if (candidate.getTime() > now.getTime()) { + return candidate; + } + } + const fallback = buildTaipeiDateTime(now, candidateHours[0], minute); + return new Date(fallback.getTime() + 24 * 60 * 60 * 1000); +} + +// 解析「單次提醒」型委託,例如「三點提醒我開會」「晚上八點半提醒我倒垃圾」。 +// 無法辨識時間片語時回傳 null(呼叫端可視為一般對話,不建立任務)。 +export function parseReminderRequest(text: string, now: Date = new Date()): ParsedReminderRequest | null { + const match = text.match(/^(.*?)提醒我(.+)$/); + if (!match) { + return null; + } + const [, timeSegment, rawContent] = match; + + const hour = parseHour(timeSegment); + if (hour === null) { + return null; + } + const minute = parseMinute(timeSegment); + const candidates = resolveHourCandidates(timeSegment, hour); + const triggerAt = nearestFutureOccurrence(now, candidates, minute); + + const content = rawContent.trim().replace(/^[,,、]/, "").trim(); + if (!content) { + return null; + } + + return { content, triggerAt }; +} diff --git a/apps/api/src/task/task-performance.ts b/apps/api/src/task/task-performance.ts new file mode 100644 index 0000000..0b3f919 --- /dev/null +++ b/apps/api/src/task/task-performance.ts @@ -0,0 +1,106 @@ +import type { Archetype } from "../personality/archetype-params.js"; + +// J-2 接案演出:無論表面台詞是拒絕還是嫌麻煩,引擎層都一定會實際建立任務(見 TaskService.createFromText)。 +const ACCEPTANCE_LINES: Record = { + 傲嬌: ["哈?我才不是你的秘書咧……真是的,這次就特別幫你記著", "誰、誰要幫你了!……我記下了啦,別誤會"], + 冷淡: ["……嗯,記住了", "……知道了"], + 天然呆: ["咦?好,我盡量記得的說!", "嗯嗯,交給我吧的說"], + 元氣: ["交給我吧!包在我身上!", "沒問題!我一定會提醒你的!"], + 大小姐: ["真拿你沒辦法呢,本小姐就大發慈悲幫你這一次", "哼,算你走運,我記下了"], + 三無: ["……嗯", "…知道了"], +}; +const DEFAULT_ACCEPTANCE_LINES = ["好,我記下了"]; + +export function acceptanceLine(archetype: string, index = 0): string { + const lines = ACCEPTANCE_LINES[archetype as Archetype] ?? DEFAULT_ACCEPTANCE_LINES; + return lines[index % lines.length]; +} + +// J-3 觸發演出:語氣依時段調整(深夜安靜、剛睡醒迷糊但仍準時),內容盡量帶出與此委託相關的記憶回顧。 +export function triggerTone(archetype: string, isLateNight: boolean, isWaking: boolean): string { + if (isLateNight) { + const quiet: Record = { + 傲嬌: "……喂,還醒著吧?", + 冷淡: "……時間到了", + 天然呆: "……欸嘿,還沒睡嗎的說?", + 元氣: "(小聲)……喂,時間到囉", + 大小姐: "……這麼晚了,你還在吧?", + 三無: "……", + }; + return quiet[archetype as Archetype] ?? "……時間到了"; + } + if (isWaking) { + const groggy: Record = { + 傲嬌: "……唔,剛醒就要唸這個,真是的——", + 冷淡: "……早,時間到了", + 天然呆: "……早安的說,欸?已經這個時間了嗎", + 元氣: "早安!雖然還有點想睡,但時間到囉!", + 大小姐: "……早安,本小姐可是準時的", + 三無: "……早", + }; + return groggy[archetype as Archetype] ?? "……時間到了"; + } + const normal: Record = { + 傲嬌: "喂,時間到了啦,還不快去", + 冷淡: "時間到了", + 天然呆: "時間到囉的說!", + 元氣: "時間到囉!說好的事情不能忘記喔!", + 大小姐: "時間到了,本小姐可沒忘記", + 三無: "……時間到了", + }; + return normal[archetype as Archetype] ?? "時間到了"; +} + +export function buildTriggerMessage( + archetype: string, + content: string, + isLateNight: boolean, + isWaking: boolean, + memoryCallback: string | null, +): string { + const tone = triggerTone(archetype, isLateNight, isWaking); + const reminder = `${tone}——「${content}」`; + return memoryCallback ? `${reminder}(${memoryCallback})` : reminder; +} + +// J-4 逾期追擊:原型決定追擊節奏與嘴硬程度,但引擎層仍會確實記錄追擊次數。 +const OVERDUE_NUDGES: Record = { + 傲嬌: ["喂!你是不是又忘記了!……哼,我才没有担心你", "……算了,反正你也不會理我", "真是的,講都講不聽"], + 冷淡: ["……還沒做嗎", "……"], + 天然呆: ["咦,還沒做完嗎的說?要不要我再提醒一次?", "……欸嘿,是不是忘記了的說"], + 元氣: ["喂喂喂!還沒做嗎!快點快點!", "欸~你是不是忘記了!我都要開始擔心了!", "拜託拜託,快去做啦!"], + 大小姐: ["哼,本小姐都提醒過了,你還敢忘記", "真是不像話,還不快去"], + 三無: ["……", "…還沒"], +}; +const DEFAULT_OVERDUE_NUDGES = ["還記得剛剛提醒你的事嗎?"]; + +export function overdueNudgeLine(archetype: string, nudgeIndex: number): string { + const lines = OVERDUE_NUDGES[archetype as Archetype] ?? DEFAULT_OVERDUE_NUDGES; + return lines[Math.min(nudgeIndex, lines.length - 1)]; +} + +// J-6 主動關照:從記憶偵測使用者提過的待辦事項,主動問候進度。 +export function proactiveCareLine(archetype: string, memoryContent: string): string { + const templates: Record = { + 傲嬌: `之前你不是說「${memoryContent}」……我才不是關心你,只是剛好想到而已`, + 冷淡: `……「${memoryContent}」,處理了嗎`, + 天然呆: `欸,你之前說的「${memoryContent}」,還好嗎的說?`, + 元氣: `對了!你之前說「${memoryContent}」,進度怎麼樣了!`, + 大小姐: `本小姐記得你提過「${memoryContent}」,進展如何?`, + 三無: `……「${memoryContent}」……還好嗎`, + }; + return templates[archetype as Archetype] ?? `之前你提過「${memoryContent}」,還順利嗎?`; +} + +// J-6 交叉比對作息型態:偵測到使用者常態性熬夜,睡前時段主動關心。 +export function bedtimeCareLine(archetype: string): string { + const templates: Record = { + 傲嬌: "喂,你又要熬夜了吧……早點睡啦,笨蛋", + 冷淡: "……早點睡", + 天然呆: "欸,這麼晚了還不睡的說?", + 元氣: "欸~已經很晚囉!不可以又熬夜喔!", + 大小姐: "這個時間了,本小姐命令你去睡覺", + 三無: "……該睡了", + }; + return templates[archetype as Archetype] ?? "早點休息吧"; +} diff --git a/apps/api/src/task/task-scheduler.service.ts b/apps/api/src/task/task-scheduler.service.ts new file mode 100644 index 0000000..838d74d --- /dev/null +++ b/apps/api/src/task/task-scheduler.service.ts @@ -0,0 +1,30 @@ +import { Inject, Injectable, OnModuleInit } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service.js"; +import { JOB_QUEUE, type JobQueue } from "../memory/job-queue.js"; +import { TaskTriggerService } from "./task-trigger.service.js"; + +// J-7 排程器可靠性:InProcessJobQueue 的計時器只存在於記憶體中,重啟後全部消失。 +// 因此每次啟動都要重新掃描資料庫裡「尚未觸發」的任務並掛回排程,逾期未觸發的會立刻補觸發。 +@Injectable() +export class TaskSchedulerService implements OnModuleInit { + constructor( + private readonly prisma: PrismaService, + @Inject(JOB_QUEUE) private readonly jobQueue: JobQueue, + private readonly trigger: TaskTriggerService, + ) {} + + async onModuleInit(): Promise { + const pending = await this.prisma.client.task.findMany({ + where: { status: "PENDING", triggerAt: { not: null } }, + }); + for (const task of pending) { + this.scheduleTask(task.id, task.triggerAt!); + } + } + + scheduleTask(taskId: string, triggerAt: Date): void { + this.jobQueue.schedule(triggerAt, `task:${taskId}`, () => { + this.trigger.fire(taskId); + }); + } +} diff --git a/apps/api/src/task/task-trigger.service.ts b/apps/api/src/task/task-trigger.service.ts new file mode 100644 index 0000000..3121110 --- /dev/null +++ b/apps/api/src/task/task-trigger.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service.js"; +import { ScheduleService } from "../schedule/schedule.service.js"; +import { KeywordMemoryRetriever } from "../memory/memory-retriever.service.js"; +import { taipeiParts } from "../schedule/time-utils.js"; +import { buildTriggerMessage } from "./task-performance.js"; + +// J-3 觸發演出:任務時間到時,依作息語氣+相關記憶回顧組成訊息;準時觸發是引擎責任,語氣是角色責任。 +@Injectable() +export class TaskTriggerService { + constructor( + private readonly prisma: PrismaService, + private readonly schedule: ScheduleService, + private readonly memoryRetriever: KeywordMemoryRetriever, + ) {} + + async fire(taskId: string, now: Date = new Date()): Promise { + const task = await this.prisma.client.task.findUnique({ where: { id: taskId } }); + // 服務重啟時舊的 setTimeout 可能與新掛回的排程重複註冊;狀態不是 PENDING 就不再重複觸發。 + if (!task || task.status !== "PENDING") { + return null; + } + + const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: task.characterId } }); + const info = await this.schedule.getInfo(task.characterId, now); + const { hour } = taipeiParts(now); + const isLateNight = info.slot?.status === "SLEEP" || hour < 6 || hour >= 23; + + const memories = await this.memoryRetriever.retrieve(task.characterId, task.content, { limit: 1 }); + const topMemory = memories[0]; + const memoryCallback = + topMemory && task.content.length > 0 && topMemory.content.includes(task.content) + ? `記得你之前提過:「${topMemory.content}」` + : null; + + const message = buildTriggerMessage( + character.personalityArchetype, + task.content, + isLateNight, + info.isWaking, + memoryCallback, + ); + + await this.prisma.client.task.update({ + where: { id: task.id }, + data: { status: "TRIGGERED", triggeredAt: now, triggerMessage: message }, + }); + + return message; + } +} diff --git a/apps/api/src/task/task.controller.ts b/apps/api/src/task/task.controller.ts new file mode 100644 index 0000000..bf1d185 --- /dev/null +++ b/apps/api/src/task/task.controller.ts @@ -0,0 +1,103 @@ +import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common"; +import { TaskService } from "./task.service.js"; +import { TaskTriggerService } from "./task-trigger.service.js"; +import { TaskOverdueService } from "./task-overdue.service.js"; +import { TaskFeedbackService } from "./task-feedback.service.js"; +import { ProactiveCareService } from "./proactive-care.service.js"; + +interface ParseBody { + userId: string; + text: string; + now?: string; +} + +interface CreateBody { + userId: string; + type: "ONE_TIME" | "RECURRING" | "CONDITIONAL" | "QUERY_ORGANIZE" | "TODO_TRACKING"; + content: string; + rawText: string; + triggerAt?: string; + cronExpression?: string; + condition?: string; + now?: string; +} + +@Controller("task") +export class TaskController { + constructor( + private readonly taskService: TaskService, + private readonly trigger: TaskTriggerService, + private readonly overdue: TaskOverdueService, + private readonly feedback: TaskFeedbackService, + private readonly proactiveCare: ProactiveCareService, + ) {} + + @Get(":taskId") + async getTask(@Param("taskId") taskId: string) { + return this.taskService.get(taskId); + } + + @Get() + async list(@Query("characterId") characterId: string, @Query("userId") userId: string) { + return this.taskService.listForUser(characterId, userId); + } + + // J-1/J-2:嘗試把自然語句解析為單次提醒委託;無法辨識時 created=false(呼叫端應走一般對話)。 + @Post(":characterId/parse") + async parse(@Param("characterId") characterId: string, @Body() body: ParseBody) { + const now = body.now ? new Date(body.now) : undefined; + const result = await this.taskService.tryCreateFromText(characterId, body.userId, body.text, now); + if (!result) { + return { created: false }; + } + return { created: true, ...result }; + } + + @Post(":characterId") + async create(@Param("characterId") characterId: string, @Body() body: CreateBody) { + const now = body.now ? new Date(body.now) : undefined; + const result = await this.taskService.create( + characterId, + body.userId, + { + type: body.type, + content: body.content, + rawText: body.rawText, + triggerAt: body.triggerAt ? new Date(body.triggerAt) : undefined, + cronExpression: body.cronExpression, + condition: body.condition, + }, + now, + ); + return result; + } + + // 測試用途:直接觸發(略過真實排程等待),驗證 J-3 觸發演出的內容邏輯。 + @Post(":taskId/fire-now") + async fireNow(@Param("taskId") taskId: string, @Body() body: { now?: string }) { + const message = await this.trigger.fire(taskId, body.now ? new Date(body.now) : undefined); + return { message }; + } + + @Post(":taskId/check-overdue") + async checkOverdue(@Param("taskId") taskId: string, @Body() body: { now?: string }) { + const message = await this.overdue.checkAndNudge(taskId, body.now ? new Date(body.now) : undefined); + return { message }; + } + + @Post(":taskId/complete") + async complete(@Param("taskId") taskId: string, @Body() body: { now?: string }) { + return this.feedback.complete(taskId, body.now ? new Date(body.now) : undefined); + } + + @Post(":taskId/fail") + async fail(@Param("taskId") taskId: string, @Body() body: { now?: string }) { + return this.feedback.fail(taskId, body.now ? new Date(body.now) : undefined); + } + + @Post("proactive-care/:characterId") + async proactive(@Param("characterId") characterId: string, @Body() body: { userId: string; now?: string }) { + const message = await this.proactiveCare.maybeGenerate(characterId, body.userId, body.now ? new Date(body.now) : undefined); + return { message }; + } +} diff --git a/apps/api/src/task/task.module.ts b/apps/api/src/task/task.module.ts new file mode 100644 index 0000000..f193cf1 --- /dev/null +++ b/apps/api/src/task/task.module.ts @@ -0,0 +1,28 @@ +import { Module } from "@nestjs/common"; +import { PrismaModule } from "../prisma/prisma.module.js"; +import { MemoryModule } from "../memory/memory.module.js"; +import { RelationshipModule } from "../relationship/relationship.module.js"; +import { EmotionModule } from "../emotion/emotion.module.js"; +import { ScheduleModule } from "../schedule/schedule.module.js"; +import { TaskService } from "./task.service.js"; +import { TaskSchedulerService } from "./task-scheduler.service.js"; +import { TaskTriggerService } from "./task-trigger.service.js"; +import { TaskOverdueService } from "./task-overdue.service.js"; +import { TaskFeedbackService } from "./task-feedback.service.js"; +import { ProactiveCareService } from "./proactive-care.service.js"; +import { TaskController } from "./task.controller.js"; + +@Module({ + imports: [PrismaModule, MemoryModule, RelationshipModule, EmotionModule, ScheduleModule], + controllers: [TaskController], + providers: [ + TaskService, + TaskSchedulerService, + TaskTriggerService, + TaskOverdueService, + TaskFeedbackService, + ProactiveCareService, + ], + exports: [TaskService, TaskSchedulerService, TaskTriggerService, TaskOverdueService, TaskFeedbackService, ProactiveCareService], +}) +export class TaskModule {} diff --git a/apps/api/src/task/task.service.ts b/apps/api/src/task/task.service.ts new file mode 100644 index 0000000..54a48ee --- /dev/null +++ b/apps/api/src/task/task.service.ts @@ -0,0 +1,101 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service.js"; +import { SentimentLedgerService } from "../relationship/sentiment-ledger.service.js"; +import { TaskSchedulerService } from "./task-scheduler.service.js"; +import { parseReminderRequest } from "./task-parser.js"; +import { acceptanceLine } from "./task-performance.js"; +import { TASK_LEDGER_EVENT, TASK_LEDGER_WEIGHT, RAPPORT_THRESHOLD } from "./constants.js"; + +export interface CreateTaskResult { + task: Awaited>; + performanceLine: string; + rapport: boolean; +} + +// J-1/J-2/J-5 委託子系統核心:解析自然語句、實際建立任務(引擎責任),並附上接案演出(角色責任)。 +@Injectable() +export class TaskService { + constructor( + private readonly prisma: PrismaService, + private readonly sentimentLedger: SentimentLedgerService, + private readonly scheduler: TaskSchedulerService, + ) {} + + // 嘗試把使用者輸入解析為單次提醒委託;無法辨識時回傳 null(呼叫端應視為一般對話)。 + async tryCreateFromText( + characterId: string, + userId: string, + text: string, + now: Date = new Date(), + ): Promise { + const parsed = parseReminderRequest(text, now); + if (!parsed) { + return null; + } + return this.create( + characterId, + userId, + { type: "ONE_TIME", content: parsed.content, rawText: text, triggerAt: parsed.triggerAt }, + now, + ); + } + + async create( + characterId: string, + userId: string, + input: { type: "ONE_TIME" | "RECURRING" | "CONDITIONAL" | "QUERY_ORGANIZE" | "TODO_TRACKING"; content: string; rawText: string; triggerAt?: Date; cronExpression?: string; condition?: string }, + now: Date = new Date(), + ): Promise { + const character = await this.prisma.client.character.findUniqueOrThrow({ where: { id: characterId } }); + + // J-5 默契旗標:同角色/同使用者/同類型/同內容的委託,這是第幾次了。 + const priorCount = await this.prisma.client.task.count({ + where: { characterId, userId, type: input.type, content: input.content }, + }); + const rapport = priorCount + 1 >= RAPPORT_THRESHOLD; + + const task = await this.prisma.client.task.create({ + data: { + characterId, + userId, + type: input.type, + content: input.content, + rawText: input.rawText, + triggerAt: input.triggerAt, + cronExpression: input.cronExpression, + condition: input.condition, + }, + }); + + if (task.triggerAt) { + this.scheduler.scheduleTask(task.id, task.triggerAt); + } + + // J-5 任務與關係的迴路:委託本身(接案)就寫入正向關係帳本事件,不必等到完成。 + await this.sentimentLedger.recordEvent( + characterId, + userId, + TASK_LEDGER_EVENT.ACCEPTED, + TASK_LEDGER_WEIGHT.ACCEPTED, + { now }, + ); + + const performanceLine = acceptanceLine(character.personalityArchetype, priorCount); + return { task, performanceLine, rapport }; + } + + async get(taskId: string) { + return this.prisma.client.task.findUnique({ where: { id: taskId } }); + } + + async listForUser(characterId: string, userId: string) { + return this.prisma.client.task.findMany({ + where: { characterId, userId }, + orderBy: { createdAt: "desc" }, + }); + } + + async cancel(taskId: string) { + return this.prisma.client.task.update({ where: { id: taskId }, data: { status: "CANCELLED" } }); + } +} diff --git a/prisma/migrations/20260813061952_add_task_tables/migration.sql b/prisma/migrations/20260813061952_add_task_tables/migration.sql new file mode 100644 index 0000000..903911d --- /dev/null +++ b/prisma/migrations/20260813061952_add_task_tables/migration.sql @@ -0,0 +1,32 @@ +-- CreateTable +CREATE TABLE "tasks" ( + "id" TEXT NOT NULL PRIMARY KEY, + "characterId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "content" TEXT NOT NULL, + "rawText" TEXT NOT NULL, + "triggerAt" DATETIME, + "cronExpression" TEXT, + "condition" TEXT, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "triggeredAt" DATETIME, + "triggerMessage" TEXT, + "overdueNudgeCount" INTEGER NOT NULL DEFAULT 0, + "lastNudgeAt" DATETIME, + "completedAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "tasks_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "tasks_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "proactive_care_logs" ( + "id" TEXT NOT NULL PRIMARY KEY, + "characterId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "message" TEXT NOT NULL, + "triggeredAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "proactive_care_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 83260ab..402a858 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -58,6 +58,23 @@ enum ScheduleSlotStatus { FREE // 空閒 } +// J-1 委託類型 +enum TaskType { + ONE_TIME // 單次提醒 + RECURRING // 週期提醒 + CONDITIONAL // 條件提醒 + QUERY_ORGANIZE // 查詢整理 + TODO_TRACKING // 代辦追蹤 +} + +enum TaskStatus { + PENDING // 尚未觸發 + TRIGGERED // 已觸發,等候使用者回應 + COMPLETED // 已完成 + FAILED // 執行失敗 + CANCELLED // 已取消 +} + model User { id String @id @default(cuid()) name String @@ -66,6 +83,7 @@ model User { relationships Relationship[] relatedMemories EpisodicMemory[] + tasks Task[] @@map("users") } @@ -114,6 +132,8 @@ model Character { scheduleSlots ScheduleSlot[] specialDays SpecialDay[] scheduleExceptions ScheduleExceptionLog[] + tasks Task[] + proactiveCareLogs ProactiveCareLog[] @@map("characters") } @@ -270,3 +290,46 @@ model SentimentLedgerEntry { @@map("sentiment_ledger_entries") } + +// J-1 委託:使用者請角色做的事。執行(觸發準時度)是引擎責任,演出(接案/觸發/追擊的台詞)是角色責任。 +model Task { + id String @id @default(cuid()) + characterId String + character Character @relation(fields: [characterId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + type TaskType + content String // 委託內容(去除時間片語後的純委託描述,例如「開會」) + rawText String // 使用者原始輸入,供回顧/記憶引用 + + triggerAt DateTime? // 單次提醒的觸發時間;週期提醒改用 cronExpression + cronExpression String? // 週期提醒(node-cron 表達式) + condition String? // 條件提醒的條件描述(MVP 為文字紀錄,不做真實環境條件監測) + + status TaskStatus @default(PENDING) + triggeredAt DateTime? // 最近一次實際觸發的時間 + triggerMessage String? // 最近一次觸發演出的訊息內容(供前端/測試輪詢) + + overdueNudgeCount Int @default(0) // J-4 逾期追擊次數 + lastNudgeAt DateTime? + + completedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("tasks") +} + +// J-6 主動關照:記錄每次主動關心的觸發時間與內容,供稀有度節流與「不重複台詞」判定。 +model ProactiveCareLog { + id String @id @default(cuid()) + characterId String + character Character @relation(fields: [characterId], references: [id], onDelete: Cascade) + userId String + message String + triggeredAt DateTime @default(now()) + + @@map("proactive_care_logs") +} diff --git a/scripts/smoke/J.mjs b/scripts/smoke/J.mjs new file mode 100644 index 0000000..008346c --- /dev/null +++ b/scripts/smoke/J.mjs @@ -0,0 +1,256 @@ +import { prisma } from "@kokorone/db"; + +const API_PORT = process.env.PORT_API ?? "3001"; +const CHARACTER_ID = "smoke-j-character"; +const USER_ID = "seed-user-primary"; + +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(); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export default async function smokeJ() { + await prisma.character.deleteMany({ where: { id: CHARACTER_ID } }); + await post("/personality/characters", { + id: CHARACTER_ID, + source: "ORIGINAL", + buildStatus: "BUILT", + formalName: "委託測試角色", + basicInfo: "委託測試角色", + backgroundStory: "測試用背景", + personalityArchetype: "元氣", + likesDislikes: "測試", + goalsObsessions: "測試", + speechStyle: "第一人稱「我」", + initialRelationship: { userId: USER_ID, intimacy: 30, trust: 10 }, + }); + + const PARSE_NOW = "2026-08-13T00:00:00.000Z"; // 週四 08:00 Taipei + + // J-1 自然語句解析:時間片語+內容都要正確拆解出來。 + const parsedMeeting = await post(`/task/${CHARACTER_ID}/parse`, { + userId: USER_ID, + text: "三點提醒我開會", + now: PARSE_NOW, + }); + if (!parsedMeeting.created || parsedMeeting.task.content !== "開會") { + throw new Error(`「三點提醒我開會」應解析出內容「開會」,實際為 ${JSON.stringify(parsedMeeting)}`); + } + if (parsedMeeting.task.triggerAt !== "2026-08-13T07:00:00.000Z") { + throw new Error(`「三點」在 08:00 之後應解析為當天 15:00(UTC 07:00),實際為 ${parsedMeeting.task.triggerAt}`); + } + + const parsedTrash = await post(`/task/${CHARACTER_ID}/parse`, { + userId: USER_ID, + text: "晚上八點半提醒我倒垃圾", + now: PARSE_NOW, + }); + if (parsedTrash.task.content !== "倒垃圾" || parsedTrash.task.triggerAt !== "2026-08-13T12:30:00.000Z") { + throw new Error(`「晚上八點半」應解析為當天 20:30(UTC 12:30),實際為 ${JSON.stringify(parsedTrash.task)}`); + } + + const unparsed = await post(`/task/${CHARACTER_ID}/parse`, { + userId: USER_ID, + text: "今天天氣真好", + now: PARSE_NOW, + }); + if (unparsed.created !== false) { + throw new Error("不含委託語句的輸入不應被解析為任務"); + } + + // J-2 接案演出:無論表面台詞如何,引擎層都一定實際建立了任務(上面兩筆已驗證),且有對應原型的演出台詞。 + if (!parsedMeeting.performanceLine) { + throw new Error("接案應附上演出台詞"); + } + + // J-3 觸發演出:語氣依時段調整,且能帶出相關記憶回顧。 + await prisma.episodicMemory.create({ + data: { + characterId: CHARACTER_ID, + content: "使用者說要看書準備考試", + occurredAt: new Date(), + emotionTag: "CALM", + emotionIntensity: 0.3, + source: "INTERACTION", + relatedUserId: USER_ID, + }, + }); + const readingTask = await post(`/task/${CHARACTER_ID}`, { + userId: USER_ID, + type: "ONE_TIME", + content: "看書", + rawText: "十點提醒我看書", + }); + const lateNightFire = await post(`/task/${readingTask.task.id}/fire-now`, { now: "2026-08-12T18:00:00.000Z" }); + if (!lateNightFire.message?.startsWith("(小聲)")) { + throw new Error(`深夜觸發語氣應該放輕聲量,實際為 ${lateNightFire.message}`); + } + + const readingTask2 = await post(`/task/${CHARACTER_ID}`, { + userId: USER_ID, + type: "ONE_TIME", + content: "看書", + rawText: "十點提醒我看書", + }); + const daytimeFire = await post(`/task/${readingTask2.task.id}/fire-now`, { now: "2026-08-13T05:00:00.000Z" }); + if (!daytimeFire.message.includes("使用者說要看書準備考試")) { + throw new Error(`白天觸發應帶出相關記憶回顧,實際為 ${daytimeFire.message}`); + } + + // J-4 逾期追擊:觸發後太快檢查不應追擊;超過寬限期後應逐次追擊,累積到門檻應觸發「被忽視」。 + const dishesTask = await post(`/task/${CHARACTER_ID}`, { + userId: USER_ID, + type: "ONE_TIME", + content: "洗碗", + rawText: "提醒我洗碗", + }); + await post(`/task/${dishesTask.task.id}/fire-now`, { now: "2026-08-13T05:00:00.000Z" }); + + const tooEarly = await post(`/task/${dishesTask.task.id}/check-overdue`, { now: "2026-08-13T05:05:00.000Z" }); + if (tooEarly.message !== null) { + throw new Error("寬限期內不應該追擊"); + } + const nudge1 = await post(`/task/${dishesTask.task.id}/check-overdue`, { now: "2026-08-13T05:15:00.000Z" }); + const nudge2 = await post(`/task/${dishesTask.task.id}/check-overdue`, { now: "2026-08-13T05:25:00.000Z" }); + const nudge3 = await post(`/task/${dishesTask.task.id}/check-overdue`, { now: "2026-08-13T05:35:00.000Z" }); + if (!nudge1.message || !nudge2.message || !nudge3.message || nudge1.message === nudge2.message) { + throw new Error("逾期追擊應該逐次產生(且不完全重複的)訊息"); + } + const relationshipAfterIgnore = await prisma.relationship.findUnique({ + where: { characterId_userId: { characterId: CHARACTER_ID, userId: USER_ID } }, + }); + const ignoredLedger = await prisma.sentimentLedgerEntry.findFirst({ + where: { relationshipId: relationshipAfterIgnore.id, event: "TASK_IGNORED" }, + }); + if (!ignoredLedger) { + throw new Error("累積追擊達門檻仍無回應時,應記錄一筆 TASK_IGNORED 負向事件"); + } + + // J-5 委託回饋迴路:完成/失敗會誠實寫入關係帳本;重複同類委託形成默契旗標。 + const completeTarget = await post(`/task/${CHARACTER_ID}`, { + userId: USER_ID, + type: "ONE_TIME", + content: "測試完成", + rawText: "提醒我測試完成", + }); + await post(`/task/${completeTarget.task.id}/complete`, {}); + const completedTask = await get(`/task/${completeTarget.task.id}`); + if (completedTask.status !== "COMPLETED" || !completedTask.completedAt) { + throw new Error("完成後任務狀態應為 COMPLETED 且有 completedAt"); + } + const completedLedger = await prisma.sentimentLedgerEntry.findFirst({ + where: { relationshipId: relationshipAfterIgnore.id, event: "TASK_COMPLETED" }, + }); + if (!completedLedger || completedLedger.weight <= 0) { + throw new Error("完成任務應記錄一筆正向的 TASK_COMPLETED 事件"); + } + + const failTarget = await post(`/task/${CHARACTER_ID}`, { + userId: USER_ID, + type: "ONE_TIME", + content: "測試失敗", + rawText: "提醒我測試失敗", + }); + await post(`/task/${failTarget.task.id}/fail`, {}); + const failedTask = await get(`/task/${failTarget.task.id}`); + if (failedTask.status !== "FAILED") { + throw new Error("失敗後任務狀態應為 FAILED"); + } + + const rapport1 = await post(`/task/${CHARACTER_ID}`, { + userId: USER_ID, + type: "TODO_TRACKING", + content: "倒垃圾", + rawText: "追蹤倒垃圾", + }); + const rapport2 = await post(`/task/${CHARACTER_ID}`, { + userId: USER_ID, + type: "TODO_TRACKING", + content: "倒垃圾", + rawText: "追蹤倒垃圾", + }); + if (rapport1.rapport !== false || rapport2.rapport !== true) { + throw new Error("同類委託第二次提出時應形成默契旗標(rapport=true)"); + } + + // J-6 主動關照:從記憶偵測待辦並主動關心,且有稀有度節流(冷卻期內不重複觸發)。 + await prisma.episodicMemory.create({ + data: { + characterId: CHARACTER_ID, + content: "使用者說明天要交報告", + occurredAt: new Date(), + emotionTag: "CALM", + emotionIntensity: 0.3, + source: "INTERACTION", + relatedUserId: USER_ID, + }, + }); + const careFirst = await post(`/task/proactive-care/${CHARACTER_ID}`, { userId: USER_ID }); + if (!careFirst.message?.includes("要交報告")) { + throw new Error(`主動關照應引用偵測到的待辦記憶,實際為 ${careFirst.message}`); + } + const careSecond = await post(`/task/proactive-care/${CHARACTER_ID}`, { userId: USER_ID }); + if (careSecond.message !== null) { + throw new Error("冷卻期內不應該再次觸發主動關照"); + } + + // J-7 排程器可靠性:建立提醒後即使服務重啟(記憶體計時器全部消失),時間到仍準時觸發。 + // 必須使用真實時間(而非模擬 now),因為 InProcessJobQueue 的排程是依真實時鐘計算延遲。 + const realTriggerAt = new Date(Date.now() + 8000); + const reliabilityTask = await post(`/task/${CHARACTER_ID}`, { + userId: USER_ID, + type: "ONE_TIME", + content: "重啟可靠性測試", + rawText: "重啟可靠性測試", + triggerAt: realTriggerAt.toISOString(), + }); + + const { spawnSync } = await import("node:child_process"); + const restartResult = spawnSync("npm", ["run", "restart"], { cwd: process.cwd(), stdio: "pipe" }); + if (restartResult.status !== 0) { + throw new Error(`重啟服務失敗:${restartResult.stderr?.toString()}`); + } + + const deadline = Date.now() + 20_000; + let fired = null; + while (Date.now() < deadline) { + // 重啟瞬間舊連線可能被強制關閉(keep-alive socket 指向已終止的舊行程),視為尚未就緒,稍後重試。 + let current; + try { + current = await get(`/task/${reliabilityTask.task.id}`); + } catch { + await sleep(500); + continue; + } + if (current.status === "TRIGGERED") { + fired = current; + break; + } + await sleep(500); + } + if (!fired) { + throw new Error("重啟服務後,任務應在原訂時間準時觸發(狀態應變為 TRIGGERED)"); + } + + // 清理本次測試建立的角色(cascade 會一併清掉任務、主動關照紀錄、記憶等)。 + await prisma.character.deleteMany({ where: { id: CHARACTER_ID } }); +} diff --git a/todo.md b/todo.md index 30468d2..0671f21 100644 --- a/todo.md +++ b/todo.md @@ -265,14 +265,24 @@ flowchart TB ### J. 委託子系統 -- [ ] **J-1 任務資料表與解析(S)**:`Task`(類型=單次/週期/條件/查詢整理/代辦追蹤、觸發時間或條件、內容、狀態),並實作自然語句解析成任務。驗收:「三點提醒我開會」建立正確的單次提醒。依據:§委託類型表。 -- [ ] **J-2 接案演出層(S)**:依性格原型輸出接案台詞(傲嬌嘴硬、元氣熱情、冷淡一個「嗯」),但引擎層必定接受任務——拒絕只是演出。驗收:六原型各有對應台詞且任務皆成功建立。依據:§接案的角色演出「該接的最後都會接」。 -- [ ] **J-3 觸發演出(S)**:提醒觸發時語氣隨作息(深夜壓低、早晨迷糊),內容帶記憶前因後果(「資料你昨天說還沒做完,帶了嗎?」)。驗收:觸發訊息含相關記憶引用。依據:§提醒的觸發演出「提醒不是鬧鐘,是記得前因後果的人在提醒你」。 -- [ ] **J-4 逾期追擊(S)**:使用者無反應時依性格追擊(元氣連環、冷淡隔十分鐘補一句、傲嬌口是心非再傳一次)。驗收:模擬無回應後產生符合原型的追擊訊息。依據:§提醒的觸發演出「逾期追擊」。 -- [ ] **J-5 委託回饋迴路(S)**:每次委託與完成寫入關係帳本(正向事件),被感謝提升情緒、被忽略產生小失落;重複同類委託形成默契旗標。驗收:完成委託後親密度與情緒皆有變化。依據:§任務與記憶、關係的迴路。 -- [ ] **J-6 主動關照(S)**:偵測使用者待辦訊號並追蹤、作息交叉比對(常熬夜 → 到點主動出現),且頻率克制、不重複同一句。驗收:連續觸發時受頻率上限抑制。依據:§主動關照:沒被委託的提醒。 -- [ ] **J-7 排程器可靠性(M)**:走 C-3 的 `JobQueue`,時間到必觸發(角色設定的迷糊只能表現在演出,不得真的忘記);服務重啟後未觸發的任務仍會被重新排入。驗收:建立提醒後重啟服務,時間到仍準時觸發。依據:§分層:任務引擎與角色演出「排程與執行走確定性系統」。 -- [ ] **J-V 階段驗證(XS)**:`npm run restart && npm run smoke -- J`(J.mjs:一分鐘後提醒必觸發、重啟後不遺失、演出符合原型、逾期追擊)。 +- [x] **J-1 任務資料表與解析(S)**:`Task`(類型=單次/週期/條件/查詢整理/代辦追蹤、觸發時間或條件、內容、狀態),並實作自然語句解析成任務。驗收:「三點提醒我開會」建立正確的單次提醒。依據:§委託類型表。 +- [x] **J-2 接案演出層(S)**:依性格原型輸出接案台詞(傲嬌嘴硬、元氣熱情、冷淡一個「嗯」),但引擎層必定接受任務——拒絕只是演出。驗收:六原型各有對應台詞且任務皆成功建立。依據:§接案的角色演出「該接的最後都會接」。 +- [x] **J-3 觸發演出(S)**:提醒觸發時語氣隨作息(深夜壓低、早晨迷糊),內容帶記憶前因後果(「資料你昨天說還沒做完,帶了嗎?」)。驗收:觸發訊息含相關記憶引用。依據:§提醒的觸發演出「提醒不是鬧鐘,是記得前因後果的人在提醒你」。 +- [x] **J-4 逾期追擊(S)**:使用者無反應時依性格追擊(元氣連環、冷淡隔十分鐘補一句、傲嬌口是心非再傳一次)。驗收:模擬無回應後產生符合原型的追擊訊息。依據:§提醒的觸發演出「逾期追擊」。 +- [x] **J-5 委託回饋迴路(S)**:每次委託與完成寫入關係帳本(正向事件),被感謝提升情緒、被忽略產生小失落;重複同類委託形成默契旗標。驗收:完成委託後親密度與情緒皆有變化。依據:§任務與記憶、關係的迴路。 +- [x] **J-6 主動關照(S)**:偵測使用者待辦訊號並追蹤、作息交叉比對(常熬夜 → 到點主動出現),且頻率克制、不重複同一句。驗收:連續觸發時受頻率上限抑制。依據:§主動關照:沒被委託的提醒。 +- [x] **J-7 排程器可靠性(M)**:走 C-3 的 `JobQueue`,時間到必觸發(角色設定的迷糊只能表現在演出,不得真的忘記);服務重啟後未觸發的任務仍會被重新排入。驗收:建立提醒後重啟服務,時間到仍準時觸發。依據:§分層:任務引擎與角色演出「排程與執行走確定性系統」。 +- [x] **J-V 階段驗證(XS)**:`npm run restart && npm run smoke -- J`(J.mjs:一分鐘後提醒必觸發、重啟後不遺失、演出符合原型、逾期追擊)。 + +> **實作記錄(J 群組)**: +> - 委託子系統放在 `apps/api/src/task/`,嚴格遵守「執行是引擎責任、演出是角色責任」的分層:`TaskService`/`TaskSchedulerService`/`TaskTriggerService` 這條主線絕不因性格而跳過或延遲觸發;`task-performance.ts` 純粹是各原型的台詞庫,不影響任何狀態轉移邏輯。 +> - **J-1 自然語句解析**(`task-parser.ts`)只處理「`<時間片語>提醒我<內容>`」這個句型,中文數字(一~十二)與阿拉伯數字時刻皆可辨識,並用「取最接近的未來時刻」規則消解無上下午標記的模糊時刻(例如現在是早上 8 點時,「三點」會解析成當天下午 3 點;現在是晚上 8 點時,「三點」會解析成隔天凌晨 3 點)。其餘四種委託類型(週期/條件/查詢整理/代辦追蹤)目前只有資料結構與直接建立 API(`POST /task/:characterId`,可指定 `type`),還沒有對應的自然語句解析規則——**未來若要支援「每天提醒我」「下雨提醒我帶傘」之類的句型,需要在 `task-parser.ts` 擴充,目前只有 `parseReminderRequest` 這一個解析函式**。 +> - **J-7 排程器可靠性的關鍵機制**:`InProcessJobQueue` 的計時器只存在於記憶體,程序重啟就會全部消失,因此 `TaskSchedulerService` 實作了 `OnModuleInit`:每次應用程式啟動時,都會重新掃描資料庫裡所有 `status=PENDING` 且有 `triggerAt` 的任務並重新掛回 `JobQueue.schedule()`;若 `triggerAt` 已經是過去(服務重啟期間錯過的),`InProcessJobQueue.schedule()` 內部的 `Math.max(0, ...)` 會讓它幾乎立刻補觸發。**冒煙測試裡的 J-7 是唯一一個會真的呼叫 `npm run restart`(用 `spawnSync` 實際重啟整組 api/web 行程)的測試**,藉此證明這個機制不是紙上談兵——手動驗證時也是先建立一個 8 秒後觸發的任務、立刻重啟服務、再等待確認狀態變成 `TRIGGERED`。**因為這樣,J.mjs 執行時間明顯比其他群組長(含一次完整的建置+重啟+健康檢查),這是預期行為,不是效能異常。** +> - **重啟瞬間的 keep-alive 連線陷阱**:`npm run restart` 執行期間,Node 內建 `fetch`(undici)可能持有指向「舊行程」的 keep-alive socket;舊行程被殺掉時,下一次重用這條 socket 送出的請求會直接丟出 `fetch failed`(cause 是 `SocketError: other side closed`)。J.mjs 在重啟後的輪詢迴圈裡把這類錯誤當成「服務還沒就緒」重試,而不是直接讓測試失敗——**這不是本群組特有的問題,任何在服務重啟前後緊接著發請求的測試都可能遇到,之後若有群組也需要跨重啟測試,記得比照這裡的重試寫法**。 +> - **J-3 觸發訊息的記憶回顧**是刻意簡化的版本:直接重用 C-6 的 `KeywordMemoryRetriever` 以任務內容當查詢字,取第一筆結果,且只有在該記憶內容「包含」任務內容字串時才附加回顧文字,避免牽強附會的引用。沒有做更複雜的語意關聯判斷(留給 R-2 的 pgvector 語意檢索之後自然變好)。 +> - **J-4 逾期追擊**與**J-6 主動關照**都刻意重用既有的稀有度節流寫法:J-4 用 `Task.overdueNudgeCount`/`lastNudgeAt` 兩個欄位控制節奏(首次追擊需等 `OVERDUE_GRACE_MINUTES`,之後每次追擊需等 `OVERDUE_NUDGE_INTERVAL_MINUTES`),追擊次數累積到 `OVERDUE_IGNORED_NUDGE_COUNT`(預設 3 次)仍無回應才會判定「被忽視」;J-6 則是獨立的 `ProactiveCareLog` 表+`PROACTIVE_CARE_COOLDOWN_HOURS` 冷卻窗(風格對齊 G-4 反差萌與 I-4 破例規則的冷卻表寫法),且會排除「已經講過的同一句台詞」,避免同一句反覆出現。 +> - **J-5 委託/關係/情緒的迴路**:任務**建立**(接案)與**完成**都各寫一筆正向 `SentimentLedgerEntry`(事件名稱 `TASK_ACCEPTED`/`TASK_COMPLETED`);「被忽視」則寫負向事件 `TASK_IGNORED`,且直接複用 I-5 引進的 `EmotionService.baselineSignal` 機制(傳入空文字+`{tag:"SAD", intensity:0.3}` 的基線訊號)讓情緒真的往低落偏一點,不必另外幫 `EmotionService` 開新的公開方法。「被感謝提升情緒」則完全不需要額外程式碼——D-1 的 `RuleBasedEmotionTagger` 本來就把「謝謝」標記為 JOY 關鍵字,只要使用者在對話中道謝,既有的 `DialogueService.handleMessage` 情緒管線就會自然生效,J 群組不重複實作。「重複同類委託形成默契旗標」用最簡單的方式判斷:查詢同角色/同使用者/同類型/同內容的委託累積次數(含本次)是否達到 `RAPPORT_THRESHOLD`(預設 2 次),回傳布林旗標 `rapport`,暫不影響任何其他行為(留給後續群組視需要使用這個旗標調整演出)。 +> - `TaskModule` 依賴 `ScheduleModule`(J-3 語氣判斷)、`EmotionModule`(J-5 情緒訊號)、`RelationshipModule`(J-5 關係帳本)、`MemoryModule`(J-3 記憶檢索、J-7 的 `JOB_QUEUE`),已在 `app.module.ts` 註冊。**目前 `DialogueService.handleMessage` 尚未整合委託子系統**——也就是說一般聊天訊息不會自動被判斷成委託請求並建立任務,`/task/:characterId/parse` 目前是獨立端點。若之後要讓使用者在一般對話中直接說「三點提醒我開會」就自動建立任務,需要在 `DialogueService` 裡加入呼叫 `TaskService.tryCreateFromText` 的分支(類似目前 fastChannel/schedule exception 的插入方式),並決定解析成功時要不要跳過一般的 LLM 生成、改用接案演出句作為回覆。 ### K. 對話模式:群聊與角色自聊