shikumi-0.5.0.0: src/Shikumi/Decision.hs
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DefaultSignatures #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE UndecidableInstances #-}
-- | Closed decisions and their probability evidence. Answers are derived
-- locally; schemas describe judgments, while JSON decoding consumes evidence.
-- No provider transport or provider-specific response type lives here.
module Shikumi.Decision
( NoulWith (..),
Noul,
Choice (..),
Score (..),
NoulEvidence (..),
ChoiceEvidence (..),
ScoreEvidence (..),
FieldEvidence (..),
ChoiceOptions (..),
IsDecision (..),
DecisionOutputs,
judgmentsOf,
Judgment (..),
JudgmentKind (..),
judgmentsInSchema,
questionJSON,
renderDecisionState,
deriveNoul,
deriveScore,
deriveChoice,
)
where
import Control.Applicative ((<|>))
import Data.Aeson (FromJSON (..), ToJSON (..), Value (..), object, withObject, (.:), (.:?), (.=))
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KM
import Data.Aeson.Types (Pair, Parser, parseEither)
import Data.Bifunctor (first)
import Data.Kind (Constraint, Type)
import Data.List (sort)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import Data.Text qualified as T
import Data.Vector (Vector)
import Data.Vector qualified as V
import GHC.Generics
import GHC.TypeLits (ErrorMessage (..), KnownSymbol, Nat, Symbol, TypeError, symbolVal, type (+))
import Shikumi.Adapter.Prompt (PromptValue (..), ToPrompt (..))
import Shikumi.Error (ShikumiError (..))
import Shikumi.Schema (FromModel (..), ToSchema (..), Validatable)
import Shikumi.Schema.Types (Field, FieldMeta (..), FieldPath, renderPath)
import Shikumi.Signature (Demo (..), Signature, getDemos, getInstruction, inputFields)
-- Evidence JSON is shared by provider-backed and generative decisions.
newtype NoulEvidence = NoulEvidence {pTrue :: Double}
deriving stock (Eq, Show, Generic)
data ChoiceEvidence = ChoiceEvidence
{probabilities :: !(Map Text Double), confidence :: !(Maybe Double)}
deriving stock (Eq, Show, Generic)
data ScoreEvidence = ScoreEvidence
{levelProbabilities :: !(Vector Double), confidence :: !(Maybe Double)}
deriving stock (Eq, Show, Generic)
data FieldEvidence = FieldNoul !NoulEvidence | FieldChoice !ChoiceEvidence | FieldScore !ScoreEvidence
deriving stock (Eq, Show, Generic)
instance ToJSON NoulEvidence where
toJSON (NoulEvidence p) = object ["noul" .= p]
instance FromJSON NoulEvidence where
parseJSON = withObject "NoulEvidence" $ \o -> NoulEvidence <$> (o .: "noul" >>= probabilityP)
instance ToJSON ChoiceEvidence where
toJSON (ChoiceEvidence ps c) = object ["probabilities" .= ps, "confidence" .= c]
instance FromJSON ChoiceEvidence where
parseJSON = withObject "ChoiceEvidence" $ \o -> do
ps <- o .: "probabilities" >>= traverse probabilityP
if Map.null ps then fail "empty probability distribution" else pure ()
c <- o .:? "confidence" >>= traverse probabilityP
pure (ChoiceEvidence ps c)
instance ToJSON ScoreEvidence where
toJSON (ScoreEvidence ps c) = object ["probabilities" .= indexedProbabilities ps, "confidence" .= c]
instance FromJSON ScoreEvidence where
parseJSON = withObject "ScoreEvidence" $ \o -> do
ps <- o .: "probabilities" >>= traverse probabilityP
let n = Map.size ps
keys = map (T.pack . show) [0 .. n - 1]
if n < 2 || n > 10 || sort keys /= Map.keys ps
then fail "score probabilities must have contiguous indices 0..N-1, with 2 to 10 levels"
else pure ()
c <- o .:? "confidence" >>= traverse probabilityP
pure (ScoreEvidence (V.fromList [ps Map.! k | k <- keys]) c)
-- A wrapper needs a tag because numeric Choice labels and Score indices overlap.
instance ToJSON FieldEvidence where
toJSON e = case e of
FieldNoul x -> tagged "noul" (toJSON x)
FieldChoice x -> tagged "choice" (toJSON x)
FieldScore x -> tagged "score" (toJSON x)
where
tagged :: Text -> Value -> Value
tagged k v = object ["kind" .= k, "evidence" .= v]
instance FromJSON FieldEvidence where
parseJSON = withObject "FieldEvidence" $ \o -> do
kind <- o .: "kind" :: Parser Text
case kind of
"noul" -> FieldNoul <$> o .: "evidence"
"choice" -> FieldChoice <$> o .: "evidence"
"score" -> FieldScore <$> o .: "evidence"
_ -> fail "unknown decision evidence kind"
probabilityP :: Double -> Parser Double
probabilityP p
| finite p && p >= 0 && p <= 1 = pure p
| otherwise = fail "probability or confidence must be finite and in [0,1]"
finite :: Double -> Bool
finite p = not (isNaN p || isInfinite p)
indexedProbabilities :: Vector Double -> Map Text Double
indexedProbabilities ps = Map.fromList [(T.pack (show i), p) | (i, p) <- zip [0 :: Int ..] (V.toList ps)]
data NoulWith (t :: Symbol) (f :: Symbol) = Noul
{value :: !Bool, probability :: !Double, confidence :: !Double}
deriving stock (Eq, Show, Generic)
type Noul = NoulWith "" ""
data Choice a = Choice
{value :: !a, probabilities :: !(Map Text Double), confidence :: !(Maybe Double)}
deriving stock (Eq, Show, Generic)
-- The constructor carries the level-count constraint as well as the schema
-- instance, so an invalid Score cannot be constructed outside schema derivation.
data Score (levels :: [Symbol]) where
Score ::
(CheckLevels (Length levels)) =>
{value :: !Double, level :: !Int, levelProbabilities :: !(Vector Double), confidence :: !(Maybe Double)} ->
Score levels
deriving stock instance Eq (Score levels)
deriving stock instance Show (Score levels)
-- | Labels in declaration order, with optional criteria. Override the generic
-- nullary-enum default to use domain labels and descriptions.
class (Eq a) => ChoiceOptions a where
choiceOptions :: [(Text, a, Maybe Text)]
default choiceOptions :: (Generic a, GChoiceOptions (Rep a)) => [(Text, a, Maybe Text)]
choiceOptions = [(n, to v, Nothing) | (n, v) <- gChoiceOptions]
class GChoiceOptions (f :: Type -> Type) where
gChoiceOptions :: [(Text, f p)]
instance (GChoiceOptions f) => GChoiceOptions (D1 d f) where
gChoiceOptions = [(n, M1 v) | (n, v) <- gChoiceOptions @f]
instance (GChoiceOptions a, GChoiceOptions b) => GChoiceOptions (a :+: b) where
gChoiceOptions = [(n, L1 v) | (n, v) <- gChoiceOptions @a] ++ [(n, R1 v) | (n, v) <- gChoiceOptions @b]
instance (Constructor c) => GChoiceOptions (C1 c U1) where
gChoiceOptions = [(T.pack (conName (undefined :: C1 c U1 ())), M1 U1)]
class KnownLevels (ss :: [Symbol]) where
levelNames :: Proxy ss -> [Text]
instance KnownLevels '[] where levelNames _ = []
instance (KnownSymbol s, KnownLevels ss) => KnownLevels (s ': ss) where
levelNames _ = T.pack (symbolVal (Proxy @s)) : levelNames (Proxy @ss)
type family Length (ss :: [Symbol]) :: Nat where
Length '[] = 0
Length (s ': ss) = 1 + Length ss
type family CheckLevels (n :: Nat) :: Constraint where
CheckLevels 0 = TypeError ('Text "Score requires 2 to 10 levels")
CheckLevels 1 = TypeError ('Text "Score requires 2 to 10 levels")
CheckLevels 2 = ()
CheckLevels 3 = ()
CheckLevels 4 = ()
CheckLevels 5 = ()
CheckLevels 6 = ()
CheckLevels 7 = ()
CheckLevels 8 = ()
CheckLevels 9 = ()
CheckLevels 10 = ()
CheckLevels n = TypeError ('Text "Score requires 2 to 10 levels; got " ':<>: 'ShowType n)
-- | Schema-independent metadata, also consumed by the generative evidence path.
data JudgmentKind
= JudgeBoolean !(Maybe Value) !(Maybe Value)
| JudgeChoice ![(Text, Maybe Value)]
| JudgeOrdered ![Value]
deriving stock (Eq, Show, Generic)
data Judgment = Judgment
{judgmentField :: !Text, judgmentInstruction :: !(Maybe Text), judgmentKind :: !JudgmentKind}
deriving stock (Eq, Show, Generic)
class IsDecision a where
decisionKind :: Proxy a -> JudgmentKind
decisionDescription :: Proxy a -> Maybe Text
decisionDescription _ = Nothing
instance (KnownSymbol t, KnownSymbol f) => IsDecision (NoulWith t f) where
decisionKind _ = JudgeBoolean (criterion @t) (criterion @f)
instance IsDecision Bool where decisionKind _ = JudgeBoolean Nothing Nothing
instance (ChoiceOptions a) => IsDecision (Choice a) where
decisionKind _ = JudgeChoice [(n, String <$> d) | (n, _, d) <- choiceOptions @a]
instance (KnownLevels ss, CheckLevels (Length ss)) => IsDecision (Score ss) where
decisionKind _ = JudgeOrdered (map String (levelNames (Proxy @ss)))
instance (KnownSymbol d, IsDecision a) => IsDecision (Field d a) where
decisionKind _ = decisionKind (Proxy @a)
decisionDescription _ = Just (T.pack (symbolVal (Proxy @d)))
criterion :: forall s. (KnownSymbol s) => Maybe Value
criterion = case T.pack (symbolVal (Proxy @s)) of
"" -> Nothing
t -> Just (String t)
-- | Compile-time guarantee that every output is a supported decision leaf.
class DecisionOutputs o where
decisionJudgments :: Proxy o -> [Judgment]
default decisionJudgments :: (GDecisionOutputs (Rep o)) => Proxy o -> [Judgment]
decisionJudgments _ = gDecisionJudgments (Proxy @(Rep o))
judgmentsOf :: forall o. (DecisionOutputs o) => [Judgment]
judgmentsOf = decisionJudgments (Proxy @o)
type family RequireDecision (name :: Symbol) a :: Constraint where
RequireDecision name (Field d a) = RequireDecision name a
RequireDecision name (NoulWith t f) = ()
RequireDecision name (Choice a) = ()
RequireDecision name (Score ss) = ()
RequireDecision name Bool = ()
RequireDecision name a = TypeError ('Text "Decision output field `" ':<>: 'Text name ':<>: 'Text "` has unsupported type " ':<>: 'ShowType a)
class GDecisionOutputs (f :: Type -> Type) where
gDecisionJudgments :: Proxy f -> [Judgment]
instance (GDecisionOutputs f) => GDecisionOutputs (D1 d f) where
gDecisionJudgments _ = gDecisionJudgments (Proxy @f)
instance (GDecisionOutputs f) => GDecisionOutputs (C1 c f) where
gDecisionJudgments _ = gDecisionJudgments (Proxy @f)
instance (GDecisionOutputs a, GDecisionOutputs b) => GDecisionOutputs (a :*: b) where
gDecisionJudgments _ = gDecisionJudgments (Proxy @a) ++ gDecisionJudgments (Proxy @b)
instance (KnownSymbol name, RequireDecision name a, IsDecision a) => GDecisionOutputs (S1 ('MetaSel ('Just name) su ss ds) (K1 R a)) where
gDecisionJudgments _ = [Judgment (T.pack (symbolVal (Proxy @name))) (decisionDescription (Proxy @a)) (decisionKind (Proxy @a))]
instance (KnownSymbol t, KnownSymbol f) => ToSchema (NoulWith t f) where
toSchema _ = case decisionKind (Proxy @(NoulWith t f)) of
JudgeBoolean Nothing Nothing -> marked "noul" ["type" .= ("boolean" :: Text)]
JudgeBoolean t f -> marked "noul" ["type" .= ("boolean" :: Text), "anyOf" .= [branch (Bool True) t, branch (Bool False) f]]
_ -> marked "noul" []
instance (ChoiceOptions a) => ToSchema (Choice a) where
toSchema _ = marked "choice" ["type" .= ("string" :: Text), "anyOf" .= [branch (String n) (String <$> d) | (n, _, d) <- choiceOptions @a]]
instance (KnownLevels ss, CheckLevels (Length ss)) => ToSchema (Score ss) where
toSchema _ = marked "score" ["type" .= ("integer" :: Text), "anyOf" .= [branch (toJSON i) (Just (String d)) | (i, d) <- zip [0 :: Int ..] (levelNames (Proxy @ss))]]
marked :: Text -> [Pair] -> Value
marked kind fields = object (fields ++ ["x-shikumi-decision" .= object ["kind" .= kind]])
branch :: Value -> Maybe Value -> Value
branch c d = object ["const" .= c, "description" .= d]
-- | Read only the declared judgment grammar. Unsupported or malformed fields
-- are returned separately for a pre-dispatch refusal. Property order follows a
-- complete, unique required array, otherwise ascending property name.
judgmentsInSchema :: Value -> ([Judgment], [Text])
judgmentsInSchema (Object schema)
| Just (Object props) <- KM.lookup "properties" schema =
foldMap (readField props) (propertyOrder schema props)
where
readField props name = case KM.lookup (Key.fromText name) props >>= readJudgment name of
Just j -> ([j], [])
Nothing -> ([], [name])
judgmentsInSchema _ = ([], [])
propertyOrder :: KM.KeyMap Value -> KM.KeyMap Value -> [Text]
propertyOrder schema props =
let names = map (Key.toText . fst) (KM.toAscList props)
in case KM.lookup "required" schema of
Just (Array xs)
| Just required <- traverse textOf (V.toList xs), sort required == names -> required
_ -> names
readJudgment :: Text -> Value -> Maybe Judgment
readJudgment name (Object prop) = do
-- Ignoring an unfamiliar constraint would widen the caller's declared domain.
if all (`elem` ["type", "description", "title", "x-shikumi-decision", "anyOf", "oneOf", "enum"]) (KM.keys prop)
then pure ()
else Nothing
instruction <- case KM.lookup "description" prop of
Nothing -> Just Nothing
Just Null -> Just Nothing
Just (String t) -> Just (Just t)
_ -> Nothing
kind <- booleanKind prop <|> choiceKind prop <|> scoreKind prop
pure (Judgment name instruction kind)
readJudgment _ _ = Nothing
booleanKind :: KM.KeyMap Value -> Maybe JudgmentKind
booleanKind p
| KM.member "enum" p = Nothing
| KM.lookup "type" p == Just (String "boolean"), not (KM.member "anyOf" p || KM.member "oneOf" p) = Just (JudgeBoolean Nothing Nothing)
| allowedType "boolean" p = do
if KM.member "anyOf" p && KM.member "oneOf" p then Nothing else pure ()
Array xs <- KM.lookup "anyOf" p <|> KM.lookup "oneOf" p
pairs <- traverse readBranch (V.toList xs)
if sort (map fst pairs) == [Bool False, Bool True]
then Just (JudgeBoolean (lookup (Bool True) pairs >>= id) (lookup (Bool False) pairs >>= id))
else Nothing
| otherwise = Nothing
choiceKind :: KM.KeyMap Value -> Maybe JudgmentKind
choiceKind p = do
if allowedType "string" p && not (KM.member "oneOf" p) then pure () else Nothing
labels <- case (KM.lookup "enum" p, KM.lookup "anyOf" p) of
(Just (Array xs), Nothing) -> traverse (\v -> (,Nothing) <$> textOf v) (V.toList xs)
(Nothing, Just (Array xs)) -> traverse (\v -> do (c, d) <- readBranch v; n <- textOf c; pure (n, d)) (V.toList xs)
_ -> Nothing
let names = map fst labels
if null names || length names > 255 || Map.size (Map.fromList labels) /= length names
then Nothing
else Just (JudgeChoice labels)
scoreKind :: KM.KeyMap Value -> Maybe JudgmentKind
scoreKind p = do
if allowedType "integer" p && not (KM.member "enum" p || KM.member "oneOf" p) then pure () else Nothing
Array xs <- KM.lookup "anyOf" p
pairs <- traverse readBranch (V.toList xs)
let n = length pairs
if n >= 2 && n <= 10 && map fst pairs == map toJSON [0 :: Int .. n - 1]
then Just (JudgeOrdered (map (fromMaybe Null . snd) pairs))
else Nothing
allowedType :: Text -> KM.KeyMap Value -> Bool
allowedType t p = maybe True (== String t) (KM.lookup "type" p)
readBranch :: Value -> Maybe (Value, Maybe Value)
readBranch (Object o)
| all (`elem` ["const", "description"]) (KM.keys o) = do
c <- KM.lookup "const" o
pure (c, case KM.lookup "description" o of Just Null -> Nothing; d -> d)
readBranch _ = Nothing
textOf :: Value -> Maybe Text
textOf (String t) = Just t
textOf _ = Nothing
-- | Render a provider-neutral judgment question; no private marker is needed.
questionJSON :: Judgment -> Value
questionJSON (Judgment name instruction kind) =
object
(["type" .= kindName, "instructions" .= fromMaybe name instruction] ++ criteria)
where
(kindName, criteria) = case kind of
JudgeBoolean Nothing Nothing -> ("noul" :: Text, [])
JudgeBoolean t f -> ("noul", ["criteria" .= object ["true" .= t, "false" .= f]])
JudgeChoice xs -> ("choice", ["criteria" .= object [Key.fromText n .= d | (n, d) <- xs]])
JudgeOrdered ds -> ("score", ["criteria" .= ds])
-- | A single JSON state, with flat input/output demos. Demo answers use their
-- derived values through 'PromptValue', rather than evidence or Haskell Show.
renderDecisionState :: (ToPrompt i, ToPrompt o) => Signature i o -> i -> Value
renderDecisionState sig i =
object
( [ "instructions" .= getInstruction sig,
"input_fields" .= T.intercalate "\n" (zipWith describe [1 :: Int ..] (inputFields sig)),
"inputs" .= toPromptJSON i
]
++ ["demos" .= map demo (getDemos sig) | not (null (getDemos sig))]
)
where
inputObject = case toPromptJSON i of Object o -> o; _ -> KM.empty
describe n (FieldMeta name description) =
T.pack (show n) <> ". `" <> name <> "` (" <> jsonType (KM.lookup (Key.fromText name) inputObject) <> "): " <> fromMaybe "" description
demo (Demo di o) = case (toPromptJSON di, toPromptJSON o) of
(Object ins, Object outs) -> Object (KM.union outs ins)
_ -> object []
jsonType = \case
Just (String _) -> "str"
Just (Bool _) -> "bool"
Just (Number _) -> "number"
Just (Array _) -> "list"
Just (Object _) -> "dict"
_ -> "null"
-- Pure derivation accepts explicit settings; defaults are selected by decoding.
deriveNoul :: Double -> NoulEvidence -> (Bool, Double)
deriveNoul threshold (NoulEvidence p) = (p >= threshold, if denominator == 0 then 0 else abs (p - threshold) / denominator)
where
denominator = max threshold (1 - threshold)
deriveScore :: [Double] -> ScoreEvidence -> Either Text (Double, Int)
deriveScore cuts (ScoreEvidence ps _) = do
validateMass (V.toList ps)
let v = sum [fromIntegral n * p | (n, p) <- zip [0 :: Int ..] (V.toList ps)] / V.sum ps
pure (v, length (filter (<= v) cuts))
-- | Select from declared labels in order; ties prefer the raw winner, then
-- declaration order. A probability map alone cannot retain that order.
deriveChoice :: [Text] -> Map Text Double -> ChoiceEvidence -> Either Text Text
deriveChoice labels weights (ChoiceEvidence ps _) = do
if sort labels == Map.keys ps then pure () else Left "probability keys must match declared choice labels exactly"
validateMass (Map.elems ps)
let scored = [(label, ps Map.! label * Map.findWithDefault 1 label weights) | label <- labels]
if any (\(_, p) -> not (finite p) || p < 0) scored
then Left "weighted probabilities must be finite and non-negative"
else if all ((== 0) . snd) scored then Left "no remaining probability mass" else pure ()
let winner pairs = case pairs of
[] -> Left "empty choice"
x : xs -> Right (fst (foldl (\best next -> if snd next > snd best then next else best) x xs))
raw <- winner [(label, ps Map.! label) | label <- labels]
weighted <- winner scored
pure (if lookup raw scored == lookup weighted scored then raw else weighted)
validateMass :: [Double] -> Either Text ()
validateMass ps
| any (\p -> not (finite p) || p < 0 || p > 1) ps = Left "probabilities must be finite and in [0,1]"
| sum ps <= 0 = Left "no remaining probability mass"
| otherwise = Right ()
decodeEvidence :: (FromJSON a) => FieldPath -> Value -> Either ShikumiError a
decodeEvidence path = first (SchemaMismatch . ((renderPath path <> ": ") <>) . T.pack) . parseEither parseJSON
-- Internal normalized decode values, supplied after local derivation. These
-- annotations are never accepted from a provider evidence object.
derivedAt :: (FromJSON a) => FieldPath -> Value -> a -> Either ShikumiError a
derivedAt path (Object o) fallback = maybe (Right fallback) (decodeEvidence path) (KM.lookup "x-shikumi-derived" o)
derivedAt _ _ fallback = Right fallback
instance FromModel (NoulWith t f) where
fromModelP path v = do
e@(NoulEvidence p) <- decodeEvidence path v
let (answer, c) = deriveNoul 0.5 e
(answer', c') <- derivedAt path v (answer, c)
pure (Noul answer' p c')
instance (ChoiceOptions a) => FromModel (Choice a) where
fromModelP path v = do
e@(ChoiceEvidence ps c) <- decodeEvidence path v
label <- first (SchemaMismatch . ((renderPath path <> ": ") <>)) (deriveChoice [n | (n, _, _) <- choiceOptions @a] Map.empty e)
label' <- derivedAt path v label
case [a | (n, a, _) <- choiceOptions @a, n == label'] of
a : _ -> pure (Choice a ps c)
[] -> Left (SchemaMismatch (renderPath path <> ": undeclared choice label"))
instance (KnownLevels ss, CheckLevels (Length ss)) => FromModel (Score ss) where
fromModelP path v = do
e@(ScoreEvidence ps c) <- decodeEvidence path v
let n = length (levelNames (Proxy @ss))
if V.length ps == n then pure () else Left (SchemaMismatch (renderPath path <> ": score level count does not match declaration"))
(answer, l) <- first (SchemaMismatch . ((renderPath path <> ": ") <>)) (deriveScore [fromIntegral k + 0.5 | k <- [0 .. n - 2]] e)
(answer', l') <- derivedAt path v (answer, l)
pure (Score answer' l' ps c)
instance ToJSON (NoulWith t f) where toJSON (Noul _ p _) = toJSON (NoulEvidence p)
instance ToJSON (Choice a) where toJSON (Choice _ ps c) = toJSON (ChoiceEvidence ps c)
instance ToJSON (Score ss) where toJSON (Score _ _ ps c) = toJSON (ScoreEvidence ps c)
instance Validatable (NoulWith t f)
instance Validatable (Choice a)
instance Validatable (Score ss)
instance PromptValue (NoulWith t f) where
promptValue (Noul v _ _) = if v then "true" else "false"
promptValueJSON (Noul v _ _) = Bool v
promptEvidenceJSON n@(Noul v _ c) = case toJSON n of Object o -> Object (KM.insert "value" (Bool v) (KM.insert "confidence" (toJSON c) o)); x -> x
instance (ChoiceOptions a) => PromptValue (Choice a) where
promptValue (Choice a _ _) = fromMaybe "" (lookup a [(v, n) | (n, v, _) <- choiceOptions @a])
promptValueJSON = String . promptValue
promptEvidenceJSON c = case toJSON c of Object o -> Object (KM.insert "value" (String (promptValue c)) o); x -> x
instance PromptValue (Score ss) where
promptValue (Score _ l _ _) = T.pack (show l)
promptValueJSON (Score _ l _ _) = toJSON l
promptEvidenceJSON s@(Score v l _ _) = case toJSON s of Object o -> Object (KM.insert "value" (toJSON v) (KM.insert "level" (toJSON l) o)); x -> x