Files
Jyotisha/frontend/src/lib/consultation-plan.ts
T
Jesse_Chen 9958e00abc fix(web): keep unrectified birth data usable without inventing a minute
Rectification stays optional. Reported minutes can consult and generate reports; date-plus-period uses a declared window instead of a midpoint or 00:00. Updates BUG-341.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 15:29:58 +08:00

121 lines
4.8 KiB
TypeScript

import { z } from "zod";
import type { ConsultationBirthTimeMode } from "./consultation-birth-time-mode.ts";
import {
consultationDomainDefinition,
consultationDomainIds,
consultationDomainSchema,
type ConsultationTheme,
} from "./consultation-domain-registry.ts";
export const consultationDomainValues = consultationDomainIds;
export const consultationDepthValues = ["concise", "standard", "deep", "research"] as const;
export const consultationTimingHorizonValues = ["next_3_months", "next_12_months", "next_24_months", "long_term"] as const;
export const consultationEvidenceCategoryValues = ["natal_foundation", "domain", "timing", "validation"] as const;
export const consultationPlanVersion = "consultation-plan-v2" as const;
export const consultationPlanMaxCreditCost = 1 as const;
const consultationPrecisionBoundaryValues = ["server_evidence_required", "precise_timing_blocked"] as const;
export const consultationPlanSchema = z.object({
plan_version: z.literal(consultationPlanVersion),
userIntent: z.string().trim().min(1).max(500),
requestedDomains: z.array(consultationDomainSchema).min(1).max(6),
depth: z.enum(consultationDepthValues),
timingHorizon: z.enum(consultationTimingHorizonValues).nullable(),
requiredEvidenceCategories: z.array(z.enum(consultationEvidenceCategoryValues)).min(1).max(4),
birthTimeMode: z.enum([
"verified_chart",
"unverified_birth_time",
"declared_birth_window",
"general_no_birth_time",
]),
precisionBoundary: z.enum(consultationPrecisionBoundaryValues),
maxCreditCost: z.literal(consultationPlanMaxCreditCost),
}).strict();
type DeepReadonly<T> = T extends (...args: never[]) => unknown
? T
: T extends readonly (infer Item)[]
? readonly DeepReadonly<Item>[]
: T extends object
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
: T;
export type ConsultationPlan = DeepReadonly<z.infer<typeof consultationPlanSchema>>;
export type ConsultationPlanTheme = ConsultationTheme;
export type ConsultationPlanValidationCode =
| "plan_mode_mismatch"
| "plan_precision_mismatch"
| "plan_cost_ceiling_exceeded";
export class ConsultationPlanValidationError extends Error {
readonly code: ConsultationPlanValidationCode;
constructor(code: ConsultationPlanValidationCode) {
super(`Consultation plan rejected: ${code}`);
this.name = "ConsultationPlanValidationError";
this.code = code;
}
}
function domainsForTheme(theme: ConsultationPlanTheme): ConsultationPlan["requestedDomains"] {
return [...consultationDomainDefinition(theme).workflowThemes];
}
function deepFreeze<T>(value: T): DeepReadonly<T> {
if (value && typeof value === "object" && !Object.isFrozen(value)) {
for (const child of Object.values(value)) deepFreeze(child);
Object.freeze(value);
}
return value as DeepReadonly<T>;
}
function precisionBoundaryFor(mode: ConsultationBirthTimeMode) {
return mode === "general_no_birth_time" || mode === "declared_birth_window"
? "precise_timing_blocked" as const
: "server_evidence_required" as const;
}
export function validateConsultationPlan(
plan: ConsultationPlan,
input: Readonly<{ consultationMode: ConsultationBirthTimeMode; modelCreditCost: number }>,
) {
if (plan.birthTimeMode !== input.consultationMode) {
throw new ConsultationPlanValidationError("plan_mode_mismatch");
}
if (plan.precisionBoundary !== precisionBoundaryFor(input.consultationMode)) {
throw new ConsultationPlanValidationError("plan_precision_mismatch");
}
if (!Number.isInteger(input.modelCreditCost) || input.modelCreditCost < 1 || input.modelCreditCost > plan.maxCreditCost) {
throw new ConsultationPlanValidationError("plan_cost_ceiling_exceeded");
}
return plan;
}
export function createConsultationPlan(input: {
userIntent: string;
theme: ConsultationPlanTheme;
consultationMode?: ConsultationBirthTimeMode;
modelCreditCost?: number;
depth?: ConsultationPlan["depth"];
timingHorizon?: ConsultationPlan["timingHorizon"];
}): ConsultationPlan {
const consultationMode = input.consultationMode ?? "verified_chart";
const theme = consultationDomainSchema.parse(input.theme);
const plan = deepFreeze(consultationPlanSchema.parse({
plan_version: consultationPlanVersion,
userIntent: input.userIntent,
requestedDomains: domainsForTheme(theme),
depth: input.depth ?? "standard",
timingHorizon: input.timingHorizon ?? (theme === "timing" || theme === "annual" ? "next_12_months" : null),
requiredEvidenceCategories: [...consultationEvidenceCategoryValues],
birthTimeMode: consultationMode,
precisionBoundary: precisionBoundaryFor(consultationMode),
maxCreditCost: consultationPlanMaxCreditCost,
}));
return input.modelCreditCost === undefined
? plan
: validateConsultationPlan(plan, { consultationMode, modelCreditCost: input.modelCreditCost });
}