Distinguish followups that cannot stamp a probe now go to persistExhaustionCollect instead of repeating the card stem in the assistant body. Choice focuses without asked_turn_id hang on the last assistant turn, and persisted_question with a live choice_card renders the existing card. Representative-time inconsistency is warn-only (BUG-676 investigating).
78 lines
2.8 KiB
TypeScript
78 lines
2.8 KiB
TypeScript
export type RepresentativeTimeGuardInput = Readonly<{
|
|
representativeTime: string | null | undefined;
|
|
eliminatedIds?: readonly string[] | null;
|
|
credibleRange?: readonly string[] | null;
|
|
winnerId?: string | null;
|
|
scoresAfter?: Readonly<Record<string, number>> | null;
|
|
}>;
|
|
|
|
export type RepresentativeTimeGuardResult = Readonly<{
|
|
ok: boolean;
|
|
inEliminated: boolean;
|
|
outsideRange: boolean;
|
|
}>;
|
|
|
|
function clockMinutes(value: string): number | null {
|
|
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(value.trim());
|
|
if (!match) return null;
|
|
return Number(match[1]) * 60 + Number(match[2]);
|
|
}
|
|
|
|
function outsideCredibleRange(time: string, range: readonly string[]): boolean {
|
|
if (range.length === 0) return false;
|
|
if (range.includes(time)) return false;
|
|
if (range.length < 2) return true;
|
|
const start = clockMinutes(range[0] ?? "");
|
|
const end = clockMinutes(range[range.length - 1] ?? "");
|
|
const point = clockMinutes(time);
|
|
if (start === null || end === null || point === null) return true;
|
|
if (start <= end) return point < start || point > end;
|
|
return point < start && point > end;
|
|
}
|
|
|
|
function scoreExtremes(scores: Readonly<Record<string, number>> | null | undefined): {
|
|
max?: readonly [string, number];
|
|
min?: readonly [string, number];
|
|
} {
|
|
let max: readonly [string, number] | undefined;
|
|
let min: readonly [string, number] | undefined;
|
|
for (const [time, score] of Object.entries(scores ?? {})) {
|
|
if (!Number.isFinite(score)) continue;
|
|
if (!max || score > max[1]) max = [time, score];
|
|
if (!min || score < min[1]) min = [time, score];
|
|
}
|
|
return { max, min };
|
|
}
|
|
|
|
export function inspectRepresentativeTime(input: RepresentativeTimeGuardInput): RepresentativeTimeGuardResult {
|
|
const time = typeof input.representativeTime === "string" ? input.representativeTime.trim() : "";
|
|
if (!time) {
|
|
return { ok: true, inEliminated: false, outsideRange: false };
|
|
}
|
|
const inEliminated = (input.eliminatedIds ?? []).includes(time);
|
|
const outsideRange = outsideCredibleRange(time, input.credibleRange ?? []);
|
|
return {
|
|
ok: !inEliminated && !outsideRange,
|
|
inEliminated,
|
|
outsideRange,
|
|
};
|
|
}
|
|
|
|
export function warnRepresentativeTimeInconsistency(input: RepresentativeTimeGuardInput): RepresentativeTimeGuardResult {
|
|
const result = inspectRepresentativeTime(input);
|
|
if (result.ok) return result;
|
|
const extremes = scoreExtremes(input.scoresAfter);
|
|
console.warn(JSON.stringify({
|
|
event: "rectification_representative_time_inconsistent",
|
|
winner_id: input.winnerId ?? null,
|
|
representative_time: input.representativeTime ?? null,
|
|
credible_range: input.credibleRange ?? null,
|
|
eliminated_ids: input.eliminatedIds ?? [],
|
|
scores_after_max: extremes.max ?? null,
|
|
scores_after_min: extremes.min ?? null,
|
|
in_eliminated: result.inEliminated,
|
|
outside_range: result.outsideRange,
|
|
}));
|
|
return result;
|
|
}
|