packages feed

langchain-hs-0.0.5.0: test/Test/Langchain/PromptTemplate/Chat/ChatPromptTemplateSpec.hs

{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedStrings #-}

module Test.Langchain.PromptTemplate.Chat.ChatPromptTemplateSpec (tests) where

import Data.Aeson (decode, encode, object, (.=))
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import Test.Tasty
import Test.Tasty.HUnit

import Langchain.Core.Model.Types
  ( ContentBlock (..)
  , ImageContent (..)
  , ImageSource (..)
  , Message (..)
  , Role (..)
  , extractMessageText
  , textMessage
  , userMessage
  )
import Langchain.PromptTemplate.Chat.ChatPromptTemplate
  ( ChatPromptInput (..)
  , ChatPromptMessage
  , ChatPromptTemplate (..)
  , ContentPromptBlock (..)
  , PartialValue (..)
  , append
  , contentMessage
  , extend
  , format
  , formatPrompt
  , fromMessages
  , fromTemplate
  , fromTemplateWithOptions
  , invoke
  , message
  , messagesPlaceholder
  , messagesPlaceholderWithOptions
  , partial
  , templateMessage
  , toMessages
  , toString
  )
import Langchain.PromptTemplate.Chat.MessagesPlaceholder
  ( MessagesPlaceholder (..)
  , MessagesPlaceholderOptions (..)
  )
import Langchain.PromptTemplate.Prompt (PromptTemplateOptions (..), TemplateFormat (..))

tests :: TestTree
tests =
  testGroup
    "ChatPromptTemplate"
    [ fromTemplateTests
    , fromMessagesTests
    , richContentTests
    , formatPromptTests
    , missingVariableTests
    , partialTests
    , appendExtendTests
    , invokeTests
    , serializationTests
    ]

fromTemplateTests :: TestTree
fromTemplateTests =
  testGroup
    "fromTemplate"
    [ testCase "creates a chat prompt template" $ do
        let actual = fromTemplate "hi {foo} {bar}"
            expected =
              ChatPromptTemplate
                { messages =
                    [templateMessage User "hi {foo} {bar}"]
                , inputVariables = ["foo", "bar"]
                }
        actual @?= expected
    , testCase "creates a chat prompt template with partials" $ do
        let actual =
              fromTemplateWithOptions
                "hi {foo} {bar}"
                (PromptTemplateOptions (Map.singleton "foo" "jim"))
        inputVariables actual @?= ["bar"]
        case formatPrompt actual (Map.singleton "bar" "bob") of
          Left err -> assertFailure $ "Expected formatted prompt, got " <> show err
          Right promptValue -> toMessages promptValue @?= [userMessage "hi jim bob"]
    ]

fromMessagesTests :: TestTree
fromMessagesTests =
  testGroup
    "fromMessages"
    [ testCase "preserves static messages" $ do
        let actual =
              fromMessages $
                chatPromptMessages <> [message (userMessage "foo")]
        case actual of
          ChatPromptTemplate {inputVariables = actualInputVariables} ->
            actualInputVariables @?= ["context", "foo", "bar"]
        length (messages actual) @?= 5
        case formatPrompt actual withMessagesVariables of
          Left err -> assertFailure $ "Expected formatted prompt, got " <> show err
          Right promptValue ->
            last (toMessages promptValue) @?= userMessage "foo"
    ]

formatPromptTests :: TestTree
formatPromptTests =
  testGroup
    "formatPrompt / format"
    [ testCase "formats all chat prompt messages" $ do
        let actual = formatPrompt chatPromptTemplate promptVariables
        case actual of
          Left err -> assertFailure $ "Expected formatted prompt, got " <> show err
          Right promptValue -> do
            let promptMessages = toMessages promptValue
            length promptMessages @?= 4
            map extractMessageText promptMessages
              @?= [ "Here's some context: context"
                  , "Hello foo, I'm bar. Thanks for the context"
                  , "I'm an AI. I'm foo. I'm bar."
                  , "I'm a generic message. I'm foo. I'm bar."
                  ]
            toString promptValue @?= expectedFormattedPrompt
        format chatPromptTemplate promptVariables @?= Right expectedFormattedPrompt
    ]

missingVariableTests :: TestTree
missingVariableTests =
  testGroup
    "missing variables"
    [ testCase "fails for missing FString variables in chat messages" $ do
        let template = fromMessages [templateMessage User "Hi {foo}"]
        assertMissingVariable "Parameter not found: foo" (formatPrompt template Map.empty)
    , testCase "fails for missing FString variables in multipart text blocks" $ do
        let template = fromMessages [contentMessage User [TextPromptBlock FString "Hi {foo}"]]
        assertMissingVariable "Parameter not found: foo" (formatPrompt template Map.empty)
    , testCase "fails for missing FString variables in image url blocks" $ do
        let template =
              fromMessages
                [ contentMessage
                    User
                    [ ImagePromptBlock FString $
                        ImageContent (ImageUrl "https://example.com/{foo}") Nothing Nothing
                    ]
                ]
        assertMissingVariable "Parameter not found: foo" (formatPrompt template Map.empty)
    , testCase "fails for missing FString variables in image detail blocks" $ do
        let template =
              fromMessages
                [ contentMessage
                    User
                    [ ImagePromptBlock FString $
                        ImageContent (ImageUrl "https://example.com/image.png") (Just "{foo}") Nothing
                    ]
                ]
        assertMissingVariable "Parameter not found: foo" (formatPrompt template Map.empty)
    , testCase "fails for missing FString variables in image metadata blocks" $ do
        let template =
              fromMessages
                [ contentMessage
                    User
                    [ ImagePromptBlock FString $
                        ImageContent
                          (ImageUrl "https://example.com/image.png")
                          Nothing
                          (Just $ object ["cache_control" .= object ["type" .= ("{foo}" :: Text)]])
                    ]
                ]
        assertMissingVariable "Parameter not found: foo" (formatPrompt template Map.empty)
    ]

richContentTests :: TestTree
richContentTests =
  testGroup
    "rich content"
    [ testCase "formats multipart text blocks" $ do
        let template =
              fromMessages
                [ templateMessage System "You are an AI assistant named {name}."
                , contentMessage
                    User
                    [TextPromptBlock FString "What's in this image?", TextPromptBlock FString "Oh nvm"]
                ]

        case formatPrompt template (Map.singleton "name" "R2D2") of
          Left err -> assertFailure $ "Expected multipart text prompt, got " <> show err
          Right promptValue ->
            toMessages promptValue
              @?= [ textMessage System "You are an AI assistant named R2D2."
                  , Message
                      User
                      (TextBlock "What's in this image?" :| [TextBlock "Oh nvm"])
                      Nothing
                      Nothing
                      Nothing
                      Map.empty
                  ]
    , testCase "formats templated multipart text blocks" $ do
        let template =
              fromMessages
                [ templateMessage System "You are an AI assistant named {name}."
                , contentMessage
                    User
                    [TextPromptBlock FString "What's in this {object_name}?", TextPromptBlock FString "Oh nvm"]
                ]
            variables = Map.fromList [("name", "R2D2"), ("object_name", "image")]

        case formatPrompt template variables of
          Left err -> assertFailure $ "Expected templated multipart text prompt, got " <> show err
          Right promptValue ->
            toMessages promptValue
              @?= [ textMessage System "You are an AI assistant named R2D2."
                  , Message
                      User
                      (TextBlock "What's in this image?" :| [TextBlock "Oh nvm"])
                      Nothing
                      Nothing
                      Nothing
                      Map.empty
                  ]
    , testCase "formats system template with partial variables" $ do
        let graphCreatorContent = "\n    Your instructions are:\n    {instructions}\n    History:\n    {history}\n    "
            template =
              partial
                (fromMessages [templateMessage System graphCreatorContent])
                (Map.singleton "instructions" (PartialText "{}"))

        case formatPrompt template (Map.singleton "history" "history") of
          Left err -> assertFailure $ "Expected system partial prompt, got " <> show err
          Right promptValue ->
            toMessages promptValue
              @?= [ textMessage
                      System
                      "\n    Your instructions are:\n    {}\n    History:\n    history\n    "
                  ]
    , testCase "formats system multipart text template" $ do
        let graphCreatorContent1 = "\n    This is the prompt for the first test:\n    {variables}\n    "
            graphCreatorContent2 = "\n    This is the prompt for the second test:\n        {variables}\n        "
            template =
              fromMessages
                [ contentMessage
                    System
                    [ TextPromptBlock FString graphCreatorContent1
                    , TextPromptBlock FString graphCreatorContent2
                    ]
                ]

        case formatPrompt template (Map.singleton "variables" "foo") of
          Left err -> assertFailure $ "Expected system multipart text prompt, got " <> show err
          Right promptValue ->
            toMessages promptValue
              @?= [ Message
                      System
                      ( TextBlock "\n    This is the prompt for the first test:\n    foo\n    "
                          :| [TextBlock "\n    This is the prompt for the second test:\n        foo\n        "]
                      )
                      Nothing
                      Nothing
                      Nothing
                      Map.empty
                  ]
    , testCase "formats image_url blocks" $ do
        let base64Image = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAA"
            otherBase64Image = "other_iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAA"
            template =
              fromMessages
                [ templateMessage System "You are an AI assistant named {name}."
                , contentMessage
                    User
                    [ TextPromptBlock FString "What's in this image?"
                    , ImagePromptBlock FString $
                        ImageContent (ImageUrl "data:image/jpeg;base64,{my_image}") Nothing Nothing
                    , ImagePromptBlock FString $ ImageContent (ImageUrl "{my_other_image}") Nothing Nothing
                    , ImagePromptBlock FString $ ImageContent (ImageUrl "{my_other_image}") (Just "medium") Nothing
                    , ImagePromptBlock FString $
                        ImageContent (ImageUrl "https://www.langchain.com/image.png") Nothing Nothing
                    ]
                ]
            variables = Map.fromList [("name", "R2D2"), ("my_image", base64Image), ("my_other_image", otherBase64Image)]

        case formatPrompt template variables of
          Left err -> assertFailure $ "Expected image_url prompt, got " <> show err
          Right promptValue ->
            toMessages promptValue
              @?= [ textMessage System "You are an AI assistant named R2D2."
                  , Message
                      User
                      ( TextBlock "What's in this image?"
                          :| [ ImageBlock $ ImageContent (ImageUrl ("data:image/jpeg;base64," <> base64Image)) Nothing Nothing
                             , ImageBlock $ ImageContent (ImageUrl otherBase64Image) Nothing Nothing
                             , ImageBlock $ ImageContent (ImageUrl otherBase64Image) (Just "medium") Nothing
                             , ImageBlock $ ImageContent (ImageUrl "https://www.langchain.com/image.png") Nothing Nothing
                             ]
                      )
                      Nothing
                      Nothing
                      Nothing
                      Map.empty
                  ]
    , testCase "formats image_url blocks with detail" $ do
        let templateWith templateFormat urlTemplate =
              fromMessages
                [ contentMessage
                    User
                    [ ImagePromptBlock templateFormat $
                        ImageContent urlTemplate (Just "low") Nothing
                    ]
                ]
            expected =
              [ Message
                  User
                  ( ImageBlock
                      (ImageContent (ImageUrl "data:image/png;base64, base64data") (Just "low") Nothing)
                      :| []
                  )
                  Nothing
                  Nothing
                  Nothing
                  Map.empty
              ]
            assertFormats template variables =
              case formatPrompt template variables of
                Left err -> assertFailure $ "Expected image_url detail prompt, got " <> show err
                Right promptValue -> toMessages promptValue @?= expected

        assertFormats
          (templateWith FString (ImageUrl "data:{image_type};base64, {image_data}"))
          (Map.fromList [("image_type", "image/png"), ("image_data", "base64data")])
    , testCase "rejects nested f-string replacement fields in image_url blocks" $ do
        let template =
              fromMessages
                [ contentMessage
                    User
                    [ ImagePromptBlock FString $
                        ImageContent (ImageUrl "{img:{img.__class__.__name__}}") Nothing Nothing
                    ]
                ]
        case formatPrompt template (Map.singleton "img" "image-url") of
          Left err ->
            "Nested replacement fields are not allowed" `T.isInfixOf` T.pack (show err)
              @? "Expected nested replacement field error"
          Right _ -> assertFailure "Expected nested replacement field error"
    , testCase "formats image data blocks with metadata" $ do
        let metadata = object ["cache_control" .= object ["type" .= ("{cache_type}" :: Text)]]
            template =
              fromMessages
                [ contentMessage
                    User
                    [ ImagePromptBlock FString $
                        ImageContent (ImageBase64 Nothing "{source_data}") Nothing (Just metadata)
                    ]
                ]
            variables = Map.fromList [("cache_type", "ephemeral"), ("source_data", "base64data")]

        case formatPrompt template variables of
          Left err -> assertFailure $ "Expected image data prompt, got " <> show err
          Right promptValue ->
            toMessages promptValue
              @?= [ Message
                      User
                      ( ImageBlock
                          ( ImageContent
                              (ImageBase64 Nothing "base64data")
                              Nothing
                              (Just $ object ["cache_control" .= object ["type" .= ("ephemeral" :: Text)]])
                          )
                          :| []
                      )
                      Nothing
                      Nothing
                      Nothing
                      Map.empty
                  ]
    , testCase "round-trips rendered image data blocks through json" $ do
        let block = ImageBlock $ ImageContent (ImageUrl "https://example.com/image.png") Nothing Nothing
        decode (encode block) @?= Just block
    ]

partialTests :: TestTree
partialTests =
  testGroup
    "partial"
    [ testCase "formats chat messages with stored variables" $ do
        let template1 =
              fromMessages
                [ templateMessage System "You are an AI assistant named {name}."
                , templateMessage User "Hi I'm {user}"
                , templateMessage Assistant "Hi there, {user}, I'm {name}."
                , templateMessage User "{input}"
                ]
            template2 =
              partial
                template1
                (Map.fromList [("user", PartialText "Lucy"), ("name", PartialText "R2D2")])
            variables = Map.singleton "input" "hello"
            expected =
              [ textMessage System "You are an AI assistant named R2D2."
              , userMessage "Hi I'm Lucy"
              , textMessage Assistant "Hi there, Lucy, I'm R2D2."
              , userMessage "hello"
              ]
            expectedString =
              T.intercalate
                "\n"
                [ "System: You are an AI assistant named R2D2."
                , "Human: Hi I'm Lucy"
                , "AI: Hi there, Lucy, I'm R2D2."
                , "Human: hello"
                ]

        case formatPrompt template1 variables of
          Left _ -> pure ()
          Right promptValue ->
            assertFailure $ "Expected missing variable error, got " <> show promptValue

        case formatPrompt template2 variables of
          Left err -> assertFailure $ "Expected formatted prompt, got " <> show err
          Right promptValue -> toMessages promptValue @?= expected
        format template2 variables @?= Right expectedString
    , testCase "formats role template messages with partial variables" $ do
        let template =
              fromMessages
                [ templateMessage System "You are {name}, a {role} assistant."
                , templateMessage User "{question}"
                ]
            partialTemplate = partial template (Map.fromList [("name", PartialText "Alice"), ("role", PartialText "helpful")])

        inputVariables partialTemplate @?= ["question"]
        case formatPrompt partialTemplate (Map.singleton "question" "What is Python?") of
          Left err -> assertFailure $ "Expected formatted prompt, got " <> show err
          Right promptValue ->
            toMessages promptValue
              @?= [ textMessage System "You are Alice, a helpful assistant."
                  , userMessage "What is Python?"
                  ]
    , testCase "infers required variables after partial variables" $ do
        let template =
              fromMessages
                [ templateMessage User "Do something with {question} using {context} giving it like {formatins}"
                ]
            partialTemplate = partial template (Map.singleton "formatins" (PartialText "some structure"))

        inputVariables partialTemplate @?= ["question", "context"]
    , testCase "composes partially initialized messages" $ do
        let prompt =
              partial
                (fromMessages [templateMessage System "Prompt {x} {y}"])
                (Map.singleton "x" (PartialText "1"))
            appendix = fromMessages [templateMessage System "Appendix {z}"]
            composed = extend prompt (messages appendix)

        case formatPrompt composed (Map.fromList [("y", "2"), ("z", "3")]) of
          Left err -> assertFailure $ "Expected formatted prompt, got " <> show err
          Right promptValue ->
            toMessages promptValue
              @?= [ textMessage System "Prompt 1 2"
                  , textMessage System "Appendix 3"
                  ]
    , testCase "formats messages placeholder with partial messages" $ do
        let prompt = fromMessages [messagesPlaceholder "history"]
            partialPrompt = partial prompt (Map.singleton "history" (PartialMessages [textMessage System "foo"]))

        inputVariables partialPrompt @?= []
        case formatPrompt partialPrompt Map.empty of
          Left err -> assertFailure $ "Expected formatted placeholder, got " <> show err
          Right promptValue -> toMessages promptValue @?= [textMessage System "foo"]

        case invoke
          partialPrompt
          (ChatPromptInputs Map.empty (Map.singleton "history" [textMessage System "bar"])) of
          Left err -> assertFailure $ "Expected runtime placeholder override, got " <> show err
          Right promptValue -> toMessages promptValue @?= [textMessage System "bar"]

        let optionalPrompt =
              fromMessages
                [ messagesPlaceholderWithOptions $
                    MessagesPlaceholderOptions "history" True Nothing
                ]
            partialOptionalPrompt = partial optionalPrompt (Map.singleton "history" (PartialMessages [textMessage System "foo"]))

        case formatPrompt optionalPrompt Map.empty of
          Left err -> assertFailure $ "Expected empty optional placeholder, got " <> show err
          Right promptValue -> toMessages promptValue @?= []
        case formatPrompt partialOptionalPrompt Map.empty of
          Left err -> assertFailure $ "Expected formatted optional placeholder, got " <> show err
          Right promptValue -> toMessages promptValue @?= [textMessage System "foo"]
    ]

appendExtendTests :: TestTree
appendExtendTests =
  testGroup
    "append / extend"
    [ testCase "appends template messages" $ do
        let template =
              fromMessages
                [templateMessage System "You are helpful."]
            template' = append template (templateMessage User "{question}")

        case formatPrompt template' (Map.singleton "question" "What is AI?") of
          Left err -> assertFailure $ "Expected formatted prompt, got " <> show err
          Right promptValue ->
            toMessages promptValue
              @?= [ textMessage System "You are helpful."
                  , userMessage "What is AI?"
                  ]
    , testCase "appends and extends messages" $ do
        let message1 = textMessage System "foo"
            message2 = userMessage "bar"
            message3 = userMessage "baz"
            baseTemplate = fromMessages [message message1]
            template' = append (append baseTemplate (message message2)) (message message3)
            template'' = extend template' [message message2, message message3]
            template''' = append template'' (templateMessage System "hello!")

        length (messages template') @?= 3
        length (messages template'') @?= 5
        messages template''
          @?= [ message message1
              , message message2
              , message message3
              , message message2
              , message message3
              ]
        case formatPrompt template''' Map.empty of
          Left err -> assertFailure $ "Expected formatted prompt, got " <> show err
          Right promptValue ->
            last (toMessages promptValue) @?= textMessage System "hello!"
    ]

invokeTests :: TestTree
invokeTests =
  testGroup
    "invoke"
    [ testCase "formats chat prompt template messages" $ do
        let invokeTemplate =
              fromMessages
                [ templateMessage System "You are {name}."
                , templateMessage User "{question}"
                ]
            variables = ChatPromptVariables $ Map.fromList [("name", "Alice"), ("question", "Hello?")]

        case invoke invokeTemplate variables of
          Left err -> assertFailure $ "Expected formatted prompt, got " <> show err
          Right promptValue ->
            toMessages promptValue
              @?= [ textMessage System "You are Alice."
                  , userMessage "Hello?"
                  ]
    , testCase "accepts message list input for a single messages placeholder" $ do
        let placeholderTemplate =
              fromMessages
                [messagesPlaceholder "history"]
            input = ChatPromptMessageList [userMessage "Hi there"]

        case invoke placeholderTemplate input of
          Left err -> assertFailure $ "Expected placeholder prompt value, got " <> show err
          Right promptValue -> toMessages promptValue @?= [userMessage "Hi there"]
    , testCase "rejects list input for mixed templates" $ do
        let mixedPrompt =
              fromMessages
                [ templateMessage System "You are a {foo}"
                , messagesPlaceholder "history"
                ]
            listInput = ChatPromptMessageList [userMessage "Hi there"]
        case invoke mixedPrompt listInput of
          Left _ -> pure ()
          Right promptValue ->
            assertFailure $ "Expected list input validation error, got " <> show promptValue
    ]

serializationTests :: TestTree
serializationTests =
  testGroup
    "serialization"
    [ testCase "round-trips messages placeholder and chat prompt" $ do
        let placeholder = MessagesPlaceholder "bar" False Nothing
            prompt =
              fromMessages
                [ templateMessage System "foo"
                , messagesPlaceholder "bar"
                , templateMessage User "baz"
                ]

        decode (encode placeholder) @?= Just placeholder
        decode (encode prompt) @?= Just prompt
    , testCase "round-trips rich chat prompt template" $ do
        let prompt =
              fromMessages
                [ templateMessage System "You are an AI assistant named {name}."
                , contentMessage
                    System
                    [TextPromptBlock FString "You are an AI assistant named {name}."]
                , templateMessage System "you are {foo}"
                , contentMessage
                    User
                    [ TextPromptBlock FString "hello"
                    , TextPromptBlock FString "What's in this image?"
                    , TextPromptBlock FString "What's in this image?"
                    , ImagePromptBlock FString $
                        ImageContent (ImageUrl "data:image/jpeg;base64,{my_image}") Nothing Nothing
                    , ImagePromptBlock FString $
                        ImageContent (ImageUrl "{my_other_image}") Nothing Nothing
                    , ImagePromptBlock FString $ ImageContent (ImageUrl "{my_other_image}") (Just "medium") Nothing
                    , ImagePromptBlock FString $
                        ImageContent (ImageUrl "https://www.langchain.com/image.png") Nothing Nothing
                    , ImagePromptBlock FString $
                        ImageContent (ImageUrl "data:image/jpeg;base64,foobar") Nothing Nothing
                    ]
                , messagesPlaceholderWithOptions $ MessagesPlaceholderOptions "history" True (Just 3)
                , messagesPlaceholder "chat_history"
                , messagesPlaceholder "more_history"
                ]

        decode (encode prompt) @?= Just prompt
    ]

assertMissingVariable :: (Show err, Show a) => Text -> Either err a -> Assertion
assertMissingVariable expectedFragment result =
  case result of
    Left err ->
      if T.isInfixOf expectedFragment (T.pack (show err))
        then pure ()
        else assertFailure $ "Expected missing variable error, got " <> show err
    Right value ->
      assertFailure $ "Expected missing variable error, got " <> show value

promptVariables :: Map.Map Text Text
promptVariables = Map.fromList [("foo", "foo"), ("bar", "bar"), ("context", "context")]

withMessagesVariables :: Map.Map Text Text
withMessagesVariables =
  Map.fromList [("context", "see"), ("foo", "this"), ("bar", "magic")]

chatPromptTemplate :: ChatPromptTemplate
chatPromptTemplate =
  ChatPromptTemplate
    { messages = chatPromptMessages
    , inputVariables = ["foo", "bar", "context"]
    }

chatPromptMessages :: [ChatPromptMessage]
chatPromptMessages =
  [ templateMessage System "Here's some context: {context}"
  , templateMessage User "Hello {foo}, I'm {bar}. Thanks for the {context}"
  , templateMessage Assistant "I'm an AI. I'm {foo}. I'm {bar}."
  , templateMessage User "I'm a generic message. I'm {foo}. I'm {bar}."
  ]

expectedFormattedPrompt :: Text
expectedFormattedPrompt =
  T.intercalate
    "\n"
    [ "System: Here's some context: context"
    , "Human: Hello foo, I'm bar. Thanks for the context"
    , "AI: I'm an AI. I'm foo. I'm bar."
    , "Human: I'm a generic message. I'm foo. I'm bar."
    ]