Files
Jyotisha/frontend/tests/rectification-post-adopt-verdict-20261002.test.ts
T
Jesse_ChenandClaude Opus 5.5 6987875b84
Independent Staging Quality Gate / validate (push) Successful in 14m56s
Independent Staging Quality Gate / publish (push) Successful in 3m23s
feat(rectification): post-adopt verdict says whether the adopted time still fits and offers 改用 HH:MM; renumber BUG-1170→1172 (BUG-1173)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-02 07:40:30 +08:00

102 lines
7.5 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { candidateForAdoptedMinute, postAdoptCheckVerdict, readoptOffer } from "../src/lib/rectification-agentic/v9/post-adopt-verdict.ts";
import { attachOfferResultToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import type { SegmentSummary } from "../src/lib/rectification-agentic/core/segment-summary.ts";
// BUG-1172 (product 10-01 option A): after a post-adopt check the reply says whether the event
// fits the adopted time, and whether the chart verdicts moved — not only 「前事核对到这里」.
const candidate = (time: string, range: [string, string], clusterTimes?: string[]) => ({
id: time, time, cluster_range: range, ...(clusterTimes ? { cluster_times: clusterTimes } : {}),
prior_score: 10, posterior_score: 10, probability: 0.5, status: "active", rank: 1, strong_conflict_count: 0,
}) as never;
const CANDIDATES = [candidate("04:53", ["04:48", "04:57"], ["04:53", "04:55"]), candidate("05:03", ["05:00", "05:07"])];
const probe = {
id: "probe:education.2016.09.known_event_quality:x", semantic_key: "education.2016.09.known_event_quality",
candidate_split_hash: "x", domain: "education", year: 2016, question: "2016", candidate_ids: ["04:53", "05:03"],
information_gain: 0.4, source: "known_event_quality",
expected_outcomes: [
{ answer_class: "yes", supports: ["04:53"], conflicts: ["05:03"] },
{ answer_class: "no", supports: ["05:03"], conflicts: ["04:53"] },
{ answer_class: "weak_yes", supports: [], conflicts: [] },
],
} as never;
const summary = (tier: string, sign: number, adoptionTime: string | null = null): SegmentSummary => ({
targets: ["D1", "D9"], window_minutes: 21, scan_complete: true, no_rectification_needed: false,
adoption_minute: adoptionTime ? { offset: 0, time: adoptionTime, date: "1997-08-08", signs: {} } : null,
charts: [
{ chart: "D1", sign: 3, tier: "certain", alternatives: [], tied: false },
{ chart: "D9", sign, tier, alternatives: [], tied: false },
],
}) as never;
test("the adopted minute maps to the candidate whose cluster holds it, else the nearest", () => {
assert.equal(candidateForAdoptedMinute("04:55:00", CANDIDATES)?.time, "04:53");
assert.equal(candidateForAdoptedMinute("05:01", CANDIDATES)?.time, "05:03");
assert.equal(candidateForAdoptedMinute("05:30", CANDIDATES)?.time, "05:03", "outside every cluster: nearest");
});
test("verdict: fits, does not fit, cannot tell, and unsure", () => {
const base = { acceptedTime: "04:55:00", probe, candidates: CANDIDATES };
assert.equal(postAdoptCheckVerdict({ ...base, answerClass: "yes" }), "这件事和 04:55 对得上。");
// Minute product (no chart summary): the card is where a re-pick happens.
assert.equal(postAdoptCheckVerdict({ ...base, answerClass: "no" }),
"这件事和 04:55 对不上;如果别的经历也对不上,可以回到上面的卡片改选。");
assert.equal(postAdoptCheckVerdict({ ...base, answerClass: "weak_yes" }), "这件事分不出 04:55 和别的时间。");
assert.equal(postAdoptCheckVerdict({ ...base, answerClass: "unsure" }), "这件事记不清,不算。");
assert.equal(postAdoptCheckVerdict({ ...base, answerClass: null }), null);
assert.equal(postAdoptCheckVerdict({ ...base, acceptedTime: null, answerClass: "yes" }), null);
assert.equal(postAdoptCheckVerdict({ ...base, probe: null, answerClass: "yes" }), null, "no probe, no claim");
});
test("segment product says whether the chart verdicts moved", () => {
const base = { acceptedTime: "04:55", probe, candidates: CANDIDATES, answerClass: "yes" as const };
assert.equal(postAdoptCheckVerdict({ ...base, segmentBefore: summary("credible", 7), segmentAfter: summary("credible", 7) }),
"这件事和 04:55 对得上。盘型结论不变。");
// BUG-1173 — 原值: 「…盘型结论有变化:D1 上升:巨蟹(确定);D9 上升:天秤(倾向,备选也要看)。」 / 新值: 同一句
// 再接「你采用的 04:55 仍在其中,不用改。」 / 原因: 产品 10-01 选 A,变化时要说清采用的时间还在不在新结论里。
const minutes = [
{ offset: 7, time: "04:55", signs: { D1: 3, D9: 6 } },
{ offset: 14, time: "05:02", signs: { D1: 3, D9: 7 } },
];
const moved = postAdoptCheckVerdict({ ...base, segmentBefore: summary("credible", 7), segmentAfter: summary("tentative", 6), segmentMinutes: minutes });
assert.equal(moved, "这件事和 04:55 对得上。盘型结论有变化:D1 上升:巨蟹(确定);D9 上升:天秤(倾向,备选也要看)。你采用的 04:55 仍在其中,不用改。");
const left = postAdoptCheckVerdict({ ...base, answerClass: "no", segmentBefore: summary("credible", 6), segmentAfter: summary("credible", 7, "05:02"), segmentMinutes: minutes });
assert.equal(left, "这件事和 04:55 对不上。盘型结论有变化:D1 上升:巨蟹(确定);D9 上升:天蝎(较可信)。你采用的 04:55 的 D9 上升是天秤,和新的结论不一致;新的推荐时间是 05:02,可以点下面的「改用 05:02」。");
const noMinute = postAdoptCheckVerdict({ ...base, answerClass: "no", segmentBefore: summary("credible", 6), segmentAfter: summary("credible", 7), segmentMinutes: minutes });
assert.equal(noMinute, "这件事和 04:55 对不上。盘型结论有变化:D1 上升:巨蟹(确定);D9 上升:天蝎(较可信)。你采用的 04:55 的 D9 上升是天秤,和新的结论不一致,但现在还推荐不出单一时间,先按 04:55 排盘。");
for (const text of [moved!, left!, noMinute!]) assert.doesNotMatch(text, /确认|精确|相对支持|领先|落后/);
});
test("a clash that does not move the verdict says so instead of inviting a re-pick", () => {
const base = { acceptedTime: "04:55", probe, candidates: CANDIDATES, answerClass: "no" as const };
assert.equal(postAdoptCheckVerdict({ ...base, segmentBefore: summary("credible", 7), segmentAfter: summary("credible", 7) }),
"这件事和 04:55 对不上。一件事还不足以改变盘型结论,先按 04:55 排盘。");
});
test("re-offer only when the adopted minute left the verdict and a new minute exists", () => {
const minutes = [
{ offset: 7, time: "04:55", signs: { D1: 3, D9: 6 } },
{ offset: 14, time: "05:02", signs: { D1: 3, D9: 7 } },
];
assert.equal(readoptOffer("04:55:00", summary("credible", 7, "05:02"), minutes), "05:02");
assert.equal(readoptOffer("04:55", summary("credible", 6, "05:02"), minutes), null, "still fits: no re-offer");
assert.equal(readoptOffer("04:55", summary("credible", 7, null), minutes), null, "no server minute: no re-offer");
assert.equal(readoptOffer("04:55", summary("indistinct", 7, "05:02"), minutes), null, "an indistinct chart does not count");
assert.equal(readoptOffer(null, summary("credible", 7, "05:02"), minutes), null);
});
test("the card hangs under the latest reply only when a re-adopt is offered", () => {
const turns = [
{ id: "delivery", role: "assistant", question: null },
{ id: "adopt", role: "assistant", question: { kind: "reverse_verify", status: "answered", answer_option: "C" } },
{ id: "verdict", role: "assistant", question: null },
] as never[];
const owner = (readopt: boolean) => attachOfferResultToTurns(turns, { resultId: "r1", canAdopt: true, acceptedTime: "04:55", readopt })
.find((turn) => (turn as { offer_result_id: string | null }).offer_result_id) as { id: string } | undefined;
assert.equal(owner(false)?.id, "delivery");
assert.equal(owner(true)?.id, "verdict");
});