Files
memind/temporal-recall-service/event-time-extract.mjs
T
john 212ff3ff80 Add User Model Service and Temporal Recall for MeMind V0.1.
Introduce UMS ingest/snapshot pipeline, Context Planner with multi-source recall, runtime context injection, canonical user mapping, and session snapshot loading on auth/me.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 23:25:18 +08:00

274 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const TZ_OFFSET_MIN = Number(process.env.TEMPORAL_RECALL_TZ_OFFSET_MIN ?? 480);
/** @param {Date} d */
function toLocalParts(d) {
const shifted = new Date(d.getTime() + TZ_OFFSET_MIN * 60_000);
return {
year: shifted.getUTCFullYear(),
month: shifted.getUTCMonth(),
day: shifted.getUTCDate(),
};
}
/** @param {{ year: number, month: number, day: number }} p @param {number} h @param {number} m */
function partsToDate(p, h = 0, m = 0) {
const utcMs =
Date.UTC(p.year, p.month, p.day, h, m, 0, 0) - TZ_OFFSET_MIN * 60_000;
return new Date(utcMs);
}
/** @param {Date} anchor @param {number} deltaDays */
function addDays(anchor, deltaDays) {
const p = toLocalParts(anchor);
return partsToDate({ year: p.year, month: p.month, day: p.day + deltaDays }, 0, 0);
}
const WEEKDAY_MAP = {
: 1,
: 2,
: 3,
: 4,
: 5,
: 6,
: 7,
: 7,
};
function parseHour(text, hourRaw, minuteRaw) {
let hour = Number(hourRaw);
let minute = minuteRaw != null && minuteRaw !== '' ? Number(minuteRaw) : 0;
if (Number.isNaN(hour)) return null;
if (Number.isNaN(minute)) minute = 0;
if (/下午|晚上|傍晚/.test(text) && hour >= 1 && hour <= 11) hour += 12;
if (/中午/.test(text) && hour >= 1 && hour <= 10) hour += 12;
if (/凌晨/.test(text) && hour === 12) hour = 0;
if (hour === 24) hour = 0;
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
return { hour, minute };
}
function resolveDayOffset(text) {
if (/大后天/.test(text)) return 3;
if (/后天/.test(text)) return 2;
if (/明天|明日/.test(text)) return 1;
if (/今天|今日/.test(text)) return 0;
if (/昨天|昨日/.test(text)) return -1;
if (/前天/.test(text)) return -2;
return null;
}
function resolveWeekday(text, anchor) {
const m = text.match(/(下?周|这个星期|这星期|本周)?([一二三四五六日天])/);
if (!m) return null;
const target = WEEKDAY_MAP[m[2]];
if (!target) return null;
const p = toLocalParts(anchor);
const anchorDate = partsToDate(p, 0, 0);
const anchorDow = new Date(anchorDate.getTime() + TZ_OFFSET_MIN * 60_000).getUTCDay();
const anchorMonBased = anchorDow === 0 ? 7 : anchorDow;
let delta = target - anchorMonBased;
if (delta <= 0) delta += 7;
if (/下周/.test(m[1] ?? '')) delta += 7;
return addDays(anchor, delta);
}
function resolveMonthDay(text, anchor) {
const m = text.match(/(\d{1,2})\s*月\s*(\d{1,2})\s*日/);
if (!m) return null;
const month = Number(m[1]) - 1;
const day = Number(m[2]);
const p = toLocalParts(anchor);
let year = p.year;
if (month < p.month || (month === p.month && day < p.day)) year += 1;
return partsToDate({ year, month, day }, 0, 0);
}
const CN_DIGIT = {
: 0,
: 0,
: 1,
: 2,
: 2,
: 3,
: 4,
: 5,
: 6,
: 7,
: 8,
: 9,
: 10,
};
function parseChineseNumber(raw) {
const s = String(raw ?? '').trim();
if (!s) return null;
if (/^\d+$/.test(s)) return Number(s);
if (s === '十') return 10;
if (s.startsWith('十')) {
const tail = CN_DIGIT[s[1]];
return tail != null ? 10 + tail : null;
}
if (s.endsWith('十')) {
const head = CN_DIGIT[s[0]];
return head != null ? head * 10 : null;
}
if (s.includes('十')) {
const [a, b] = s.split('十');
const head = a ? CN_DIGIT[a] ?? null : 1;
const tail = b ? CN_DIGIT[b] ?? null : 0;
if (head == null || tail == null) return null;
return head * 10 + tail;
}
return CN_DIGIT[s] ?? null;
}
function findTimeInText(text) {
const cnPattern =
/(上午|早上|中午|下午|晚上|傍晚|凌晨)?\s*([零〇一二两三四五六七八九十]{1,3})\s*(?:点|时)\s*(?:([零〇一二两三四五六七八九十]{1,3})\s*分?)?/;
const cnMatch = text.match(cnPattern);
if (cnMatch) {
const hour = parseChineseNumber(cnMatch[2]);
const minute = cnMatch[3] ? parseChineseNumber(cnMatch[3]) : 0;
if (hour != null) {
const parsed = parseHour(text, hour, minute ?? 0);
if (parsed) return parsed;
}
}
const patterns = [
/(上午|早上|中午|下午|晚上|傍晚|凌晨)?\s*(\d{1,2})\s*(?:[::点时])\s*(\d{1,2})?\s*(?:分)?/,
/(\d{1,2})\s*[:]\s*(\d{2})/,
];
for (const re of patterns) {
const m = text.match(re);
if (!m) continue;
const parsed = parseHour(text, m[2] ?? m[1], m[3] ?? m[2]);
if (parsed) return parsed;
}
return null;
}
function hasFutureIntent(text) {
return /明天|明日|后天|大后天|下周|周一|周二|周三|周四|周五|周六|周日|星期|安排|预约|会议|截止|之前|前要/.test(
text,
);
}
/**
* 从中文文本抽取计划/事件发生时间(相对 observed_time 锚点)。
*
* @param {string} text
* @param {string | Date} observedAt
* @returns {{ event_time: string | null, confidence: number, status: 'planned' | 'mentioned' | 'unknown' }}
*/
export function extractEventTime(text, observedAt) {
const raw = String(text ?? '').trim();
if (!raw) {
return { event_time: null, confidence: 0, status: 'unknown' };
}
const anchor = observedAt instanceof Date ? observedAt : new Date(observedAt);
if (Number.isNaN(anchor.getTime())) {
return { event_time: null, confidence: 0, status: 'unknown' };
}
let baseDay = null;
let dayConfidence = 0;
const dayOffset = resolveDayOffset(raw);
if (dayOffset != null) {
baseDay = addDays(anchor, dayOffset);
dayConfidence = 0.88;
}
if (!baseDay) {
baseDay = resolveWeekday(raw, anchor);
if (baseDay) dayConfidence = 0.75;
}
if (!baseDay) {
baseDay = resolveMonthDay(raw, anchor);
if (baseDay) dayConfidence = 0.8;
}
const timePart = findTimeInText(raw);
if (baseDay && timePart) {
const p = toLocalParts(baseDay);
const dt = partsToDate(p, timePart.hour, timePart.minute);
return {
event_time: dt.toISOString(),
confidence: Math.min(0.95, dayConfidence + 0.1),
status: 'planned',
};
}
if (baseDay) {
const p = toLocalParts(baseDay);
return {
event_time: partsToDate(p, 9, 0).toISOString(),
confidence: dayConfidence * 0.85,
status: 'planned',
};
}
if (timePart && /今天|今日/.test(raw)) {
const p = toLocalParts(anchor);
return {
event_time: partsToDate(p, timePart.hour, timePart.minute).toISOString(),
confidence: 0.82,
status: 'planned',
};
}
if (hasFutureIntent(raw)) {
return { event_time: null, confidence: 0.35, status: 'mentioned' };
}
return { event_time: null, confidence: 0, status: 'unknown' };
}
/**
* 判断 timeline item 是否与查询时间窗相关(observed 或 event 命中)。
*/
export function itemMatchesTimeWindow(item, time, temporalMode) {
const mentionStart = new Date(time?.mention_range?.start ?? 0).getTime();
const mentionEnd = new Date(time?.mention_range?.end ?? 0).getTime();
const eventStart = time?.event_range?.start
? new Date(time.event_range.start).getTime()
: mentionStart;
const eventEnd = time?.event_range?.end
? new Date(time.event_range.end).getTime()
: mentionEnd;
const observed = new Date(item.observed_time).getTime();
const event = item.event_time ? new Date(item.event_time).getTime() : null;
const inMention = observed >= mentionStart && observed < mentionEnd;
const inEvent = event != null && event >= eventStart && event < eventEnd;
switch (temporalMode) {
case 'OCCURRED_IN':
return inEvent || (!event && inMention);
case 'MENTIONED_IN':
case 'CREATED_IN':
return inMention;
case 'PLANNED_IN':
case 'DUE_IN':
return inEvent || inMention;
default:
return inMention || inEvent;
}
}
/**
* 为 DB 检索扩展 observed 窗口(捕获「昨天说明天」类输入)。
*/
export function expandObservedFetchRange(time, temporalMode) {
const start = new Date(time?.mention_range?.start ?? Date.now());
const end = new Date(time?.mention_range?.end ?? Date.now());
if (['PLANNED_IN', 'OCCURRED_IN', 'AMBIGUOUS', 'DUE_IN'].includes(temporalMode ?? '')) {
start.setDate(start.getDate() - 14);
}
return { start: start.toISOString(), end: end.toISOString() };
}