From 15cf00cc42766d6e5f8544112651d7ce7ed489bc Mon Sep 17 00:00:00 2001 From: urieljareth Date: Sun, 13 Sep 2026 22:39:39 -0600 Subject: [PATCH] OCR de imagenes con Mistral: ImageProcessor (png/jpg/webp/gif/bmp/tiff), modelo mistral-ocr-latest y recuperacion de fuentes unknown --- .env.example | 2 +- AGENTS.md | 1 + backend/app/config.py | 2 +- backend/app/main.py | 1 + backend/app/managers/knowledge_manager.py | 14 ++++++++++++++ backend/app/services/ingestion_service.py | 17 ++++++++++++++--- .../services/processors/image_processor.py | 19 +++++++++++++++++++ .../app/services/providers/mistral_client.py | 14 +++++++++++++- docker-compose.yml | 2 +- frontend/app/settings/page.tsx | 1 + .../[subjectId]/weeks/[weekNumber]/page.tsx | 18 ++++++++++++------ frontend/lib/api.ts | 2 +- 12 files changed, 79 insertions(+), 14 deletions(-) create mode 100644 backend/app/services/processors/image_processor.py diff --git a/.env.example b/.env.example index 8ea216d..1dccfbe 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ APP_ENV=development APP_DATA_DIR=./data DATABASE_PATH=./data/app.db MISTRAL_API_KEY= -MISTRAL_OCR_MODEL=mistral-ocr-4-0 +MISTRAL_OCR_MODEL=mistral-ocr-latest MISTRAL_OCR_BATCH_THRESHOLD=3 MISTRAL_OCR_BATCH_POLL_SECONDS=10 MISTRAL_OCR_BATCH_TIMEOUT_SECONDS=1800 diff --git a/AGENTS.md b/AGENTS.md index 6bb42c1..36b33e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ - `KnowledgeManager` owns filesystem layout and SQLite metadata; processors should not write final Markdown directly. - `IngestionService` selects processors by `source_type` and wraps output with Markdown frontmatter for LLM ingestion. - Text and Markdown are read directly; `.docx` uses `python-docx`; PDFs use PyMuPDF text extraction unless `use_ocr=true` is passed. PDF uploads can also pass `page_ranges` like `1-5,8,10-12`; ranges apply to both standard extraction and Mistral OCR. +- Images (png, jpg, jpeg, webp, gif, bmp, tiff) are always processed with Mistral OCR (`ImageProcessor`), no `use_ocr` flag needed. Sources stored with `source_type="unknown"` (uploaded before a format was supported) are re-detected by extension on reprocess. - OCR uses Mistral `https://api.mistral.ai/v1/ocr` with `MISTRAL_OCR_MODEL`, defaulting to `mistral-ocr-latest`. - Audio uses Deepgram `https://api.deepgram.com/v1/listen?model=nova-3&smart_format=true&language=es`. - Video processing requires `ffmpeg`; audio is extracted to `data/tmp/`, transcribed with Deepgram, then deleted. diff --git a/backend/app/config.py b/backend/app/config.py index dd55be4..8b2951d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,7 +8,7 @@ class Settings(BaseSettings): app_data_dir: Path = Path("./data") database_path: Path = Path("./data/app.db") mistral_api_key: str = "" - mistral_ocr_model: str = "mistral-ocr-4-0" + mistral_ocr_model: str = "mistral-ocr-latest" mistral_ocr_batch_threshold: int = 3 mistral_ocr_batch_poll_seconds: int = 10 mistral_ocr_batch_timeout_seconds: int = 1800 diff --git a/backend/app/main.py b/backend/app/main.py index 9360a3e..d83141b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -34,6 +34,7 @@ def health() -> dict[str, str]: def settings_status() -> dict: return { "mistral_configured": bool(settings.mistral_api_key), + "mistral_ocr_model": settings.mistral_ocr_model, "deepgram_configured": bool(settings.deepgram_api_key), "data_dir": str(settings.app_data_dir), "database_path": str(settings.database_path), diff --git a/backend/app/managers/knowledge_manager.py b/backend/app/managers/knowledge_manager.py index ab83253..ceaf4c1 100644 --- a/backend/app/managers/knowledge_manager.py +++ b/backend/app/managers/knowledge_manager.py @@ -378,6 +378,18 @@ class KnowledgeManager: raise ValueError("Fuente no encontrada") return dict(row) + def refresh_source_type(self, source_id: int) -> str: + """Recalcula y persiste el tipo de una fuente guardada como ``unknown``. + + Permite reprocesar archivos subidos antes de soportar su formato + (por ejemplo, imagenes que quedaron como ``unknown`` y fallaban). + """ + source = self.get_source(source_id) + source_type = self.detect_source_type(Path(source["stored_path"])) + with self._connect() as conn: + conn.execute("update sources set source_type = ? where id = ?", (source_type, source_id)) + return source_type + def list_sources(self, subject_id: int, week_number: int) -> list[dict[str, Any]]: week = self.get_week(subject_id, week_number) with self._connect() as conn: @@ -552,6 +564,8 @@ class KnowledgeManager: return "docx" if ext == "pdf": return "pdf" + if ext in {"png", "jpg", "jpeg", "webp", "gif", "bmp", "tif", "tiff"}: + return "image" if ext in {"mp3", "wav", "m4a", "ogg", "flac", "webm"}: return "audio" if ext in {"mp4", "mov", "mkv", "avi"}: diff --git a/backend/app/services/ingestion_service.py b/backend/app/services/ingestion_service.py index 55cad32..f4d9b22 100644 --- a/backend/app/services/ingestion_service.py +++ b/backend/app/services/ingestion_service.py @@ -6,6 +6,7 @@ from backend.app.managers.knowledge_manager import KnowledgeManager from backend.app.services.markdown_builder import build_markdown from backend.app.services.processors.audio_processor import AudioProcessor from backend.app.services.processors.docx_processor import DocxProcessor +from backend.app.services.processors.image_processor import ImageProcessor from backend.app.services.processors.pdf_processor import PdfProcessor from backend.app.services.processors.text_processor import TextProcessor from backend.app.services.processors.video_processor import VideoProcessor @@ -19,7 +20,8 @@ class IngestionService: source = self.manager.get_source(source_id) context = self.manager.get_week_context(source["week_id"]) path = Path(source["stored_path"]) - processor = self._processor_for(source["source_type"], use_ocr, page_ranges) + source_type = self._resolve_source_type(source, path) + processor = self._processor_for(source_type, use_ocr, page_ranges) processed = processor.process(path) markdown = build_markdown( title=processed.title, @@ -29,14 +31,21 @@ class IngestionService: "subject_slug": context["subject_slug"], "week": context["week_number"], "source_file": source["original_name"], - "source_type": source["source_type"], + "source_type": source_type, "processor": processed.processor, - "page_ranges": page_ranges if source["source_type"] == "pdf" and page_ranges else None, + "page_ranges": page_ranges if source_type == "pdf" and page_ranges else None, "language": "es", }, ) return self.manager.create_document(source_id, processed.title, markdown, processor=processed.processor, page_ranges=page_ranges) + def _resolve_source_type(self, source: dict, path: Path) -> str: + if source["source_type"] != "unknown": + return source["source_type"] + # Archivos subidos antes de soportar su formato quedaron como + # ``unknown``; al reprocesarlos se recalcula el tipo por extension. + return self.manager.refresh_source_type(source["id"]) + def _processor_for(self, source_type: str, use_ocr: bool, page_ranges: str | None): if source_type == "text": return TextProcessor() @@ -44,6 +53,8 @@ class IngestionService: return DocxProcessor() if source_type == "pdf": return PdfProcessor(use_ocr=use_ocr, page_ranges=page_ranges) + if source_type == "image": + return ImageProcessor() if source_type == "audio": return AudioProcessor() if source_type == "video": diff --git a/backend/app/services/processors/image_processor.py b/backend/app/services/processors/image_processor.py new file mode 100644 index 0000000..d2015f8 --- /dev/null +++ b/backend/app/services/processors/image_processor.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from pathlib import Path + +from backend.app.services.markdown_builder import title_from_path +from backend.app.services.processors.base import ProcessedContent +from backend.app.services.providers.mistral_client import MistralClient + + +class ImageProcessor: + """Procesa imagenes sueltas (png, jpg, webp, gif, bmp, tiff) con Mistral OCR. + + No hay extraccion de texto alternativa para imagenes: siempre pasan por + OCR, independiente de la opcion ``use_ocr``. + """ + + def process(self, path: Path) -> ProcessedContent: + markdown = MistralClient().ocr_image(path) + return ProcessedContent(title=title_from_path(path), body=markdown, processor="mistral-ocr-image") diff --git a/backend/app/services/providers/mistral_client.py b/backend/app/services/providers/mistral_client.py index fa4ed47..e0e8a11 100644 --- a/backend/app/services/providers/mistral_client.py +++ b/backend/app/services/providers/mistral_client.py @@ -36,6 +36,17 @@ class MistralClient: files_url = f"{base_url}/files" batch_url = f"{base_url}/batch/jobs" + _IMAGE_MIME_BY_EXT = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + ".bmp": "image/bmp", + ".tif": "image/tiff", + ".tiff": "image/tiff", + } + def __init__(self) -> None: self._api_key = settings.mistral_api_key self._model = settings.mistral_ocr_model @@ -285,4 +296,5 @@ class MistralClient: def _image_data_url(self, image_path: Path) -> str: encoded = base64.b64encode(image_path.read_bytes()).decode("ascii") - return f"data:image/jpeg;base64,{encoded}" + mime = self._IMAGE_MIME_BY_EXT.get(image_path.suffix.lower(), "image/jpeg") + return f"data:{mime};base64,{encoded}" diff --git a/docker-compose.yml b/docker-compose.yml index 9c087da..b2a9383 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: APP_DATA_DIR: /app/data DATABASE_PATH: /app/data/app.db MISTRAL_API_KEY: ${MISTRAL_API_KEY:-} - MISTRAL_OCR_MODEL: ${MISTRAL_OCR_MODEL:-mistral-ocr-4-0} + MISTRAL_OCR_MODEL: ${MISTRAL_OCR_MODEL:-mistral-ocr-latest} MISTRAL_OCR_BATCH_THRESHOLD: ${MISTRAL_OCR_BATCH_THRESHOLD:-3} MISTRAL_OCR_BATCH_POLL_SECONDS: ${MISTRAL_OCR_BATCH_POLL_SECONDS:-10} MISTRAL_OCR_BATCH_TIMEOUT_SECONDS: ${MISTRAL_OCR_BATCH_TIMEOUT_SECONDS:-1800} diff --git a/frontend/app/settings/page.tsx b/frontend/app/settings/page.tsx index a0d2770..e8a5bd0 100644 --- a/frontend/app/settings/page.tsx +++ b/frontend/app/settings/page.tsx @@ -26,6 +26,7 @@ export default function SettingsPage() { + {settings?.mistral_ocr_model && } {error &&

