fix(rectification): compare health and occupation aliases through one merge (BUG-672)
Skipped or already-reported health must not be asked again as health_pressure, and occupation declined as other must still close the occupation line. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
SEMANTIC_YEAR_KEY,
|
||||
} from "../v9/probe-question-contract.ts";
|
||||
import { probeBelowAdultFloor } from "../v9/adult-floor.ts";
|
||||
import { canonicalCollectDomain, sameCollectDomain } from "../v9/domain-alias.ts";
|
||||
|
||||
export type ContrastChoiceKind = "existence" | "varga_style" | "event_quality";
|
||||
|
||||
@@ -235,7 +236,7 @@ export function mentionedVargaKeysFromLedgerEvidence(
|
||||
keys.add("varga.d2");
|
||||
keys.add("varga.d11");
|
||||
}
|
||||
if (item.domain === "health_pressure" || HEALTH_RE.test(summary)) keys.add("varga.d30");
|
||||
if (sameCollectDomain(item.domain, "health_pressure") || HEALTH_RE.test(summary)) keys.add("varga.d30");
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
@@ -254,7 +255,7 @@ export function askedEventProbeKeysFromLedgerEvidence(
|
||||
if (!item.domain) continue;
|
||||
for (const date of [item.occurredFrom, item.occurredTo]) {
|
||||
const year = date?.match(/^(\d{4})/)?.[1];
|
||||
if (year) keys.add(`${item.domain}.${year}`);
|
||||
if (year) keys.add(`${canonicalCollectDomain(item.domain)}.${year}`);
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
@@ -273,7 +274,7 @@ export function datedDomainsFromEvidence(
|
||||
if (item.status && !LIVE_EVIDENCE.has(item.status)) continue;
|
||||
if (!item.domain) continue;
|
||||
const dated = [item.occurredFrom, item.occurredTo].some((value) => /^\d{4}/.test(value ?? ""));
|
||||
if (dated) domains.add(item.domain);
|
||||
if (dated) domains.add(canonicalCollectDomain(item.domain));
|
||||
}
|
||||
return [...domains];
|
||||
}
|
||||
@@ -287,7 +288,7 @@ export function volunteeredDomainsFromEvidence(
|
||||
const domains = new Set<string>();
|
||||
for (const item of evidence) {
|
||||
if (item.status && !LIVE_EVIDENCE.has(item.status)) continue;
|
||||
if (item.domain) domains.add(item.domain);
|
||||
if (item.domain) domains.add(canonicalCollectDomain(item.domain));
|
||||
}
|
||||
return [...domains];
|
||||
}
|
||||
@@ -329,18 +330,20 @@ export function buildCandidateContrastPacket(input: {
|
||||
}): CandidateContrastPacket {
|
||||
const asked = new Set(input.askedKeys ?? []);
|
||||
// mentionedKeys are ranking-only; passing them here must not skip remaining varga probes.
|
||||
const provided = new Set(input.providedDomains ?? []);
|
||||
const provided = new Set((input.providedDomains ?? []).map((item) => canonicalCollectDomain(item)));
|
||||
const fromEngine = (input.engineProbes ?? []).flatMap((probe) => {
|
||||
const built = probeFromEngine(probe, input.candidateSetVersion, input.calculationResultId ?? null);
|
||||
if (!built) return [];
|
||||
const eventKey = built.domain && built.year ? `${built.domain}.${built.year}` : null;
|
||||
const eventKey = built.domain && built.year
|
||||
? `${canonicalCollectDomain(built.domain)}.${built.year}`
|
||||
: null;
|
||||
const isStructured = isStructuredDiscriminator(built);
|
||||
if (
|
||||
asked.has(built.semanticKey)
|
||||
|| asked.has(built.candidateSplitHash)
|
||||
|| asked.has(built.probeId)
|
||||
|| (!isStructured && eventKey && asked.has(eventKey))
|
||||
|| (!isStructured && !(built.year && built.year > 0) && built.domain && provided.has(built.domain))
|
||||
|| (!isStructured && !(built.year && built.year > 0) && built.domain && provided.has(canonicalCollectDomain(built.domain)))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { USER_COLLECT_QUESTION } from "../user-copy.ts";
|
||||
import { canonicalCollectDomain } from "./domain-alias.ts";
|
||||
|
||||
export const COLLECT_KIND_ORDER = [
|
||||
"education",
|
||||
@@ -83,9 +84,9 @@ const INVITE_VALUE = 2;
|
||||
const GENERIC_VALUE = 0.15;
|
||||
|
||||
export function normalizeCollectKind(domain: string | null | undefined): CollectKind | null {
|
||||
if (domain === "health" || domain === "health_pressure") return "health_pressure";
|
||||
if ((COLLECT_KIND_ORDER as readonly string[]).includes(domain ?? "")) {
|
||||
return domain as CollectKind;
|
||||
const canonical = canonicalCollectDomain(domain);
|
||||
if ((COLLECT_KIND_ORDER as readonly string[]).includes(canonical)) {
|
||||
return canonical as CollectKind;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Read-side aliases for collect domains.
|
||||
*
|
||||
* Ledger and DB focus store `health`; the planner / engine catalog use
|
||||
* `health_pressure`. Occupation focuses persist as `other` under a
|
||||
* `collect:occupation:` question id. Comparisons go through here so the
|
||||
* two spellings are one domain. Persistable focus domains stay unchanged.
|
||||
*/
|
||||
|
||||
export function canonicalCollectDomain(
|
||||
raw: string | null | undefined,
|
||||
questionId?: string | null,
|
||||
): string {
|
||||
const value = typeof raw === "string" ? raw.trim() : "";
|
||||
if (value === "health") return "health_pressure";
|
||||
const id = typeof questionId === "string" ? questionId : "";
|
||||
if (value === "other" && id.startsWith("collect:occupation:")) return "occupation";
|
||||
return value;
|
||||
}
|
||||
|
||||
export function sameCollectDomain(
|
||||
left: string | null | undefined,
|
||||
right: string | null | undefined,
|
||||
questionId?: string | null,
|
||||
): boolean {
|
||||
const a = canonicalCollectDomain(left);
|
||||
const b = canonicalCollectDomain(right, questionId);
|
||||
return Boolean(a) && a === b;
|
||||
}
|
||||
|
||||
export function declinedHasDomain(
|
||||
declined: ReadonlySet<string>,
|
||||
domain: string | null | undefined,
|
||||
questionId?: string | null,
|
||||
): boolean {
|
||||
const wanted = canonicalCollectDomain(domain, questionId);
|
||||
if (!wanted) return false;
|
||||
for (const item of declined) {
|
||||
if (canonicalCollectDomain(item) === wanted) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -105,6 +105,11 @@ import {
|
||||
type CollectKind,
|
||||
type CollectionPoolItem,
|
||||
} from "./collection-question-pool.ts";
|
||||
import {
|
||||
canonicalCollectDomain,
|
||||
declinedHasDomain,
|
||||
sameCollectDomain,
|
||||
} from "./domain-alias.ts";
|
||||
|
||||
export { GENERIC_COLLECT_QUESTION };
|
||||
import {
|
||||
@@ -275,7 +280,7 @@ function isConfirmedDated(
|
||||
}
|
||||
|
||||
function hasConfirmedDomain(evidence: readonly MethodFollowupEvidence[], domain: string): boolean {
|
||||
return evidence.some((item) => item.status === "confirmed" && item.domain === domain);
|
||||
return evidence.some((item) => item.status === "confirmed" && sameCollectDomain(item.domain, domain));
|
||||
}
|
||||
|
||||
export type AnsweredProbeCoverageRow = Readonly<{
|
||||
@@ -314,7 +319,7 @@ export function domainsAnsweredYes(
|
||||
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);
|
||||
if (domain) domains.add(canonicalCollectDomain(domain));
|
||||
}
|
||||
return domains;
|
||||
}
|
||||
@@ -367,7 +372,7 @@ export function existenceProbeAsked(
|
||||
if (nearby <= 0) return false;
|
||||
for (const key of asked) {
|
||||
const match = key.match(SEMANTIC_YEAR_KEY);
|
||||
if (!match || match[1] !== domain) continue;
|
||||
if (!match || !sameCollectDomain(match[1], domain)) continue;
|
||||
const askedYear = Number(match[2]);
|
||||
if (Number.isInteger(askedYear) && Math.abs(askedYear - year) <= nearby) return true;
|
||||
}
|
||||
@@ -381,7 +386,7 @@ export function datedLedgerAnchor(
|
||||
if (!domain) return null;
|
||||
let best: { year: number; month: number | null; label: string } | null = null;
|
||||
for (const item of evidence ?? []) {
|
||||
if (!isConfirmedDated(item) || item.domain !== domain) continue;
|
||||
if (!isConfirmedDated(item) || !sameCollectDomain(item.domain, domain)) continue;
|
||||
const year = evidenceYear(item);
|
||||
if (year === null) continue;
|
||||
const raw = item.occurredFrom || item.occurredTo || "";
|
||||
@@ -408,7 +413,7 @@ function nearbyLedgerHint(
|
||||
const probeIndex = month && month >= 1 && month <= 12 ? year * 12 + month : null;
|
||||
let best: { label: string; family: string; delta: number } | null = null;
|
||||
for (const item of evidence ?? []) {
|
||||
if (!isConfirmedDated(item) || item.domain === domain) continue;
|
||||
if (!isConfirmedDated(item) || sameCollectDomain(item.domain, domain)) continue;
|
||||
const otherYear = evidenceYear(item);
|
||||
if (otherYear === null) continue;
|
||||
const raw = item.occurredFrom || item.occurredTo || "";
|
||||
@@ -447,7 +452,7 @@ function probeYearAlreadyCovered(
|
||||
if (item.status !== "confirmed" && item.status !== "draft" && item.status !== "pending_confirmation") {
|
||||
return false;
|
||||
}
|
||||
if (item.domain !== domain) return false;
|
||||
if (!sameCollectDomain(item.domain, domain)) return false;
|
||||
const itemYear = evidenceYear(item);
|
||||
if (itemYear === null) return false;
|
||||
return Math.abs(itemYear - year) <= nearby;
|
||||
@@ -503,11 +508,11 @@ export function datedMethodCollectOpen(
|
||||
}
|
||||
|
||||
function hasConfirmedHealth(evidence: readonly MethodFollowupEvidence[]): boolean {
|
||||
return hasConfirmedDomain(evidence, "health_pressure") || hasConfirmedDomain(evidence, "health");
|
||||
return hasConfirmedDomain(evidence, "health_pressure");
|
||||
}
|
||||
|
||||
function declinedHealth(declined: ReadonlySet<string>): boolean {
|
||||
return declined.has("health") || declined.has("health_pressure");
|
||||
return declinedHasDomain(declined, "health_pressure");
|
||||
}
|
||||
|
||||
function topicDomain(topic: Readonly<Record<string, unknown>>): string | null {
|
||||
@@ -553,7 +558,8 @@ function declinedDomains(
|
||||
if (intent && intent !== "collect_method_evidence") continue;
|
||||
if (!includeSkipped && topicStatus(topic) === "skipped") continue;
|
||||
const domain = topicDomain(topic);
|
||||
if (domain) domains.add(domain);
|
||||
const canonical = canonicalCollectDomain(domain, topicQuestionId(topic));
|
||||
if (canonical) domains.add(canonical);
|
||||
}
|
||||
return domains;
|
||||
}
|
||||
@@ -766,7 +772,7 @@ export function remainingReverseVerifyProbes(
|
||||
&& probe.role === "distinguish";
|
||||
if ((probe.source === "known_event_quality" && !anchoredQuality) || probe.role === "clarify" || probe.phase === "event_clarification") continue;
|
||||
if (probe.role === "collect" || probe.phase === "evidence_collection") continue;
|
||||
if (declined.has(probe.domain)) continue;
|
||||
if (declinedHasDomain(declined, probe.domain)) continue;
|
||||
if (!anchoredQuality && probeYearAlreadyCovered(evidence, probe.domain, probe.year)) continue;
|
||||
if (probeBelowAdultFloor(probe, birthDate)) continue;
|
||||
if (reverseVerifyProbeAsked(probe, askedKeys)) continue;
|
||||
@@ -809,7 +815,7 @@ function datedCollectionProbe(
|
||||
probes: readonly DiscriminatingEventProbe[] | undefined,
|
||||
domain: string,
|
||||
): DiscriminatingEventProbe | null {
|
||||
const rows = (probes ?? []).filter((item) => item.domain === domain && Number(item.year) > 0);
|
||||
const rows = (probes ?? []).filter((item) => sameCollectDomain(item.domain, domain) && Number(item.year) > 0);
|
||||
return rows.sort((left, right) => (right.information_gain ?? 0) - (left.information_gain ?? 0))[0] ?? null;
|
||||
}
|
||||
|
||||
@@ -825,7 +831,7 @@ function collectionYearFields(probe: DiscriminatingEventProbe | null): Pick<
|
||||
};
|
||||
}
|
||||
|
||||
function remainingConflictProbes(
|
||||
export function remainingConflictProbes(
|
||||
probes: readonly DiscriminatingEventProbe[] | undefined,
|
||||
evidence: readonly MethodFollowupEvidence[],
|
||||
declined: ReadonlySet<string>,
|
||||
@@ -837,7 +843,7 @@ function remainingConflictProbes(
|
||||
if (!CONFLICT_PROBE_SOURCES.has(probe.source)) continue;
|
||||
if (probe.source === "known_event_quality" || probe.role === "clarify") continue;
|
||||
if (!isValidDistinguishProbe({ ...probe, role: "distinguish" })) continue;
|
||||
if (declined.has(probe.domain)) continue;
|
||||
if (declinedHasDomain(declined, probe.domain)) continue;
|
||||
if (probeYearAlreadyCovered(evidence, probe.domain, probe.year)) continue;
|
||||
if (probeBelowAdultFloor(probe, birthDate)) continue;
|
||||
const semantic = probe.semantic_key ?? `${probe.domain}.${probe.year}`;
|
||||
@@ -1179,7 +1185,7 @@ function rankRenderableDiscriminators(input: {
|
||||
birthDate?: string | null;
|
||||
}): { locked: RankedDiscriminator[]; personality: RankedDiscriminator[]; yearless: RankedDiscriminator[]; dropped: DroppedProbe[] } {
|
||||
const top = input.topCandidateTimes ?? [];
|
||||
const provided = new Set(input.providedDomains ?? []);
|
||||
const provided = new Set((input.providedDomains ?? []).map((item) => canonicalCollectDomain(item)));
|
||||
const mentioned = input.mentionedKeys ?? new Set();
|
||||
const rows: RankedDiscriminator[] = [];
|
||||
const dropped: DroppedProbe[] = [];
|
||||
@@ -1213,7 +1219,7 @@ function rankRenderableDiscriminators(input: {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!isStructuredDiscriminator(probe) && probe.domain && provided.has(probe.domain)) {
|
||||
if (!isStructuredDiscriminator(probe) && probe.domain && provided.has(canonicalCollectDomain(probe.domain))) {
|
||||
const year = probe.year ?? 0;
|
||||
if (year <= 0) continue;
|
||||
if (input.evidence && probeYearAlreadyCovered(input.evidence, probe.domain, year)) continue;
|
||||
@@ -1320,15 +1326,9 @@ function datedCollectDomainBlocked(
|
||||
declined: ReadonlySet<string>,
|
||||
answeredYes: ReadonlySet<string> = new Set(),
|
||||
): boolean {
|
||||
if (domain === "health_pressure") {
|
||||
return declinedHealth(declined)
|
||||
|| hasConfirmedHealth(evidence)
|
||||
|| answeredYes.has("health_pressure")
|
||||
|| answeredYes.has("health");
|
||||
}
|
||||
return declined.has(domain)
|
||||
return declinedHasDomain(declined, domain)
|
||||
|| hasConfirmedDomain(evidence, domain)
|
||||
|| answeredYes.has(domain);
|
||||
|| [...answeredYes].some((item) => sameCollectDomain(item, domain));
|
||||
}
|
||||
|
||||
export function datedCollectAlreadyAsked(
|
||||
@@ -1535,16 +1535,11 @@ export function remainingEvidenceCollectStillOpen(
|
||||
}
|
||||
|
||||
function holdoutOccupiedDomains(evidence: readonly MethodFollowupEvidence[]): Set<string> {
|
||||
const occupied = new Set(
|
||||
return new Set(
|
||||
evidence
|
||||
.filter((item) => isConfirmedDated(item) && evidenceYear(item) != null)
|
||||
.map((item) => item.domain),
|
||||
.map((item) => canonicalCollectDomain(item.domain)),
|
||||
);
|
||||
if (hasConfirmedHealth(evidence) || occupied.has("health") || occupied.has("health_pressure")) {
|
||||
occupied.add("health");
|
||||
occupied.add("health_pressure");
|
||||
}
|
||||
return occupied;
|
||||
}
|
||||
|
||||
export function exhaustionSpokenCollectFollowup(input: {
|
||||
@@ -1924,12 +1919,7 @@ function holdoutAskFields(
|
||||
}
|
||||
|
||||
function declinedForHoldout(declined: ReadonlySet<string>): Set<string> {
|
||||
const next = new Set(declined);
|
||||
if (declinedHealth(declined)) {
|
||||
next.add("health");
|
||||
next.add("health_pressure");
|
||||
}
|
||||
return next;
|
||||
return new Set([...declined].map((item) => canonicalCollectDomain(item)));
|
||||
}
|
||||
|
||||
export function holdoutFollowupFor(
|
||||
@@ -1943,12 +1933,14 @@ export function holdoutFollowupFor(
|
||||
if (!meetsAcceptanceEventQuality(input.evidence)) return null;
|
||||
const occupied = holdoutOccupiedDomains(input.evidence);
|
||||
const blocked = declinedForHoldout(declined);
|
||||
const prompt = (input.oosBlindPrompts ?? []).find((item) => (
|
||||
Boolean(item.domain) && !occupied.has(item.domain) && !blocked.has(item.domain)
|
||||
)) ?? null;
|
||||
const reserved = (input.holdoutEvents ?? []).find((item) => (
|
||||
item.year != null && !occupied.has(item.domain) && !blocked.has(item.domain)
|
||||
)) ?? null;
|
||||
const prompt = (input.oosBlindPrompts ?? []).find((item) => {
|
||||
const domain = canonicalCollectDomain(item.domain);
|
||||
return Boolean(domain) && !occupied.has(domain) && !blocked.has(domain);
|
||||
}) ?? null;
|
||||
const reserved = (input.holdoutEvents ?? []).find((item) => {
|
||||
const domain = canonicalCollectDomain(item.domain);
|
||||
return item.year != null && Boolean(domain) && !occupied.has(domain) && !blocked.has(domain);
|
||||
}) ?? null;
|
||||
return holdoutAskFields(prompt, reserved);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user