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