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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user