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>
75 lines
3.1 KiB
TypeScript
75 lines
3.1 KiB
TypeScript
/**
|
||
* 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`;
|
||
}
|