packages feed

shikumi-0.3.0.0: src/Shikumi/Schema/Types.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE KindSignatures #-}

-- | Shared vocabulary for the schema engine: the per-field description wrapper
-- 'Field', the derived 'FieldMeta' record, JSON-Schema smart constructors (the
-- small subset OpenAI/Anthropic structured-output endpoints accept), and the
-- field-path breadcrumb used to locate decode errors.
--
-- This module is owned by EP-3 (integration point #3). The user-facing error
-- type is 'Shikumi.Error.ShikumiError' (owned by EP-1); decode errors render a
-- 'FieldPath' breadcrumb into that type's @Text@ payloads.
module Shikumi.Schema.Types
  ( -- * Field description wrapper
    Field (..),
    field,

    -- * Declarative field constraints (EP-26)
    Constraint (..),
    Constrained (..),
    constrained,

    -- * Derived field metadata
    FieldMeta (..),

    -- * Field-path breadcrumbs
    FieldPath,
    pushField,
    pushIndex,
    renderPath,

    -- * JSON-Schema smart constructors
    objectSchema,
    stringSchema,
    integerSchema,
    numberSchema,
    boolSchema,
    arraySchema,
    enumSchema,
    nullableSchema,
    withDescription,
    withMinLength,
    withMaxLength,
    withMinimum,
    withMaximum,
    withEnum,
  )
where

import Data.Aeson (Value (..), object, toJSON, (.=))
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KM
import Data.Scientific (Scientific)
import Data.Text (Text)
import Data.Text qualified as T
import GHC.Generics (Generic)
import GHC.TypeLits (Nat, Symbol)

-- | A field carrying a compile-time description as a type-level 'Symbol'. A
-- single 'GHC.Generics' traversal recovers both the field name (from the record
-- selector) and its description (from @desc@), so the two cannot drift. The
-- wrapper is opt-in per field: a bare @Text@ field simply has no description.
newtype Field (desc :: Symbol) a = Field {unField :: a}
  deriving stock (Eq, Show)

-- | Smart constructor mirroring 'unField'.
field :: a -> Field desc a
field = Field

-- | A type-level vocabulary of field constraints (EP-26). Each constructor names
-- one JSON-Schema-expressible rule. 'Nat' bounds are non-negative (fine for the
-- string-length rules); the signed/decimal numeric bounds 'MinVal'\/'MaxVal' carry
-- the bound as a 'Symbol' (e.g. @MinVal "0"@, @MaxVal "100"@, @MinVal "-3.5"@) so
-- both the schema emitter and the validator parse it to a 'Scientific'. Used
-- promoted, as the @cs@ of 'Constrained'.
data Constraint
  = -- | string minimum length -> @"minLength"@
    MinLen Nat
  | -- | string maximum length -> @"maxLength"@
    MaxLen Nat
  | -- | numeric lower bound -> @"minimum"@
    MinVal Symbol
  | -- | numeric upper bound -> @"maximum"@
    MaxVal Symbol
  | -- | allowed string values -> @"enum"@
    EnumOneOf [Symbol]

-- | A field value carrying a compile-time list of constraints (EP-26). Parallel to
-- 'Field'; the two compose: @Field "desc" (Constrained '[MinLen 10] Text)@. The
-- reflection of @cs@ to schema keywords and a post-decode validator lives in
-- "Shikumi.Schema" ('Shikumi.Schema.ReflectConstraints').
newtype Constrained (cs :: [Constraint]) a = Constrained {unConstrained :: a}
  deriving stock (Eq, Show)

-- | Smart constructor mirroring 'unConstrained'.
constrained :: a -> Constrained cs a
constrained = Constrained

-- | Per-field metadata recovered by the generic walk (used by 'Shikumi.Signature'
-- and the prompt adapters).
data FieldMeta = FieldMeta
  { fieldName :: !Text,
    fieldDesc :: !(Maybe Text)
  }
  deriving stock (Eq, Show, Generic)

-- | A breadcrumb trail to a location inside a decoded value, e.g.
-- @["bullets", "[2]"]@. Rendered into 'Shikumi.Error.ShikumiError' messages.
type FieldPath = [Text]

-- | Extend a path with a named record field.
pushField :: Text -> FieldPath -> FieldPath
pushField name p = p ++ [name]

-- | Extend a path with an array index.
pushIndex :: Int -> FieldPath -> FieldPath
pushIndex i p = p ++ ["[" <> T.pack (show i) <> "]"]

-- | Render a path to a single dotted breadcrumb (@"bullets.[2]"@). The empty path
-- renders as @"(root)"@ so a top-level error is still legible.
renderPath :: FieldPath -> Text
renderPath [] = "(root)"
renderPath p = T.intercalate "." p

-- | An object schema from @(name, schema)@ properties and a required-name list.
objectSchema :: [(Text, Value)] -> [Text] -> Value
objectSchema props required =
  object
    [ "type" .= ("object" :: Text),
      "properties" .= object [Key.fromText k .= v | (k, v) <- props],
      "required" .= required,
      "additionalProperties" .= False
    ]

-- | @{"type":"string"}@.
stringSchema :: Value
stringSchema = object ["type" .= ("string" :: Text)]

-- | @{"type":"integer"}@.
integerSchema :: Value
integerSchema = object ["type" .= ("integer" :: Text)]

-- | @{"type":"number"}@.
numberSchema :: Value
numberSchema = object ["type" .= ("number" :: Text)]

-- | @{"type":"boolean"}@.
boolSchema :: Value
boolSchema = object ["type" .= ("boolean" :: Text)]

-- | @{"type":"array","items":<items>}@.
arraySchema :: Value -> Value
arraySchema items = object ["type" .= ("array" :: Text), "items" .= items]

-- | @{"type":"string","enum":[...]}@ for an enum-like sum of nullary
-- constructors. OpenAI strict mode requires every schema node to carry an explicit
-- type; the enum values are the constructor names, so the type is @"string"@.
enumSchema :: [Text] -> Value
enumSchema names = object ["type" .= ("string" :: Text), "enum" .= names]

-- | Permit JSON @null@ in addition to the wrapped schema. The @anyOf@ form is
-- used (more portable across providers than @"type":["T","null"]@).
nullableSchema :: Value -> Value
nullableSchema s = object ["anyOf" .= [s, object ["type" .= ("null" :: Text)]]]

-- | Attach a @"description"@ key to a schema object (no-op on a non-object).
withDescription :: Text -> Value -> Value
withDescription desc (Object o) = Object (KM.insert "description" (String desc) o)
withDescription _ v = v

-- | Attach a @"minLength"@ keyword to a string schema object (no-op otherwise).
withMinLength :: Int -> Value -> Value
withMinLength n (Object o) = Object (KM.insert "minLength" (toJSON n) o)
withMinLength _ v = v

-- | Attach a @"maxLength"@ keyword to a string schema object (no-op otherwise).
withMaxLength :: Int -> Value -> Value
withMaxLength n (Object o) = Object (KM.insert "maxLength" (toJSON n) o)
withMaxLength _ v = v

-- | Attach a @"minimum"@ keyword to a numeric schema object (no-op otherwise).
withMinimum :: Scientific -> Value -> Value
withMinimum n (Object o) = Object (KM.insert "minimum" (Number n) o)
withMinimum _ v = v

-- | Attach a @"maximum"@ keyword to a numeric schema object (no-op otherwise).
withMaximum :: Scientific -> Value -> Value
withMaximum n (Object o) = Object (KM.insert "maximum" (Number n) o)
withMaximum _ v = v

-- | Attach an @"enum"@ keyword (the allowed string values) to a schema object
-- (no-op otherwise).
withEnum :: [Text] -> Value -> Value
withEnum names (Object o) = Object (KM.insert "enum" (toJSON names) o)
withEnum _ v = v