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,
};
}
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
parseBirthTimeProfile,
parseRectificationQuestionnaire,
parseRectificationScoring,
} from "../src/lib/birth-time-journey-adapters.ts";
const coordinates = {
latitude: 31.2304,
longitude: 121.4737,
timezone_offset: 8,
} as const;
test("birth time profile adapter parses an exact hospital declaration", () => {
const assessment = parseBirthTimeProfile({
birth_date: "1993-04-17",
reported_birth_time: "08:16:00",
birth_time_source: "hospital_record",
uncertainty_before_minutes: 2,
uncertainty_after_minutes: 2,
...coordinates,
});
assert.equal(assessment.source, "hospital_record");
if (assessment.source === "hospital_record") {
assert.equal(assessment.reportedTime, "08:16");
assert.equal(assessment.location.lon, 121.4737);
}
});
test("birth time profile adapter parses a period without inventing a time", () => {
const assessment = parseBirthTimeProfile({
birth_date: "1993-04-17",
reported_birth_time: null,
birth_time_source: "period_only",
birth_time_period: "evening",
...coordinates,
});
assert.equal(assessment.source, "period_only");
assert.equal("reportedTime" in assessment, false);
});
test("birth time profile adapter rejects missing location coordinates", () => {
assert.throws(() => parseBirthTimeProfile({
birth_date: "1993-04-17",
reported_birth_time: "08:16:00",
birth_time_source: "hospital_record",
uncertainty_before_minutes: 2,
uncertainty_after_minutes: 2,
}));
});
test("rectification adapter normalizes Python questionnaire samples and options", () => {
const questionnaire = parseRectificationQuestionnaire({
questions: [{
id: "education_environment_shift",
prompt: "是否有明显学业变化?",
options: [
{ key: "A", label: "明确有" },
{ key: "D", label: "不记得" },
],
}],
candidate_scan: {
samples: [{
ascendant: { sign: "Cancer" },
varga_lagna: {
D9: { sign: "Leo" },
D10: { sign: "Virgo" },
},
}],
},
});
assert.deepEqual(questionnaire.questions[0]?.options, [
{ key: "A", label: "明确有" },
{ key: "D", label: "不记得" },
]);
assert.deepEqual(questionnaire.samples[0], {
ascendantSign: "Cancer",
d9Sign: "Leo",
d10Sign: "Virgo",
});
});
test("rectification adapter rejects a malformed Python questionnaire", () => {
assert.throws(() => parseRectificationQuestionnaire({
questions: [{ id: "missing_prompt" }],
candidate_scan: { samples: [] },
}));
});
test("rectification adapter normalizes scoring without elevating confidence", () => {
const scoring = parseRectificationScoring({
answered_count: 3,
candidate_cluster_rankings: [
{ cluster: "middle_candidate_cluster", score: 5 },
],
next_round: 2,
});
assert.equal(scoring.answeredCount, 3);
assert.deepEqual(scoring.candidateClusterRankings, [
{ cluster: "middle_candidate_cluster", score: 5 },
]);
assert.equal(scoring.raw.next_round, 2);
});
@@ -0,0 +1,152 @@
import assert from "node:assert/strict";
import test from "node:test";
import { birthTimeAssessmentSchema } from "../src/lib/birth-time-journey.ts";
import {
createBirthTimeJourneyService,
type BirthTimeJourneyEngine,
type BirthTimeJourneyStore,
type PersistedJourneyAssessment,
type StoredRectificationCase,
} from "../src/lib/birth-time-journey-service.ts";
const hospitalAssessment = birthTimeAssessmentSchema.parse({
date: "1993-04-17",
source: "hospital_record",
reportedTime: "08:16",
uncertaintyBeforeMinutes: 2,
uncertaintyAfterMinutes: 2,
location: { lat: 31.2304, lon: 121.4737, tz: 8 },
});
function scanWithSigns(signs: readonly string[]) {
const samples = signs.map((sign) => ({
ascendantSign: sign,
d9Sign: sign,
d10Sign: sign,
}));
return {
questionnaire: {
questions: [{ id: "education_environment_shift", prompt: "是否有明显学业变化?" }],
samples,
raw: { candidate_scan: { samples } },
},
};
}
function memoryStore(initialCase?: StoredRectificationCase) {
let savedAssessment: PersistedJourneyAssessment | null = null;
let savedCase = initialCase ?? null;
const store: BirthTimeJourneyStore = {
async saveAssessment(value) {
savedAssessment = value;
return "case-1";
},
async loadCase() {
return savedCase;
},
async saveScoring(value) {
savedCase = value;
},
};
return {
store,
savedAssessment: () => savedAssessment,
savedCase: () => savedCase,
};
}
test("journey service activates a stable hospital record and persists its scan", async () => {
const memory = memoryStore();
let receivedUncertainty = 0;
const engine: BirthTimeJourneyEngine = {
async scan(input) {
receivedUncertainty = input.uncertaintyMinutes;
return scanWithSigns(["Cancer", "Cancer", "Cancer"]);
},
async score() {
throw new Error("not used");
},
};
const service = createBirthTimeJourneyService({ store: memory.store, engine });
const result = await service.assess("user-1", hospitalAssessment);
assert.equal(receivedUncertainty, 2);
assert.equal(result.snapshot.route, "direct_chart");
assert.equal(result.snapshot.activeTime, "08:16");
assert.equal(result.caseId, "case-1");
assert.deepEqual(memory.savedAssessment()?.candidateScan, result.questionnaire);
});
test("journey service fails a scanner error closed without activating the time", async () => {
const memory = memoryStore();
const engine: BirthTimeJourneyEngine = {
async scan() {
throw new TypeError("scanner offline");
},
async score() {
throw new Error("not used");
},
};
const service = createBirthTimeJourneyService({ store: memory.store, engine });
const result = await service.assess("user-1", hospitalAssessment);
assert.equal(result.snapshot.route, "rectification");
assert.equal(result.snapshot.canApply, false);
assert.equal(result.questionnaire, null);
assert.equal(memory.savedAssessment()?.snapshot.activeTime, null);
});
test("journey service accumulates answers while preserving the application gate", async () => {
const approximate = birthTimeAssessmentSchema.parse({
date: "1993-04-17",
source: "approximate",
reportedTime: "14:30",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
location: { lat: 31.2304, lon: 121.4737, tz: 8 },
});
const questionnaire = scanWithSigns(["Cancer", "Leo", "Leo"]).questionnaire;
const initialSnapshot = createBirthTimeJourneyService({
store: memoryStore().store,
engine: {
async scan() { return { questionnaire }; },
async score() { throw new Error("not used"); },
},
});
const assessed = await initialSnapshot.assess("user-1", approximate);
const storedCase: StoredRectificationCase = {
id: "case-1",
userId: "user-1",
snapshot: assessed.snapshot,
questionnaire,
answers: { education_environment_shift: "A" },
};
const memory = memoryStore(storedCase);
let scoredAnswers: Readonly<Record<string, "A" | "B" | "C" | "D">> = {};
const engine: BirthTimeJourneyEngine = {
async scan() {
return { questionnaire };
},
async score(input) {
scoredAnswers = input.answers;
return {
answeredCount: 3,
candidateClusterRankings: [{ cluster: "middle_candidate_cluster", score: 5 }],
raw: { next_round: 2 },
};
},
};
const service = createBirthTimeJourneyService({ store: memory.store, engine });
const result = await service.answerQuestion("user-1", "case-1", "career_responsibility_pressure", "B");
assert.deepEqual(scoredAnswers, {
education_environment_shift: "A",
career_responsibility_pressure: "B",
});
assert.equal(result.snapshot.state, "candidate");
assert.equal(result.snapshot.canApply, false);
assert.deepEqual(memory.savedCase()?.answers, scoredAnswers);
});
+153
View File
@@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
assessBirthTime,
birthTimeAssessmentSchema,
withRectificationScoring,
} from "../src/lib/birth-time-journey.ts";
const location = { lat: 31.2304, lon: 121.4737, tz: 8 } as const;
test("birth time journey sends a stable hospital record directly to charting", () => {
const assessment = birthTimeAssessmentSchema.parse({
date: "1993-04-17",
source: "hospital_record",
reportedTime: "08:16",
uncertaintyBeforeMinutes: 2,
uncertaintyAfterMinutes: 2,
location,
});
const snapshot = assessBirthTime(assessment, { kind: "stable" });
assert.equal(snapshot.state, "ready");
assert.equal(snapshot.route, "direct_chart");
assert.equal(snapshot.canApply, true);
assert.equal(snapshot.activeTime, "08:16");
assert.equal(snapshot.assistantIntent, "confirm_stable_record");
});
test("birth time journey fails a sensitive hospital record closed into rectification", () => {
const assessment = birthTimeAssessmentSchema.parse({
date: "1993-04-17",
source: "hospital_record",
reportedTime: "08:16",
uncertaintyBeforeMinutes: 2,
uncertaintyAfterMinutes: 2,
location,
});
const snapshot = assessBirthTime(assessment, { kind: "sensitive" });
assert.equal(snapshot.state, "rectifying");
assert.equal(snapshot.route, "rectification");
assert.equal(snapshot.canApply, false);
assert.equal(snapshot.activeTime, null);
assert.equal(snapshot.assistantIntent, "explain_sensitive_boundary");
});
test("birth time journey fails a scanner outage closed into rectification", () => {
const assessment = birthTimeAssessmentSchema.parse({
date: "1993-04-17",
source: "hospital_record",
reportedTime: "08:16",
uncertaintyBeforeMinutes: 2,
uncertaintyAfterMinutes: 2,
location,
});
const snapshot = assessBirthTime(assessment, { kind: "unavailable" });
assert.equal(snapshot.route, "rectification");
assert.equal(snapshot.canApply, false);
assert.equal(snapshot.assistantIntent, "explain_assessment_unavailable");
});
test("birth time journey routes family and approximate declarations to rectification", () => {
const family = birthTimeAssessmentSchema.parse({
date: "1993-04-17",
source: "family_exact",
reportedTime: "14:30",
uncertaintyBeforeMinutes: 10,
uncertaintyAfterMinutes: 10,
location,
});
const approximate = birthTimeAssessmentSchema.parse({
date: "1993-04-17",
source: "approximate",
reportedTime: "14:30",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
location,
});
assert.equal(assessBirthTime(family, { kind: "sensitive" }).assistantIntent, "start_light_rectification");
assert.equal(assessBirthTime(approximate, { kind: "sensitive" }).assistantIntent, "start_standard_rectification");
});
test("birth time journey accepts period-only and unknown declarations without inventing a clock time", () => {
const period = birthTimeAssessmentSchema.parse({
date: "1993-04-17",
source: "period_only",
period: "evening",
location,
});
const unknown = birthTimeAssessmentSchema.parse({
date: "1993-04-17",
source: "unknown",
clue: "家人只记得天黑以后",
location,
});
const periodSnapshot = assessBirthTime(period, { kind: "not_required" });
const unknownSnapshot = assessBirthTime(unknown, { kind: "not_required" });
assert.equal(periodSnapshot.reportedRange.label, "18:00—22:59");
assert.equal(periodSnapshot.activeTime, null);
assert.equal(periodSnapshot.assistantIntent, "start_period_rectification");
assert.equal(unknownSnapshot.reportedRange.label, "全天待确认");
assert.equal(unknownSnapshot.assistantIntent, "collect_time_clues");
});
test("birth time assessment rejects source-specific missing or invalid fields", () => {
const missingTime = birthTimeAssessmentSchema.safeParse({
date: "1993-04-17",
source: "hospital_record",
uncertaintyBeforeMinutes: 2,
uncertaintyAfterMinutes: 2,
location,
});
const invalidFamilyRange = birthTimeAssessmentSchema.safeParse({
date: "1993-04-17",
source: "family_exact",
reportedTime: "14:30",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
location,
});
assert.equal(missingTime.success, false);
assert.equal(invalidFamilyRange.success, false);
});
test("rectification scoring can save a candidate but never apply an exact minute", () => {
const assessment = birthTimeAssessmentSchema.parse({
date: "1993-04-17",
source: "approximate",
reportedTime: "14:30",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
location,
});
const initial = assessBirthTime(assessment, { kind: "sensitive" });
const scored = withRectificationScoring(initial, {
answeredCount: 3,
candidateClusterRankings: [{ cluster: "middle_candidate_cluster", score: 5 }],
});
assert.equal(scored.state, "candidate");
assert.equal(scored.canApply, false);
assert.equal(scored.activeTime, null);
assert.equal(scored.assistantIntent, "present_saved_candidate_range");
});