-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Add structured LLM extraction endpoint #97
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +0,0 @@ | ||
| from flask import Flask\nfrom .config import Config\nfrom .routes import main as main_blueprint\ndef create_app():\n app = Flask(__name__)\n app.config.from_object(Config)\n app.register_blueprint(main_blueprint)\n return app | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,3 @@ | ||
| from .text_extract import extract_text | ||
| from .text_extract import extract_text | ||
| from .pdf_extract import extract_pdf | ||
| from .structured_extract import extract_structured |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,180 @@ | ||
| from fastapi import APIRouter, UploadFile, File, Form, HTTPException | ||
| from typing import List, Optional | ||
| import time | ||
| import os | ||
|
|
||
| from app.domain import ocr, pdf_ocr, fileUpload, structured_extraction | ||
| from app.model.StructuredSchema import StructuredExtractionResult | ||
| from app.services import storage | ||
|
|
||
| router = APIRouter() | ||
|
|
||
| @router.post("/extract_structured", response_model=List[StructuredExtractionResult]) | ||
| async def extract_structured( | ||
|
Check failure on line 13 in app/api/structured_extract.py
|
||
| input_files: Optional[List[UploadFile]] = File(None, description="List of files (Images or PDFs) to process (required if source='upload')"), | ||
|
Check failure on line 14 in app/api/structured_extract.py
|
||
| source: str = Form("upload", description="Source of the file: 'upload' or 'object_storage'"), | ||
|
Check failure on line 15 in app/api/structured_extract.py
|
||
| client_id: Optional[str] = Form(None, description="Client ID for object storage (required if source='object_storage')"), | ||
|
Check failure on line 16 in app/api/structured_extract.py
|
||
| object_keys: Optional[List[str]] = Form(None, description="List of object keys in the bucket (required if source='object_storage')"), | ||
|
Check failure on line 17 in app/api/structured_extract.py
|
||
| lang: str = Form("eng+por", description="Language code (eng, por, eng+por)."), | ||
|
Check failure on line 18 in app/api/structured_extract.py
|
||
| mode: str = Form("fast", description="OCR mode: 'fast' or 'accurate'."), | ||
|
Check failure on line 19 in app/api/structured_extract.py
|
||
| auto_detect: bool = Form(False, description="Enable Orientation and Script Detection (OSD)."), | ||
|
Check failure on line 20 in app/api/structured_extract.py
|
||
| model_provider: str = Form("local_tesseract", description="Provider for structured extraction (e.g., 'local_tesseract', 'openai', 'ollama', 'azure')"), | ||
|
Check failure on line 21 in app/api/structured_extract.py
|
||
| model_name: str = Form("", description="Model name to use (e.g., 'gpt-4o-mini', 'llama3'). Required if provider is not 'local_tesseract'"), | ||
|
Check failure on line 22 in app/api/structured_extract.py
|
||
| force_processing: bool = Form(False, description="Force PDF processing even if page count > 10.") | ||
|
Check failure on line 23 in app/api/structured_extract.py
|
||
| ): | ||
| """ | ||
| Extract structured data from uploaded images or PDFs. | ||
| First performs OCR, then optionally passes the raw text to an LLM for structured JSON output. | ||
| """ | ||
|
|
||
| if mode not in ["fast", "accurate"]: | ||
| raise HTTPException(status_code=400, detail="Invalid mode. Use 'fast' or 'accurate'.") | ||
|
Check failure on line 31 in app/api/structured_extract.py
|
||
|
|
||
| if lang == "auto": | ||
| auto_detect = True | ||
| lang = "eng+por" | ||
|
|
||
| if model_provider != "local_tesseract" and not model_name: | ||
| raise HTTPException(status_code=400, detail="model_name is required when model_provider is not 'local_tesseract'.") | ||
|
Check failure on line 38 in app/api/structured_extract.py
|
||
|
|
||
| results = [] | ||
|
|
||
| if source == "upload": | ||
| if not input_files: | ||
| raise HTTPException(status_code=400, detail="input_files is required when source is 'upload'.") | ||
|
Check failure on line 44 in app/api/structured_extract.py
|
||
|
|
||
| if client_id or object_keys: | ||
| raise HTTPException(status_code=400, detail="Ambiguous request: cannot provide both upload files and object storage parameters.") | ||
|
Check failure on line 47 in app/api/structured_extract.py
|
||
|
|
||
| for file in input_files: | ||
| start_time = time.time() | ||
| filename = file.filename | ||
|
|
||
| # Determine file type | ||
| is_pdf = filename.lower().endswith(".pdf") | ||
|
|
||
| # Save file | ||
| temp_file = fileUpload._save_file_to_server(file) | ||
|
|
||
| try: | ||
| # Perform OCR | ||
| if is_pdf: | ||
| # Basic PDF header check | ||
| try: | ||
| with open(temp_file, "rb") as f: | ||
|
Check failure on line 64 in app/api/structured_extract.py
|
||
| header = f.read(5) | ||
| if header != b"%PDF-": | ||
| raise HTTPException(status_code=400, detail="Downloaded file is not a valid PDF.") | ||
|
Check failure on line 67 in app/api/structured_extract.py
|
||
| except HTTPException: | ||
| raise | ||
| except Exception as e: | ||
| raise HTTPException(status_code=400, detail=f"Error validating file: {e}") | ||
|
Check failure on line 71 in app/api/structured_extract.py
|
||
|
|
||
| raw_text = await pdf_ocr.process_pdf( | ||
| file_path=temp_file, | ||
| lang=lang, | ||
| mode=mode, | ||
| auto=auto_detect, | ||
| force_processing=force_processing | ||
| ) | ||
| else: | ||
| fileUpload.validate_image_file(file) | ||
| raw_text = await ocr.read_image( | ||
| img_path=temp_file, | ||
| lang=lang, | ||
| mode=mode, | ||
| auto=auto_detect | ||
| ) | ||
|
|
||
| # Extract Structured Data | ||
| structured_data = structured_extraction.extract_structured_data( | ||
| text=raw_text, | ||
| provider=model_provider, | ||
| model_name=model_name, | ||
| lang_hint=lang | ||
| ) | ||
|
|
||
| finally: | ||
| if os.path.exists(temp_file): | ||
| os.remove(temp_file) | ||
|
|
||
| time_taken = str(round((time.time() - start_time), 2)) | ||
|
|
||
| results.append(StructuredExtractionResult( | ||
| file_name=filename or "unknown", | ||
| provider_used=model_provider, | ||
| model_used=model_name if model_provider != "local_tesseract" else None, | ||
| data=structured_data, | ||
| time_taken=time_taken | ||
| )) | ||
|
|
||
| elif source == "object_storage": | ||
| if input_files: | ||
| raise HTTPException(status_code=400, detail="Ambiguous request: cannot provide both upload files and object storage parameters.") | ||
|
Check failure on line 113 in app/api/structured_extract.py
|
||
|
|
||
| if not client_id or not object_keys: | ||
| raise HTTPException(status_code=400, detail="client_id and object_keys are required when source is 'object_storage'.") | ||
|
Check failure on line 116 in app/api/structured_extract.py
|
||
|
|
||
| for key in object_keys: | ||
| start_time = time.time() | ||
|
|
||
| is_pdf = key.lower().endswith(".pdf") | ||
| temp_file = None | ||
|
|
||
| try: | ||
| try: | ||
| temp_file = storage.download_file_from_storage(client_id, key) | ||
| except ValueError as e: | ||
| raise HTTPException(status_code=400, detail=str(e)) | ||
|
Check failure on line 128 in app/api/structured_extract.py
|
||
| except Exception as e: | ||
| raise HTTPException(status_code=500, detail=f"Error retrieving file {key} from storage.") | ||
|
Check failure on line 130 in app/api/structured_extract.py
|
||
|
|
||
| if is_pdf: | ||
| try: | ||
| with open(temp_file, "rb") as f: | ||
|
Check failure on line 134 in app/api/structured_extract.py
|
||
| header = f.read(5) | ||
| if header != b"%PDF-": | ||
| raise HTTPException(status_code=400, detail="Downloaded file is not a valid PDF.") | ||
|
Check failure on line 137 in app/api/structured_extract.py
|
||
| except HTTPException: | ||
| raise | ||
| except Exception as e: | ||
| raise HTTPException(status_code=400, detail=f"Error validating file: {e}") | ||
|
Check failure on line 141 in app/api/structured_extract.py
|
||
|
|
||
| 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 | ||
| ) | ||
|
|
||
| # Extract Structured Data | ||
| structured_data = structured_extraction.extract_structured_data( | ||
| text=raw_text, | ||
| provider=model_provider, | ||
| model_name=model_name, | ||
| lang_hint=lang | ||
| ) | ||
|
|
||
| time_taken = str(round((time.time() - start_time), 2)) | ||
| results.append(StructuredExtractionResult( | ||
| file_name=key, | ||
| provider_used=model_provider, | ||
| model_used=model_name if model_provider != "local_tesseract" else None, | ||
| data=structured_data, | ||
| time_taken=time_taken | ||
| )) | ||
| finally: | ||
| if temp_file and os.path.exists(temp_file): | ||
| os.remove(temp_file) | ||
| else: | ||
| raise HTTPException(status_code=400, detail="Invalid source. Use 'upload' or 'object_storage'.") | ||
|
Check failure on line 178 in app/api/structured_extract.py
|
||
|
|
||
| return results | ||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import litellm | ||
| import instructor | ||
| from litellm import completion | ||
| from app.model.StructuredSchema import StructuredDocumentData, DocumentEntity | ||
| from app.domain import ner | ||
| import logging | ||
|
|
||
| log = logging.getLogger("uvicorn") | ||
|
|
||
| def extract_structured_data( | ||
| text: str, | ||
| provider: str = "local_tesseract", | ||
| model_name: str = "", | ||
| lang_hint: str = "eng+por" | ||
| ) -> StructuredDocumentData: | ||
| """ | ||
| Extracts structured data from raw text. | ||
| If provider is 'local_tesseract', returns a fallback structure using heuristics and spaCy. | ||
| Otherwise, uses litellm + instructor to ask the specified model for the StructuredDocumentData. | ||
| """ | ||
|
|
||
| if provider == "local_tesseract" or not model_name: | ||
| # Fallback to local heuristic extraction | ||
| log.info("Using local fallback for structured extraction (no LLM)") | ||
|
|
||
| # Extract entities using existing spaCy NER | ||
| spacy_entities = ner.extract_entities(text, lang_hint=lang_hint) | ||
| entities = [ | ||
| DocumentEntity(text=e["text"], label=e["label"]) | ||
| for e in spacy_entities | ||
| ] | ||
|
|
||
| return StructuredDocumentData( | ||
| document_type="unknown", | ||
| key_values=[], | ||
| tables=[], | ||
| entities=entities, | ||
| text_summary=f"Fallback extraction. Extracted {len(text)} characters of text." | ||
| ) | ||
|
|
||
| # Use litellm with instructor to get structured JSON | ||
| log.info(f"Using LLM for structured extraction: provider={provider}, model={model_name}") | ||
|
Check warning on line 42 in app/domain/structured_extraction.py
|
||
|
|
||
|
|
||
| # Patch litellm completion with instructor | ||
| 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, | ||
| ) | ||
|
Comment on lines
+45
to
+72
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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.pyRepository: 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 -60Repository: 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.pyRepository: gomesrocha/api_ocr Length of output: 1260 Don't make a blocking LLM call from the async request path.
🤖 Prompt for AI Agents |
||
| return structured_data | ||
| 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) | ||
|
Comment on lines
+74
to
+77
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 🧰 Tools🪛 Ruff (0.15.9)[warning] 74-74: Do not catch blind exception: (BLE001) 🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,21 +1,12 @@ | ||
| from fastapi import FastAPI | ||
| from pydantic import BaseModel | ||
| import uvicorn | ||
| from app.api import text_extract, pdf_extract, structured_extract | ||
|
|
||
| app = FastAPI() | ||
| app = FastAPI(title="API_OCR") | ||
|
|
||
| class Item(BaseModel): | ||
| name: str | ||
| description: str | None = None | ||
| price: float | ||
| app.include_router(text_extract.router) | ||
| app.include_router(pdf_extract.router) | ||
| app.include_router(structured_extract.router) | ||
|
|
||
| @app.get('/') | ||
| def read_root(): | ||
| return {'message': 'Welcome to the FastAPI app!'} | ||
|
|
||
| @app.post('/items/') | ||
| def create_item(item: Item): | ||
| return item | ||
|
|
||
| if __name__ == '__main__': | ||
| uvicorn.run('main:app', host='127.0.0.1', port=8000, reload=True) | ||
| if __name__ == "__main__": | ||
| import uvicorn | ||
| uvicorn.run(app, host="0.0.0.0", port=8080) | ||
|
Check failure on line 12 in app/main.py
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| from pydantic import BaseModel, Field | ||
| from typing import List, Optional, Dict, Any | ||
|
|
||
| class KeyValue(BaseModel): | ||
| key: str = Field(description="The field name or key") | ||
| value: str | float | int | bool | None = Field(description="The extracted value") | ||
|
|
||
| class TableRow(BaseModel): | ||
| cells: List[str] = Field(description="The cells in this row") | ||
|
|
||
| class Table(BaseModel): | ||
| title: Optional[str] = Field(None, description="Optional title or caption of the table") | ||
| columns: List[str] = Field(description="Column headers") | ||
| rows: List[TableRow] = Field(description="Rows of the table") | ||
|
|
||
| class DocumentEntity(BaseModel): | ||
| text: str = Field(description="The text of the entity") | ||
| label: str = Field(description="The type of the entity (e.g., PER, ORG, LOC, DATE, MONEY)") | ||
|
|
||
| class StructuredDocumentData(BaseModel): | ||
| document_type: str = Field(description="Inferred type of the document (e.g., 'invoice', 'scientific_paper', 'receipt', 'contract', 'unknown')") | ||
| key_values: List[KeyValue] = Field(default_factory=list, description="Extracted key-value pairs from the document") | ||
| tables: List[Table] = Field(default_factory=list, description="Extracted tables from the document") | ||
| entities: List[DocumentEntity] = Field(default_factory=list, description="Named entities found in the document") | ||
| text_summary: Optional[str] = Field(None, description="A brief summary of the document's content") | ||
|
|
||
| class StructuredExtractionResult(BaseModel): | ||
| file_name: str | ||
| provider_used: str | ||
| model_used: Optional[str] = None | ||
| data: StructuredDocumentData | ||
| time_taken: str |
This file was deleted.
This file was deleted.
This file was deleted.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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-.pdfblob straight toocr.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
exceptclause, raise exceptions withraise ... from errorraise ... from Noneto distinguish them from errors in exception handling(B904)
🤖 Prompt for AI Agents