packages feed

claude-1.0.1: examples/claude-structured-outputs-example/Main.hs

{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE NamedFieldPuns        #-}
{-# LANGUAGE OverloadedLists       #-}
{-# LANGUAGE OverloadedStrings     #-}

-- | Example demonstrating structured outputs with Claude
--
-- This example shows how to use:
-- 1. JSON outputs (output_format) to get structured JSON responses
-- 2. Strict tool use (strict: true) to validate tool parameters
--
-- Requires the structured-outputs-2025-11-13 beta header.
module Main where

import Data.Aeson (FromJSON(..), (.:), (.=))
import Data.Foldable (toList)
import Data.Text (Text)

import qualified Claude.V1 as V1
import qualified Claude.V1.Messages as Messages
import qualified Claude.V1.Tool as Tool
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.KeyMap as KeyMap
import qualified Data.ByteString.Lazy as LBS
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text.Encoding
import qualified Data.Text.IO as Text.IO
import qualified System.Environment as Environment

-- | Structured output type for contact extraction
data ContactInfo = ContactInfo
    { name :: Text
    , email :: Text
    , company :: Maybe Text
    , interested_in :: Text
    } deriving Show

instance FromJSON ContactInfo where
    parseJSON = Aeson.withObject "ContactInfo" $ \obj -> do
        n <- obj .: "name"
        e <- obj .: "email"
        c <- obj .: "company"
        i <- obj .: "interested_in"
        pure ContactInfo{ name = n, email = e, company = c, interested_in = i }

main :: IO ()
main = do
    key <- Text.pack <$> Environment.getEnv "ANTHROPIC_KEY"
    env <- V1.getClientEnv "https://api.anthropic.com"

    -- Use makeMethodsWith to pass the structured outputs beta header
    let options = V1.defaultClientOptions
            { V1.apiKey = key
            , V1.anthropicBeta = Just "structured-outputs-2025-11-13"
            }

    let V1.Methods{ V1.createMessage } = V1.makeMethodsWith env options

    -- Example 1: JSON outputs for data extraction
    Text.IO.putStrLn "=== Example 1: JSON Outputs (Data Extraction) ===\n"
    jsonOutputsExample createMessage

    Text.IO.putStrLn "\n"

    -- Example 2: Strict tool use for validated parameters
    Text.IO.putStrLn "=== Example 2: Strict Tool Use ===\n"
    strictToolUseExample createMessage

-- | Example using output_format to get structured JSON responses
jsonOutputsExample :: (Messages.CreateMessage -> IO Messages.MessageResponse) -> IO ()
jsonOutputsExample createMessage = do
    let emailText = "Hi there! I'm John Smith from Acme Corp (john.smith@acme.com). \
                    \I saw your demo at the conference and I'm very interested in \
                    \your Enterprise plan. Could we schedule a call next week?"

    -- Define the JSON schema for our expected output
    let outputSchema = Aeson.object
            [ "type" .= ("object" :: Text)
            , "properties" .= Aeson.object
                [ "name" .= Aeson.object
                    [ "type" .= ("string" :: Text)
                    , "description" .= ("Full name of the contact" :: Text)
                    ]
                , "email" .= Aeson.object
                    [ "type" .= ("string" :: Text)
                    , "description" .= ("Email address" :: Text)
                    ]
                , "company" .= Aeson.object
                    [ "type" .= ("string" :: Text)
                    , "description" .= ("Company name if mentioned" :: Text)
                    ]
                , "interested_in" .= Aeson.object
                    [ "type" .= ("string" :: Text)
                    , "description" .= ("What product/service they're interested in" :: Text)
                    ]
                ]
            , "required" .= (["name", "email", "interested_in"] :: [Text])
            , "additionalProperties" .= False
            ]

    let message = Messages.Message
            { Messages.role = Messages.User
            , Messages.content = [Messages.textContent $ "Extract contact information from this email:\n\n" <> emailText]
            , Messages.cache_control = Nothing
            }

    Text.IO.putStrLn $ "Input email: " <> emailText
    Text.IO.putStrLn "\nExtracting structured data..."

    response <- createMessage Messages._CreateMessage
        { Messages.model = "claude-sonnet-4-5-20250929"
        , Messages.messages = [message]
        , Messages.max_tokens = 1024
        , Messages.output_format = Just (Messages.jsonSchemaFormat outputSchema)
        }

    let Messages.MessageResponse{ Messages.content = responseContent, Messages.stop_reason = stopReason } = response

    Text.IO.putStrLn $ "Stop reason: " <> Text.pack (show stopReason)

    -- The response text should be valid JSON matching our schema
    case toList responseContent of
        [Messages.ContentBlock_Text{ Messages.text = jsonText }] -> do
            Text.IO.putStrLn $ "\nRaw JSON response:\n" <> jsonText

            -- Parse and display the structured data
            case Aeson.eitherDecodeStrict (Text.Encoding.encodeUtf8 jsonText) of
                Left err -> Text.IO.putStrLn $ "Parse error: " <> Text.pack err
                Right ContactInfo{ name, email, company, interested_in } -> do
                    Text.IO.putStrLn "\nParsed contact info:"
                    Text.IO.putStrLn $ "  Name: " <> name
                    Text.IO.putStrLn $ "  Email: " <> email
                    Text.IO.putStrLn $ "  Company: " <> maybe "(not specified)" id company
                    Text.IO.putStrLn $ "  Interested in: " <> interested_in
        _ -> Text.IO.putStrLn "Unexpected response format"

-- | Example using strict: true for validated tool parameters
strictToolUseExample :: (Messages.CreateMessage -> IO Messages.MessageResponse) -> IO ()
strictToolUseExample createMessage = do
    -- Define a tool with strict mode enabled
    -- This guarantees the tool input will match the schema exactly
    let flightSearchTool = Tool.strictFunctionTool
            "search_flights"
            (Just "Search for available flights between two cities")
            $ Aeson.object
                [ "type" .= ("object" :: Text)
                , "properties" .= Aeson.object
                    [ "origin" .= Aeson.object
                        [ "type" .= ("string" :: Text)
                        , "description" .= ("Origin airport code (e.g., SFO)" :: Text)
                        ]
                    , "destination" .= Aeson.object
                        [ "type" .= ("string" :: Text)
                        , "description" .= ("Destination airport code (e.g., JFK)" :: Text)
                        ]
                    , "date" .= Aeson.object
                        [ "type" .= ("string" :: Text)
                        , "format" .= ("date" :: Text)
                        , "description" .= ("Travel date in YYYY-MM-DD format" :: Text)
                        ]
                    , "passengers" .= Aeson.object
                        [ "type" .= ("integer" :: Text)
                        , "enum" .= ([1, 2, 3, 4, 5, 6, 7, 8, 9, 10] :: [Int])
                        , "description" .= ("Number of passengers (1-10)" :: Text)
                        ]
                    ]
                , "required" .= (["origin", "destination", "date", "passengers"] :: [Text])
                , "additionalProperties" .= False
                ]

    let message = Messages.Message
            { Messages.role = Messages.User
            , Messages.content = [Messages.textContent "Find me flights from San Francisco to New York for 2 people on January 15th, 2025"]
            , Messages.cache_control = Nothing
            }

    Text.IO.putStrLn "Asking Claude to search for flights with strict tool validation..."

    response <- createMessage Messages._CreateMessage
        { Messages.model = "claude-sonnet-4-5-20250929"
        , Messages.messages = [message]
        , Messages.max_tokens = 1024
        , Messages.tools = Just [Tool.inlineTool flightSearchTool]
        , Messages.tool_choice = Just Tool.toolChoiceAny  -- Force tool use
        }

    let Messages.MessageResponse{ Messages.content = responseContent, Messages.stop_reason = stopReason } = response

    Text.IO.putStrLn $ "Stop reason: " <> Text.pack (show stopReason)

    -- With strict mode, we're guaranteed the tool input matches the schema
    mapM_ processBlock (toList responseContent)
  where
    processBlock (Messages.ContentBlock_Tool_Use{ Messages.id = toolId, Messages.name = toolName, Messages.input = toolInput }) = do
        Text.IO.putStrLn $ "\nTool called: " <> toolName
        Text.IO.putStrLn $ "Tool ID: " <> toolId
        Text.IO.putStrLn $ "Input (guaranteed to match schema):"
        Text.IO.putStrLn $ "  " <> Text.Encoding.decodeUtf8 (LBS.toStrict (Aeson.encode toolInput))

        -- In strict mode, we can safely extract fields without error handling
        case toolInput of
            Aeson.Object obj -> do
                let origin = lookupString "origin" obj
                let destination = lookupString "destination" obj
                let date = lookupString "date" obj
                let passengers = lookupInt "passengers" obj
                Text.IO.putStrLn "\nParsed parameters:"
                Text.IO.putStrLn $ "  Origin: " <> origin
                Text.IO.putStrLn $ "  Destination: " <> destination
                Text.IO.putStrLn $ "  Date: " <> date
                Text.IO.putStrLn $ "  Passengers: " <> Text.pack (show passengers)
            _ -> pure ()

    processBlock (Messages.ContentBlock_Text{ Messages.text = t }) =
        Text.IO.putStrLn $ "Text: " <> t

    processBlock _ = pure ()

    lookupString key obj = case KeyMap.lookup key obj of
        Just (Aeson.String s) -> s
        _ -> "(missing)"

    lookupInt key obj = case KeyMap.lookup key obj of
        Just (Aeson.Number n) -> round n :: Int
        _ -> 0