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 { PersonalityModule } from "./personality/personality.module.js";
|
||||||
import { ChatModule } from "./chat/chat.module.js";
|
import { ChatModule } from "./chat/chat.module.js";
|
||||||
import { ScheduleModule } from "./schedule/schedule.module.js";
|
import { ScheduleModule } from "./schedule/schedule.module.js";
|
||||||
|
import { TaskModule } from "./task/task.module.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -21,6 +22,7 @@ import { ScheduleModule } from "./schedule/schedule.module.js";
|
|||||||
PersonalityModule,
|
PersonalityModule,
|
||||||
ChatModule,
|
ChatModule,
|
||||||
ScheduleModule,
|
ScheduleModule,
|
||||||
|
TaskModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
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" } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -58,6 +58,23 @@ enum ScheduleSlotStatus {
|
|||||||
FREE // 空閒
|
FREE // 空閒
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// J-1 委託類型
|
||||||
|
enum TaskType {
|
||||||
|
ONE_TIME // 單次提醒
|
||||||
|
RECURRING // 週期提醒
|
||||||
|
CONDITIONAL // 條件提醒
|
||||||
|
QUERY_ORGANIZE // 查詢整理
|
||||||
|
TODO_TRACKING // 代辦追蹤
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TaskStatus {
|
||||||
|
PENDING // 尚未觸發
|
||||||
|
TRIGGERED // 已觸發,等候使用者回應
|
||||||
|
COMPLETED // 已完成
|
||||||
|
FAILED // 執行失敗
|
||||||
|
CANCELLED // 已取消
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
@@ -66,6 +83,7 @@ model User {
|
|||||||
|
|
||||||
relationships Relationship[]
|
relationships Relationship[]
|
||||||
relatedMemories EpisodicMemory[]
|
relatedMemories EpisodicMemory[]
|
||||||
|
tasks Task[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -114,6 +132,8 @@ model Character {
|
|||||||
scheduleSlots ScheduleSlot[]
|
scheduleSlots ScheduleSlot[]
|
||||||
specialDays SpecialDay[]
|
specialDays SpecialDay[]
|
||||||
scheduleExceptions ScheduleExceptionLog[]
|
scheduleExceptions ScheduleExceptionLog[]
|
||||||
|
tasks Task[]
|
||||||
|
proactiveCareLogs ProactiveCareLog[]
|
||||||
|
|
||||||
@@map("characters")
|
@@map("characters")
|
||||||
}
|
}
|
||||||
@@ -270,3 +290,46 @@ model SentimentLedgerEntry {
|
|||||||
|
|
||||||
@@map("sentiment_ledger_entries")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
}
|
||||||
@@ -265,14 +265,24 @@ flowchart TB
|
|||||||
|
|
||||||
### J. 委託子系統
|
### J. 委託子系統
|
||||||
|
|
||||||
- [ ] **J-1 任務資料表與解析(S)**:`Task`(類型=單次/週期/條件/查詢整理/代辦追蹤、觸發時間或條件、內容、狀態),並實作自然語句解析成任務。驗收:「三點提醒我開會」建立正確的單次提醒。依據:§委託類型表。
|
- [x] **J-1 任務資料表與解析(S)**:`Task`(類型=單次/週期/條件/查詢整理/代辦追蹤、觸發時間或條件、內容、狀態),並實作自然語句解析成任務。驗收:「三點提醒我開會」建立正確的單次提醒。依據:§委託類型表。
|
||||||
- [ ] **J-2 接案演出層(S)**:依性格原型輸出接案台詞(傲嬌嘴硬、元氣熱情、冷淡一個「嗯」),但引擎層必定接受任務——拒絕只是演出。驗收:六原型各有對應台詞且任務皆成功建立。依據:§接案的角色演出「該接的最後都會接」。
|
- [x] **J-2 接案演出層(S)**:依性格原型輸出接案台詞(傲嬌嘴硬、元氣熱情、冷淡一個「嗯」),但引擎層必定接受任務——拒絕只是演出。驗收:六原型各有對應台詞且任務皆成功建立。依據:§接案的角色演出「該接的最後都會接」。
|
||||||
- [ ] **J-3 觸發演出(S)**:提醒觸發時語氣隨作息(深夜壓低、早晨迷糊),內容帶記憶前因後果(「資料你昨天說還沒做完,帶了嗎?」)。驗收:觸發訊息含相關記憶引用。依據:§提醒的觸發演出「提醒不是鬧鐘,是記得前因後果的人在提醒你」。
|
- [x] **J-3 觸發演出(S)**:提醒觸發時語氣隨作息(深夜壓低、早晨迷糊),內容帶記憶前因後果(「資料你昨天說還沒做完,帶了嗎?」)。驗收:觸發訊息含相關記憶引用。依據:§提醒的觸發演出「提醒不是鬧鐘,是記得前因後果的人在提醒你」。
|
||||||
- [ ] **J-4 逾期追擊(S)**:使用者無反應時依性格追擊(元氣連環、冷淡隔十分鐘補一句、傲嬌口是心非再傳一次)。驗收:模擬無回應後產生符合原型的追擊訊息。依據:§提醒的觸發演出「逾期追擊」。
|
- [x] **J-4 逾期追擊(S)**:使用者無反應時依性格追擊(元氣連環、冷淡隔十分鐘補一句、傲嬌口是心非再傳一次)。驗收:模擬無回應後產生符合原型的追擊訊息。依據:§提醒的觸發演出「逾期追擊」。
|
||||||
- [ ] **J-5 委託回饋迴路(S)**:每次委託與完成寫入關係帳本(正向事件),被感謝提升情緒、被忽略產生小失落;重複同類委託形成默契旗標。驗收:完成委託後親密度與情緒皆有變化。依據:§任務與記憶、關係的迴路。
|
- [x] **J-5 委託回饋迴路(S)**:每次委託與完成寫入關係帳本(正向事件),被感謝提升情緒、被忽略產生小失落;重複同類委託形成默契旗標。驗收:完成委託後親密度與情緒皆有變化。依據:§任務與記憶、關係的迴路。
|
||||||
- [ ] **J-6 主動關照(S)**:偵測使用者待辦訊號並追蹤、作息交叉比對(常熬夜 → 到點主動出現),且頻率克制、不重複同一句。驗收:連續觸發時受頻率上限抑制。依據:§主動關照:沒被委託的提醒。
|
- [x] **J-6 主動關照(S)**:偵測使用者待辦訊號並追蹤、作息交叉比對(常熬夜 → 到點主動出現),且頻率克制、不重複同一句。驗收:連續觸發時受頻率上限抑制。依據:§主動關照:沒被委託的提醒。
|
||||||
- [ ] **J-7 排程器可靠性(M)**:走 C-3 的 `JobQueue`,時間到必觸發(角色設定的迷糊只能表現在演出,不得真的忘記);服務重啟後未觸發的任務仍會被重新排入。驗收:建立提醒後重啟服務,時間到仍準時觸發。依據:§分層:任務引擎與角色演出「排程與執行走確定性系統」。
|
- [x] **J-7 排程器可靠性(M)**:走 C-3 的 `JobQueue`,時間到必觸發(角色設定的迷糊只能表現在演出,不得真的忘記);服務重啟後未觸發的任務仍會被重新排入。驗收:建立提醒後重啟服務,時間到仍準時觸發。依據:§分層:任務引擎與角色演出「排程與執行走確定性系統」。
|
||||||
- [ ] **J-V 階段驗證(XS)**:`npm run restart && npm run smoke -- J`(J.mjs:一分鐘後提醒必觸發、重啟後不遺失、演出符合原型、逾期追擊)。
|
- [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. 對話模式:群聊與角色自聊
|
### K. 對話模式:群聊與角色自聊
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user