fix(rectification): 记录与校正区间冲突时记录优先、分歧并列(BUG-691)
医院记录那一分钟落在校正区间外时,交付卡以前只说一句「相差 N 分钟」:
没说默认按哪个时间排盘,采用按钮也仍写中性的「更像这个」,用户看不出
点下去会把之后的排盘换成另一分钟。
产品负责人 2026-09-14 拍板「记录优先,分歧如实呈现」:
- 冲突文案补满三层——默认仍按出生记录时间排盘、经历指向另一段时间相差
N 分钟、两条路都可以走。
- 冲突态采用按钮改「改用校正结果」,按钮下按列写明
「选它之后,排盘会从出生记录时间 hh:mm 换成 hh:mm」。
- 新增 FORBIDDEN_RECORD_VERDICT_PHRASE 锁住 D4:不得宣布记录不准,
也不得宣布校正结果无效。
只改文案层与采用入口措辞。打分、判据、采用 RPC、置信度与确认门控、
数据库一律未动;不新增入口、不加确认弹窗;记录落在范围内或来源是
「大概时间 / 时间段」时卡片与今天完全一致。Skill 未 bump,只补
references §6.1。
验收:tsc 0 错;lint 0 error;npm test 3335 项 fail 31,失败清单与基线
37e6c519 逐条一致(全部是无 Docker / 无外网的既有环境缺口);next build
通过且 / 仍 ○ Static;首屏 chunks gzip +269 B(+0.019%)。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
co-authored by
Claude Opus 5
parent
63c419f375
commit
6df40c322c
@@ -20,6 +20,7 @@ import {
|
||||
} from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import { COLLECT_FLOW_BANNED_PHRASES, SPLIT_ENDPOINT_PHRASE } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts";
|
||||
import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts";
|
||||
import { FORBIDDEN_RECORD_VERDICT_PHRASE } from "../src/lib/rectification-agentic/birth-time-provenance.ts";
|
||||
import { attachQuestionsToTurns } from "../src/lib/rectification-agentic/v9/turn-question.ts";
|
||||
import { CASE_ID, FOCUS_ID, TURN_ID } from "./rectification-v9-test-support.ts";
|
||||
|
||||
@@ -381,7 +382,18 @@ test("declared-time copy splits hospital records from family estimates", () => {
|
||||
assert.match(visible, /你填的大概时间/);
|
||||
assert.match(visible, /你给的时间段/);
|
||||
assert.match(visible, /与你填的大概时间 05:00 相差 7 分钟/);
|
||||
assert.match(visible, /出生记录时间 05:12。目前范围不含这一分钟,相差 5 分钟/);
|
||||
// 原值: /出生记录时间 05:12。目前范围不含这一分钟,相差 5 分钟/
|
||||
// 新值: 三层冲突文案(默认按记录排盘 / 指向另一段时间差 5 分钟 / 两条路都可以走)+ 采用入口措辞
|
||||
// 原因: BUG-691 决策 D2、D3——冲突时只并列差值不够,必须说清默认按哪个排盘、改用会换掉什么
|
||||
assert.match(visible, /出生记录时间 05:12,默认仍按这个时间排盘/);
|
||||
assert.match(visible, /指向另一段时间,相差 5 分钟/);
|
||||
assert.match(visible, /你可以改用校正结果,也可以继续用记录/);
|
||||
assert.match(visible, /改用校正结果/);
|
||||
assert.match(visible, /选它之后,排盘会从出生记录时间 05:12 换成 04:53/);
|
||||
// D4: 并列不等于站队,任何一方都不得被宣布无效。
|
||||
for (const item of listUserVisibleCopy()) {
|
||||
assert.doesNotMatch(item, FORBIDDEN_RECORD_VERDICT_PHRASE, item);
|
||||
}
|
||||
const approximate = listUserVisibleCopy().filter((item) => item.includes("大概时间") || item.includes("时间段"));
|
||||
for (const item of approximate) {
|
||||
assert.doesNotMatch(item, /你的出生时间/);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import {
|
||||
FORBIDDEN_DECLARED_BIRTH_TIME_PHRASE,
|
||||
SWITCH_TO_RECTIFIED_LABEL,
|
||||
boardDeclaredTimeLine,
|
||||
normalizeBirthTimeSource,
|
||||
reportedTimeOffsetCopy,
|
||||
@@ -179,5 +180,11 @@ test("hospital_record copy names the record and states the offset without pickin
|
||||
}));
|
||||
assert.match(html, /出生记录时间 05:12/);
|
||||
assert.match(html, /相差 5 分钟/);
|
||||
assert.ok(html.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis));
|
||||
// 原值: assert.ok(html.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis))
|
||||
// 新值: 记录落在范围外时按钮写 SWITCH_TO_RECTIFIED_LABEL(「改用校正结果」)
|
||||
// 原因: BUG-691 决策 D3——记录与范围冲突时采用入口要说明自己换掉的是什么;
|
||||
// 05:12 对 04:48–05:07 正是冲突态。非冲突态仍走「更像这个」,
|
||||
// 由 rectification-record-conflict-copy-20260916.test.ts 逐态锁住。
|
||||
assert.ok(html.includes(SWITCH_TO_RECTIFIED_LABEL));
|
||||
assert.equal(html.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis), false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* BUG-691 · 记录与校正区间冲突时:记录优先,分歧如实呈现。
|
||||
*
|
||||
* 产品拍板(2026-09-14):默认仍按出生记录时间排盘,但校正结果必须并列呈现,
|
||||
* 系统不得宣布任何一方无效。本文件锁的是文案层与采用入口措辞,不涉及打分与采用逻辑。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import {
|
||||
FORBIDDEN_RECORD_VERDICT_PHRASE,
|
||||
SWITCH_TO_RECTIFIED_LABEL,
|
||||
recordRangeConflict,
|
||||
reportedTimeOffsetCopy,
|
||||
switchToRectifiedConfirmCopy,
|
||||
} from "../src/lib/rectification-agentic/birth-time-provenance.ts";
|
||||
import { buildRangeDelivery } from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
|
||||
import { RectificationRangeDelivery } from "../src/components/rectification-range-delivery.tsx";
|
||||
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import { INFERENCE_ALGORITHM_VERSION, type InferenceCandidate, type InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
|
||||
import type { RectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
|
||||
|
||||
const RANGE = ["04:48", "05:07"] as const;
|
||||
/** Outside RANGE by 5 minutes — the conflict case. */
|
||||
const RECORD_OUTSIDE = "05:12";
|
||||
/** Inside RANGE — must keep the pre-BUG-691 wording. */
|
||||
const RECORD_INSIDE = "05:00";
|
||||
|
||||
function cand(time: string, probability: number): InferenceCandidate {
|
||||
return {
|
||||
id: time,
|
||||
time,
|
||||
cluster_range: [RANGE[0], RANGE[1]],
|
||||
prior_score: probability * 100,
|
||||
posterior_score: probability * 100,
|
||||
probability,
|
||||
status: "active",
|
||||
rank: 1,
|
||||
strong_conflict_count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function inference(): InferenceState {
|
||||
return {
|
||||
algorithm_version: INFERENCE_ALGORITHM_VERSION,
|
||||
candidate_set_id: "set",
|
||||
revision: 1,
|
||||
phase: "discrimination",
|
||||
result_status: "credible_range",
|
||||
range_start: RANGE[0],
|
||||
range_end: RANGE[1],
|
||||
candidates: [cand("04:53", 0.4), cand("04:50", 0.35)],
|
||||
events: [],
|
||||
probes: [],
|
||||
answered_probes: [],
|
||||
rounds: [],
|
||||
entropy: 1,
|
||||
representative_time: "04:53",
|
||||
credible_range: [RANGE[0], RANGE[1]],
|
||||
};
|
||||
}
|
||||
|
||||
function delivery(reportedBirthTime: string, birthTimeSource: string) {
|
||||
return buildRangeDelivery({
|
||||
inference: inference(),
|
||||
publicCandidates: [
|
||||
{ candidateId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", time: "04:53" },
|
||||
{ candidateId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2", time: "04:50" },
|
||||
],
|
||||
credibleRange: [RANGE[0], RANGE[1]],
|
||||
representativeTime: "04:53",
|
||||
reportedBirthTime,
|
||||
birthTimeSource,
|
||||
});
|
||||
}
|
||||
|
||||
function deliveryResult(built: ReturnType<typeof buildRangeDelivery>): RectificationCandidateResult {
|
||||
return {
|
||||
resultId: "result-record-conflict",
|
||||
candidates: built.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: built.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: built.range,
|
||||
rangeDelivery: built,
|
||||
verificationReportMarkdown: built.verification_markdown,
|
||||
};
|
||||
}
|
||||
|
||||
function html(reportedBirthTime: string, birthTimeSource: string): string {
|
||||
return renderToStaticMarkup(createElement(RectificationRangeDelivery, {
|
||||
result: deliveryResult(delivery(reportedBirthTime, birthTimeSource)),
|
||||
acceptingCandidateId: null,
|
||||
readonly: false,
|
||||
onAccept: () => undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
test("conflict is only a hospital record that falls outside the range", () => {
|
||||
assert.deepEqual(
|
||||
recordRangeConflict({ source: "hospital_record", reportedTime: RECORD_OUTSIDE, range: RANGE }),
|
||||
{ reportedTime: RECORD_OUTSIDE, gapMinutes: 5 },
|
||||
);
|
||||
// Inside the range there is nothing to reconcile.
|
||||
assert.equal(recordRangeConflict({ source: "hospital_record", reportedTime: RECORD_INSIDE, range: RANGE }), null);
|
||||
// The other two sources are already declared approximate, so they cannot conflict.
|
||||
assert.equal(recordRangeConflict({ source: "approximate", reportedTime: RECORD_OUTSIDE, range: RANGE }), null);
|
||||
assert.equal(recordRangeConflict({ source: "period_only", reportedTime: RECORD_OUTSIDE, range: RANGE }), null);
|
||||
// Missing or unusable inputs are not a conflict.
|
||||
assert.equal(recordRangeConflict({ source: "hospital_record", reportedTime: RECORD_OUTSIDE, range: null }), null);
|
||||
assert.equal(recordRangeConflict({ source: "hospital_record", reportedTime: "", range: RANGE }), null);
|
||||
assert.equal(recordRangeConflict({ source: "hospital_record", reportedTime: "25:99", range: RANGE }), null);
|
||||
});
|
||||
|
||||
test("conflict copy states record-first default, the divergence, and the user's choice", () => {
|
||||
const copy = reportedTimeOffsetCopy({
|
||||
source: "hospital_record",
|
||||
reportedTime: RECORD_OUTSIDE,
|
||||
range: RANGE,
|
||||
representativeTime: "04:53",
|
||||
}) ?? "";
|
||||
// D2 ①: the chart still uses the record until the user says otherwise.
|
||||
assert.match(copy, /出生记录时间 05:12/);
|
||||
assert.match(copy, /默认仍按这个时间排盘/);
|
||||
// D2 ②: the evidence points somewhere else, and the gap is named.
|
||||
assert.match(copy, /指向另一段时间/);
|
||||
assert.match(copy, /相差 5 分钟/);
|
||||
// D2 ③: both doors stay open.
|
||||
assert.match(copy, /改用校正结果/);
|
||||
assert.match(copy, /继续用记录/);
|
||||
// D4: neither side may be declared invalid.
|
||||
assert.doesNotMatch(copy, FORBIDDEN_RECORD_VERDICT_PHRASE);
|
||||
// BUG-587: adoption is never described as a confirmed minute.
|
||||
assert.doesNotMatch(copy, /已确认|确定就是|唯一出生分钟是/);
|
||||
});
|
||||
|
||||
test("a record inside the range keeps the pre-BUG-691 wording", () => {
|
||||
const copy = reportedTimeOffsetCopy({
|
||||
source: "hospital_record",
|
||||
reportedTime: RECORD_INSIDE,
|
||||
range: RANGE,
|
||||
representativeTime: "04:53",
|
||||
}) ?? "";
|
||||
assert.equal(copy, "出生记录时间 05:00,落在目前范围内");
|
||||
assert.doesNotMatch(copy, /改用校正结果/);
|
||||
assert.doesNotMatch(copy, FORBIDDEN_RECORD_VERDICT_PHRASE);
|
||||
});
|
||||
|
||||
test("conflict swaps the adopt entry wording and says which clock replaces which", () => {
|
||||
const conflict = html(RECORD_OUTSIDE, "hospital_record");
|
||||
assert.ok(conflict.includes(SWITCH_TO_RECTIFIED_LABEL));
|
||||
assert.equal(conflict.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis), false);
|
||||
// The confirmation line is per column, so each button names its own target minute.
|
||||
assert.ok(conflict.includes(switchToRectifiedConfirmCopy(RECORD_OUTSIDE, "04:53")));
|
||||
assert.ok(conflict.includes(switchToRectifiedConfirmCopy(RECORD_OUTSIDE, "04:50")));
|
||||
assert.doesNotMatch(conflict, FORBIDDEN_RECORD_VERDICT_PHRASE);
|
||||
});
|
||||
|
||||
test("non-conflict states keep 更像这个 and show no switch note", () => {
|
||||
for (const [reported, source] of [
|
||||
[RECORD_INSIDE, "hospital_record"],
|
||||
[RECORD_OUTSIDE, "approximate"],
|
||||
[RECORD_OUTSIDE, "period_only"],
|
||||
] as const) {
|
||||
const markup = html(reported, source);
|
||||
assert.ok(markup.includes(RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis), `${reported}/${source}`);
|
||||
assert.equal(markup.includes(SWITCH_TO_RECTIFIED_LABEL), false, `${reported}/${source}`);
|
||||
assert.equal(markup.includes("换成 04:53"), false, `${reported}/${source}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("record_conflict survives the projection round trip and stays adopt-logic free", () => {
|
||||
const built = delivery(RECORD_OUTSIDE, "hospital_record");
|
||||
assert.deepEqual(built.record_conflict, { reported_time: RECORD_OUTSIDE, gap_minutes: 5 });
|
||||
assert.equal(delivery(RECORD_INSIDE, "hospital_record").record_conflict, null);
|
||||
// Adoption itself is untouched: the same candidates stay selectable either way.
|
||||
assert.deepEqual(
|
||||
built.columns.map((column) => column.candidate_id),
|
||||
delivery(RECORD_INSIDE, "hospital_record").columns.map((column) => column.candidate_id),
|
||||
);
|
||||
assert.equal(built.representative_time, delivery(RECORD_INSIDE, "hospital_record").representative_time);
|
||||
});
|
||||
Reference in New Issue
Block a user