// Recency-intent detection for the retrieval layer. // // Keyword search cannot answer a question about WHEN. bm25 ranks by term // overlap, so "what was the last mail I received" matches documents that // happen to contain the word "last", and "summarise everything from July" // matches documents containing "July" — not documents dated in July. Both were // asked by a real user against a correctly-populated index and both returned // nothing useful, which is what this module exists to fix: it decides when a // question is really a date question, and turns it into a date RANGE the index // can answer with an ordered scan over `occurred_at`. // // Deliberately a heuristic on English/German keywords rather than an LLM call: // it runs on every question, must be instant, and a false positive is cheap // (the recency hits are fused with the keyword hits, not substituted for them). // TIMEZONE NOTE: all bounds are built from LOCAL calendar boundaries and then // serialised as UTC instants. That is deliberate — "July" means the user's // July, so a mail received 00:30 local on 1 July belongs to it even though its // stored UTC timestamp reads 30 June. Building the bounds in UTC instead would // silently drop the first/last hours of every named period for anyone not on // UTC. export interface RecencyIntent { /** ISO lower bound, if the question named one. */ since?: string; /** ISO upper bound, if the question named a closed period. */ until?: string; /** How many documents the recency leg should contribute. */ limit: number; } const RECENCY_WORDS = [ // English 'last', 'latest', 'recent', 'recently', 'newest', 'new', 'today', 'yesterday', 'this week', 'this month', 'past week', 'past month', 'so far', 'just now', 'current', // German — the app ships a German UI and users mix languages freely 'letzte', 'letzten', 'letzter', 'neueste', 'neuesten', 'neu', 'heute', 'gestern', 'diese woche', 'diesen monat', 'kürzlich', 'zuletzt', 'aktuell', ]; const MONTHS: Record = { january: 0, february: 1, march: 2, april: 3, may: 4, june: 5, july: 6, august: 7, september: 8, october: 9, november: 10, december: 11, januar: 0, februar: 1, märz: 2, maerz: 2, mai: 4, juni: 5, juli: 6, oktober: 9, dezember: 11, }; function startOfDay(d: Date): Date { const c = new Date(d); c.setHours(0, 0, 0, 0); return c; } /** * @param now injected so the behaviour is testable and deterministic — the * month-name branch depends on "which year is it" and must not be a coin * flip in a test suite. */ export function detectRecencyIntent(question: string, now: Date = new Date()): RecencyIntent | null { const q = question.toLowerCase(); // A named month wins over generic recency words: "everything from July" is a // bounded range, which is far more useful than "the newest N". for (const [name, monthIndex] of Object.entries(MONTHS)) { if (!new RegExp(`\\b${name}\\b`).test(q)) continue; // A month later than the current one must mean LAST year — "July" asked in // March means the July that already happened, not one nine months away. const year = monthIndex > now.getMonth() ? now.getFullYear() - 1 : now.getFullYear(); const since = new Date(year, monthIndex, 1, 0, 0, 0, 0); const until = new Date(year, monthIndex + 1, 1, 0, 0, 0, 0); return { since: since.toISOString(), until: until.toISOString(), limit: 40 }; } if (/\btoday\b|\bheute\b/.test(q)) { return { since: startOfDay(now).toISOString(), limit: 25 }; } if (/\byesterday\b|\bgestern\b/.test(q)) { const start = startOfDay(new Date(now.getTime() - 86_400_000)); return { since: start.toISOString(), until: startOfDay(now).toISOString(), limit: 25 }; } if (/this week|past week|diese woche|letzte woche/.test(q)) { return { since: startOfDay(new Date(now.getTime() - 7 * 86_400_000)).toISOString(), limit: 40 }; } if (/this month|past month|diesen monat|letzten monat/.test(q)) { return { since: startOfDay(new Date(now.getTime() - 30 * 86_400_000)).toISOString(), limit: 40 }; } if (RECENCY_WORDS.some((w) => (w.includes(' ') ? q.includes(w) : new RegExp(`\\b${w}\\b`).test(q)))) { // No period named — "the last mail", "what's new". Unbounded top-N. return { limit: 15 }; } return null; }