feat: add deterministic birth time journey
This commit is contained in:
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user