Files
Jyotisha/frontend/tests/rectification-dead-d9-choice-20260916.test.ts
T
Jesse_ChenandClaude Opus 5.5 3a66c39f48 refactor(rectification): split the chat component into hooks, parameter functions and blocks
Move-only (TASK-rectification-code-split-20260926 T1/T4). No behavior, API,
copy, DB, Skill or scoring change.

- rectification-agentic-chat.tsx: 2043 -> 737 lines; component body
  1625 -> 630; useState 35 -> 12, useRef 16 -> 9, effects 9 -> 5,
  useCallback 12 -> 6.
- Snapshot sync -> hooks/use-rectification-case-snapshot.ts; board layout
  and live label clock -> two small hooks.
- send / submitStructuredChoice / acceptCandidate bodies ->
  lib/rectification-chat-{turn,choice,accept}-run.ts parameter functions
  (bodies verbatim, deps destructured to the same names); copy/regenerate,
  question repair, pure transcript and snapshot helpers and the per-render
  view derivation -> lib/rectification-chat-*.ts (no React hooks).
- Question-gap blocks and the read-only range line ->
  components/rectification-question-gap-notices.tsx.
- Dependency arrays kept exactly as before (lint warnings +3, listed in
  PROGRESS); no dep added to appease the linter.
- Tests: whole-source contracts read tests/rectification-chat-surface.ts
  (container + split files, like home-surface.ts); slices of moved code now
  call the extracted functions or render the extracted block, each with a
  原值/新值/原因 comment. New tests/rectification-growth-contract.test.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 19:23:52 +08:00

