Skip to content

feat: Add structured LLM extraction endpoint#97

Merged
gomesrocha merged 1 commit into
mainfrom
feat/structured-extraction-13248805800921705453
Apr 14, 2026
Merged

feat: Add structured LLM extraction endpoint#97
gomesrocha merged 1 commit into
mainfrom
feat/structured-extraction-13248805800921705453

Conversation

@gomesrocha

@gomesrocha gomesrocha commented Apr 14, 2026

Copy link
Copy Markdown
Owner

This pull request introduces a new /extract_structured API endpoint which is capable of returning extracted data in a typed and categorized JSON format.

The feature uses litellm and instructor to leverage remote Models and enforce Pydantic structured schemas. In cases where the configuration does not provide a cloud provider, it gracefully falls back to a fast, heuristic local execution utilizing the previously existing Tesseract OCR and SpaCy NER components, preserving the ability to run multi-cloud without extensive footprint requirements for the Docker execution.

Legacy Flask remnants (app/__init__.py, app/routes.py, src/main.py) which caused pytest loading errors were cleaned up and the tests suite executed completely cleanly.


PR created automatically by Jules for task 13248805800921705453 started by @gomesrocha

Summary by CodeRabbit

Release Notes

  • New Features

    • Introduced a structured data extraction endpoint supporting both local OCR with entity recognition and LLM-powered extraction modes.
    • Added PDF document processing with multi-language support.
    • Support for multiple input sources: file uploads and object storage integration.
  • Chores

    • Updated runtime dependencies.

- Adds litellm and instructor dependencies for model integration and structured outputs
- Introduces `/extract_structured` API endpoint for handling files and cloud storage payloads
- Creates Pydantic models for structured outputs (`StructuredDocumentData`, `StructuredExtractionResult`)
- Implements hybrid text extraction pipeline with fallback to local NER using spaCy
- Provides tests mocking LLM extraction and fallback behavior
- Cleans up legacy Flask and corrupted test files from repository

Co-authored-by: gomesrocha <3893269+gomesrocha@users.noreply.github.com>
@google-labs-jules

Copy link
Copy Markdown
Contributor

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

@coderabbitai

coderabbitai Bot commented Apr 14, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR migrates the application from Flask to FastAPI with modular router architecture, removes Flask-specific configuration and routes, and introduces structured document extraction capabilities with LLM support via instructor and litellm libraries. Legacy OCR scripts are removed, and new Pydantic schemas define extraction outputs.

Changes

Cohort / File(s) Summary
Application Structure Migration
app/__init__.py, app/config.py, app/routes.py, run.py
Removed Flask application factory, Config class, and route handlers. Eliminated entry point script and Flask-specific bootstrap logic.
FastAPI Setup
app/main.py
Replaced Flask initialization with FastAPI, removed inline Item model and root endpoints, registered external routers for text, PDF, and structured extraction, updated uvicorn startup to listen on 0.0.0.0:8080.
API Module Exports
app/api/__init__.py
Added exports for extract_pdf and extract_structured alongside existing extract_text import.
Structured Extraction Feature
app/api/structured_extract.py
Added new POST /extract_structured endpoint accepting files from upload or object storage with configurable model providers, OCR modes, and language settings; performs PDF detection and conditional OCR, then runs structured extraction via LLM or local NER fallback.
Structured Extraction Logic
app/domain/structured_extraction.py
New function for conditional extraction: uses local NER heuristics when provider="local_tesseract" or model missing, otherwise invokes LLM via instructor/litellm with fallback on failure.
Structured Data Schemas
app/model/StructuredSchema.py
Added Pydantic models for extraction results: KeyValue, TableRow, Table, DocumentEntity, StructuredDocumentData, and StructuredExtractionResult with field descriptions and defaults.
Dependencies
pyproject.toml
Added instructor>=1.15.1 and litellm>=1.83.0 as runtime dependencies.
Legacy Code Cleanup
src/main.py
Removed standalone OCR script with hardcoded Tesseract configuration and image processing helper.
Test Updates
tests/conftest.py, tests/test_structured_extract.py
Removed Flask test fixture; added new test module covering structured extraction endpoint with mocked OCR, PDF, NER, and LLM dependencies under three scenarios.

Sequence Diagram

