Files
Jyotisha/frontend/src/lib/birth-time-evidence.ts
T
2026-07-18 18:40:23 +08:00

234 lines
7.3 KiB
TypeScript

import { z } from "zod";
import type { JourneySnapshot } from "./birth-time-journey.ts";
const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
export const lifeEventDomainSchema = z.enum([
"education",
"relocation",
"relationship",
"career",
"health_pressure",
]);
export const lifeEventPrecisionSchema = z.enum(["year", "month", "day"]);
const lifeEventBase = {
id: z.string().uuid(),
domain: lifeEventDomainSchema,
} as const;
const supportedYearSchema = z.string().regex(/^(19|20)\d{2}$/).refine(
(value) => Number(value) <= new Date().getFullYear(),
"event year cannot be in the future",
);
const monthDateSchema = z.string().regex(/^(19|20)\d{2}-(0[1-9]|1[0-2])$/).refine(
(value) => value <= new Date().toISOString().slice(0, 7),
"event month cannot be in the future",
);
const dayDateSchema = z.string().regex(/^(19|20)\d{2}-(0[1-9]|1[0-2])-([0-2]\d|3[01])$/).refine(
(value) => {
const [year, month, day] = value.split("-").map(Number);
const parsed = new Date(Date.UTC(year, month - 1, day));
return parsed.getUTCFullYear() === year
&& parsed.getUTCMonth() === month - 1
&& parsed.getUTCDate() === day
&& value <= new Date().toISOString().slice(0, 10);
},
"event day must be a real past or present calendar date",
);
export const lifeEventSchema = z.discriminatedUnion("precision", [
z.object({ ...lifeEventBase, precision: z.literal("year"), date: supportedYearSchema }).strict(),
z.object({ ...lifeEventBase, precision: z.literal("month"), date: monthDateSchema }).strict(),
z.object({ ...lifeEventBase, precision: z.literal("day"), date: dayDateSchema }).strict(),
]).readonly();
export type LifeEvent = z.infer<typeof lifeEventSchema>;
export const evidenceDraftProposalSchema = z.object({
domain: lifeEventDomainSchema,
precision: lifeEventPrecisionSchema.nullable(),
date: z.string().trim().min(1).max(10).nullable(),
}).strict().readonly();
export type EvidenceDraftProposal = z.infer<typeof evidenceDraftProposalSchema>;
export const evidenceDraftSchema = z.object({
draftId: z.string().uuid(),
questionId: z.string().trim().min(1).max(120),
domain: lifeEventDomainSchema,
precision: lifeEventPrecisionSchema.nullable(),
date: z.string().trim().min(1).max(10).nullable(),
status: z.literal("draft"),
needsReview: z.boolean(),
}).strict().readonly().superRefine((value, context) => {
if ((value.precision === null || value.date === null) && !value.needsReview) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["needsReview"],
message: "incomplete evidence drafts require review",
});
}
});
export type EvidenceDraft = z.infer<typeof evidenceDraftSchema>;
const candidateEvidenceSchema = z.object({
eventId: z.string().uuid(),
domain: lifeEventDomainSchema,
candidateTime: timeSchema,
ruleIds: z.array(z.string().trim().min(1)),
points: z.number(),
}).strict().readonly();
export const candidateResultSchema = z.object({
resultId: z.string().uuid(),
confidence: z.enum(["low", "medium", "high"]),
canApply: z.boolean(),
winningSegment: z.object({
startTime: timeSchema,
endTime: timeSchema,
representativeTime: timeSchema,
widthMinutes: z.number().int().min(1).max(1_440),
}).strict().readonly().nullable(),
eventCount: z.number().int().min(0).max(6),
domainCount: z.number().int().min(0).max(5),
topScore: z.number(),
secondScore: z.number(),
marginPercent: z.number().min(0),
reasons: z.array(z.string().trim().min(1)),
evidence: z.array(candidateEvidenceSchema),
algorithmVersion: z.string().trim().min(1),
}).strict().readonly().superRefine((value, context) => {
const eligible = value.confidence === "high" && highCandidateMeetsSafetyGates(value);
if (value.confidence === "high" && value.eventCount < 4) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["eventCount"],
message: "high candidates require at least four events",
});
}
if (value.confidence === "high" && value.domainCount < 3) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["domainCount"],
message: "high candidates require at least three domains",
});
}
if (value.confidence === "high" && value.winningSegment === null) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["winningSegment"],
message: "high candidates require a winning segment",
});
}
if (value.confidence === "high" && value.winningSegment !== null && value.winningSegment.widthMinutes > 5) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["winningSegment", "widthMinutes"],
message: "high candidate segments cannot exceed five minutes",
});
}
if (value.confidence === "high" && value.marginPercent < 20) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["marginPercent"],
message: "high candidates require at least twenty percent margin",
});
}
if (value.canApply !== eligible) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["canApply"],
message: "only a high candidate with a winning segment can be confirmed",
});
}
});
export type CandidateResult = z.infer<typeof candidateResultSchema>;
function highCandidateMeetsSafetyGates(value: {
readonly confidence: CandidateResult["confidence"];
readonly winningSegment: CandidateResult["winningSegment"];
readonly eventCount: number;
readonly domainCount: number;
readonly marginPercent: number;
}): boolean {
return value.confidence === "high"
&& value.winningSegment !== null
&& value.eventCount >= 4
&& value.domainCount >= 3
&& value.winningSegment.widthMinutes <= 5
&& value.marginPercent >= 20;
}
export class CandidateConfirmationError extends Error {
readonly name = "CandidateConfirmationError";
}
export function withCandidateResult(
snapshot: JourneySnapshot,
result: CandidateResult,
): JourneySnapshot {
switch (result.confidence) {
case "low":
return {
...snapshot,
state: "rectifying",
assistantIntent: "explain_event_evidence_insufficient",
input: "life_events",
confidence: "low",
canApply: false,
activeTime: null,
};
case "medium":
return {
...snapshot,
state: "candidate",
assistantIntent: "present_candidate_result",
input: "candidate_actions",
confidence: "medium",
canApply: false,
activeTime: null,
};
case "high":
return {
...snapshot,
state: "confirming",
assistantIntent: "confirm_candidate_time",
input: "candidate_confirmation",
confidence: "high",
canApply: true,
activeTime: null,
};
}
}
export function withConfirmedCandidate(
snapshot: JourneySnapshot,
result: CandidateResult,
confirmedTime: string,
): JourneySnapshot {
if (
snapshot.state !== "confirming"
|| !snapshot.canApply
|| !highCandidateMeetsSafetyGates(result)
|| !result.canApply
|| result.winningSegment?.representativeTime !== confirmedTime
) {
throw new CandidateConfirmationError();
}
return {
...snapshot,
state: "ready",
assistantIntent: "confirmed_candidate_time",
input: "none",
route: "direct_chart",
confidence: "high",
canApply: false,
activeTime: confirmedTime,
};
}