Skip to content

Commit 5ee53c5

Browse files
feat(api): add optional MarkItDown OCR support (#2145)
MarkItDown advertises image extensions, but without OCR config it can fail screenshots or scanned images with low-level no-content errors. Add server-level MarkItDown OCR config that is off by default and independent from HINDSIGHT_API_LLM_*. When OCR is enabled, the OCR API key, base URL, and model are required explicitly. Wire those settings into MarkItDown's llm_client support with a built-in OCR prompt. Image uploads now fail fast with actionable errors when OCR is disabled or required settings are missing. Docs and front-end copy explain that image OCR depends on server config and requires an OpenAI-compatible OCR/vision endpoint. Closes #927
1 parent 4efa204 commit 5ee53c5

28 files changed

Lines changed: 409 additions & 32 deletions

File tree

.env.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,18 @@ HINDSIGHT_API_LOG_LEVEL=info
8787
# korean_lindera/lindera(korean), ngram(min,max), edge_ngram(min,max)
8888
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER=
8989

90+
# File Parser (Optional - uses markitdown by default)
91+
# HINDSIGHT_API_FILE_PARSER=markitdown
92+
# Enable image OCR for MarkItDown using an OpenAI-compatible OCR/vision endpoint.
93+
# These OCR settings are independent from HINDSIGHT_API_LLM_* because MarkItDown
94+
# uses the OpenAI SDK directly and requires Chat Completions image input support.
95+
# When OCR is enabled, API_KEY, BASE_URL, and MODEL are required.
96+
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED=false
97+
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY=
98+
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL=
99+
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL=
100+
# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_PROMPT=
101+
90102
# Embeddings Configuration (Optional - uses local by default)
91103
# Provider: "local" (default), "onnx", "tei", "openai", "cohere", "google", "openrouter", "zeroentropy", "litellm", or "litellm-sdk"
92104
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=local

hindsight-api-slim/hindsight_api/api/http.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6755,7 +6755,7 @@ async def api_retain(
67556755
description="Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories.\n\n"
67566756
"This endpoint handles file upload, conversion, and memory creation in a single operation.\n\n"
67576757
"**Features:**\n"
6758-
"- Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription)\n"
6758+
"- Supports PDF, DOCX, PPTX, XLSX, images (parser-dependent OCR), audio (with transcription)\n"
67596759
"- Automatic file-to-markdown conversion using pluggable parsers\n"
67606760
"- Files stored in object storage (PostgreSQL by default, S3 for production)\n"
67616761
"- Each file becomes a separate document with optional metadata/tags\n"

hindsight-api-slim/hindsight_api/config.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,11 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]:
424424
ENV_FILE_STORAGE_AZURE_ACCOUNT_KEY = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_KEY"
425425
ENV_FILE_PARSER = "HINDSIGHT_API_FILE_PARSER"
426426
ENV_FILE_PARSER_ALLOWLIST = "HINDSIGHT_API_FILE_PARSER_ALLOWLIST"
427+
ENV_FILE_PARSER_MARKITDOWN_OCR_ENABLED = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED"
428+
ENV_FILE_PARSER_MARKITDOWN_OCR_API_KEY = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY"
429+
ENV_FILE_PARSER_MARKITDOWN_OCR_BASE_URL = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL"
430+
ENV_FILE_PARSER_MARKITDOWN_OCR_MODEL = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL"
431+
ENV_FILE_PARSER_MARKITDOWN_OCR_PROMPT = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_PROMPT"
427432
ENV_FILE_PARSER_IRIS_TOKEN = "HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN"
428433
ENV_FILE_PARSER_IRIS_ORG_ID = "HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID"
429434
ENV_FILE_PARSER_LLAMA_PARSE_API_KEY = "HINDSIGHT_API_FILE_PARSER_LLAMA_PARSE_API_KEY"
@@ -847,6 +852,10 @@ def _parse_strategy_boosts(raw: str | None) -> dict[str, str]:
847852
DEFAULT_FILE_STORAGE_TYPE = "native" # PostgreSQL BYTEA storage
848853
DEFAULT_FILE_PARSER = "markitdown" # Default parser fallback chain (comma-separated, e.g. "iris,markitdown")
849854
DEFAULT_FILE_PARSER_ALLOWLIST = None # Allowlist of parsers clients may request (None = all registered parsers)
855+
DEFAULT_FILE_PARSER_MARKITDOWN_OCR_ENABLED = False
856+
DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT = """You are a precise OCR transcription engine.
857+
858+
Transcribe only the visible text in the image. Do not describe the image, summarize it, translate it, infer missing content, or add commentary. Preserve the original language, wording, numbers, punctuation, capitalization, and reading order. Reconstruct headings, lists, key-value fields, stamps, and tables as clean Markdown when the layout is clear. If text is unreadable or uncertain, write [unclear] for that span. Return only the extracted Markdown."""
850859
DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE_MB = 100 # Max total batch size in MB (all files combined)
851860
DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE = 10 # Max files per batch upload
852861
DEFAULT_ENABLE_FILE_UPLOAD_API = True # Enable file upload endpoint
@@ -1676,6 +1685,11 @@ class HindsightConfig:
16761685
embeddings_zeroentropy_encoding_format: str = DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT
16771686
embeddings_zeroentropy_batch_size: int = DEFAULT_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE
16781687
embeddings_zeroentropy_latency: str | None = DEFAULT_EMBEDDINGS_ZEROENTROPY_LATENCY
1688+
file_parser_markitdown_ocr_enabled: bool = DEFAULT_FILE_PARSER_MARKITDOWN_OCR_ENABLED
1689+
file_parser_markitdown_ocr_api_key: str | None = None
1690+
file_parser_markitdown_ocr_base_url: str | None = None
1691+
file_parser_markitdown_ocr_model: str | None = None
1692+
file_parser_markitdown_ocr_prompt: str = DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT
16791693

16801694
# Class-level sets for configuration categorization
16811695

@@ -1716,6 +1730,8 @@ class HindsightConfig:
17161730
"file_storage_gcs_service_account_key",
17171731
"file_storage_azure_account_key",
17181732
# File parser credentials
1733+
"file_parser_markitdown_ocr_api_key",
1734+
"file_parser_markitdown_ocr_base_url",
17191735
"file_parser_iris_token",
17201736
"file_parser_llama_parse_api_key",
17211737
}
@@ -2424,6 +2440,18 @@ def from_env(cls) -> "HindsightConfig":
24242440
file_parser_allowlist=_parse_str_list(os.getenv(ENV_FILE_PARSER_ALLOWLIST))
24252441
if os.getenv(ENV_FILE_PARSER_ALLOWLIST)
24262442
else None,
2443+
file_parser_markitdown_ocr_enabled=os.getenv(
2444+
ENV_FILE_PARSER_MARKITDOWN_OCR_ENABLED,
2445+
str(DEFAULT_FILE_PARSER_MARKITDOWN_OCR_ENABLED),
2446+
).lower()
2447+
in ("1", "true", "yes", "on"),
2448+
file_parser_markitdown_ocr_api_key=os.getenv(ENV_FILE_PARSER_MARKITDOWN_OCR_API_KEY) or None,
2449+
file_parser_markitdown_ocr_base_url=os.getenv(ENV_FILE_PARSER_MARKITDOWN_OCR_BASE_URL) or None,
2450+
file_parser_markitdown_ocr_model=os.getenv(ENV_FILE_PARSER_MARKITDOWN_OCR_MODEL) or None,
2451+
file_parser_markitdown_ocr_prompt=os.getenv(
2452+
ENV_FILE_PARSER_MARKITDOWN_OCR_PROMPT,
2453+
DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT,
2454+
),
24272455
file_parser_iris_token=os.getenv(ENV_FILE_PARSER_IRIS_TOKEN) or None,
24282456
file_parser_iris_org_id=os.getenv(ENV_FILE_PARSER_IRIS_ORG_ID) or None,
24292457
file_parser_llama_parse_api_key=os.getenv(ENV_FILE_PARSER_LLAMA_PARSE_API_KEY) or None,

hindsight-api-slim/hindsight_api/engine/memory_engine.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2755,7 +2755,15 @@ async def _init_connection(conn: asyncpg.Connection) -> None:
27552755

27562756
self._parser_registry = FileParserRegistry()
27572757
try:
2758-
self._parser_registry.register(MarkitdownParser())
2758+
self._parser_registry.register(
2759+
MarkitdownParser(
2760+
ocr_enabled=config.file_parser_markitdown_ocr_enabled,
2761+
ocr_api_key=config.file_parser_markitdown_ocr_api_key,
2762+
ocr_base_url=config.file_parser_markitdown_ocr_base_url,
2763+
ocr_model=config.file_parser_markitdown_ocr_model,
2764+
ocr_prompt=config.file_parser_markitdown_ocr_prompt,
2765+
)
2766+
)
27592767
logger.debug("Registered markitdown parser")
27602768
except ImportError:
27612769
logger.warning("markitdown not available - file parsing disabled")

hindsight-api-slim/hindsight_api/engine/parsers/markitdown.py

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,43 +3,116 @@
33
import asyncio
44
import logging
55
import tempfile
6+
from dataclasses import dataclass
67
from pathlib import Path
78

9+
from hindsight_api.config import DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT
10+
811
from .base import FileParser
912

1013
logger = logging.getLogger(__name__)
1114

