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
235 lines
9.0 KiB
TypeScript
235 lines
9.0 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import test from "node:test";
|
||
|
||
import {
|
||
templatePostAdoptExplain,
|
||
validateAdoptNarration,
|
||
} from "../src/lib/rectification-agentic/v9/adopt-narration.ts";
|
||
import {
|
||
buildMethodFollowupPlan,
|
||
buildNextUserAction,
|
||
spokenFollowupForUser,
|
||
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||
import {
|
||
RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||
rectificationQuestionGapState,
|
||
rectificationQuestionRetryActive,
|
||
type RectificationQuestionGapInput,
|
||
} from "../src/lib/rectification-surface-state.ts";
|
||
import { rectificationChatSurface } from "./rectification-chat-surface.ts";
|
||
import { createElement } from "react";
|
||
import { renderToStaticMarkup } from "react-dom/server";
|
||
import { RectificationQuestionGapNotices } from "../src/components/rectification-question-gap-notices.tsx";
|
||
import { RECTIFICATION_QUESTION_RELOAD_LABEL } from "../src/lib/rectification-surface-state.ts";
|
||
|
||
const STYLE_OPTIONS = [
|
||
{ label: "明确发生且时间吻合", answer_class: "yes" as const },
|
||
{ label: "发生过但程度较弱", answer_class: "weak_yes" as const },
|
||
{ label: "明确没有发生", answer_class: "no" as const },
|
||
{ label: "这段记不清楚", answer_class: "unsure" as const },
|
||
];
|
||
|
||
function verifyProbe(input: {
|
||
domain: "finance" | "relocation";
|
||
year: number;
|
||
semantic: string;
|
||
split: string;
|
||
}) {
|
||
return {
|
||
year: input.year,
|
||
year_label: `${input.year} 年前后`,
|
||
domain: input.domain,
|
||
event_family: input.domain === "finance"
|
||
? "收入明显变化、大笔支出或欠债"
|
||
: "搬家或长期住到外地",
|
||
source: "dasha_activation" as const,
|
||
tracks: ["vimshottari", "narayana"] as const,
|
||
tracks_agree: true,
|
||
unique_minute_claim: false as const,
|
||
user_meaning: `${input.year} 年前后是否有相关经历。`,
|
||
role: "distinguish" as const,
|
||
style_options: STYLE_OPTIONS,
|
||
semantic_key: input.semantic,
|
||
candidate_split_hash: input.split,
|
||
};
|
||
}
|
||
|
||
const FINANCE_2013 = verifyProbe({
|
||
domain: "finance",
|
||
year: 2013,
|
||
semantic: "finance.2013.verify",
|
||
split: "finance-2013-split",
|
||
});
|
||
const RELOC_2017 = verifyProbe({
|
||
domain: "relocation",
|
||
year: 2017,
|
||
semantic: "relocation.2017.verify",
|
||
split: "reloc-2017-split",
|
||
});
|
||
|
||
const EDUCATION_EVIDENCE = [{
|
||
status: "confirmed" as const,
|
||
domain: "education",
|
||
datePrecision: "year" as const,
|
||
occurredFrom: "2008-01-01",
|
||
occurredTo: null,
|
||
}];
|
||
|
||
const gapBase: RectificationQuestionGapInput = {
|
||
liveQuestionVisible: false,
|
||
questionMissing: true,
|
||
questionLoadFailed: false,
|
||
busy: false,
|
||
readonly: false,
|
||
regenerating: false,
|
||
snapshotLoaded: true,
|
||
resumableCase: true,
|
||
retryAttempts: 0,
|
||
};
|
||
|
||
test("post-adopt reverse-verify skips a probe already present in askedKeys", () => {
|
||
const remaining = buildMethodFollowupPlan({
|
||
evidence: EDUCATION_EVIDENCE,
|
||
accepted: true,
|
||
eventProbes: [FINANCE_2013, RELOC_2017],
|
||
askedProbeKeys: [FINANCE_2013.semantic_key],
|
||
});
|
||
assert.equal(remaining.next_followup?.domain, "relocation");
|
||
assert.equal(remaining.next_followup?.semantic_key, RELOC_2017.semantic_key);
|
||
|
||
const none = buildMethodFollowupPlan({
|
||
evidence: EDUCATION_EVIDENCE,
|
||
accepted: true,
|
||
eventProbes: [FINANCE_2013, RELOC_2017],
|
||
askedProbeKeys: [FINANCE_2013.semantic_key, RELOC_2017.semantic_key],
|
||
});
|
||
assert.equal(none.next_followup, null);
|
||
const action = buildNextUserAction({
|
||
scorableCount: 1,
|
||
evidenceCount: 1,
|
||
hasLatestResult: true,
|
||
selectionAllowed: true,
|
||
sessionOutcome: "adopt_representative",
|
||
nextFollowup: none.next_followup,
|
||
workingTime: "05:07",
|
||
accepted: true,
|
||
});
|
||
assert.equal(action.id, "start_consultation");
|
||
});
|
||
|
||
test("post-adopt reverse-verify also dedupes by candidate_split_hash", () => {
|
||
const plan = buildMethodFollowupPlan({
|
||
evidence: EDUCATION_EVIDENCE,
|
||
accepted: true,
|
||
eventProbes: [FINANCE_2013, RELOC_2017],
|
||
askedProbeKeys: [FINANCE_2013.candidate_split_hash],
|
||
});
|
||
assert.equal(plan.next_followup?.domain, "relocation");
|
||
assert.notEqual(plan.next_followup?.semantic_key, FINANCE_2013.semantic_key);
|
||
});
|
||
|
||
test("empty reverse-verify list forbids a 会拿…核对 promise", () => {
|
||
const facts = {
|
||
precision_stage: "ready_to_adopt" as const,
|
||
already_accepted: false,
|
||
credible_range: ["05:00", "05:06"] as const,
|
||
representative_minute: "05:00",
|
||
runner_up_minute: "05:06",
|
||
opening_window: ["04:45", "05:15"] as const,
|
||
active_candidates: [],
|
||
answered_rounds: 5,
|
||
stop_facts: [],
|
||
post_adopt_verification: [],
|
||
minutes: ["05:00", "05:06"],
|
||
years: [],
|
||
};
|
||
assert.match(templatePostAdoptExplain(facts), /没有还能核对的前事/);
|
||
const denied = validateAdoptNarration("采用后会拿 2016 年教育题来核对。", facts);
|
||
assert.equal(denied.ok, false);
|
||
if (!denied.ok) assert.equal(denied.reason, "promise");
|
||
});
|
||
|
||
test("question gap: start_consultation is verified_idle; missing next_user_action keeps the old path", () => {
|
||
assert.equal(rectificationQuestionGapState({
|
||
...gapBase,
|
||
nextUserActionId: "start_consultation",
|
||
}), "verified_idle");
|
||
assert.equal(rectificationQuestionRetryActive("verified_idle"), false);
|
||
assert.equal(rectificationQuestionGapState(gapBase), "preparing");
|
||
assert.equal(rectificationQuestionGapState({
|
||
...gapBase,
|
||
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||
}), "unavailable");
|
||
});
|
||
|
||
test("verified_idle UI is a closing line without reload, gap copy, or consult handoff", () => {
|
||
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
|
||
const chat = rectificationChatSurface;
|
||
assert.match(chat, /questionGap === "verified_idle"/);
|
||
assert.match(chat, /postAdoptVerifyDoneCopy/);
|
||
assert.match(chat, /skip_this_probe/);
|
||
assert.match(chat, /SKIP_PROBE_ACTION/);
|
||
assert.doesNotMatch(chat, /rectification-consult-handoff/);
|
||
// 原值: 从 chat 源码第一处 `questionGap === "verified_idle"` 起取 400 字,断言不含
|
||
// rectification-question-gap__copy 与 RECTIFICATION_QUESTION_RELOAD_LABEL。
|
||
// 新值: 真渲染缺题块(verified_idle + 收尾句、无对照卡):只有一行 role="status" 的收尾句,
|
||
// 没有缺题文案、没有「接着问」重载按钮。
|
||
// 原因: TASK-rectification-code-split-20260926 该 JSX 原样搬进 rectification-question-gap-notices.tsx;
|
||
// 渲染断言比按字数截源码更准。
|
||
const idleHtml = renderToStaticMarkup(createElement(RectificationQuestionGapNotices, {
|
||
view: { showStandaloneQuestion: false, questionGap: "verified_idle", showSelectionCards: false, verifiedIdleCopy: "已按 05:07 核对完毕。" },
|
||
currentQuestion: null,
|
||
choiceCard: null,
|
||
choiceNonce: 0,
|
||
busy: false,
|
||
readonly: false,
|
||
snapshotFailure: null,
|
||
questionRepairAttempts: 0,
|
||
questionRepairing: false,
|
||
submitChoice() {},
|
||
submitStop() {},
|
||
async refetchQuestion() {},
|
||
async repairQuestion() {},
|
||
}));
|
||
assert.equal(idleHtml, '<p class="rectification-pending-note" role="status">已按 05:07 核对完毕。</p>');
|
||
assert.doesNotMatch(idleHtml, /rectification-question-gap__copy/);
|
||
assert.ok(!idleHtml.includes(RECTIFICATION_QUESTION_RELOAD_LABEL));
|
||
// 原值: 状态条「已采用 {savedTime}」+「之后新建对话即按此时间排盘」
|
||
// 新值: verified_idle 走 postAdoptVerifyDoneCopy;无核对题用新句
|
||
// 原因: BUG-595 决策 3
|
||
assert.doesNotMatch(chat, /之后新建对话即按此时间排盘/);
|
||
});
|
||
|
||
test("family collect spoken stem uses a year cue while probe_year stays dated", () => {
|
||
const plan = buildMethodFollowupPlan({
|
||
evidence: [
|
||
{ status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2011-01-01", occurredTo: null },
|
||
{ status: "confirmed", domain: "relationship", datePrecision: "year", occurredFrom: "2014-01-01", occurredTo: null },
|
||
],
|
||
evidenceCollectionProbes: [{
|
||
year: 2021,
|
||
year_label: "2021 年前后",
|
||
domain: "family",
|
||
event_family: "家人结婚、添丁或住院",
|
||
source: "age_band",
|
||
tracks: ["vimshottari", "narayana"],
|
||
tracks_agree: false,
|
||
unique_minute_claim: false,
|
||
user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。",
|
||
role: "collect",
|
||
phase: "evidence_collection",
|
||
information_gain: 0,
|
||
semantic_key: "family.2021",
|
||
}],
|
||
});
|
||
// 原值: family + 2021 年前后
|
||
// 新值: 训练门关时邀请优先,题干不含年龄段年份
|
||
// 原因: 撤回 BUG-642(BUG-648)
|
||
assert.equal(plan.next_followup?.domain, "other");
|
||
assert.equal(plan.next_followup?.collection_key, "collect:invite:more");
|
||
assert.equal(plan.next_followup?.choice_frame, null);
|
||
const spoken = spokenFollowupForUser(plan.next_followup);
|
||
assert.match(spoken ?? "", /还有吗/);
|
||
assert.doesNotMatch(spoken ?? "", /2021 年前后/);
|
||
});
|