sequenceDiagram
    participant Client
    participant API as API Endpoint
    participant Storage as Object Storage
    participant OCR as OCR/PDF Engine
    participant NER as NER Module
    participant LLM as LLM Provider
    participant Response as Response Builder

    Client->>API: POST /extract_structured (files + config)
    alt source=upload
        API->>API: Save uploaded files to temp
    else source=object_storage
        API->>Storage: Download files by client_id/keys
        Storage-->>API: File content
    end
    
    loop For each file
        alt Detected as PDF
            API->>OCR: process_pdf(file_path)
            OCR-->>API: extracted_text
        else Image file
            API->>OCR: read_image(file_path)
            OCR-->>API: extracted_text
        end
        
        alt provider=local_tesseract
            API->>NER: extract_entities(text)
            NER-->>API: entities
            API->>Response: Build result with local extraction
        else LLM provider
            API->>LLM: chat.completions.create(text, model, response_model)
            LLM-->>API: StructuredDocumentData
            API->>Response: Build result with LLM extraction
        end
    end
    
    API-->>Client: List[StructuredExtractionResult] (200)
    API->>API: Clean up temp files
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 Hops with glee through FastAPI's doors,
Flask's old configs fade to lores,
LLMs now extract with structured care,
NER and OCR join the fair,
From image to knowledge, a rabbit's affair!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main feature added in the PR: a new structured LLM extraction endpoint with /extract_structured functionality.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/structured-extraction-13248805800921705453

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Reliability Rating on New Code (required ≥ A)
E Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

Comment thread app/main.py
if __name__ == '__main__':
uvicorn.run('main:app', host='127.0.0.1', port=8000, reload=True) No newline at end of file
if __name__ == "__main__":
import uvicorn
)

# Use litellm with instructor to get structured JSON
log.info(f"Using LLM for structured extraction: provider={provider}, model={model_name}")

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
app/model/StructuredSchema.py (1)

27-32: Use a numeric duration field here.

