packages feed

agentic-aeson-0.2.0.0: src/Agentic/JsonSchema.hs

-- | Lowering the core's t'Schema' to the strict JSON Schema that providers'
-- structured outputs and strict tools accept.
--
-- The result is the core's 'Value', not aeson's, because order matters: a
-- model writes an object's fields in the order its schema lists them, and a
-- record's field order is part of its meaning (a setup comes before its
-- punchline). aeson would sort the keys. Render with 'renderJson'.
module Agentic.JsonSchema
  ( jsonSchema
  , objectSchema
  , schemaName
  , wrap
  , unwrap
  ) where

import Agentic.Schema
import Agentic.Value
import Data.Char (isAlphaNum)
import Data.List (nub)
import Data.Maybe (catMaybes)
import Data.Text (Text)
import qualified Data.Text as T

-- | A strict JSON Schema: every object lists all its fields as required and
-- forbids others; nullable fields may be null. Types are written out in full
-- wherever they appear; 'objectSchema' shares repeated ones.
jsonSchema :: Schema -> Value
jsonSchema = schemaWith []

-- | Like 'jsonSchema', but any schema named in @shared@ becomes a @$ref@ into
-- @$defs@.
schemaWith :: [Text] -> Schema -> Value
schemaWith shared s = case title s of
  Just t | t `elem` shared -> Object [("$ref", String ("#/$defs/" <> t))]
  _ -> definition shared s

-- | A schema written out, though the schemas inside it may still be references.
definition :: [Text] -> Schema -> Value
definition shared s = withDescription (body (shape s))
  where
    sub = schemaWith shared
    withDescription = \case
      Object kvs | Just d <- description -> Object (kvs <> [("description", String d)])
      v -> v
    description = case catMaybes [doc s] <> map (\c -> "Must be " <> c <> ".") (checks s) of
      [] -> Nothing
      ds -> Just (T.intercalate " " ds)
    body = \case
      SObject fs -> object (map (\f -> (fieldName f, sub (fieldSchema f))) fs)
      SSum vs -> Object [("anyOf", Array (map variant vs))]
      SEnum ls
        | all ((== Nothing) . snd) ls -> Object [typed "string", ("enum", Array (map (String . fst) ls))]
        | otherwise -> Object [("anyOf", Array [constant l d | (l, d) <- ls])]
      SArray inner -> Object [typed "array", ("items", sub inner)]
      SNullable inner -> Object [("anyOf", Array [sub inner, Object [typed "null"]])]
      SString format -> Object (typed "string" : maybe [] (\f -> [("format", String (formatName f))]) format)
      SInteger -> Object [typed "integer"]
      SNumber -> Object [typed "number"]
      SBool -> Object [typed "boolean"]
      SNull -> Object [typed "null"]
    variant v =
      let tagged = ("tag", Object [typed "string", ("const", String (variantTag v))])
          o = object (tagged : map (\f -> (fieldName f, sub (fieldSchema f))) (variantFields v))
       in case (o, variantDoc v) of
            (Object kvs, Just d) -> Object (kvs <> [("description", String d)])
            _ -> o
    constant l d = Object ([typed "string", ("const", String l)] <> maybe [] (\t -> [("description", String t)]) d)
    typed :: Text -> (Text, Value)
    typed t = ("type", String t)

object :: [(Text, Value)] -> Value
object fields =
  Object
    [ ("type", String "object")
    , ("properties", Object fields)
    , ("required", Array (map (String . fst) fields))
    , ("additionalProperties", Bool False)
    ]

formatName :: Format -> Text
formatName = \case
  DateTime -> "date-time"
  Date -> "date"
  Email -> "email"
  Uri -> "uri"
  Uuid -> "uuid"

-- | Does this schema need wrapping to be a top-level object?
wrap :: Schema -> Bool
wrap s = case shape s of
  SObject _ -> False
  _ -> True

-- | A top-level object schema: the schema itself if it's an object, or an
-- object with a single @value@ field holding it. A named type that appears more
-- than once, identically, is written once under @$defs@ and referenced.
--
-- TBD: Recursively defined schemas will hang here. A recursive type's derived
-- schema contains itself, so walking it never ends.
objectSchema :: Schema -> Value
objectSchema s = case root of
  Object kvs | not (null defs) -> Object (kvs <> [("$defs", Object defs)])
  other -> other
  where
    root
      | wrap s = object [("value", schemaWith shared s)]
      | otherwise = definition shared s
    shared = sharedNames s
    defs = [(t, definition shared d) | t <- shared, Just d <- [lookup t named']]
    named' = [(t, d) | d <- nested s, Just t <- [title d]]

-- | Names of the types inside a schema (not the schema itself) that appear more
-- than once and are identical everywhere they appear, in order of appearance.
sharedNames :: Schema -> [Text]
sharedNames s = [t | t <- nub names, uses t >= 2, length (nub [d | d <- inside, title d == Just t]) == 1]
  where
    inside = nested s
    names = catMaybes (map title inside)
    uses t = length (filter ((== Just t) . title) inside)

-- | Every schema nested inside this one, outermost first.
nested :: Schema -> [Schema]
nested s = concatMap (\c -> c : nested c) (children (shape s))
  where
    children = \case
      SObject fs -> map fieldSchema fs
      SSum vs -> concatMap (map fieldSchema . variantFields) vs
      SArray c -> [c]
      SNullable c -> [c]
      _ -> []

-- | A name for the schema, for providers that ask for one: the type's name with
-- anything but letters, digits, @_@ and @-@ dropped, or @output@.
schemaName :: Schema -> Text
schemaName s = case T.intercalate "_" (filter (not . T.null) (T.split (not . valid) (maybe "" id (title s)))) of
  "" -> "output"
  n -> T.take 64 n
  where
    valid c = isAlphaNum c || c == '_' || c == '-'

-- | Undo the wrapping 'objectSchema' adds, on a value from the provider.
unwrap :: Schema -> Value -> Value
unwrap s v
  | wrap s, Object kvs <- v, Just inner <- lookup "value" kvs = inner
  | otherwise = v