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:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user