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