fix(rectification): stop speaking unstampable distinguish stems (BUG-674/675)
Independent Staging Quality Gate / validate (push) Failing after 20s
Independent Staging Quality Gate / publish (push) Skipped

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).
This commit is contained in:
jesse-ux
2026-09-14 03:42:48 +08:00
parent 9375012f17
commit 69ffa07d6b
16 changed files with 887 additions and 46 deletions
@@ -69,6 +69,7 @@ import {
caseStageFromSnapshot,
interviewCollectWaiting,
interviewChoiceCardUnavailable,
persistedQuestionSurface,
interviewStopReasonFromSnapshot,
interviewSessionOutcomeFromSnapshot,
type RectificationCaseSnapshotPayload,
@@ -1868,7 +1869,27 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{questionGap === "persisted_question" && currentQuestion?.prompt && (
<div className="rectification-message-wrap rectification-message-entry" data-testid="persisted-question">
<div className="rectification-message-question">
<p className="rectification-message-question__prompt">{currentQuestion.prompt}</p>
{persistedQuestionSurface({
questionKind: currentQuestion.kind,
hasChoiceCard: Boolean(choiceCard),
questionPersisted: true,
}) === "choice_card" && choiceCard ? (
<>
<p className="rectification-message-question__prompt">{currentQuestion.prompt}</p>
<RectificationChoiceCard
key={`${choiceCard.question_id}:${choiceNonce}:persisted`}
variant="embedded"
card={choiceCard}
pending={busy}
disabled={busy || readonly}
selectedKey=""
onSelect={submitChoice}
onStop={submitStop}
/>
</>
) : (
<p className="rectification-message-question__prompt">{currentQuestion.prompt}</p>
)}
</div>
</div>
)}
@@ -2,6 +2,7 @@ import { applyProbeOutcome, outcomeByMinuteForAnswer } from "./apply-probe-outco
import type { TransitionSignLookup } from "./sign-from-transitions.ts";
import { clusterRangeFor, clusterEquivalentCandidates } from "./cluster-candidates.ts";
import { evaluateConvergence, holdoutStillRanksFirst, rankActive } from "./convergence-evaluator.ts";
import { warnRepresentativeTimeInconsistency } from "./representative-time-guard.ts";
import { rangeFromTimes, unionStillValidRange } from "./credible-range.ts";
import { entropyFromScores, normalizeScores } from "./entropy.ts";
import { selectHighestGainProbe } from "./select-probe.ts";
@@ -197,6 +198,14 @@ export function buildInferenceState(input: {
...(transitions ? { transitions } : {}),
};
const decision = evaluateConvergence({ ...draft, holdout_passed: holdoutPassed });
const lastRound = rounds.at(-1) ?? null;
warnRepresentativeTimeInconsistency({
representativeTime: decision.representative_time,
eliminatedIds: lastRound?.eliminated_ids,
credibleRange: decision.credible_range ?? draft.credible_range,
winnerId: lastRound?.winner_id,
scoresAfter: lastRound?.scores_after,
});
return {
...draft,
phase: phaseFor(decision.result_status, draft.phase),
@@ -0,0 +1,77 @@
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;
}
@@ -924,6 +924,7 @@ export async function persistNextInterviewAfterChoice(input: {
narrateAdopt?: AdoptNarrationWriter;
skipRefresh?: boolean;
continueTieBreakRound?: boolean;
followup?: MethodFollowup | null;
}): Promise<{
hostNarration: string;
choiceReady: boolean;
@@ -1010,7 +1011,7 @@ export async function persistNextInterviewAfterChoice(input: {
}
}
const plan = planWithDateReliability(buildMethodFollowupPlan(planInput), liveDossier.evidence, input.askedTurnId);
const followup = interviewToPersist(plan);
const followup = input.followup !== undefined ? input.followup : interviewToPersist(plan);
if (shouldSkipFollowupPersist({
canAdopt: nextAction.can_adopt,
nextAction: nextAction.type,
@@ -1048,6 +1049,15 @@ export async function persistNextInterviewAfterChoice(input: {
followup: null,
};
}
const exhaustion = {
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: liveDossier,
decision,
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
};
const persistedFocus = await persistFocusAfterChoice({
accounting: input.accounting,
userId: input.userId,
@@ -1068,6 +1078,9 @@ export async function persistNextInterviewAfterChoice(input: {
};
}
if (open?.kind === "collect_spoken" && open.prompt) {
if (stemAlreadyInLastAssistant(open.prompt, input.dossier)) {
return persistExhaustionCollect(exhaustion);
}
return {
hostNarration: open.prompt,
choiceReady: false,
@@ -1078,20 +1091,15 @@ export async function persistNextInterviewAfterChoice(input: {
};
}
if (followup && isTargetedCollectFollowup(followup) && followup.choice_frame) {
return persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: liveDossier,
decision,
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
return persistExhaustionCollect(exhaustion);
}
if (followup?.intent === "collect_method_evidence" && followup.choice_frame) {
const spokenFollowup = spokenCollectFallbackFollowup(followup);
const spoken = spokenFollowupForUser(spokenFollowup, liveDossier.evidence);
if (spoken) {
if (stemAlreadyInLastAssistant(spoken, input.dossier)) {
return persistExhaustionCollect(exhaustion);
}
const fallback = await persistFocusAfterChoice({
accounting: input.accounting,
userId: input.userId,
@@ -1121,17 +1129,12 @@ export async function persistNextInterviewAfterChoice(input: {
spoken
&& (persistedFocus.status === "created" || persistedFocus.status === "already_open")
) {
if (stemAlreadyInLastAssistant(spoken, input.dossier)) {
return persistExhaustionCollect(exhaustion);
}
return { hostNarration: spoken, choiceReady: false, focus: persistedFocus.focus, followup };
}
return persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: liveDossier,
decision,
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
return persistExhaustionCollect(exhaustion);
}
if (!followup) {
if (input.dossier.case.acceptedTime) {
@@ -1141,15 +1144,13 @@ export async function persistNextInterviewAfterChoice(input: {
followup: null,
};
}
return persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: liveDossier,
decision,
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
return persistExhaustionCollect(exhaustion);
}
if (
followup.intent === "distinguish_candidates"
|| (followup.choice_frame && followup.intent !== "collect_method_evidence")
) {
return persistExhaustionCollect(exhaustion);
}
const spoken = (spokenFollowupForUser(followup) ?? "").trim();
if (!spoken) {
@@ -1160,15 +1161,10 @@ export async function persistNextInterviewAfterChoice(input: {
followup: null,
};
}
return persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: liveDossier,
decision,
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
return persistExhaustionCollect(exhaustion);
}
if (stemAlreadyInLastAssistant(spoken, input.dossier)) {
return persistExhaustionCollect(exhaustion);
}
return {
hostNarration: spoken,
@@ -1178,6 +1174,23 @@ export async function persistNextInterviewAfterChoice(input: {
};
}
function stemAlreadyInLastAssistant(
stem: string,
dossier: { turns?: readonly { role?: string; text?: string | null }[] },
): boolean {
const needle = stem.trim();
if (needle.length < 8) return false;
const turns = dossier.turns ?? [];
for (let index = turns.length - 1; index >= 0; index -= 1) {
const turn = turns[index];
if (turn?.role !== "assistant") continue;
const text = typeof turn.text === "string" ? turn.text : "";
if (!text.trim()) continue;
return text.includes(needle);
}
return false;
}
export async function requestTieBreakPersonality(input: {
accounting: AccountingClient;
userId: string;
@@ -1747,6 +1760,7 @@ async function persistExhaustionCollect(input: {
choiceReady: boolean;
hostNarration: string;
focus?: ConversationFocus | null;
followup?: MethodFollowup | null;
terminalNote?: boolean;
}> {
let dossier = input.dossier as DecisionDossier;
@@ -1833,6 +1847,7 @@ async function persistExhaustionCollect(input: {
choiceReady: true,
hostNarration: targetedCollectHostNarration(followup, open.prompt),
focus: persistedFocus.focus,
followup,
};
}
const spoken = followup.choice_frame
@@ -26,6 +26,7 @@ import {
type RectificationDecision,
} from "../core/rectification-decision.ts";
import { evaluateCandidateSeparation } from "../core/candidate-separation.ts";
import { warnRepresentativeTimeInconsistency } from "../core/representative-time-guard.ts";
import type { ConflictProbe, InferenceState } from "../core/types.ts";
import {
askedDiscriminatorKeys,
@@ -1031,18 +1032,19 @@ export function overlayPublicDecision<T extends object>(
completionStatus: ReturnType<typeof publicDecisionFields>["completion_status"];
} {
const fields = publicDecisionFields(decision);
const receipt = (snapshot as DecisionDossier["latestResult"])?.decisionReceipt ?? null;
const inference = previousInferenceFromReceipt(receipt);
const round = inference?.last_inference_round ?? null;
const projection = authoritativeCandidateProjection(snapshot as DecisionDossier["latestResult"]);
const inconsistent = !projection.consistent;
const ceiling = engineCapabilityCeilingFromReceipt(
(snapshot as DecisionDossier["latestResult"])?.decisionReceipt ?? null,
);
const ceiling = engineCapabilityCeilingFromReceipt(receipt);
const canAdopt = !inconsistent && fields.can_adopt && ceiling.acceptanceAllowed;
const selectionAllowed = !inconsistent && fields.selection_allowed && ceiling.selectionAllowed;
const proposeAllowed = !inconsistent && fields.propose_allowed && ceiling.proposeAllowed;
const canConfirmExactMinute = !inconsistent
&& fields.can_confirm_exact_minute
&& ceiling.confirmationAllowed;
return {
const overlaid = {
...snapshot,
...fields,
can_adopt: canAdopt,
@@ -1062,4 +1064,12 @@ export function overlayPublicDecision<T extends object>(
validated: fields.validated,
completionStatus: fields.completion_status,
};
warnRepresentativeTimeInconsistency({
representativeTime: overlaid.representative_time,
eliminatedIds: round?.eliminated_ids,
credibleRange: overlaid.credible_range ?? inference?.credible_range,
winnerId: round?.winner_id,
scoresAfter: round?.scores_after,
});
return overlaid;
}
@@ -104,10 +104,20 @@ export function attachQuestionsToTurns<T extends { id: string; role: string; tex
focuses: readonly ConversationFocus[],
): Array<T & { question: TurnQuestion | null; text: string | null }> {
const byTurn = new Map<string, ConversationFocus>();
const hanging: ConversationFocus[] = [];
for (const focus of focuses) {
if (!focus.askedTurnId) continue;
if (!focus.askedTurnId) {
if (focus.status === "active" && turnQuestionKind(focus) === "choice") {
hanging.push(focus);
}
continue;
}
byTurn.set(focus.askedTurnId, focus);
}
const lastAssistant = [...turns].reverse().find((turn) => turn.role === "assistant");
if (hanging[0] && lastAssistant && !byTurn.has(lastAssistant.id)) {
byTurn.set(lastAssistant.id, hanging[0]);
}
return turns.map((turn) => {
if (turn.role !== "assistant") return { ...turn, question: null };
const focus = byTurn.get(turn.id);
@@ -347,6 +347,16 @@ export function interviewSessionOutcomeFromSnapshot(payload: RectificationCaseSn
return null;
}
export function persistedQuestionSurface(input: Readonly<{
questionKind?: string | null;
hasChoiceCard: boolean;
questionPersisted: boolean;
}>): "none" | "spoken" | "choice_card" {
if (!input.questionPersisted) return "none";
if (input.questionKind === "choice" && input.hasChoiceCard) return "choice_card";
return "spoken";
}
export function interviewChoiceCardUnavailable(input: Readonly<{
questionKind?: string | null;
hasChoiceCard: boolean;