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() {
{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; weekPDF, 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