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() }; }