Files
Jyotisha/frontend/src/lib/birth-time-journey.ts
T
Jesse_Chen 9958e00abc fix(web): keep unrectified birth data usable without inventing a minute
Rectification stays optional. Reported minutes can consult and generate reports; date-plus-period uses a declared window instead of a midpoint or 00:00. Updates BUG-341.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 15:29:58 +08:00

239 lines
7.6 KiB
TypeScript

import { z } from "zod";
import { DECLARED_PERIOD_RANGES } from "./declared-birth-window.ts";
export {
candidateResultSchema,
lifeEventSchema,
withCandidateResult,
withConfirmedCandidate,
} from "./birth-time-evidence.ts";
const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
const locationSchema = z.object({
lat: z.number().min(-90).max(90),
lon: z.number().min(-180).max(180),
tz: z.number().min(-12).max(14),
}).readonly();
const exactFields = {
date: dateSchema,
reportedTime: timeSchema,
location: locationSchema,
} as const;
export const birthTimeAssessmentSchema = z.union([
z.object({
...exactFields,
source: z.literal("hospital_record"),
uncertaintyBeforeMinutes: z.literal(2),
uncertaintyAfterMinutes: z.literal(2),
}).readonly(),
z.object({
...exactFields,
source: z.literal("family_exact"),
uncertaintyBeforeMinutes: z.union([z.literal(5), z.literal(10), z.literal(15)]),
uncertaintyAfterMinutes: z.union([z.literal(5), z.literal(10), z.literal(15)]),
}).readonly().refine(
(value) => value.uncertaintyBeforeMinutes === value.uncertaintyAfterMinutes,
{ message: "family uncertainty must be symmetric" },
),
z.object({
...exactFields,
source: z.literal("approximate"),
uncertaintyBeforeMinutes: z.union([z.literal(15), z.literal(30), z.literal(60)]),
uncertaintyAfterMinutes: z.union([z.literal(15), z.literal(30), z.literal(60)]),
}).readonly().refine(
(value) => value.uncertaintyBeforeMinutes === value.uncertaintyAfterMinutes,
{ message: "approximate uncertainty must be symmetric" },
),
z.object({
date: dateSchema,
source: z.literal("period_only"),
period: z.enum(["early_morning", "morning", "afternoon", "evening", "late_night"]),
location: locationSchema,
}).readonly(),
z.object({
date: dateSchema,
source: z.literal("unknown"),
clue: z.string().trim().max(240).default(""),
location: locationSchema,
}).readonly(),
]);
export type BirthTimeAssessment = z.infer<typeof birthTimeAssessmentSchema>;
export type ScanStability =
| { readonly kind: "stable" }
| { readonly kind: "sensitive" }
| { readonly kind: "unavailable" }
| { readonly kind: "not_required" };
export const journeySnapshotSchema = z.object({
state: z.enum(["rectifying", "candidate", "confirming", "ready"]),
assistantIntent: z.enum([
"confirm_stable_record",
"explain_sensitive_boundary",
"explain_assessment_unavailable",
"start_light_rectification",
"start_standard_rectification",
"start_period_rectification",
"collect_time_clues",
"continue_rectification_questions",
"present_saved_candidate_range",
"collect_dated_life_events",
"explain_event_evidence_insufficient",
"present_candidate_result",
"confirm_candidate_time",
"confirmed_candidate_time",
]),
input: z.enum([
"none",
"rectification_questions",
"time_clue",
"life_events",
"candidate_actions",
"candidate_confirmation",
]),
route: z.enum(["direct_chart", "rectification"]),
confidence: z.enum(["low", "medium", "high"]).nullable(),
canApply: z.boolean(),
activeTime: z.string().nullable(),
reportedRange: z.object({
label: z.string(),
startTime: z.string().nullable(),
endTime: z.string().nullable(),
}).readonly(),
}).readonly();
export type JourneySnapshot = z.infer<typeof journeySnapshotSchema>;
export type RectificationScoring = {
readonly answeredCount: number;
readonly candidateClusterRankings: readonly {
readonly cluster: string;
readonly score: number;
}[];
readonly nextRound?: number | null;
readonly nextRoundQuestions?: readonly unknown[];
};
class UnexpectedJourneyVariantError extends Error {
readonly name = "UnexpectedJourneyVariantError";
constructor(value: never) {
super(`Unexpected birth-time journey variant: ${JSON.stringify(value)}`);
}
}
function shiftedTime(time: string, offsetMinutes: number): string {
const [hourText, minuteText] = time.split(":");
const minutes = Number(hourText) * 60 + Number(minuteText) + offsetMinutes;
const normalized = (minutes + 24 * 60) % (24 * 60);
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
}
function exactRange(time: string, before: number, after: number): JourneySnapshot["reportedRange"] {
const startTime = shiftedTime(time, -before);
const endTime = shiftedTime(time, after);
return { label: `${startTime}${endTime}`, startTime, endTime };
}
function rectificationSnapshot(
assistantIntent: JourneySnapshot["assistantIntent"],
reportedRange: JourneySnapshot["reportedRange"],
input: JourneySnapshot["input"] = "rectification_questions",
): JourneySnapshot {
return {
state: "rectifying",
assistantIntent,
input,
route: "rectification",
confidence: null,
canApply: false,
activeTime: null,
reportedRange,
};
}
export function assessBirthTime(
assessment: BirthTimeAssessment,
scanStability: ScanStability,
): JourneySnapshot {
switch (assessment.source) {
case "hospital_record": {
const reportedRange = exactRange(assessment.reportedTime, 2, 2);
switch (scanStability.kind) {
case "stable":
return {
state: "ready",
assistantIntent: "confirm_stable_record",
input: "none",
route: "direct_chart",
confidence: "high",
canApply: true,
activeTime: assessment.reportedTime,
reportedRange,
};
case "sensitive":
return rectificationSnapshot("explain_sensitive_boundary", reportedRange);
case "unavailable":
case "not_required":
return rectificationSnapshot("explain_assessment_unavailable", reportedRange);
default:
throw new UnexpectedJourneyVariantError(scanStability);
}
}
case "family_exact":
return rectificationSnapshot(
"start_light_rectification",
exactRange(assessment.reportedTime, assessment.uncertaintyBeforeMinutes, assessment.uncertaintyAfterMinutes),
);
case "approximate":
return rectificationSnapshot(
"start_standard_rectification",
exactRange(assessment.reportedTime, assessment.uncertaintyBeforeMinutes, assessment.uncertaintyAfterMinutes),
);
case "period_only":
return rectificationSnapshot("start_period_rectification", DECLARED_PERIOD_RANGES[assessment.period]);
case "unknown":
return rectificationSnapshot(
"collect_time_clues",
{ label: "全天待确认", startTime: null, endTime: null },
"time_clue",
);
default:
throw new UnexpectedJourneyVariantError(assessment);
}
}
export function withRectificationScoring(
snapshot: JourneySnapshot,
scoring: RectificationScoring,
): JourneySnapshot {
if (snapshot.route === "direct_chart") return snapshot;
const questionnaireComplete = scoring.nextRound === null
&& scoring.nextRoundQuestions?.length === 0
&& scoring.answeredCount > 0;
if (questionnaireComplete) {
return {
...snapshot,
state: "rectifying",
assistantIntent: "collect_dated_life_events",
input: "life_events",
confidence: null,
canApply: false,
activeTime: null,
};
}
const hasCandidate = scoring.answeredCount >= 3 && scoring.candidateClusterRankings.length > 0;
return {
...snapshot,
state: hasCandidate ? "candidate" : "rectifying",
assistantIntent: hasCandidate ? "present_saved_candidate_range" : "continue_rectification_questions",
canApply: false,
activeTime: null,
};
}