fix(web): stop adopting tied rectification candidates after method coverage
Coverage complete only unlocks discrimination. A 34/33/33 window plus an occupation note must ask a D9/D10 contrast probe instead of offering a stale winner card. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -44,12 +44,17 @@ test("low-confidence near ties never receive a recommendation badge", () => {
|
||||
assert.equal(isRecommendedRectificationCandidate(result, result.candidates[0]!), false);
|
||||
});
|
||||
|
||||
test("only an unselected representative receives a recommendation badge", () => {
|
||||
test("only an unselected separated representative receives a recommendation badge", () => {
|
||||
const result = parseRectificationCandidateResult({
|
||||
...camelCaseSnapshot,
|
||||
overallConfidence: "medium",
|
||||
confirmationAllowed: false,
|
||||
representativeTime: "05:07",
|
||||
candidates: [
|
||||
{ candidateId: CANDIDATE_ID, rank: 1, time: "05:07", relativeSupport: 62, tiedMinuteCount: 1 },
|
||||
{ candidateId: SECOND_CANDIDATE_ID, rank: 2, time: "05:08", relativeSupport: 22, tiedMinuteCount: 1 },
|
||||
{ candidateId: THIRD_CANDIDATE_ID, rank: 3, time: "05:09", relativeSupport: 16, tiedMinuteCount: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
|
||||
@@ -374,7 +374,7 @@ test("distinguish follow-up copy forbids competing-chart ranking", () => {
|
||||
assert.match(plan.next_followup?.user_prompt_hint ?? "", /不得发明年份/);
|
||||
});
|
||||
|
||||
test("GET A/B card stays hidden once representative time can be offered", () => {
|
||||
test("GET A/B card stays visible after coverage when candidates remain tied", () => {
|
||||
const card = projectRectificationChoiceCard({
|
||||
evidence: [{
|
||||
status: "confirmed",
|
||||
@@ -411,6 +411,11 @@ test("GET A/B card stays hidden once representative time can be offered", () =>
|
||||
eventProbes: [MOVE_PROBE],
|
||||
selectionAllowed: true,
|
||||
proposeAllowed: true,
|
||||
candidateScores: [
|
||||
{ time: "05:00", score: 34 },
|
||||
{ time: "05:01", score: 33 },
|
||||
{ time: "05:02", score: 33 },
|
||||
],
|
||||
activeFocus: {
|
||||
intent: "distinguish_candidates",
|
||||
targetDomain: "relocation",
|
||||
@@ -418,6 +423,54 @@ test("GET A/B card stays hidden once representative time can be offered", () =>
|
||||
expectedAnswerSchema: { choice: SAMPLE_COPY },
|
||||
},
|
||||
});
|
||||
assert.ok(card);
|
||||
assert.equal(card.prompt, SAMPLE_COPY.prompt);
|
||||
});
|
||||
|
||||
test("GET A/B card stays hidden once candidates are separated and ready to offer", () => {
|
||||
const card = projectRectificationChoiceCard({
|
||||
evidence: [{
|
||||
status: "confirmed",
|
||||
domain: "education",
|
||||
datePrecision: "year",
|
||||
occurredFrom: "2016-01-01",
|
||||
occurredTo: null,
|
||||
}, {
|
||||
status: "confirmed",
|
||||
domain: "relationship",
|
||||
datePrecision: "year",
|
||||
occurredFrom: "2018-01-01",
|
||||
occurredTo: null,
|
||||
}, {
|
||||
status: "confirmed",
|
||||
domain: "career",
|
||||
datePrecision: "year",
|
||||
occurredFrom: "2019-01-01",
|
||||
occurredTo: null,
|
||||
}, {
|
||||
status: "confirmed",
|
||||
domain: "family",
|
||||
datePrecision: "year",
|
||||
occurredFrom: "2020-01-01",
|
||||
occurredTo: null,
|
||||
}, {
|
||||
status: "draft",
|
||||
domain: "occupation",
|
||||
datePrecision: "unknown",
|
||||
occurredFrom: null,
|
||||
occurredTo: null,
|
||||
eventKind: "occupation_note",
|
||||
}],
|
||||
eventProbes: [MOVE_PROBE],
|
||||
selectionAllowed: true,
|
||||
proposeAllowed: true,
|
||||
holdoutValidation: "passed",
|
||||
candidateScores: [
|
||||
{ time: "05:00", score: 62 },
|
||||
{ time: "05:01", score: 22 },
|
||||
{ time: "05:02", score: 16 },
|
||||
],
|
||||
});
|
||||
assert.equal(card, null);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildCandidateContrastPacket,
|
||||
selectDiscriminatorProbe,
|
||||
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
|
||||
import { evaluateCandidateSeparation } from "../src/lib/rectification-agentic/core/candidate-separation.ts";
|
||||
import { decideNextAction } from "../src/lib/rectification-agentic/core/decide-next-action.ts";
|
||||
import {
|
||||
classifySnapshotStaleReason,
|
||||
scoreableSnapshotIsCurrent,
|
||||
snapshotIsCurrent,
|
||||
type CandidateSnapshotSource,
|
||||
} from "../src/lib/rectification-agentic/core/snapshot-source.ts";
|
||||
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { buildSkillVerificationReport } from "../src/lib/rectification-agentic/v9/skill-verification-report.ts";
|
||||
|
||||
const TIED = [
|
||||
{ id: "c0", time: "05:00", score: 34 },
|
||||
{ id: "c1", time: "05:01", score: 33 },
|
||||
{ id: "c2", time: "05:02", score: 33 },
|
||||
];
|
||||
|
||||
const SEPARATED = [
|
||||
{ id: "c0", time: "05:00", score: 62 },
|
||||
{ id: "c1", time: "05:01", score: 22 },
|
||||
{ id: "c2", time: "05:02", score: 16 },
|
||||
];
|
||||
|
||||
const CONTRAST_PROBE = selectDiscriminatorProbe(buildCandidateContrastPacket({
|
||||
candidateSetVersion: "05:00-05:02:05:00,05:01,05:02",
|
||||
calculationResultId: "11111111-1111-4111-8111-111111111111",
|
||||
engineProbes: [{
|
||||
semantic_key: "career.2018.dasha_activation",
|
||||
candidate_split_hash: "career:2018:05:00|05:01",
|
||||
domain: "career",
|
||||
year: 2018,
|
||||
user_meaning: "2018 年前后是否职责明显加重?",
|
||||
information_gain: 0.21,
|
||||
expected_outcomes: [
|
||||
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:01", "05:02"] },
|
||||
{ answer_class: "no", supports: ["05:01"], conflicts: ["05:00"] },
|
||||
],
|
||||
}],
|
||||
vargaDifferences: [{ layer: "d10", signs: ["巨蟹", "狮子", "处女"] }],
|
||||
}));
|
||||
|
||||
function source(overrides: Partial<CandidateSnapshotSource> = {}): CandidateSnapshotSource {
|
||||
return {
|
||||
birthProfileFingerprint: "birth-a",
|
||||
scoreableEvidenceFingerprint: "score-a",
|
||||
inferenceRevision: 3,
|
||||
candidateSetVersion: "set-a",
|
||||
scoringPolicyVersion: "policy-v2",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("does not adopt when method coverage is complete but candidates remain tied", () => {
|
||||
const next = decideNextAction({
|
||||
methodCoverageAll: true,
|
||||
proposeAllowed: true,
|
||||
candidateScores: TIED,
|
||||
discriminatorProbe: CONTRAST_PROBE,
|
||||
});
|
||||
assert.equal(next.type, "ask_candidate_discriminator");
|
||||
assert.equal(next.separation.status, "not_separated");
|
||||
assert.ok((next.probe?.expectedOutcomes.length ?? 0) >= 2);
|
||||
});
|
||||
|
||||
test("tied candidates prefer a discriminator probe with at least two predicted outcomes", () => {
|
||||
const next = decideNextAction({
|
||||
methodCoverageAll: true,
|
||||
proposeAllowed: true,
|
||||
candidateScores: TIED,
|
||||
discriminatorProbe: CONTRAST_PROBE,
|
||||
});
|
||||
assert.equal(next.type, "ask_candidate_discriminator");
|
||||
assert.ok((next.probe?.expectedOutcomes.length ?? 0) >= 2);
|
||||
});
|
||||
|
||||
test("holdout that has not passed cannot enter ready_to_adopt", () => {
|
||||
const next = decideNextAction({
|
||||
methodCoverageAll: true,
|
||||
proposeAllowed: true,
|
||||
candidateScores: SEPARATED,
|
||||
holdoutValidation: "not_started",
|
||||
});
|
||||
assert.equal(next.type, "ask_holdout_validation");
|
||||
assert.notEqual(next.type, "ready_to_adopt");
|
||||
});
|
||||
|
||||
test("separated candidates with holdout passed are ready to adopt", () => {
|
||||
const next = decideNextAction({
|
||||
methodCoverageAll: true,
|
||||
proposeAllowed: true,
|
||||
candidateScores: SEPARATED,
|
||||
holdoutValidation: "passed",
|
||||
});
|
||||
assert.equal(next.type, "ready_to_adopt");
|
||||
});
|
||||
|
||||
test("missing method coverage stays in fact collection even if scores look separated", () => {
|
||||
const next = decideNextAction({
|
||||
methodCoverageAll: false,
|
||||
proposeAllowed: true,
|
||||
candidateScores: SEPARATED,
|
||||
discriminatorProbe: CONTRAST_PROBE,
|
||||
});
|
||||
assert.equal(next.type, "ask_fact_collection");
|
||||
});
|
||||
|
||||
test("D9/D10 sign differences synthesize a contrast probe when engine probes are empty", () => {
|
||||
const packet = buildCandidateContrastPacket({
|
||||
candidateSetVersion: "set-a",
|
||||
calculationResultId: "22222222-2222-4222-8222-222222222222",
|
||||
vargaDifferences: [
|
||||
{ layer: "d9", signs: ["天秤", "天蝎", "射手"] },
|
||||
{ layer: "d10", signs: ["巨蟹", "狮子", "处女"] },
|
||||
],
|
||||
});
|
||||
const probe = selectDiscriminatorProbe(packet);
|
||||
assert.ok(probe);
|
||||
assert.ok(probe.expectedOutcomes.length >= 2);
|
||||
assert.equal(probe.sourceFeatures[0]?.calculationResultId, "22222222-2222-4222-8222-222222222222");
|
||||
assert.match(probe.question, /职业前事|事业盘/);
|
||||
});
|
||||
|
||||
test("non-scoreable occupation note does not change the scoreable evidence fingerprint", () => {
|
||||
const dated = [{
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
|
||||
status: "confirmed" as const,
|
||||
eventKind: "business_start",
|
||||
domain: "career",
|
||||
occurredFrom: "2026-07-19",
|
||||
occurredTo: null,
|
||||
datePrecision: "day" as const,
|
||||
summary: "注册公司",
|
||||
}];
|
||||
const withNote = [...dated, {
|
||||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2",
|
||||
status: "confirmed" as const,
|
||||
eventKind: "occupation_note",
|
||||
domain: "occupation",
|
||||
occurredFrom: null,
|
||||
occurredTo: null,
|
||||
datePrecision: "unknown" as const,
|
||||
summary: "长期一直是程序员",
|
||||
}];
|
||||
const before = evidenceLedgerFingerprint(dated as never);
|
||||
const after = evidenceLedgerFingerprint(withNote as never);
|
||||
assert.equal(after, before);
|
||||
const snapshot = source({ scoreableEvidenceFingerprint: before });
|
||||
const current = source({ scoreableEvidenceFingerprint: after, inferenceRevision: snapshot.inferenceRevision });
|
||||
assert.equal(snapshotIsCurrent(snapshot, current), true);
|
||||
assert.equal(scoreableSnapshotIsCurrent(snapshot, current), true);
|
||||
});
|
||||
|
||||
test("scoreable occupation evidence requires a new inference revision", () => {
|
||||
const before = source();
|
||||
const afterScoreable = source({
|
||||
scoreableEvidenceFingerprint: "score-b",
|
||||
inferenceRevision: before.inferenceRevision + 1,
|
||||
});
|
||||
assert.equal(classifySnapshotStaleReason(before, afterScoreable), "scoreable_evidence_changed");
|
||||
assert.equal(afterScoreable.inferenceRevision, before.inferenceRevision + 1);
|
||||
});
|
||||
|
||||
test("candidate cards must be created from the current inference and scoreable revisions", () => {
|
||||
const current = source({ inferenceRevision: 4 });
|
||||
const card = source({ inferenceRevision: 3 });
|
||||
assert.equal(classifySnapshotStaleReason(card, current), "inference_revision_changed");
|
||||
const matching = source({ inferenceRevision: 4 });
|
||||
assert.equal(snapshotIsCurrent(matching, current), true);
|
||||
});
|
||||
|
||||
test("stale reasons distinguish birth profile from scoreable evidence", () => {
|
||||
const current = source();
|
||||
assert.equal(
|
||||
classifySnapshotStaleReason(source({ birthProfileFingerprint: "birth-b" }), current),
|
||||
"birth_profile_changed",
|
||||
);
|
||||
assert.equal(
|
||||
classifySnapshotStaleReason(source({ scoreableEvidenceFingerprint: "score-b" }), current),
|
||||
"scoreable_evidence_changed",
|
||||
);
|
||||
assert.equal(
|
||||
classifySnapshotStaleReason(source({ candidateSetVersion: "set-b" }), current),
|
||||
"candidate_set_superseded",
|
||||
);
|
||||
});
|
||||
|
||||
test("34/33/33 is a tie, not a recommended winner", () => {
|
||||
const separation = evaluateCandidateSeparation(TIED);
|
||||
assert.equal(separation.status, "not_separated");
|
||||
assert.equal(separation.sufficient, false);
|
||||
assert.deepEqual(separation.credibleRange, ["05:00", "05:01", "05:02"]);
|
||||
assert.equal(separation.representativeTime, "05:00");
|
||||
});
|
||||
|
||||
test("final report does not claim executed techniques without calculationResultId", () => {
|
||||
const report = buildSkillVerificationReport({
|
||||
representativeTime: "05:00",
|
||||
widthMinutes: 3,
|
||||
candidates: [
|
||||
{ time: "05:00", rank: 1, relativeSupport: 34 },
|
||||
{ time: "05:01", rank: 2, relativeSupport: 33 },
|
||||
{ time: "05:02", rank: 3, relativeSupport: 33 },
|
||||
],
|
||||
separation: evaluateCandidateSeparation(TIED),
|
||||
techniqueAuditTable: [
|
||||
{ technique: "D9", status: "executed", note: "no id" },
|
||||
{ technique: "D10", status: "executed", note: "has id", calculation_result_id: "33333333-3333-4333-8333-333333333333" },
|
||||
],
|
||||
eventFitRate: {
|
||||
matched: 8,
|
||||
total: 9,
|
||||
percent: 89,
|
||||
label: "8/9",
|
||||
user_meaning: "这段时间窗对已收事件有一定解释力",
|
||||
unique_minute_claim: false,
|
||||
},
|
||||
});
|
||||
assert.match(report, /基本并列/);
|
||||
assert.match(report, /事件拟合程度,不是候选区分程度/);
|
||||
assert.match(report, /\| D9 \| input_covered \|/);
|
||||
assert.match(report, /\| D10 \| executed \|/);
|
||||
assert.doesNotMatch(report, /当前推荐/);
|
||||
});
|
||||
@@ -812,21 +812,27 @@ test("precision stage lagna_frame waits for uncovered career before asking anoth
|
||||
assert.doesNotMatch(JSON.stringify(plan), UNIQUE_MINUTE_COPY);
|
||||
});
|
||||
|
||||
test("lagna_frame after classic coverage does not keep blocking representative time cards", () => {
|
||||
test("lagna_frame after classic coverage still discriminates when candidates are tied", () => {
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: CLASSIC_COVERAGE,
|
||||
precisionStage: "lagna_frame",
|
||||
});
|
||||
assert.equal(plan.next_followup?.source, "precision_stage");
|
||||
assert.equal(plan.next_followup?.ask_theme, "dated_event");
|
||||
assert.equal(isOfferBlockingFollowup(plan.next_followup, plan.methods), false);
|
||||
assert.equal(isOfferBlockingFollowup(plan.next_followup, plan.methods), true);
|
||||
assert.equal(isOfferBlockingFollowup(plan.next_followup, plan.methods, { separated: true }), false);
|
||||
assert.equal(conversationalSessionOutcome({
|
||||
selectionAllowed: true,
|
||||
proposeAllowed: true,
|
||||
confirmationAllowed: false,
|
||||
nextFollowup: plan.next_followup,
|
||||
methods: plan.methods,
|
||||
}), "adopt_representative");
|
||||
candidateScores: [
|
||||
{ time: "05:00", score: 34 },
|
||||
{ time: "05:01", score: 33 },
|
||||
{ time: "05:02", score: 33 },
|
||||
],
|
||||
}), "discriminate_candidates");
|
||||
});
|
||||
|
||||
test("precision stage d4 asks home change not family, and d5 asks education", () => {
|
||||
@@ -1007,7 +1013,7 @@ test("career evidence does not cover occupation; occupation still blocks until a
|
||||
}), "collect_evidence");
|
||||
});
|
||||
|
||||
test("draft occupation_note without a date covers occupation and unblocks offering", () => {
|
||||
test("draft occupation_note without a date covers occupation and does not adopt a tie", () => {
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: [
|
||||
{ status: "confirmed", domain: "education", datePrecision: "year", occurredFrom: "2016-01-01", occurredTo: null },
|
||||
@@ -1027,12 +1033,17 @@ test("draft occupation_note without a date covers occupation and unblocks offeri
|
||||
});
|
||||
assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "covered");
|
||||
assert.notEqual(plan.next_followup?.method_id, "occupation");
|
||||
assert.equal(conversationalSessionOutcome({
|
||||
assert.notEqual(conversationalSessionOutcome({
|
||||
selectionAllowed: true,
|
||||
proposeAllowed: true,
|
||||
confirmationAllowed: false,
|
||||
nextFollowup: plan.next_followup,
|
||||
methods: plan.methods,
|
||||
candidateScores: [
|
||||
{ time: "05:00", score: 34 },
|
||||
{ time: "05:01", score: 33 },
|
||||
{ time: "05:02", score: 33 },
|
||||
],
|
||||
}), "adopt_representative");
|
||||
});
|
||||
|
||||
@@ -1059,15 +1070,76 @@ test("stale occupation collect focus does not keep interviewing after occupation
|
||||
targetKind: "occupation_note",
|
||||
},
|
||||
});
|
||||
assert.notEqual(plan.next_followup?.method_id, "active_focus");
|
||||
assert.notEqual(plan.next_followup?.method_id, "occupation");
|
||||
assert.notEqual(conversationalSessionOutcome({
|
||||
selectionAllowed: true,
|
||||
proposeAllowed: true,
|
||||
confirmationAllowed: false,
|
||||
nextFollowup: plan.next_followup,
|
||||
methods: plan.methods,
|
||||
candidateScores: [
|
||||
{ time: "05:00", score: 34 },
|
||||
{ time: "05:01", score: 33 },
|
||||
{ time: "05:02", score: 33 },
|
||||
],
|
||||
}), "adopt_representative");
|
||||
});
|
||||
|
||||
test("D9/D10 contrast after occupation coverage asks a discriminator, not adopt", () => {
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: CLASSIC_COVERAGE.filter((item) => item.domain !== "horary"),
|
||||
contrastPacket: {
|
||||
candidateSetVersion: "05:00-05:02",
|
||||
vargaDifferences: [
|
||||
{ layer: "d9", signs: ["天秤", "天蝎", "射手"] },
|
||||
{ layer: "d10", signs: ["巨蟹", "狮子", "处女"] },
|
||||
],
|
||||
probes: [{
|
||||
probeId: "contrast:varga.d10.巨蟹|狮子|处女",
|
||||
candidateSetVersion: "05:00-05:02",
|
||||
question: "当前几个候选在事业盘上还分得开。请核对一段还没用进评分的职业前事。",
|
||||
expectedOutcomes: [
|
||||
{ outcomeId: "supports_巨蟹", supportsCandidateIds: ["巨蟹"], conflictsCandidateIds: ["狮子", "处女"] },
|
||||
{ outcomeId: "supports_狮子", supportsCandidateIds: ["狮子"], conflictsCandidateIds: ["巨蟹", "处女"] },
|
||||
{ outcomeId: "supports_处女", supportsCandidateIds: ["处女"], conflictsCandidateIds: ["巨蟹", "狮子"] },
|
||||
],
|
||||
candidateSplitHash: "varga.d10.巨蟹|狮子|处女",
|
||||
informationGain: 0.12,
|
||||
sourceFeatures: [{ technique: "D10", calculationResultId: RESULT_ID }],
|
||||
domain: "career",
|
||||
year: null,
|
||||
semanticKey: "varga.d10.巨蟹|狮子|处女",
|
||||
}],
|
||||
},
|
||||
});
|
||||
assert.equal(plan.next_followup?.intent, "distinguish_candidates");
|
||||
assert.match(plan.next_followup?.user_prompt_hint ?? "", /职业前事|事业盘/);
|
||||
assert.equal(conversationalSessionOutcome({
|
||||
selectionAllowed: true,
|
||||
proposeAllowed: true,
|
||||
confirmationAllowed: false,
|
||||
nextFollowup: plan.next_followup,
|
||||
methods: plan.methods,
|
||||
}), "adopt_representative");
|
||||
discriminatorProbe: {
|
||||
probeId: "contrast:varga.d10",
|
||||
candidateSetVersion: "05:00-05:02",
|
||||
question: "核对一段还没用进评分的职业前事",
|
||||
expectedOutcomes: [
|
||||
{ outcomeId: "a", supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["05:01"] },
|
||||
{ outcomeId: "b", supportsCandidateIds: ["05:01"], conflictsCandidateIds: ["05:00"] },
|
||||
],
|
||||
candidateSplitHash: "varga.d10",
|
||||
informationGain: 0.12,
|
||||
sourceFeatures: [{ technique: "D10", calculationResultId: RESULT_ID }],
|
||||
domain: "career",
|
||||
year: null,
|
||||
semanticKey: "varga.d10",
|
||||
},
|
||||
candidateScores: [
|
||||
{ time: "05:00", score: 34 },
|
||||
{ time: "05:01", score: 33 },
|
||||
{ time: "05:02", score: 33 },
|
||||
],
|
||||
}), "discriminate_candidates");
|
||||
});
|
||||
|
||||
|
||||
@@ -1125,10 +1197,10 @@ test("high information_gain leftover probe still blocks offering after coverage"
|
||||
confirmationAllowed: false,
|
||||
nextFollowup: plan.next_followup,
|
||||
methods: plan.methods,
|
||||
}), "collect_evidence");
|
||||
}), "discriminate_candidates");
|
||||
});
|
||||
|
||||
test("event_probe does not block offering once blocking methods are covered", () => {
|
||||
test("event_probe still discriminates after coverage when candidates remain tied", () => {
|
||||
const plan = buildMethodFollowupPlan({
|
||||
evidence: CLASSIC_COVERAGE.filter((item) => item.domain !== "horary"),
|
||||
eventProbes: [{
|
||||
@@ -1144,14 +1216,20 @@ test("event_probe does not block offering once blocking methods are covered", ()
|
||||
role: "reverse_verify",
|
||||
}],
|
||||
});
|
||||
assert.equal(isOfferBlockingFollowup(plan.next_followup, plan.methods), false);
|
||||
assert.equal(plan.next_followup?.source, "event_probe");
|
||||
assert.equal(isOfferBlockingFollowup(plan.next_followup, plan.methods), true);
|
||||
assert.equal(conversationalSessionOutcome({
|
||||
selectionAllowed: true,
|
||||
proposeAllowed: true,
|
||||
confirmationAllowed: false,
|
||||
nextFollowup: plan.next_followup,
|
||||
methods: plan.methods,
|
||||
}), "adopt_representative");
|
||||
candidateScores: [
|
||||
{ time: "05:00", score: 34 },
|
||||
{ time: "05:01", score: 33 },
|
||||
{ time: "05:02", score: 33 },
|
||||
],
|
||||
}), "discriminate_candidates");
|
||||
});
|
||||
|
||||
test("accepted time reverse-verifies an uncovered year in a covered domain", () => {
|
||||
@@ -1206,6 +1284,12 @@ test("declining occupation covers the method; declining horary is skipped_by_pol
|
||||
confirmationAllowed: false,
|
||||
nextFollowup: plan.next_followup,
|
||||
methods: plan.methods,
|
||||
candidateScores: [
|
||||
{ time: "05:00", score: 62 },
|
||||
{ time: "05:01", score: 22 },
|
||||
{ time: "05:02", score: 16 },
|
||||
],
|
||||
holdoutValidation: "passed",
|
||||
}), "adopt_representative");
|
||||
});
|
||||
|
||||
@@ -1214,12 +1298,19 @@ test("horary follow-up does not block propose once occupation is covered", () =>
|
||||
evidence: CLASSIC_COVERAGE.filter((item) => item.domain !== "horary"),
|
||||
});
|
||||
assert.equal(plan.next_followup?.method_id, "horary");
|
||||
assert.equal(isOfferBlockingFollowup(plan.next_followup, plan.methods), false);
|
||||
assert.equal(conversationalSessionOutcome({
|
||||
selectionAllowed: true,
|
||||
proposeAllowed: true,
|
||||
confirmationAllowed: false,
|
||||
nextFollowup: plan.next_followup,
|
||||
methods: plan.methods,
|
||||
candidateScores: [
|
||||
{ time: "05:00", score: 62 },
|
||||
{ time: "05:01", score: 22 },
|
||||
{ time: "05:02", score: 16 },
|
||||
],
|
||||
holdoutValidation: "passed",
|
||||
}), "adopt_representative");
|
||||
});
|
||||
|
||||
@@ -1275,6 +1366,7 @@ test("user stop with selection_allowed may offer the escape hatch", async () =>
|
||||
latestResult: candidateSnapshotFixture({
|
||||
selectionAllowed: true,
|
||||
representativeTime: "04:48",
|
||||
evidenceLedgerFingerprint: null,
|
||||
candidates: [
|
||||
{ candidate_id: CANDIDATE_ID, rank: 1, time: "04:48", relative_support: 58, tied_minute_count: 1 },
|
||||
{ candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "04:49", relative_support: 42, tied_minute_count: 2 },
|
||||
@@ -1306,3 +1398,104 @@ test("user stop with selection_allowed may offer the escape hatch", async () =>
|
||||
);
|
||||
});
|
||||
|
||||
test("offer-candidates refuses a 34/33/33 tie after method coverage", async () => {
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture({
|
||||
evidence: [
|
||||
{ ...educationEvidence, domain: "education" },
|
||||
{
|
||||
id: "44444444-4444-4444-8444-444444444442",
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: "relationship_start",
|
||||
domain: "relationship",
|
||||
occurred_from: "2018-01-01",
|
||||
occurred_to: null,
|
||||
date_precision: "year",
|
||||
summary: "感情变化",
|
||||
status: "confirmed",
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-12T10:00:07.000Z",
|
||||
},
|
||||
{
|
||||
id: "44444444-4444-4444-8444-444444444443",
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: "career_entry",
|
||||
domain: "career",
|
||||
occurred_from: "2019-01-01",
|
||||
occurred_to: null,
|
||||
date_precision: "year",
|
||||
summary: "工作变化",
|
||||
status: "confirmed",
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-12T10:00:08.000Z",
|
||||
},
|
||||
{
|
||||
id: "44444444-4444-4444-8444-444444444446",
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: "family_event",
|
||||
domain: "family",
|
||||
occurred_from: "2020-01-01",
|
||||
occurred_to: null,
|
||||
date_precision: "year",
|
||||
summary: "家人变化",
|
||||
status: "confirmed",
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-12T10:00:09.000Z",
|
||||
},
|
||||
{
|
||||
id: "44444444-4444-4444-8444-444444444445",
|
||||
source_turn_id: TURN_ID,
|
||||
subject: "self",
|
||||
event_kind: "occupation_note",
|
||||
domain: "occupation",
|
||||
occurred_from: null,
|
||||
occurred_to: null,
|
||||
date_precision: "unknown",
|
||||
summary: "长期一直是程序员",
|
||||
status: "confirmed",
|
||||
supersedes_evidence_id: null,
|
||||
created_at: "2026-08-12T10:00:10.000Z",
|
||||
},
|
||||
],
|
||||
latestResult: candidateSnapshotFixture({
|
||||
selectionAllowed: true,
|
||||
representativeTime: "05:00",
|
||||
evidenceLedgerFingerprint: null,
|
||||
decisionReceipt: {
|
||||
propose_allowed: true,
|
||||
window_scan: {
|
||||
scanned: true,
|
||||
d9_lagna_count: 3,
|
||||
d10_lagna_count: 3,
|
||||
d9_candidates_differ: true,
|
||||
d10_candidates_differ: true,
|
||||
d9_sign_names: ["天秤", "天蝎", "射手"],
|
||||
d10_sign_names: ["巨蟹", "狮子", "处女"],
|
||||
},
|
||||
},
|
||||
candidates: [
|
||||
{ candidate_id: CANDIDATE_ID, rank: 1, time: "05:00", relative_support: 34, tied_minute_count: 1 },
|
||||
{ candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "05:01", relative_support: 33, tied_minute_count: 1 },
|
||||
{ candidate_id: THIRD_CANDIDATE_ID, rank: 3, time: "05:02", relative_support: 33, tied_minute_count: 1 },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const tools = createRectificationV9Tools({
|
||||
userId: USER_ID,
|
||||
caseId: CASE_ID,
|
||||
turnId: TURN_ID,
|
||||
accounting: accounting.client as never,
|
||||
});
|
||||
await assert.rejects(
|
||||
() => (tools["rectification-offer-candidates"] as unknown as {
|
||||
execute(input: unknown): Promise<unknown>;
|
||||
}).execute({ caseId: CASE_ID }),
|
||||
(error: unknown) => error instanceof RectificationToolServiceError && error.code === "offer_not_allowed",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -71,7 +71,9 @@ test("system prompt carries only high-priority boundaries, never the method copy
|
||||
assert.match(prompt, /工具执行过程保持静默/);
|
||||
assert.match(prompt, /思考过程必须用简体中文/);
|
||||
assert.match(prompt, /skill_verification_report/);
|
||||
assert.match(prompt, /精度阶段追问不挡出牌/);
|
||||
assert.match(prompt, /ask_candidate_discriminator/);
|
||||
assert.match(prompt, /方法覆盖已齐只进入候选区分/);
|
||||
assert.doesNotMatch(prompt, /方法覆盖已齐且 propose_allowed 时本轮 adopt/);
|
||||
assert.match(prompt, /不得询问外貌、体质、胎记或疤痕/);
|
||||
assert.match(prompt, /expectedAnswerSchema.choice/);
|
||||
assert.match(prompt, /不要写 choice/);
|
||||
|
||||
@@ -226,6 +226,8 @@ export function candidateSnapshotFixture(overrides: {
|
||||
selectedTime?: string | null;
|
||||
selectionKind?: string | null;
|
||||
candidates?: unknown[];
|
||||
evidenceLedgerFingerprint?: string | null;
|
||||
decisionReceipt?: Record<string, unknown>;
|
||||
} = {}) {
|
||||
return {
|
||||
result_id: RESULT_ID,
|
||||
@@ -244,12 +246,15 @@ export function candidateSnapshotFixture(overrides: {
|
||||
confirmation_allowed: overrides.confirmationAllowed ?? false,
|
||||
representative_candidate_id: overrides.representativeTime ? CANDIDATE_ID : null,
|
||||
overall_confidence: "medium",
|
||||
...overrides.decisionReceipt,
|
||||
},
|
||||
execution_ledger: [{ method: "d1-rashi", status: "executed" }],
|
||||
representative_time: overrides.representativeTime ?? null,
|
||||
selected_time: overrides.selectedTime ?? null,
|
||||
selection_kind: overrides.selectionKind ?? null,
|
||||
evidence_ledger_fingerprint: "b".repeat(64),
|
||||
evidence_ledger_fingerprint: overrides.evidenceLedgerFingerprint === undefined
|
||||
? "b".repeat(64)
|
||||
: overrides.evidenceLedgerFingerprint,
|
||||
candidate_range_fingerprint: "c".repeat(64),
|
||||
skill_version: "9.0.0",
|
||||
algorithm_version: "rectification-v5",
|
||||
|
||||
Reference in New Issue
Block a user