Compare commits

...
Author SHA1 Message Date
Ben 039a498377 feat(parsers): add LlamaParse file parser
Adds LlamaParseParser as a third file parser alongside markitdown (default,
local) and iris (Vectorize cloud). LlamaParse is LlamaIndex's hosted parsing
service, well-suited to complex PDFs with tables, charts, and multi-column
layouts.

- hindsight_api/engine/parsers/llama_parse.py: async httpx-based parser
  that uploads, polls the job, and fetches markdown
- hindsight_api/engine/parsers/__init__.py: export LlamaParseParser
- hindsight_api/config.py: add HINDSIGHT_API_FILE_PARSER_LLAMA_PARSE_API_KEY
  env var, file_parser_llama_parse_api_key dataclass field, and from_env loader
- hindsight_api/engine/memory_engine.py: conditionally register when
  the API key is set (mirrors iris registration pattern)
- tests/test_llama_parse_parser.py: integration tests that skip cleanly
  without the API key
- hindsight-docs/docs/developer/configuration.md: document the new parser

The REST endpoint requires no changes: the parser allowlist is built
dynamically from the registry, so 'llama_parse' becomes a valid choice in
the parser fallback chain (e.g. parser_priority=['llama_parse','markitdown'])
once the env var is set.

Verified: 21 parser-related tests pass (17 file_retain + 2 iris skip + 2
llama_parse skip); ruff and ty checks pass.
2026-04-27 14:53:19 -04:00
6 changed files with 224 additions and 1 deletions
@@ -331,6 +331,7 @@ ENV_FILE_PARSER = "HINDSIGHT_API_FILE_PARSER"
ENV_FILE_PARSER_ALLOWLIST = "HINDSIGHT_API_FILE_PARSER_ALLOWLIST"
ENV_FILE_PARSER_IRIS_TOKEN = "HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN"
ENV_FILE_PARSER_IRIS_ORG_ID = "HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID"
ENV_FILE_PARSER_LLAMA_PARSE_API_KEY = "HINDSIGHT_API_FILE_PARSER_LLAMA_PARSE_API_KEY"
ENV_FILE_CONVERSION_MAX_BATCH_SIZE_MB = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE_MB"
ENV_FILE_CONVERSION_MAX_BATCH_SIZE = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE"
ENV_ENABLE_FILE_UPLOAD_API = "HINDSIGHT_API_ENABLE_FILE_UPLOAD_API"
@@ -1009,6 +1010,7 @@ class HindsightConfig:
file_parser_allowlist: list[str] | None # Parsers clients may request (None = all registered)
file_parser_iris_token: str | None # Vectorize API token for iris parser (VECTORIZE_TOKEN)
file_parser_iris_org_id: str | None # Vectorize org ID for iris parser (VECTORIZE_ORG_ID)
file_parser_llama_parse_api_key: str | None # LlamaCloud API key for llama_parse parser
file_conversion_max_batch_size_mb: int # Max total batch size in MB (all files combined)
file_conversion_max_batch_size: int # Max files per request
enable_file_upload_api: bool
@@ -1148,6 +1150,7 @@ class HindsightConfig:
"file_storage_azure_account_key",
# File parser credentials
"file_parser_iris_token",
"file_parser_llama_parse_api_key",
}
# CONFIGURABLE_FIELDS: Safe behavioral settings that can be customized per-tenant/bank
@@ -1630,6 +1633,7 @@ class HindsightConfig:
else None,
file_parser_iris_token=os.getenv(ENV_FILE_PARSER_IRIS_TOKEN) or None,
file_parser_iris_org_id=os.getenv(ENV_FILE_PARSER_IRIS_ORG_ID) or None,
file_parser_llama_parse_api_key=os.getenv(ENV_FILE_PARSER_LLAMA_PARSE_API_KEY) or None,
file_conversion_max_batch_size_mb=int(
os.getenv(ENV_FILE_CONVERSION_MAX_BATCH_SIZE_MB, str(DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE_MB))
),
@@ -1905,7 +1905,7 @@ class MemoryEngine(MemoryEngineInterface):
logger.debug(f"File storage initialized ({config.file_storage_type})")
# Initialize parser registry
from .parsers import FileParserRegistry, IrisParser, MarkitdownParser
from .parsers import FileParserRegistry, IrisParser, LlamaParseParser, MarkitdownParser
self._parser_registry = FileParserRegistry()
try:
@@ -1920,6 +1920,12 @@ class MemoryEngine(MemoryEngineInterface):
logger.debug("Registered iris parser")
else:
logger.debug("Iris parser not registered (VECTORIZE_TOKEN or VECTORIZE_ORG_ID not set)")
llama_parse_key = config.file_parser_llama_parse_api_key
if llama_parse_key:
self._parser_registry.register(LlamaParseParser(api_key=llama_parse_key))
logger.debug("Registered llama_parse parser")
else:
logger.debug("LlamaParse parser not registered (HINDSIGHT_API_FILE_PARSER_LLAMA_PARSE_API_KEY not set)")
# Initialize webhook manager
from ..webhooks import WebhookManager
@@ -5,12 +5,14 @@ from dataclasses import dataclass
from .base import FileParser, UnsupportedFileTypeError
from .iris import IrisParser
from .llama_parse import LlamaParseParser
from .markitdown import MarkitdownParser
__all__ = [
"FileParser",
"UnsupportedFileTypeError",
"IrisParser",
"LlamaParseParser",
"MarkitdownParser",
"FileParserRegistry",
"ConvertResult",
@@ -0,0 +1,123 @@
"""LlamaParse parser implementation using the LlamaIndex Cloud parsing API."""
import asyncio
import logging
import mimetypes
import time
import httpx
from .base import FileParser, UnsupportedFileTypeError
logger = logging.getLogger(__name__)
_LLAMA_PARSE_BASE_URL = "https://api.cloud.llamaindex.ai/api/parsing"
_DEFAULT_POLL_INTERVAL = 2.0 # seconds
_DEFAULT_TIMEOUT = 300.0 # seconds
class LlamaParseParser(FileParser):
"""
LlamaParse file parser using LlamaIndex's hosted parsing service.
Uploads files to the LlamaParse API, polls until the parse job completes,
and returns the resulting markdown. The API determines which file types
are supported — UnsupportedFileTypeError is raised if the file is rejected.
Authentication:
Requires HINDSIGHT_API_FILE_PARSER_LLAMA_PARSE_API_KEY environment
variable, or pass the key explicitly via the constructor.
"""
def __init__(
self,
api_key: str,
poll_interval: float = _DEFAULT_POLL_INTERVAL,
timeout: float = _DEFAULT_TIMEOUT,
):
"""
Initialize llama_parse parser.
Args:
api_key: LlamaCloud API key (typically starts with "llx-")
poll_interval: Seconds between status poll requests (default: 2)
timeout: Maximum seconds to wait for parsing (default: 300)
"""
self._api_key = api_key
self._poll_interval = poll_interval
self._timeout = timeout
self._auth_headers = {"Authorization": f"Bearer {api_key}"}
async def convert(self, file_data: bytes, filename: str) -> str:
"""
Parse file to markdown using the LlamaParse API.
Raises:
UnsupportedFileTypeError: If the LlamaParse API rejects the file type (4xx)
RuntimeError: If parsing fails for another reason
"""
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, read=120.0)) as client:
# Step 1: Upload file and start parse job
# LlamaParse expects multipart/form-data with the file under "file"
upload_resp = await client.post(
f"{_LLAMA_PARSE_BASE_URL}/upload",
headers=self._auth_headers,
files={"file": (filename, bytes(file_data), content_type)},
)
_raise_for_status(upload_resp, filename, "upload")
job_id: str = upload_resp.json()["id"]
# Step 2: Poll job status until SUCCESS or ERROR
deadline = time.monotonic() + self._timeout
while True:
status_resp = await client.get(
f"{_LLAMA_PARSE_BASE_URL}/job/{job_id}",
headers=self._auth_headers,
)
_raise_for_status(status_resp, filename, "poll job status")
status_data = status_resp.json()
status = status_data.get("status")
if status == "SUCCESS":
break
if status in ("ERROR", "CANCELLED"):
error = status_data.get("error_code") or status_data.get("error") or "unknown error"
raise RuntimeError(f"LlamaParse job failed for '{filename}': {error}")
if time.monotonic() >= deadline:
raise RuntimeError(f"LlamaParse job timed out after {self._timeout}s for '{filename}'")
await asyncio.sleep(self._poll_interval)
# Step 3: Fetch the markdown result
result_resp = await client.get(
f"{_LLAMA_PARSE_BASE_URL}/job/{job_id}/result/markdown",
headers=self._auth_headers,
)
_raise_for_status(result_resp, filename, "fetch markdown result")
markdown = result_resp.json().get("markdown")
if not markdown:
raise RuntimeError(f"No content extracted from '{filename}'")
return markdown
def name(self) -> str:
"""Get parser name."""
return "llama_parse"
def _raise_for_status(response: httpx.Response, filename: str, step: str) -> None:
"""
Raise an appropriate error including the response body on HTTP errors.
Raises UnsupportedFileTypeError for 4xx responses (file rejected by the API),
RuntimeError for other HTTP errors.
"""
if not response.is_error:
return
body = response.text or "<empty>"
msg = f"LlamaParse API error during {step} for '{filename}': {response.status_code} {response.reason_phrase}{body}"
if response.is_client_error:
raise UnsupportedFileTypeError(msg)
raise RuntimeError(msg)
@@ -0,0 +1,69 @@
"""
Integration tests for the LlamaParse file parser.
Tests are skipped automatically if HINDSIGHT_API_FILE_PARSER_LLAMA_PARSE_API_KEY
is not set in the environment.
"""
import os
import pytest
from hindsight_api.config import ENV_FILE_PARSER_LLAMA_PARSE_API_KEY
from hindsight_api.engine.parsers.llama_parse import LlamaParseParser
_api_key = os.getenv(ENV_FILE_PARSER_LLAMA_PARSE_API_KEY)
pytestmark = pytest.mark.skipif(
not _api_key,
reason="HINDSIGHT_API_FILE_PARSER_LLAMA_PARSE_API_KEY not set",
)
# Minimal valid PDF with the text "Hello from Hindsight"
_SAMPLE_PDF = b"""%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
/Contents 4 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> >>
endobj
4 0 obj
<< /Length 44 >>
stream
BT /F1 12 Tf 100 700 Td (Hello from Hindsight) Tj ET
endstream
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000274 00000 n
trailer << /Size 5 /Root 1 0 R >>
startxref
369
%%EOF"""
@pytest.fixture
def llama_parse_parser() -> LlamaParseParser:
return LlamaParseParser(api_key=_api_key)
@pytest.mark.asyncio
async def test_llama_parse_parser_converts_pdf(llama_parse_parser: LlamaParseParser):
"""LlamaParseParser should extract text from a valid PDF."""
result = await llama_parse_parser.convert(_SAMPLE_PDF, "sample.pdf")
assert isinstance(result, str)
assert len(result) > 0
@pytest.mark.asyncio
async def test_llama_parse_parser_name(llama_parse_parser: LlamaParseParser):
"""LlamaParseParser.name() should return 'llama_parse'."""
assert llama_parse_parser.name() == "llama_parse"
@@ -920,6 +920,25 @@ export HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID=your-org-id
export HINDSIGHT_API_FILE_PARSER=iris,markitdown
```
#### Parser: llama_parse
Cloud-based extraction via [LlamaParse](https://docs.cloud.llamaindex.ai/llamaparse) (LlamaIndex). Strong extraction for complex layouts — tables, charts, multi-column PDFs.
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_FILE_PARSER_LLAMA_PARSE_API_KEY` | LlamaCloud API key (typically starts with `llx-`) | — |
**Supported formats:** PDF, DOCX, PPTX, XLSX, HTML, EPUB, RTF, TXT, and many more — see the [LlamaParse docs](https://docs.cloud.llamaindex.ai/llamaparse/features/supported_document_types) for the full list.
```bash
# Use llama_parse as the only parser
export HINDSIGHT_API_FILE_PARSER=llama_parse
export HINDSIGHT_API_FILE_PARSER_LLAMA_PARSE_API_KEY=llx-your-api-key
# Or: try llama_parse first, fall back to markitdown
export HINDSIGHT_API_FILE_PARSER=llama_parse,markitdown
```
```bash
# Increase batch limits for large file imports
export HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE=20