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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user