Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Tables become Markdown tables, charts become Mermaid diagrams, and images get co

If you don't believe it there's a whole gallery of examples with really wide range of OCR tasks you can explore here -> [Gallery](https://github.com/sethupavan12/Markdownify/blob/main/examples/gallery.md)

![Handwritten notes converted to Markdown](examples/image.png)

<img width="1867" height="450" alt="image" src="https://github.com/user-attachments/assets/9a8b5176-03d8-4063-a8f3-4b1e52bdbe72" />

### Install
Expand Down
Binary file added examples/image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ dependencies = [
"typer>=0.12.3",
"pydantic>=2.7.0",
"tqdm>=4.66.0",
"tenacity>=8.2.0",
]

[project.optional-dependencies]
Expand Down
28 changes: 27 additions & 1 deletion src/llm_markdownify/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
from __future__ import annotations

from pathlib import Path
from typing import Optional
from typing import Literal, Optional

from .config import MarkdownifyConfig
from .markdownifier import Markdownifier

LogLevel = Literal["quiet", "normal", "verbose", "debug"]


def convert(
input_path: str | Path,
Expand All @@ -24,6 +26,16 @@ def convert(
concurrency: int = 4,
profile: Optional[str] = None,
allow_docx: bool = False,
# Retry options
max_retries: int = 3,
retry_delay: float = 1.0,
# Rate limiting
rate_limit_rpm: Optional[int] = None,
# Caching
enable_cache: bool = False,
cache_dir: Optional[str | Path] = None,
# Logging
log_level: LogLevel = "normal",
) -> Path:
"""Convert a document to Markdown using the configured LLM via LiteLLM.

Expand All @@ -39,6 +51,12 @@ def convert(
- concurrency: Max parallel LLM calls across page groups
- profile: Prompt profile name ('contracts', 'generic') or path to a JSON profile
- allow_docx: Enable DOCX via Word/COM conversion (not recommended; prefer PDFs)
- max_retries: Max retry attempts for failed LLM calls (default: 3)
- retry_delay: Initial delay between retries in seconds (exponential backoff, default: 1.0)
- rate_limit_rpm: Max requests per minute (None = no limit)
- enable_cache: Enable response caching to avoid redundant LLM calls
- cache_dir: Directory for response cache (defaults to ~/.cache/llm-markdownify)
- log_level: Log verbosity: 'quiet', 'normal', 'verbose', 'debug'

Returns
- Path to the written Markdown file
Expand All @@ -52,11 +70,19 @@ def convert(
temperature=temperature,
concurrency=concurrency,
allow_docx=allow_docx,
max_retries=max_retries,
retry_delay=retry_delay,
enable_cache=enable_cache,
log_level=log_level,
)
if model is not None:
cfg_kwargs["model"] = model
if max_tokens is not None:
cfg_kwargs["max_tokens"] = max_tokens
if rate_limit_rpm is not None:
cfg_kwargs["rate_limit_rpm"] = rate_limit_rpm
if cache_dir is not None:
cfg_kwargs["cache_dir"] = Path(cache_dir)

cfg = MarkdownifyConfig(**cfg_kwargs)
return Markdownifier(cfg, profile=profile).run()
111 changes: 111 additions & 0 deletions src/llm_markdownify/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Copyright (c) 2025 Sethu Pavan Venkata Reddy Pastula
# Licensed under the Apache License, Version 2.0. See LICENSE file for details.
# SPDX-License-Identifier: Apache-2.0

"""Response caching for LLM calls to avoid redundant API costs."""

from __future__ import annotations

import hashlib
import json
from pathlib import Path
from typing import Optional

from .logging import get_logger

logger = get_logger("llm_markdownify.cache")


def _hash_inputs(*args: str) -> str:
"""Create a stable hash from input strings."""
combined = "||".join(args)
return hashlib.sha256(combined.encode("utf-8")).hexdigest()[:16]


class ResponseCache:
"""File-based cache for LLM responses."""

def __init__(self, cache_dir: Optional[Path] = None, enabled: bool = True) -> None:
self.enabled = enabled
if cache_dir:
self.cache_dir = cache_dir
else:
self.cache_dir = Path.home() / ".cache" / "llm-markdownify"

if self.enabled:
self.cache_dir.mkdir(parents=True, exist_ok=True)

def _cache_path(self, key: str) -> Path:
return self.cache_dir / f"{key}.json"

def get(self, model: str, prompt_hash: str, image_hashes: list[str]) -> Optional[str]:
"""Retrieve cached response if exists."""
if not self.enabled:
return None

key = _hash_inputs(model, prompt_hash, *image_hashes)
path = self._cache_path(key)

if path.exists():
try:
data = json.loads(path.read_text(encoding="utf-8"))
logger.info("Cache hit for key %s", key)
return data.get("response")
except (json.JSONDecodeError, OSError) as e:
logger.warning("Cache read error: %s", e)
return None
return None

def set(self, model: str, prompt_hash: str, image_hashes: list[str], response: str) -> None:
"""Store response in cache."""
if not self.enabled:
return

key = _hash_inputs(model, prompt_hash, *image_hashes)
path = self._cache_path(key)

try:
data = {
"model": model,
"prompt_hash": prompt_hash,
"image_hashes": image_hashes,
"response": response,
}
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
logger.debug("Cached response with key %s", key)
except OSError as e:
logger.warning("Cache write error: %s", e)

def clear(self) -> int:
"""Clear all cached responses. Returns count of deleted entries."""
if not self.cache_dir.exists():
return 0

count = 0
for f in self.cache_dir.glob("*.json"):
try:
f.unlink()
count += 1
except OSError:
pass
logger.info("Cleared %d cache entries", count)
return count


# Default global cache instance (disabled until configured)
_default_cache: Optional[ResponseCache] = None


def get_cache() -> ResponseCache:
"""Get the global cache instance."""
global _default_cache
if _default_cache is None:
_default_cache = ResponseCache(enabled=False)
return _default_cache


def configure_cache(cache_dir: Optional[Path] = None, enabled: bool = True) -> ResponseCache:
"""Configure and return the global cache instance."""
global _default_cache
_default_cache = ResponseCache(cache_dir=cache_dir, enabled=enabled)
return _default_cache
36 changes: 36 additions & 0 deletions src/llm_markdownify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,35 @@ def run(
allow_docx: bool = typer.Option(
False, help="Allow DOCX via Word/COM conversion (not recommended). Prefer PDFs."
),
# Retry options
max_retries: int = typer.Option(3, help="Max retry attempts for failed LLM calls"),
retry_delay: float = typer.Option(
1.0, help="Initial delay between retries in seconds (exponential backoff)"
),
# Rate limiting
rate_limit: Optional[int] = typer.Option(
None, "--rate-limit", help="Max requests per minute (None = no limit)"
),
# Caching
cache: bool = typer.Option(
False, "--cache/--no-cache", help="Enable response caching to avoid redundant LLM calls"
),
cache_dir: Optional[str] = typer.Option(
None, help="Directory for response cache (defaults to ~/.cache/llm-markdownify)"
),
# Logging
verbose: bool = typer.Option(
False, "-v", "--verbose", help="Enable verbose logging (debug level)"
),
quiet: bool = typer.Option(False, "-q", "--quiet", help="Suppress non-error output"),
):
# Determine log level
log_level = "normal"
if quiet:
log_level = "quiet"
elif verbose:
log_level = "verbose"

cfg_kwargs = dict(
input_path=Path(input_path),
output_path=Path(output),
Expand All @@ -73,13 +101,21 @@ def run(
temperature=temperature,
concurrency=concurrency,
allow_docx=allow_docx,
max_retries=max_retries,
retry_delay=retry_delay,
enable_cache=cache,
log_level=log_level,
)
if model:
cfg_kwargs["model"] = model
if max_tokens is not None:
cfg_kwargs["max_tokens"] = max_tokens
if grouping_concurrency is not None:
cfg_kwargs["grouping_concurrency"] = grouping_concurrency
if rate_limit is not None:
cfg_kwargs["rate_limit_rpm"] = rate_limit
if cache_dir:
cfg_kwargs["cache_dir"] = Path(cache_dir)

cfg = MarkdownifyConfig(**cfg_kwargs)
Markdownifier(cfg, profile=profile).run()
Expand Down
44 changes: 41 additions & 3 deletions src/llm_markdownify/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@

import os
from pathlib import Path
from typing import Optional
from typing import Literal, Optional

from pydantic import BaseModel, Field, field_validator, model_validator


LogLevel = Literal["quiet", "normal", "verbose", "debug"]


class MarkdownifyConfig(BaseModel):
"""Configuration for the markdownification process."""

Expand Down Expand Up @@ -55,8 +58,43 @@ class MarkdownifyConfig(BaseModel):
description="Max concurrent LLM requests for adjacent-page continuation checks (defaults to concurrency)",
)

# Optional path where page images are cached for debugging
cache_dir: Optional[Path] = Field(None)
# Retry configuration
max_retries: int = Field(
3,
ge=0,
le=10,
description="Max retry attempts for failed LLM calls",
)
retry_delay: float = Field(
1.0,
ge=0.1,
le=60.0,
description="Initial delay between retries in seconds (exponential backoff)",
)

# Rate limiting
rate_limit_rpm: Optional[int] = Field(
None,
ge=1,
le=10000,
description="Max requests per minute (None = no limit)",
)

# Caching
enable_cache: bool = Field(
False,
description="Enable response caching to avoid redundant LLM calls",
)
cache_dir: Optional[Path] = Field(
None,
description="Directory for response cache (defaults to ~/.cache/llm-markdownify)",
)

# Logging
log_level: LogLevel = Field(
"normal",
description="Log verbosity: quiet, normal, verbose, debug",
)

@field_validator("input_path")
@classmethod
Expand Down
Loading