212ff3ff80
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>
274 lines
8.0 KiB
JavaScript
274 lines
8.0 KiB
JavaScript
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() };
|
||
}
|