fix(rectification): label declared birth time as record or estimate (BUG-690)
Independent Staging Quality Gate / validate (push) Failing after 8m55s
Independent Staging Quality Gate / publish (push) Skipped

Hospital records, family estimates and period-only windows now keep distinct copy on the board and range card. Scoring still uses the reported clock as the search-window centre. Hospital minutes outside the current range are stated with the offset and no preference.
This commit is contained in:
jesse-ux
2026-09-14 23:20:15 +08:00
parent a266b6b727
commit c5fbee56a6
25 changed files with 531 additions and 35 deletions
@@ -0,0 +1,113 @@
/**
* User-facing labels for the declared birth-time source.
* Scoring still uses reportedBirthTime as the search-window centre.
*/
export const PROVENANCE_SOURCES = ["hospital_record", "approximate", "period_only"] as const;
export type ProvenanceSource = (typeof PROVENANCE_SOURCES)[number];
const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
export function normalizeBirthTimeSource(value: string | null | undefined): ProvenanceSource {
if (value === "hospital_record") return "hospital_record";
if (value === "period_only") return "period_only";
return "approximate";
}
export const REPORTED_TIME_LABEL: Readonly<Record<ProvenanceSource, string>> = {
hospital_record: "你的出生记录时间",
approximate: "你填的大概时间",
period_only: "你给的时间段",
};
/** Approximate / period_only copy must not match this. Hospital uses 出生记录时间. */
export const FORBIDDEN_DECLARED_BIRTH_TIME_PHRASE = /你的出生时间/;
export function reportedTimeLabel(source: string | null | undefined): string {
return REPORTED_TIME_LABEL[normalizeBirthTimeSource(source)];
}
export function boardDeclaredTimeLine(time: string, source?: string | null): string {
const kind = normalizeBirthTimeSource(source);
if (kind === "hospital_record") return `出生记录时间 ${time}`;
if (kind === "period_only") return `你给的时间段 ${time}`;
return `你填的大概时间 ${time}`;
}
export function boardDeclaredTimePeek(time: string, source?: string | null): string {
const kind = normalizeBirthTimeSource(source);
if (kind === "hospital_record") return `出生记录 ${time}`;
if (kind === "period_only") return `时间段 ${time}`;
return `大概 ${time}`;
}
function clockMinutes(value: string): number | null {
const clock = value.trim().slice(0, 5);
if (!CLOCK.test(clock)) return null;
const hours = Number(clock.slice(0, 2));
const minutes = Number(clock.slice(3, 5));
return hours * 60 + minutes;
}
export function minutesBetweenClocks(left: string, right: string): number | null {
const start = clockMinutes(left);
const end = clockMinutes(right);
if (start == null || end == null) return null;
return Math.abs(start - end);
}
function clockInRange(clock: string, range: readonly [string, string]): boolean {
const time = clockMinutes(clock);
const start = clockMinutes(range[0]);
const end = clockMinutes(range[1]);
if (time == null || start == null || end == null) return false;
return time >= Math.min(start, end) && time <= Math.max(start, end);
}
function offsetMinutes(reported: string, range: readonly [string, string] | null, representative: string | null): number | null {
if (representative) {
const direct = minutesBetweenClocks(reported, representative);
if (direct != null) return direct;
}
if (!range) return null;
if (clockInRange(reported, range)) return 0;
const toStart = minutesBetweenClocks(reported, range[0]);
const toEnd = minutesBetweenClocks(reported, range[1]);
if (toStart == null) return toEnd;
if (toEnd == null) return toStart;
return Math.min(toStart, toEnd);
}
/**
* How the declared clock sits next to the current range. Null when there is
* no comparable minute (typical for period_only).
*/
export function reportedTimeOffsetCopy(input: {
source?: string | null;
reportedTime?: string | null;
range?: readonly [string, string] | null;
representativeTime?: string | null;
}): string | null {
const reported = (input.reportedTime ?? "").trim().slice(0, 5);
if (!CLOCK.test(reported)) return null;
const source = normalizeBirthTimeSource(input.source);
const offset = offsetMinutes(reported, input.range ?? null, input.representativeTime ?? null);
if (offset == null) return null;
if (source === "hospital_record") {
const inside = input.range ? clockInRange(reported, input.range) : offset === 0;
if (inside) return `出生记录时间 ${reported},落在目前范围内`;
const edge = input.range
? Math.min(
minutesBetweenClocks(reported, input.range[0]) ?? Number.POSITIVE_INFINITY,
minutesBetweenClocks(reported, input.range[1]) ?? Number.POSITIVE_INFINITY,
)
: offset;
const gap = Number.isFinite(edge) ? edge : offset;
return `出生记录时间 ${reported}。目前范围不含这一分钟,相差 ${gap} 分钟`;
}
if (source === "period_only") {
return `与你给的时间段参照 ${reported} 相差 ${offset} 分钟`;
}
return `与你填的大概时间 ${reported} 相差 ${offset} 分钟`;
}
@@ -25,6 +25,7 @@ import {
REPRESENTATIVE_MINUTE_DISCLAIMER,
nonConvergingRangeNarration,
} from "../user-copy.ts";
import { normalizeBirthTimeSource, type ProvenanceSource } from "../birth-time-provenance.ts";
export { REPRESENTATIVE_MINUTE_DISCLAIMER, nonConvergingRangeNarration };
@@ -199,6 +200,8 @@ export type RectificationDecision = Readonly<{
terminationCopy?: string | null;
/** Close lead with unused D9/D10 style questions: ask those before delivering. */
heldForTieBreak?: boolean;
/** Declared-time source for copy. Missing means approximate. */
birthTimeSource?: "hospital_record" | "approximate" | "period_only";
}>;
export type DecideRectificationInput = Readonly<{
@@ -930,6 +933,7 @@ export function publicNextAction(decision: RectificationDecision): Readonly<{
representative_time: string | null;
credible_range: readonly [string, string] | null;
stop_reason: EvidenceStopReason | null;
birth_time_source?: ProvenanceSource;
}> {
return {
type: decision.nextAction,
@@ -945,6 +949,7 @@ export function publicNextAction(decision: RectificationDecision): Readonly<{
representative_time: decision.representativeTime,
credible_range: decision.credibleRange,
stop_reason: decision.stopReason ?? null,
birth_time_source: normalizeBirthTimeSource(decision.birthTimeSource),
};
}
@@ -8,6 +8,11 @@
import { PROBE_EXPLAIN_COPY } from "./v9/probe-explain.ts";
import { STEP_STATE_COPY } from "./v9/step-state.ts";
import {
REPORTED_TIME_LABEL,
boardDeclaredTimeLine,
reportedTimeOffsetCopy,
} from "./birth-time-provenance.ts";
export const REPRESENTATIVE_MINUTE_DISCLAIMER = "这只是代表性候选,不是已确认的唯一出生分钟。";
@@ -606,6 +611,23 @@ export function listUserVisibleCopy(): string[] {
...Object.values(USER_COLLECT_QUESTION_RETRY),
"还有吗?比如第一份工作、搬到别的城市、谈恋爱或分手、家里添丁或长辈住院。",
"再说一件带年月的事",
REPORTED_TIME_LABEL.hospital_record,
REPORTED_TIME_LABEL.approximate,
REPORTED_TIME_LABEL.period_only,
boardDeclaredTimeLine("05:00", "hospital_record"),
boardDeclaredTimeLine("05:00", "approximate"),
reportedTimeOffsetCopy({
source: "approximate",
reportedTime: "05:00",
range: ["04:48", "05:07"],
representativeTime: "04:53",
}) ?? "",
reportedTimeOffsetCopy({
source: "hospital_record",
reportedTime: "05:12",
range: ["04:48", "05:07"],
representativeTime: "04:53",
}) ?? "",
];
return values;
}
@@ -18,6 +18,7 @@ import { rangeDeliveryForSnapshot } from "@/lib/rectification-agentic/v9/diverge
import { rectificationFollowupCatalog } from "@/lib/rectification-agentic/v9/decision-from-dossier";
import { tieBreakGateInput, tieBreakPersonalityAvailable } from "@/lib/rectification-agentic/v9/method-followup";
import { latestResultToolProjection } from "@/mastra/rectification-v9-tools";
import { normalizeBirthTimeSource } from "@/lib/rectification-agentic/birth-time-provenance";
export function dossierResponse(
dossier: V9CaseDossier,
@@ -31,6 +32,7 @@ export function dossierResponse(
const listed = options.listed ?? { focuses: [] as const, available: true };
const turnsWithQuestions = attachQuestionsToTurns(dossier.turns, listed.focuses);
const fields = publicDecisionFields(decision);
const birthTimeSource = normalizeBirthTimeSource(dossier.case.birthTimeSource);
const overlaid = dossier.latestResult
? overlayPublicDecision(dossier.latestResult, decision)
: fields;
@@ -55,6 +57,7 @@ export function dossierResponse(
completed_at: dossier.case.completedAt,
closed_reason: dossier.case.closedReason,
last_activity_at: dossier.case.lastActivityAt,
birth_time_source: birthTimeSource,
case_revision: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null)?.revision ?? 0,
},
turns: turns.map((turn) => ({
@@ -75,6 +78,7 @@ export function dossierResponse(
: null,
interview: {
...fields,
birth_time_source: birthTimeSource,
collection_progress: collectionProgressFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
},
current_question: projectCurrentQuestion(dossier.conversationSummary.activeFocus),
@@ -121,6 +125,8 @@ function publicLatestResult(
key.includes("varga.d9") || key.includes("varga.d10")
)),
stillTied: decision.separation.lead <= 1 && decision.separation.ranked.length >= 2,
reportedBirthTime: dossier.case.reportedBirthTime,
birthTimeSource: dossier.case.birthTimeSource,
});
const withDelivery = {
...projected,
@@ -64,6 +64,7 @@ import {
import { evidenceLedgerFingerprint } from "./tool-service";
import { followupCaseArgs, blockScanDeclinedForFingerprint } from "./block-scan.ts";
import { openingRangeFromCandidateRange } from "../user-copy.ts";
import { normalizeBirthTimeSource } from "../birth-time-provenance.ts";
import {
representativeNearWindowEdge,
} from "./search-window.ts";
@@ -926,6 +927,7 @@ export function decideFromDossier(
...narrowingExhaustion(dossier, inference, options, catalog),
}),
droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped),
birthTimeSource: normalizeBirthTimeSource(dossier.case.birthTimeSource),
};
}
@@ -971,30 +973,33 @@ export function decideAfterInferenceChange(input: {
if (!input.state) {
const evidenceStops = evidenceStopInputs(input.dossier.evidence);
const caseStage = input.dossier.case.stage === "block_scan" ? "block_scan" : "minute";
return decideRectification({
methodCoverageAll: blockingMethodsCovered(collecting.methods),
trainingGateOpen: caseStage === "block_scan"
? evidenceStops.datedEventCount >= MIN_STANDALONE_DATED_EVENTS
&& evidenceStops.datedDomainCount >= MIN_STANDALONE_DATED_DOMAINS
: trainingScoreableGate(input.dossier.evidence).open,
candidateScores: [],
userStopped: input.userStopped,
snapshotCurrent,
engineCeiling: engineCapabilityCeilingFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null),
...decisionBudgetFromInference(null),
...evidenceStops,
caseStage: input.dossier.case.stage === "block_scan" ? "block_scan" : "minute",
blockScanDeclined: blockScanDeclinedForFingerprint(
input.dossier.case.blockScan,
evidenceLedgerFingerprint(input.dossier.evidence as never),
),
windowWidenSuggested: windowWidenSuggestedFromDossier(
input.dossier,
evidenceLedgerFingerprint(input.dossier.evidence as never),
),
openingCandidateRange: openingRangeFromCandidateRange(input.dossier.case.candidateRange),
...tieBreakHold,
});
return {
...decideRectification({
methodCoverageAll: blockingMethodsCovered(collecting.methods),
trainingGateOpen: caseStage === "block_scan"
? evidenceStops.datedEventCount >= MIN_STANDALONE_DATED_EVENTS
&& evidenceStops.datedDomainCount >= MIN_STANDALONE_DATED_DOMAINS
: trainingScoreableGate(input.dossier.evidence).open,
candidateScores: [],
userStopped: input.userStopped,
snapshotCurrent,
engineCeiling: engineCapabilityCeilingFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null),
...decisionBudgetFromInference(null),
...evidenceStops,
caseStage: input.dossier.case.stage === "block_scan" ? "block_scan" : "minute",
blockScanDeclined: blockScanDeclinedForFingerprint(
input.dossier.case.blockScan,
evidenceLedgerFingerprint(input.dossier.evidence as never),
),
windowWidenSuggested: windowWidenSuggestedFromDossier(
input.dossier,
evidenceLedgerFingerprint(input.dossier.evidence as never),
),
openingCandidateRange: openingRangeFromCandidateRange(input.dossier.case.candidateRange),
...tieBreakHold,
}),
birthTimeSource: normalizeBirthTimeSource(input.dossier.case.birthTimeSource),
};
}
const training = input.state.events.filter((item) => item.usage === "training");
const trainingDomains = new Set(training.map((item) => item.domain));
@@ -1075,6 +1080,7 @@ export function decideAfterInferenceChange(input: {
...narrowingExhaustion(input.dossier, input.state, undefined, catalog),
}),
droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped),
birthTimeSource: normalizeBirthTimeSource(input.dossier.case.birthTimeSource),
};
}
@@ -18,6 +18,7 @@ import {
rangeDeliveryWindowLine,
sharedTraitLine,
} from "../user-copy.ts";
import { reportedTimeOffsetCopy } from "../birth-time-provenance.ts";
import { previousInferenceFromReceipt } from "./inference-adapter.ts";
import {
rangeNarrowHint,
@@ -84,6 +85,7 @@ export type RangeDeliveryProjection = Readonly<{
narrow_hint: string | null;
tie_break_available: boolean;
tie_break_note: string | null;
provenance_line: string | null;
}>;
export type PublicCandidateClock = Readonly<{
@@ -338,6 +340,8 @@ export function buildRangeDelivery(input: {
tieBreakAvailable?: boolean;
tieBreakUsed?: boolean;
stillTied?: boolean;
reportedBirthTime?: string | null;
birthTimeSource?: string | null;
}): RangeDeliveryProjection {
const inference = input.inference;
const range = input.credibleRange
@@ -428,6 +432,12 @@ export function buildRangeDelivery(input: {
&& input.stillTied === true
? RECTIFICATION_USER_COPY.rangeDeliveryTieBreakUsed
: null,
provenance_line: reportedTimeOffsetCopy({
source: input.birthTimeSource,
reportedTime: input.reportedBirthTime,
range: normalizedRange,
representativeTime,
}),
};
}
@@ -488,6 +498,10 @@ export function rangeDeliveryForSnapshot(snapshot: {
tieBreakAvailable?: boolean;
tieBreakUsed?: boolean;
stillTied?: boolean;
reportedBirthTime?: string | null;
reported_birth_time?: string | null;
birthTimeSource?: string | null;
birth_time_source?: string | null;
} | null | undefined): RangeDeliveryProjection {
const receipt = snapshot?.decisionReceipt ?? snapshot?.decision_receipt ?? null;
const inference = previousInferenceFromReceipt(receipt);
@@ -520,6 +534,8 @@ export function rangeDeliveryForSnapshot(snapshot: {
tieBreakAvailable: snapshot?.accepted !== true && snapshot?.tieBreakAvailable === true,
tieBreakUsed: snapshot?.tieBreakUsed === true,
stillTied: snapshot?.stillTied === true,
reportedBirthTime: snapshot?.reportedBirthTime ?? snapshot?.reported_birth_time,
birthTimeSource: snapshot?.birthTimeSource ?? snapshot?.birth_time_source,
});
}
@@ -667,5 +683,10 @@ export function parseRangeDelivery(value: unknown): RangeDeliveryProjection | nu
: typeof row.tieBreakNote === "string" && row.tieBreakNote.trim()
? row.tieBreakNote.trim()
: null,
provenance_line: typeof row.provenance_line === "string" && row.provenance_line.trim()
? row.provenance_line.trim()
: typeof row.provenanceLine === "string" && row.provenanceLine.trim()
? row.provenanceLine.trim()
: null,
};
}
@@ -10,6 +10,7 @@ import {
type RectificationCandidateResult,
type RectificationHouseTable,
} from "./rectification-candidate-result";
import { boardDeclaredTimePeek } from "./rectification-agentic/birth-time-provenance";
export const RECTIFICATION_BOARD_SPLIT_MIN_PX = 768;
@@ -163,6 +164,7 @@ export function diffRectificationBoard(
export function rectificationBoardPeekCopy(
result: RectificationCandidateResult | null,
declaredTime: string | null = null,
source?: string | null,
): Readonly<{
title: string;
detail: string | null;
@@ -171,7 +173,10 @@ export function rectificationBoardPeekCopy(
if (!table) {
// Before any candidate exists the board still has a time to show: the one
// the reader declared. Without one, say what fills it later.
return { title: "当前盘面", detail: declaredTime ? `填报 ${declaredTime}` : "补充经历后会在这里更新" };
return {
title: "当前盘面",
detail: declaredTime ? boardDeclaredTimePeek(declaredTime, source) : "补充经历后会在这里更新",
};
}
return { title: "当前盘面", detail: `${table.time} · 上升${table.lagna}` };
}
@@ -11,6 +11,11 @@
import type { PersistedRectificationTurn } from "../components/conversational-birth-time-rectification.tsx";
import { BOOTSTRAP_PREPARE_TIMEOUT_MS } from "./home-bootstrap.ts";
import { sessionOutcomeAllowsDelivery } from "./rectification-agentic/core/rectification-decision.ts";
import {
boardDeclaredTimeLine,
normalizeBirthTimeSource,
type ProvenanceSource,
} from "./rectification-agentic/birth-time-provenance.ts";
/**
* Upper bound on hydrating a Case (turns + snapshot) after `/cases/open`
@@ -82,6 +87,7 @@ export type RectificationCaseSnapshotPayload = Readonly<{
*/
candidate_range?: unknown;
stage?: unknown;
birth_time_source?: unknown;
}>;
turns?: unknown;
}>;
@@ -469,7 +475,10 @@ export function rectificationAdoptingLabel(time: string): string {
}
/** Board copy when no candidate result exists yet (task 1.3). */
export function rectificationBoardEmptyCopy(declaredTime: string | null): Readonly<{
export function rectificationBoardEmptyCopy(
declaredTime: string | null,
source?: string | null,
): Readonly<{
clock: string | null;
lines: readonly string[];
}> {
@@ -478,10 +487,15 @@ export function rectificationBoardEmptyCopy(declaredTime: string | null): Readon
}
return {
clock: declaredTime,
lines: [`填报出生时间 ${declaredTime}`, "回答几个问题后,这里会显示宫位随时间的变化。"],
lines: [boardDeclaredTimeLine(declaredTime, source), "回答几个问题后,这里会显示宫位随时间的变化。"],
};
}
export function birthTimeSourceFromSnapshot(payload: RectificationCaseSnapshotPayload | null | undefined): ProvenanceSource {
const raw = payload?.case?.birth_time_source;
return normalizeBirthTimeSource(typeof raw === "string" ? raw : null);
}
/** The declared birth minute from the profile, or null when only a period was given. */
export function declaredBirthTime(profile: Readonly<{ time?: string; reportedTime?: string }>): string | null {
const candidate = (profile.reportedTime || profile.time || "").trim();