272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
import { z } from "zod";
|
|
import {
|
|
conversationalRectificationMessageHistoryEntrySchema,
|
|
conversationalRectificationTurnSchema,
|
|
} from "./contracts.ts";
|
|
import { boundedJson } from "./json-bounds.ts";
|
|
|
|
const uuidSchema = z.string().uuid();
|
|
const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
|
|
const boundedText = (maximum: number) => z.string()
|
|
.min(1)
|
|
.max(maximum)
|
|
.refine((value) => value.trim().length > 0, "text must contain a non-whitespace character");
|
|
|
|
const birthDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine((value) => {
|
|
const [year, month, day] = value.split("-").map(Number);
|
|
if (year === undefined || month === undefined || day === undefined) return false;
|
|
if (year < 1_000 || year > 9_999 || month < 1 || month > 12 || day < 1) return false;
|
|
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
return day <= (days[month - 1] ?? 0);
|
|
}, "invalid calendar date");
|
|
|
|
const locationCodeSchema = boundedText(80);
|
|
const birthplaceSchema = boundedJson(z.object({
|
|
city: boundedText(120).optional(),
|
|
countryCode: z.string().regex(/^[A-Z0-9-]{1,8}$/).optional(),
|
|
provinceCode: locationCodeSchema.optional(),
|
|
cityCode: locationCodeSchema.optional(),
|
|
districtCode: locationCodeSchema.optional(),
|
|
latitude: z.number().finite().min(-90).max(90).optional(),
|
|
longitude: z.number().finite().min(-180).max(180).optional(),
|
|
timezoneOffset: z.number().finite().min(-12).max(14),
|
|
}).strict().superRefine((value, context) => {
|
|
if (!value.city && !value.cityCode) {
|
|
context.addIssue({ code: "custom", message: "city or cityCode is required" });
|
|
}
|
|
if ((value.latitude === undefined) !== (value.longitude === undefined)) {
|
|
context.addIssue({ code: "custom", message: "coordinates must be supplied as a pair" });
|
|
}
|
|
}), 4_096);
|
|
|
|
const clueSchema = boundedText(240).nullable();
|
|
const commonBirthFields = {
|
|
birthDate: birthDateSchema,
|
|
birthTimeClue: clueSchema,
|
|
birthplace: birthplaceSchema,
|
|
} as const;
|
|
const periodSchema = z.enum([
|
|
"early_morning",
|
|
"morning",
|
|
"afternoon",
|
|
"evening",
|
|
"late_night",
|
|
]);
|
|
|
|
const hospitalDeclarationSchema = z.object({
|
|
...commonBirthFields,
|
|
source: z.literal("hospital_record"),
|
|
reportedTime: timeSchema,
|
|
uncertaintyBeforeMinutes: z.literal(2),
|
|
uncertaintyAfterMinutes: z.literal(2),
|
|
}).strict();
|
|
const familyDeclarationSchema = z.object({
|
|
...commonBirthFields,
|
|
source: z.literal("family_exact"),
|
|
reportedTime: timeSchema,
|
|
uncertaintyBeforeMinutes: z.union([z.literal(5), z.literal(10), z.literal(15)]),
|
|
uncertaintyAfterMinutes: z.union([z.literal(5), z.literal(10), z.literal(15)]),
|
|
}).strict().refine(
|
|
(value) => value.uncertaintyBeforeMinutes === value.uncertaintyAfterMinutes,
|
|
"family uncertainty must be symmetric",
|
|
);
|
|
const approximateDeclarationSchema = z.object({
|
|
...commonBirthFields,
|
|
source: z.literal("approximate"),
|
|
reportedTime: timeSchema,
|
|
uncertaintyBeforeMinutes: z.union([z.literal(15), z.literal(30), z.literal(60)]),
|
|
uncertaintyAfterMinutes: z.union([z.literal(15), z.literal(30), z.literal(60)]),
|
|
}).strict().refine(
|
|
(value) => value.uncertaintyBeforeMinutes === value.uncertaintyAfterMinutes,
|
|
"approximate uncertainty must be symmetric",
|
|
);
|
|
const periodDeclarationSchema = z.object({
|
|
...commonBirthFields,
|
|
source: z.literal("period_only"),
|
|
reportedPeriod: periodSchema,
|
|
}).strict();
|
|
const unknownDeclarationSchema = z.object({
|
|
...commonBirthFields,
|
|
source: z.literal("unknown"),
|
|
}).strict();
|
|
const legacyDeclarationSchema = z.object({
|
|
...commonBirthFields,
|
|
source: z.literal("legacy_import"),
|
|
reportedTime: timeSchema.optional(),
|
|
reportedPeriod: periodSchema.optional(),
|
|
uncertaintyBeforeMinutes: z.number().int().min(0).max(720).optional(),
|
|
uncertaintyAfterMinutes: z.number().int().min(0).max(720).optional(),
|
|
}).strict().superRefine((value, context) => {
|
|
const hasTime = typeof value.reportedTime === "string";
|
|
const hasPeriod = typeof value.reportedPeriod === "string";
|
|
if (hasTime && hasPeriod) {
|
|
context.addIssue({ code: "custom", message: "legacy declaration has two time modes" });
|
|
}
|
|
const before = value.uncertaintyBeforeMinutes;
|
|
const after = value.uncertaintyAfterMinutes;
|
|
if ((before === undefined) !== (after === undefined)
|
|
|| (!hasTime && (before !== undefined || after !== undefined))) {
|
|
context.addIssue({ code: "custom", message: "legacy uncertainty is incoherent" });
|
|
}
|
|
});
|
|
|
|
export const declaredBirthInputSchema = boundedJson(z.union([
|
|
hospitalDeclarationSchema,
|
|
familyDeclarationSchema,
|
|
approximateDeclarationSchema,
|
|
periodDeclarationSchema,
|
|
unknownDeclarationSchema,
|
|
legacyDeclarationSchema,
|
|
]), 12_000);
|
|
export type DeclaredBirthInput = z.infer<typeof declaredBirthInputSchema>;
|
|
|
|
const evidenceDomainSchema = z.enum([
|
|
"career",
|
|
"education",
|
|
"finance",
|
|
"health_pressure",
|
|
"relocation",
|
|
"relationship",
|
|
"family",
|
|
"other",
|
|
]);
|
|
const scoredEvidenceSchema = boundedJson(z.object({
|
|
evidenceId: uuidSchema,
|
|
domain: evidenceDomainSchema,
|
|
candidateTime: timeSchema.nullable(),
|
|
score: z.number().finite().min(-1_000_000).max(1_000_000),
|
|
ruleRefs: z.array(boundedText(120)).max(40),
|
|
}).strict(), 8_192);
|
|
const futureWindowSchema = boundedJson(z.object({
|
|
label: boundedText(240),
|
|
startDate: birthDateSchema,
|
|
endDate: birthDateSchema,
|
|
scoreable: z.literal(false),
|
|
}).strict().refine((value) => value.startDate <= value.endDate, "window order is invalid"), 2_048);
|
|
const privateWorkingStateSchema = boundedJson(z.object({
|
|
phase: z.enum(["initial", "collecting_evidence", "rescoring", "ready", "confirmed"]),
|
|
iteration: z.number().int().min(0).max(100),
|
|
notes: z.array(boundedText(240)).max(20),
|
|
}).strict(), 8_192);
|
|
|
|
export const privateCandidateSchema = boundedJson(z.object({
|
|
resultId: uuidSchema.nullable().optional(),
|
|
representativeTime: timeSchema.nullable().optional(),
|
|
rangeStart: timeSchema.nullable().optional(),
|
|
rangeEnd: timeSchema.nullable().optional(),
|
|
calculationVersion: boundedText(80),
|
|
candidateWeights: z.array(z.number().finite().min(0).max(1)).max(1_440).optional(),
|
|
candidateModelRefs: boundedJson(z.array(boundedText(120)).max(80), 16_384).optional(),
|
|
d1Stability: z.enum(["stable", "sensitive", "unavailable"]).optional(),
|
|
boundaryDistanceMinutes: z.number().int().min(0).max(1_440).nullable().optional(),
|
|
supportedSensitiveLayers: boundedJson(z.array(boundedText(80)).max(40), 8_192).optional(),
|
|
scoredHistoricalEvidence: z.array(scoredEvidenceSchema).max(100).optional(),
|
|
suggestedDomains: z.array(evidenceDomainSchema).max(6).optional(),
|
|
futureWindows: z.array(futureWindowSchema).max(20).optional(),
|
|
workingState: privateWorkingStateSchema.optional(),
|
|
}).strict().superRefine((value, context) => {
|
|
const hasRangeStart = value.rangeStart !== undefined;
|
|
const hasRangeEnd = value.rangeEnd !== undefined;
|
|
if (hasRangeStart !== hasRangeEnd
|
|
|| (hasRangeStart && (value.rangeStart === null) !== (value.rangeEnd === null))) {
|
|
context.addIssue({ code: "custom", message: "candidate range must be supplied as a pair" });
|
|
}
|
|
}), 65_536);
|
|
export type PrivateCandidate = z.infer<typeof privateCandidateSchema>;
|
|
|
|
export const validationReceiptSchema = boundedJson(z.object({
|
|
modelId: boundedText(120),
|
|
schemaValidated: z.boolean(),
|
|
validatorVersion: boundedText(80).optional(),
|
|
validatedAt: z.string().max(40).datetime({ offset: true }).optional(),
|
|
retryCount: z.number().int().min(0).max(2).optional(),
|
|
fallbackUsed: z.boolean().optional(),
|
|
issues: z.array(boundedText(240)).max(20).optional(),
|
|
}).strict(), 8_192);
|
|
export type ValidationReceipt = z.infer<typeof validationReceiptSchema>;
|
|
|
|
const correctionEvidenceIdsSchema = boundedJson(z.array(uuidSchema).max(1), 64);
|
|
|
|
export const lifeEventEvidenceSchema = boundedJson(z.object({
|
|
id: uuidSchema,
|
|
rawText: boundedText(4_000),
|
|
domain: evidenceDomainSchema,
|
|
eventSummary: boundedText(1_000),
|
|
dateValue: boundedText(80).nullable(),
|
|
datePrecision: z.enum(["day", "month", "year", "range", "unknown"]),
|
|
extractionStatus: z.enum(["clear", "needs_clarification", "corrected"]),
|
|
scoreable: z.boolean().optional(),
|
|
// Optional only for rows written before durable correction lineage existed.
|
|
correctsEvidenceIds: correctionEvidenceIdsSchema.optional(),
|
|
}).strict().superRefine((value, context) => {
|
|
const correctionCount = value.correctsEvidenceIds?.length ?? 0;
|
|
if (value.extractionStatus === "corrected" && correctionCount !== 1) {
|
|
context.addIssue({ code: "custom", message: "corrected evidence requires one target" });
|
|
}
|
|
if (value.extractionStatus === "clear" && correctionCount !== 0) {
|
|
context.addIssue({ code: "custom", message: "clear evidence cannot correct another row" });
|
|
}
|
|
}), 16_384);
|
|
export type LifeEventEvidence = z.infer<typeof lifeEventEvidenceSchema>;
|
|
|
|
const mutationKindSchema = z.enum([
|
|
"create",
|
|
"save_turn",
|
|
"pause",
|
|
"abandon",
|
|
"confirm",
|
|
"import_legacy",
|
|
"reserve_fee",
|
|
"complete_fee",
|
|
"release_fee",
|
|
"recover_fee",
|
|
]);
|
|
export const conversationalRectificationActionReceiptRequestSchema = boundedJson(z.object({
|
|
kind: mutationKindSchema,
|
|
userId: uuidSchema,
|
|
caseId: uuidSchema,
|
|
expectedVersion: z.number().int().nonnegative(),
|
|
actionId: uuidSchema,
|
|
requestFingerprint: z.string().regex(/^[0-9a-f]{64}$/),
|
|
commandFingerprint: z.string().regex(/^[0-9a-f]{64}$/).optional(),
|
|
}).strict(), 2_048);
|
|
|
|
export const billingReceiptResponseSchema = boundedJson(z.object({
|
|
success: z.boolean(),
|
|
credits: z.number().int().nonnegative().nullable(),
|
|
billing_state: z.enum(["reserved", "charged", "released", "migration_waived"]).nullable(),
|
|
error_code: boundedText(80).nullable(),
|
|
}).strict(), 2_048);
|
|
|
|
export const storedCaseRowSchema = boundedJson(z.object({
|
|
case_id: uuidSchema,
|
|
user_id: uuidSchema,
|
|
status: z.enum(["starting", "active", "paused", "confirming", "completed", "abandoned"]),
|
|
turn_version: z.number().int().nonnegative(),
|
|
revision_of_case_id: uuidSchema.nullable(),
|
|
imported_from_case_id: uuidSchema.nullable(),
|
|
baseline_active_time: timeSchema.nullable(),
|
|
pending_consultation_question: boundedText(500).nullable(),
|
|
billing_state: z.enum(["reserved", "charged", "released", "migration_waived"]).nullable(),
|
|
latest_turn: conversationalRectificationTurnSchema,
|
|
message_history: z.array(conversationalRectificationMessageHistoryEntrySchema).max(200).optional(),
|
|
declared_birth_input: declaredBirthInputSchema.optional(),
|
|
private_candidate: privateCandidateSchema.optional(),
|
|
event_evidence: z.array(lifeEventEvidenceSchema).max(2_000).optional(),
|
|
validation_receipts: z.array(validationReceiptSchema).max(2_000).optional(),
|
|
}).strict(), 4_194_304);
|
|
|
|
const publicStoredCaseRowSchema = storedCaseRowSchema.refine(
|
|
(value) => value.declared_birth_input === undefined
|
|
&& value.private_candidate === undefined
|
|
&& value.event_evidence === undefined
|
|
&& value.validation_receipts === undefined,
|
|
"action receipt response cannot contain private state",
|
|
);
|
|
|
|
export const conversationalRectificationActionReceiptResponseSchema = z.union([
|
|
billingReceiptResponseSchema,
|
|
boundedJson(publicStoredCaseRowSchema, 69_632),
|
|
]);
|