feat: add deterministic birth time journey

This commit is contained in:
Jesse_Chen
2026-07-17 15:48:09 +08:00
parent f0c863bb0a
commit 516c389183
6 changed files with 932 additions and 0 deletions
@@ -0,0 +1,143 @@
import { z } from "zod";
import {
birthTimeAssessmentSchema,
type BirthTimeAssessment,
} from "./birth-time-journey.ts";
import type {
RectificationAnswer,
RectificationQuestionnaire,
} from "./birth-time-journey-service.ts";
const profileSchema = z.object({
birth_date: z.string(),
reported_birth_time: z.string().nullable().optional(),
birth_time_source: z.enum([
"hospital_record",
"family_exact",
"approximate",
"period_only",
"unknown",
]),
birth_time_period: z.enum([
"early_morning",
"morning",
"afternoon",
"evening",
"late_night",
]).nullable().optional(),
birth_time_clue: z.string().nullable().optional(),
uncertainty_before_minutes: z.number().int().nullable().optional(),
uncertainty_after_minutes: z.number().int().nullable().optional(),
latitude: z.number(),
longitude: z.number(),
timezone_offset: z.number(),
});
const optionSchema = z.object({
key: z.enum(["A", "B", "C", "D"]),
label: z.string().trim().min(1),
});
const questionSchema = z.object({
id: z.string().trim().min(1),
prompt: z.string().trim().min(1),
options: z.array(optionSchema).optional(),
});
const signSchema = z.object({ sign: z.string().trim().min(1) }).nullable().optional();
const sampleSchema = z.object({
ascendant: signSchema,
varga_lagna: z.object({
D9: signSchema,
D10: signSchema,
}).optional(),
});
const questionnaireSchema = z.object({
questions: z.array(questionSchema),
candidate_scan: z.object({ samples: z.array(sampleSchema) }),
}).passthrough();
const scoringSchema = z.object({
answered_count: z.number().int().min(0),
candidate_cluster_rankings: z.array(z.object({
cluster: z.string().trim().min(1),
score: z.number(),
})),
}).passthrough();
class UnexpectedProfileSourceError extends Error {
readonly name = "UnexpectedProfileSourceError";
constructor(source: never) {
super(`Unexpected profile birth-time source: ${JSON.stringify(source)}`);
}
}
export function parseBirthTimeProfile(value: unknown): BirthTimeAssessment {
const profile = profileSchema.parse(value);
const location = {
lat: profile.latitude,
lon: profile.longitude,
tz: profile.timezone_offset,
};
switch (profile.birth_time_source) {
case "hospital_record":
case "family_exact":
case "approximate":
return birthTimeAssessmentSchema.parse({
date: profile.birth_date,
source: profile.birth_time_source,
reportedTime: profile.reported_birth_time?.slice(0, 5),
uncertaintyBeforeMinutes: profile.uncertainty_before_minutes,
uncertaintyAfterMinutes: profile.uncertainty_after_minutes,
location,
});
case "period_only":
return birthTimeAssessmentSchema.parse({
date: profile.birth_date,
source: profile.birth_time_source,
period: profile.birth_time_period,
location,
});
case "unknown":
return birthTimeAssessmentSchema.parse({
date: profile.birth_date,
source: profile.birth_time_source,
clue: profile.birth_time_clue ?? "",
location,
});
default:
throw new UnexpectedProfileSourceError(profile.birth_time_source);
}
}
export function parseRectificationQuestionnaire(value: unknown): RectificationQuestionnaire {
const parsed = questionnaireSchema.parse(value);
return {
questions: parsed.questions.map((question) => ({
id: question.id,
prompt: question.prompt,
...(question.options ? { options: question.options } : {}),
})),
samples: parsed.candidate_scan.samples.map((sample) => ({
ascendantSign: sample.ascendant?.sign ?? null,
d9Sign: sample.varga_lagna?.D9?.sign ?? null,
d10Sign: sample.varga_lagna?.D10?.sign ?? null,
})),
raw: parsed,
};
}
export function parseRectificationScoring(value: unknown) {
const parsed = scoringSchema.parse(value);
return {
answeredCount: parsed.answered_count,
candidateClusterRankings: parsed.candidate_cluster_rankings,
raw: parsed,
};
}
export function parseRectificationAnswer(value: unknown): RectificationAnswer {
return z.enum(["A", "B", "C", "D"]).parse(value);
}
@@ -0,0 +1,170 @@
import {
assessBirthTime,
withRectificationScoring,
type BirthTimeAssessment,
type JourneySnapshot,
type RectificationScoring,
type ScanStability,
} from "./birth-time-journey.ts";
export type RectificationAnswer = "A" | "B" | "C" | "D";
export type RectificationQuestionnaire = {
readonly questions: readonly {
readonly id: string;
readonly prompt: string;
readonly options?: readonly {
readonly key: RectificationAnswer;
readonly label: string;
}[];
}[];
readonly samples: readonly {
readonly ascendantSign: string | null;
readonly d9Sign: string | null;
readonly d10Sign: string | null;
}[];
readonly raw: Readonly<Record<string, unknown>>;
};
export type JourneyScanInput = {
readonly birthTime: string;
readonly uncertaintyMinutes: number;
readonly lat: number;
readonly lon: number;
readonly tz: number;
readonly ayanamsa: "lahiri";
};
export type JourneyScoreInput = {
readonly questionnaire: RectificationQuestionnaire;
readonly answers: Readonly<Record<string, RectificationAnswer>>;
};
export interface BirthTimeJourneyEngine {
scan(input: JourneyScanInput): Promise<{ readonly questionnaire: RectificationQuestionnaire }>;
score(input: JourneyScoreInput): Promise<RectificationScoring & { readonly raw: Readonly<Record<string, unknown>> }>;
}
export type PersistedJourneyAssessment = {
readonly userId: string;
readonly assessment: BirthTimeAssessment;
readonly snapshot: JourneySnapshot;
readonly questionnaire: RectificationQuestionnaire | null;
readonly candidateScan: RectificationQuestionnaire | null;
};
export type StoredRectificationCase = {
readonly id: string;
readonly userId: string;
readonly snapshot: JourneySnapshot;
readonly questionnaire: RectificationQuestionnaire;
readonly answers: Readonly<Record<string, RectificationAnswer>>;
readonly scoring?: RectificationScoring & { readonly raw: Readonly<Record<string, unknown>> };
};
export interface BirthTimeJourneyStore {
saveAssessment(value: PersistedJourneyAssessment): Promise<string>;
loadCase(userId: string, caseId: string): Promise<StoredRectificationCase | null>;
saveScoring(value: StoredRectificationCase): Promise<void>;
}
type BirthTimeJourneyPorts = {
readonly store: BirthTimeJourneyStore;
readonly engine: BirthTimeJourneyEngine;
};
type JourneyResponse = {
readonly caseId: string;
readonly snapshot: JourneySnapshot;
readonly questionnaire: RectificationQuestionnaire | null;
readonly scoring: (RectificationScoring & { readonly raw: Readonly<Record<string, unknown>> }) | null;
};
export class RectificationCaseNotFoundError extends Error {
readonly name = "RectificationCaseNotFoundError";
readonly caseId: string;
constructor(caseId: string) {
super(`Rectification case ${caseId} was not found`);
this.caseId = caseId;
}
}
function scanInput(assessment: BirthTimeAssessment): JourneyScanInput | null {
if (!("reportedTime" in assessment)) return null;
return {
birthTime: `${assessment.date} ${assessment.reportedTime}`,
uncertaintyMinutes: Math.max(
assessment.uncertaintyBeforeMinutes,
assessment.uncertaintyAfterMinutes,
),
lat: assessment.location.lat,
lon: assessment.location.lon,
tz: assessment.location.tz,
ayanamsa: "lahiri",
};
}
function questionnaireStability(questionnaire: RectificationQuestionnaire): ScanStability {
if (questionnaire.samples.length < 2) return { kind: "unavailable" };
const signatures = questionnaire.samples.map((sample) => {
if (!sample.ascendantSign || !sample.d9Sign || !sample.d10Sign) return null;
return `${sample.ascendantSign}|${sample.d9Sign}|${sample.d10Sign}`;
});
if (signatures.some((signature) => signature === null)) return { kind: "unavailable" };
return new Set(signatures).size === 1 ? { kind: "stable" } : { kind: "sensitive" };
}
async function scanAssessment(
engine: BirthTimeJourneyEngine,
assessment: BirthTimeAssessment,
): Promise<{ readonly stability: ScanStability; readonly questionnaire: RectificationQuestionnaire | null }> {
const input = scanInput(assessment);
if (!input) return { stability: { kind: "not_required" }, questionnaire: null };
try {
const result = await engine.scan(input);
return {
stability: questionnaireStability(result.questionnaire),
questionnaire: result.questionnaire,
};
} catch (error) {
if (error instanceof Error) {
return { stability: { kind: "unavailable" }, questionnaire: null };
}
throw error;
}
}
export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) {
return {
async assess(userId: string, assessment: BirthTimeAssessment): Promise<JourneyResponse> {
const scan = await scanAssessment(ports.engine, assessment);
const snapshot = assessBirthTime(assessment, scan.stability);
const persisted = {
userId,
assessment,
snapshot,
questionnaire: scan.questionnaire,
candidateScan: scan.questionnaire,
} satisfies PersistedJourneyAssessment;
const caseId = await ports.store.saveAssessment(persisted);
return { caseId, snapshot, questionnaire: scan.questionnaire, scoring: null };
},
async answerQuestion(
userId: string,
caseId: string,
questionId: string,
answer: RectificationAnswer,
): Promise<JourneyResponse> {
const stored = await ports.store.loadCase(userId, caseId);
if (!stored) throw new RectificationCaseNotFoundError(caseId);
const answers = { ...stored.answers, [questionId]: answer };
const scoring = await ports.engine.score({ questionnaire: stored.questionnaire, answers });
const snapshot = withRectificationScoring(stored.snapshot, scoring);
const updated = { ...stored, answers, scoring, snapshot } satisfies StoredRectificationCase;
await ports.store.saveScoring(updated);
return { caseId, snapshot, questionnaire: stored.questionnaire, scoring };
},
};
}
+206
View File
@@ -0,0 +1,206 @@
import { z } from "zod";
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 type JourneySnapshot = {
readonly state: "rectifying" | "candidate" | "ready";
readonly assistantIntent:
| "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";
readonly input: "none" | "rectification_questions" | "time_clue";
readonly route: "direct_chart" | "rectification";
readonly confidence: "high" | null;
readonly canApply: boolean;
readonly activeTime: string | null;
readonly reportedRange: {
readonly label: string;
readonly startTime: string | null;
readonly endTime: string | null;
};
};
export type RectificationScoring = {
readonly answeredCount: number;
readonly candidateClusterRankings: readonly {
readonly cluster: string;
readonly score: number;
}[];
};
class UnexpectedJourneyVariantError extends Error {
readonly name = "UnexpectedJourneyVariantError";
constructor(value: never) {
super(`Unexpected birth-time journey variant: ${JSON.stringify(value)}`);
}
}
const periodRanges = {
early_morning: { label: "04:00—07:59", startTime: "04:00", endTime: "07:59" },
morning: { label: "08:00—11:59", startTime: "08:00", endTime: "11:59" },
afternoon: { label: "12:00—17:59", startTime: "12:00", endTime: "17:59" },
evening: { label: "18:00—22:59", startTime: "18:00", endTime: "22:59" },
late_night: { label: "23:00—03:59", startTime: "23:00", endTime: "03:59" },
} as const;
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", periodRanges[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 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,
};
}