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
@@ -3338,6 +3338,12 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
font-size: var(--type-caption);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.rectification-range-delivery__switch-note {
|
||||
margin: 0;
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-caption);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.rectification-range-delivery__boundary {
|
||||
margin: 0;
|
||||
color: var(--color-ink-secondary);
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
rangeDeliveryShowsOpenCollectInvite,
|
||||
} from "@/lib/rectification-agentic/user-copy";
|
||||
import type { RangeDeliveryProjection } from "@/lib/rectification-agentic/v9/divergence-panel";
|
||||
import {
|
||||
SWITCH_TO_RECTIFIED_LABEL,
|
||||
switchToRectifiedConfirmCopy,
|
||||
} from "@/lib/rectification-agentic/birth-time-provenance";
|
||||
import { RectificationVerificationReport } from "@/components/rectification-verification-report";
|
||||
|
||||
function columnHasTraits(column: RangeDeliveryProjection["columns"][number]): boolean {
|
||||
@@ -48,6 +52,9 @@ export function RectificationRangeDelivery({
|
||||
hint: delivery?.narrow_hint,
|
||||
columns,
|
||||
});
|
||||
// BUG-691: while the birth record disagrees with the range the adopt entry says what
|
||||
// it actually does — swap the charting clock — instead of the neutral 「更像这个」.
|
||||
const recordConflict = delivery?.record_conflict ?? null;
|
||||
const caption = showOpenCollectInvite || closedInvite
|
||||
? rangeDeliveryCaptionWithoutClosedInvite(delivery?.narrow_hint)
|
||||
: delivery?.narrow_hint ?? null;
|
||||
@@ -129,8 +136,15 @@ export function RectificationRangeDelivery({
|
||||
? RECTIFICATION_USER_COPY.rangeDeliveryAdopting
|
||||
: adopted
|
||||
? RECTIFICATION_USER_COPY.rangeDeliveryAdopted
|
||||
: RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis}
|
||||
: recordConflict
|
||||
? SWITCH_TO_RECTIFIED_LABEL
|
||||
: RECTIFICATION_USER_COPY.rangeDeliveryMoreLikeThis}
|
||||
</button>
|
||||
{recordConflict && !adopted ? (
|
||||
<p className="rectification-range-delivery__switch-note">
|
||||
{switchToRectifiedConfirmCopy(recordConflict.reported_time, column.time)}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -24,6 +24,17 @@ export const REPORTED_TIME_LABEL: Readonly<Record<ProvenanceSource, string>> = {
|
||||
/** Approximate / period_only copy must not match this. Hospital uses 出生记录时间. */
|
||||
export const FORBIDDEN_DECLARED_BIRTH_TIME_PHRASE = /你的出生时间/;
|
||||
|
||||
/**
|
||||
* BUG-691 / D4: when the birth record and the rectified range disagree the product
|
||||
* states both sides and the gap. It must never declare either side invalid — not the
|
||||
* record ("记录不准"), and not the rectification ("校正结果无效").
|
||||
*/
|
||||
export const FORBIDDEN_RECORD_VERDICT_PHRASE =
|
||||
/记录不准|记录不对|记录错了|记录有误|记录写错|以记录为准|以证据为准|校正结果无效|校正结果不作数|应该信/;
|
||||
|
||||
/** Adopt entry wording while the record and the range disagree (BUG-691 / D3). */
|
||||
export const SWITCH_TO_RECTIFIED_LABEL = "改用校正结果";
|
||||
|
||||
export function reportedTimeLabel(source: string | null | undefined): string {
|
||||
return REPORTED_TIME_LABEL[normalizeBirthTimeSource(source)];
|
||||
}
|
||||
@@ -104,10 +115,42 @@ export function reportedTimeOffsetCopy(input: {
|
||||
)
|
||||
: offset;
|
||||
const gap = Number.isFinite(edge) ? edge : offset;
|
||||
return `出生记录时间 ${reported}。目前范围不含这一分钟,相差 ${gap} 分钟`;
|
||||
// BUG-691 / D2: record first, disagreement stated in full. Three beats —
|
||||
// what we chart by now, where the evidence points, and that the choice is the user's.
|
||||
return `出生记录时间 ${reported},默认仍按这个时间排盘。你说的这些经历指向另一段时间,相差 ${gap} 分钟。你可以改用校正结果,也可以继续用记录`;
|
||||
}
|
||||
if (source === "period_only") {
|
||||
return `与你给的时间段参照 ${reported} 相差 ${offset} 分钟`;
|
||||
}
|
||||
return `与你填的大概时间 ${reported} 相差 ${offset} 分钟`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The declared birth record sits outside the current range (BUG-691).
|
||||
* Only `hospital_record` can conflict: the other two sources are already stated as
|
||||
* approximate, so there is nothing to overrule. Returns null when there is no conflict.
|
||||
*/
|
||||
export function recordRangeConflict(input: {
|
||||
source?: string | null;
|
||||
reportedTime?: string | null;
|
||||
range?: readonly [string, string] | null;
|
||||
}): Readonly<{ reportedTime: string; gapMinutes: number }> | null {
|
||||
if (normalizeBirthTimeSource(input.source) !== "hospital_record") return null;
|
||||
const reported = (input.reportedTime ?? "").trim().slice(0, 5);
|
||||
if (!CLOCK.test(reported)) return null;
|
||||
const range = input.range ?? null;
|
||||
if (!range) return null;
|
||||
if (clockInRange(reported, range)) return null;
|
||||
const toStart = minutesBetweenClocks(reported, range[0]);
|
||||
const toEnd = minutesBetweenClocks(reported, range[1]);
|
||||
if (toStart == null || toEnd == null) return null;
|
||||
return { reportedTime: reported, gapMinutes: Math.min(toStart, toEnd) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Said again at the point of action (BUG-691 / D3), so the reader sees exactly which
|
||||
* clock replaces which. Adoption stays a 校正采用时间, never a confirmed minute.
|
||||
*/
|
||||
export function switchToRectifiedConfirmCopy(reportedTime: string, targetTime: string): string {
|
||||
return `选它之后,排盘会从出生记录时间 ${reportedTime} 换成 ${targetTime}`;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
REPORTED_TIME_LABEL,
|
||||
boardDeclaredTimeLine,
|
||||
reportedTimeOffsetCopy,
|
||||
SWITCH_TO_RECTIFIED_LABEL,
|
||||
switchToRectifiedConfirmCopy,
|
||||
} from "./birth-time-provenance.ts";
|
||||
|
||||
export const REPRESENTATIVE_MINUTE_DISCLAIMER = "这只是代表性候选,不是已确认的唯一出生分钟。";
|
||||
@@ -685,6 +687,8 @@ export function listUserVisibleCopy(): string[] {
|
||||
range: ["04:48", "05:07"],
|
||||
representativeTime: "04:53",
|
||||
}) ?? "",
|
||||
SWITCH_TO_RECTIFIED_LABEL,
|
||||
switchToRectifiedConfirmCopy("05:12", "04:53"),
|
||||
];
|
||||
return values;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
rangeDeliveryWindowLine,
|
||||
sharedTraitLine,
|
||||
} from "../user-copy.ts";
|
||||
import { reportedTimeOffsetCopy } from "../birth-time-provenance.ts";
|
||||
import { recordRangeConflict, reportedTimeOffsetCopy } from "../birth-time-provenance.ts";
|
||||
import { previousInferenceFromReceipt } from "./inference-adapter.ts";
|
||||
import {
|
||||
rangeNarrowHint,
|
||||
@@ -70,6 +70,11 @@ export type RangeDeliveryColumn = Readonly<{
|
||||
window_line: string;
|
||||
}>;
|
||||
|
||||
export type RangeDeliveryRecordConflict = Readonly<{
|
||||
reported_time: string;
|
||||
gap_minutes: number;
|
||||
}>;
|
||||
|
||||
export type RangeDeliveryProjection = Readonly<{
|
||||
range: readonly [string, string] | null;
|
||||
representative_time: string | null;
|
||||
@@ -86,6 +91,8 @@ export type RangeDeliveryProjection = Readonly<{
|
||||
tie_break_available: boolean;
|
||||
tie_break_note: string | null;
|
||||
provenance_line: string | null;
|
||||
/** Set only when a hospital record sits outside the range (BUG-691). */
|
||||
record_conflict: RangeDeliveryRecordConflict | null;
|
||||
}>;
|
||||
|
||||
export type PublicCandidateClock = Readonly<{
|
||||
@@ -438,9 +445,29 @@ export function buildRangeDelivery(input: {
|
||||
range: normalizedRange,
|
||||
representativeTime,
|
||||
}),
|
||||
record_conflict: recordConflictProjection(recordRangeConflict({
|
||||
source: input.birthTimeSource,
|
||||
reportedTime: input.reportedBirthTime,
|
||||
range: normalizedRange,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function recordConflictProjection(
|
||||
conflict: Readonly<{ reportedTime: string; gapMinutes: number }> | null,
|
||||
): RangeDeliveryRecordConflict | null {
|
||||
return conflict ? { reported_time: conflict.reportedTime, gap_minutes: conflict.gapMinutes } : null;
|
||||
}
|
||||
|
||||
function recordConflictFromUnknown(value: unknown): RangeDeliveryRecordConflict | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const row = value as Record<string, unknown>;
|
||||
const reported = clock(row.reported_time ?? row.reportedTime);
|
||||
const gap = row.gap_minutes ?? row.gapMinutes;
|
||||
if (!reported || typeof gap !== "number" || !Number.isFinite(gap)) return null;
|
||||
return { reported_time: reported, gap_minutes: gap };
|
||||
}
|
||||
|
||||
function publicCandidatesFromUnknown(value: unknown): PublicCandidateClock[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const out: PublicCandidateClock[] = [];
|
||||
@@ -688,5 +715,6 @@ export function parseRangeDelivery(value: unknown): RangeDeliveryProjection | nu
|
||||
: typeof row.provenanceLine === "string" && row.provenanceLine.trim()
|
||||
? row.provenanceLine.trim()
|
||||
: null,
|
||||
record_conflict: recordConflictFromUnknown(row.record_conflict ?? row.recordConflict),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user