From 516c3891838e32997c2aaf1186c5df6ce53f07da Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 17 Jul 2026 15:48:09 +0800 Subject: [PATCH] feat: add deterministic birth time journey --- .../src/lib/birth-time-journey-adapters.ts | 143 ++++++++++++ .../src/lib/birth-time-journey-service.ts | 170 +++++++++++++++ frontend/src/lib/birth-time-journey.ts | 206 ++++++++++++++++++ .../tests/birth-time-journey-adapters.test.ts | 108 +++++++++ .../tests/birth-time-journey-service.test.ts | 152 +++++++++++++ frontend/tests/birth-time-journey.test.ts | 153 +++++++++++++ 6 files changed, 932 insertions(+) create mode 100644 frontend/src/lib/birth-time-journey-adapters.ts create mode 100644 frontend/src/lib/birth-time-journey-service.ts create mode 100644 frontend/src/lib/birth-time-journey.ts create mode 100644 frontend/tests/birth-time-journey-adapters.test.ts create mode 100644 frontend/tests/birth-time-journey-service.test.ts create mode 100644 frontend/tests/birth-time-journey.test.ts diff --git a/frontend/src/lib/birth-time-journey-adapters.ts b/frontend/src/lib/birth-time-journey-adapters.ts new file mode 100644 index 00000000..c1ba2b67 --- /dev/null +++ b/frontend/src/lib/birth-time-journey-adapters.ts @@ -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); +} diff --git a/frontend/src/lib/birth-time-journey-service.ts b/frontend/src/lib/birth-time-journey-service.ts new file mode 100644 index 00000000..97a58f7c --- /dev/null +++ b/frontend/src/lib/birth-time-journey-service.ts @@ -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>; +}; + +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>; +}; + +export interface BirthTimeJourneyEngine { + scan(input: JourneyScanInput): Promise<{ readonly questionnaire: RectificationQuestionnaire }>; + score(input: JourneyScoreInput): Promise> }>; +} + +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>; + readonly scoring?: RectificationScoring & { readonly raw: Readonly> }; +}; + +export interface BirthTimeJourneyStore { + saveAssessment(value: PersistedJourneyAssessment): Promise; + loadCase(userId: string, caseId: string): Promise; + saveScoring(value: StoredRectificationCase): Promise; +} + +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> }) | 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 { + 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 { + 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 }; + }, + }; +} diff --git a/frontend/src/lib/birth-time-journey.ts b/frontend/src/lib/birth-time-journey.ts new file mode 100644 index 00000000..1c7ce281 --- /dev/null +++ b/frontend/src/lib/birth-time-journey.ts @@ -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; + +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, + }; +} diff --git a/frontend/tests/birth-time-journey-adapters.test.ts b/frontend/tests/birth-time-journey-adapters.test.ts new file mode 100644 index 00000000..186517ec --- /dev/null +++ b/frontend/tests/birth-time-journey-adapters.test.ts @@ -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); +}); diff --git a/frontend/tests/birth-time-journey-service.test.ts b/frontend/tests/birth-time-journey-service.test.ts new file mode 100644 index 00000000..9e636297 --- /dev/null +++ b/frontend/tests/birth-time-journey-service.test.ts @@ -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> = {}; + 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); +}); diff --git a/frontend/tests/birth-time-journey.test.ts b/frontend/tests/birth-time-journey.test.ts new file mode 100644 index 00000000..80fccb90 --- /dev/null +++ b/frontend/tests/birth-time-journey.test.ts @@ -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"); +});