Count-aware closed-pool invite, catch the five stale contract assertions, and mark M1b V1/V2 as not_measured.
434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import test from "node:test";
|
||
import { createElement } from "react";
|
||
import { renderToStaticMarkup } from "react-dom/server";
|
||
|
||
import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||
import { INFERENCE_ALGORITHM_VERSION, type InferenceCandidate, type InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
|
||
import {
|
||
RANGE_DELIVERY_OPEN_COLLECT_DAY_EXAMPLES,
|
||
RANGE_DELIVERY_OPEN_COLLECT_EXAMPLES,
|
||
RANGE_DELIVERY_TIE_PERCENT,
|
||
rangeDeliveryCollectClosed,
|
||
rangeDeliveryShowsOpenCollectInvite,
|
||
} from "../src/lib/rectification-agentic/user-copy.ts";
|
||
import {
|
||
COLLECT_KIND_ORDER,
|
||
rangeNarrowHint,
|
||
} from "../src/lib/rectification-agentic/v9/collection-question-pool.ts";
|
||
import {
|
||
decideFromDossier,
|
||
scoreableSnapshotCurrentFromDossier,
|
||
type DecisionDossier,
|
||
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||
import { previousInferenceFromReceipt } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
|
||
import { buildRangeDelivery } from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
|
||
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||
import { RectificationRangeDelivery } from "../src/components/rectification-range-delivery.tsx";
|
||
import type { RectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
|
||
import { rectificationQuestionGapState } from "../src/lib/rectification-surface-state.ts";
|
||
|
||
const ID_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1";
|
||
const ID_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2";
|
||
const ID_C = "cccccccc-cccc-4ccc-8ccc-ccccccccccc3";
|
||
|
||
const CLOSED_LAYERS = ["d9", "d10", "d4", "d5", "d7", "d2", "d30"] as const;
|
||
|
||
const DATED_EVIDENCE = [
|
||
{
|
||
id: "evidence-education",
|
||
status: "confirmed",
|
||
domain: "education",
|
||
datePrecision: "month",
|
||
occurredFrom: "2016-09-01",
|
||
occurredTo: null,
|
||
eventKind: "education_start",
|
||
summary: "上大学",
|
||
},
|
||
{
|
||
id: "evidence-career",
|
||
status: "confirmed",
|
||
domain: "career",
|
||
datePrecision: "month",
|
||
occurredFrom: "2020-04-01",
|
||
occurredTo: null,
|
||
eventKind: "career_entry",
|
||
summary: "入职",
|
||
},
|
||
{
|
||
id: "evidence-relocation",
|
||
status: "confirmed",
|
||
domain: "relocation",
|
||
datePrecision: "month",
|
||
occurredFrom: "2018-07-01",
|
||
occurredTo: null,
|
||
eventKind: "relocation",
|
||
summary: "搬家",
|
||
},
|
||
] as const;
|
||
|
||
function declineAllTargeted() {
|
||
return COLLECT_KIND_ORDER.map((domain) => ({
|
||
questionId: `collect:targeted:${domain}`,
|
||
status: "declined",
|
||
target_kind: `targeted:${domain}`,
|
||
intent: "collect_method_evidence",
|
||
target_domain: domain,
|
||
}));
|
||
}
|
||
|
||
function cand(
|
||
time: string,
|
||
probability: number,
|
||
range: readonly [string, string] = ["04:48", "05:07"],
|
||
): InferenceCandidate {
|
||
return {
|
||
id: time,
|
||
time,
|
||
cluster_range: range,
|
||
prior_score: probability * 100,
|
||
posterior_score: probability * 100,
|
||
probability,
|
||
status: "active",
|
||
rank: 1,
|
||
strong_conflict_count: 0,
|
||
};
|
||
}
|
||
|
||
function inference(overrides: Partial<InferenceState> = {}): InferenceState {
|
||
return {
|
||
algorithm_version: INFERENCE_ALGORITHM_VERSION,
|
||
candidate_set_id: "04:48-05:07:04:50,04:52,04:56",
|
||
revision: 1,
|
||
phase: "discrimination",
|
||
result_status: "credible_range",
|
||
range_start: "04:48",
|
||
range_end: "05:07",
|
||
candidates: [
|
||
cand("04:50", 0.26),
|
||
cand("04:52", 0.26),
|
||
cand("04:56", 0.20),
|
||
],
|
||
events: [
|
||
{ id: "e1", domain: "career", year: 2020, precision: "month", usage: "training" },
|
||
{ id: "e2", domain: "education", year: 2016, precision: "month", usage: "training" },
|
||
{ id: "e3", domain: "relocation", year: 2018, precision: "month", usage: "training" },
|
||
],
|
||
probes: [],
|
||
answered_probes: [],
|
||
rounds: [],
|
||
entropy: 1,
|
||
representative_time: "04:50",
|
||
credible_range: ["04:48", "05:07"],
|
||
refresh_count: 1,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function deliveryResult(
|
||
delivery: ReturnType<typeof buildRangeDelivery>,
|
||
): RectificationCandidateResult {
|
||
return {
|
||
resultId: "result-open-collect",
|
||
candidates: delivery.columns.map((column, index) => ({
|
||
candidateId: column.candidate_id,
|
||
rank: index + 1,
|
||
time: column.time,
|
||
relativeSupport: column.probability_percent,
|
||
tiedMinuteCount: 1,
|
||
})),
|
||
overallConfidence: "medium",
|
||
selectionAllowed: true,
|
||
canAdopt: true,
|
||
confirmationAllowed: false,
|
||
decisionReceipt: null,
|
||
representativeTime: delivery.representative_time,
|
||
selectedTime: null,
|
||
selectionKind: null,
|
||
houseTable: null,
|
||
houseTablesByTime: {},
|
||
natalRecast: null,
|
||
techniqueAudit: [],
|
||
windowTransitions: [],
|
||
eventDashaLedger: [],
|
||
dashaAgreement: null,
|
||
lagnaContrast: null,
|
||
nakshatraBoundary: null,
|
||
precisionStage: null,
|
||
oosBlindPrompts: [],
|
||
confirmationGate: { confirmation_allowed: false } as RectificationCandidateResult["confirmationGate"],
|
||
validated: false,
|
||
completionStatus: null,
|
||
sessionOutcome: "adopt_representative",
|
||
credibleRange: delivery.range,
|
||
rangeDelivery: delivery,
|
||
verificationReportMarkdown: delivery.verification_markdown,
|
||
};
|
||
}
|
||
|
||
function renderCard(delivery: ReturnType<typeof buildRangeDelivery>): string {
|
||
return renderToStaticMarkup(createElement(RectificationRangeDelivery, {
|
||
result: deliveryResult(delivery),
|
||
acceptingCandidateId: null,
|
||
readonly: false,
|
||
onAccept: () => undefined,
|
||
}));
|
||
}
|
||
|
||
function gapFor(decision: { sessionOutcome: string; stopReason?: string | null }): string {
|
||
return rectificationQuestionGapState({
|
||
liveQuestionVisible: false,
|
||
questionMissing: true,
|
||
questionLoadFailed: false,
|
||
collectWaiting: false,
|
||
busy: false,
|
||
readonly: false,
|
||
regenerating: false,
|
||
snapshotLoaded: true,
|
||
resumableCase: true,
|
||
retryAttempts: 0,
|
||
sessionOutcome: decision.sessionOutcome,
|
||
stopReason: decision.stopReason ?? null,
|
||
});
|
||
}
|
||
|
||
test("closed collect copy invites another dated event instead of saying the pool is finished", () => {
|
||
const copy = rangeNarrowHint(
|
||
CLOSED_LAYERS,
|
||
DATED_EVIDENCE,
|
||
declineAllTargeted(),
|
||
["04:48", "05:07"],
|
||
5,
|
||
["04:48", "05:07"],
|
||
);
|
||
assert.match(copy, /现在还剩 04:48–05:07 里 5 个候选/);
|
||
assert.doesNotMatch(copy, /问完了|都问完|没有更多/);
|
||
assert.match(copy, /不限领域/);
|
||
assert.match(copy, /确切哪一天/);
|
||
assert.match(copy, /有年月也行/);
|
||
assert.match(copy, /接着算/);
|
||
assert.doesNotMatch(copy, /定到分钟|精确到分钟/);
|
||
const dayHits = RANGE_DELIVERY_OPEN_COLLECT_DAY_EXAMPLES.filter((item) => copy.includes(item));
|
||
assert.ok(dayHits.length >= 2, dayHits.join(","));
|
||
const exampleHits = RANGE_DELIVERY_OPEN_COLLECT_EXAMPLES.filter((item) => copy.includes(item));
|
||
assert.ok(exampleHits.length >= 2, exampleHits.join(","));
|
||
// 原值: /结婚|收入|添丁/(空池不得再列已拒答的线)
|
||
// 新值: 仍不得写「哪年结婚或订婚」这类还开着的线;D1b 允许用「登记结婚那天」作日精度例子
|
||
// 原因: BUG-687 不得把已拒答的线再问一遍;BUG-689 D1b 优先要确切日期
|
||
assert.doesNotMatch(copy, /哪年结婚或订婚|哪年收入明显变过|家里哪年添丁/);
|
||
assert.ok(copy.includes(rangeDeliveryCollectClosed(5)));
|
||
assert.doesNotMatch(copy, /这两分钟/);
|
||
assert.match(copy, /现在还剩 04:48–05:07 里 5 个候选。这几个候选按现有信息分不开/);
|
||
});
|
||
|
||
test("closed-pool invite names two minutes only when two candidates remain", () => {
|
||
const two = rangeNarrowHint(
|
||
CLOSED_LAYERS,
|
||
DATED_EVIDENCE,
|
||
declineAllTargeted(),
|
||
["04:48", "05:07"],
|
||
2,
|
||
["04:48", "05:07"],
|
||
);
|
||
assert.match(two, /现在还剩 04:48–05:07 里 2 个候选。这两分钟按现有信息分不开/);
|
||
assert.doesNotMatch(two, /这几个候选/);
|
||
assert.match(two, /不限领域/);
|
||
assert.match(two, /确切哪一天/);
|
||
assert.match(two, /有年月也行/);
|
||
assert.match(two, /接着算/);
|
||
const dayHits = RANGE_DELIVERY_OPEN_COLLECT_DAY_EXAMPLES.filter((item) => two.includes(item));
|
||
assert.ok(dayHits.length >= 2, dayHits.join(","));
|
||
const exampleHits = RANGE_DELIVERY_OPEN_COLLECT_EXAMPLES.filter((item) => two.includes(item));
|
||
assert.ok(exampleHits.length >= 2, exampleHits.join(","));
|
||
|
||
const four = rangeNarrowHint(
|
||
CLOSED_LAYERS,
|
||
DATED_EVIDENCE,
|
||
declineAllTargeted(),
|
||
["04:47", "04:53"],
|
||
4,
|
||
["04:47", "04:53"],
|
||
);
|
||
assert.match(four, /现在还剩 04:47–04:53 里 4 个候选。这几个候选按现有信息分不开/);
|
||
assert.doesNotMatch(four, /这两分钟/);
|
||
assert.match(four, /不限领域/);
|
||
assert.match(four, /登记结婚那天/);
|
||
|
||
const unknown = rangeNarrowHint(
|
||
CLOSED_LAYERS,
|
||
DATED_EVIDENCE,
|
||
declineAllTargeted(),
|
||
);
|
||
assert.equal(unknown, rangeDeliveryCollectClosed());
|
||
assert.doesNotMatch(unknown, /这两分钟|这几个候选/);
|
||
assert.match(unknown, /^按现有信息分不开。/);
|
||
assert.match(unknown, /不限领域/);
|
||
});
|
||
|
||
test("the range card puts the closed-pool invite in the body only when the top two are tied", () => {
|
||
const publicCandidates = [
|
||
{ candidateId: ID_A, time: "04:50" },
|
||
{ candidateId: ID_B, time: "04:52" },
|
||
{ candidateId: ID_C, time: "04:56" },
|
||
];
|
||
const tied = buildRangeDelivery({
|
||
inference: inference(),
|
||
publicCandidates,
|
||
credibleRange: ["04:48", "05:07"],
|
||
representativeTime: "04:50",
|
||
evidence: DATED_EVIDENCE,
|
||
declinedTopics: declineAllTargeted(),
|
||
});
|
||
assert.equal(
|
||
Math.abs((tied.columns[0]?.probability_percent ?? 0) - (tied.columns[1]?.probability_percent ?? 0))
|
||
<= RANGE_DELIVERY_TIE_PERCENT,
|
||
true,
|
||
);
|
||
assert.equal(rangeDeliveryShowsOpenCollectInvite({
|
||
hint: tied.narrow_hint,
|
||
columns: tied.columns,
|
||
}), true);
|
||
const tiedHtml = renderCard(tied);
|
||
assert.match(tiedHtml, /rectification-range-delivery__invite/);
|
||
assert.match(tiedHtml, /不限领域/);
|
||
assert.match(tiedHtml, /确切哪一天/);
|
||
assert.match(tiedHtml, /换专业/);
|
||
assert.match(tiedHtml, /创业/);
|
||
assert.match(tiedHtml, /更像这个/);
|
||
assert.match(tiedHtml, /这几个候选按现有信息分不开/);
|
||
assert.doesNotMatch(tiedHtml, /这两分钟按现有信息分不开。/);
|
||
assert.doesNotMatch(tiedHtml, /问完了/);
|
||
const caption = tiedHtml.match(
|
||
/class="rectification-range-delivery__narrow"[^>]*>([^<]*)</,
|
||
)?.[1] ?? "";
|
||
assert.doesNotMatch(caption, /不限领域/);
|
||
|
||
const clearLead = buildRangeDelivery({
|
||
inference: inference({
|
||
candidates: [
|
||
cand("04:50", 0.70),
|
||
cand("04:52", 0.20),
|
||
cand("04:56", 0.10),
|
||
],
|
||
}),
|
||
publicCandidates,
|
||
credibleRange: ["04:48", "05:07"],
|
||
representativeTime: "04:50",
|
||
evidence: DATED_EVIDENCE,
|
||
declinedTopics: declineAllTargeted(),
|
||
});
|
||
assert.equal(rangeDeliveryShowsOpenCollectInvite({
|
||
hint: clearLead.narrow_hint,
|
||
columns: clearLead.columns,
|
||
}), false);
|
||
const leadHtml = renderCard(clearLead);
|
||
assert.doesNotMatch(leadHtml, /rectification-range-delivery__invite/);
|
||
assert.doesNotMatch(leadHtml, /不限领域/);
|
||
assert.match(leadHtml, /更像这个/);
|
||
|
||
const stillOpen = buildRangeDelivery({
|
||
inference: inference({
|
||
transitions: [{ layer: "d9", at: "04:52" }],
|
||
}),
|
||
publicCandidates,
|
||
credibleRange: ["04:48", "05:07"],
|
||
representativeTime: "04:50",
|
||
evidence: DATED_EVIDENCE,
|
||
declinedTopics: [],
|
||
});
|
||
assert.match(stillOpen.narrow_hint ?? "", /还能再收窄/);
|
||
assert.equal(rangeDeliveryShowsOpenCollectInvite({
|
||
hint: stillOpen.narrow_hint,
|
||
columns: stillOpen.columns,
|
||
}), false);
|
||
const openHtml = renderCard(stillOpen);
|
||
assert.doesNotMatch(openHtml, /rectification-range-delivery__invite/);
|
||
assert.doesNotMatch(openHtml, /不限领域/);
|
||
});
|
||
|
||
test("a new dated event after delivery stales the snapshot and leaves the delivered gap", () => {
|
||
const rows = DATED_EVIDENCE.map((item) => ({ ...item }));
|
||
const fingerprint = evidenceLedgerFingerprint(rows as never);
|
||
const times = ["04:50", "04:52", "04:56"] as const;
|
||
const state = inference({
|
||
candidate_set_id: candidateSetId("04:48", "05:07", [...times]),
|
||
refresh_count: 1,
|
||
refresh_attempts: [{
|
||
candidate_set_id: candidateSetId("04:48", "05:07", [...times]),
|
||
answer_count: 0,
|
||
result: "no_new_probes",
|
||
at: "2026-09-14T00:00:00.000Z",
|
||
}],
|
||
});
|
||
const dossier: DecisionDossier = {
|
||
evidence: rows,
|
||
conversationSummary: {
|
||
activeFocus: null,
|
||
declinedSkippedTopics: declineAllTargeted(),
|
||
},
|
||
latestResult: {
|
||
resultId: "55555555-5555-4555-8555-555555555555",
|
||
selectionAllowed: true,
|
||
confirmationAllowed: false,
|
||
evidenceLedgerFingerprint: fingerprint,
|
||
candidates: times.map((time, index) => ({
|
||
candidateId: [ID_A, ID_B, ID_C][index],
|
||
time,
|
||
rank: index + 1,
|
||
relativeSupport: time === "04:56" ? 20 : 26,
|
||
posterior_score: time === "04:56" ? 20 : 26,
|
||
})),
|
||
representativeTime: "04:50",
|
||
decisionReceipt: {
|
||
accept_allowed: true,
|
||
acceptance_allowed: true,
|
||
propose_allowed: true,
|
||
selection_allowed: true,
|
||
confirmation_allowed: false,
|
||
inference_state: state,
|
||
},
|
||
},
|
||
case: {
|
||
acceptedTime: null,
|
||
status: "candidate_ready",
|
||
candidateRange: { start_time: "04:45", end_time: "05:15" },
|
||
},
|
||
};
|
||
const delivered = decideFromDossier(dossier, { birthDate: "1997-08-08" });
|
||
assert.ok(
|
||
delivered.nextAction === "offer_provisional_range"
|
||
|| delivered.nextAction === "ready_to_adopt"
|
||
|| delivered.nextAction === "complete_with_range",
|
||
delivered.nextAction,
|
||
);
|
||
assert.equal(gapFor(delivered), "delivered");
|
||
|
||
const nextEvidence = [
|
||
...rows,
|
||
{
|
||
id: "evidence-lawsuit",
|
||
status: "confirmed",
|
||
domain: "other",
|
||
datePrecision: "month",
|
||
occurredFrom: "2019-03-01",
|
||
occurredTo: null,
|
||
eventKind: "legal_case",
|
||
summary: "打官司",
|
||
},
|
||
];
|
||
const nextDossier: DecisionDossier = { ...dossier, evidence: nextEvidence };
|
||
const inferenceAfter = previousInferenceFromReceipt(nextDossier.latestResult?.decisionReceipt ?? null);
|
||
assert.equal(scoreableSnapshotCurrentFromDossier(dossier, undefined, inferenceAfter), true);
|
||
assert.equal(scoreableSnapshotCurrentFromDossier(nextDossier, undefined, inferenceAfter), false);
|
||
const after = decideFromDossier(nextDossier, { birthDate: "1997-08-08" });
|
||
assert.equal(
|
||
after.nextAction === "complete_with_range"
|
||
|| after.nextAction === "offer_provisional_range"
|
||
|| after.nextAction === "ready_to_adopt",
|
||
false,
|
||
after.nextAction,
|
||
);
|
||
assert.notEqual(after.stopReason, "tied_first");
|
||
assert.notEqual(gapFor(after), "delivered");
|
||
});
|