493 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import {
applyChoiceWithoutEvidence,
previousInferenceFromReceipt,
stampChoiceSchemaWithProbe,
} from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import {
buildMethodFollowupPlan,
yearlessPersonalityCanAsk,
type MethodFollowup,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts";
import { rectificationFollowupCatalog } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import {
inferenceForPersistedAnswer,
persistedDistinguishFocusStale,
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { expectedAnswerSchemaFor } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { buildChoiceFrame } from "../src/lib/rectification-agentic/v9/choice-card.ts";
import { buildRangeDelivery } from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
import { parseWindowScan } from "../src/lib/rectification-agentic/v9/varga-observations.ts";
import {
buildVargaDistinguishFields,
withOwnedDistinguishProbes,
} from "../src/lib/rectification-agentic/v9/varga-distinguish-probe.ts";
import { rectificationQuestionGapState } from "../src/lib/rectification-surface-state.ts";
import { FOCUS_ID } from "./rectification-v9-test-support.ts";
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
const OWNED_KEY = "d9_relationship:relationship_style";
const QUESTION_ID = "d9_relationship:relationship_style:score";
const DATED = [
{
status: "confirmed",
domain: "health_pressure",
datePrecision: "month",
occurredFrom: "2019-03-01",
occurredTo: "2019-03-31",
},
{
status: "confirmed",
domain: "career",
datePrecision: "year",
occurredFrom: "2018-01-01",
occurredTo: "2018-12-31",
},
{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: "2016-12-31",
},
{
status: "confirmed",
domain: "occupation",
datePrecision: "unknown",
occurredFrom: null,
occurredTo: null,
},
];
const D24 = {
id: "contrast:varga.d24.白羊座|金牛座|双子座|巨蟹座|狮子座|处女座",
semantic_key: "varga.d24.白羊座|金牛座|双子座|巨蟹座|狮子座|处女座",
candidate_split_hash: "set:varga.d24.白羊座|金牛座|双子座|巨蟹座|狮子座|处女座",
domain: "education",
year: 0,
question: "学业对照",
candidate_ids: ["04:48", "04:59"],
expected_outcomes: [
{ answer_class: "yes" as const, supports: ["04:48"], conflicts: ["04:59"] },
{ answer_class: "weak_yes" as const, supports: ["04:59"], conflicts: ["04:48"] },
{ answer_class: "no" as const, supports: [], conflicts: [] },
{ answer_class: "unsure" as const, supports: [], conflicts: [] },
],
information_gain: 0.9,
source: "engine",
choice_kind: "event_quality" as const,
};
/** Engine window scan as it arrives on the decision receipt. */
const WINDOW_SCAN_RECEIPT = {
scanned: true,
d9_lagna_count: 2,
d10_lagna_count: 1,
d9_candidates_differ: true,
d10_candidates_differ: false,
d9_sign_names: ["天蝎座", "天秤座"],
d10_sign_names: ["巨蟹座"],
transitions: [
{ layer: "d9", at: "04:53", from_sign: "天蝎座", to_sign: "天秤座" },
{ layer: "d10", at: "04:40", from_sign: "巨蟹座", to_sign: "巨蟹座" },
],
};
const WINDOW = parseWindowScan(WINDOW_SCAN_RECEIPT)!;
const CANDIDATES = [
{ id: "04:48", time: "04:48" },
{ id: "04:53", time: "04:53" },
{ id: "04:59", time: "04:59" },
];
/** The persisted state: engine probes only, exactly as the receipt stores it. */
function persistedState() {
return buildInferenceState({
range_start: "04:48",
range_end: "05:07",
candidates: [
{ id: "04:48", time: "04:48", relative_support: 44 },
{ id: "04:53", time: "04:53", relative_support: 35 },
{ id: "04:59", time: "04:59", relative_support: 21 },
],
events: Array.from({ length: 5 }, (_, index) => ({
id: `e${index}`,
domain: "health",
year: 2019,
precision: "month" as const,
})),
probes: [D24],
});
}
function receipt() {
return { inference_state: persistedState(), window_scan: WINDOW_SCAN_RECEIPT };
}
/**
* A D9 style focus as it was persisted before the 2026-09-17 (b) decision:
* its identity is the layer's own key, and its probe lives nowhere but the
* receipt's window scan.
*/
function legacyD9Followup(): MethodFollowup {
const fields = buildVargaDistinguishFields({
methodId: "d9_relationship",
askTheme: "relationship_style",
transitions: WINDOW.transitions,
candidates: CANDIDATES,
});
assert.ok(fields);
const followup = {
method_id: "d9_relationship",
intent: "distinguish_candidates",
ask_theme: "relationship_style",
domain: "relationship",
kind_hint: "relationship_change",
user_prompt_hint: "当前候选在关系主题上仍分不开。",
source: "varga_observation",
must_not_label: false,
choice_frame: null,
...fields,
} as MethodFollowup;
const frame = buildChoiceFrame(followup, {
evidence: DATED,
probes: [{
year: 0,
year_label: "当前这几个候选",
domain: "relationship",
event_family: "开始认真关系、分手或结婚",
source: "dasha_activation",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: followup.user_prompt_hint,
role: "distinguish",
phase: "candidate_discriminator",
information_gain: followup.information_gain,
semantic_key: followup.semantic_key,
candidate_split_hash: followup.candidate_split_hash,
candidate_ids: followup.candidate_ids,
expected_outcomes: followup.expected_outcomes,
choice_kind: "varga_style" as const,
style_options: followup.style_options,
}],
});
assert.ok(frame);
return { ...followup, choice_frame: frame };
}
function persistedSchema(): Record<string, unknown> {
const followup = legacyD9Followup();
const schema = expectedAnswerSchemaFor(
followup.choice_frame!,
QUESTION_ID,
receipt(),
followup,
);
assert.ok(schema);
return schema;
}
test("stamping rebuilds the owned probe from the receipt instead of borrowing D24 (BUG-912/915)", () => {
const state = persistedState();
const borrowed = stampChoiceSchemaWithProbe({
choice: {
options: [
{ key: "A", answer_class: "yes" },
{ key: "B", answer_class: "weak_yes" },
{ key: "C", answer_class: "no" },
{ key: "D", answer_class: "unsure" },
],
},
}, state, QUESTION_ID);
assert.notEqual(borrowed.probe_id, D24.id);
// The persisted receipt itself never carries the owned probe.
const stored = previousInferenceFromReceipt(receipt());
assert.ok(stored);
assert.equal(stored.probes.some((probe) => probe.semantic_key === OWNED_KEY), false);
const rebuilt = withOwnedDistinguishProbes(stored, receipt());
assert.ok(rebuilt?.probes.some((probe) => probe.semantic_key === OWNED_KEY));
const schema = persistedSchema();
assert.equal(schema.semantic_key, OWNED_KEY);
assert.equal(schema.probe_id, `probe:${OWNED_KEY}`);
assert.notEqual(String(schema.probe_id ?? ""), D24.id);
});
test("the production answer path applies a persisted owned-probe choice (BUG-915)", () => {
const schema = persistedSchema();
const answerInput = {
choiceKey: "C",
schema,
questionId: QUESTION_ID,
domain: "relationship",
classifiedFrom: "choice" as const,
};
// Without the rebuild the same schema is unanswerable: that was the P0.
const withoutRebuild = applyChoiceWithoutEvidence(previousInferenceFromReceipt(receipt())!, answerInput);
assert.equal(withoutRebuild.applied, false);
assert.equal(withoutRebuild.reason, "stale_probe");
const applied = applyChoiceWithoutEvidence(inferenceForPersistedAnswer(receipt())!, answerInput);
assert.equal(applied.reason, "applied");
assert.equal(applied.applied, true);
assert.equal(applied.state.answered_probes.at(-1)?.semantic_key, OWNED_KEY);
assert.ok(!applied.state.answered_probes.some((row) => row.probe_id === D24.id));
});
test("GET projects the persisted D9 card from the receipt alone (BUG-915)", () => {
const schema = persistedSchema();
const card = choiceCardFromCaseDossier({
evidence: DATED,
conversationSummary: {
activeFocus: {
id: FOCUS_ID,
questionId: QUESTION_ID,
intent: "distinguish_candidates",
targetDomain: "relationship",
targetKind: null,
expectedAnswerSchema: schema,
},
declinedSkippedTopics: [],
},
latestResult: {
resultId: "result-1",
decisionReceipt: receipt(),
selectionAllowed: false,
confirmationAllowed: false,
candidates: [
{ time: "04:48", rank: 1, relativeSupport: 44 },
{ time: "04:53", rank: 2, relativeSupport: 35 },
{ time: "04:59", rank: 3, relativeSupport: 21 },
],
},
case: { acceptedTime: null, status: "active", stage: "minute" },
});
assert.ok(card, "GET must render the persisted focus");
assert.equal(card.question_id, QUESTION_ID);
assert.equal(card.options.length, 4);
});
test("idle persist does not supersede a focus whose probe is rebuilt (BUG-915)", () => {
const schema = persistedSchema();
const focus = { intent: "distinguish_candidates", expectedAnswerSchema: schema };
// The raw receipt state has no owned probe: that is what superseded the
// fresh focus on every idle persist.
assert.equal(persistedDistinguishFocusStale(previousInferenceFromReceipt(receipt()), focus), true);
assert.equal(persistedDistinguishFocusStale(inferenceForPersistedAnswer(receipt()), focus), false);
// A changed candidate set still expires the focus (BUG-559 stays).
const movedReceipt = {
window_scan: WINDOW_SCAN_RECEIPT,
inference_state: buildInferenceState({
range_start: "04:48",
range_end: "05:07",
candidates: [
{ id: "04:48", time: "04:48", relative_support: 60 },
{ id: "04:59", time: "04:59", relative_support: 40 },
],
events: persistedState().events,
probes: [D24],
}),
};
assert.equal(persistedDistinguishFocusStale(inferenceForPersistedAnswer(movedReceipt), focus), true);
});
test("every state read that can touch an owned probe rebuilds it (BUG-915 source contract)", () => {
const sites = [
"src/lib/rectification-agentic/v9/server-focus.ts",
"src/lib/rectification-agentic/v9/interview-state.ts",
];
for (const site of sites) {
const source = readFileSync(new URL(`../${site}`, import.meta.url), "utf8");
assert.match(source, /withOwnedDistinguishProbes\(/, site);
}
const answerChoice = readFileSync(
new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url),
"utf8",
);
// The tap answer and the idle supersede check both read the rebuilt state.
assert.match(answerChoice, /const previous = inferenceForPersistedAnswer\(receipt\);/);
assert.match(answerChoice, /const inferenceForStale = withOwnedDistinguishProbes\(/);
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
// The typed answer goes through the same helper as the tap.
assert.match(tools, /const previous = inferenceForPersistedAnswer\(/);
});
const REL_2020_PROBE = {
year: 2020,
year_label: "2020 年前后",
domain: "relationship" as const,
event_family: "开始认真关系、分手或结婚",
source: "dasha_activation" as const,
tracks: ["vimshottari", "narayana"] as const,
tracks_agree: true,
unique_minute_claim: false as const,
user_meaning: "2020 年感情线",
role: "distinguish" as const,
phase: "candidate_discriminator" as const,
information_gain: 0.5,
semantic_key: "relationship.2020",
candidate_split_hash: "set:relationship.2020",
candidate_ids: ["04:48", "04:53", "04:59"],
expected_outcomes: [
{ answer_class: "yes" as const, supports: ["04:48"], conflicts: ["04:53", "04:59"] },
{ answer_class: "weak_yes" as const, supports: ["04:53"], conflicts: ["04:48", "04:59"] },
{ answer_class: "no" as const, supports: ["04:59"], conflicts: ["04:48", "04:53"] },
{ answer_class: "unsure" as const, supports: [], conflicts: [] },
],
choice_kind: "existence" as const,
};
/** Every dated collect line already closed, as in the 2026-09-17 real run. */
const CLOSED_COLLECT = [
"family",
"education",
"finance",
"relocation",
"health_pressure",
"career",
"relationship",
].map((domain) => ({
question_id: `collect:targeted:${domain}`,
target_kind: `targeted:${domain}`,
domain,
status: "declined",
intent: "collect_method_evidence",
}));
function planWithD9Split(input: { askedProbeKeys: readonly string[] }) {
return buildMethodFollowupPlan({
evidence: DATED,
observations: [
{ layer: "d9", candidates_differ: true, ask_theme: "relationship_style" },
{ layer: "d10", candidates_differ: false, ask_theme: null },
],
windowScan: WINDOW,
inferenceState: persistedState(),
sessionOutcome: "discriminate_candidates",
precisionStage: "d9_refine",
eventProbes: [REL_2020_PROBE],
askedProbeKeys: input.askedProbeKeys,
closedCollectFocuses: CLOSED_COLLECT,
topCandidateTimes: ["04:48", "04:53", "04:59"],
candidateRanges: CANDIDATES.map((row) => ({ id: row.id, time: row.time })),
});
}
test("a varga style question is never a scoring discriminator (2026-09-17 decision b)", () => {
// The engine's relationship probe is already asked, so nothing dates this
// line: before the decision this is where 「你更接近哪一种相处方式」 came from.
const plan = planWithD9Split({ askedProbeKeys: ["relationship.2020"] });
const next = plan.next_followup;
assert.notEqual(next?.semantic_key, OWNED_KEY);
assert.notEqual(next?.choice_kind, "varga_style");
assert.doesNotMatch(next?.choice_frame?.prompt ?? "", /更接近哪一种/);
assert.equal(
next?.intent === "distinguish_candidates"
&& (next.source === "varga_observation" || next.source === "precision_stage"),
false,
JSON.stringify({ intent: next?.intent, source: next?.source, key: next?.semantic_key }),
);
assert.ok(
plan.dropped_probes.some((row) => (
row.semantic_key === "d9_relationship" && row.reason === "frameless_distinguish"
)),
JSON.stringify(plan.dropped_probes),
);
});
test("the D9 line is still asked when the engine dates it (2026-09-17 decision b)", () => {
const plan = planWithD9Split({ askedProbeKeys: [] });
const next = plan.next_followup;
assert.equal(next?.method_id, "d9_relationship");
assert.equal(next?.intent, "distinguish_candidates");
assert.equal(next?.semantic_key, REL_2020_PROBE.semantic_key);
assert.equal(next?.choice_kind, "existence");
assert.match(next?.choice_frame?.prompt ?? "", /2020/);
});
test("a rebuilt owned probe never becomes an askable discriminator (decision b)", () => {
// Answering an old style focus writes the rebuilt probes into the receipt.
const answered = withOwnedDistinguishProbes(previousInferenceFromReceipt(receipt()), receipt())!;
const owned = answered.probes.find((probe) => probe.semantic_key === OWNED_KEY);
assert.ok(owned);
const catalog = rectificationFollowupCatalog(
{ resultId: "result-1", decisionReceipt: { ...receipt(), inference_state: answered } },
DATED,
);
assert.equal(catalog.eventProbes.some((probe) => probe.semantic_key === OWNED_KEY), false);
assert.equal(
(catalog.contrastPacket?.probes ?? []).some((probe) => probe.semanticKey === OWNED_KEY),
false,
);
});
test("a dead tap card never shares the screen with the collect-wait placeholder (BUG-917)", () => {
const base = {
liveQuestionVisible: false,
questionMissing: true,
questionLoadFailed: false,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: 0,
collectWaiting: true,
};
assert.equal(rectificationQuestionGapState(base), "collect_waiting");
assert.equal(
rectificationQuestionGapState({ ...base, unansweredDeadChoice: true }),
"unavailable",
);
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
assert.match(chat, /unansweredDeadChoice: unansweredDeadChoiceOnMessages,/);
assert.match(chat, /latestSettledAssistant\.question\.status === "superseded"/);
const entry = readFileSync(
new URL("../src/components/rectification-message-entry.tsx", import.meta.url),
"utf8",
);
assert.match(entry, /question\.status === "superseded" && !question\.answer_option/);
});
test("five dated events do not hold for yearless personality (BUG-913 closed_by_design)", () => {
assert.equal(yearlessPersonalityCanAsk({ datedCount: 5, candidatesSeparated: false }), false);
assert.equal(yearlessPersonalityCanAsk({ datedCount: 0, candidatesSeparated: false, topCandidateTimes: ["04:48", "04:59"] }), true);
});
test("nakshatra opposite poles are not both lifted as shared traits (BUG-914)", () => {
const earlier = "希望两边都能说得过去";
const later = "必要时会直接选边";
const delivery = buildRangeDelivery({
inference: persistedState(),
publicCandidates: [
{ candidateId: "a", time: "04:48" },
{ candidateId: "b", time: "04:53" },
{ candidateId: "c", time: "04:59" },
],
credibleRange: ["04:48", "04:59"],
representativeTime: "04:59",
nakshatraBoundary: {
near_boundary: true,
user_meaning: `A:${earlier}。B:${later}。`,
options: [
{ key: "A", time_bias: "earlier", traits: [earlier] },
{ key: "B", time_bias: "later", traits: [later] },
],
},
});
const shared = delivery.shared_traits.join(" ");
assert.equal(shared.includes(earlier) && shared.includes(later), false);
});