← back to Homesonspec

packages/schemas/src/index.ts

139 lines

import { z } from "zod";

/**
 * FieldValue — the per-field evidence envelope. Every extracted fact carries
 * its own provenance. `value: null` means the source did not state it;
 * values are NEVER inferred or guessed.
 */
export const fieldValueSchema = <T extends z.ZodTypeAny>(valueSchema: T) =>
  z.object({
    value: valueSchema.nullable(),
    raw: z.string().nullable(),
    evidenceText: z.string().nullable(),
    sourceUrl: z.string(),
    confidence: z.number().min(0).max(1),
  });

export type FieldValue<V> = {
  value: V | null;
  raw: string | null;
  evidenceText: string | null;
  sourceUrl: string;
  confidence: number;
};

export const entityTypeSchema = z.enum([
  "community",
  "floor_plan",
  "inventory_home",
  "incentive",
  "sales_office",
  "lot",
]);
export type ExtractedEntityType = z.infer<typeof entityTypeSchema>;

export const canonicalHintsSchema = z.object({
  builderSlug: z.string().min(1),
  communityName: z.string().nullish(),
  address: z.string().nullish(),
  lotNumber: z.string().nullish(),
  builderInventoryId: z.string().nullish(),
  lat: z.number().nullish(),
  lon: z.number().nullish(),
  planName: z.string().nullish(),
});

export const constructionStatusSchema = z.enum([
  "PLANNED",
  "UNDER_CONSTRUCTION",
  "MOVE_IN_READY",
]);

export const homeTypeSchema = z.enum([
  "SINGLE_FAMILY",
  "TOWNHOME",
  "CONDO",
  "DUPLEX",
  "OTHER",
]);

/** Payload shape for an extracted inventory home (staged, pre-publish). */
export const inventoryHomePayloadSchema = z.object({
  street: fieldValueSchema(z.string()),
  city: fieldValueSchema(z.string()),
  state: fieldValueSchema(z.string().length(2)),
  zip: fieldValueSchema(z.string().regex(/^\d{5}$/)),
  price: fieldValueSchema(z.number().positive()),
  beds: fieldValueSchema(z.number().int()),
  bathsTotal: fieldValueSchema(z.number()),
  sqft: fieldValueSchema(z.number().int()),
  stories: fieldValueSchema(z.number().int()),
  garageSpaces: fieldValueSchema(z.number().int()),
  homeType: fieldValueSchema(homeTypeSchema),
  constructionStatus: fieldValueSchema(constructionStatusSchema),
  estCompletionDate: fieldValueSchema(z.string()), // ISO date string
  lotNumber: fieldValueSchema(z.string()),
  builderInventoryId: fieldValueSchema(z.string()),
  lat: fieldValueSchema(z.number()).optional(), // not every builder feed carries per-home geo
  lon: fieldValueSchema(z.number()).optional(),
  planName: fieldValueSchema(z.string()),
  // Builder-supplied listing photos (largest srcset variant per asset). Optional
  // so adapters that don't yet capture media still validate.
  images: fieldValueSchema(z.array(z.string())).optional(),
});
export type InventoryHomePayload = z.infer<typeof inventoryHomePayloadSchema>;

/** Payload shape for an extracted community. */
export const communityPayloadSchema = z.object({
  name: fieldValueSchema(z.string()),
  street: fieldValueSchema(z.string()),
  city: fieldValueSchema(z.string()),
  state: fieldValueSchema(z.string().length(2)),
  zip: fieldValueSchema(z.string().regex(/^\d{5}$/)),
  county: fieldValueSchema(z.string()),
  metro: fieldValueSchema(z.string()),
  lat: fieldValueSchema(z.number()),
  lon: fieldValueSchema(z.number()),
  hoaFeeMonthly: fieldValueSchema(z.number()),
  schoolDistrict: fieldValueSchema(z.string()),
  ageRestricted: fieldValueSchema(z.boolean()),
  salesPhone: fieldValueSchema(z.string()).optional(),
});
export type CommunityPayload = z.infer<typeof communityPayloadSchema>;

/** Payload shape for an extracted incentive. */
export const incentivePayloadSchema = z.object({
  title: fieldValueSchema(z.string()),
  description: fieldValueSchema(z.string()),
  valueUsd: fieldValueSchema(z.number()),
  expiresAt: fieldValueSchema(z.string()), // ISO date; null → evergreenLabel required
});
export type IncentivePayload = z.infer<typeof incentivePayloadSchema>;

export const extractedRecordSchema = z.object({
  entityType: entityTypeSchema,
  canonicalHints: canonicalHintsSchema,
  fields: z.record(z.string(), fieldValueSchema(z.unknown())),
});
export type ExtractedRecord = z.infer<typeof extractedRecordSchema>;

const payloadSchemas: Partial<Record<ExtractedEntityType, z.ZodTypeAny>> = {
  inventory_home: inventoryHomePayloadSchema,
  community: communityPayloadSchema,
  incentive: incentivePayloadSchema,
};

/** Validate a record's fields against its entity-specific payload schema. */
export function validatePayload(record: ExtractedRecord):
  | { ok: true }
  | { ok: false; issues: string[] } {
  const schema = payloadSchemas[record.entityType];
  if (!schema) return { ok: true }; // entity types without a strict payload contract yet
  const parsed = schema.safeParse(record.fields);
  if (parsed.success) return { ok: true };
  return {
    ok: false,
    issues: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`),
  };
}