fix(web): restore rectification discriminator cards and stop-offer path

Coverage-complete ties never persisted A/B/C/D because contrast probes were stamped with an answered education quality probe, remaining minutes were asked as window D10 signs, and 「没有了」 missed the stop pattern.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-25 14:41:48 +08:00
co-authored by Cursor
parent 20ede7cadf
commit d7afe5b50d
19 changed files with 924 additions and 48 deletions
@@ -574,7 +574,7 @@ test("rectification Agent output stays natural and keeps tool execution silent",
assert.match(agent, /本轮做了什么/);
assert.match(agent, /完成凭证完全由服务端公开 Activity\/receipt 展示/);
assert.match(agent, /禁止只说记下了、会话会保留、以后再继续/);
assert.match(agent, /暂时想不到了 \/ 没有更多 \/ 先这样/);
assert.match(agent, /暂时想不到了 \/ 没有更多 \/ 没有了 \/ 没了 \/ 没有其它 \/ 想不起来了 \/ 先这样/);
assert.match(agent, /on_user_stop/);
assert.match(agent, /skill_verification_report/);
assert.match(agent, /D9\/D10 类型对照/);
@@ -13,6 +13,7 @@ import {
parseRectificationChoiceCard,
} from "../src/lib/rectification-agentic/v9/choice-card.ts";
import { buildMethodFollowupPlan, projectRectificationChoiceCard } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts";
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
const SAMPLE_COPY = {
@@ -516,3 +517,152 @@ test("choice card user messages expose A/B/C/D as a leading key", () => {
assert.equal(parseChoiceKeyFromUserMessage(`${HOLDOUT_MESSAGE_PREFIX}B. 有类似但年份不对`), "B");
assert.equal(parseChoiceKeyFromUserMessage("没有明显发生"), null);
});
test("GET choice_card stays after coverage when remaining minutes still split on D24", () => {
const card = choiceCardFromCaseDossier({
evidence: [{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
summary: "2016 年大学入学",
}, {
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",
summary: "医疗器械算法,第三个(技术执行)",
}],
conversationSummary: {
activeFocus: {
id: "abababab-abab-4bab-8bab-abababababab",
intent: "distinguish_candidates",
targetDomain: "education",
targetKind: "education_milestone",
expectedAnswerSchema: {
choice: SAMPLE_COPY,
probe_id: "contrast:varga.d24.05:00/05:06|05:07",
semantic_key: "varga.d24.05:00/05:06|05:07",
},
},
declinedSkippedTopics: [],
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
selectionAllowed: true,
candidates: [
{ time: "05:00", relativeSupport: 34 },
{ time: "05:06", relativeSupport: 33 },
{ time: "05:07", relativeSupport: 33 },
],
decisionReceipt: {
propose_allowed: true,
window_scan: {
scanned: true,
d10_candidates_differ: true,
d10_sign_names: ["巨蟹座", "狮子座", "处女座"],
d24_candidates_differ: true,
transitions: [
{ layer: "d10", at: "05:00" },
{ layer: "d10", at: "05:15" },
{ layer: "d24", at: "05:00" },
{ layer: "d24", at: "05:06" },
],
},
inference_state: {
answered_probes: [{
probe_id: "probe:education.2016",
semantic_key: "education.2016",
answer_class: "yes",
}],
probes: [{
id: "probe:education.2016",
semantic_key: "education.2016",
information_gain: 0,
source: "known_event_quality",
expected_outcomes: [],
}],
},
},
},
case: { acceptedTime: null },
turns: [],
});
assert.ok(card);
assert.equal(card.probe_id, "contrast:varga.d24.05:00/05:06|05:07");
assert.equal(card.prompt, SAMPLE_COPY.prompt);
});
test("GET choice_card stays hidden after 没有了 when selection is allowed", () => {
const card = choiceCardFromCaseDossier({
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",
}],
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: [],
},
latestResult: {
selectionAllowed: true,
candidates: [
{ time: "05:00", relativeSupport: 34 },
{ time: "05:06", relativeSupport: 33 },
{ time: "05:07", relativeSupport: 33 },
],
decisionReceipt: { propose_allowed: true },
},
case: { acceptedTime: null },
turns: [{ role: "user", text: "没有了" }],
});
assert.equal(card, null);
});
@@ -127,6 +127,45 @@ test("D9/D10 sign differences synthesize a contrast probe when engine probes are
assert.match(probe.question, /职业前事|事业盘/);
});
test("remaining-candidate D24 split beats window D10 signs", () => {
const packet = buildCandidateContrastPacket({
candidateSetVersion: "05:00-05:07",
calculationResultId: "22222222-2222-4222-8222-222222222222",
vargaDifferences: [
{ layer: "d10", signs: ["巨蟹座", "狮子座", "处女座"] },
],
candidateTimes: ["05:00", "05:06", "05:07"],
transitions: [
{ layer: "d10", at: "05:00" },
{ layer: "d10", at: "05:15" },
{ layer: "d24", at: "05:00" },
{ layer: "d24", at: "05:06" },
],
askedKeys: ["education.2016", "varga.d10"],
});
const probe = selectDiscriminatorProbe(packet);
assert.ok(probe);
assert.match(probe.semanticKey, /varga\.d24/);
assert.equal(probe.domain, "education");
assert.deepEqual(probe.expectedOutcomes[0]?.supportsCandidateIds, ["05:00"]);
assert.ok(probe.expectedOutcomes[0]?.conflictsCandidateIds.includes("05:06"));
assert.ok(probe.expectedOutcomes.every((row) => (
row.supportsCandidateIds.every((id) => id.includes(":"))
)));
});
test("user stop with selection_allowed offers a provisional range", () => {
const next = decideNextAction({
methodCoverageAll: true,
proposeAllowed: true,
selectionAllowed: true,
userStopped: true,
candidateScores: TIED,
discriminatorProbe: CONTRAST_PROBE,
});
assert.equal(next.type, "offer_provisional_range");
});
test("non-scoreable occupation note does not change the scoreable evidence fingerprint", () => {
const dated = [{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { buildMethodFollowupPlan, buildNextUserAction, conversationalSessionOutcome, isOfferBlockingFollowup } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { buildMethodFollowupPlan, buildNextUserAction, conversationalSessionOutcome, isOfferBlockingFollowup, latestUserStoppedCollecting } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
internalObservationsFromWindowScan,
parseWindowScan,
@@ -1144,6 +1144,72 @@ test("D9/D10 contrast after occupation coverage asks a discriminator, not adopt"
}), "discriminate_candidates");
});
test("answered duty language skips window D10 and uses remaining D24", () => {
const packet = {
candidateSetVersion: "05:00-05:07",
vargaDifferences: [
{ layer: "d10", signs: ["巨蟹座", "狮子座", "处女座"] },
{ layer: "d24", signs: ["05:00", "05:06|05:07"] },
],
probes: [{
probeId: "contrast:varga.d24.05:00/05:06|05:07",
candidateSetVersion: "05:00-05:07",
question: "当前几个候选在学业盘上还分得开。请核对一段还没用进评分的学业前事。",
expectedOutcomes: [
{ outcomeId: "yes", supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["05:06", "05:07"] },
{ outcomeId: "no", supportsCandidateIds: ["05:06", "05:07"], conflictsCandidateIds: ["05:00"] },
],
candidateSplitHash: "varga.d24.05:00/05:06|05:07",
informationGain: 0.16,
sourceFeatures: [{ technique: "D24", calculationResultId: RESULT_ID }],
domain: "education",
year: null,
semanticKey: "varga.d24.05:00/05:06|05:07",
}],
};
const evidence = [
...CLASSIC_COVERAGE.filter((item) => item.domain !== "horary"),
{
status: "draft" as const,
domain: "occupation",
datePrecision: "unknown" as const,
occurredFrom: null,
occurredTo: null,
eventKind: "occupation_note",
summary: "医疗器械算法,第三个(技术执行)",
},
];
const plan = buildMethodFollowupPlan({
evidence,
contrastPacket: packet,
});
assert.equal(plan.next_followup?.domain, "education");
assert.equal(plan.next_followup?.semantic_key, "varga.d24.05:00/05:06|05:07");
assert.doesNotMatch(plan.next_followup?.semantic_key ?? "", /varga\.d10/);
});
test("没有了 is a user stop", () => {
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "没有了" }]), true);
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "没了" }]), true);
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "想不起来了" }]), true);
assert.equal(latestUserStoppedCollecting([{ role: "user", text: "还有一件升学" }]), false);
assert.equal(conversationalSessionOutcome({
selectionAllowed: true,
proposeAllowed: true,
confirmationAllowed: false,
nextFollowup: null,
methods: buildMethodFollowupPlan({
evidence: CLASSIC_COVERAGE.filter((item) => item.domain !== "horary"),
}).methods,
userStopped: true,
candidateScores: [
{ time: "05:00", score: 34 },
{ time: "05:06", score: 33 },
{ time: "05:07", score: 33 },
],
}), "provisional_range");
});
test("same domain different year still asks a conflict probe", () => {
const plan = buildMethodFollowupPlan({
@@ -322,6 +322,15 @@ test("holdout events stay out of training and a winner must stay stable for two
[{ id: "job", domain: "career", year: 2019, precision: "year" }],
);
assert.equal(matching[0]?.classified_from, "evidence");
const quality = {
...probe({ id: "p-quality", domain: "education", year: 2016, gain: 0, yesSupports: ["05:00"], yesConflicts: ["05:10"] }),
source: "known_event_quality",
};
const skippedQuality = answersFromEvidence(
[quality],
[{ id: "enroll", domain: "education", year: 2016, precision: "year" }],
);
assert.equal(skippedQuality.length, 0);
});
test("C without new evidence updates the posterior immediately and D only marks the split asked", () => {
@@ -385,6 +394,50 @@ test("C without new evidence updates the posterior immediately and D only marks
})], unsure.state.answered_probes)?.id, "p-other");
});
test("A/B/C/D on a remaining-minute contrast probe moves the posterior", () => {
const contrast: ConflictProbe = {
id: "contrast:varga.d24.05:00/05:06|05:07",
semantic_key: "varga.d24.05:00/05:06|05:07",
candidate_split_hash: "varga.d24.05:00/05:06|05:07",
domain: "education",
year: 0,
question: "学业盘还分得开",
candidate_ids: ["05:00", "05:06", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:06", "05:07"] },
{ answer_class: "no", supports: ["05:06", "05:07"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.16,
source: "varga_contrast",
};
const state = buildInferenceState({
range_start: "04:45",
range_end: "05:15",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 34 },
{ id: "05:06", time: "05:06", relative_support: 33 },
{ id: "05:07", time: "05:07", relative_support: 33 },
],
events: [{ id: "e1", domain: "education", year: 2016, precision: "year" }],
probes: [contrast],
});
const before = posteriorMap(state.candidates);
const applied = applyChoiceWithoutEvidence(state, {
choiceKey: "C",
schema: {
choice: { prompt: "那次考试有没有发挥失常?" },
probe_id: contrast.id,
semantic_key: contrast.semantic_key,
},
});
assert.equal(applied.applied, true);
assert.equal(applied.answerClass, "no");
assert.notDeepEqual(posteriorMap(applied.state.candidates), before);
assert.ok((applied.state.candidates.find((item) => item.time === "05:06")?.posterior_score ?? 0)
> (applied.state.candidates.find((item) => item.time === "05:00")?.posterior_score ?? 0));
});
test("holdout and collection declines do not write a probe answer", () => {
const conflict = probe({
id: "p-holdout",
@@ -109,3 +109,64 @@ test("duplicate focus conflict does not throw", async () => {
});
assert.equal(result.status, "duplicate_focus");
});
test("contrast probe is not replaced by an already-answered education quality probe", async () => {
const followup = discriminatorFollowup({
domain: "education",
ask_theme: "education_style",
information_gain: 0.16,
semantic_key: "varga.d24.05:00/05:06|05:07",
candidate_split_hash: "varga.d24.05:00/05:06|05:07",
probe_year: undefined,
});
const accounting = fakeAccounting({
set_agentic_rectification_conversation_focus: (_fn, args) => ({
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: null,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-08-25T00:00:00.000Z",
resolved_at: null,
idempotent: false,
}),
});
const result = await persistServerOwnedFocus({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
activeFocus: null,
decisionReceipt: {
inference_state: {
answered_probes: [{
probe_id: "probe:education.2016",
semantic_key: "education.2016",
answer_class: "yes",
}],
probes: [{
id: "probe:education.2016",
semantic_key: "education.2016",
candidate_split_hash: "education:2016",
domain: "education",
year: 2016,
question: "发挥失常",
candidate_ids: [],
expected_outcomes: [],
information_gain: 0,
source: "known_event_quality",
}],
},
},
followup,
});
assert.equal(result.status, "created");
assert.ok(result.focus);
const schema = result.focus.expectedAnswerSchema;
assert.equal(schema.semantic_key, "varga.d24.05:00/05:06|05:07");
assert.notEqual(schema.probe_id, "probe:education.2016");
assert.match(String(schema.probe_id), /varga\.d24/);
});
@@ -113,6 +113,30 @@ test("Chinese interview planning after tools stays out of the spoken answer", ()
assert.equal(isRectificationProcessNarration(spoken), false);
});
test("planning about candidate_contrast_packet stays out of the spoken answer", () => {
const processTalk = [
"这意味着:方法资料已齐,还不能出牌。",
"服务器给了 candidate_contrast_packetchoice_frame 已写好。",
"第 7 条边界:id=ask_candidate_discriminator 时不得 offer。",
].join("\n\n");
const spoken = "那次高考或重要考试,发挥有没有明显失常、压力很大?说有或没有就行。";
const split = splitRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`);
assert.equal(split.spoken, spoken);
assert.match(split.thinking, /这意味着/);
assert.match(split.thinking, /candidate_contrast_packet/);
assert.match(split.thinking, /不得 offer/);
assert.match(split.thinking, /第 7 条边界/);
assert.doesNotMatch(split.thinking, /发挥有没有明显失常/);
assert.equal(isRectificationProcessNarration("这意味着:方法资料已齐"), true);
assert.equal(isRectificationProcessNarration("服务器给了 candidate_contrast_packet"), true);
assert.equal(isRectificationProcessNarration(spoken), false);
const settled = settleRectificationSpokenAndThinking(`${processTalk}\n\n${spoken}`, "");
assert.equal(settled.spoken, spoken);
assert.doesNotMatch(settled.spoken, /candidate_contrast_packet/);
});
test("leaked process text on the answer channel is not mixed into native thinking", () => {
const processTalk = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户";
const spoken = "记下了升学这两件。接下来有没有一件带大概年份的工作变化?";