1215

16+
@dataclass(frozen=True)
17+
class MarkitdownOcrOptions:
18+
"""OpenAI-compatible OCR options passed through to MarkItDown."""
19+
20+
# Keep this typed as object so the OpenAI SDK import stays lazy for non-OCR users.
21+
llm_client: object
22+
llm_model: str
23+
llm_prompt: str
24+
25+
1326
class MarkitdownParser(FileParser):
1427
"""
1528
Markitdown file parser.
1629
1730
Uses Microsoft's markitdown library to convert various file formats
18-
to markdown including PDF, Office docs, images (via OCR), audio, HTML.
31+
to markdown including PDF, Office docs, images with optional OCR,
32+
audio, HTML.
1933
2034
Supported formats:
2135
- PDF (.pdf)
2236
- Word (.docx, .doc)
2337
- PowerPoint (.pptx, .ppt)
2438
- Excel (.xlsx, .xls)
25-
- Images (.jpg, .jpeg, .png) - with OCR
39+
- Images (.jpg, .jpeg, .png) - optional OCR
2640
- HTML (.html, .htm)
2741
- Text (.txt, .md)
2842
- Audio (.mp3, .wav) - with transcription
2943
"""
3044

31-
def __init__(self):
45+
def __init__(
46+
self,
47+
*,
48+
ocr_enabled: bool = False,
49+
ocr_api_key: str | None = None,
50+
ocr_base_url: str | None = None,
51+
ocr_model: str | None = None,
52+
ocr_prompt: str | None = None,
53+
):
3254
"""Initialize markitdown parser."""
3355
# Lazy import to avoid requiring markitdown for all users
3456
try:
3557
from markitdown import MarkItDown
36-
37-
self._markitdown = MarkItDown()
3858
except ImportError as e:
3959
raise ImportError(
4060
"markitdown package is required for file parsing. Install with: pip install markitdown"
4161
) from e
4262

63+
self._ocr_enabled = ocr_enabled
64+
if ocr_enabled:
65+
ocr_options = self._build_ocr_options(
66+
api_key=ocr_api_key,
67+
base_url=ocr_base_url,
68+
model=ocr_model,
69+
prompt=ocr_prompt,
70+
)
71+
self._markitdown = MarkItDown(
72+
llm_client=ocr_options.llm_client,
73+
llm_model=ocr_options.llm_model,
74+
llm_prompt=ocr_options.llm_prompt,
75+
)
76+
else:
77+
self._markitdown = MarkItDown()
78+
79+
def _build_ocr_options(
80+
self,
81+
*,
82+
api_key: str | None,
83+
base_url: str | None,
84+
model: str | None,
85+
prompt: str | None,
86+
) -> MarkitdownOcrOptions:
87+
"""Build MarkItDown options for OpenAI-compatible image OCR."""
88+
if not model or not model.strip():
89+
raise ValueError(
90+
"Markitdown OCR is enabled but no model is configured. "
91+
"Set HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL to an OpenAI-compatible OCR/vision model "
92+
"with image-input support."
93+
)
94+
if not api_key:
95+
raise ValueError(
96+
"Markitdown OCR is enabled but no API key is configured. "
97+
"Set HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY."
98+
)
99+
if not base_url or not base_url.strip():
100+
raise ValueError(
101+
"Markitdown OCR is enabled but no base URL is configured. "
102+
"Set HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL to an OpenAI-compatible OCR/vision endpoint."
103+
)
104+
105+
try:
106+
from openai import OpenAI
107+
except ImportError as e:
108+
raise RuntimeError("openai package is required when Markitdown OCR is enabled.") from e
109+
110+
return MarkitdownOcrOptions(
111+
llm_client=OpenAI(api_key=api_key, base_url=base_url.strip()),
112+
llm_model=model.strip(),
113+
llm_prompt=prompt or DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT,
114+
)
115+
43116
async def convert(self, file_data: bytes, filename: str) -> str:
44117
"""Parse file to markdown using markitdown."""
45118
# markitdown is synchronous, so we run it in executor to avoid blocking
@@ -48,6 +121,13 @@ async def convert(self, file_data: bytes, filename: str) -> str:
48121