time_taken as str pushes parsing work onto every client and loses unit semantics. A numeric field like time_taken_seconds: float will be easier to consume and keeps the contract machine-friendly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/model/StructuredSchema.py` around lines 27 - 32, The
StructuredExtractionResult model currently exposes time_taken: str; change it to
a numeric seconds field (e.g., time_taken_seconds: float) on the
StructuredExtractionResult class so consumers get a machine-friendly duration in
seconds; update the field name and type in the model (replace time_taken: str
with time_taken_seconds: float), add any necessary Optional/nullable typing if
durations can be missing, and search/replace all usages of
StructuredExtractionResult.time_taken across the codebase (serializers,
constructors, tests, consumers) to convert string durations to the new float
seconds value or to accept the new field.
tests/test_structured_extract.py (1)

56-85: Add a regression test for the remote-failure branch.

This only exercises the happy-path LLM response. It doesn’t cover instructor / provider failures, which is where the implementation currently falls back locally and can emit misleading provider metadata. A test with mock_client.chat.completions.create.side_effect = ... would lock that behavior down.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_structured_extract.py` around lines 56 - 85, Add a regression test
that simulates a remote LLM failure by configuring the patched instructor client
used in test_extract_structured_llm to raise an exception from
mock_client.chat.completions.create (e.g., set side_effect to an Exception) so
the code exercises the remote-failure branch; then call the same POST to
"/extract_structured" and assert the response still succeeds but reports the
correct fallback provider/model metadata (verify res_data[0]["provider_used"] /
["model_used"] reflect the local fallback) and that mock_ocr is still called;
locate the patch on app.domain.structured_extraction.instructor and the
mock_client.chat.completions.create symbol to implement this.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/api/structured_extract.py`:
- Around line 132-156: Non-PDF blobs sent to ocr.read_image bypass image
validation; call the existing image validator (fileUpload.validate_image_file or
the corresponding validate function used for uploads) on temp_file in the else
branch before calling ocr.read_image, and if validation fails raise an
HTTPException(400) with a clear message; ensure you catch/propagate validation
exceptions similar to the PDF branch so invalid files are rejected prior to
calling ocr.read_image (refer to temp_file, is_pdf, pdf_ocr.process_pdf,
ocr.read_image and fileUpload.validate_image_file to locate the code).

In `@app/domain/structured_extraction.py`:
- Around line 74-77: The current except block in structured_extraction.py that
catches Exception as e and returns extract_structured_data(text,
"local_tesseract", "", lang_hint) silently hides remote LLM failures; update the
error handling in the except of the LLM extraction path (the block surrounding
extract_structured_data call) to either re-raise the original exception or
return a structured fallback that includes explicit metadata (e.g.,
provider_used="local_tesseract", model_used="" plus an error field or
fallback_flag=true and original_error message) so callers can detect the
downgrade; ensure this change is applied around the function/method performing
LLM structured extraction and that any return type changes are propagated to
callers or documented.
- Around line 45-72: The blocking call to client.chat.completions.create inside
extract_structured_data() (which is invoked from async extract_structured(...))
must be moved off the event loop; wrap the synchronous call to
client.chat.completions.create(..., response_model=StructuredDocumentData, ...)
in a thread executor (e.g. use asyncio.to_thread(...) or
starlette.concurrency.run_in_threadpool(...)) or alternatively convert
extract_structured_data() to async with a non-blocking client; ensure you return
the StructuredDocumentData result from the threaded call and preserve the same
parameters (model=full_model_name, messages, max_tokens) when invoking the
client.

---

Nitpick comments:
In `@app/model/StructuredSchema.py`:
- Around line 27-32: The StructuredExtractionResult model currently exposes
time_taken: str; change it to a numeric seconds field (e.g., time_taken_seconds:
float) on the StructuredExtractionResult class so consumers get a
machine-friendly duration in seconds; update the field name and type in the
model (replace time_taken: str with time_taken_seconds: float), add any
necessary Optional/nullable typing if durations can be missing, and
search/replace all usages of StructuredExtractionResult.time_taken across the
codebase (serializers, constructors, tests, consumers) to convert string
durations to the new float seconds value or to accept the new field.

In `@tests/test_structured_extract.py`:
- Around line 56-85: Add a regression test that simulates a remote LLM failure
by configuring the patched instructor client used in test_extract_structured_llm
to raise an exception from mock_client.chat.completions.create (e.g., set
side_effect to an Exception) so the code exercises the remote-failure branch;
then call the same POST to "/extract_structured" and assert the response still
succeeds but reports the correct fallback provider/model metadata (verify
res_data[0]["provider_used"] / ["model_used"] reflect the local fallback) and
that mock_ocr is still called; locate the patch on
app.domain.structured_extraction.instructor and the
mock_client.chat.completions.create symbol to implement this.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3cd9f704-a0e1-41b7-b889-f272e35a239b

📥 Commits

Reviewing files that changed from the base of the PR and between 7c972cc and 67bcd49.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (13)
  • app/__init__.py
  • app/api/__init__.py
  • app/api/structured_extract.py
  • app/config.py
  • app/domain/structured_extraction.py
  • app/main.py
  • app/model/StructuredSchema.py
  • app/routes.py
  • pyproject.toml
  • run.py
  • src/main.py
  • tests/conftest.py
  • tests/test_structured_extract.py
💤 Files with no reviewable changes (6)
  • run.py
  • app/config.py
  • app/init.py
  • tests/conftest.py
  • src/main.py
  • app/routes.py

Comment on lines +132 to +156
if is_pdf:
try:
with open(temp_file, "rb") as f:
header = f.read(5)
if header != b"%PDF-":
raise HTTPException(status_code=400, detail="Downloaded file is not a valid PDF.")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=f"Error validating file: {e}")

raw_text = await pdf_ocr.process_pdf(
file_path=temp_file,
lang=lang,
mode=mode,
auto=auto_detect,
force_processing=force_processing
)
else:
raw_text = await ocr.read_image(
img_path=temp_file,
lang=lang,
mode=mode,
auto=auto_detect
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate downloaded non-PDF files before sending them to OCR.

The upload path validates images via fileUpload.validate_image_file(...), but the object-storage path sends any non-.pdf blob straight to ocr.read_image(...). That means a bad key or unexpected file type bypasses the same guardrails as uploads.

🧰 Tools
🪛 Ruff (0.15.9)

[warning] 140-140: Do not catch blind exception: Exception

(BLE001)


[warning] 141-141: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/structured_extract.py` around lines 132 - 156, Non-PDF blobs sent to
ocr.read_image bypass image validation; call the existing image validator
(fileUpload.validate_image_file or the corresponding validate function used for
uploads) on temp_file in the else branch before calling ocr.read_image, and if
validation fails raise an HTTPException(400) with a clear message; ensure you
catch/propagate validation exceptions similar to the PDF branch so invalid files
are rejected prior to calling ocr.read_image (refer to temp_file, is_pdf,
pdf_ocr.process_pdf, ocr.read_image and fileUpload.validate_image_file to locate
the code).

Comment on lines +45 to +72
client = instructor.from_litellm(completion)

