intelli-monad 0.1.1.1 → 0.1.2.0
raw patch · 16 files changed
+483/−361 lines, 16 filesdep +hspecdep −openai-servant-gendep −servantdep −servant-clientdep ~aesondep ~bytestringdep ~exceptions
Dependencies added: hspec
Dependencies removed: openai-servant-gen, servant, servant-client, servant-client-core, servant-server
Dependency ranges changed: aeson, bytestring, exceptions, text, time, transformers, vector
Files
- intelli-monad.cabal +12/−12
- src/IntelliMonad/Cmd.hs +1/−2
- src/IntelliMonad/CustomInstructions.hs +0/−1
- src/IntelliMonad/Prompt.hs +5/−6
- src/IntelliMonad/Repl.hs +4/−4
- src/IntelliMonad/Tools.hs +0/−8
- src/IntelliMonad/Tools/Arxiv.hs +0/−1
- src/IntelliMonad/Tools/Bash.hs +0/−1
- src/IntelliMonad/Tools/DallE3.hs +0/−113
- src/IntelliMonad/Tools/KeyValue.hs +0/−1
- src/IntelliMonad/Tools/TextToSpeech.hs +0/−76
- src/IntelliMonad/Tools/Utils.hs +0/−1
- src/IntelliMonad/Types.hs +234/−131
- test/ArxivSpec.hs +66/−0
- test/Main.hs +0/−4
- test/Spec.hs +161/−0
intelli-monad.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: intelli-monad-version: 0.1.1.1+version: 0.1.2.0 synopsis: Type level prompt with LLMs via louter. description: Type level prompt with LLMs via louter. This allows us to define function calls and value validation using types. homepage: https://github.com/junjihashimoto/intelli-monad@@ -40,8 +40,6 @@ , IntelliMonad.Tools , IntelliMonad.Tools.Arxiv , IntelliMonad.Tools.Bash- , IntelliMonad.Tools.TextToSpeech- , IntelliMonad.Tools.DallE3 , IntelliMonad.Tools.Utils , IntelliMonad.Tools.KeyValue , IntelliMonad.Persist@@ -76,9 +74,6 @@ , http-conduit >= 2.2 && < 2.4 , megaparsec >= 9.5 && < 9.7 , louter >= 0.1.1 && < 0.1.2- , openai-servant-gen >= 0.1.0 && < 0.2- , servant >= 0.20.1 && < 0.22- , servant-client >= 0.20 && < 0.21 , persistent >= 2.14.6 && < 2.15 , persistent-sqlite >= 2.13.3 && < 2.14 , sixel >= 0.1.2 && < 0.2@@ -88,10 +83,8 @@ , yaml >= 0.11 && < 0.12 , wai >=3.2 && <4.0 , warp >=3.3 && <4.0- , servant-server >= 0.20 && < 0.21- , servant-client-core >= 0.20 && < 0.21 , mtl >=2.2 && <3.0- , exceptions >=1.10 && <1.11+ , exceptions >=0.10 && <1.0 , wai-extra >=3.1 && <3.2 , stm >=2.5 && <2.6 , kan-extensions >=5.2 && <5.3@@ -149,7 +142,7 @@ default-language: Haskell2010 -- Modules included in this executable, other than Main.- -- other-modules:+ other-modules: ArxivSpec -- LANGUAGE extensions used by modules in this package. -- other-extensions:@@ -161,9 +154,16 @@ hs-source-dirs: test -- The entrypoint to the test suite.- main-is: Main.hs+ main-is: Spec.hs -- Test dependencies. build-depends: base ==4.*,- intelli-monad+ intelli-monad,+ hspec >=2.0,+ aeson,+ text,+ bytestring,+ vector,+ transformers,+ time
src/IntelliMonad/Cmd.hs view
@@ -35,7 +35,6 @@ import IntelliMonad.Repl import IntelliMonad.Tools import IntelliMonad.Types-import qualified OpenAI.Types as API import Options.Applicative import System.Console.Haskeline import System.Environment (lookupEnv)@@ -80,7 +79,7 @@ let tools = defaultTools customs = [] sessionName = "default"- defaultReq = LouterRequest (defaultRequest @OpenAI)+ defaultReq = defaultRequest runInputT ( Settings { complete = completeFilename,
src/IntelliMonad/CustomInstructions.hs view
@@ -24,7 +24,6 @@ import Data.Proxy import GHC.Generics import IntelliMonad.Types-import qualified OpenAI.Types as API defaultCustomInstructions :: [CustomInstructionProxy] defaultCustomInstructions = []
src/IntelliMonad/Prompt.hs view
@@ -37,11 +37,10 @@ import IntelliMonad.Persist import IntelliMonad.Tools import IntelliMonad.Types+import qualified Louter.Types.Request as Louter+import qualified Louter.Types.Response as Louter import Network.HTTP.Client (managerResponseTimeout, newManager, responseTimeoutMicro) import Network.HTTP.Client.TLS (tlsManagerSettings)-import qualified OpenAI.API as API-import qualified OpenAI.Types as API-import Servant.Client (mkClientEnv, parseBaseUrl) import System.Environment (getEnv, lookupEnv) getContext :: (MonadIO m, MonadFail m) => Prompt m Context@@ -208,7 +207,7 @@ [ToolProxy] -> [CustomInstructionProxy] -> Text ->- LLMRequest ->+ Louter.ChatRequest -> Text -> m (Maybe validation) runPromptWithValidation tools customs sessionName req input = do@@ -259,7 +258,7 @@ push @p contents call @p >>= callWithValidation @output @StatelessConf -initializePrompt :: forall p m. (MonadIO m, MonadFail m, PersistentBackend p) => [ToolProxy] -> [CustomInstructionProxy] -> Text -> LLMRequest -> m PromptEnv+initializePrompt :: forall p m. (MonadIO m, MonadFail m, PersistentBackend p) => [ToolProxy] -> [CustomInstructionProxy] -> Text -> Louter.ChatRequest -> m PromptEnv initializePrompt tools customs sessionName req = do -- config <- readConfig let settings = addTools tools req@@ -297,7 +296,7 @@ initialize @p conn (init'.context) return init' -runPrompt :: forall p m a. (MonadIO m, MonadFail m, PersistentBackend p) => [ToolProxy] -> [CustomInstructionProxy] -> Text -> LLMRequest -> Prompt m a -> m a+runPrompt :: forall p m a. (MonadIO m, MonadFail m, PersistentBackend p) => [ToolProxy] -> [CustomInstructionProxy] -> Text -> Louter.ChatRequest -> Prompt m a -> m a runPrompt tools customs sessionName req func = do context <- initializePrompt @p tools customs sessionName req fst <$> runStateT func context
src/IntelliMonad/Repl.hs view
@@ -33,7 +33,7 @@ import IntelliMonad.Persist import IntelliMonad.Prompt hiding (user, system, assistant) import IntelliMonad.Types-import qualified OpenAI.Types as API+import qualified Louter.Types.Request as Louter import System.Console.Haskeline import System.Environment (lookupEnv) import System.IO (hClose)@@ -107,7 +107,7 @@ ExitSuccess -> Just <$> T.readFile filePath ExitFailure _ -> return Nothing -editRequestWithEditor :: forall m. (MonadIO m, MonadFail m) => LLMRequest -> m (Maybe LLMRequest)+editRequestWithEditor :: forall m. (MonadIO m, MonadFail m) => Louter.ChatRequest -> m (Maybe Louter.ChatRequest) editRequestWithEditor req = do liftIO $ withSystemTempFile "tempfile.yaml" $ \filePath fileHandle -> do hClose fileHandle@@ -119,7 +119,7 @@ code <- system (editor <> " " <> filePath) case code of ExitSuccess -> do- newReq <- Y.decodeFileEither @LLMRequest filePath+ newReq <- Y.decodeFileEither @Louter.ChatRequest filePath case newReq of Right newReq' -> return $ Just newReq' Left err -> do@@ -322,7 +322,7 @@ cmd <- getUserCommand @p runCmd' @p cmd (Just (runRepl' @p)) -runRepl :: forall p. (PersistentBackend p) => [ToolProxy] -> [CustomInstructionProxy] -> Text -> LLMRequest -> Contents -> IO ()+runRepl :: forall p. (PersistentBackend p) => [ToolProxy] -> [CustomInstructionProxy] -> Text -> Louter.ChatRequest -> Contents -> IO () runRepl tools customs sessionName defaultReq contents = do config <- readConfig runInputT
src/IntelliMonad/Tools.hs view
@@ -21,8 +21,6 @@ ( module IntelliMonad.Tools.Utils, module IntelliMonad.Tools.Arxiv, module IntelliMonad.Tools.Bash,- module IntelliMonad.Tools.TextToSpeech,- module IntelliMonad.Tools.DallE3, defaultTools, ) where@@ -30,18 +28,12 @@ import Data.Proxy import IntelliMonad.Tools.Arxiv import IntelliMonad.Tools.Bash-import IntelliMonad.Tools.DallE3-import IntelliMonad.Tools.TextToSpeech import IntelliMonad.Tools.Utils import IntelliMonad.Types arxiv = ToolProxy (Proxy :: Proxy Arxiv) bash = ToolProxy (Proxy :: Proxy Bash)--textToSpeech = ToolProxy (Proxy :: Proxy TextToSpeech)--dallE3 = ToolProxy (Proxy :: Proxy DallE3) defaultTools :: [ToolProxy] defaultTools =
src/IntelliMonad/Tools/Arxiv.hs view
@@ -62,7 +62,6 @@ import Network.HTTP.Client import Network.HTTP.Client.TLS import Network.HTTP.Simple (setRequestQueryString)-import qualified OpenAI.Types as API import Text.XML import Text.XML.Cursor (Axis, Cursor, attributeIs, checkName, content, element, fromDocument, ($//), (&/), (&//))
src/IntelliMonad/Tools/Bash.hs view
@@ -25,7 +25,6 @@ import GHC.Generics import GHC.IO.Exception import IntelliMonad.Types-import qualified OpenAI.Types as API import System.Process data Bash = Bash
− src/IntelliMonad/Tools/DallE3.hs
@@ -1,113 +0,0 @@-{-# LANGUAGE AllowAmbiguousTypes #-}-{-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE FunctionalDependencies #-}-{-# LANGUAGE GADTs #-}-{-# LANGUAGE GeneralizedNewtypeDeriving #-}-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE MultiParamTypeClasses #-}-{-# LANGUAGE OverloadedLists #-}-{-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RankNTypes #-}-{-# LANGUAGE RecordWildCards #-}-{-# LANGUAGE ScopedTypeVariables #-}-{-# LANGUAGE TypeApplications #-}-{-# LANGUAGE TypeFamilies #-}--module IntelliMonad.Tools.DallE3 where--import Codec.Picture-import Control.Monad.IO.Class-import qualified Data.Aeson as A-import qualified Data.ByteString.Lazy as LBS-import Data.Maybe (fromMaybe)-import qualified Data.OSC1337 as OSC-import qualified Data.Sixel as Sixel-import Data.Text (Text)-import qualified Data.Text as T-import GHC.Generics-import IntelliMonad.Types-import Network.HTTP.Client (httpLbs, newManager, parseUrlThrow, responseBody)-import Network.HTTP.Client.TLS (tlsManagerSettings)-import qualified OpenAI.API as API-import qualified OpenAI.Types as API-import Servant.Client-import System.Environment (getEnv, lookupEnv)--putImage :: FilePath -> IO (Either String ())-putImage image' = do- termProgram <- lookupEnv "TERM_PROGRAM"- imageBin <- readImage image'- case imageBin of- Left err -> return $ Left $ "Image file " ++ image' ++ " can not be read. : " ++ show err- Right imageBin' -> do- let image = convertRGB8 imageBin'- case termProgram of- Just "iTerm.app" -> do- OSC.putOSC image- putStrLn ""- Just "vscode" -> do- Sixel.putSixel image- putStrLn ""- _ -> do- Sixel.putSixel image- putStrLn ""- return $ Right ()--data DallE3 = DallE3- { prompt :: T.Text,- size :: T.Text- }- deriving (Eq, Show, Generic, JSONSchema, A.FromJSON, A.ToJSON)--instance HasFunctionObject DallE3 where- getFunctionName = "image_generation"- getFunctionDescription = "Creating images from scratch based on a text prompt"- getFieldDescription "prompt" = "A text description of the desired image. The maximum length is 4000 characters."- getFieldDescription "size" = "The size of the generated images. Must be one of 1024x1024, 1792x1024, or 1024x1792 for dall-e-3 models."--instance Tool DallE3 where- data Output DallE3 = DallE3Output- { code :: Int,- stdout :: String,- stderr :: String,- url :: Text- }- deriving (Eq, Show, Generic, A.FromJSON, A.ToJSON)- toolExec args = liftIO $ do- api_key <- (API.clientAuth . T.pack) <$> getEnv "OPENAI_API_KEY"- url <- parseBaseUrl "https://api.openai.com/v1/"- manager <- newManager tlsManagerSettings- let API.OpenAIBackend {..} = API.createOpenAIClient- let request =- API.CreateImageRequest- { createImageRequestPrompt = args.prompt,- createImageRequestModel = Just $ API.CreateImageRequestModel "dall-e-3",- createImageRequestN = Nothing,- createImageRequestQuality = Nothing,- createImageRequestResponseUnderscoreformat = Just "url",- createImageRequestSize = Just args.size,- createImageRequestStyle = Nothing,- createImageRequestUser = Nothing- }- res <- API.callOpenAI (mkClientEnv manager url) $ createImage api_key request- let url' = case res of- (API.ImagesResponse _ (img : _)) -> fromMaybe "" (API.imageUrl img)- _ -> ""- let downloadImage = do- request' <- parseUrlThrow $ T.unpack url'- manager' <- newManager tlsManagerSettings- response <- httpLbs request' manager'- let imageBytes = Network.HTTP.Client.responseBody response- LBS.writeFile "image.png" imageBytes- downloadImage- err <- do- liftIO $- putImage "image.png" >>= \case- Left err -> return err- Right _ -> return ""- return $ DallE3Output 0 "" err url'
src/IntelliMonad/Tools/KeyValue.hs view
@@ -31,7 +31,6 @@ import IntelliMonad.Persist import IntelliMonad.Prompt import IntelliMonad.Types-import qualified OpenAI.Types as API import System.Process import Data.Proxy
− src/IntelliMonad/Tools/TextToSpeech.hs
@@ -1,76 +0,0 @@-{-# LANGUAGE AllowAmbiguousTypes #-}-{-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE DuplicateRecordFields #-}-{-# LANGUAGE FlexibleContexts #-}-{-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE FunctionalDependencies #-}-{-# LANGUAGE GADTs #-}-{-# LANGUAGE GeneralizedNewtypeDeriving #-}-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE InstanceSigs #-}-{-# LANGUAGE MultiParamTypeClasses #-}-{-# LANGUAGE OverloadedLists #-}-{-# LANGUAGE OverloadedRecordDot #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RankNTypes #-}-{-# LANGUAGE RecordWildCards #-}-{-# LANGUAGE ScopedTypeVariables #-}-{-# LANGUAGE TypeApplications #-}-{-# LANGUAGE TypeFamilies #-}--module IntelliMonad.Tools.TextToSpeech where--import Control.Monad.IO.Class-import qualified Data.Aeson as A-import qualified Data.ByteString as BS-import qualified Data.Text as T-import Data.Proxy-import GHC.Generics-import GHC.IO.Exception-import IntelliMonad.Types-import Network.HTTP.Client (newManager)-import Network.HTTP.Client.TLS (tlsManagerSettings)-import qualified OpenAI.API as API-import qualified OpenAI.Types as API-import Servant.Client-import System.Environment (getEnv)-import System.Process--data TextToSpeech = TextToSpeech- { script :: T.Text- }- deriving (Eq, Show, Generic, JSONSchema, A.FromJSON, A.ToJSON)--instance HasFunctionObject TextToSpeech where- getFunctionName = "text_to_speech"- getFunctionDescription = "Speak text"- getFieldDescription "script" = "A script for speech"--instance Tool TextToSpeech where- data Output TextToSpeech = TextToSpeechOutput- { code :: Int,- stdout :: String,- stderr :: String- }- deriving (Eq, Show, Generic, A.FromJSON, A.ToJSON)- toolExec args = liftIO $ do- api_key <- (API.clientAuth . T.pack) <$> (getEnv "OPENAI_API_KEY")- url <- parseBaseUrl "https://api.openai.com/v1/"- manager <- newManager tlsManagerSettings- let API.OpenAIBackend {..} = API.createOpenAIClient- let request =- API.CreateSpeechRequest- { API.createSpeechRequestModel = API.CreateSpeechRequestModel "tts-1",- API.createSpeechRequestInput = (script args :: T.Text),- API.createSpeechRequestVoice = "alloy",- API.createSpeechRequestResponseUnderscoreformat = Just "mp3",- API.createSpeechRequestSpeed = Nothing- }- res <- API.callOpenAI (mkClientEnv manager url) $ createSpeech api_key request- BS.writeFile "out.mp3" res- (code, stdout, stderr) <- readCreateProcessWithExitCode (shell $ "afplay " <> "out.mp3") ""- let code' = case code of- ExitSuccess -> 0- ExitFailure v -> v- return $ TextToSpeechOutput code' stdout stderr
src/IntelliMonad/Tools/Utils.hs view
@@ -30,7 +30,6 @@ import qualified Data.Text.Encoding as T import Data.Time import IntelliMonad.Types-import qualified OpenAI.Types as API toolExec' :: forall t p m.
src/IntelliMonad/Types.hs view
@@ -40,7 +40,9 @@ import qualified Data.Aeson as A import qualified Data.Aeson.Key as A import qualified Data.Aeson.KeyMap as A+import qualified Data.Aeson.KeyMap as HM import qualified Data.Aeson.Text as A+import Data.List (nub) import Data.ByteString (ByteString, fromStrict, toStrict) import Data.Coerce import Data.Kind (Type)@@ -148,6 +150,13 @@ getFunctionDescription :: String getFieldDescription :: String -> String +-- | Constructor schema for sum types+data ConstructorSchema = ConstructorSchema+ { csName :: Text -- ^ Constructor name+ , csPayload :: Schema -- ^ Payload schema+ , csIsNullary :: Bool -- ^ True for zero-field constructors+ } deriving (Show, Eq)+ data Schema = Maybe' Schema | String'@@ -157,6 +166,11 @@ | Array' Schema | Boolean' | Null'+ -- Sum type schemas+ | Enum' [Text] -- ^ String enum for nullary constructors+ | OneOfUntagged [ConstructorSchema] -- ^ Untagged union (distinguishable shapes)+ | OneOfTagged [ConstructorSchema] -- ^ Tagged union with @tag/@value+ deriving (Show, Eq) class GSchema s f where gschema :: forall a. f a -> Schema@@ -166,51 +180,9 @@ default schema :: (HasFunctionObject r, Generic r, GSchema r (Rep r)) => Schema schema = gschema @r (from (undefined :: r)) -data OpenAI--data LLMProtocol- = OpenAI--data LLMRequest- = LouterRequest Louter.ChatRequest- deriving (Show, Eq, Generic)--data LLMResponse- = LouterResponse Louter.ChatResponse- deriving (Show, Eq, Generic)--data LLMTool- = LouterTool Louter.Tool- deriving (Show, Eq, Generic)--instance ToJSON LLMRequest-instance FromJSON LLMRequest-instance ToJSON LLMResponse-instance FromJSON LLMResponse---- Manual Ord instances (Louter types don't have Ord)-instance Ord LLMRequest where- compare _ _ = EQ -- Simplified: treat all requests as equal for ordering--instance Ord LLMResponse where- compare _ _ = EQ -- Simplified: treat all responses as equal for ordering--instance Ord LLMTool where- compare _ _ = EQ -- Simplified: treat all tools as equal for ordering--class LLMApi api where- type LLMRequest' api- type LLMResponse' api- type LLMTool' api- defaultRequest :: LLMRequest' api- newTool :: forall a. (HasFunctionObject a, JSONSchema a) => Proxy a -> LLMTool' api- toTools :: LLMRequest' api -> [LLMTool' api]- fromTools :: LLMRequest' api -> [LLMTool' api] -> LLMRequest' api- fromModel_ :: Text -> LLMRequest' api- class ChatCompletion b where- toRequest :: LLMRequest -> b -> LLMRequest- fromResponse :: Text -> LLMResponse -> (b, FinishReason)+ toRequest :: Louter.ChatRequest -> b -> Louter.ChatRequest+ fromResponse :: Text -> Louter.ChatResponse -> (b, FinishReason) toPV :: (ToJSON a) => a -> PersistValue toPV = toPersistValue . toStrict . encode@@ -250,22 +222,8 @@ instance PersistFieldSql Message where sqlType _ = sqlType (Proxy @ByteString) -instance PersistField LLMRequest where- toPersistValue = toPV- fromPersistValue = fromPV -instance PersistField LLMResponse where- toPersistValue = toPV- fromPersistValue = fromPV -instance PersistFieldSql LLMRequest where- sqlType _ = sqlType (Proxy @ByteString)--instance PersistFieldSql LLMResponse where- sqlType _ = sqlType (Proxy @ByteString)--- share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|@@ -281,8 +239,8 @@ deriving FromJSON deriving Generic Context- request LLMRequest- response LLMResponse Maybe+ request Louter.ChatRequest+ response Louter.ChatResponse Maybe header [Content] body [Content] footer [Content]@@ -291,7 +249,6 @@ created UTCTime default=CURRENT_TIME deriving Show deriving Eq- deriving Ord KeyValue namespace Text key Text@@ -302,6 +259,15 @@ deriving Ord |] +-- Manual Ord instance for Context+-- We compare only the fields that have Ord, ignoring request and response+instance Ord Context where+ compare c1 c2 =+ compare (contextHeader c1, contextBody c1, contextFooter c1,+ contextTotalTokens c1, contextSessionName c1, contextCreated c1)+ (contextHeader c2, contextBody c2, contextFooter c2,+ contextTotalTokens c2, contextSessionName c2, contextCreated c2)+ data ToolProxy = forall t. (Tool t, A.FromJSON t, A.ToJSON t, A.FromJSON (Output t), A.ToJSON (Output t), HasFunctionObject t, JSONSchema t) => ToolProxy (Proxy t) class CustomInstruction a where@@ -390,7 +356,78 @@ ] Boolean' -> A.Object [("type", "boolean")] Null' -> A.Object [("type", "null")]+ -- Sum type schemas+ Enum' constructors ->+ A.Object+ [ ("type", "string"),+ ("enum", A.Array $ V.fromList $ map A.String constructors)+ ]+ OneOfUntagged constructors ->+ A.Object+ [ ("oneOf", A.Array $ V.fromList $ map constructorToUntaggedSchema constructors)+ ]+ OneOfTagged constructors ->+ A.Object+ [ ("oneOf", A.Array $ V.fromList $ map constructorToTaggedSchema constructors)+ ] +-- | Convert a constructor schema to an untagged JSON schema+constructorToUntaggedSchema :: ConstructorSchema -> A.Value+constructorToUntaggedSchema (ConstructorSchema name payload _) =+ -- For untagged, just emit the payload schema+ -- The constructor name is used for documentation but not in the schema itself+ toAeson payload++-- | Convert a constructor schema to a tagged JSON schema with @tag/@value+constructorToTaggedSchema :: ConstructorSchema -> A.Value+constructorToTaggedSchema (ConstructorSchema name payload isNullary) =+ if isNullary+ then+ -- Nullary constructor: {"@tag": "ConstructorName"}+ A.Object+ [ ("type", "object"),+ ("properties", A.Object+ [ ("@tag", A.Object [("type", "string"), ("const", A.String name)])+ ]),+ ("required", A.Array $ V.fromList [A.String "@tag"]),+ ("additionalProperties", A.Bool False)+ ]+ else+ case payload of+ Object' _ ->+ -- Object payload: flat tagged format {"@tag": "Ctor", "field1": ..., "field2": ...}+ case toAeson payload of+ A.Object payloadObj ->+ let tagProp = ("@tag", A.Object [("type", "string"), ("const", A.String name)])+ -- Extract properties from payload+ props = case HM.lookup "properties" payloadObj of+ Just (A.Object p) -> p+ _ -> HM.empty+ -- Extract required fields+ req = case HM.lookup "required" payloadObj of+ Just (A.Array r) -> V.toList r+ _ -> []+ -- Merge @tag with payload properties+ allProps = HM.insert "@tag" (A.Object [("type", "string"), ("const", A.String name)]) props+ allRequired = A.String "@tag" : req+ in A.Object+ [ ("type", "object"),+ ("properties", A.Object allProps),+ ("required", A.Array $ V.fromList allRequired)+ ]+ _ -> A.Null -- Shouldn't happen+ _ ->+ -- Non-object payload: nested format {"@tag": "Ctor", "@value": ...}+ A.Object+ [ ("type", "object"),+ ("properties", A.Object+ [ ("@tag", A.Object [("type", "string"), ("const", A.String name)]),+ ("@value", toAeson payload)+ ]),+ ("required", A.Array $ V.fromList [A.String "@tag", A.String "@value"]),+ ("additionalProperties", A.Bool False)+ ]+ instance Semigroup Schema where (<>) (Object' a) (Object' b) = Object' (a <> b) (<>) (Array' a) (Array' b) = Array' (a <> b)@@ -428,7 +465,66 @@ instance JSONSchema () where schema = Null' -instance (HasFunctionObject s, JSONSchema c) => GSchema s U1 where+-- | Helper functions for sum type schema generation++-- | Internal wrapper to track constructor schemas during generic traversal+-- This allows us to distinguish between a single constructor and a sum type+data SchemaOrConstructors+ = SingleSchema Schema -- ^ Not a sum type, just a regular schema+ | Constructors [ConstructorSchema] -- ^ Sum type with multiple constructors+ deriving (Show, Eq)++-- | Extract constructors from a schema, wrapping non-sum schemas as single-constructor lists+extractConstructors :: Schema -> [ConstructorSchema]+extractConstructors (Enum' names) = map (\n -> ConstructorSchema n Null' True) names+extractConstructors (OneOfUntagged cs) = cs+extractConstructors (OneOfTagged cs) = cs+extractConstructors other = [ConstructorSchema "" other (isNullarySchema other)]++-- | Normalize constructor name (e.g., "Red" -> "red")+normalizeConstructorName :: Text -> Text+normalizeConstructorName = T.toLower++-- | Check if a schema represents a nullary constructor+isNullarySchema :: Schema -> Bool+isNullarySchema Null' = True+isNullarySchema _ = False++-- | Check if all constructors are nullary (enum pattern)+isEnum :: [ConstructorSchema] -> Bool+isEnum = all csIsNullary++-- | Extract schema shape for distinguishability check+-- Two schemas are distinguishable if they have different shapes+schemaShape :: Schema -> Text+schemaShape String' = "string"+schemaShape Number' = "number"+schemaShape Integer' = "integer"+schemaShape Boolean' = "boolean"+schemaShape Null' = "null"+schemaShape (Array' _) = "array"+schemaShape (Object' fields) = "object:" <> T.intercalate "," (map (\(n,_,_) -> T.pack n) fields)+schemaShape (Maybe' s) = "maybe:" <> schemaShape s+schemaShape (Enum' _) = "enum"+schemaShape (OneOfUntagged _) = "oneof-untagged"+schemaShape (OneOfTagged _) = "oneof-tagged"++-- | Check if constructor shapes are mutually exclusive (distinguishable)+areShapesDistinguishable :: [ConstructorSchema] -> Bool+areShapesDistinguishable constructors =+ let shapes = map (schemaShape . csPayload) constructors+ uniqueShapes = nub shapes+ in length shapes == length uniqueShapes++-- | Choose appropriate sum type encoding based on constructor analysis+chooseSumEncoding :: [ConstructorSchema] -> Schema+chooseSumEncoding constructors+ | null constructors = Null' -- Shouldn't happen, but handle gracefully+ | isEnum constructors = Enum' (map csName constructors)+ | areShapesDistinguishable constructors = OneOfUntagged constructors+ | otherwise = OneOfTagged constructors++instance (HasFunctionObject s) => GSchema s U1 where gschema _ = Null' instance (HasFunctionObject s, JSONSchema c) => GSchema s (K1 i c) where@@ -437,17 +533,39 @@ instance (HasFunctionObject s, GSchema s a, GSchema s b) => GSchema s (a :*: b) where gschema _ = gschema @s @a undefined <> gschema @s @b undefined +-- | Sum type instance - collects all constructors from both branches instance (HasFunctionObject s, GSchema s a, GSchema s b) => GSchema s (a :+: b) where- gschema _ = gschema @s @a undefined- gschema _ = gschema @s @b undefined+ gschema _ =+ let leftSchema = gschema @s @a undefined+ rightSchema = gschema @s @b undefined+ leftConstructors = extractConstructors leftSchema+ rightConstructors = extractConstructors rightSchema+ allConstructors = leftConstructors ++ rightConstructors+ in chooseSumEncoding allConstructors --- | Datatype+-- | Datatype - unwraps single-constructor types to their payload instance (HasFunctionObject s, GSchema s f) => GSchema s (M1 D c f) where- gschema _ = gschema @s @f undefined+ gschema _ =+ let innerSchema = gschema @s @f undefined+ in case innerSchema of+ -- Single constructor case: unwrap to just the payload+ Enum' [_] -> Null' -- Single nullary constructor is just Null+ OneOfUntagged [ConstructorSchema _ payload _] -> payload+ -- Multiple constructors: keep as-is+ _ -> innerSchema --- | Constructor Metadata+-- | Constructor Metadata - captures constructor name and wraps payload instance (HasFunctionObject s, GSchema s f, Constructor c) => GSchema s (M1 C c f) where- gschema _ = gschema @s @f undefined+ gschema proxy =+ let name = T.pack $ conName (undefined :: M1 C c f p)+ normalizedName = normalizeConstructorName name+ payload = gschema @s @f undefined+ isNullary = isNullarySchema payload+ -- Return a single-constructor "sum type" that will be collected by :+:+ -- or used directly if there's only one constructor+ in case payload of+ Null' -> Enum' [normalizedName] -- Nullary constructor+ _ -> OneOfUntagged [ConstructorSchema normalizedName payload isNullary] -- | Selector Metadata instance (HasFunctionObject s, GSchema s f, Selector c) => GSchema s (M1 S c f) where@@ -456,13 +574,13 @@ desc = getFieldDescription @s name in Object' [(name, desc, (gschema @s @f undefined))] -toolAdd :: forall api a. (LLMApi api, Tool a, HasFunctionObject a, JSONSchema a) => LLMRequest' api -> LLMRequest' api+toolAdd :: forall a. (Tool a, HasFunctionObject a, JSONSchema a) => Louter.ChatRequest -> Louter.ChatRequest toolAdd req =- let prevTools = case toTools @api req of+ let prevTools = case toTools req of [] -> [] v -> v- newTools = prevTools ++ [newTool @api @a Proxy]- in fromTools @api req newTools+ newTools = prevTools ++ [newTool @a Proxy]+ in fromTools req newTools defaultUTCTime :: UTCTime defaultUTCTime = UTCTime (coerce (0 :: Integer)) 0@@ -530,33 +648,38 @@ deleteKey :: (MonadIO m, MonadFail m) => Conn p -> Unique KeyValue -> m () -instance LLMApi OpenAI where- type LLMRequest' OpenAI = Louter.ChatRequest- type LLMResponse' OpenAI = Louter.ChatResponse- type LLMTool' OpenAI = Louter.Tool- defaultRequest =- Louter.ChatRequest- { Louter.reqModel = "gpt-4"- , Louter.reqMessages = []- , Louter.reqTools = []- , Louter.reqToolChoice = Louter.ToolChoiceAuto- , Louter.reqTemperature = Nothing- , Louter.reqMaxTokens = Nothing- , Louter.reqStream = False- }- newTool (Proxy :: Proxy a) =- Louter.Tool- { Louter.toolName = T.pack $ getFunctionName @a- , Louter.toolDescription = Just (T.pack $ getFunctionDescription @a)- , Louter.toolParameters = toAeson (schema @a)- }- toTools req = Louter.reqTools req- fromTools req tools = req { Louter.reqTools = tools }- fromModel_ model =- (defaultRequest @OpenAI :: LLMRequest' OpenAI)- { Louter.reqModel = model- }+defaultRequest :: Louter.ChatRequest+defaultRequest =+ Louter.ChatRequest+ { Louter.reqModel = "gpt-4"+ , Louter.reqMessages = []+ , Louter.reqTools = []+ , Louter.reqToolChoice = Louter.ToolChoiceAuto+ , Louter.reqTemperature = Nothing+ , Louter.reqMaxTokens = Nothing+ , Louter.reqStream = False+ } +newTool :: forall a. (HasFunctionObject a, JSONSchema a) => Proxy a -> Louter.Tool+newTool (Proxy :: Proxy a) =+ Louter.Tool+ { Louter.toolName = T.pack $ getFunctionName @a+ , Louter.toolDescription = Just (T.pack $ getFunctionDescription @a)+ , Louter.toolParameters = toAeson (schema @a)+ }++toTools :: Louter.ChatRequest -> [Louter.Tool]+toTools req = Louter.reqTools req++fromTools :: Louter.ChatRequest -> [Louter.Tool] -> Louter.ChatRequest+fromTools req tools = req { Louter.reqTools = tools }++fromModel_ :: Text -> Louter.ChatRequest+fromModel_ model =+ (defaultRequest :: Louter.ChatRequest)+ { Louter.reqModel = model+ }+ -- | Read the JSON object and convert it to a Map toMap :: Text -> Map Text Value toMap json =@@ -569,7 +692,7 @@ fromMap txt = TL.toStrict $ A.encodeToLazyText txt instance ChatCompletion Contents where- toRequest (LouterRequest orgRequest) contents =+ toRequest orgRequest contents = let messages = flip map contents $ \case Content user (Message message) _ _ -> Louter.Message@@ -600,9 +723,9 @@ { Louter.msgRole = Louter.RoleTool , Louter.msgContent = [Louter.TextPart ret'] }- in LouterRequest $ orgRequest { Louter.reqMessages = messages }+ in orgRequest { Louter.reqMessages = messages } - fromResponse sessionName (LouterResponse response) =+ fromResponse sessionName response = let choice = head (Louter.respChoices response) message = Louter.choiceMessage choice toolCalls = Louter.choiceToolCalls choice@@ -624,41 +747,21 @@ in (contents, finishReason) -data LLMProxy api =- LLMProxy- { toRequest' :: LLMRequest' api -> LLMRequest- , toResponse' :: LLMResponse' api -> LLMResponse- }--withLLMRequest- :: forall a. LLMRequest- -> (forall (api :: Type)- . (LLMApi api)- => LLMProxy api- -> LLMRequest' api- -> a)- -> a-withLLMRequest req func =- case req of- LouterRequest req' -> func (LLMProxy @OpenAI LouterRequest LouterResponse) req'--updateRequest :: LLMRequest -> Contents -> LLMRequest+updateRequest :: Louter.ChatRequest -> Contents -> Louter.ChatRequest updateRequest = toRequest --- withLLMRequest req' $ \(p :: LLMProxy api) req -> (toRequest' @api p) (toRequest req cs)--addTools :: [ToolProxy] -> LLMRequest -> LLMRequest+addTools :: [ToolProxy] -> Louter.ChatRequest -> Louter.ChatRequest addTools [] req' = req' addTools (tool : tools') req' = case tool of (ToolProxy (_ :: Proxy a)) ->- withLLMRequest req' $ \(p :: LLMProxy api) req -> addTools tools' ((toRequest' @api p) $ toolAdd @api @a req)- -fromModel :: Text -> LLMRequest-fromModel = LouterRequest . fromModel_ @OpenAI+ addTools tools' (toolAdd @a req') -runRequest :: forall a. (ChatCompletion a) => Text -> LLMRequest -> a -> IO ((a, FinishReason), LLMResponse)-runRequest sessionName (LouterRequest defaultReq) request = do+fromModel :: Text -> Louter.ChatRequest+fromModel = fromModel_++runRequest :: forall a. (ChatCompletion a) => Text -> Louter.ChatRequest -> a -> IO ((a, FinishReason), Louter.ChatResponse)+runRequest sessionName defaultReq request = do config <- readConfig -- Determine backend type (default to OpenAI if not specified)@@ -684,7 +787,7 @@ } client <- Louter.newClient louterBackend- let (LouterRequest req) = (toRequest (LouterRequest defaultReq) request)+ let req = toRequest defaultReq request lookupEnv "OPENAI_DEBUG" >>= \case Just "1" -> do@@ -696,4 +799,4 @@ result <- Louter.chatCompletion client req case result of Left err -> error $ T.unpack $ "Louter error: " <> err- Right res -> return $ (fromResponse sessionName (LouterResponse res), LouterResponse res)+ Right res -> return $ (fromResponse sessionName res, res)
+ test/ArxivSpec.hs view
@@ -0,0 +1,66 @@+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE OverloadedStrings #-}++module ArxivSpec (spec) where++import IntelliMonad.Tools.Arxiv+import IntelliMonad.Types+import IntelliMonad.Prompt+import IntelliMonad.Persist+import Test.Hspec++spec :: Spec+spec = do+ describe "Arxiv Tool" $ do+ describe "Schema Generation" $ do+ it "generates correct JSON schema" $ do+ let arxivSchema = schema @Arxiv+ case arxivSchema of+ Object' fields -> do+ length fields `shouldBe` 3+ -- Check that all expected fields are present+ let fieldNames = map (\(name, _, _) -> name) fields+ fieldNames `shouldContain` ["searchQuery"]+ fieldNames `shouldContain` ["maxResults"]+ fieldNames `shouldContain` ["start"]+ _ -> expectationFailure "Expected Object' schema"++ describe "Real API Request" $ do+ it "makes successful request to Arxiv API and retrieves papers" $ do+ -- Create Arxiv query+ let arxivQuery = Arxiv+ { searchQuery = "quantum computing"+ , maxResults = Just 3+ , start = Just 0+ }++ -- Run toolExec in the Prompt monad using runPrompt like calc.hs does+ result <- runPrompt @StatelessConf [] [] "test-session" (defaultRequest) $ do+ toolExec @Arxiv @StatelessConf arxivQuery++ -- Verify results+ length (papers result) `shouldSatisfy` (> 0)+ length (papers result) `shouldSatisfy` (<= 3)++ -- Verify first paper has required fields+ case papers result of+ (firstPaper:_) -> do+ arxivId firstPaper `shouldSatisfy` (not . null . show)+ title firstPaper `shouldSatisfy` (not . null . show)+ summary firstPaper `shouldSatisfy` (not . null . show)+ published firstPaper `shouldSatisfy` (not . null . show)+ [] -> expectationFailure "No papers returned"++ it "handles different search queries" $ do+ let arxivQuery = Arxiv+ { searchQuery = "neural networks"+ , maxResults = Just 2+ , start = Just 0+ }++ -- Run toolExec using runPrompt+ result <- runPrompt @StatelessConf [] [] "test-session" (defaultRequest) $ do+ toolExec @Arxiv @StatelessConf arxivQuery++ length (papers result) `shouldSatisfy` (> 0)+ length (papers result) `shouldSatisfy` (<= 2)
− test/Main.hs
@@ -1,4 +0,0 @@-module Main (main) where--main :: IO ()-main = putStrLn "Test suite not yet implemented."
+ test/Spec.hs view
@@ -0,0 +1,161 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE UndecidableInstances #-}++module Main (main) where++import Data.Aeson (Value, encode)+import qualified Data.Aeson as A+import qualified Data.Aeson.Key as Key+import qualified Data.Aeson.KeyMap as KM+import qualified Data.Text.Encoding as TE+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Vector as V+import GHC.Generics+import IntelliMonad.Types+import Test.Hspec+import qualified Data.ByteString.Lazy as BL+import qualified ArxivSpec++-- Test types for enum (all nullary constructors)+data Color = Red | Green | Blue+ deriving (Eq, Show, Generic)++instance HasFunctionObject Color where+ getFunctionName = "set_color"+ getFunctionDescription = "Set the color"+ getFieldDescription _ = ""++instance JSONSchema Color++-- Test types for untagged sum type (distinguishable shapes)+data Result+ = Success { code :: Int }+ | Error { message :: String }+ deriving (Eq, Show, Generic)++instance JSONSchema Result++instance HasFunctionObject Result where+ getFunctionName = "get_result"+ getFunctionDescription = "Get operation result"+ getFieldDescription "code" = "Success code"+ getFieldDescription "message" = "Error message"+ getFieldDescription _ = ""++-- Test types for tagged sum type (overlapping shapes)+data ValueType+ = IntVal Int+ | StrVal String+ deriving (Eq, Show, Generic)++instance JSONSchema ValueType++instance HasFunctionObject ValueType where+ getFunctionName = "set_value"+ getFunctionDescription = "Set a value"+ getFieldDescription _ = ""++-- Test mixed nullary and payload constructors+data Status+ = Pending+ | InProgress { percentage :: Int }+ | Completed+ | Failed { reason :: String }+ deriving (Eq, Show, Generic)++instance JSONSchema Status++instance HasFunctionObject Status where+ getFunctionName = "set_status"+ getFunctionDescription = "Set operation status"+ getFieldDescription "percentage" = "Completion percentage"+ getFieldDescription "reason" = "Failure reason"+ getFieldDescription _ = ""++-- Helper to pretty print JSON+prettyJSON :: Value -> String+prettyJSON = T.unpack . TE.decodeUtf8 . BL.toStrict . encode++main :: IO ()+main = hspec $ do+ -- Run Arxiv tool tests+ ArxivSpec.spec++ describe "JSON Schema Generation for Sum Types" $ do++ describe "Enum type (all nullary constructors)" $ do+ it "generates string enum schema" $ do+ let s = schema @Color+ let json = toAeson s+ putStrLn $ "\nColor schema: " ++ prettyJSON json+ -- Should be: {"type": "string", "enum": ["red", "green", "blue"]}+ case json of+ A.Object obj -> do+ KM.lookup (Key.fromString "type") obj `shouldBe` Just (A.String $ T.pack "string")+ case KM.lookup (Key.fromString "enum") obj of+ Just (A.Array arr) -> V.length arr `shouldBe` 3+ _ -> expectationFailure "Missing enum array"+ _ -> expectationFailure "Expected object schema"++ describe "Untagged sum type (distinguishable shapes)" $ do+ it "generates oneOf schema with distinct object schemas" $ do+ let s = schema @Result+ let json = toAeson s+ putStrLn $ "\nResult schema: " ++ prettyJSON json+ -- Should have oneOf with two different object schemas+ case json of+ A.Object obj -> do+ case KM.lookup (Key.fromString "oneOf") obj of+ Just (A.Array arr) -> V.length arr `shouldBe` 2+ _ -> expectationFailure "Missing oneOf array"+ _ -> expectationFailure "Expected object schema"++ describe "Tagged sum type (overlapping shapes)" $ do+ it "generates oneOf schema with @tag/@value" $ do+ let s = schema @ValueType+ let json = toAeson s+ putStrLn $ "\nValueType schema: " ++ prettyJSON json+ -- Should have oneOf with @tag/@value schemas+ case json of+ A.Object obj -> do+ case KM.lookup (Key.fromString "oneOf") obj of+ Just (A.Array arr) -> do+ V.length arr `shouldBe` 2+ -- Check that first variant has @tag field+ case arr V.!? 0 of+ Just (A.Object variantObj) -> do+ case KM.lookup (Key.fromString "properties") variantObj of+ Just (A.Object props) -> do+ KM.lookup (Key.fromString "@tag") props `shouldSatisfy` (/= Nothing)+ _ -> expectationFailure "Missing properties"+ _ -> expectationFailure "Expected object variant"+ _ -> expectationFailure "Missing oneOf array"+ _ -> expectationFailure "Expected object schema"++ describe "Mixed nullary and payload constructors" $ do+ it "generates oneOf schema with mixed types" $ do+ let s = schema @Status+ let json = toAeson s+ putStrLn $ "\nStatus schema: " ++ prettyJSON json+ -- Should have oneOf with 4 variants+ case json of+ A.Object obj -> do+ case KM.lookup (Key.fromString "oneOf") obj of+ Just (A.Array arr) -> V.length arr `shouldBe` 4+ _ -> expectationFailure "Missing oneOf array"+ _ -> expectationFailure "Expected object schema"++ describe "Round-trip encoding" $ do+ it "schema can be encoded to JSON and is valid" $ do+ let colorSchema = toAeson $ schema @Color+ let resultSchema = toAeson $ schema @Result+ let valueSchema = toAeson $ schema @ValueType++ -- Just check they encode without errors+ encode colorSchema `shouldSatisfy` (\bs -> BL.length bs > 0)+ encode resultSchema `shouldSatisfy` (\bs -> BL.length bs > 0)+ encode valueSchema `shouldSatisfy` (\bs -> BL.length bs > 0)