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:
@@ -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_range,session_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:45–05:15。"),
|
||||
`目前范围 04:45–05: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 {
|
||||
|
||||
Reference in New Issue
Block a user