fix(rectification): treat a yes discriminator as covering that domain's collect
A choice-card yes/weak_yes already told us the domain happened, so skip the same-domain "which year" collect without writing the ledger. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1048,9 +1048,15 @@ async function persistExhaustionCollect(input: {
|
||||
hostNarration: string;
|
||||
focus?: ConversationFocus | null;
|
||||
}> {
|
||||
const catalog = rectificationFollowupCatalog(
|
||||
input.dossier.latestResult ?? null,
|
||||
input.dossier.evidence,
|
||||
);
|
||||
const followup = exhaustionSpokenCollectFollowup({
|
||||
evidence: input.dossier.evidence,
|
||||
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
|
||||
answeredProbes: catalog.answeredProbes,
|
||||
eventProbes: catalog.eventProbes,
|
||||
});
|
||||
const range = nonConvergingRangeNarration({
|
||||
...input.decision,
|
||||
|
||||
@@ -282,6 +282,7 @@ export function rectificationFollowupCatalog(
|
||||
contrastPacket: contrastPacketFromLatestResult(latest ?? null, evidence),
|
||||
topCandidateTimes,
|
||||
askedProbeKeys: askedKeys,
|
||||
answeredProbes: inference?.answered_probes ?? [],
|
||||
eventProbes: refinement.discriminating_event_probes,
|
||||
eventClarificationProbes: refinement.event_clarification_probes,
|
||||
evidenceCollectionProbes: refinement.evidence_collection_probes,
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
* Known-event quality probes (exam went badly for a year already
|
||||
* in the ledger) are not reverse-inference cards. Dasha existence
|
||||
* probes skip a year already in the ledger, not the whole domain.
|
||||
* A yes/weak_yes discriminator answer covers that domain's spoken
|
||||
* collect without writing the ledger. no/unsure does not.
|
||||
* Contrast-packet ranking must use the same year rule: a dated
|
||||
* unstructured probe stays eligible when the domain already has a
|
||||
* different year. Information gain is recomputed on the current
|
||||
@@ -223,6 +225,47 @@ function hasConfirmedDomain(evidence: readonly MethodFollowupEvidence[], domain:
|
||||
return evidence.some((item) => item.status === "confirmed" && item.domain === domain);
|
||||
}
|
||||
|
||||
export type AnsweredProbeCoverageRow = Readonly<{
|
||||
semantic_key: string;
|
||||
probe_id?: string;
|
||||
answer_class: string;
|
||||
classified_from?: string;
|
||||
}>;
|
||||
|
||||
function domainForAnsweredProbe(
|
||||
answer: AnsweredProbeCoverageRow,
|
||||
eventProbes: readonly DiscriminatingEventProbe[],
|
||||
): string | null {
|
||||
const keys = new Set(
|
||||
[answer.semantic_key, answer.probe_id].filter((key): key is string => Boolean(key?.trim())),
|
||||
);
|
||||
if (keys.size === 0) return null;
|
||||
for (const probe of eventProbes) {
|
||||
const semantic = probe.semantic_key?.trim() ?? "";
|
||||
if (!semantic || !keys.has(semantic)) continue;
|
||||
const domain = probe.domain?.trim();
|
||||
if (domain) return domain;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Domains where a discriminator was answered yes/weak_yes. Lookup is by probe identity, not key prefix. */
|
||||
export function domainsAnsweredYes(
|
||||
answeredProbes: readonly AnsweredProbeCoverageRow[] | undefined,
|
||||
eventProbes: readonly DiscriminatingEventProbe[] | undefined,
|
||||
): Set<string> {
|
||||
const domains = new Set<string>();
|
||||
if (!answeredProbes?.length) return domains;
|
||||
const catalog = eventProbes ?? [];
|
||||
for (const answer of answeredProbes) {
|
||||
if (answer.answer_class !== "yes" && answer.answer_class !== "weak_yes") continue;
|
||||
if (answer.classified_from && answer.classified_from !== "choice") continue;
|
||||
const domain = domainForAnsweredProbe(answer, catalog);
|
||||
if (domain) domains.add(domain);
|
||||
}
|
||||
return domains;
|
||||
}
|
||||
|
||||
function evidenceYear(item: MethodFollowupEvidence): number | null {
|
||||
const raw = item.occurredFrom || item.occurredTo;
|
||||
if (!raw || raw.length < 4 || !/^\d{4}/.test(raw)) return null;
|
||||
@@ -1070,19 +1113,26 @@ function datedCollectDomainBlocked(
|
||||
domain: (typeof DATED_COLLECT_ORDER)[number],
|
||||
evidence: readonly MethodFollowupEvidence[],
|
||||
declined: ReadonlySet<string>,
|
||||
answeredYes: ReadonlySet<string> = new Set(),
|
||||
): boolean {
|
||||
if (domain === "health_pressure") {
|
||||
return declinedHealth(declined) || hasConfirmedHealth(evidence);
|
||||
return declinedHealth(declined)
|
||||
|| hasConfirmedHealth(evidence)
|
||||
|| answeredYes.has("health_pressure")
|
||||
|| answeredYes.has("health");
|
||||
}
|
||||
return declined.has(domain) || hasConfirmedDomain(evidence, domain);
|
||||
return declined.has(domain)
|
||||
|| hasConfirmedDomain(evidence, domain)
|
||||
|| answeredYes.has(domain);
|
||||
}
|
||||
|
||||
export function nextDatedCollectFollowup(
|
||||
evidence: readonly MethodFollowupEvidence[],
|
||||
declined: ReadonlySet<string>,
|
||||
answeredYes: ReadonlySet<string> = new Set(),
|
||||
): MethodFollowup | null {
|
||||
for (const domain of DATED_COLLECT_ORDER) {
|
||||
if (datedCollectDomainBlocked(domain, evidence, declined)) continue;
|
||||
if (datedCollectDomainBlocked(domain, evidence, declined, answeredYes)) continue;
|
||||
const next = datedCollectFollowup(domain, evidence);
|
||||
if (next) return next;
|
||||
}
|
||||
@@ -1125,9 +1175,12 @@ function otherCollectFollowup(evidence: readonly MethodFollowupEvidence[]): Meth
|
||||
export function exhaustionSpokenCollectFollowup(input: {
|
||||
evidence: readonly MethodFollowupEvidence[];
|
||||
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
|
||||
answeredProbes?: readonly AnsweredProbeCoverageRow[];
|
||||
eventProbes?: readonly DiscriminatingEventProbe[];
|
||||
}): MethodFollowup | null {
|
||||
const declined = declinedDomains(input.declinedTopics ?? []);
|
||||
const dated = nextDatedCollectFollowup(input.evidence, declined);
|
||||
const answeredYes = domainsAnsweredYes(input.answeredProbes, input.eventProbes);
|
||||
const dated = nextDatedCollectFollowup(input.evidence, declined, answeredYes);
|
||||
if (dated) return dated;
|
||||
if (
|
||||
!declined.has("occupation")
|
||||
@@ -1506,6 +1559,7 @@ export function buildMethodFollowupPlan(input: {
|
||||
eventClarificationProbes?: readonly DiscriminatingEventProbe[];
|
||||
evidenceCollectionProbes?: readonly DiscriminatingEventProbe[];
|
||||
askedProbeKeys?: readonly string[];
|
||||
answeredProbes?: readonly AnsweredProbeCoverageRow[];
|
||||
closedCollectFocuses?: readonly Readonly<Record<string, unknown>>[];
|
||||
birthDate?: string | null;
|
||||
accepted?: boolean;
|
||||
@@ -1559,10 +1613,18 @@ export function buildMethodFollowupPlan(input: {
|
||||
const ask = (why: string, varga: string, extra = "") =>
|
||||
agentHint(why, varga, extra, input.evidence);
|
||||
const declined = declinedDomains(input.declinedTopics ?? []);
|
||||
const answeredYes = domainsAnsweredYes(input.answeredProbes, [
|
||||
...(input.eventProbes ?? []),
|
||||
...(input.eventClarificationProbes ?? []),
|
||||
...(input.evidenceCollectionProbes ?? []),
|
||||
]);
|
||||
const dashaCovered = input.evidence.some(isConfirmedDated);
|
||||
const relationshipCovered = hasConfirmedDomain(input.evidence, "relationship");
|
||||
const careerCovered = hasConfirmedDomain(input.evidence, "career");
|
||||
const familyCovered = hasConfirmedDomain(input.evidence, "family");
|
||||
const relationshipCovered = hasConfirmedDomain(input.evidence, "relationship")
|
||||
|| answeredYes.has("relationship");
|
||||
const careerCovered = hasConfirmedDomain(input.evidence, "career")
|
||||
|| answeredYes.has("career");
|
||||
const familyCovered = hasConfirmedDomain(input.evidence, "family")
|
||||
|| answeredYes.has("family");
|
||||
const financeCovered = hasConfirmedDomain(input.evidence, "finance");
|
||||
const healthCovered = hasConfirmedHealth(input.evidence);
|
||||
const occupationCovered = hasConfirmedDomain(input.evidence, "occupation")
|
||||
@@ -2120,7 +2182,7 @@ export function buildMethodFollowupPlan(input: {
|
||||
&& precisionCard?.choice_frame
|
||||
) {
|
||||
next = precisionCard;
|
||||
} else if ((datedCollect = nextDatedCollectFollowup(input.evidence, declined))) {
|
||||
} else if ((datedCollect = nextDatedCollectFollowup(input.evidence, declined, answeredYes))) {
|
||||
next = makeFollowup(datedCollect);
|
||||
} else if (!occupationCovered) {
|
||||
if (meetsAcceptanceEventQuality(input.evidence)) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
spokenFollowupForUser,
|
||||
type MethodFollowupEvidence,
|
||||
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import type { DiscriminatingEventProbe } from "../src/lib/rectification-agentic/v9/refinement-packet.ts";
|
||||
import { persistNextInterviewAfterChoice } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||||
import { projectTurnDecision } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
|
||||
import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
@@ -472,3 +473,99 @@ test("turn decision and GET interview expose collection_progress 2/3/1 or null",
|
||||
);
|
||||
assert.match(tools, /采集题必须写出服务端给你的领域/);
|
||||
});
|
||||
|
||||
const CAREER_EXISTENCE_PROBE: DiscriminatingEventProbe = {
|
||||
year: 2011,
|
||||
year_label: "2011 年前后",
|
||||
domain: "career",
|
||||
event_family: "入职、换工作或职责加重",
|
||||
source: "dasha_activation",
|
||||
tracks: ["vimshottari", "narayana"],
|
||||
tracks_agree: true,
|
||||
unique_minute_claim: false,
|
||||
user_meaning: "年份锁定 2011 年前后。事件家族:入职、换工作或职责加重。",
|
||||
role: "distinguish",
|
||||
information_gain: 0.4,
|
||||
candidate_ids: ["05:00", "05:20"],
|
||||
expected_outcomes: [
|
||||
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:20"] },
|
||||
{ answer_class: "no", supports: ["05:20"], conflicts: ["05:00"] },
|
||||
],
|
||||
semantic_key: "career.2011",
|
||||
candidate_split_hash: "career.2011:05:00|05:20",
|
||||
choice_kind: "existence",
|
||||
};
|
||||
|
||||
const RELATIONSHIP_AND_FAMILY = [
|
||||
dated("relationship", "2014", { eventKind: "relationship_start" }),
|
||||
dated("family", "2016", { eventKind: "family_event" }),
|
||||
] as const;
|
||||
|
||||
function careerAnswerPlan(answerClass: string, extra: Partial<Parameters<typeof buildMethodFollowupPlan>[0]> = {}) {
|
||||
return buildMethodFollowupPlan({
|
||||
evidence: RELATIONSHIP_AND_FAMILY,
|
||||
eventProbes: [CAREER_EXISTENCE_PROBE],
|
||||
askedProbeKeys: ["career.2011", "probe:career.2011"],
|
||||
answeredProbes: [{
|
||||
semantic_key: "career.2011",
|
||||
probe_id: "probe:career.2011",
|
||||
answer_class: answerClass,
|
||||
classified_from: "choice",
|
||||
}],
|
||||
holdoutValidation: "not_started",
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
test("career discriminator yes covers career collect without ledger career evidence", () => {
|
||||
const unanswered = buildMethodFollowupPlan({
|
||||
evidence: RELATIONSHIP_AND_FAMILY,
|
||||
eventProbes: [CAREER_EXISTENCE_PROBE],
|
||||
askedProbeKeys: ["career.2011", "probe:career.2011"],
|
||||
holdoutValidation: "not_started",
|
||||
});
|
||||
assert.equal(unanswered.next_followup?.domain, "career");
|
||||
assert.equal(unanswered.next_followup?.intent, "collect_method_evidence");
|
||||
assert.equal(spokenFollowupForUser(unanswered.next_followup), USER_COLLECT_QUESTION.career);
|
||||
|
||||
const yes = careerAnswerPlan("yes");
|
||||
assert.notEqual(yes.next_followup?.domain, "career");
|
||||
assert.equal(yes.next_followup?.domain, "education");
|
||||
assert.equal(yes.next_followup?.intent, "collect_method_evidence");
|
||||
assert.equal(spokenFollowupForUser(yes.next_followup), USER_COLLECT_QUESTION.education);
|
||||
assert.equal(
|
||||
yes.methods.find((item) => item.method_id === "d10_career")?.status,
|
||||
"covered",
|
||||
);
|
||||
|
||||
const weakYes = careerAnswerPlan("weak_yes");
|
||||
assert.notEqual(weakYes.next_followup?.domain, "career");
|
||||
assert.equal(weakYes.next_followup?.domain, "education");
|
||||
|
||||
const no = careerAnswerPlan("no");
|
||||
assert.equal(no.next_followup?.domain, "career");
|
||||
assert.equal(spokenFollowupForUser(no.next_followup), USER_COLLECT_QUESTION.career);
|
||||
|
||||
const unsure = careerAnswerPlan("unsure");
|
||||
assert.equal(unsure.next_followup?.domain, "career");
|
||||
assert.equal(spokenFollowupForUser(unsure.next_followup), USER_COLLECT_QUESTION.career);
|
||||
});
|
||||
|
||||
test("career discriminator yes does not invent coverage from a semantic_key prefix", () => {
|
||||
const plan = careerAnswerPlan("yes", { eventProbes: [] });
|
||||
assert.equal(plan.next_followup?.domain, "career");
|
||||
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.career);
|
||||
});
|
||||
|
||||
test("ledger-classified career probe answers do not cover career collect", () => {
|
||||
const plan = careerAnswerPlan("yes", {
|
||||
answeredProbes: [{
|
||||
semantic_key: "career.2011",
|
||||
probe_id: "probe:career.2011",
|
||||
answer_class: "yes",
|
||||
classified_from: "evidence",
|
||||
}],
|
||||
});
|
||||
assert.equal(plan.next_followup?.domain, "career");
|
||||
assert.equal(spokenFollowupForUser(plan.next_followup), USER_COLLECT_QUESTION.career);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user