feat: Introduce a new desktop application for IPSExtractor, enabling extraction workflows through a React-based UI. This commit includes the application structure, API client integration, and initial components for user interaction. Enhance documentation to guide setup and usage, and update .gitignore to accommodate new files.

This commit is contained in:
erik
2026-07-05 15:07:11 -05:00
parent b55ad74539
commit 9aca926988
31 changed files with 11870 additions and 66 deletions
+9 -1
View File
@@ -22,6 +22,15 @@ pip install -r requirements.txt
uvicorn api.main:app --host 0.0.0.0 --port 8001
```
## Poppler
El contenedor instala `poppler-utils` para que `pdf2image` encuentre
`pdfinfo` y `pdftoppm` desde el PATH del sistema, sin configurar variables de
entorno.
En desarrollo local Windows, el servicio usa automaticamente la carpeta incluida
en `dependencies/poppler-24.08.0/Library/bin`.
## Endpoints principales
- `GET /health`
@@ -32,4 +41,3 @@ uvicorn api.main:app --host 0.0.0.0 --port 8001
- `POST /batches`
- `GET /batches/{batch_id}`
- `GET /batches/{batch_id}/rows`
+206 -52
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from contextlib import asynccontextmanager
from typing import Any
from fastapi import FastAPI, HTTPException
from fastapi import BackgroundTasks, FastAPI, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select
@@ -17,6 +17,7 @@ from domain.strategies import OpenAICompatibleExtractor
from infrastructure.database import (
ExtractionBatch,
ExtractionRow,
Task,
init_db,
session_scope,
utcnow,
@@ -102,6 +103,21 @@ class ExtractionBatchRead(BaseModel):
updated_at: str
class TaskRead(BaseModel):
id: str
type: str
status: str
title: str | None
request: dict[str, Any]
result: dict[str, Any]
error: str | None
owner_id: str | None
created_at: str
updated_at: str
started_at: str | None
finished_at: str | None
def serialize_batch(batch: ExtractionBatch) -> dict[str, Any]:
return {
"id": batch.id,
@@ -114,6 +130,123 @@ def serialize_batch(batch: ExtractionBatch) -> dict[str, Any]:
}
def serialize_task(task: Task) -> dict[str, Any]:
return {
"id": task.id,
"type": task.type,
"status": task.status,
"title": task.title,
"request": task.request,
"result": task.result,
"error": task.error,
"owner_id": task.owner_id,
"created_at": task.created_at.isoformat(),
"updated_at": task.updated_at.isoformat(),
"started_at": task.started_at.isoformat() if task.started_at else None,
"finished_at": task.finished_at.isoformat() if task.finished_at else None,
}
def persist_extraction_request(request: ExtractionRequest) -> dict[str, Any]:
with session_scope() as session:
batch = ExtractionBatch(
status="running",
root_path=request.root_path,
request=request.model_dump(),
updated_at=utcnow(),
)
session.add(batch)
session.flush()
batch_id = batch.id
try:
results = processing_service.process_root_with_field_selection(
request.root_path,
request.keywords,
doc_types=[dt.model_dump() for dt in request.doc_types or []] or None,
fields=request.fields,
csv_output_path=None,
instruction_override=request.instruction,
alias_map=request.alias_map,
tiff_page_range=request.tiff_page_range,
structure_mode=request.structure_mode,
retry_on_empty=request.retry_on_empty,
radicado_filter=request.radicado_filter,
)
with session_scope() as session:
row_index = 0
for item in results:
raw = item if isinstance(item, dict) else {"data": item}
rows = flatten_extraction_result(raw, request.fields, request.alias_map)
for row in rows:
row_index += 1
session.add(
ExtractionRow(
batch_id=batch_id,
radicado=str(raw.get("radicado") or row.get("radicado") or ""),
source_pdf=raw.get("_source_pdf"),
row_index=row_index,
row_json=row,
raw_json=raw,
)
)
batch = session.get(ExtractionBatch, batch_id)
if batch is None:
raise RuntimeError("No se encontro el lote recien creado.")
batch.status = "completed"
batch.updated_at = utcnow()
session.flush()
serialized = serialize_batch(batch)
serialized["row_count"] = row_index
return serialized
except Exception as exc:
with session_scope() as session:
batch = session.get(ExtractionBatch, batch_id)
if batch is not None:
batch.status = "failed"
batch.error = str(exc)
batch.updated_at = utcnow()
raise
def run_extraction_task(task_id: str) -> None:
with session_scope() as session:
task = session.get(Task, task_id)
if task is None:
return
task.status = "running"
task.started_at = utcnow()
task.updated_at = utcnow()
request_payload = dict(task.request or {})
try:
request = ExtractionRequest.model_validate(request_payload)
batch = persist_extraction_request(request)
with session_scope() as session:
task = session.get(Task, task_id)
if task is None:
return
task.status = "completed"
task.result = {
"extraction_batch_id": batch["id"],
"row_count": batch.get("row_count", 0),
"batch": batch,
}
task.error = None
task.finished_at = utcnow()
task.updated_at = utcnow()
except Exception as exc:
with session_scope() as session:
task = session.get(Task, task_id)
if task is None:
return
task.status = "failed"
task.error = str(exc)
task.finished_at = utcnow()
task.updated_at = utcnow()
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@@ -228,64 +361,85 @@ def run_extraction(request: ExtractionRequest) -> dict[str, Any]:
detail="El API no tiene selector visual: envia fields. Usa POST /schemas para descubrirlos.",
)
try:
return persist_extraction_request(request)
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.post("/tasks/extraction", response_model=TaskRead)
def create_extraction_task(request: ExtractionRequest, background_tasks: BackgroundTasks) -> dict[str, Any]:
if not request.fields:
raise HTTPException(
status_code=400,
detail="Envia fields. Usa POST /schemas para descubrir campos antes de crear la tarea.",
)
with session_scope() as session:
batch = ExtractionBatch(
status="running",
root_path=request.root_path,
task = Task(
type="extraction",
status="pending",
title=f"Extraccion {request.root_path}",
request=request.model_dump(),
result={},
updated_at=utcnow(),
)
session.add(batch)
session.add(task)
session.flush()
batch_id = batch.id
task_id = task.id
serialized = serialize_task(task)
try:
results = processing_service.process_root_with_field_selection(
request.root_path,
request.keywords,
doc_types=[dt.model_dump() for dt in request.doc_types or []] or None,
fields=request.fields,
csv_output_path=None,
instruction_override=request.instruction,
alias_map=request.alias_map,
tiff_page_range=request.tiff_page_range,
structure_mode=request.structure_mode,
retry_on_empty=request.retry_on_empty,
radicado_filter=request.radicado_filter,
)
background_tasks.add_task(run_extraction_task, task_id)
return serialized
with session_scope() as session:
row_index = 0
for item in results:
raw = item if isinstance(item, dict) else {"data": item}
rows = flatten_extraction_result(raw, request.fields, request.alias_map)
for row in rows:
row_index += 1
session.add(
ExtractionRow(
batch_id=batch_id,
radicado=str(raw.get("radicado") or row.get("radicado") or ""),
source_pdf=raw.get("_source_pdf"),
row_index=row_index,
row_json=row,
raw_json=raw,
)
)
batch = session.get(ExtractionBatch, batch_id)
if batch is None:
raise RuntimeError("No se encontro el lote recien creado.")
batch.status = "completed"
batch.updated_at = utcnow()
session.flush()
return serialize_batch(batch)
except Exception as exc:
with session_scope() as session:
batch = session.get(ExtractionBatch, batch_id)
if batch is not None:
batch.status = "failed"
batch.error = str(exc)
batch.updated_at = utcnow()
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.get("/tasks", response_model=list[TaskRead])
def list_tasks(type: str | None = None, status: str | None = None) -> list[dict[str, Any]]:
with session_scope() as session:
query = select(Task).order_by(Task.created_at.desc())
if type:
query = query.where(Task.type == type)
if status:
query = query.where(Task.status == status)
tasks = session.scalars(query).all()
return [serialize_task(task) for task in tasks]
@app.get("/tasks/{task_id}", response_model=TaskRead)
def get_task(task_id: str) -> dict[str, Any]:
with session_scope() as session:
task = session.get(Task, task_id)
if task is None:
raise HTTPException(status_code=404, detail="Tarea no encontrada")
return serialize_task(task)
@app.get("/tasks/{task_id}/rows")
def get_task_rows(task_id: str, limit: int = 500, offset: int = 0) -> dict[str, Any]:
with session_scope() as session:
task = session.get(Task, task_id)
if task is None:
raise HTTPException(status_code=404, detail="Tarea no encontrada")
batch_id = (task.result or {}).get("extraction_batch_id")
if not batch_id:
raise HTTPException(status_code=400, detail="La tarea aun no tiene lote de extraccion asociado")
batch = session.get(ExtractionBatch, batch_id)
if batch is None:
raise HTTPException(status_code=404, detail="Lote no encontrado")
rows = session.scalars(
select(ExtractionRow)
.where(ExtractionRow.batch_id == batch_id)
.order_by(ExtractionRow.row_index, ExtractionRow.id)
.offset(offset)
.limit(limit)
).all()
return {
"task_id": task_id,
"batch_id": batch_id,
"rows": [row.row_json for row in rows],
"limit": limit,
"offset": offset,
}
@app.get("/batches")
+18 -9
View File
@@ -1,11 +1,8 @@
"""Utilidades para convertir documentos (PDF/TIFF) a imágenes base64.
En Windows se requiere Poppler. Este proyecto incluye una ruta por defecto:
`dependencies/poppler-24.08.0/Library/bin`. Ajuste si su instalación difiere.
"""
"""Utilidades para convertir documentos (PDF/TIFF) a imágenes base64."""
import base64
import logging
import os
from pathlib import Path
from io import BytesIO
from typing import List, Tuple, Optional
import time
@@ -17,12 +14,24 @@ Image.MAX_IMAGE_PIXELS = None
logger = logging.getLogger(__name__)
_DEFAULT_POPPLER = 'dependencies/poppler-24.08.0/Library/bin'
_SERVICE_ROOT = Path(__file__).resolve().parents[1]
_BUNDLED_WINDOWS_POPPLER = _SERVICE_ROOT / "dependencies" / "poppler-24.08.0" / "Library" / "bin"
_LEGACY_WINDOWS_POPPLER = Path("dependencies/poppler-24.08.0/Library/bin")
_DOCKER_POPPLER = Path("/usr/bin")
def _resolve_poppler(poppler_path: str | None) -> str:
"""Devuelve la ruta a Poppler: usa la provista o el default del proyecto."""
return poppler_path or _DEFAULT_POPPLER
def _resolve_poppler(poppler_path: str | None) -> str | None:
"""Devuelve una ruta de Poppler solo si existe; si no, usa el PATH del sistema."""
candidates = [
Path(poppler_path.strip()) if poppler_path and poppler_path.strip() else None,
_BUNDLED_WINDOWS_POPPLER,
_LEGACY_WINDOWS_POPPLER,
_DOCKER_POPPLER,
]
for candidate in candidates:
if candidate and candidate.exists():
return str(candidate)
return None
def pdf_to_images_bs64(
+18 -1
View File
@@ -54,6 +54,23 @@ class ExtractionBatch(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class Task(Base):
__tablename__ = "tasks"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id)
type: Mapped[str] = mapped_column(String(64), index=True)
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
title: Mapped[str | None] = mapped_column(Text, nullable=True)
request: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
result: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
owner_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class ExtractionRow(Base):
__tablename__ = "extraction_rows"
@@ -93,7 +110,7 @@ class CleanedRow(Base):
def init_db() -> None:
if DATABASE_URL.startswith("postgresql"):
with engine.begin() as connection:
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as connection:
connection.execute(text("SELECT pg_advisory_lock(74512001)"))
try:
Base.metadata.create_all(bind=connection)