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>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* by_time ledgers default to the full engine candidate set (≤64).
|
||||
* If a previous compare of that set exceeded the budget, the next score
|
||||
* may pass only still-active inference minutes (BUG-632).
|
||||
*/
|
||||
|
||||
export const COLUMN_COMPARE_BUDGET_MS = 3000;
|
||||
export const MAX_COLUMN_COMPARE_TIMES = 64;
|
||||
const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
export function columnTimesForSlowCompare(input: {
|
||||
previousColumnCompareMs: number | null | undefined;
|
||||
activeTimes: readonly string[] | null | undefined;
|
||||
}): string[] | undefined {
|
||||
if (
|
||||
input.previousColumnCompareMs == null
|
||||
|| !Number.isFinite(input.previousColumnCompareMs)
|
||||
|| input.previousColumnCompareMs <= COLUMN_COMPARE_BUDGET_MS
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const seen: string[] = [];
|
||||
for (const raw of input.activeTimes ?? []) {
|
||||
const clock = raw.slice(0, 5);
|
||||
if (!CLOCK.test(clock) || seen.includes(clock)) continue;
|
||||
seen.push(clock);
|
||||
if (seen.length >= MAX_COLUMN_COMPARE_TIMES) break;
|
||||
}
|
||||
return seen.length > 0 ? seen : undefined;
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
/**
|
||||
* 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 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 }
|
||||
@@ -20,21 +26,49 @@ export function parseDeclaredBirthWindow(message: string): DeclaredBirthWindow |
|
||||
if (range) {
|
||||
const start = normalizeClock(range[1] ?? "");
|
||||
const end = normalizeClock(range[2] ?? "");
|
||||
if (start && end && start !== end) return { kind: "range", start, end };
|
||||
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) return { kind: "around", time };
|
||||
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 match = /([01]?\d|2[0-3])\s*[::点]\s*([0-5]?\d)/.exec(raw);
|
||||
if (!match) return null;
|
||||
const hour = Number(match[1]);
|
||||
const minute = Number(match[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 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`;
|
||||
}
|
||||
|
||||
@@ -518,6 +518,7 @@ export function engineRequestBody(input: {
|
||||
candidateRange: { start_time: string; end_time: string };
|
||||
events: readonly V9EngineEvent[];
|
||||
askedProbeKeys?: readonly string[];
|
||||
columnTimes?: readonly string[];
|
||||
}): Record<string, unknown> {
|
||||
const snapshot = input.baselineBirthSnapshot;
|
||||
const birthDate = String(snapshot.birth_date ?? "");
|
||||
@@ -531,6 +532,11 @@ export function engineRequestBody(input: {
|
||||
throw new RectificationEngineError("no_scorable_evidence", "no scorable evidence for the engine");
|
||||
}
|
||||
const askedProbeKeys = sanitizeAskedProbeKeysForEngine(input.askedProbeKeys);
|
||||
const columnTimes = [...new Set(
|
||||
(input.columnTimes ?? [])
|
||||
.map((value) => value.slice(0, 5))
|
||||
.filter((value) => timePattern.test(value)),
|
||||
)].slice(0, 64);
|
||||
return {
|
||||
birth_date: birthDate,
|
||||
start_time: input.candidateRange.start_time,
|
||||
@@ -546,6 +552,7 @@ export function engineRequestBody(input: {
|
||||
timezone_source: snapshot.timezone_source,
|
||||
local_time_status: snapshot.local_time_status,
|
||||
...(askedProbeKeys.length ? { asked_probe_keys: askedProbeKeys } : {}),
|
||||
...(columnTimes.length ? { column_times: columnTimes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -659,6 +666,7 @@ export async function runV9CandidateScore(input: {
|
||||
candidateRange: { start_time: string; end_time: string };
|
||||
events: readonly V9EngineEvent[];
|
||||
askedProbeKeys?: readonly string[];
|
||||
columnTimes?: readonly string[];
|
||||
}): Promise<V9EngineScoreResult> {
|
||||
const data = await postEngine("/api/rectification/v5/score", engineRequestBody(input));
|
||||
const candidates = readCandidates(data.candidate_decisions, input.candidateRange);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
buildCaseInferenceState,
|
||||
previousInferenceFromReceipt,
|
||||
} from "./inference-adapter.ts";
|
||||
import { columnTimesForSlowCompare } from "./column-times-for-compare.ts";
|
||||
import { refinementFromDecisionReceipt } from "./refinement-packet.ts";
|
||||
import { blockScanRequestExtras } from "./search-window.ts";
|
||||
import {
|
||||
@@ -360,11 +361,23 @@ export async function scoreAndPersistCurrentEvidence(input: {
|
||||
};
|
||||
}
|
||||
const scoreStarted = Date.now();
|
||||
const latestReceipt = dossier.latestResult?.decisionReceipt;
|
||||
const previousCompareMs = typeof latestReceipt?.column_compare_ms === "number"
|
||||
? latestReceipt.column_compare_ms
|
||||
: null;
|
||||
const previousInference = previousInferenceFromReceipt(latestReceipt);
|
||||
const columnTimes = columnTimesForSlowCompare({
|
||||
previousColumnCompareMs: previousCompareMs,
|
||||
activeTimes: previousInference?.candidates
|
||||
.filter((candidate) => candidate.status === "active")
|
||||
.map((candidate) => candidate.time) ?? [],
|
||||
});
|
||||
const score = await runV9CandidateScore({
|
||||
baselineBirthSnapshot: compute.baselineBirthSnapshot,
|
||||
candidateRange,
|
||||
events,
|
||||
askedProbeKeys,
|
||||
columnTimes,
|
||||
});
|
||||
const engineCompareMs = Date.now() - scoreStarted;
|
||||
const vedastroStarted = Date.now();
|
||||
|
||||
Reference in New Issue
Block a user