49122
def _convert_sync(self, file_data: bytes, filename: str) -> str:
50123
"""Synchronous parsing (runs in thread pool)."""
124+
if self._is_image_file(filename) and not self._ocr_enabled:
125+
raise RuntimeError(
126+
"Image OCR is not enabled for the markitdown parser. "
127+
"Set HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED=true and configure an OpenAI-compatible "
128+
"OCR/vision endpoint with image-input support, or choose an OCR-capable parser."
129+
)
130+
51131
# Write to temp file (markitdown requires file path)
52132
with tempfile.NamedTemporaryFile(suffix=Path(filename).suffix, delete=False) as tmp:
53133
tmp.write(file_data)
@@ -73,6 +153,11 @@ def _convert_sync(self, file_data: bytes, filename: str) -> str:
73153
except Exception:
74154
pass
75155

156+
@staticmethod
157+
def _is_image_file(filename: str) -> bool:
158+
"""Return whether the file type needs OCR to extract useful text."""
159+
return Path(filename).suffix.lower() in {".jpg", ".jpeg", ".png"}
160+
76161
def supports(self, filename: str, content_type: str | None = None) -> bool:
77162
"""Check if markitdown supports this file type."""
78163
# Supported extensions (from markitdown docs)
@@ -85,7 +170,7 @@ def supports(self, filename: str, content_type: str | None = None) -> bool:
85170
".ppt",
86171
".xlsx",
87172
".xls",
88-
# Images (with OCR)
173+
# Images (optional OCR)
89174
".jpg",
90175
".jpeg",
91176
".png",

hindsight-api-slim/tests/test_config_validation.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,53 @@ def test_llm_output_language_empty_string_is_unset(monkeypatch):
452452
assert config.llm_output_language is None
453453

454454

455+
def test_markitdown_ocr_defaults_disabled(monkeypatch):
456+
from hindsight_api.config import HindsightConfig
457+
458+
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
459+
460+
config = HindsightConfig.from_env()
461+
assert config.file_parser_markitdown_ocr_enabled is False
462+
463+
464+
def test_markitdown_ocr_does_not_fall_back_to_main_llm_config(monkeypatch):
465+
from hindsight_api.config import DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT, HindsightConfig
466+
467+
monkeypatch.setenv("HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED", "true")
468+
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "anthropic")
469+
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "main-key")
470+
monkeypatch.setenv("HINDSIGHT_API_LLM_BASE_URL", "https://main.example/v1")
471+
monkeypatch.setenv("HINDSIGHT_API_LLM_MODEL", "main-vision-model")
472+
473+
config = HindsightConfig.from_env()
474+
assert config.file_parser_markitdown_ocr_enabled is True
475+
assert config.file_parser_markitdown_ocr_api_key is None
476+
assert config.file_parser_markitdown_ocr_base_url is None
477+
assert config.file_parser_markitdown_ocr_model is None
478+
assert config.file_parser_markitdown_ocr_prompt == DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT
479+
480+
481+
def test_markitdown_ocr_uses_explicit_config(monkeypatch):
482+
from hindsight_api.config import HindsightConfig
483+
484+
monkeypatch.setenv("HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED", "true")
485+
monkeypatch.setenv("HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY", "parser-key")
486+
monkeypatch.setenv("HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL", "https://parser.example/v1")
487+
monkeypatch.setenv("HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL", "parser-vision-model")
488+
monkeypatch.setenv("HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_PROMPT", "Extract this document exactly.")
489+
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
490+
monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "main-key")
491+
monkeypatch.setenv("HINDSIGHT_API_LLM_BASE_URL", "https://main.example/v1")
492+
monkeypatch.setenv("HINDSIGHT_API_LLM_MODEL", "main-vision-model")
493+
494+
config = HindsightConfig.from_env()
495+
assert config.file_parser_markitdown_ocr_enabled is True
496+
assert config.file_parser_markitdown_ocr_api_key == "parser-key"
497+
assert config.file_parser_markitdown_ocr_base_url == "https://parser.example/v1"
498+
assert config.file_parser_markitdown_ocr_model == "parser-vision-model"
499+
assert config.file_parser_markitdown_ocr_prompt == "Extract this document exactly."
500+
501+
455502
def test_llm_reasoning_effort_defaults_to_low(monkeypatch):
456503
from hindsight_api.config import HindsightConfig
457504

0 commit comments

Comments
 (0)