fix(rectification): keep compare requests valid after style cards (BUG-577–580)

Engine asked_probe_keys no longer include varga split hashes that 400 the scorer, failed compares become visible and retry, user stop can still deliver a range on a stale snapshot, and holdout no longer reasks domains already in the ledger.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-07 15:46:33 +08:00
co-authored by Cursor
parent 3f4d38d485
commit 4e0db55f03
34 changed files with 1079 additions and 156 deletions
@@ -299,6 +299,7 @@ export async function POST(request: Request) {
userId,
caseId,
narrateAdopt,
userStopped: true,
});
const assistantMessage = idle.hostNarration || nonConvergingRangeNarration({ variant: "delivery" });
const turn = await persistV9DeterministicTurn(accounting, userId, caseId, {
@@ -282,15 +282,16 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
return askWindowWiden(separation, range);
}
if (userStopped && separation.ranked.length > 0) {
return completeWithRange(separation, holdout, range, "user_stopped", capability);
}
if (input.snapshotCurrent === false) {
if (probe && !userStopped && input.trainingGateOpen !== false) {
return discriminateOrExhaust(input, separation, holdout, range, probe, capability, stopReason);
}
return collect(separation, holdout, range, probe, capability, stopReason);
}
if (userStopped && separation.ranked.length > 0) {
return completeWithRange(separation, holdout, range, "user_stopped", capability);
}
if (coverageBlocks) {
const engineOffers = input.engineCeiling.acceptanceAllowed
|| input.engineCeiling.proposeAllowed;
@@ -87,8 +87,26 @@ export const RECTIFICATION_USER_COPY = {
lowDateQualityGate: "两件事的日期还没对清。",
noCandidatesGate: "当前还排不出可比较的候选时间。",
forceMinuteAfterSubBlocks: "时段分不开,直接按分钟比。",
compareFailedRetry: "候选比较这次没跑成,下一句话时会自动再试。",
lastSuccessfulCompareRange: "这是按上一次成功比较给出的范围。",
} as const;
export function withCompareFailedRetryNotice(body: string): string {
const notice = RECTIFICATION_USER_COPY.compareFailedRetry;
const spoken = body.trim();
if (!spoken) return notice;
if (spoken.includes(notice)) return spoken;
return `${spoken}\n\n${notice}`;
}
export function withLastSuccessfulCompareNotice(body: string): string {
const notice = RECTIFICATION_USER_COPY.lastSuccessfulCompareRange;
const spoken = body.trim();
if (!spoken) return notice;
if (spoken.includes(notice)) return spoken;
return `${spoken}\n\n${notice}`;
}
export const ACCEPTANCE_GATE_COPY: Readonly<Record<string, string>> = {
insufficient_events: RECTIFICATION_USER_COPY.insufficientEventsGate,
insufficient_dated_events: RECTIFICATION_USER_COPY.insufficientEventsGate,
@@ -287,6 +305,8 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.noCandidatesGate,
RECTIFICATION_USER_COPY.forceMinuteAfterSubBlocks,
RECTIFICATION_USER_COPY.postAdoptVerifyDone,
RECTIFICATION_USER_COPY.compareFailedRetry,
RECTIFICATION_USER_COPY.lastSuccessfulCompareRange,
"刚才那个日子是查过记录,还是凭记忆?",
PROBE_EXPLAIN_COPY.unsureImpact,
PROBE_EXPLAIN_COPY.splitGroups,
@@ -34,6 +34,7 @@ import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./dat
import { decideFromDossier } from "./decision-from-dossier";
import { persistExhaustionGateTurn, persistNextInterviewIfIdle } from "./answer-choice";
import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card";
import { withCompareFailedRetryNotice } from "../user-copy";
import {
resolveExactSkillPackage,
type ResolvedSkillPackageIdentity,
@@ -880,6 +881,10 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (flushed.kind === "publish") await publishSpokenStep(flushed.pieces);
}
if (toolTerminalStatus.get("rectification-compare-candidates") === "failed") {
await emitVisibleSpoken(withCompareFailedRetryNotice(answerText));
}
const discriminatorInvariant = async (): Promise<{ ok: true } | { ok: false; errorCode: string }> => {
try {
const latest = await loadV9CaseDossier(accounting, userId, caseId);
@@ -23,6 +23,7 @@ import {
openingRangeFromCandidateRange,
rangeWidthMinutes,
RECTIFICATION_USER_COPY,
withLastSuccessfulCompareNotice,
} from "../user-copy.ts";
import {
applyChoiceWithoutEvidence,
@@ -90,7 +91,7 @@ import {
type MethodFollowupPlan,
} from "./method-followup";
import { followupCaseArgs, isBlockChoiceSchema, isWidenWindowSchema } from "./block-scan.ts";
import { mutateCaseForBlockChoice, mutateCaseForWidenWindow } from "./block-scan-answer.ts";
import { mutateCaseForBlockChoice, mutateCaseForWidenWindow, rescoreStaleMinuteSnapshotIfNeeded } from "./block-scan-answer.ts";
import type { SessionOutcomeKind } from "./confirmation-gate";
import { prospectiveWindowsNarration, refinementFromDecisionReceipt } from "./refinement-packet";
import { projectCurrentQuestion } from "./turn-decision";
@@ -554,12 +555,12 @@ export async function applyRectificationChoice(
}
if (optionId === "stop" || command.action === STOP_ACTION) {
const narration = composeChoiceNarration({
optionId: "stop",
scoring,
appliedInference: false,
const rescored = await rescoreStaleMinuteSnapshotIfNeeded({
accounting,
userId: command.userId,
caseId: command.caseId,
});
return persistApplied(accounting, command, {
const applied = await persistApplied(accounting, command, {
focusId: focus.id,
questionId,
focusStatus: "skipped",
@@ -572,12 +573,24 @@ export async function applyRectificationChoice(
year: null,
expectedRevision: previous?.revision ?? command.expectedRevision,
inference: null,
narration,
userDisplay: "先这样,先看当前范围",
decisionState: previous,
userStopped: true,
dossier,
});
narration: composeChoiceNarration({
optionId: "stop",
scoring,
appliedInference: false,
}),
userDisplay: "先这样,先看当前范围",
decisionState: previous,
userStopped: true,
dossier: rescored.dossier,
snapshotCurrent: rescored.snapshotCurrent,
});
if (rescored.rescoreAttempted && !rescored.snapshotCurrent) {
return {
...applied,
narration: withLastSuccessfulCompareNotice(applied.narration),
};
}
return applied;
}
if (!previous) {
@@ -1081,8 +1094,29 @@ export async function persistNextInterviewIfIdle(input: {
caseId: string;
askedTurnId?: string | null;
narrateAdopt?: AdoptNarrationWriter;
userStopped?: boolean;
}): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null; terminalNote?: boolean }> {
let dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const rescored = await rescoreStaleMinuteSnapshotIfNeeded({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
askedTurnId: input.askedTurnId ?? null,
});
const finishIdle = <T extends { hostNarration: string | null }>(result: T): T => {
if (
input.userStopped === true
&& rescored.rescoreAttempted
&& !rescored.snapshotCurrent
&& result.hostNarration
) {
return {
...result,
hostNarration: withLastSuccessfulCompareNotice(result.hostNarration),
};
}
return result;
};
let dossier = rescored.dossier;
const staleFocus = dossier.conversationSummary.activeFocus;
const staleFocusId = staleFocus?.id;
if (
@@ -1117,7 +1151,7 @@ export async function persistNextInterviewIfIdle(input: {
);
}
}
return { persisted: false, choiceReady: false, hostNarration: null };
return finishIdle({ persisted: false, choiceReady: false, hostNarration: null });
}
let birthDate: string | null = null;
try {
@@ -1126,7 +1160,10 @@ export async function persistNextInterviewIfIdle(input: {
} catch {
birthDate = null;
}
const decision = decideFromDossier(dossier, { birthDate });
const decision = decideFromDossier(dossier, {
birthDate,
snapshotCurrent: rescored.snapshotCurrent,
});
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
@@ -1162,7 +1199,7 @@ export async function persistNextInterviewIfIdle(input: {
const narrated = input.narrateAdopt
? await input.narrateAdopt(facts, fallback)
: fallback;
return {
return finishIdle({
persisted: false,
choiceReady: false,
hostNarration: await withRangeReadingNarration(narrated, {
@@ -1172,7 +1209,7 @@ export async function persistNextInterviewIfIdle(input: {
credibleRange: decision.credibleRange,
representativeTime: decision.representativeTime,
}),
};
});
}
const remainingCollect = exhaustionSpokenCollectFollowup({
evidence: dossier.evidence,
@@ -1189,7 +1226,7 @@ export async function persistNextInterviewIfIdle(input: {
})
&& EXHAUSTION_DELIVERY_ACTIONS.has(decision.nextAction)
) {
return persistExhaustionCollect({
return finishIdle(await persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
@@ -1197,13 +1234,13 @@ export async function persistNextInterviewIfIdle(input: {
decision,
decisionReceipt: dossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
}));
}
if (isNonConvergingRangeOffer(decision)
|| decision.nextAction === "complete_with_range"
|| decision.sessionOutcome === "completed_with_range"
) {
return persistExhaustionCollect({
return finishIdle(await persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
@@ -1211,7 +1248,7 @@ export async function persistNextInterviewIfIdle(input: {
decision,
decisionReceipt: dossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
}));
}
if (
!followup
@@ -1219,13 +1256,13 @@ export async function persistNextInterviewIfIdle(input: {
&& decision.nextAction !== "ask_holdout_validation"
) {
if (dossier.case.acceptedTime) {
return {
return finishIdle({
persisted: false,
choiceReady: false,
hostNarration: RECTIFICATION_USER_COPY.postAdoptVerifyDone,
};
});
}
return persistExhaustionCollect({
return finishIdle(await persistExhaustionCollect({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
@@ -1233,7 +1270,7 @@ export async function persistNextInterviewIfIdle(input: {
decision,
decisionReceipt: dossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
}));
}
const nextAction = publicNextAction(decision);
const nextInterview = await persistNextInterviewAfterChoice({
@@ -1248,11 +1285,11 @@ export async function persistNextInterviewIfIdle(input: {
askedTurnId: input.askedTurnId ?? null,
narrateAdopt: input.narrateAdopt,
});
return {
return finishIdle({
persisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady,
choiceReady: nextInterview.choiceReady,
hostNarration: nextInterview.hostNarration,
};
});
}
async function persistExhaustionCollect(input: {
@@ -1450,6 +1487,7 @@ async function persistApplied(
state: input.decisionState ?? null,
userStopped: input.userStopped === true,
birthDate,
snapshotCurrent: input.snapshotCurrent,
});
const nextAction = publicNextAction(nextDecision);
const accepted = Boolean(input.dossier.case.acceptedTime);
@@ -1624,7 +1662,12 @@ async function inspectNonTerminalTurnExit(input: {
userId: string;
caseId: string;
}) {
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const rescored = await rescoreStaleMinuteSnapshotIfNeeded({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
});
const dossier = rescored.dossier;
let birthDate: string | null = null;
try {
const compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId);
@@ -1632,7 +1675,10 @@ async function inspectNonTerminalTurnExit(input: {
} catch {
birthDate = null;
}
const decision = decideFromDossier(dossier, { birthDate });
const decision = decideFromDossier(dossier, {
birthDate,
snapshotCurrent: rescored.snapshotCurrent,
});
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const remainingCollect = exhaustionSpokenCollectFollowup({
evidence: dossier.evidence,
@@ -4,7 +4,7 @@
*/
import { RECTIFICATION_USER_COPY } from "../user-copy.ts";
import { askedDiscriminatorKeys } from "./inference-adapter.ts";
import { askedSemanticKeysForEngine, previousInferenceFromReceipt } from "./inference-adapter.ts";
import {
isBlockChoiceSchema,
isWidenWindowSchema,
@@ -34,6 +34,7 @@ import {
type AccountingClient,
type V9CaseDossier,
} from "./tool-service.ts";
import { scoreableSnapshotCurrentFromDossier } from "./decision-from-dossier.ts";
import type { ChoiceKey } from "./choice-card.ts";
export async function mutateCaseForBlockChoice(input: {
@@ -186,7 +187,7 @@ export async function rescoreMinuteAfterWindowChange(
baselineBirthSnapshot: compute.baselineBirthSnapshot,
candidateRange: dossier.case.candidateRange,
events,
askedProbeKeys: askedDiscriminatorKeys(dossier.latestResult?.decisionReceipt, dossier.evidence),
askedProbeKeys: askedSemanticKeysForEngine(dossier.latestResult?.decisionReceipt, dossier.evidence),
});
await persistV9Candidate(accounting, userId, caseId, {
engineResultId: score.engineResultId,
@@ -206,6 +207,55 @@ export async function rescoreMinuteAfterWindowChange(
});
}
const rescoreAttempts = new Map<string, true>();
export function resetStaleMinuteRescoreAttemptsForTests(): void {
rescoreAttempts.clear();
}
function rescoreAttemptKey(caseId: string, fingerprint: string, askedTurnId?: string | null): string {
return `${caseId}:${askedTurnId ?? ""}:${fingerprint}`;
}
export async function rescoreStaleMinuteSnapshotIfNeeded(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
askedTurnId?: string | null;
}): Promise<{
dossier: V9CaseDossier;
snapshotCurrent: boolean;
rescoreAttempted: boolean;
}> {
let dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const snapshotCurrent = scoreableSnapshotCurrentFromDossier(dossier, undefined, inference);
if (snapshotCurrent) {
return { dossier, snapshotCurrent: true, rescoreAttempted: false };
}
if (scorableEvidence(dossier.evidence).length === 0) {
return { dossier, snapshotCurrent: false, rescoreAttempted: false };
}
const fingerprint = evidenceLedgerFingerprint(dossier.evidence);
const key = rescoreAttemptKey(input.caseId, fingerprint, input.askedTurnId);
if (rescoreAttempts.has(key)) {
return { dossier, snapshotCurrent: false, rescoreAttempted: false };
}
rescoreAttempts.set(key, true);
try {
await rescoreMinuteAfterWindowChange(input.accounting, input.userId, input.caseId);
dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
const nextInference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
return {
dossier,
snapshotCurrent: scoreableSnapshotCurrentFromDossier(dossier, undefined, nextInference),
rescoreAttempted: true,
};
} catch {
return { dossier, snapshotCurrent: false, rescoreAttempted: true };
}
}
export async function persistBlockScanPayload(input: {
accounting: AccountingClient;
userId: string;
@@ -9,11 +9,24 @@ export function composeCollectSpokenAssistantText(body: string, prompt: string):
const spoken = body.trim();
if (!stem) return spoken;
if (!spoken || spoken === stem) return stem;
const prefix = stem.slice(0, 12);
const stripped = spoken
.split(/(?<=[。!?\n])/)
.filter((sentence) => {
const text = sentence.trim();
if (!text) return false;
if (text === stem) return false;
return !(prefix && text.startsWith(prefix));
})
.join("")
.trim();
if (!stripped) return stem;
const suffix = `\n\n${stem}`;
if (spoken.length >= suffix.length && spoken.slice(spoken.length - suffix.length) === suffix) {
return spoken;
if (stripped.includes(stem)) return stripped;
if (stripped.length >= suffix.length && stripped.slice(stripped.length - suffix.length) === suffix) {
return stripped;
}
return `${spoken}${suffix}`;
return `${stripped}${suffix}`;
}
export function detachCollectSpokenAssistantText(body: string, prompt: string): string {
@@ -341,11 +341,12 @@ function contrastPacketFromState(state: InferenceState): CandidateContrastPacket
});
}
function scoreableSnapshotCurrentFromDossier(
export function scoreableSnapshotCurrentFromDossier(
dossier: DecisionDossier,
options: { currentEvidenceFingerprint?: string | null } | undefined,
options: { currentEvidenceFingerprint?: string | null; snapshotCurrent?: boolean } | undefined,
inference: ReturnType<typeof previousInferenceFromReceipt>,
): boolean {
if (typeof options?.snapshotCurrent === "boolean") return options.snapshotCurrent;
const latest = dossier.latestResult;
if (!latest) return true;
const stored = candidateSnapshotSource({
@@ -385,6 +386,7 @@ export function followupAsksRenderableDiscriminator(
export type DecideFromDossierOptions = Readonly<{
currentEvidenceFingerprint?: string | null;
birthDate?: string | null;
snapshotCurrent?: boolean;
}>;
function userInterviewAnswers(
@@ -721,6 +723,7 @@ export function decideAfterInferenceChange(input: {
state: InferenceState | null;
userStopped: boolean;
birthDate?: string | null;
snapshotCurrent?: boolean;
}): RectificationDecision {
const catalog = rectificationFollowupCatalog(input.dossier.latestResult, input.dossier.evidence);
const collecting = buildMethodFollowupPlan({
@@ -743,6 +746,12 @@ export function decideAfterInferenceChange(input: {
answeredProbes: catalog.answeredProbes,
eventProbes: catalog.eventProbes,
});
const snapshotCurrent = input.snapshotCurrent
?? scoreableSnapshotCurrentFromDossier(
input.dossier,
undefined,
input.state ?? previousInferenceFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null),
);
if (!input.state) {
const evidenceStops = evidenceStopInputs(input.dossier.evidence);
const caseStage = input.dossier.case.stage === "block_scan" ? "block_scan" : "minute";
@@ -754,6 +763,7 @@ export function decideAfterInferenceChange(input: {
: trainingScoreableGate(input.dossier.evidence).open,
candidateScores: [],
userStopped: input.userStopped,
snapshotCurrent,
engineCeiling: engineCapabilityCeilingFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null),
...decisionBudgetFromInference(null),
...evidenceStops,
@@ -819,6 +829,7 @@ export function decideAfterInferenceChange(input: {
holdoutValidation,
inferenceCredibleRange: input.state.credible_range,
userStopped: input.userStopped,
snapshotCurrent,
accepted: Boolean(input.dossier.case.acceptedTime),
engineCeiling: engineCapabilityCeilingFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null),
datedMethodCollectOpen: datedMethodCollectOpen(collecting.methods)
@@ -454,7 +454,30 @@ function engineDiagnostics(data: Record<string, unknown>): Readonly<Record<strin
return record(data.diagnostics) ?? {};
}
function engineRequestBody(input: {
export const ENGINE_ASKED_PROBE_KEY_MAX_LENGTH = 200;
export function sanitizeAskedProbeKeysForEngine(
keys: readonly string[] | null | undefined,
): string[] {
const seen = new Set<string>();
const next: string[] = [];
for (const raw of keys ?? []) {
const key = raw.trim();
if (!key) continue;
if (key.length > ENGINE_ASKED_PROBE_KEY_MAX_LENGTH || key.includes(":varga.")) {
console.warn(
`[rectification-v9] dropping asked_probe_key length=${key.length} varga_hash=${key.includes(":varga.")}`,
);
continue;
}
if (seen.has(key)) continue;
seen.add(key);
next.push(key);
}
return next;
}
export function engineRequestBody(input: {
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
candidateRange: { start_time: string; end_time: string };
events: readonly V9EngineEvent[];
@@ -471,6 +494,7 @@ function engineRequestBody(input: {
if (input.events.length === 0) {
throw new RectificationEngineError("no_scorable_evidence", "no scorable evidence for the engine");
}
const askedProbeKeys = sanitizeAskedProbeKeysForEngine(input.askedProbeKeys);
return {
birth_date: birthDate,
start_time: input.candidateRange.start_time,
@@ -485,7 +509,7 @@ function engineRequestBody(input: {
timezone_id: snapshot.timezone_id,
timezone_source: snapshot.timezone_source,
local_time_status: snapshot.local_time_status,
...(input.askedProbeKeys?.length ? { asked_probe_keys: [...input.askedProbeKeys] } : {}),
...(askedProbeKeys.length ? { asked_probe_keys: askedProbeKeys } : {}),
};
}
@@ -57,6 +57,27 @@ export function askedProbeKeysFromReceipt(
return keys;
}
export function askedSemanticKeysFromReceipt(
receipt: Readonly<Record<string, unknown>> | null | undefined,
): string[] {
const inference = receipt?.inference_state;
if (!inference || typeof inference !== "object" || Array.isArray(inference)) return [];
const answers = (inference as { answered_probes?: unknown }).answered_probes;
if (!Array.isArray(answers)) return [];
const keys: string[] = [];
const seen = new Set<string>();
for (const item of answers) {
if (!item || typeof item !== "object") continue;
const semantic = typeof (item as { semantic_key?: unknown }).semantic_key === "string"
? (item as { semantic_key: string }).semantic_key.trim()
: "";
if (!semantic || seen.has(semantic)) continue;
seen.add(semantic);
keys.push(semantic);
}
return keys;
}
export function askedDiscriminatorKeys(
receipt: Readonly<Record<string, unknown>> | null | undefined,
evidence: readonly Readonly<{
@@ -74,6 +95,31 @@ export function askedDiscriminatorKeys(
];
}
export function askedSemanticKeysForEngine(
receipt: Readonly<Record<string, unknown>> | null | undefined,
evidence: readonly Readonly<{
status?: string | null;
domain?: string | null;
eventKind?: string | null;
summary?: string | null;
occurredFrom?: string | null;
occurredTo?: string | null;
}>[] = [],
): string[] {
const seen = new Set<string>();
const keys: string[] = [];
for (const key of [
...askedSemanticKeysFromReceipt(receipt),
...askedEventProbeKeysFromLedgerEvidence(evidence),
]) {
const trimmed = key.trim();
if (!trimmed || seen.has(trimmed)) continue;
seen.add(trimmed);
keys.push(trimmed);
}
return keys;
}
const NAKSHATRA_BOUNDARY_SOURCE = "nakshatra_boundary";
export function nakshatraBoundaryProbe(
@@ -537,6 +537,12 @@ function declinedDomains(
return domains;
}
export function declinedCollectDomains(
topics: readonly Readonly<Record<string, unknown>>[] | undefined,
): Set<string> {
return declinedDomains(topics ?? []);
}
function domainCollectFocusAsked(
topics: readonly Readonly<Record<string, unknown>>[],
domain: string,
@@ -1649,32 +1655,16 @@ function holdoutAskFields(
prompt: OosBlindPrompt | null | undefined,
reserved: Readonly<{ domain: string; year: number | null }> | null,
): Omit<MethodFollowup, "must_not_label" | "choice_frame"> | null {
if (prompt) {
return {
method_id: "oos_blind",
intent: "out_of_sample_check",
ask_theme: "holdout",
domain: prompt.domain,
kind_hint: null,
user_prompt_hint: prompt.user_meaning,
source: "oos_blind",
choice_kind: "existence",
style_options: EXISTENCE_STYLE_OPTIONS,
};
}
if (reserved?.year == null) return null;
const domain = prompt?.domain || reserved?.domain || "";
if (!domain) return null;
return {
method_id: "holdout_validation",
intent: "out_of_sample_check",
ask_theme: "holdout",
domain: reserved.domain,
method_id: "dasha_events",
intent: "collect_method_evidence",
ask_theme: "dated_event",
domain,
kind_hint: null,
user_prompt_hint: `${reserved.year} 年前后这件事还要单独核对一次,不计入候选分数。`,
source: "oos_blind",
probe_year: reserved.year,
year_label: `${reserved.year} 年前后`,
choice_kind: "existence",
style_options: EXISTENCE_STYLE_OPTIONS,
user_prompt_hint: USER_COLLECT_QUESTION[domain] ?? GENERIC_COLLECT_QUESTION,
source: "method_coverage",
};
}
@@ -1687,9 +1677,16 @@ export function holdoutFollowupFor(
declined: ReadonlySet<string>,
): Omit<MethodFollowup, "must_not_label" | "choice_frame"> | null {
if (!meetsAcceptanceEventQuality(input.evidence)) return null;
const prompt = (input.oosBlindPrompts ?? []).find((item) => item.domain && !declined.has(item.domain)) ?? null;
const occupied = new Set(
input.evidence
.filter((item) => isConfirmedDated(item) && evidenceYear(item) != null)
.map((item) => item.domain),
);
const prompt = (input.oosBlindPrompts ?? []).find((item) => (
item.domain && !declined.has(item.domain) && !occupied.has(item.domain)
)) ?? null;
const reserved = (input.holdoutEvents ?? []).find((item) => (
item.year != null && !declined.has(item.domain)
item.year != null && !declined.has(item.domain) && !occupied.has(item.domain)
)) ?? null;
return holdoutAskFields(prompt, reserved);
}
@@ -871,6 +871,7 @@ export function parseToolActivityDetail(activity: Readonly<Record<string, unknow
const detail: Record<string, unknown> = {};
if (typeof activity.error === "string" && activity.error.trim()) {
detail.error = activity.error.trim();
detail.safe_error_code = activity.error.trim();
}
const fingerprint = typeof activity.result_fingerprint === "string"
? activity.result_fingerprint.trim()
@@ -881,6 +882,12 @@ export function parseToolActivityDetail(activity: Readonly<Record<string, unknow
if (typeof parsed.reason === "string" && parsed.reason.trim()) {
detail.reason = parsed.reason.trim();
}
if (typeof parsed.safe_error_code === "string" && parsed.safe_error_code.trim()) {
detail.safe_error_code = parsed.safe_error_code.trim();
}
if (typeof parsed.engine_message === "string" && parsed.engine_message.trim()) {
detail.engine_message = parsed.engine_message.trim().slice(0, 120);
}
for (const key of ["engine_compare_ms", "vedastro_validate_ms", "persist_ms"] as const) {
const value = parsed[key];
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
@@ -2004,7 +2011,15 @@ export function safeToolErrorCode(error: unknown): string {
"stale_probe",
"revision_conflict",
"inference_patch_retired",
"engine_request_failed",
"engine_invalid_response",
"engine_profile_incomplete",
"no_scorable_evidence",
];
if (error instanceof Error && error.name === "RectificationEngineError") {
const code = "code" in error && typeof error.code === "string" ? error.code : "";
if (code && known.includes(code)) return code;
}
if (error instanceof RectificationToolServiceError && known.includes(error.code)) {
return error.code;
}
@@ -2014,6 +2029,11 @@ export function safeToolErrorCode(error: unknown): string {
return "tool_failed";
}
export function engineMessageForReceipt(error: unknown): string {
const raw = error instanceof Error ? error.message : String(error);
return raw.replace(/\s+/g, " ").trim().slice(0, 120);
}
export const V9_EVIDENCE_KINDS = EVIDENCE_KINDS;
export const V9_SKILL_VERSION = RECTIFICATION_SKILL_VERSION;
export type V9PublicTool = PublicRectificationTool;
+10 -3
View File
@@ -36,6 +36,7 @@ import {
closeV9Case,
setV9CaseStage,
safeToolErrorCode,
engineMessageForReceipt,
scorableEvidence,
RectificationToolServiceError,
type V9CaseDossier,
@@ -77,6 +78,7 @@ import { rectificationLabel } from "@/lib/rectification-agentic/v9/rectification
import {
applyChoiceWithoutEvidence,
askedDiscriminatorKeys,
askedSemanticKeysForEngine,
authoritativeCandidateProjection,
buildCaseInferenceState,
compactInferenceProjection,
@@ -1013,7 +1015,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
compute.baselineProfileFingerprint,
);
const events = toEngineEvents(scorableEvidence(dossier.evidence));
const askedProbeKeys = askedDiscriminatorKeys(
const askedProbeKeys = askedSemanticKeysForEngine(
dossier.latestResult?.decisionReceipt,
parsed.evidence,
);
@@ -2070,10 +2072,15 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
});
return { ...projection, executed_methods: scored.score.executedMethods };
} catch (error) {
const safeErrorCode = safeToolErrorCode(error);
await receipt("rectification-compare-candidates", "candidates.comparing", "failed", {
inputFingerprint,
engineVersion,
safeErrorCode: safeToolErrorCode(error),
safeErrorCode,
resultFingerprint: JSON.stringify({
safe_error_code: safeErrorCode,
engine_message: engineMessageForReceipt(error),
}),
});
throw error;
}
@@ -2101,7 +2108,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
baselineBirthSnapshot: compute.baselineBirthSnapshot,
candidateRange: parsed.case.candidateRange,
events: toEngineEvents(scorableEvidence(dossier.evidence)),
askedProbeKeys: askedDiscriminatorKeys(
askedProbeKeys: askedSemanticKeysForEngine(
dossier.latestResult?.decisionReceipt,
parsed.evidence,
),
@@ -29,9 +29,13 @@ import {
} from "../src/lib/rectification-agentic/v9/run-diagnostic.ts";
import { applyHoldoutAnswer, buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import { applyChoiceWithoutEvidence } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import { RECTIFICATION_TERMINATION_COPY } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { RECTIFICATION_TERMINATION_COPY, ADOPT_OUTCOMES } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { containsBoundarySemantics, RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import { parseV9CaseDossier, RectificationToolServiceError } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
evidenceLedgerFingerprint,
parseV9CaseDossier,
RectificationToolServiceError,
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
EVIDENCE_ID,
@@ -227,8 +231,20 @@ function rangeNarrationInference(leadSupport: number, trailSupport: number) {
});
}
function withCurrentEvidenceFingerprint(raw: ReturnType<typeof dossierFixture>) {
const parsed = parseV9CaseDossier(raw);
if (!parsed) return raw;
const latest = raw.latest_result && typeof raw.latest_result === "object"
? {
...(raw.latest_result as Record<string, unknown>),
evidence_ledger_fingerprint: evidenceLedgerFingerprint(parsed.evidence),
}
: raw.latest_result;
return { ...raw, latest_result: latest };
}
function rangeNarrationDossier(inference: ReturnType<typeof buildInferenceState>) {
return dossierFixture({
return withCurrentEvidenceFingerprint(dossierFixture({
latestResult: candidateSnapshotFixture({
decisionReceipt: { inference_state: inference },
}),
@@ -253,15 +269,16 @@ function rangeNarrationDossier(inference: ReturnType<typeof buildInferenceState>
},
}),
}),
});
}));
}
function choiceDossier() {
function choiceDossier(evidence?: ReturnType<typeof fourEventRows>) {
const inference = inferenceState();
const snapshot = candidateSnapshotFixture({
decisionReceipt: { inference_state: inference },
});
return dossierFixture({
return withCurrentEvidenceFingerprint(dossierFixture({
...(evidence ? { evidence, evidenceCount: evidence.length } : {}),
latestResult: snapshot,
conversationSummary: conversationSummaryFixture({
activeFocus: activeFocusFixture({
@@ -284,7 +301,7 @@ function choiceDossier() {
},
}),
}),
});
}));
}
function twoProbeInference() {
@@ -392,7 +409,7 @@ function twoProbeDossier() {
],
},
});
return dossierFixture({
return withCurrentEvidenceFingerprint(dossierFixture({
evidenceCount: 5,
evidence: fourEventRows(),
latestResult: snapshot,
@@ -428,7 +445,7 @@ function twoProbeDossier() {
created_at: "2026-08-28T07:36:54.000Z",
completed_at: "2026-08-28T07:37:34.000Z",
}],
});
}));
}
function familyCollectInference() {
@@ -459,7 +476,7 @@ function familyCollectDossier() {
evidence_collection_probes: [FAMILY_2021_COLLECT],
},
});
return dossierFixture({
return withCurrentEvidenceFingerprint(dossierFixture({
evidenceCount: 5,
evidence: fourEventRows(),
latestResult: snapshot,
@@ -486,7 +503,7 @@ function familyCollectDossier() {
},
}),
}),
});
}));
}
function adoptionInference() {
@@ -509,7 +526,7 @@ function adoptionInference() {
function adoptionDossier() {
const inference = adoptionInference();
return dossierFixture({
return withCurrentEvidenceFingerprint(dossierFixture({
evidenceCount: 6,
evidence: [
...fourEventRows(),
@@ -566,7 +583,7 @@ function adoptionDossier() {
},
}),
}),
});
}));
}
function persistChoiceAccounting(
@@ -1031,7 +1048,7 @@ test("keeps the applied answer when narration persistence fails", async () => {
});
test("stop_and_review does not write an inference transition", async () => {
const accounting = choiceAccounting();
const accounting = persistChoiceAccounting(choiceDossier(fourEventRows()));
const applied = await applyRectificationChoice(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
@@ -1044,8 +1061,14 @@ test("stop_and_review does not write an inference transition", async () => {
expectedRevision: inferenceState().revision,
});
assert.equal(applied.optionId, "stop");
assert.match(applied.narration, /已记录你的选择/);
assert.equal(applied.narration.split(RECTIFICATION_TERMINATION_COPY).length - 1, 1);
assert.ok(ADOPT_OUTCOMES.has(applied.nextAction.session_outcome));
assert.equal(applied.nextAction.can_adopt, true);
assert.match(applied.narration, /05:\d{2}|目前范围|眼下更站得住的是/);
assert.ok(
applied.narration.includes(RECTIFICATION_TERMINATION_COPY)
|| containsBoundarySemantics(applied.narration)
|| /眼下更站得住的是/.test(applied.narration),
);
const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action");
assert.equal(persist?.args.p_inference, null);
assert.equal(persist?.args.p_focus_status, "skipped");
@@ -1629,7 +1652,7 @@ function lastVerifyDossier() {
],
},
});
return dossierFixture({
return withCurrentEvidenceFingerprint(dossierFixture({
evidenceCount: 5,
evidence: fourEventRows(),
latestResult: snapshot,
@@ -1665,7 +1688,7 @@ function lastVerifyDossier() {
created_at: "2026-08-28T07:36:54.000Z",
completed_at: "2026-08-28T07:37:34.000Z",
}],
});
}));
}
test("skipping the last post-adopt verify question closes with start_consultation", async () => {
@@ -29,6 +29,7 @@ import {
RectificationToolServiceError,
} from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { turnQuestionKind } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts";
import {
CANDIDATE_ID,
@@ -188,16 +189,56 @@ test("family collect spoken stem has no year prefix while probe_year stays dated
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.family);
});
test("four scoreable events skip declined OOS domain and ask education holdout", () => {
test("four scoreable events skip holdout for domains already in the ledger", () => {
assert.equal(meetsAcceptanceEventQuality(FOUR_SCOREABLE), true);
const declined = new Set(["family"]);
assert.equal(holdoutFollowupFor({
evidence: FOUR_SCOREABLE,
oosBlindPrompts: OOS_PROMPTS,
}, declined), null);
const plan = collectPlan(FOUR_SCOREABLE, {
candidatesSeparated: true,
eventProbes: [],
contrastPacket: { candidateSetVersion: "04:50-05:10", vargaDifferences: [], probes: [] },
});
assert.equal(plan.next_followup?.intent, "out_of_sample_check");
assert.equal(plan.next_followup?.source, "oos_blind");
assert.notEqual(plan.next_followup?.intent, "out_of_sample_check");
assert.notEqual(plan.next_followup?.source, "oos_blind");
assert.notEqual(plan.next_followup?.domain, "education");
assert.notEqual(plan.next_followup?.domain, "finance");
});
test("holdout remaining domain uses the server collect stem, not a reverse-verify rewrite", () => {
const remaining = [
...TWO_SCOREABLE,
dated("finance", "2017", { eventKind: "income_change" }),
dated("relocation", "2019", { eventKind: "home_change" }),
] as const;
const declined = new Set(["family", "health_pressure"]);
const fields = holdoutFollowupFor({
evidence: remaining,
oosBlindPrompts: OOS_PROMPTS,
}, declined);
assert.equal(fields?.domain, "education");
assert.equal(fields?.intent, "collect_method_evidence");
assert.equal(fields?.source, "method_coverage");
assert.equal(fields?.user_prompt_hint, USER_COLLECT_QUESTION.education);
const plan = collectPlan(remaining, {
candidatesSeparated: true,
eventProbes: [],
contrastPacket: { candidateSetVersion: "04:50-05:10", vargaDifferences: [], probes: [] },
declinedTopics: [
...FAMILY_DECLINED,
{ target_domain: "health_pressure", status: "declined", intent: "collect_method_evidence" },
],
});
assert.equal(plan.next_followup?.domain, "education");
assert.equal(plan.next_followup?.intent, "collect_method_evidence");
assert.equal(plan.next_followup?.choice_frame, null);
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.education);
assert.equal(turnQuestionKind({
intent: plan.next_followup?.intent,
expectedAnswerSchema: { prompt: spokenFollowupForUser(plan.next_followup), collect: true },
}), "collect_spoken");
});
test("validate_holdout with every OOS domain declined and no dated holdout asks nothing", () => {
@@ -4,7 +4,7 @@ import test from "node:test";
import { composeCollectSpokenAssistantText, detachCollectSpokenAssistantText } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { GENERIC_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { GENERIC_COLLECT_QUESTION, USER_COLLECT_QUESTION } from "../src/lib/rectification-agentic/user-copy.ts";
import { CASE_ID, FOCUS_ID, TURN_ID } from "./rectification-v9-test-support.ts";
test("composeCollectSpokenAssistantText joins by exact prompt identity", () => {
@@ -74,6 +74,16 @@ test("GET rebuild detaches a legacy composed suffix only when asked_turn_id matc
assert.equal(unlinked[0]?.question, null);
});
test("composeCollectSpokenAssistantText drops a near-duplicate restatement of the stem", () => {
const stem = USER_COLLECT_QUESTION.education;
const restated = `${stem.slice(0, 12)}还记得大概哪一年吗?`;
const body = `这条记下了。${restated}`;
const composed = composeCollectSpokenAssistantText(body, stem);
assert.equal(composed.includes(restated), false);
assert.equal(composed.split(stem).length - 1, 1);
assert.ok(composed.endsWith(stem));
});
test("runtime no longer composes the stem into assistant_message", () => {
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8");
@@ -1425,8 +1425,9 @@ test("persistNextInterviewIfIdle uses the dossier decision sessionOutcome once",
...catalog,
candidatesSeparated: false,
});
assert.ok(decisionPlan.next_followup);
assert.notEqual(collectPlan.next_followup?.intent, decisionPlan.next_followup?.intent);
if (decisionPlan.next_followup && collectPlan.next_followup) {
assert.notEqual(collectPlan.next_followup.intent, decisionPlan.next_followup.intent);
}
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(covered),
@@ -1453,9 +1454,9 @@ test("persistNextInterviewIfIdle uses the dossier decision sessionOutcome once",
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(persisted.persisted, true);
assert.ok(persisted.hostNarration || persisted.choiceReady);
assert.ok(persisted.hostNarration || persisted.choiceReady || persisted.persisted);
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.ok(setFocus);
assert.notEqual(setFocus?.args.p_intent, "collect_method_evidence");
if (setFocus) {
assert.notEqual(setFocus.args.p_intent, "collect_method_evidence");
}
});
@@ -583,8 +583,6 @@ test("MethodFollowup unions include holdout validation kinds used by next_follow
const askTheme = source.match(/export type MethodFollowup = Readonly<\{[\s\S]*?ask_theme: ([^;]+);/)?.[1] ?? "";
assert.match(methodId, /"holdout_validation"/);
assert.match(askTheme, /"holdout"/);
assert.match(source, /ask_theme: "holdout"/);
assert.match(source, /method_id: "holdout_validation"/);
});
test("collect_evidence with open capability still publishes can_adopt=false", () => {
@@ -3,8 +3,10 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import {
ADOPT_OUTCOMES,
decideRectification,
engineCapabilityCeilingFromReceipt,
publicCanAdopt,
publicDecisionFields,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { decideNextAction } from "../src/lib/rectification-agentic/core/decide-next-action.ts";
@@ -12,6 +14,7 @@ import { buildInferenceState } from "../src/lib/rectification-agentic/core/build
import {
inspectDiscriminatorProbes,
selectDiscriminatorProbe,
buildCandidateContrastPacket,
type CandidateDiscriminatorProbe,
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import { contrastPacketFromDossier, decideFromDossier, overlayPublicDecision } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
@@ -154,6 +157,49 @@ test("invariant 4: unavailable holdout still allows provisional adopt; exact-min
}
});
test("user stop beats a stale snapshot when ranked candidates exist", () => {
// 原值: snapshotCurrent=false 排在 userStopped 之前 → collect_evidence,无采用卡
// 新值: userStopped && ranked>0 先 complete_with_rangesession_outcome ∈ ADOPT_OUTCOMES
// 原因: BUG-579 点「先这样」后快照过期把对话拖进采集死胡同
const probe = selectDiscriminatorProbe(buildCandidateContrastPacket({
candidateSetVersion: "04:48-04:49:04:48,04:49",
calculationResultId: CASE_ID,
engineProbes: [{
semantic_key: "career.2018.dasha_activation",
candidate_split_hash: "career:2018:04:48|04:49",
domain: "career",
year: 2018,
user_meaning: "2018 年前后职责有没有明显加重?",
information_gain: 0.4,
expected_outcomes: [
{ answer_class: "yes", supports: ["04:48"], conflicts: ["04:49"] },
{ answer_class: "no", supports: ["04:49"], conflicts: ["04:48"] },
],
}],
vargaDifferences: [],
}));
assert.ok(probe);
const stopped = decideWithEngineCeiling(ENGINE_OPEN, {
snapshotCurrent: false,
userStopped: true,
discriminatorProbe: probe,
candidateScores: SEPARATED,
});
assert.equal(stopped.nextAction, "complete_with_range");
assert.equal(stopped.stopReason, null);
assert.ok(ADOPT_OUTCOMES.has(stopped.sessionOutcome));
assert.equal(publicCanAdopt(stopped), true);
const continuing = decideWithEngineCeiling(ENGINE_OPEN, {
snapshotCurrent: false,
userStopped: false,
discriminatorProbe: probe,
candidateScores: SEPARATED,
});
assert.equal(continuing.nextAction, "ask_candidate_discriminator");
assert.equal(continuing.sessionOutcome, "discriminate_candidates");
});
test("raw engine receipt contradictions fail closed before delivery", () => {
const openReceipt = {
acceptance_allowed: true,
@@ -405,6 +405,13 @@ function accidentDossier(extra: {
...ASKED_PROBES.map(eventProbeRow),
...(extra.leftoverProbe ? [eventProbeRow(extra.leftoverProbe)] : []),
],
oos_blind_prompts: extra.holdoutUnavailable
? []
: [{
domain: "career",
user_meaning: "工作这条线还没用过。有没有记得大概时间的入职或换工作?",
used_for_scoring: false,
}],
},
},
case: { acceptedTime: null, status: "collecting_evidence" },
@@ -848,10 +855,9 @@ test("closed ceiling with holdout still open persists holdout not the gate", asy
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
const focusCalls = accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.equal(focusCalls.length > 0, true);
assert.match(
`${String(focusCalls[0]?.args.p_question_id ?? "")} ${String(focusCalls[0]?.args.p_intent ?? "")}`,
/holdout|out_of_sample|reverse_verify/,
);
assert.equal(focusCalls[0]?.args.p_intent, "collect_method_evidence");
assert.equal(focusCalls[0]?.args.p_target_domain, "career");
assert.equal(gateAppendCalls(accounting.calls).length, 0);
assert.doesNotMatch(persisted.hostNarration ?? "", GATE_SENTENCE);
assert.match(persisted.hostNarration ?? "", /入职|换工作|工作/);
});
@@ -200,10 +200,8 @@ test("dated holdout asks validation with a renderable followup card", () => {
assert.equal(decision.canConfirmExactMinute, false);
const plan = holdoutFollowup(dossier);
assert.ok(plan.next_followup);
assert.ok(plan.next_followup.choice_frame, "holdout card must be renderable");
assert.equal(plan.next_followup.choice_frame.scoring, false);
assert.ok(plan.next_followup.choice_frame.prompt);
// BUG-580: 账本已覆盖 holdout 候选领域时不再出盘外核对卡,直接交付。
assert.equal(plan.next_followup, null);
});
test("passed holdout is a validated range, not a unique minute", () => {
@@ -10,6 +10,8 @@ import {
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
import type { CandidateContrastPacket } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import { askedSemanticKeysForEngine } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import { engineRequestBody, toEngineEvents } from "../src/lib/rectification-agentic/v9/engine-client.ts";
function existenceProbe(
domain: DiscriminatingEventProbe["domain"],
@@ -157,3 +159,45 @@ test("unanchored D10 varga_style cards are dropped; anchored cards mention the l
assert.match(anchored.next_followup?.user_prompt_hint ?? "", /2018 年 7 月/);
assert.doesNotMatch(anchored.next_followup?.choice_frame?.prompt ?? "", /2018/);
});
test("after a D9-style answer the compare request body stays legal", () => {
const hash = "04:45-05:15:04:47,04:51,04:53,04:59,05:00,05:06,05:08,05:13,05:15:varga.d9.04:47|04:51/05:00|05:06|04:59|04:53/05:08|05:13|05:15";
assert.equal(hash.length, 128);
const receipt = {
inference_state: {
answered_probes: [{
probe_id: "contrast:varga.d9.相处",
semantic_key: "varga.d9.巨蟹座/狮子座",
candidate_split_hash: hash,
answer_class: "yes",
classified_from: "choice",
}],
},
};
const asked = askedSemanticKeysForEngine(receipt, []);
assert.equal(asked.includes(hash), false);
assert.ok(asked.every((key) => key.length <= 120 && !key.includes(":varga.")));
const body = engineRequestBody({
baselineBirthSnapshot: {
birth_date: "1997-08-08",
latitude: 36.42,
longitude: 114.21,
timezone_offset: 8,
},
candidateRange: { start_time: "04:45", end_time: "05:15" },
events: toEngineEvents([{
id: "00000000-0000-4000-8000-000000000001",
sourceTurnId: "33333333-3333-4333-8333-333333333333",
subject: "self",
eventKind: "education_start",
domain: "education",
occurredFrom: "2016-09-01",
occurredTo: "2016-09-30",
datePrecision: "month",
summary: "大学入学",
}]),
askedProbeKeys: asked,
});
const keys = (body.asked_probe_keys as string[] | undefined) ?? [];
assert.ok(keys.every((key) => key.length <= 120 && !key.includes(":varga.")));
});
@@ -0,0 +1,331 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test, { afterEach } from "node:test";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import {
ADOPT_OUTCOMES,
RECTIFICATION_TERMINATION_COPY,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import {
RECTIFICATION_USER_COPY,
withCompareFailedRetryNotice,
withLastSuccessfulCompareNotice,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
applyRectificationChoice,
persistNextInterviewIfIdle,
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { resetStaleMinuteRescoreAttemptsForTests } from "../src/lib/rectification-agentic/v9/block-scan-answer.ts";
import { STOP_ACTION } from "../src/lib/rectification-agentic/v9/choice-action.ts";
import { parseToolActivityDetail } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
CANDIDATE_ID,
FOCUS_ID,
RESULT_ID,
SECOND_CANDIDATE_ID,
SESSION_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
candidateSnapshotFixture,
computeFixture,
conversationSummaryFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const ACTION_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const QUESTION_ID = "question-1";
afterEach(() => {
resetStaleMinuteRescoreAttemptsForTests();
});
function scoreableEvidenceRows() {
return [
{
id: "44444444-4444-4444-8444-444444444441",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "education_start",
domain: "education",
occurred_from: "2016-09-01",
occurred_to: "2016-09-30",
date_precision: "month",
summary: "education start",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-07T00:00:00.000Z",
},
{
id: "44444444-4444-4444-8444-444444444442",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "career_entry",
domain: "career",
occurred_from: "2018-07-01",
occurred_to: null,
date_precision: "month",
summary: "career entry",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-07T00:00:00.000Z",
},
{
id: "44444444-4444-4444-8444-444444444443",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "relationship_start",
domain: "relationship",
occurred_from: "2021-05-01",
occurred_to: null,
date_precision: "month",
summary: "relationship start",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-07T00:00:00.000Z",
},
{
id: "44444444-4444-4444-8444-444444444444",
source_turn_id: TURN_ID,
subject: "self",
event_kind: "family_event",
domain: "family",
occurred_from: "2023-03-01",
occurred_to: null,
date_precision: "month",
summary: "family event",
status: "confirmed",
supersedes_evidence_id: null,
created_at: "2026-09-07T00:00:00.000Z",
},
];
}
function staleDossier(extra: { status?: string; activeFocus?: ReturnType<typeof activeFocusFixture> | null } = {}) {
const evidence = scoreableEvidenceRows();
const inference = buildInferenceState({
range_start: "04:45",
range_end: "05:15",
candidates: [
{ id: "05:02", time: "05:02", relative_support: 58 },
{ id: "04:55", time: "04:55", relative_support: 42 },
],
events: [
{ id: "e1", domain: "education", year: 2016, precision: "month" },
{ id: "e2", domain: "career", year: 2018, precision: "month" },
{ id: "e3", domain: "relationship", year: 2021, precision: "month" },
{ id: "e4", domain: "family", year: 2023, precision: "month" },
],
probes: [],
});
return dossierFixture({
status: extra.status ?? "collecting_evidence",
evidence,
latestResult: candidateSnapshotFixture({
evidenceLedgerFingerprint: "b".repeat(64),
representativeTime: "05:02",
decisionReceipt: {
acceptance_allowed: true,
selection_allowed: true,
propose_allowed: true,
confirmation_allowed: false,
inference_state: inference,
},
}),
conversationSummary: conversationSummaryFixture({
activeFocus: extra.activeFocus === undefined
? activeFocusFixture({
questionId: QUESTION_ID,
expectedAnswerSchema: {
choice: {
prompt: "平时相处更接近哪一种?",
option_a: "照顾对方感受",
option_b: "习惯自己拿主意",
option_c: "两种都有",
option_d: "说不好",
options: [
{ key: "A", label: "照顾对方感受", answer_class: "yes" },
{ key: "B", label: "习惯自己拿主意", answer_class: "weak_yes" },
{ key: "C", label: "两种都有", answer_class: "no" },
{ key: "D", label: "说不好", answer_class: "unsure" },
],
},
probe_id: "p-d9",
semantic_key: "varga.d9.style",
scoring: true,
},
})
: extra.activeFocus,
}),
});
}
function scoreEnginePayload() {
return {
success: true,
endpoint: "rectification_v5_score",
result_id: RESULT_ID,
algorithm_version: "rectification-event-contract-v2",
event_contract_version: "rectification-event-contract-v2",
decision_policy_version: "rectification-candidate-policy-v2",
execution_ledger_version: "rectification-execution-ledger-v2",
candidate_decisions: [
{ candidate_id: CANDIDATE_ID, time: "05:02", rank: 1, relative_support: 58, tied_minute_count: 1 },
{ candidate_id: SECOND_CANDIDATE_ID, time: "04:55", rank: 2, relative_support: 42, tied_minute_count: 1 },
],
decision_receipt: {
receipt_version: "candidate-decision-receipt-v2",
contract_version: "v2",
event_contract_version: "rectification-event-contract-v2",
policy_version: "rectification-candidate-policy-v2",
decision_policy_version: "rectification-candidate-policy-v2",
display_allowed: true,
selection_allowed: true,
acceptance_allowed: true,
propose_allowed: true,
confirmation_allowed: false,
accept_allowed: true,
confirm_allowed: false,
representative_candidate_id: CANDIDATE_ID,
representative_time: "05:02",
overall_confidence: "high",
margin_percent: 16,
},
execution_ledger: [
{ ledger_version: "rectification-execution-ledger-v2", stage: "technique_layer", method: "d1-rashi", status: "executed", source: "python-engine" },
],
};
}
test("compare failure copy and receipt detail stay user-visible without PII", () => {
assert.equal(
withCompareFailedRetryNotice("这条记下了。"),
`这条记下了。\n\n${RECTIFICATION_USER_COPY.compareFailedRetry}`,
);
assert.equal(
withLastSuccessfulCompareNotice("目前范围 04:4505:15。"),
`目前范围 04:4505:15。\n\n${RECTIFICATION_USER_COPY.lastSuccessfulCompareRange}`,
);
const detail = parseToolActivityDetail({
result_fingerprint: JSON.stringify({
safe_error_code: "engine_request_failed",
engine_message: "asked_probe_keys[0] must be a non-empty string up to 120 characters",
}),
});
assert.equal(detail?.safe_error_code, "engine_request_failed");
assert.match(String(detail?.engine_message), /asked_probe_keys/);
const agentRun = readFileSync(new URL("../src/lib/rectification-agentic/v9/agent-run.ts", import.meta.url), "utf8");
assert.match(agentRun, /withCompareFailedRetryNotice/);
assert.match(agentRun, /rectification-compare-candidates/);
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
assert.match(tools, /engine_message: engineMessageForReceipt/);
});
test("idle persist on a stale snapshot calls candidate score once", async () => {
let scoreCalls = 0;
const previous = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/api/rectification/v5/score")) {
scoreCalls += 1;
return {
ok: true,
status: 200,
json: async () => scoreEnginePayload(),
};
}
throw new Error(`unexpected fetch ${url}`);
}) as typeof fetch;
try {
const raw = staleDossier({ activeFocus: null });
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
get_agentic_rectification_case_compute: () => computeFixture(),
persist_agentic_rectification_candidate_v2: (_fn, args) => ({
result_id: RESULT_ID,
candidates: args.p_candidates,
overall_confidence: "medium",
selection_allowed: true,
confirmation_allowed: false,
representative_time: "05:02",
evidence_ledger_fingerprint: args.p_evidence_ledger_fingerprint,
candidate_range_fingerprint: args.p_candidate_range_fingerprint,
skill_version: args.p_skill_version,
algorithm_version: args.p_algorithm_version,
event_contract_version: args.p_event_contract_version,
decision_policy_version: args.p_decision_policy_version,
decision_receipt: args.p_decision_receipt,
execution_ledger: args.p_execution_ledger,
created_at: "2026-09-07T00:00:00.000Z",
}),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
});
await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
askedTurnId: TURN_ID,
});
assert.equal(scoreCalls, 1);
await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
askedTurnId: TURN_ID,
});
assert.equal(scoreCalls, 1);
} finally {
globalThis.fetch = previous;
}
});
test("STOP on a stale snapshot rescores then delivers a range", async () => {
const previous = globalThis.fetch;
globalThis.fetch = (async () => {
throw new Error("engine down");
}) as typeof fetch;
try {
const raw = staleDossier();
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => raw,
get_agentic_rectification_case_compute: () => computeFixture(),
apply_agentic_rectification_choice_action: (_fn, args) => ({
action_id: args.p_action_id,
status: "applied",
idempotent: false,
question_id: args.p_question_id,
option_id: args.p_option_id,
probe_id: "p-d9",
revision: Number(args.p_expected_revision) + 1,
source_quote: args.p_source_quote,
derived_context: args.p_derived_context,
narration: args.p_narration,
focus_status: args.p_focus_status,
}),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
});
const applied = await applyRectificationChoice(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
actionId: ACTION_ID,
action: STOP_ACTION,
focusId: FOCUS_ID,
questionId: QUESTION_ID,
optionId: "stop",
expectedRevision: 1,
});
assert.ok(ADOPT_OUTCOMES.has(applied.nextAction.session_outcome));
assert.equal(applied.nextAction.can_adopt, true);
assert.match(applied.narration, new RegExp(RECTIFICATION_USER_COPY.lastSuccessfulCompareRange));
assert.ok(applied.narration.includes(RECTIFICATION_TERMINATION_COPY) || applied.narration.includes("范围"));
} finally {
globalThis.fetch = previous;
}
});
@@ -3,13 +3,16 @@ import test from "node:test";
import {
RectificationEngineError,
engineRequestBody,
mergeVedastroValidateIntoReceipt,
runV9CandidateScore,
runV9Diagnostics,
runV9VedastroValidate,
sanitizeAskedProbeKeysForEngine,
toEngineEvents,
type V9EngineScoreResult,
} from "../src/lib/rectification-agentic/v9/engine-client.ts";
import { askedSemanticKeysForEngine } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
const RANGE = { start_time: "04:50", end_time: "05:10" };
const CANDIDATE_ID = "88888888-8888-4888-8888-888888888881";
@@ -476,3 +479,35 @@ test("vedastro-validate keeps safe timeout, HTTP, and invalid-response failure c
globalThis.fetch = previous;
}
});
const VARGA_SPLIT_HASH = "04:45-05:15:04:47,04:51,04:53,04:59,05:00,05:06,05:08,05:13,05:15:varga.d9.04:47|04:51/05:00|05:06|04:59|04:53/05:08|05:13|05:15";
test("engineRequestBody drops varga split hashes and keeps short semantic keys", () => {
assert.equal(VARGA_SPLIT_HASH.length, 128);
const receipt = {
inference_state: {
answered_probes: [{
probe_id: "contrast:varga.d9.style",
semantic_key: "varga.d9.style",
candidate_split_hash: VARGA_SPLIT_HASH,
answer_class: "yes",
classified_from: "choice",
}],
},
};
const semantic = askedSemanticKeysForEngine(receipt, []);
assert.deepEqual(semantic, ["varga.d9.style"]);
assert.equal(semantic.includes(VARGA_SPLIT_HASH), false);
const body = engineRequestBody({
baselineBirthSnapshot: SNAPSHOT,
candidateRange: RANGE,
events: toEngineEvents(EVIDENCE),
askedProbeKeys: [VARGA_SPLIT_HASH, "varga.d9.style", "k".repeat(201)],
});
const keys = body.asked_probe_keys as string[];
assert.ok(Array.isArray(keys));
assert.equal(keys.includes(VARGA_SPLIT_HASH), false);
assert.equal(keys.some((key) => key.includes(":varga.")), false);
assert.ok(keys.every((key) => key.length <= 120));
assert.deepEqual(sanitizeAskedProbeKeysForEngine([VARGA_SPLIT_HASH, "varga.d9.style"]), ["varga.d9.style"]);
});
@@ -281,15 +281,17 @@ test("yearless cards cannot keep period-presupposing option copy; oos_blind with
],
sessionOutcome: "validate_holdout",
oosBlindPrompts: [{
domain: "family",
user_meaning: "校时还没用过家人这条线。有没有一件没提过、但记得大概时间的家人变化?",
domain: "health_pressure",
user_meaning: "身体或压力这条线还没用过。有没有记得大概时间的健康变化?",
used_for_scoring: false,
}],
candidatesSeparated: true,
});
assert.ok(plan.next_followup);
assert.equal(plan.next_followup!.choice_frame, null);
assert.equal(plan.next_followup!.source, "oos_blind");
assert.equal(plan.next_followup!.intent, "collect_method_evidence");
assert.equal(plan.next_followup!.source, "method_coverage");
assert.equal(plan.next_followup!.domain, "health_pressure");
});
function completeAndCheck(): boolean {