# Prefix the model with the provider if litellm requires it
# litellm format is usually "provider/model_name"
full_model_name = f"{provider}/{model_name}" if "/" not in model_name and provider != "openai" else model_name

prompt = (
"You are an expert data extraction assistant. I will provide you with the raw OCR text of a document. "
"Your task is to analyze the text and extract structured information, including the type of the document, "
"any key-value pairs (like invoice numbers, dates, totals, names), any tables you can identify, "
"and a brief summary. Also extract important named entities.\n\n"
"Here is the raw document text:\n"
"------------------\n"
f"{text}\n"
"------------------\n"
)

try:
# instructor will handle forcing the StructuredDocumentData schema
structured_data = client.chat.completions.create(
model=full_model_name,
messages=[
{"role": "system", "content": "You are a precise data extraction AI."},
{"role": "user", "content": prompt}
],
response_model=StructuredDocumentData,
max_tokens=4096,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify that the FastAPI handler is async, calls the helper without `await`,
# and that the helper performs a synchronous Instructor/LiteLLM completion call.
set -euo pipefail

echo "== async endpoint calling structured_extraction.extract_structured_data =="
rg -n -C2 'async def extract_structured|structured_extraction\.extract_structured_data\(' app/api/structured_extract.py

echo
echo "== sync helper using litellm completion + blocking create(...) call =="
rg -n -C2 'def extract_structured_data|from_litellm|client\.chat\.completions\.create' app/domain/structured_extraction.py

Repository: gomesrocha/api_ocr

Length of output: 1561


🏁 Script executed:

# Check if there's any threadpool wrapping around the extract_structured_data calls
rg -n -B5 -A5 'structured_extraction\.extract_structured_data' app/api/structured_extract.py | head -60

Repository: gomesrocha/api_ocr

Length of output: 937


🏁 Script executed:

# Check imports in the structured_extract.py to see if asyncio utilities are imported
head -20 app/api/structured_extract.py

Repository: gomesrocha/api_ocr

Length of output: 1260


Don't make a blocking LLM call from the async request path.

extract_structured_data() is a synchronous function that's called directly from async def extract_structured(...) without wrapping in a threadpool executor. The function performs a blocking I/O call with client.chat.completions.create(...) (line 64), which blocks the event loop. Under load, this serializes requests and stalls the entire async handler. Wrap the call with asyncio.to_thread() or starlette.concurrency.run_in_threadpool(), or convert the function to async with an async HTTP client.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/domain/structured_extraction.py` around lines 45 - 72, The blocking call
to client.chat.completions.create inside extract_structured_data() (which is
invoked from async extract_structured(...)) must be moved off the event loop;
wrap the synchronous call to client.chat.completions.create(...,
response_model=StructuredDocumentData, ...) in a thread executor (e.g. use
asyncio.to_thread(...) or starlette.concurrency.run_in_threadpool(...)) or
alternatively convert extract_structured_data() to async with a non-blocking
client; ensure you return the StructuredDocumentData result from the threaded
call and preserve the same parameters (model=full_model_name, messages,
max_tokens) when invoking the client.

Comment on lines +74 to +77
except Exception as e:
log.error(f"Error during LLM structured extraction: {e}")
# Return fallback on error
return extract_structured_data(text, "local_tesseract", "", lang_hint)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Surface remote-provider failures instead of silently downgrading.

This catch-all fallback returns local data after any LLM error, but the API layer still reports the requested provider_used / model_used. That makes a degraded local result look like a successful remote extraction. Please either propagate an error or return enough metadata for the caller to see that fallback happened.

🧰 Tools
🪛 Ruff (0.15.9)

[warning] 74-74: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/domain/structured_extraction.py` around lines 74 - 77, The current except
block in structured_extraction.py that catches Exception as e and returns
extract_structured_data(text, "local_tesseract", "", lang_hint) silently hides
remote LLM failures; update the error handling in the except of the LLM
extraction path (the block surrounding extract_structured_data call) to either
re-raise the original exception or return a structured fallback that includes
explicit metadata (e.g., provider_used="local_tesseract", model_used="" plus an
error field or fallback_flag=true and original_error message) so callers can
detect the downgrade; ensure this change is applied around the function/method
performing LLM structured extraction and that any return type changes are
propagated to callers or documented.

@gomesrocha gomesrocha merged commit bbac213 into main Apr 14, 2026
3 of 6 checks passed
@gomesrocha gomesrocha deleted the feat/structured-extraction-13248805800921705453 branch April 14, 2026 19:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants