feat: 完成 J 群組 — 委託子系統
Task 資料表與自然語句解析(單次提醒)、依原型的接案/觸發/逾期追擊演出、 委託與關係帳本/情緒的回饋迴路(含默契旗標)、主動關照(待辦偵測+作息交叉比對, 稀有度節流不重複台詞)、排程器可靠性(服務重啟後重新掃描 PENDING 任務掛回 JobQueue,逾期立即補觸發)。J.mjs 以 spawnSync 實際重啟服務驗證 J-7。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d82b834e47
commit
3503cc0be6
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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" } });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user