praxo-core-0.3.0: src/Praxo/Middleware.hs
module Praxo.Middleware
( piiRedact, redactValue, extractJson, StructuredOutput(..), structuredOutput, applyStructured
, CallLimit(..), callLimit, callLimitModel, callLimitTool
, toolSelection, selectTools, checkToolSelected
, fallbackGenerate, fallbackEither
) where
import Data.Char (isAlphaNum, isDigit, isLetter)
import Data.List (findIndex)
import Praxo.Json (Json(..), parseJson)
import Praxo.JsonSchema (validateSchema)
piiRedact :: String -> String
piiRedact = redactPhone . redactCard . redactSsn . redactEmails
redactEmails :: String -> String
redactEmails text = go 0 ""
where
n = length text
go cursor output
| cursor >= n = output ++ drop cursor text
| otherwise = case findIndex (== '@') (drop cursor text) of
Nothing -> output ++ drop cursor text
Just offset ->
let at = cursor + offset
first = expandLeft at
endPos = expandRight (at + 1)
dots = [i | i <- [at + 1 .. endPos - 1], atChar i == '.']
dot = if null dots then 0 else last dots
suffix = expandLetters (dot + 1) endPos
in if first < at && dot > at + 1 && suffix - dot - 1 >= 2
then go suffix (output ++ take (first - cursor) (drop cursor text) ++ "[REDACTED:email]")
else go (at + 1) (output ++ take (at - cursor + 1) (drop cursor text))
atChar i = text !! i
expandLeft i = if i > 0 && atChar (i - 1) `elem` emailLocal then expandLeft (i - 1) else i
expandRight i = if i < n && atChar i `elem` emailDomain then expandRight (i + 1) else i
expandLetters i limit = if i < limit && isLetter (atChar i) then expandLetters (i + 1) limit else i
emailLocal = ['a'..'z'] ++ ['A'..'Z'] ++ ['0'..'9'] ++ "._%+-"
emailDomain = ['a'..'z'] ++ ['A'..'Z'] ++ ['0'..'9'] ++ ".-"
redactSsn :: String -> String
redactSsn = replaceMatches match "[REDACTED:ssn]"
where
match i text
| i + 10 < length text && boundaryLeft i text && digitsAt i 3 text
&& atChar (i + 3) text == '-' && digitsAt (i + 4) 2 text
&& atChar (i + 6) text == '-' && digitsAt (i + 7) 4 text
&& boundaryRight (i + 10) text = Just (i + 10)
| otherwise = Nothing
atChar i text = text !! i
boundaryLeft i text = i == 0 || not (isWord (text !! (i - 1)))
boundaryRight i text = i >= length text - 1 || not (isWord (text !! (i + 1)))
digitsAt i count text = i >= 0 && i + count <= length text && all isDigit (take count (drop i text))
isWord c = isAlphaNum c || c == '_'
redactCard :: String -> String
redactCard = replaceMatches match "[REDACTED:credit_card]"
where
match i text
| boundaryLeft i text = case parseGroups i text of
Just finish | boundaryRight finish text -> Just finish
_ -> Nothing
| otherwise = Nothing
parseGroups i text = go i (1 :: Int)
where
go position group
| group > 4 = Just (position - 1)
| not (digitsAt position 4 text) = Nothing
| otherwise =
let next = position + 4
after = if group < 4 && next < length text && text !! next `elem` " -" then next + 1 else next
in go after (group + 1)
digitsAt i count text = i + count <= length text && all isDigit (take count (drop i text))
boundaryLeft i text = i == 0 || not (isWord (text !! (i - 1)))
boundaryRight i text = i >= length text - 1 || not (isWord (text !! (i + 1)))
isWord c = isAlphaNum c || c == '_'
redactPhone :: String -> String
redactPhone = replaceMatches match "[REDACTED:phone]"
where
match i text
| not (boundaryLeft i text) = Nothing
| otherwise = case [finish | Just finish <- candidates, boundaryRightAt text finish] of
(finish:_) -> Just finish
[] -> Nothing
where
candidates =
[parseBody i text] ++
[parseBody (skipSeparator (i + 1) text) text | atChar i text == '1'] ++
[parseBody (skipSeparator (i + 2) text) text | take 2 (drop i text) == "+1"]
parseBody start text = go start
where
go position
| position > length text = Nothing
| position < length text && text !! position == '(' =
if digitsAt (position + 1) 3 text && position + 4 < length text && text !! (position + 4) == ')'
then finish (position + 5)
else Nothing
| digitsAt position 3 text = finish (position + 3)
| otherwise = Nothing
finish position =
let p1 = skipSeparator position text
in if digitsAt p1 3 text
then let p2 = skipSeparator (p1 + 3) text
in if digitsAt p2 4 text then Just (p2 + 3) else Nothing
else Nothing
digitsAt i count text = i >= 0 && i + count <= length text && all isDigit (take count (drop i text))
atChar i text = if i < length text then text !! i else '\0'
skipSeparator i text = if i < length text && text !! i `elem` " -." then i + 1 else i
boundaryLeft i text = i == 0 || not (isWord (text !! (i - 1)))
boundaryRightAt text finish = finish >= length text - 1 || not (isWord (text !! (finish + 1)))
isWord c = isAlphaNum c || c == '_'
replaceMatches :: (Int -> String -> Maybe Int) -> String -> String -> String
replaceMatches match replacement text = go 0 ""
where
n = length text
go cursor output
| cursor >= n = output
| Just finish <- match cursor text = go (finish + 1) (output ++ replacement)
| otherwise = go (cursor + 1) (output ++ [text !! cursor])
redactValue :: Json -> Json
redactValue (JString text) = JString (piiRedact text)
redactValue (JArray values) = JArray (map redactValue values)
redactValue (JObject fields) = JObject [(key, redactValue value) | (key, value) <- fields]
redactValue value = value
extractJson :: String -> Maybe Json
extractJson text =
let trimmed = trim text
stripped = if take 3 trimmed == "```" then trim (stripFence trimmed) else trimmed
in case findIndex (`elem` "{[" ) stripped of
Nothing -> Nothing
Just start -> either (const Nothing) Just (parseJson (drop start stripped))
stripFence :: String -> String
stripFence text = case break (== '\n') text of
(_, '\n':rest) -> reverse (dropWhile (== '\n') (reverse rest))
_ -> text
trim :: String -> String
trim = reverse . dropWhile (`elem` " \t\r\n") . reverse . dropWhile (`elem` " \t\r\n")
data StructuredOutput = StructuredOutput
{ outputSchema :: Json
, maxRepairs :: Int
, repairer :: Maybe (String -> Either String String)
}
structuredOutput :: Json -> Int -> Maybe (String -> Either String String) -> StructuredOutput
structuredOutput = StructuredOutput
applyStructured :: StructuredOutput -> String -> Either String String
applyStructured config = attempt 0
where
attempt count text =
case extractJson text of
Just value | either (const False) null (validateSchema (outputSchema config) value) -> Right text
_ | count >= maxRepairs config -> Left "AGENT_STRUCTURED_OUTPUT"
_ -> case repairer config of
Nothing -> Left "AGENT_STRUCTURED_OUTPUT"
Just repair -> case repair text of
Left errorCode -> Left errorCode
Right repaired
| null (trim repaired) -> Left "AGENT_STRUCTURED_OUTPUT"
| otherwise -> attempt (count + 1) repaired
data CallLimit = CallLimit
{ limitModel :: Int
, limitTool :: Int
, modelsCalled :: Int
, toolsCalled :: Int
} deriving (Eq, Show)
callLimit :: Int -> Int -> CallLimit
callLimit model tool = CallLimit model tool 0 0
callLimitModel :: CallLimit -> Either String CallLimit
callLimitModel limit =
let updated = limit { modelsCalled = modelsCalled limit + 1 }
in if limitModel limit > 0 && modelsCalled updated > limitModel limit
then Left "AGENT_MODEL_CALL_LIMIT"
else Right updated
callLimitTool :: CallLimit -> Either String CallLimit
callLimitTool limit =
let updated = limit { toolsCalled = toolsCalled limit + 1 }
in if limitTool limit > 0 && toolsCalled updated > limitTool limit
then Left "AGENT_TOOL_CALL_LIMIT"
else Right updated
type ToolSelection = [String]
toolSelection :: [String] -> ToolSelection
toolSelection = id
selectTools :: ToolSelection -> [String] -> [String]
selectTools selection = filter (`elem` selection)
checkToolSelected :: ToolSelection -> String -> Either String ()
checkToolSelected selection name
| name `elem` selection = Right ()
| otherwise = Left "AGENT_TOOL_NOT_SELECTED"
fallbackEither :: Either e a -> Either e a -> Either e a
fallbackEither primary fallback = case primary of
Left _ -> fallback
Right _ -> primary
fallbackGenerate :: IO (Either e a) -> IO (Either e a) -> IO (Either e a)
fallbackGenerate primary fallback = do
result <- primary
case result of
Left _ -> fallback
Right _ -> pure result