typesafe-hs-0.1.0.0: src/TypeSafe/Wire/Response.hs
-- | Lossless success-response codecs. Semantic validation belongs to callers.
module TypeSafe.Wire.Response
( SystemOneResponse (..),
Answer (..),
NoulAnswer (..),
ChoiceAnswer (..),
ScoreAnswer (..),
BoxAnswer (..),
BoxCoordinates (..),
Usage (..),
decodeSystemOneResponse,
finiteDouble,
unitInterval,
)
where
import Control.Monad (unless)
import Data.Aeson
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Aeson.Types (Parser)
import Data.ByteString.Lazy (ByteString)
import Data.Map.Strict (Map)
import Data.Scientific (Scientific, toBoundedRealFloat)
import Data.Text (Text)
import Data.Text qualified as Text
import GHC.Generics (Generic)
import TypeSafe.Wire.Common
import TypeSafe.Wire.Internal.Number
requireTag :: Text -> Object -> Parser ()
requireTag expected o = do
tag <- o .: "type"
unless (tag == expected) (fail "unexpected answer discriminator")
-- | A model's answers keyed by caller-chosen question names, with token usage.
data SystemOneResponse = SystemOneResponse
{ model :: !(ModelName),
answers :: !(Map QuestionName Answer),
usage :: !(Usage)
}
deriving stock (Generic, Eq, Show)
instance ToJSON SystemOneResponse where
toJSON x = object $ [] <> ["model" .= x.model] <> ["answers" .= x.answers] <> ["usage" .= x.usage]
toEncoding x = pairs $ "model" .= x.model <> "answers" .= x.answers <> "usage" .= x.usage
instance FromJSON SystemOneResponse where
parseJSON = withObject "SystemOneResponse" $ \o -> SystemOneResponse <$> o .: "model" <*> o .: "answers" <*> o .: "usage"
-- | Known variants and complete unknown objects for forward compatibility.
data Answer
= AnswerNoul !NoulAnswer
| AnswerChoice !ChoiceAnswer
| AnswerScore !ScoreAnswer
| AnswerBox !BoxAnswer
| AnswerOther !Text !Object
deriving stock (Generic, Eq, Show)
instance ToJSON Answer where
toJSON (AnswerNoul a) = toJSON a
toJSON (AnswerChoice a) = toJSON a
toJSON (AnswerScore a) = toJSON a
toJSON (AnswerBox a) = toJSON a
toJSON (AnswerOther tag o) = Object (KeyMap.insert "type" (String tag) o)
toEncoding (AnswerNoul a) = toEncoding a
toEncoding (AnswerChoice a) = toEncoding a
toEncoding (AnswerScore a) = toEncoding a
toEncoding (AnswerBox a) = toEncoding a
toEncoding (AnswerOther tag o) = toEncoding (KeyMap.insert "type" (String tag) o)
instance FromJSON Answer where
parseJSON v =
withObject
"Answer"
( \o -> do
tag <- o .: "type"
case tag of
"noul" -> AnswerNoul <$> parseJSON v
"choice" -> AnswerChoice <$> parseJSON v
"score" -> AnswerScore <$> parseJSON v
"box" -> AnswerBox <$> parseJSON v
_ -> pure (AnswerOther tag o)
)
v
-- | The probability of yes or true, preserved without range checks.
data NoulAnswer = NoulAnswer
{ noul :: !(Scientific)
}
deriving stock (Generic, Eq, Show)
instance ToJSON NoulAnswer where
toJSON x = object $ ["type" .= ("noul" :: Text)] <> ["noul" .= x.noul]
toEncoding x = pairs $ "type" .= ("noul" :: Text) <> "noul" .= x.noul
instance FromJSON NoulAnswer where
parseJSON = withObject "NoulAnswer" $ \o -> requireTag "noul" o *> (NoulAnswer <$> o .: "noul")
-- | The selected label, its confidence and the complete label-probability map.
data ChoiceAnswer = ChoiceAnswer
{ choice :: !(Text),
confidence :: !(Scientific),
probabilities :: !(Map Text Scientific)
}
deriving stock (Generic, Eq, Show)
instance ToJSON ChoiceAnswer where
toJSON x = object $ ["type" .= ("choice" :: Text)] <> ["choice" .= x.choice] <> ["confidence" .= x.confidence] <> ["probabilities" .= x.probabilities]
toEncoding x = pairs $ "type" .= ("choice" :: Text) <> "choice" .= x.choice <> "confidence" .= x.confidence <> "probabilities" .= x.probabilities
instance FromJSON ChoiceAnswer where
parseJSON = withObject "ChoiceAnswer" $ \o -> requireTag "choice" o *> (ChoiceAnswer <$> o .: "choice" <*> o .: "confidence" <*> o .: "probabilities")
-- | An expected rating, its confidence and the verbatim level legend and probabilities.
data ScoreAnswer = ScoreAnswer
{ score :: !(Scientific),
confidence :: !(Scientific),
legend :: !(Map Text Content),
probabilities :: !(Map Text Scientific)
}
deriving stock (Generic, Eq, Show)
instance ToJSON ScoreAnswer where
toJSON x = object $ ["type" .= ("score" :: Text)] <> ["score" .= x.score] <> ["confidence" .= x.confidence] <> ["legend" .= x.legend] <> ["probabilities" .= x.probabilities]
toEncoding x = pairs $ "type" .= ("score" :: Text) <> "score" .= x.score <> "confidence" .= x.confidence <> "legend" .= x.legend <> "probabilities" .= x.probabilities
instance FromJSON ScoreAnswer where
parseJSON = withObject "ScoreAnswer" $ \o -> requireTag "score" o *> (ScoreAnswer <$> o .: "score" <*> o .: "confidence" <*> o .: "legend" <*> o .: "probabilities")
-- | An image box with required confidence that may explicitly be null on the wire.
data BoxAnswer = BoxAnswer
{ box :: !(BoxCoordinates),
confidence :: !(Maybe Scientific)
}
deriving stock (Generic, Eq, Show)
instance ToJSON BoxAnswer where
toJSON x = object $ ["type" .= ("box" :: Text)] <> ["box" .= x.box] <> ["confidence" .= x.confidence]
toEncoding x = pairs $ "type" .= ("box" :: Text) <> "box" .= x.box <> "confidence" .= x.confidence
instance FromJSON BoxAnswer where
parseJSON = withObject "BoxAnswer" $ \o -> requireTag "box" o *> (BoxAnswer <$> o .: "box" <*> o .: "confidence")
-- | Image-edge coordinates as fractions of height and width, preserved without clamping.
data BoxCoordinates = BoxCoordinates
{ top :: !(Scientific),
bottom :: !(Scientific),
left :: !(Scientific),
right :: !(Scientific)
}
deriving stock (Generic, Eq, Show)
instance ToJSON BoxCoordinates where
toJSON x = object $ [] <> ["top" .= x.top] <> ["bottom" .= x.bottom] <> ["left" .= x.left] <> ["right" .= x.right]
toEncoding x = pairs $ "top" .= x.top <> "bottom" .= x.bottom <> "left" .= x.left <> "right" .= x.right
instance FromJSON BoxCoordinates where
parseJSON = withObject "BoxCoordinates" $ \o -> BoxCoordinates <$> o .: "top" <*> o .: "bottom" <*> o .: "left" <*> o .: "right"
-- | Integral token counts; decoding rejects fractions and values outside Int.
data Usage = Usage
{ inputTokens :: !(Int),
outputTokens :: !(Int)
}
deriving stock (Generic, Eq, Show)
instance ToJSON Usage where
toJSON x = object $ [] <> ["input_tokens" .= x.inputTokens] <> ["output_tokens" .= x.outputTokens]
toEncoding x = pairs $ "input_tokens" .= x.inputTokens <> "output_tokens" .= x.outputTokens
instance FromJSON Usage where
parseJSON = withObject "Usage" $ \o -> Usage <$> (o .: "input_tokens" >>= parseBoundedIntegral) <*> (o .: "output_tokens" >>= parseBoundedIntegral)
-- | Decode without converting or validating probabilities (R4, R5).
decodeSystemOneResponse :: ByteString -> Either Text SystemOneResponse
decodeSystemOneResponse = either (Left . Text.pack) Right . eitherDecode
-- | Convert to Double only if representable, rejecting overflow and underflow.
finiteDouble :: Scientific -> Either Text Double
finiteDouble n = case toBoundedRealFloat n of
Left _ -> Left "number outside the representable Double range"
Right d
| isInfinite d || isNaN d -> Left "number is not finite"
| otherwise -> Right d
-- | Consumer helper; wire decoders never call this range check.
unitInterval :: Scientific -> Either Text Double
unitInterval n
| n < 0 || n > 1 = Left "number outside [0, 1]"
| otherwise = finiteDouble n