{error}

} diff --git a/frontend/app/subjects/[subjectId]/weeks/[weekNumber]/page.tsx b/frontend/app/subjects/[subjectId]/weeks/[weekNumber]/page.tsx index 3ff6bfb..704e4d6 100644 --- a/frontend/app/subjects/[subjectId]/weeks/[weekNumber]/page.tsx +++ b/frontend/app/subjects/[subjectId]/weeks/[weekNumber]/page.tsx @@ -51,7 +51,7 @@ export default function WeekPage({ params }: { params: { subjectId: string; week setUploading(true); setError(""); try { const ranges = pageMode === "ranges" ? pageRanges : undefined; - for (const file of files) await api.upload(subjectId, weekNumber, file, pdfMethod === "mistral_ocr", ranges); + for (const file of files) await api.upload(subjectId, weekNumber, file, isImageFile(file) || pdfMethod === "mistral_ocr", ranges); await load(); } catch (err) { setError(err instanceof Error ? err.message : "No se pudo subir archivo"); @@ -65,6 +65,10 @@ export default function WeekPage({ params }: { params: { subjectId: string; week return file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf"); } + function isImageFile(file: File) { + return file.type.startsWith("image/") || /\.(png|jpe?g|webp|gif|bmp|tiff?)$/i.test(file.name); + } + async function prepareFiles(files: File[]) { if (!files.length) return; setError(""); @@ -136,8 +140,10 @@ export default function WeekPage({ params }: { params: { subjectId: string; week async function reprocessSource(source: Source) { setReprocessingId(source.id); setError(""); try { - const ranges = source.source_type === "pdf" && pageMode === "ranges" ? pageRanges : undefined; - await api.reprocessSource(source.id, source.source_type === "pdf" && pdfMethod === "mistral_ocr", ranges); + const isPdf = source.source_type === "pdf"; + const ranges = isPdf && pageMode === "ranges" ? pageRanges : undefined; + const useOcr = isPdf ? pdfMethod === "mistral_ocr" : source.source_type === "image"; + await api.reprocessSource(source.id, useOcr, ranges); await load(); } catch (err) { setError(err instanceof Error ? err.message : "No se pudo reprocesar la fuente"); @@ -187,11 +193,11 @@ export default function WeekPage({ params }: { params: { subjectId: string; week

Subir archivos

-

PDF, DOCX, TXT, Markdown, audio y video.

+

PDF, DOCX, TXT, Markdown, imagenes, audio y video.

↑

Arrastra archivos aqui

Puedes soltar varios archivos a la vez. Se procesaran en esta semana.

-

Soporta PDF, DOCX, TXT, Markdown, audio y video.

+

Soporta PDF, DOCX, TXT, Markdown, imagenes (OCR automatico), audio y video.

{uploading &&

Subiendo archivos...

} {pendingFiles.length > 0 && ( diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 0d27c9f..70764a0 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -35,7 +35,7 @@ export type DocumentItem = { source_page_ranges?: string | null; created_at: string; }; -export type SettingsStatus = { mistral_configured: boolean; deepgram_configured: boolean; data_dir: string; database_path: string }; +export type SettingsStatus = { mistral_configured: boolean; mistral_ocr_model?: string; deepgram_configured: boolean; data_dir: string; database_path: string }; async function parseResponse(response: Response): Promise { if (!response.ok) {