langchain-hs-0.0.5.0: src/Langchain/DocumentLoader/Csv.hs
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
{- |
Module : Langchain.DocumentLoader.Csv
Description : CSV file document loader
Copyright : (c) 2025-2026 Tushar Adhatrao
License : MIT
Maintainer : Tushar Adhatrao <tusharadhatrao@gmail.com>
Stability : experimental
Loads CSV files as LangChain Documents where each row produces a Document.
-}
module Langchain.DocumentLoader.Csv
( CsvLoader (..)
, defaultCsvLoader
, parseCsvRows
) where
import Control.Exception (try)
import Control.Monad.Except (throwError)
import Control.Monad.IO.Class (liftIO)
import Data.Aeson (Value (..))
import qualified Data.Map.Strict as Map
import qualified Data.Text as TS
import Data.Text.Lazy (Text)
import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.IO as TLIO
import Langchain.Core.Error (documentLoaderError)
import Langchain.DocumentLoader.Core (BaseLoader (..), Document (..))
import Langchain.TextSplitter.Character (defaultCharacterSplitterOps, splitText)
-- | Configuration options for CSV loader
data CsvLoader = CsvLoader
{ csvFilePath :: FilePath
, csvDelimiter :: Char
, csvContentColumns :: Maybe [TS.Text]
-- ^ Optional list of columns to include in pageContent. If Nothing, all columns are concatenated.
, csvSplitter :: Maybe (Text -> [Text])
}
-- | Default CSV loader configuration
defaultCsvLoader :: FilePath -> CsvLoader
defaultCsvLoader path =
CsvLoader
{ csvFilePath = path
, csvDelimiter = ','
, csvContentColumns = Nothing
, csvSplitter = Nothing
}
instance BaseLoader CsvLoader where
load loader = do
contentRes <- liftIO $ try $ TLIO.readFile (csvFilePath loader)
content <- case contentRes of
Left err ->
throwError $
documentLoaderError
(TS.pack $ "Failed to read CSV file: " ++ show (err :: IOError))
(Just "CsvLoader")
Nothing
Right c -> pure c
let rows = parseCsvRows (csvDelimiter loader) content
case rows of
[] -> pure []
(headers : dataRows) -> do
let headerTexts = map (TS.pack . TL.unpack . TL.strip) headers
docs =
[ makeDocument headerTexts (map TL.strip row) (csvContentColumns loader) (csvFilePath loader) idx
| (idx, row) <- zip [1 ..] dataRows
, not (null row) && not (all TL.null row)
]
pure docs
loadAndSplit loader = do
docs <- load loader
let splitter = case csvSplitter loader of
Just s -> s
Nothing -> splitText defaultCharacterSplitterOps
pure $ concatMap (splitter . pageContent) docs
makeDocument :: [TS.Text] -> [Text] -> Maybe [TS.Text] -> FilePath -> Int -> Document
makeDocument headers values mbSelectedCols filePath rowIdx =
let pairs = zip headers values
metaMap =
Map.fromList
[ (h, String (TS.pack $ TL.unpack val))
| (h, val) <- pairs
]
metaWithSource =
Map.insert "source" (String $ TS.pack filePath) $
Map.insert "row" (Number $ fromIntegral rowIdx) metaMap
contentLines = case mbSelectedCols of
Just selected ->
[ h <> ": " <> TS.pack (TL.unpack val)
| (h, val) <- pairs
, h `elem` selected
]
Nothing ->
[ h <> ": " <> TS.pack (TL.unpack val)
| (h, val) <- pairs
]
content = TL.pack $ TS.unpack $ TS.intercalate "\n" contentLines
in Document content metaWithSource
-- | Robust CSV line parser supporting quoted cells with commas
parseCsvRows :: Char -> Text -> [[Text]]
parseCsvRows delim text =
let allLines = TL.lines text
in map (parseCsvLine delim) allLines
parseCsvLine :: Char -> Text -> [Text]
parseCsvLine delim line = go False [] "" (TL.unpack line)
where
go :: Bool -> [Text] -> String -> String -> [Text]
go _ acc cur [] = reverse (TL.pack (reverse cur) : acc)
go inQuote acc cur ('"' : cs) =
case cs of
('"' : rest) -> go inQuote acc ('"' : cur) rest
_ -> go (not inQuote) acc cur cs
go inQuote acc cur (c : cs)
| c == delim && not inQuote =
go inQuote (TL.pack (reverse cur) : acc) "" cs
| otherwise =
go inQuote acc (c : cur) cs