fix: harden birth-time consultation modes
This commit is contained in:
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user