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
+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 };
}