fix: harden birth-time consultation modes

This commit is contained in:
Jesse_Chen
2026-07-21 07:25:02 +08:00
parent fc1e9f1085
commit 8be39d5ad6
21 changed files with 1516 additions and 206 deletions
+195
View File
@@ -0,0 +1,195 @@
import { z } from "zod";
import { isBirthClockTime, parseBirthDate } from "./birth-time-intake-model.ts";
const nullableTrimmedString = (maximum: number) => z.string().trim().min(1).max(maximum).nullable();
const nullableBirthDate = z.string().refine((value) => parseBirthDate(value) !== undefined, {
message: "出生日期必须是真实的 1900—2100 年 ISO 日期",
}).nullable();
const nullableBirthClock = z.string().refine(isBirthClockTime, {
message: "出生时间必须是 HH:mm",
}).nullable();
const birthTimeSourceSchema = z.enum([
"hospital_record",
"family_exact",
"approximate",
"period_only",
"unknown",
"legacy_import",
]);
const birthTimePeriodSchema = z.enum([
"early_morning",
"morning",
"afternoon",
"evening",
"late_night",
]);
export const accountProfilePatchSchema = z.object({
name: nullableTrimmedString(80).optional(),
birth_date: nullableBirthDate.optional(),
// Accepted only for backward-compatible parsing. The account route never
// writes this client field into active/confirmed birth-time truth.
birth_time: nullableBirthClock.optional(),
reported_birth_time: nullableBirthClock.optional(),
birth_time_source: birthTimeSourceSchema.nullable().optional(),
birth_time_period: birthTimePeriodSchema.nullable().optional(),
birth_time_clue: nullableTrimmedString(240).optional(),
uncertainty_before_minutes: z.number().int().min(0).max(720).nullable().optional(),
uncertainty_after_minutes: z.number().int().min(0).max(720).nullable().optional(),
country_code: nullableTrimmedString(8).optional(),
province_code: nullableTrimmedString(24).optional(),
city_code: nullableTrimmedString(24).optional(),
district_code: nullableTrimmedString(24).optional(),
latitude: z.number().finite().min(-90).max(90).nullable().optional(),
longitude: z.number().finite().min(-180).max(180).nullable().optional(),
timezone_offset: z.number().finite().min(-12).max(14).nullable().optional(),
}).strict().superRefine((value, context) => {
const source = value.birth_time_source;
const time = value.reported_birth_time;
const before = value.uncertainty_before_minutes;
const after = value.uncertainty_after_minutes;
const addIssue = (path: string, message: string) => context.addIssue({
code: z.ZodIssueCode.custom,
path: [path],
message,
});
const declarationKeys = [
"birth_date",
"reported_birth_time",
"birth_time_source",
"birth_time_period",
"birth_time_clue",
"uncertainty_before_minutes",
"uncertainty_after_minutes",
] as const;
const mutatesDeclaration = declarationKeys.some((key) => value[key] !== undefined);
const coordinates = [value.latitude, value.longitude, value.timezone_offset];
const concreteCoordinateCount = coordinates.filter((coordinate) => coordinate != null).length;
if (concreteCoordinateCount > 0 && concreteCoordinateCount < coordinates.length) {
addIssue("latitude", "出生地点坐标与时区必须完整提交");
}
if (source === undefined) {
if (mutatesDeclaration) addIssue("birth_time_source", "修改出生资料时必须同时说明时间来源");
return;
}
if (source === null) {
if (value.birth_date !== undefined && value.birth_date !== null) {
addIssue("birth_time_source", "填写出生日期后必须说明时间来源");
}
if (time || value.birth_time || value.birth_time_period || value.birth_time_clue
|| before != null || after != null) {
addIssue("birth_time_source", "未选择时间来源时不得提交时间或误差范围");
}
return;
}
if (!value.birth_date) addIssue("birth_date", "出生时间声明必须包含真实出生日期");
if (source !== "legacy_import" && value.birth_time) {
addIssue("birth_time", "只有既有资料迁移可以提交兼容时间字段");
}
const ensureNoPeriod = () => {
if (value.birth_time_period) addIssue("birth_time_period", "具体时间来源不得同时提交时段");
};
const ensureNoUncertainty = () => {
if (before != null || after != null) {
addIssue("uncertainty_before_minutes", "该时间来源不得提交误差范围");
}
};
if (source === "hospital_record") {
if (!time) addIssue("reported_birth_time", "医院记录需要具体时间");
if (before !== 2 || after !== 2) addIssue("uncertainty_before_minutes", "医院记录固定检查前后 2 分钟");
ensureNoPeriod();
} else if (source === "family_exact") {
if (!time) addIssue("reported_birth_time", "家人记忆需要具体时间");
if (![5, 10, 15].includes(before ?? -1) || before !== after) {
addIssue("uncertainty_before_minutes", "家人记忆误差必须为前后 5、10 或 15 分钟");
}
ensureNoPeriod();
} else if (source === "approximate") {
if (!time) addIssue("reported_birth_time", "大概时间需要具体 HH:mm");
if (![15, 30, 60].includes(before ?? -1) || before !== after) {
addIssue("uncertainty_before_minutes", "大概时间误差必须为前后 15、30 或 60 分钟");
}
ensureNoPeriod();
} else if (source === "legacy_import") {
if (!time && !value.birth_time) addIssue("reported_birth_time", "既有资料需要具体时间");
ensureNoPeriod();
ensureNoUncertainty();
} else if (source === "period_only") {
if (!value.birth_time_period) addIssue("birth_time_period", "只知道时段时必须选择时段");
if (time) addIssue("reported_birth_time", "只知道时段时不得同时提交具体分钟");
ensureNoUncertainty();
} else if (source === "unknown") {
if (time) addIssue("reported_birth_time", "时间未知时不得提交具体分钟");
if (value.birth_time_period) addIssue("birth_time_period", "时间未知时不得提交确定时段");
ensureNoUncertainty();
}
});
export type AccountProfilePatch = z.infer<typeof accountProfilePatchSchema>;
type AccountBirthTimeState = Readonly<{
birth_date: string | null;
reported_birth_time: string | null;
birth_time_source: string | null;
birth_time_period: string | null;
birth_time_clue: string | null;
uncertainty_before_minutes: number | null;
uncertainty_after_minutes: number | null;
active_birth_time: string | null;
birth_time: string | null;
birth_time_status: string | null;
rectification_case_id: string | null;
country_code?: string | null;
province_code?: string | null;
city_code?: string | null;
district_code?: string | null;
}>;
const declarationFields = [
"birth_date",
"reported_birth_time",
"birth_time_source",
"birth_time_period",
"birth_time_clue",
"uncertainty_before_minutes",
"uncertainty_after_minutes",
"country_code",
"province_code",
"city_code",
"district_code",
] as const;
export type AccountBirthTimeApplicationPatch = Readonly<{
active_birth_time?: null;
birth_time?: null;
birth_time_status?: "reported";
rectification_case_id?: null;
}>;
export function resolveAccountBirthTimeApplicationPatch(
current: AccountBirthTimeState,
patch: AccountProfilePatch,
): AccountBirthTimeApplicationPatch {
const declarationChanged = declarationFields.some((field) => (
patch[field] !== undefined && patch[field] !== current[field]
));
if (!declarationChanged) return {};
const confirmed = current.birth_time_status === "confirmed"
|| (current.birth_time_status === null && isBirthClockTime(current.birth_time ?? ""));
if (confirmed) return {};
if (!current.active_birth_time
&& !current.birth_time
&& current.birth_time_status !== "candidate") return {};
return {
active_birth_time: null,
birth_time: null,
birth_time_status: "reported",
rectification_case_id: null,
};
}
@@ -1,6 +1,14 @@
import type { BirthTimeDraft } from "./birth-time-intake-model.ts";
import { isBirthClockTime, type BirthTimeDraft } from "./birth-time-intake-model.ts";
import type { ConsultationBirthTimeMode } from "./consultation-birth-time-mode.ts";
export type BirthTimeConsultationConsentState = Readonly<Record<string, true>>;
export type BirthTimeConsultationConsentMode = Extract<
ConsultationBirthTimeMode,
"unverified_birth_time" | "general_no_birth_time"
>;
export type BirthTimeConsultationConsentState = Readonly<
Record<string, BirthTimeConsultationConsentMode>
>;
export type AccountRectificationCaseState = Readonly<{
caseId: string;
@@ -34,15 +42,27 @@ export function hasBirthTimeConsultationConsent(
state: BirthTimeConsultationConsentState,
sessionId: string,
): boolean {
return Boolean(sessionId && state[sessionId] === true);
return consultationModeForSession(state, sessionId) !== null;
}
export function consultationModeForSession(
state: BirthTimeConsultationConsentState,
sessionId: string,
): BirthTimeConsultationConsentMode | null {
if (!sessionId) return null;
const mode = state[sessionId];
return mode === "unverified_birth_time" || mode === "general_no_birth_time"
? mode
: null;
}
export function grantBirthTimeConsultationConsent(
state: BirthTimeConsultationConsentState,
sessionId: string,
mode: BirthTimeConsultationConsentMode = "unverified_birth_time",
): BirthTimeConsultationConsentState {
if (!sessionId || state[sessionId]) return state;
return Object.freeze({ ...state, [sessionId]: true });
if (!sessionId || state[sessionId] === mode) return state;
return Object.freeze({ ...state, [sessionId]: mode });
}
export function clearBirthTimeConsultationConsent(
@@ -52,14 +72,13 @@ export function clearBirthTimeConsultationConsent(
if (!sessionId || !state[sessionId]) return state;
return Object.freeze(Object.fromEntries(
Object.entries(state).filter(([candidate]) => candidate !== sessionId),
) as Record<string, true>);
) as Record<string, BirthTimeConsultationConsentMode>);
}
export function unverifiedBirthTime(profile: BirthTimeDraft): string | null {
if (profile.birthTimeStatus === "confirmed") return null;
if (!concreteReportedSources.has(profile.birthTimeSource)) return null;
const time = profile.time || profile.reportedTime;
return /^([01]\d|2[0-3]):[0-5]\d$/.test(time) ? time : null;
return isBirthClockTime(profile.reportedTime) ? profile.reportedTime : null;
}
export function canUseUnverifiedBirthTime(profile: BirthTimeDraft): boolean {
@@ -70,6 +89,33 @@ export function requiresBirthTimeConsent(profile: BirthTimeDraft): boolean {
return canUseUnverifiedBirthTime(profile);
}
export type BirthTimeConsultationRoute =
| Readonly<{ kind: "choice"; canUseUnverifiedTime: boolean }>
| Readonly<{
kind: "consult";
mode: ConsultationBirthTimeMode;
time: string | null;
}>;
export function resolveBirthTimeConsultationRoute(
profile: BirthTimeDraft,
state: BirthTimeConsultationConsentState,
sessionId: string,
): BirthTimeConsultationRoute {
if (profile.birthTimeStatus === "confirmed" && isBirthClockTime(profile.time)) {
return { kind: "consult", mode: "verified_chart", time: profile.time };
}
const reportedTime = unverifiedBirthTime(profile);
const consentMode = consultationModeForSession(state, sessionId);
if (reportedTime && consentMode === "unverified_birth_time") {
return { kind: "consult", mode: "unverified_birth_time", time: reportedTime };
}
if (!reportedTime && consentMode === "general_no_birth_time") {
return { kind: "consult", mode: "general_no_birth_time", time: null };
}
return { kind: "choice", canUseUnverifiedTime: reportedTime !== null };
}
export function resolveRectificationCardAction(input: Readonly<{
rectificationCase: AccountRectificationCaseState | null;
hasConfirmedBirthTime: boolean;
@@ -94,3 +140,21 @@ export function parseRectificationPriceCredits(raw: string | undefined): number
}
return price;
}
export type LatestAccountRequestGuard = Readonly<{
begin(): number;
isCurrent(identity: number): boolean;
}>;
export function createLatestAccountRequestGuard(): LatestAccountRequestGuard {
let version = 0;
return Object.freeze({
begin() {
version += 1;
return version;
},
isCurrent(identity: number) {
return identity === version;
},
});
}
+98 -11
View File
@@ -2,6 +2,9 @@ import { format, isValid, parse } from "date-fns";
import type { JourneySnapshot } from "./birth-time-journey.ts";
const birthDatePattern = "yyyy-MM-dd";
const birthClockPattern = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
const earliestBirthYear = 1900;
const latestBirthYear = 2100;
export type BirthTimeSource =
| ""
@@ -42,13 +45,27 @@ export type BirthTimeDraft = {
export type BirthTimeDraftPatch = Partial<BirthTimeDraft>;
export type DeclaredBirthPlace = Readonly<{
label: string;
lat: number;
lon: number;
tz: number;
}>;
export function parseBirthDate(value: string): Date | undefined {
if (value === "") return undefined;
const parsed = parse(value, birthDatePattern, new Date(2000, 0, 1));
if (!isValid(parsed) || format(parsed, birthDatePattern) !== value) return undefined;
if (!isValid(parsed)
|| format(parsed, birthDatePattern) !== value
|| parsed.getFullYear() < earliestBirthYear
|| parsed.getFullYear() > latestBirthYear) return undefined;
return parsed;
}
export function isBirthClockTime(value: string): boolean {
return birthClockPattern.test(value);
}
export function formatBirthDate(value: Date): string {
return format(value, birthDatePattern);
}
@@ -126,23 +143,32 @@ export function assistantIntentCopy(intent: JourneySnapshot["assistantIntent"])
}
export function isBirthTimeDraftReady(draft: BirthTimeDraft) {
if (!draft.date) return false;
if (!parseBirthDate(draft.date) || draft.birthTimeClue.length > 240) return false;
switch (draft.birthTimeSource) {
case "hospital_record":
return isBirthClockTime(draft.reportedTime)
&& draft.uncertaintyBeforeMinutes === 2
&& draft.uncertaintyAfterMinutes === 2;
case "legacy_import":
return Boolean(draft.reportedTime || draft.time);
return isBirthClockTime(draft.reportedTime || draft.time);
case "family_exact":
return Boolean(draft.reportedTime)
return isBirthClockTime(draft.reportedTime)
&& [5, 10, 15].includes(draft.uncertaintyBeforeMinutes ?? -1)
&& draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes;
case "approximate":
return Boolean(draft.reportedTime)
return isBirthClockTime(draft.reportedTime)
&& [15, 30, 60].includes(draft.uncertaintyBeforeMinutes ?? -1)
&& draft.uncertaintyBeforeMinutes === draft.uncertaintyAfterMinutes;
case "period_only":
return Boolean(draft.birthTimePeriod);
return birthTimePeriodOptions.some((option) => option.value === draft.birthTimePeriod)
&& !draft.reportedTime
&& draft.uncertaintyBeforeMinutes === null
&& draft.uncertaintyAfterMinutes === null;
case "unknown":
return true;
return !draft.reportedTime
&& !draft.birthTimePeriod
&& draft.uncertaintyBeforeMinutes === null
&& draft.uncertaintyAfterMinutes === null;
case "":
return false;
default: {
@@ -156,17 +182,78 @@ export function isBirthTimeDraftReady(draft: BirthTimeDraft) {
* Whether the user has finished declaring what they actually know about birth time.
* This is an onboarding condition, not a claim that an exact chart minute is ready.
*/
export function isDeclaredBirthProfileComplete(draft: BirthTimeDraft) {
return isBirthTimeDraftReady(draft);
export function isDeclaredBirthProfileComplete(
draft: BirthTimeDraft,
place?: DeclaredBirthPlace | null,
) {
if (!isBirthTimeDraftReady(draft)) return false;
if (place === undefined) return true;
return Boolean(place
&& place.label.trim()
&& Number.isFinite(place.lat)
&& place.lat >= -90
&& place.lat <= 90
&& Number.isFinite(place.lon)
&& place.lon >= -180
&& place.lon <= 180
&& Number.isFinite(place.tz)
&& place.tz >= -12
&& place.tz <= 14);
}
export function isBirthTimeReadyForConsultation(draft: BirthTimeDraft) {
return Boolean(draft.time)
return isBirthClockTime(draft.time)
&& (draft.birthTimeStatus === "candidate" || draft.birthTimeStatus === "confirmed");
}
const declaredBirthInputKeys = [
"date",
"reportedTime",
"birthTimeSource",
"birthTimePeriod",
"birthTimeClue",
"uncertaintyBeforeMinutes",
"uncertaintyAfterMinutes",
] as const satisfies readonly (keyof BirthTimeDraft)[];
export function declaredBirthInputChanged(
current: BirthTimeDraft,
next: BirthTimeDraft,
): boolean {
return declaredBirthInputKeys.some((key) => current[key] !== next[key]);
}
/**
* Applies an intake edit without allowing a stale, unconfirmed candidate minute
* to survive changes to the declaration it was calculated from.
* Confirmed active time belongs to the account and is changed only by explicit
* rectification confirmation, so ordinary profile edits leave it intact.
*/
export function applyBirthTimeDraftPatch<T extends BirthTimeDraft>(
current: T,
patch: BirthTimeDraftPatch,
): T {
const next = { ...current, ...patch };
const declarationChanged = declaredBirthInputKeys.some((key) => (
Object.hasOwn(patch, key) && next[key] !== current[key]
));
if (!declarationChanged || current.birthTimeStatus === "confirmed") return next;
if (current.birthTimeStatus !== "candidate" && !current.time) return next;
return {
...next,
time: "",
birthTimeStatus: "reported",
} as T;
}
export function birthTimePersistenceValues(draft: BirthTimeDraft) {
const reportedTime = draft.reportedTime || draft.time || null;
const reportedTime = draft.birthTimeSource === "legacy_import"
? draft.reportedTime || draft.time || null
: draft.birthTimeSource === "hospital_record"
|| draft.birthTimeSource === "family_exact"
|| draft.birthTimeSource === "approximate"
? draft.reportedTime || null
: null;
const uncertainty = draft.birthTimeSource === "hospital_record"
? 2
: draft.birthTimeSource === "family_exact" || draft.birthTimeSource === "approximate"
@@ -0,0 +1,88 @@
import { z } from "zod";
import { guardPreciseTimingOutput } from "./timing-output-guard.ts";
export const consultationBirthTimeModeSchema = z.enum([
"verified_chart",
"unverified_birth_time",
"general_no_birth_time",
]);
export type ConsultationBirthTimeMode = z.infer<typeof consultationBirthTimeModeSchema>;
export const UNVERIFIED_BIRTH_TIME_NOTICE = "使用未校正填报时间;分钟敏感结论的置信度已降低。";
export function shouldRunBirthChartWorkflow(mode: ConsultationBirthTimeMode): boolean {
return mode !== "general_no_birth_time";
}
type ServerBirthTimeProfile = Readonly<{
active_birth_time: string | null;
reported_birth_time: string | null;
birth_time_source: string | null;
birth_time_status: string | null;
}>;
const concreteReportedSources = new Set([
"hospital_record",
"family_exact",
"approximate",
]);
export function serverProfileAllowsBirthTimeMode(
profile: ServerBirthTimeProfile,
mode: ConsultationBirthTimeMode,
requestedTime: string | null,
): boolean {
if (mode === "general_no_birth_time") return requestedTime === null;
if (!requestedTime) return false;
if (mode === "verified_chart") {
return profile.birth_time_status === "confirmed"
&& profile.active_birth_time?.slice(0, 5) === requestedTime;
}
return profile.birth_time_status !== "confirmed"
&& concreteReportedSources.has(profile.birth_time_source ?? "")
&& profile.reported_birth_time?.slice(0, 5) === requestedTime;
}
export function applyBirthTimeModeToWorkflowContext<
T extends {
consumer_context: {
answer_policy: Record<string, unknown>;
[key: string]: unknown;
};
[key: string]: unknown;
},
>(context: T, mode: ConsultationBirthTimeMode): T {
if (mode !== "unverified_birth_time") return context;
return {
...context,
consumer_context: {
...context.consumer_context,
user_facing_limitation: UNVERIFIED_BIRTH_TIME_NOTICE,
answer_policy: {
...context.consumer_context.answer_policy,
can_answer_precise_timing: false,
birth_time_confidence: "unverified_reported_time",
candidate_is_confirmed: false,
},
},
};
}
/**
* Server-side output boundary. The visible notice is added once to each HTTP
* answer stream, while timing/guarantee filtering remains active for the full
* answer whenever the consultation does not have a confirmed birth minute.
*/
export function createBirthTimeModeOutputGuard(
mode: ConsultationBirthTimeMode,
canAnswerPreciseTiming: boolean,
): (text: string) => string {
let noticeWritten = false;
return (text) => {
const guarded = canAnswerPreciseTiming ? text : guardPreciseTimingOutput(text);
if (mode !== "unverified_birth_time" || noticeWritten || !guarded.trim()) return guarded;
noticeWritten = true;
return `> ${UNVERIFIED_BIRTH_TIME_NOTICE}\n\n${guarded}`;
};
}