Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/declared-window-utterance.ts
T
Jesse_ChenandCursor 719ff55a09
Independent Staging Quality Gate / validate (push) Successful in 12m36s
Independent Staging Quality Gate / publish (push) Successful in 2m11s
fix(rectification): keep clock-stamped events out of window intercept (BUG-631, BUG-632)
Dated life events with clock times were swallowed as birth-window replies, and compare columns still stopped at the first nine candidates after the hour-window cap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 09:23:35 +08:00

75 lines
3.1 KiB
TypeScript
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.
/**
* Mid-session spoken birth-time windows are not a search-window change.
* Detect them so the route can answer with a fixed reply and skip the model.
*
* Clock-shaped text is not enough (BUG-631): dated life events and other
* non-birth sentences that happen to contain HH:MM must still reach the model.
*/
const CLOCK = /(?:[01]?\d|2[0-3])\s*[::点]\s*(?:半|[0-5]?\d(?:\s*分)?)?/;
const RANGE_SEP = /\s*(?:到|至|[-–—~])\s*/;
const AROUND = /\s*(?:左右|前后)/;
const RANGE_PATTERN = new RegExp(`(${CLOCK.source})${RANGE_SEP.source}(${CLOCK.source})`);
const AROUND_PATTERN = new RegExp(`(${CLOCK.source})${AROUND.source}`);
const BIRTH_CONTEXT = /出生时间|出生|生于|时辰|几点生|钟点是|(?<![发产陌])生的/;
const CALENDAR_DATE = /\d{4}\s*年|\d{1,2}\s*月|\d{1,2}\s*日/;
const BARE_FILLER = /上午|下午|傍晚|夜里|晚上|清晨|中午|我的|我|是|大概|大约|差不多|可能|就|在|的|了|吧|啊|呢|嗯|[,。,.、\s]|[-–—~]/g;
export type DeclaredBirthWindow =
| { kind: "range"; start: string; end: string }
| { kind: "around"; time: string };
export function parseDeclaredBirthWindow(message: string): DeclaredBirthWindow | null {
const text = message.trim();
if (!text) return null;
const range = RANGE_PATTERN.exec(text);
if (range) {
const start = normalizeClock(range[1] ?? "");
const end = normalizeClock(range[2] ?? "");
if (start && end && start !== end && shouldInterceptDeclaredWindow(text, range)) {
return { kind: "range", start, end };
}
if (start && end && start !== end) return null;
}
const around = AROUND_PATTERN.exec(text);
if (around) {
const time = normalizeClock(around[1] ?? "");
if (time && shouldInterceptDeclaredWindow(text, around)) {
return { kind: "around", time };
}
}
return null;
}
function shouldInterceptDeclaredWindow(text: string, match: RegExpExecArray): boolean {
if (BIRTH_CONTEXT.test(text)) return true;
if (CALENDAR_DATE.test(text)) return false;
return isBareClockUtterance(text, match);
}
function isBareClockUtterance(text: string, match: RegExpExecArray): boolean {
const leftover = `${text.slice(0, match.index)}${text.slice(match.index + match[0].length)}`;
return leftover.replace(BARE_FILLER, "") === "";
}
function normalizeClock(raw: string): string | null {
const half = /([01]?\d|2[0-3])\s*[::点]\s*半/.exec(raw);
if (half) {
const hour = Number(half[1]);
if (!Number.isInteger(hour) || hour > 23) return null;
return `${String(hour).padStart(2, "0")}:30`;
}
const withMinutes = /([01]?\d|2[0-3])\s*[::点]\s*([0-5]?\d)/.exec(raw);
if (withMinutes) {
const hour = Number(withMinutes[1]);
const minute = Number(withMinutes[2]);
if (!Number.isInteger(hour) || hour > 23 || !Number.isInteger(minute) || minute > 59) return null;
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
}
const hourOnly = /([01]?\d|2[0-3])\s*点/.exec(raw);
if (!hourOnly) return null;
const hour = Number(hourOnly[1]);
if (!Number.isInteger(hour) || hour > 23) return null;
return `${String(hour).padStart(2, "0")}:00`;
}