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" } });
}
}