feat: add LLM import pipeline with file upload and text input
This commit is contained in:
+8
-2
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
-}
|
-}
|
||||||
|
|||||||
Reference in New Issue
Block a user