feat: add LLM import pipeline with file upload and text input

This commit is contained in:
2026-05-21 10:29:35 -04:00
parent b179135136
commit 578d1ae068
4 changed files with 221 additions and 35 deletions
+8 -2
View File
@@ -83,6 +83,12 @@ main = do
putStrLn $ "[roux] config file: " <> optConfigFile opts putStrLn $ "[roux] config file: " <> optConfigFile opts
-- Load config (file may be missing; LLM features disabled gracefully) -- Load config (file may be missing; LLM features disabled gracefully)
_config <- Config.loadConfig (optConfigFile opts) configResult <- Config.loadConfig (optConfigFile opts)
waiApp <- Roux.app (optRecipeDir opts) let config = case configResult of
Right cfg -> cfg
Left _ -> Config.RouxConfig Nothing
case configResult of
Left err -> putStrLn $ "[roux] warning: " <> err <> " — LLM features disabled"
Right _ -> pure ()
waiApp <- Roux.app (optRecipeDir opts) config
Warp.runSettings settings waiApp Warp.runSettings settings waiApp
+1
View File
@@ -29,6 +29,7 @@ ghc-options:
dependencies: dependencies:
- base >= 4.7 && < 5 - base >= 4.7 && < 5
- aeson - aeson
- base64-bytestring
- json-fleece-aeson - json-fleece-aeson
- json-fleece-core - json-fleece-core
- blaze-html - blaze-html
+4
View File
@@ -42,6 +42,7 @@ library
build-depends: build-depends:
aeson aeson
, base >=4.7 && <5 , base >=4.7 && <5
, base64-bytestring
, blaze-html , blaze-html
, blaze-markup , blaze-markup
, bytestring , bytestring
@@ -79,6 +80,7 @@ executable check-recipes
build-depends: build-depends:
aeson aeson
, base >=4.7 && <5 , base >=4.7 && <5
, base64-bytestring
, blaze-html , blaze-html
, blaze-markup , blaze-markup
, bytestring , bytestring
@@ -117,6 +119,7 @@ executable roux-server
build-depends: build-depends:
aeson aeson
, base >=4.7 && <5 , base >=4.7 && <5
, base64-bytestring
, blaze-html , blaze-html
, blaze-markup , blaze-markup
, bytestring , bytestring
@@ -161,6 +164,7 @@ test-suite roux-server-test
build-depends: build-depends:
aeson aeson
, base >=4.7 && <5 , base >=4.7 && <5
, base64-bytestring
, blaze-html , blaze-html
, blaze-markup , blaze-markup
, bytestring , bytestring
+208 -33
View File
@@ -14,10 +14,11 @@ module Roux.Server (
import Control.Concurrent (forkIO, threadDelay) import Control.Concurrent (forkIO, threadDelay)
import Control.Exception (IOException, SomeException, catch, finally, try) import Control.Exception (IOException, SomeException, catch, finally, try)
import Control.Monad (forM_, forever, when) import Control.Monad (forM_, forever, when)
import Data.ByteString.Base64 (decode)
import Data.ByteString.Builder (lazyByteString) import Data.ByteString.Builder (lazyByteString)
import Data.ByteString.Lazy (ByteString) import Data.ByteString.Lazy (ByteString)
import qualified Data.ByteString.Lazy as LB import qualified Data.ByteString.Lazy as LB
import Data.Char (digitToInt, toLower) import Data.Char (digitToInt, isAlphaNum, toLower)
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef) import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef)
import Data.List (isSuffixOf) import Data.List (isSuffixOf)
import Data.Map.Strict (Map) import Data.Map.Strict (Map)
@@ -29,7 +30,7 @@ import Data.Time.Clock (diffUTCTime, getCurrentTime)
import qualified Network.HTTP.Types as HTTP import qualified Network.HTTP.Types as HTTP
import qualified Network.Wai as Wai import qualified Network.Wai as Wai
import System.Directory (doesFileExist, findExecutable, makeAbsolute) import System.Directory (createDirectoryIfMissing, doesFileExist, findExecutable, makeAbsolute, removeFile)
import System.FSNotify ( import System.FSNotify (
Event (..), Event (..),
EventIsDirectory (..), EventIsDirectory (..),
@@ -45,6 +46,7 @@ import qualified Data.Aeson as A
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import Data.Maybe (mapMaybe) import Data.Maybe (mapMaybe)
import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding as TE
import Roux.Config (AnthropicConfig (..), RouxConfig (..))
import qualified Roux.CooklangPrint as CooklangPrint import qualified Roux.CooklangPrint as CooklangPrint
import qualified Roux.Html as Html import qualified Roux.Html as Html
import qualified Roux.RecipeIndex as Idx import qualified Roux.RecipeIndex as Idx
@@ -65,12 +67,12 @@ readRequestBody req = do
then pure (reverse acc) then pure (reverse acc)
else gather (chunk : acc) else gather (chunk : acc)
{- | Build the WAI 'Application' given a path to the recipe directory. {- | Build the WAI 'Application' given a path to the recipe directory and config.
Recipes are scanned at startup and then watched live via fsnotify. Recipes are scanned at startup and then watched live via fsnotify.
-} -}
app :: FilePath -> IO Wai.Application app :: FilePath -> RouxConfig -> IO Wai.Application
app recipeDir = do app recipeDir config = do
putStrLn $ "[roux] scanning recipes from " <> recipeDir putStrLn $ "[roux] scanning recipes from " <> recipeDir
recipes <- Idx.scanRecipes recipeDir recipes <- Idx.scanRecipes recipeDir
let count = length recipes let count = length recipes
@@ -92,7 +94,7 @@ app recipeDir = do
forkIO $ forkIO $
watchRecipes absDir state changeLog `catch` \e -> watchRecipes absDir state changeLog `catch` \e ->
putStrLn $ "[roux] watcher thread crashed: " <> show (e :: SomeException) putStrLn $ "[roux] watcher thread crashed: " <> show (e :: SomeException)
pure (router absDir state changeLog) pure (router absDir config state changeLog)
where where
isRight (Right _) = True isRight (Right _) = True
isRight (Left _) = False isRight (Left _) = False
@@ -174,19 +176,21 @@ sseHandler changeLog _request respond = do
go `finally` pure () go `finally` pure ()
-- | Route requests — SSE endpoint, recipe pages, or import. -- | Route requests — SSE endpoint, recipe pages, or import.
router :: FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> ChangeLog -> Wai.Application router :: FilePath -> RouxConfig -> IORef (Map FilePath Idx.RecipeInfo) -> ChangeLog -> Wai.Application
router recipeDir state changeLog request respond = router recipeDir config state changeLog request respond =
case Wai.pathInfo request of case Wai.pathInfo request of
["events"] -> sseHandler changeLog request respond ["events"] -> sseHandler changeLog request respond
["import"] -> importHandler recipeDir state request respond ["import"] -> importHandler recipeDir config state request respond
_ -> handleRequest state request respond _ -> handleRequest state request respond
{- | Handle GET and POST requests to /import. {- | Handle GET and POST requests to /import.
GET — show the import form. GET — show the import form.
POST — scrape the URL, convert to Cooklang, save the file. POST — dispatch based on input: URL (existing scraper), text, or file (Anthropic).
Files are uploaded as base64-encoded content via hidden form fields
(file-b64, file-name, file-mime) set by client-side JavaScript.
-} -}
importHandler :: FilePath -> IORef (Map FilePath Idx.RecipeInfo) -> Wai.Application importHandler :: FilePath -> RouxConfig -> IORef (Map FilePath Idx.RecipeInfo) -> Wai.Application
importHandler recipeDir _state request respond = importHandler recipeDir config _state request respond =
case Wai.requestMethod request of case Wai.requestMethod request of
"GET" -> "GET" ->
respond (htmlResponse (Html.importPage Nothing)) respond (htmlResponse (Html.importPage Nothing))
@@ -194,27 +198,47 @@ importHandler recipeDir _state request respond =
body <- readRequestBody request body <- readRequestBody request
let params = parseFormBody body let params = parseFormBody body
url = maybe "" id (lookup "url" params) url = maybe "" id (lookup "url" params)
if T.null url text = maybe "" id (lookup "text" params)
then respond (htmlResponse (Html.importPage (Just ("URL is required" :: Text)))) fileB64 = maybe "" id (lookup "file-b64" params)
else do fileName = maybe "" id (lookup "file-name" params)
putStrLn $ "[roux] import request for " <> T.unpack url fileMime = maybe "" id (lookup "file-mime" params)
result <- try $ runImportPipeline recipeDir url hasFile = not (T.null fileB64)
case result of
Left (e :: SomeException) -> putStrLn $
respond $ "[roux] import request: url="
htmlResponse $ <> T.unpack url
Html.importResultPage $ <> " text="
Html.ImportError ("Unexpected error: " <> T.pack (show e)) <> show (not (T.null text))
Right (Left err) -> <> " file="
respond $ <> show hasFile
htmlResponse $
Html.importResultPage err result <- try $ case (hasFile, not (T.null text), not (T.null url)) of
Right (Right filename) -> -- File takes priority, then text, then URL
respond $ (True, _, _) ->
Wai.responseLBS runFileImport recipeDir config fileName fileMime fileB64
HTTP.status303 (_, True, _) ->
[("Location", "/recipes/" <> TE.encodeUtf8 (T.pack filename))] runTextImport recipeDir config text
"" (_, _, True) ->
runImportPipeline recipeDir url
_ ->
pure (Left (Html.ImportError "Provide a URL, recipe text, or file to import."))
case result of
Left (e :: SomeException) ->
respond $
htmlResponse $
Html.importResultPage $
Html.ImportError ("Unexpected error: " <> T.pack (show e))
Right (Left err) ->
respond $
htmlResponse $
Html.importResultPage err
Right (Right filename) ->
respond $
Wai.responseLBS
HTTP.status303
[("Location", "/recipes/" <> TE.encodeUtf8 (T.pack filename))]
""
_ -> respond notFound _ -> respond notFound
-- | Parse URL-encoded form body into key-value pairs. -- | Parse URL-encoded form body into key-value pairs.
@@ -336,6 +360,157 @@ runImportPipeline recipeDir url = do
putStrLn $ "[roux] successfully imported " <> T.unpack url <> " as " <> filename putStrLn $ "[roux] successfully imported " <> T.unpack url <> " as " <> filename
return $ Right filename return $ Right filename
{- | Import a recipe from an uploaded file via the LLM pipeline.
The file content is base64-encoded in the form data.
-}
runFileImport ::
-- | recipe directory
FilePath ->
-- | server config (for Anthropic settings)
RouxConfig ->
-- | original filename
Text ->
-- | MIME type
Text ->
-- | base64-encoded file content
Text ->
IO (Either Html.ImportError FilePath)
runFileImport recipeDir config originalName _mimeType b64Text = do
-- Decode base64
case decode (TE.encodeUtf8 b64Text) of
Left err ->
pure $
Left $
Html.ImportError $
"Could not decode uploaded file: " <> T.pack err
Right content -> do
-- Ensure recipe dir exists
createDirectoryIfMissing True recipeDir
-- Save the original file
let sourceFilename = map (\c -> if isAlphaNum c || c == '.' || c == '-' || c == '_' then c else '_') (T.unpack originalName)
sourcePath = recipeDir </> sourceFilename
BS.writeFile sourcePath content
putStrLn $ "[roux] saved uploaded file to " <> sourcePath
-- Call the LLM pipeline with the saved file
case rcAnthropic config of
Nothing ->
pure $
Left $
Html.ImportError $
"LLM import is not configured. Add an anthropic.api_key to the config file."
Just anthropicConfig -> do
result <- runLLMPipeline recipeDir anthropicConfig (Just sourcePath) ""
case result of
Right filename -> pure $ Right filename
Left err -> pure $ Left err
-- | Import a recipe from pasted text via the LLM pipeline.
runTextImport ::
FilePath ->
RouxConfig ->
Text ->
IO (Either Html.ImportError FilePath)
runTextImport recipeDir config text = do
case rcAnthropic config of
Nothing ->
pure $
Left $
Html.ImportError $
"LLM import is not configured. Add an anthropic.api_key to the config file."
Just anthropicConfig ->
runLLMPipeline recipeDir anthropicConfig Nothing text
-- | Run the full LLM import pipeline: call convert-to-cooklang -> parse -> save.
runLLMPipeline ::
-- | recipe directory
FilePath ->
-- | API config
AnthropicConfig ->
-- | Just path = uploaded source file to keep
Maybe FilePath ->
-- | recipe text (or empty if file)
Text ->
IO (Either Html.ImportError FilePath)
runLLMPipeline recipeDir anthropicConfig mSourceFile text = do
-- Step 1: locate the script
script <- findConvertScript
-- Step 2: write a temp config for the subprocess
let configPath = recipeDir </> ".convert-config.yaml"
writeConvertConfig configPath anthropicConfig
-- Step 3: build subprocess args
let args = case mSourceFile of
Just fp -> ["--config", configPath, "--file", fp]
Nothing -> ["--config", configPath, "--text", T.unpack text]
putStrLn $ "[roux] running " <> script <> " for LLM import"
(exitCode, stdoutBytes, stderrBytes) <-
readProcessBytes script args
-- Clean up temp config
_ <- try (removeFile configPath) :: IO (Either IOException ())
case exitCode of
ExitFailure _ -> do
let errMsg = TE.decodeUtf8 (LB.toStrict stderrBytes)
putStrLn $ "[roux] convert-to-cooklang failed: " <> T.unpack errMsg
return $
Left $
Html.ImportError $
"Could not convert recipe: " <> errMsg
ExitSuccess -> do
-- Step 4: parse the Cooklang output to extract the title
let cooklangText = TE.decodeUtf8 (LB.toStrict stdoutBytes)
case parseRecipeTitle cooklangText of
Nothing -> do
putStrLn "[roux] could not extract title from Cooklang output"
return $
Left $
Html.ImportError $
"Could not determine recipe title from AI output."
Just title -> do
let filename = CooklangPrint.filenameFromTitle title
filepath = recipeDir </> filename
-- Step 5: write the .cook file
putStrLn $ "[roux] writing imported recipe to " <> filepath
LB.writeFile filepath stdoutBytes
putStrLn $ "[roux] successfully imported recipe as " <> filename
return $ Right filename
-- | Locate the convert-to-cooklang script.
findConvertScript :: IO FilePath
findConvertScript = do
mPath <- findExecutable "convert-to-cooklang"
case mPath of
Just p -> pure p
Nothing -> pure "scripts/convert-to-cooklang"
-- | Write a temporary YAML config for the subprocess.
writeConvertConfig :: FilePath -> AnthropicConfig -> IO ()
writeConvertConfig path ac = do
let pairs =
[ "anthropic:"
, " api_key: " <> T.unpack (acApiKey ac)
]
++ case acBaseUrl ac of
Just url -> [" base_url: " <> T.unpack url]
Nothing -> []
++ case acModel ac of
Just m -> [" model: " <> T.unpack m]
Nothing -> []
writeFile path (unlines pairs)
{- | Parse the recipe title from Cooklang front matter.
Looks for "title: ..." in the YAML front matter block.
-}
parseRecipeTitle :: Text -> Maybe Text
parseRecipeTitle cooklang =
case T.breakOn "---" cooklang of
(_, afterFirst) ->
let (_separator, inside) = T.breakOn "---" (T.drop 3 afterFirst)
frontMatter = T.take (T.length inside) (T.drop 3 afterFirst)
in case T.breakOn "title:" frontMatter of
(_, afterTitle) ->
let titleLine = T.takeWhile (/= '\n') (T.drop 6 afterTitle)
cleaned = T.strip titleLine
in if T.null cleaned then Nothing else Just cleaned
{- | Watch a directory for .cook file changes and update the shared state. {- | Watch a directory for .cook file changes and update the shared state.
Uses watchDir callback for simplicity. Uses watchDir callback for simplicity.
-} -}