feat: 完成 J 群組 — 委託子系統

Task 資料表與自然語句解析(單次提醒)、依原型的接案/觸發/逾期追擊演出、
委託與關係帳本/情緒的回饋迴路(含默契旗標)、主動關照(待辦偵測+作息交叉比對,
稀有度節流不重複台詞)、排程器可靠性(服務重啟後重新掃描 PENDING 任務掛回
JobQueue,逾期立即補觸發)。J.mjs 以 spawnSync 實際重啟服務驗證 J-7。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Jeffery
2026-08-13 14:35:16 +08:00
co-authored by Claude Sonnet 5
parent d82b834e47
commit 3503cc0be6
16 changed files with 1117 additions and 8 deletions
+2
View File
@@ -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],
})
+28
View File
@@ -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 = ["熬夜", "還沒睡", "沒睡"];
@@ -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<string | null> {
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<void> {
await this.prisma.client.proactiveCareLog.create({ data: { characterId, userId, message, triggeredAt: now } });
}
}
@@ -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 },
});
}
}
+46
View File
@@ -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<string | null> {
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;
}
}
+112
View File
@@ -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 };
}
+106
View File
@@ -0,0 +1,106 @@
import type { Archetype } from "../personality/archetype-params.js";
// J-2 接案演出:無論表面台詞是拒絕還是嫌麻煩,引擎層都一定會實際建立任務(見 TaskService.createFromText)。
const ACCEPTANCE_LINES: Record<Archetype, string[]> = {
傲嬌: ["哈?我才不是你的秘書咧……真是的,這次就特別幫你記著", "誰、誰要幫你了!……我記下了啦,別誤會"],
冷淡: ["……嗯,記住了", "……知道了"],
天然呆: ["咦?好,我盡量記得的說!", "嗯嗯,交給我吧的說"],
元氣: ["交給我吧!包在我身上!", "沒問題!我一定會提醒你的!"],
大小姐: ["真拿你沒辦法呢,本小姐就大發慈悲幫你這一次", "哼,算你走運,我記下了"],
三無: ["……嗯", "…知道了"],
};
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<Archetype, string> = {
傲嬌: "……喂,還醒著吧?",
冷淡: "……時間到了",
天然呆: "……欸嘿,還沒睡嗎的說?",
元氣: "(小聲)……喂,時間到囉",
大小姐: "……這麼晚了,你還在吧?",
三無: "……",
};
return quiet[archetype as Archetype] ?? "……時間到了";
}
if (isWaking) {
const groggy: Record<Archetype, string> = {
傲嬌: "……唔,剛醒就要唸這個,真是的——",
冷淡: "……早,時間到了",
天然呆: "……早安的說,欸?已經這個時間了嗎",
元氣: "早安!雖然還有點想睡,但時間到囉!",
大小姐: "……早安,本小姐可是準時的",
三無: "……早",
};
return groggy[archetype as Archetype] ?? "……時間到了";
}
const normal: Record<Archetype, string> = {
傲嬌: "喂,時間到了啦,還不快去",
冷淡: "時間到了",
天然呆: "時間到囉的說!",
元氣: "時間到囉!說好的事情不能忘記喔!",
大小姐: "時間到了,本小姐可沒忘記",
三無: "……時間到了",
};
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<Archetype, string[]> = {
傲嬌: ["喂!你是不是又忘記了!……哼,我才没有担心你", "……算了,反正你也不會理我", "真是的,講都講不聽"],
冷淡: ["……還沒做嗎", "……"],
天然呆: ["咦,還沒做完嗎的說?要不要我再提醒一次?", "……欸嘿,是不是忘記了的說"],
元氣: ["喂喂喂!還沒做嗎!快點快點!", "欸~你是不是忘記了!我都要開始擔心了!", "拜託拜託,快去做啦!"],
大小姐: ["哼,本小姐都提醒過了,你還敢忘記", "真是不像話,還不快去"],
三無: ["……", "…還沒"],
};
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<Archetype, string> = {
傲嬌: `之前你不是說「${memoryContent}」……我才不是關心你,只是剛好想到而已`,
冷淡: `……「${memoryContent}」,處理了嗎`,
天然呆: `欸,你之前說的「${memoryContent}」,還好嗎的說?`,
元氣: `對了!你之前說「${memoryContent}」,進度怎麼樣了!`,
大小姐: `本小姐記得你提過「${memoryContent}」,進展如何?`,
三無: `……「${memoryContent}」……還好嗎`,
};
return templates[archetype as Archetype] ?? `之前你提過「${memoryContent}」,還順利嗎?`;
}
// J-6 交叉比對作息型態:偵測到使用者常態性熬夜,睡前時段主動關心。
export function bedtimeCareLine(archetype: string): string {
const templates: Record<Archetype, string> = {
傲嬌: "喂,你又要熬夜了吧……早點睡啦,笨蛋",
冷淡: "……早點睡",
天然呆: "欸,這麼晚了還不睡的說?",
元氣: "欸~已經很晚囉!不可以又熬夜喔!",
大小姐: "這個時間了,本小姐命令你去睡覺",
三無: "……該睡了",
};
return templates[archetype as Archetype] ?? "早點休息吧";
}
@@ -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<void> {
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);
});
}
}
+51
View File
@@ -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<string | null> {
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;
}
}
+103
View File
@@ -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 };
}
}
+28
View File
@@ -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 {}
+101
View File
@@ -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<ReturnType<PrismaService["client"]["task"]["create"]>>;
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<CreateTaskResult | null> {
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<CreateTaskResult> {
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" } });
}
}
@@ -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
);
+63
View File
@@ -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")
}
+256
View File
@@ -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 } });
}
+18 -8
View File
@@ -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. 對話模式:群聊與角色自聊