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:
@@ -6,6 +6,7 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
|
||||
EXTRACTION_URL = os.getenv("EXTRACTION_URL", "http://extraction:8001").rstrip("/")
|
||||
@@ -13,6 +14,14 @@ CLEANING_URL = os.getenv("CLEANING_URL", "http://cleaning:8002").rstrip("/")
|
||||
|
||||
app = FastAPI(title="IPSExtractor API Gateway", version="1.0.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$",
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, Any]:
|
||||
@@ -49,6 +58,16 @@ async def extraction_proxy(path: str, request: Request) -> Response:
|
||||
return await _proxy_request(request, f"{EXTRACTION_URL}/{path}")
|
||||
|
||||
|
||||
@app.api_route("/tasks", methods=["GET"])
|
||||
async def tasks_root_proxy(request: Request) -> Response:
|
||||
return await _proxy_request(request, f"{EXTRACTION_URL}/tasks")
|
||||
|
||||
|
||||
@app.api_route("/tasks/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
|
||||
async def tasks_proxy(path: str, request: Request) -> Response:
|
||||
return await _proxy_request(request, f"{EXTRACTION_URL}/tasks/{path}")
|
||||
|
||||
|
||||
@app.api_route("/cleaning/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
|
||||
async def cleaning_proxy(path: str, request: Request) -> Response:
|
||||
return await _proxy_request(request, f"{CLEANING_URL}/{path}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# IPSExtractor Desktop App
|
||||
|
||||
Aplicacion desktop Windows/Mac para ejecutar el flujo de extraccion usando el
|
||||
API Gateway de IPSExtractor. Esta carpeta funciona como repo independiente.
|
||||
|
||||
## Requisitos
|
||||
|
||||
- Node.js 20+
|
||||
- Backend levantado con `docker compose up --build`
|
||||
- Documentos montados en Docker como `/data`
|
||||
|
||||
## Configuracion de rutas
|
||||
|
||||
La app no sube archivos en v1. El usuario solo selecciona la carpeta a procesar.
|
||||
Internamente, la app traduce esa carpeta al path montado en Docker.
|
||||
|
||||
Si `IPS_DOCUMENT_ROOT` existe en el entorno de la app, la carpeta seleccionada
|
||||
debe estar dentro de ese root. Si no existe, la app toma la carpeta elegida como
|
||||
root local para traducirla a `/data`.
|
||||
|
||||
Para procesar documentos fuera de `data`, levanta Docker y la app con el mismo
|
||||
root local:
|
||||
|
||||
```bash
|
||||
IPS_DOCUMENT_ROOT=/ruta/a/documentos docker compose up --build
|
||||
IPS_DOCUMENT_ROOT=/ruta/a/documentos npm run dev
|
||||
```
|
||||
|
||||
## Desarrollo
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
El gateway por defecto es `http://localhost:8000`.
|
||||
|
||||
## Tests y build
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run build
|
||||
npm run dist
|
||||
```
|
||||
|
||||
## Flujo v1
|
||||
|
||||
1. Verificar conexion al gateway.
|
||||
2. Seleccionar una carpeta local que este montada para los servicios Docker.
|
||||
3. Configurar modo `radicados` o `general`.
|
||||
4. Descubrir campos con `/extraction/schemas`.
|
||||
5. Seleccionar campos y alias.
|
||||
6. Estimar o crear tarea de extraccion.
|
||||
7. Consultar el historial de tareas.
|
||||
8. Ver filas extraidas desde la base de datos.
|
||||
9. Exportar CSV local de forma opcional.
|
||||
|
||||
La app no incluye limpieza, preclasificacion, postproceso ni dashboard completo
|
||||
en esta version.
|
||||
@@ -0,0 +1,110 @@
|
||||
const { app, BrowserWindow, dialog, ipcMain } = require("electron");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
|
||||
const DOCUMENT_EXTENSIONS = new Set([".pdf", ".tif", ".tiff", ".png", ".jpg", ".jpeg"]);
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 860,
|
||||
minWidth: 1080,
|
||||
minHeight: 720,
|
||||
title: "IPSExtractor Desktop",
|
||||
backgroundColor: "#f6f7f9",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.cjs"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (!app.isPackaged) {
|
||||
win.loadURL("http://127.0.0.1:5173");
|
||||
win.webContents.openDevTools({ mode: "detach" });
|
||||
} else {
|
||||
win.loadFile(path.join(__dirname, "../dist/index.html"));
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
|
||||
ipcMain.handle("dialog:select-directory", async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory"],
|
||||
});
|
||||
if (result.canceled || result.filePaths.length === 0) return null;
|
||||
return result.filePaths[0];
|
||||
});
|
||||
|
||||
ipcMain.handle("runtime:config", async () => {
|
||||
return {
|
||||
documentRootLocal: process.env.IPS_DOCUMENT_ROOT || "",
|
||||
documentRootContainer: process.env.IPS_DOCUMENT_ROOT_CONTAINER || "/data",
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle("dialog:save-csv", async (_event, defaultPath) => {
|
||||
const result = await dialog.showSaveDialog({
|
||||
defaultPath: defaultPath || "extraccion.csv",
|
||||
filters: [{ name: "CSV", extensions: ["csv"] }],
|
||||
});
|
||||
if (result.canceled || !result.filePath) return null;
|
||||
return result.filePath;
|
||||
});
|
||||
|
||||
ipcMain.handle("file:write-text", async (_event, filePath, content) => {
|
||||
await fs.writeFile(filePath, content, "utf8");
|
||||
return { ok: true, filePath };
|
||||
});
|
||||
|
||||
ipcMain.handle("fs:first-document", async (_event, rootPath, keywords) => {
|
||||
const matches = await findDocuments(rootPath, Array.isArray(keywords) ? keywords : []);
|
||||
return matches[0] || null;
|
||||
});
|
||||
|
||||
async function findDocuments(rootPath, keywords) {
|
||||
const normalizedKeywords = keywords
|
||||
.map((item) => String(item || "").trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const out = [];
|
||||
|
||||
async function walk(dir) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(fullPath);
|
||||
if (out.length > 0) return;
|
||||
continue;
|
||||
}
|
||||
const ext = path.extname(entry.name).toLowerCase();
|
||||
if (!DOCUMENT_EXTENSIONS.has(ext)) continue;
|
||||
const lowerName = entry.name.toLowerCase();
|
||||
if (normalizedKeywords.length > 0 && !normalizedKeywords.some((kw) => lowerName.includes(kw))) {
|
||||
continue;
|
||||
}
|
||||
out.push(fullPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await walk(rootPath);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
contextBridge.exposeInMainWorld("desktopApi", {
|
||||
getRuntimeConfig: () => ipcRenderer.invoke("runtime:config"),
|
||||
selectDirectory: () => ipcRenderer.invoke("dialog:select-directory"),
|
||||
saveCsvDialog: (defaultPath) => ipcRenderer.invoke("dialog:save-csv", defaultPath),
|
||||
writeTextFile: (filePath, content) => ipcRenderer.invoke("file:write-text", filePath, content),
|
||||
findFirstDocument: (rootPath, keywords) => ipcRenderer.invoke("fs:first-document", rootPath, keywords),
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>IPSExtractor Desktop</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/renderer/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+9407
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "ipsextractor-desktop",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Desktop extraction client for IPSExtractor microservices.",
|
||||
"main": "electron/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"vite --host 127.0.0.1\" \"wait-on tcp:5173 && electron .\"",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"dist": "npm run build && electron-builder"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^18.3.17",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"concurrently": "^9.1.0",
|
||||
"electron": "^33.2.1",
|
||||
"electron-builder": "^25.1.8",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^2.1.8",
|
||||
"wait-on": "^8.0.1"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.ipsextractor.desktop",
|
||||
"productName": "IPSExtractor Desktop",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.productivity"
|
||||
},
|
||||
"win": {
|
||||
"target": "nsis"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import App from "./App";
|
||||
|
||||
describe("App", () => {
|
||||
it("renders the extraction form", () => {
|
||||
render(<App />);
|
||||
expect(screen.getByText("IPSExtractor")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /^extraccion$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /tareas/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /configuracion/i })).toBeInTheDocument();
|
||||
expect(screen.getByText("Carpeta y estructura")).toBeInTheDocument();
|
||||
expect(screen.queryByText("IPS_DOCUMENT_ROOT_LOCAL")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("IPS_DOCUMENT_ROOT_CONTAINER")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Ruta enviada a Docker/API")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /descubrir campos/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows API URL settings in the configuration page", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: /configuracion/i }));
|
||||
expect(screen.getByText("Conexion API")).toBeInTheDocument();
|
||||
const input = screen.getByLabelText("URL del API Gateway");
|
||||
await user.clear(input);
|
||||
await user.type(input, "http://localhost:9000");
|
||||
expect(input).toHaveValue("http://localhost:9000");
|
||||
});
|
||||
|
||||
it("adds and removes document types", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: /añadir tipo/i }));
|
||||
expect(screen.getByLabelText("Nombre tipo 2")).toBeInTheDocument();
|
||||
const removeButtons = screen.getAllByLabelText("Eliminar tipo");
|
||||
await user.click(removeButtons[1]);
|
||||
expect(screen.queryByLabelText("Nombre tipo 2")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows schema fields from a mocked API response", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(window.desktopApi!, "selectDirectory").mockResolvedValue("C:\\docs\\lote");
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => JSON.stringify({ schema_by_type: { Factura: { numero: "str" } }, sample_by_type: { Factura: { numero: "A1" } } }),
|
||||
} as Response);
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: /explorar/i }));
|
||||
await user.click(screen.getByRole("button", { name: /descubrir campos/i }));
|
||||
expect(await screen.findByText("Factura/numero")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blocks extraction while no fields are selected", () => {
|
||||
render(<App />);
|
||||
expect(screen.getByRole("button", { name: /ejecutar extraccion/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("creates an extraction task with mocked API responses", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(window.desktopApi!, "selectDirectory").mockResolvedValue("C:\\docs\\lote");
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/extraction/schemas")) {
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => JSON.stringify({ schema_by_type: { Factura: { numero: "str" } }, sample_by_type: { Factura: { numero: "A1" } } }),
|
||||
} as Response;
|
||||
}
|
||||
if (url.includes("/tasks/extraction")) {
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => JSON.stringify({ id: "task-1", type: "extraction", status: "pending", title: "Extraccion /data", request: {}, result: {}, error: null, owner_id: null, created_at: "now", updated_at: "now", started_at: null, finished_at: null }),
|
||||
} as Response;
|
||||
}
|
||||
if (url.includes("/tasks?")) {
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => JSON.stringify([{ id: "task-1", type: "extraction", status: "completed", title: "Extraccion /data", request: {}, result: { extraction_batch_id: "batch-1" }, error: null, owner_id: null, created_at: "now", updated_at: "now", started_at: "now", finished_at: "now" }]),
|
||||
} as Response;
|
||||
}
|
||||
if (url.includes("/tasks/task-1/rows")) {
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => JSON.stringify({ task_id: "task-1", batch_id: "batch-1", rows: [{ radicado: "R1", numero: "A1" }], limit: 500, offset: 0 }),
|
||||
} as Response;
|
||||
}
|
||||
return { ok: true, text: async () => "{}" } as Response;
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
await user.click(screen.getByRole("button", { name: /explorar/i }));
|
||||
await user.click(screen.getByRole("button", { name: /descubrir campos/i }));
|
||||
await user.click(await screen.findByText("Factura/numero"));
|
||||
await user.click(screen.getByRole("button", { name: /ejecutar extraccion/i }));
|
||||
expect(await screen.findByText("Tareas y resultados")).toBeInTheDocument();
|
||||
expect(await screen.findByText("completed")).toBeInTheDocument();
|
||||
expect(await screen.findByText("A1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,667 @@
|
||||
import { Activity, AlertCircle, Check, Database, Download, FileSearch, FolderOpen, Loader2, Play, Plus, RefreshCw, Search, Server, Settings, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { DocumentType, ExtractionFormState, FieldSelection, RowsResponse, SchemaResponse, Task } from "../shared/types";
|
||||
import { ApiClient } from "./lib/apiClient";
|
||||
import { rowsToCsv } from "./lib/csv";
|
||||
import { buildBatchPayload, buildEstimatePayload, buildSchemaPayload, flattenSchema } from "./lib/payloads";
|
||||
import { defaultContainerRoot, mapLocalPathToContainer, splitKeywords } from "./lib/pathMapping";
|
||||
|
||||
const defaultForm: ExtractionFormState = {
|
||||
apiBaseUrl: localStorage.getItem("apiBaseUrl") || "http://localhost:8000",
|
||||
localRoot: localStorage.getItem("localRoot") || "",
|
||||
containerRoot: localStorage.getItem("containerRoot") || "/data",
|
||||
selectedLocalPath: "",
|
||||
selectedContainerPath: "",
|
||||
mode: "radicados",
|
||||
docTypes: [{ name: "Factura", keyword: "Factura_O_Documento_Equivalente" }],
|
||||
keywordsText: "factura",
|
||||
retryOnEmpty: false,
|
||||
filterEnabled: false,
|
||||
radicadoFilterText: "",
|
||||
tiffEnabled: false,
|
||||
tiffStart: "0",
|
||||
tiffEnd: "0",
|
||||
};
|
||||
|
||||
type Status = "idle" | "ok" | "error" | "busy";
|
||||
type View = "extraction" | "tasks" | "settings";
|
||||
|
||||
function App() {
|
||||
const [view, setView] = useState<View>("extraction");
|
||||
const [form, setForm] = useState<ExtractionFormState>(defaultForm);
|
||||
const [connectionStatus, setConnectionStatus] = useState<Status>("idle");
|
||||
const [connectionMessage, setConnectionMessage] = useState("Sin verificar");
|
||||
const [configJson, setConfigJson] = useState<unknown>(null);
|
||||
const [modelsJson, setModelsJson] = useState<unknown>(null);
|
||||
const [schemaResponse, setSchemaResponse] = useState<SchemaResponse | null>(null);
|
||||
const [schemaSearch, setSchemaSearch] = useState("");
|
||||
const [selection, setSelection] = useState<FieldSelection>({ fields: [], aliasMap: {} });
|
||||
const [estimateJson, setEstimateJson] = useState<unknown>(null);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||
const [rows, setRows] = useState<RowsResponse | null>(null);
|
||||
const [statusText, setStatusText] = useState("Listo");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const client = useMemo(() => new ApiClient(form.apiBaseUrl), [form.apiBaseUrl]);
|
||||
const flattened = useMemo(() => (schemaResponse ? flattenSchema(schemaResponse) : { schema: {}, sample: null }), [schemaResponse]);
|
||||
const filteredSchemaEntries = useMemo(
|
||||
() => Object.entries(flattened.schema).filter(([path]) => path.toLowerCase().includes(schemaSearch.toLowerCase())),
|
||||
[flattened.schema, schemaSearch],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.desktopApi?.getRuntimeConfig) return;
|
||||
window.desktopApi.getRuntimeConfig().then((config) => {
|
||||
updateForm({
|
||||
localRoot: config.documentRootLocal || localStorage.getItem("localRoot") || "",
|
||||
containerRoot: config.documentRootContainer || "/data",
|
||||
});
|
||||
}).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const hasRunning = tasks.some((task) => task.status === "pending" || task.status === "running");
|
||||
if (!activeTask && !hasRunning) return;
|
||||
const timer = window.setInterval(() => {
|
||||
refreshTasks(activeTask?.id).catch(() => undefined);
|
||||
}, 3000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [activeTask?.id, form.apiBaseUrl, tasks]);
|
||||
|
||||
function updateForm(patch: Partial<ExtractionFormState>) {
|
||||
setForm((current) => {
|
||||
const next = { ...current, ...patch };
|
||||
localStorage.setItem("apiBaseUrl", next.apiBaseUrl);
|
||||
if (next.localRoot) localStorage.setItem("localRoot", next.localRoot);
|
||||
if (next.containerRoot) localStorage.setItem("containerRoot", next.containerRoot);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyConnection() {
|
||||
setConnectionStatus("busy");
|
||||
setConnectionMessage("Verificando gateway...");
|
||||
setError(null);
|
||||
try {
|
||||
const [health, config, models] = await Promise.all([client.health(), client.config(), client.models()]);
|
||||
setConfigJson(config);
|
||||
setModelsJson(models);
|
||||
setConnectionStatus("ok");
|
||||
setConnectionMessage(`Conectado: ${JSON.stringify(health)}`);
|
||||
await refreshTasks();
|
||||
} catch (err) {
|
||||
setConnectionStatus("error");
|
||||
setConnectionMessage(err instanceof Error ? err.message : "No se pudo conectar");
|
||||
}
|
||||
}
|
||||
|
||||
async function selectRoot() {
|
||||
if (!window.desktopApi) {
|
||||
setError("La seleccion de carpetas requiere Electron.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
const selected = await window.desktopApi.selectDirectory();
|
||||
if (!selected) return;
|
||||
try {
|
||||
const localRoot = form.localRoot || selected;
|
||||
const selectedContainerPath = mapLocalPathToContainer({
|
||||
localRoot,
|
||||
containerRoot: defaultContainerRoot(form.containerRoot),
|
||||
selectedPath: selected,
|
||||
});
|
||||
updateForm({ localRoot, selectedLocalPath: selected, selectedContainerPath });
|
||||
setStatusText("Carpeta lista para procesar");
|
||||
} catch (err) {
|
||||
const selectedContainerPath = mapLocalPathToContainer({
|
||||
localRoot: selected,
|
||||
containerRoot: defaultContainerRoot(form.containerRoot),
|
||||
selectedPath: selected,
|
||||
});
|
||||
updateForm({ localRoot: selected, selectedLocalPath: selected, selectedContainerPath });
|
||||
setStatusText("Carpeta seleccionada como nueva raiz local");
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverSchema() {
|
||||
await runTask("Descubriendo campos...", async () => {
|
||||
let documentPath: string | undefined;
|
||||
if (form.mode === "general") {
|
||||
if (!window.desktopApi) throw new Error("La busqueda de documentos requiere Electron.");
|
||||
const firstLocal = await window.desktopApi.findFirstDocument(form.selectedLocalPath, splitKeywords(form.keywordsText));
|
||||
if (!firstLocal) throw new Error("No se encontro un documento de muestra en la carpeta seleccionada.");
|
||||
documentPath = mapLocalPathToContainer({
|
||||
localRoot: form.localRoot,
|
||||
containerRoot: defaultContainerRoot(form.containerRoot),
|
||||
selectedPath: firstLocal,
|
||||
});
|
||||
}
|
||||
const payload = buildSchemaPayload(form, documentPath);
|
||||
const schema = await client.discoverSchema(payload);
|
||||
setSchemaResponse(schema);
|
||||
const flat = flattenSchema(schema);
|
||||
setSelection({
|
||||
fields: [],
|
||||
aliasMap: Object.fromEntries(Object.keys(flat.schema).map((path) => [path, path])),
|
||||
});
|
||||
setEstimateJson(null);
|
||||
setActiveTask(null);
|
||||
setRows(null);
|
||||
setStatusText(`Campos descubiertos: ${Object.keys(flat.schema).length}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function estimate() {
|
||||
await runTask("Estimando...", async () => {
|
||||
requireFields();
|
||||
const result = await client.estimate(buildEstimatePayload(form, schemaResponse, selection));
|
||||
setEstimateJson(result);
|
||||
setStatusText("Estimacion completada");
|
||||
});
|
||||
}
|
||||
|
||||
async function runExtraction() {
|
||||
await runTask("Creando tarea de extraccion...", async () => {
|
||||
requireFields();
|
||||
const task = await client.createExtractionTask(buildBatchPayload(form, selection));
|
||||
setActiveTask(task);
|
||||
setTasks((current) => [task, ...current.filter((item) => item.id !== task.id)]);
|
||||
setRows(null);
|
||||
setStatusText(`Tarea creada: ${task.id}`);
|
||||
await refreshTasks(task.id);
|
||||
setView("tasks");
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshTasks(selectTaskId?: string) {
|
||||
const list = await client.tasks("extraction");
|
||||
setTasks(list);
|
||||
const selected = selectTaskId ? list.find((task) => task.id === selectTaskId) : activeTask ? list.find((task) => task.id === activeTask.id) : null;
|
||||
if (selected) {
|
||||
setActiveTask(selected);
|
||||
if (selected.status === "completed") {
|
||||
const taskRows = await client.taskRows(selected.id, 500, 0);
|
||||
setRows(taskRows);
|
||||
setStatusText(`Tarea completada con ${taskRows.rows.length} filas`);
|
||||
} else if (selected.status === "failed") {
|
||||
setStatusText("Tarea fallida");
|
||||
setError(selected.error || "La tarea fallo sin detalle.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function selectTask(task: Task) {
|
||||
setActiveTask(task);
|
||||
setError(task.status === "failed" ? task.error : null);
|
||||
if (task.status === "completed") {
|
||||
await runTask("Cargando filas de la tarea...", async () => {
|
||||
const taskRows = await client.taskRows(task.id, 500, 0);
|
||||
setRows(taskRows);
|
||||
});
|
||||
} else {
|
||||
setRows(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
await runTask("Exportando CSV...", async () => {
|
||||
if (!rows || rows.rows.length === 0) throw new Error("No hay filas para exportar.");
|
||||
if (!window.desktopApi) throw new Error("Exportar CSV requiere Electron.");
|
||||
const content = rowsToCsv(rows.rows, selection.aliasMap);
|
||||
const target = await window.desktopApi.saveCsvDialog(`extraccion-${rows.batch_id}.csv`);
|
||||
if (!target) return;
|
||||
await window.desktopApi.writeTextFile(target, content);
|
||||
setStatusText(`CSV guardado: ${target}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function runTask(label: string, task: () => Promise<void>) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setStatusText(label);
|
||||
try {
|
||||
await task();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Error desconocido";
|
||||
setError(message);
|
||||
setStatusText("Error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function requireFields() {
|
||||
if (!form.selectedContainerPath) throw new Error("Selecciona una carpeta raiz antes de continuar.");
|
||||
if (selection.fields.length === 0) throw new Error("Selecciona al menos un campo.");
|
||||
}
|
||||
|
||||
function setDocType(index: number, patch: Partial<DocumentType>) {
|
||||
updateForm({
|
||||
docTypes: form.docTypes.map((item, idx) => (idx === index ? { ...item, ...patch } : item)),
|
||||
});
|
||||
}
|
||||
|
||||
function toggleField(path: string, checked: boolean) {
|
||||
setSelection((current) => {
|
||||
const fields = checked ? Array.from(new Set([...current.fields, path])) : current.fields.filter((field) => field !== path);
|
||||
return {
|
||||
fields,
|
||||
aliasMap: { ...current.aliasMap, [path]: current.aliasMap[path] || path },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const currentTitle = view === "extraction" ? "Extraccion de documentos" : view === "tasks" ? "Tareas y resultados" : "Configuracion";
|
||||
const currentDescription = view === "extraction"
|
||||
? "Selecciona una carpeta, descubre campos y crea una tarea de extraccion."
|
||||
: view === "tasks"
|
||||
? "Consulta el historial de ejecuciones y las filas guardadas en la base de datos."
|
||||
: "Administra la conexion al API Gateway local.";
|
||||
|
||||
return (
|
||||
<main className="app-frame">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<div className="brand-mark">IP</div>
|
||||
<div>
|
||||
<strong>IPSExtractor</strong>
|
||||
<span>Desktop</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="side-nav" aria-label="Principal">
|
||||
<button type="button" className={view === "extraction" ? "active" : ""} onClick={() => setView("extraction")}>
|
||||
<FileSearch size={18} /> Extraccion
|
||||
</button>
|
||||
<button type="button" className={view === "tasks" ? "active" : ""} onClick={() => setView("tasks")}>
|
||||
<Activity size={18} /> Tareas
|
||||
</button>
|
||||
<button type="button" className={view === "settings" ? "active" : ""} onClick={() => setView("settings")}>
|
||||
<Settings size={18} /> Configuracion
|
||||
</button>
|
||||
</nav>
|
||||
<div className="sidebar-status">
|
||||
<span>Gateway</span>
|
||||
<StatusPill status={connectionStatus} text={connectionStatus === "ok" ? "Conectado" : connectionStatus === "busy" ? "Verificando" : connectionStatus === "error" ? "Error" : "Sin verificar"} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="workspace">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<h1>{currentTitle}</h1>
|
||||
<p>{currentDescription}</p>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<button type="button" className="icon-button" onClick={verifyConnection} disabled={busy}>
|
||||
<Server size={16} /> Verificar API
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{view === "extraction" && (
|
||||
<ExtractionView
|
||||
form={form}
|
||||
updateForm={updateForm}
|
||||
busy={busy}
|
||||
error={error}
|
||||
statusText={statusText}
|
||||
selectRoot={selectRoot}
|
||||
discoverSchema={discoverSchema}
|
||||
estimate={estimate}
|
||||
runExtraction={runExtraction}
|
||||
exportCsv={exportCsv}
|
||||
rows={rows}
|
||||
selection={selection}
|
||||
setSelection={setSelection}
|
||||
schemaSearch={schemaSearch}
|
||||
setSchemaSearch={setSchemaSearch}
|
||||
filteredSchemaEntries={filteredSchemaEntries}
|
||||
flattened={flattened}
|
||||
estimateJson={estimateJson}
|
||||
activeTask={activeTask}
|
||||
configJson={configJson}
|
||||
modelsJson={modelsJson}
|
||||
setDocType={setDocType}
|
||||
toggleField={toggleField}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === "tasks" && (
|
||||
<TasksView
|
||||
tasks={tasks}
|
||||
activeTask={activeTask}
|
||||
rows={rows}
|
||||
busy={busy}
|
||||
refreshTasks={refreshTasks}
|
||||
selectTask={selectTask}
|
||||
exportCsv={exportCsv}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === "settings" && (
|
||||
<SettingsView
|
||||
form={form}
|
||||
updateForm={updateForm}
|
||||
connectionStatus={connectionStatus}
|
||||
connectionMessage={connectionMessage}
|
||||
verifyConnection={verifyConnection}
|
||||
busy={busy}
|
||||
configJson={configJson}
|
||||
modelsJson={modelsJson}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExtractionViewProps {
|
||||
form: ExtractionFormState;
|
||||
updateForm: (patch: Partial<ExtractionFormState>) => void;
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
statusText: string;
|
||||
selectRoot: () => Promise<void>;
|
||||
discoverSchema: () => Promise<void>;
|
||||
estimate: () => Promise<void>;
|
||||
runExtraction: () => Promise<void>;
|
||||
exportCsv: () => Promise<void>;
|
||||
rows: RowsResponse | null;
|
||||
selection: FieldSelection;
|
||||
setSelection: React.Dispatch<React.SetStateAction<FieldSelection>>;
|
||||
schemaSearch: string;
|
||||
setSchemaSearch: React.Dispatch<React.SetStateAction<string>>;
|
||||
filteredSchemaEntries: [string, string][];
|
||||
flattened: { schema: Record<string, string>; sample: unknown };
|
||||
estimateJson: unknown;
|
||||
activeTask: Task | null;
|
||||
configJson: unknown;
|
||||
modelsJson: unknown;
|
||||
setDocType: (index: number, patch: Partial<DocumentType>) => void;
|
||||
toggleField: (path: string, checked: boolean) => void;
|
||||
}
|
||||
|
||||
function ExtractionView({
|
||||
form,
|
||||
updateForm,
|
||||
busy,
|
||||
error,
|
||||
statusText,
|
||||
selectRoot,
|
||||
discoverSchema,
|
||||
estimate,
|
||||
runExtraction,
|
||||
exportCsv,
|
||||
rows,
|
||||
selection,
|
||||
setSelection,
|
||||
schemaSearch,
|
||||
setSchemaSearch,
|
||||
filteredSchemaEntries,
|
||||
flattened,
|
||||
estimateJson,
|
||||
activeTask,
|
||||
configJson,
|
||||
modelsJson,
|
||||
setDocType,
|
||||
toggleField,
|
||||
}: ExtractionViewProps) {
|
||||
return (
|
||||
<>
|
||||
<section className="grid two">
|
||||
<Panel title="Carpeta y estructura">
|
||||
<div className="path-row">
|
||||
<label>
|
||||
Carpeta a procesar
|
||||
<input value={form.selectedLocalPath} readOnly placeholder="Selecciona la carpeta con los documentos" />
|
||||
</label>
|
||||
<button type="button" className="icon-button" onClick={selectRoot} disabled={busy}>
|
||||
<FolderOpen size={16} /> Explorar
|
||||
</button>
|
||||
</div>
|
||||
<div className="segmented">
|
||||
<button type="button" className={form.mode === "radicados" ? "active" : ""} onClick={() => updateForm({ mode: "radicados" })}>
|
||||
Por radicados
|
||||
</button>
|
||||
<button type="button" className={form.mode === "general" ? "active" : ""} onClick={() => updateForm({ mode: "general" })}>
|
||||
Carpeta general
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Opciones de procesamiento">
|
||||
<div className="switch-row">
|
||||
<label className="check"><input type="checkbox" checked={form.retryOnEmpty} onChange={(event) => updateForm({ retryOnEmpty: event.target.checked })} /> Reintentar filas vacias</label>
|
||||
<label className="check"><input type="checkbox" checked={form.tiffEnabled} onChange={(event) => updateForm({ tiffEnabled: event.target.checked })} /> Rango TIFF</label>
|
||||
</div>
|
||||
{form.tiffEnabled && (
|
||||
<div className="field-grid compact">
|
||||
<label>Inicio<input value={form.tiffStart} onChange={(event) => updateForm({ tiffStart: event.target.value })} /></label>
|
||||
<label>Fin<input value={form.tiffEnd} onChange={(event) => updateForm({ tiffEnd: event.target.value })} /></label>
|
||||
</div>
|
||||
)}
|
||||
<label className="check block"><input type="checkbox" checked={form.filterEnabled} onChange={(event) => updateForm({ filterEnabled: event.target.checked })} /> Procesar solo radicados seleccionados</label>
|
||||
{form.filterEnabled && <textarea rows={4} value={form.radicadoFilterText} onChange={(event) => updateForm({ radicadoFilterText: event.target.value })} placeholder="Uno por linea o separados por coma" />}
|
||||
</Panel>
|
||||
</section>
|
||||
|
||||
<section className="grid two">
|
||||
<Panel title={form.mode === "radicados" ? "Tipos de documento" : "Keywords"}>
|
||||
{form.mode === "radicados" ? (
|
||||
<>
|
||||
<div className="doc-list">
|
||||
{form.docTypes.map((item, index) => (
|
||||
<div className="doc-row" key={index}>
|
||||
<input aria-label={`Nombre tipo ${index + 1}`} value={item.name} onChange={(event) => setDocType(index, { name: event.target.value })} placeholder="Nombre logico" />
|
||||
<input aria-label={`Keyword tipo ${index + 1}`} value={item.keyword} onChange={(event) => setDocType(index, { keyword: event.target.value })} placeholder="Keyword / patron" />
|
||||
<button type="button" className="square" aria-label="Eliminar tipo" onClick={() => updateForm({ docTypes: form.docTypes.filter((_, idx) => idx !== index) })}>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="icon-button secondary" onClick={() => updateForm({ docTypes: [...form.docTypes, { name: "", keyword: "" }] })}>
|
||||
<Plus size={16} /> Añadir tipo
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<label>
|
||||
Keywords
|
||||
<input value={form.keywordsText} onChange={(event) => updateForm({ keywordsText: event.target.value })} placeholder="factura, cuenta" />
|
||||
</label>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Acciones">
|
||||
<div className="action-grid">
|
||||
<button type="button" className="icon-button primary" onClick={discoverSchema} disabled={busy || !form.selectedContainerPath}>
|
||||
{busy ? <Loader2 className="spin" size={16} /> : <Search size={16} />} Descubrir campos
|
||||
</button>
|
||||
<button type="button" className="icon-button" onClick={estimate} disabled={busy || selection.fields.length === 0}>
|
||||
<Database size={16} /> Estimar
|
||||
</button>
|
||||
<button type="button" className="icon-button primary" onClick={runExtraction} disabled={busy || selection.fields.length === 0}>
|
||||
<Play size={16} /> Ejecutar extraccion
|
||||
</button>
|
||||
<button type="button" className="icon-button" onClick={exportCsv} disabled={busy || !rows?.rows.length}>
|
||||
<Download size={16} /> Exportar CSV
|
||||
</button>
|
||||
</div>
|
||||
<p className="status-line">{statusText}</p>
|
||||
{error && <p className="error-line"><AlertCircle size={16} /> {error}</p>}
|
||||
</Panel>
|
||||
</section>
|
||||
|
||||
<section className="grid two wide-left">
|
||||
<Panel title={`Selector de campos (${selection.fields.length} seleccionados)`}>
|
||||
<div className="schema-tools">
|
||||
<input aria-label="Buscar campo" value={schemaSearch} onChange={(event) => setSchemaSearch(event.target.value)} placeholder="Buscar campo..." />
|
||||
<button type="button" className="icon-button" onClick={() => setSelection({ fields: Object.keys(flattened.schema), aliasMap: Object.fromEntries(Object.keys(flattened.schema).map((path) => [path, selection.aliasMap[path] || path])) })}>
|
||||
<Check size={16} /> Todos
|
||||
</button>
|
||||
<button type="button" className="icon-button" onClick={() => setSelection({ fields: [], aliasMap: selection.aliasMap })}>
|
||||
<RefreshCw size={16} /> Limpiar
|
||||
</button>
|
||||
</div>
|
||||
<div className="schema-list">
|
||||
{filteredSchemaEntries.length === 0 ? (
|
||||
<p className="muted">Descubre campos para ver el schema.</p>
|
||||
) : (
|
||||
filteredSchemaEntries.map(([path, kind]) => (
|
||||
<div className="schema-row" key={path}>
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={selection.fields.includes(path)} onChange={(event) => toggleField(path, event.target.checked)} />
|
||||
<span>{path}</span>
|
||||
<small>{kind}</small>
|
||||
</label>
|
||||
<input value={selection.aliasMap[path] || path} onChange={(event) => setSelection((current) => ({ ...current, aliasMap: { ...current.aliasMap, [path]: event.target.value } }))} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Muestra / Respuesta">
|
||||
<pre className="json-box">{JSON.stringify(flattened.sample || estimateJson || activeTask || configJson || modelsJson || {}, null, 2)}</pre>
|
||||
</Panel>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TasksView({
|
||||
tasks,
|
||||
activeTask,
|
||||
rows,
|
||||
busy,
|
||||
refreshTasks,
|
||||
selectTask,
|
||||
exportCsv,
|
||||
}: {
|
||||
tasks: Task[];
|
||||
activeTask: Task | null;
|
||||
rows: RowsResponse | null;
|
||||
busy: boolean;
|
||||
refreshTasks: (selectTaskId?: string) => Promise<void>;
|
||||
selectTask: (task: Task) => Promise<void>;
|
||||
exportCsv: () => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<section className="content-actions">
|
||||
<button type="button" className="icon-button" onClick={() => refreshTasks()} disabled={busy}>
|
||||
<RefreshCw size={16} /> Actualizar
|
||||
</button>
|
||||
<button type="button" className="icon-button" onClick={exportCsv} disabled={busy || !rows?.rows.length}>
|
||||
<Download size={16} /> Exportar CSV
|
||||
</button>
|
||||
</section>
|
||||
<section className="grid two">
|
||||
<Panel title="Historial de tareas">
|
||||
<div className="task-list large">
|
||||
{tasks.length === 0 ? <p className="muted">No hay tareas todavia.</p> : tasks.map((task) => (
|
||||
<button type="button" className={`task-row ${activeTask?.id === task.id ? "active" : ""}`} key={task.id} onClick={() => selectTask(task)}>
|
||||
<span>{task.title || task.id}</span>
|
||||
<strong className={`task-status ${task.status}`}>{task.status}</strong>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title={`Resultados${rows ? ` (${rows.rows.length})` : ""}`}>
|
||||
<ResultsTable rows={rows?.rows || []} />
|
||||
</Panel>
|
||||
</section>
|
||||
<section className="grid one">
|
||||
<Panel title="Detalle de tarea">
|
||||
<pre className="json-box compact">{JSON.stringify(activeTask || {}, null, 2)}</pre>
|
||||
</Panel>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsView({
|
||||
form,
|
||||
updateForm,
|
||||
connectionStatus,
|
||||
connectionMessage,
|
||||
verifyConnection,
|
||||
busy,
|
||||
configJson,
|
||||
modelsJson,
|
||||
}: {
|
||||
form: ExtractionFormState;
|
||||
updateForm: (patch: Partial<ExtractionFormState>) => void;
|
||||
connectionStatus: Status;
|
||||
connectionMessage: string;
|
||||
verifyConnection: () => Promise<void>;
|
||||
busy: boolean;
|
||||
configJson: unknown;
|
||||
modelsJson: unknown;
|
||||
}) {
|
||||
return (
|
||||
<section className="settings-layout">
|
||||
<Panel title="Conexion API">
|
||||
<div className="settings-stack">
|
||||
<label>
|
||||
URL del API Gateway
|
||||
<input value={form.apiBaseUrl} onChange={(event) => updateForm({ apiBaseUrl: event.target.value })} placeholder="http://localhost:8000" />
|
||||
</label>
|
||||
<div className="settings-actions">
|
||||
<button type="button" className="icon-button primary" onClick={verifyConnection} disabled={busy}>
|
||||
<Server size={16} /> Verificar conexion
|
||||
</button>
|
||||
<StatusPill status={connectionStatus} text={connectionMessage} />
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Configuracion del backend">
|
||||
<pre className="json-box compact">{JSON.stringify({ config: configJson, models: modelsJson }, null, 2)}</pre>
|
||||
</Panel>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ status, text }: { status: Status; text: string }) {
|
||||
return <div className={`status-pill ${status}`}>{status === "busy" && <Loader2 className="spin" size={14} />}{text}</div>;
|
||||
}
|
||||
|
||||
function ResultsTable({ rows }: { rows: Array<Record<string, unknown>> }) {
|
||||
const columns = useMemo(() => Array.from(new Set(rows.flatMap((row) => Object.keys(row)))).slice(0, 12), [rows]);
|
||||
if (rows.length === 0) return <p className="muted">Aun no hay filas extraidas.</p>;
|
||||
return (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>{columns.map((column) => <th key={column}>{column}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.slice(0, 100).map((row, index) => (
|
||||
<tr key={index}>{columns.map((column) => <td key={column}>{formatCell(row[column])}</td>)}</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ExtractionBatch, RowsResponse, SchemaResponse, Task } from "../../shared/types";
|
||||
|
||||
export class ApiClient {
|
||||
constructor(private readonly baseUrl: string) {}
|
||||
|
||||
private url(path: string): string {
|
||||
return `${this.baseUrl.replace(/\/+$/, "")}${path}`;
|
||||
}
|
||||
|
||||
async getJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(this.url(path));
|
||||
return this.handle<T>(response);
|
||||
}
|
||||
|
||||
async postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const response = await fetch(this.url(path), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return this.handle<T>(response);
|
||||
}
|
||||
|
||||
async health(): Promise<unknown> {
|
||||
return this.getJson("/health");
|
||||
}
|
||||
|
||||
async config(): Promise<unknown> {
|
||||
return this.getJson("/config");
|
||||
}
|
||||
|
||||
async models(): Promise<unknown> {
|
||||
return this.getJson("/models");
|
||||
}
|
||||
|
||||
async discoverSchema(payload: Record<string, unknown>): Promise<SchemaResponse> {
|
||||
return this.postJson<SchemaResponse>("/extraction/schemas", payload);
|
||||
}
|
||||
|
||||
async estimate(payload: Record<string, unknown>): Promise<unknown> {
|
||||
return this.postJson("/extraction/estimate", payload);
|
||||
}
|
||||
|
||||
async createBatch(payload: Record<string, unknown>): Promise<ExtractionBatch> {
|
||||
return this.postJson<ExtractionBatch>("/extraction/batches", payload);
|
||||
}
|
||||
|
||||
async createExtractionTask(payload: Record<string, unknown>): Promise<Task> {
|
||||
return this.postJson<Task>("/tasks/extraction", payload);
|
||||
}
|
||||
|
||||
async tasks(type = "extraction"): Promise<Task[]> {
|
||||
return this.getJson<Task[]>(`/tasks?type=${encodeURIComponent(type)}`);
|
||||
}
|
||||
|
||||
async task(taskId: string): Promise<Task> {
|
||||
return this.getJson<Task>(`/tasks/${encodeURIComponent(taskId)}`);
|
||||
}
|
||||
|
||||
async taskRows(taskId: string, limit = 500, offset = 0): Promise<RowsResponse> {
|
||||
return this.getJson<RowsResponse>(`/tasks/${encodeURIComponent(taskId)}/rows?limit=${limit}&offset=${offset}`);
|
||||
}
|
||||
|
||||
async rows(batchId: string, limit = 500, offset = 0): Promise<RowsResponse> {
|
||||
return this.getJson<RowsResponse>(`/extraction/batches/${encodeURIComponent(batchId)}/rows?limit=${limit}&offset=${offset}`);
|
||||
}
|
||||
|
||||
private async handle<T>(response: Response): Promise<T> {
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
if (!response.ok) {
|
||||
const detail = typeof data?.detail === "string" ? data.detail : response.statusText;
|
||||
throw new Error(detail || `HTTP ${response.status}`);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rowsToCsv } from "./csv";
|
||||
|
||||
describe("csv export", () => {
|
||||
it("escapes commas, quotes and newlines", () => {
|
||||
const csv = rowsToCsv(
|
||||
[
|
||||
{
|
||||
radicado: "R1",
|
||||
nombre: 'Ana, "IPS"',
|
||||
nota: "linea 1\nlinea 2",
|
||||
},
|
||||
],
|
||||
{ nombre_original: "nombre" },
|
||||
);
|
||||
expect(csv).toContain('R1,"Ana, ""IPS""","linea 1\nlinea 2"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
const NEEDS_QUOTES = /[",\r\n]/;
|
||||
|
||||
export function csvEscape(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
const text = typeof value === "object" ? JSON.stringify(value) : String(value);
|
||||
if (!NEEDS_QUOTES.test(text)) return text;
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
export function rowsToCsv(rows: Array<Record<string, unknown>>, aliasMap: Record<string, string> = {}): string {
|
||||
const header = inferHeader(rows, aliasMap);
|
||||
const lines = [header.map(csvEscape).join(",")];
|
||||
for (const row of rows) {
|
||||
lines.push(header.map((column) => csvEscape(row[column])).join(","));
|
||||
}
|
||||
return `${lines.join("\r\n")}\r\n`;
|
||||
}
|
||||
|
||||
export function inferHeader(rows: Array<Record<string, unknown>>, aliasMap: Record<string, string> = {}): string[] {
|
||||
const preferred = Array.from(new Set(["radicado", ...Object.values(aliasMap).filter(Boolean)]));
|
||||
const seen = new Set<string>();
|
||||
const header: string[] = [];
|
||||
|
||||
for (const column of preferred) {
|
||||
if (!seen.has(column) && rows.some((row) => Object.prototype.hasOwnProperty.call(row, column))) {
|
||||
seen.add(column);
|
||||
header.push(column);
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
for (const column of Object.keys(row)) {
|
||||
if (!seen.has(column)) {
|
||||
seen.add(column);
|
||||
header.push(column);
|
||||
}
|
||||
}
|
||||
}
|
||||
return header;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mapLocalPathToContainer, splitKeywords, splitRadicados } from "./pathMapping";
|
||||
|
||||
describe("path mapping", () => {
|
||||
it("maps a Windows path inside the document root to /data", () => {
|
||||
expect(
|
||||
mapLocalPathToContainer({
|
||||
localRoot: "C:\\docs",
|
||||
containerRoot: "/data",
|
||||
selectedPath: "C:\\docs\\lote 1",
|
||||
}),
|
||||
).toBe("/data/lote 1");
|
||||
});
|
||||
|
||||
it("maps a macOS path inside the document root to /data", () => {
|
||||
expect(
|
||||
mapLocalPathToContainer({
|
||||
localRoot: "/Users/me/docs",
|
||||
containerRoot: "/data",
|
||||
selectedPath: "/Users/me/docs/lote-2",
|
||||
}),
|
||||
).toBe("/data/lote-2");
|
||||
});
|
||||
|
||||
it("rejects paths outside the configured root", () => {
|
||||
expect(() =>
|
||||
mapLocalPathToContainer({
|
||||
localRoot: "C:\\docs",
|
||||
containerRoot: "/data",
|
||||
selectedPath: "D:\\other",
|
||||
}),
|
||||
).toThrow(/dentro/);
|
||||
});
|
||||
|
||||
it("rejects paths when no local mount root is configured", () => {
|
||||
expect(() =>
|
||||
mapLocalPathToContainer({
|
||||
localRoot: "",
|
||||
containerRoot: "/data",
|
||||
selectedPath: "C:\\any\\folder",
|
||||
}),
|
||||
).toThrow(/carpeta local montada/);
|
||||
});
|
||||
|
||||
it("splits keywords and removes duplicate radicados", () => {
|
||||
expect(splitKeywords("factura, cuenta\nsoporte")).toEqual(["factura", "cuenta", "soporte"]);
|
||||
expect(splitRadicados("A,A\nB")).toEqual(["A", "B"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
export interface PathMappingInput {
|
||||
localRoot: string;
|
||||
containerRoot: string;
|
||||
selectedPath: string;
|
||||
}
|
||||
|
||||
function stripTrailingSlash(value: string): string {
|
||||
return value.replace(/[\\/]+$/, "");
|
||||
}
|
||||
|
||||
function normalizeSeparators(value: string): string {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function normalizeForCompare(value: string): string {
|
||||
let normalized = normalizeSeparators(stripTrailingSlash(value.trim()));
|
||||
if (/^[a-zA-Z]:\//.test(normalized)) {
|
||||
normalized = normalized[0].toLowerCase() + normalized.slice(1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function splitKeywords(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[,\n]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function splitRadicados(raw: string): string[] {
|
||||
return Array.from(new Set(splitKeywords(raw)));
|
||||
}
|
||||
|
||||
export function mapLocalPathToContainer(input: PathMappingInput): string {
|
||||
const localRootCompare = normalizeForCompare(input.localRoot);
|
||||
const selectedCompare = normalizeForCompare(input.selectedPath);
|
||||
const containerRoot = stripTrailingSlash(normalizeSeparators(input.containerRoot.trim() || "/data"));
|
||||
|
||||
if (!localRootCompare) {
|
||||
throw new Error("Configura la carpeta local montada para Docker antes de procesar documentos.");
|
||||
}
|
||||
if (!selectedCompare) {
|
||||
throw new Error("Selecciona una carpeta o documento local.");
|
||||
}
|
||||
if (selectedCompare !== localRootCompare && !selectedCompare.startsWith(`${localRootCompare}/`)) {
|
||||
throw new Error("La carpeta seleccionada debe estar dentro de la carpeta local montada para el servicio.");
|
||||
}
|
||||
|
||||
const relative = selectedCompare.slice(localRootCompare.length).replace(/^\/+/, "");
|
||||
return relative ? `${containerRoot}/${relative}` : containerRoot;
|
||||
}
|
||||
|
||||
export function defaultContainerRoot(value?: string): string {
|
||||
return value?.trim() || "/data";
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ExtractionFormState } from "../../shared/types";
|
||||
import { buildBatchPayload, buildEstimatePayload, buildSchemaPayload, flattenSchema } from "./payloads";
|
||||
|
||||
const baseForm: ExtractionFormState = {
|
||||
apiBaseUrl: "http://localhost:8000",
|
||||
localRoot: "C:\\docs",
|
||||
containerRoot: "/data",
|
||||
selectedLocalPath: "C:\\docs\\lote",
|
||||
selectedContainerPath: "/data/lote",
|
||||
mode: "radicados",
|
||||
docTypes: [{ name: "Factura", keyword: "factura" }],
|
||||
keywordsText: "factura",
|
||||
retryOnEmpty: true,
|
||||
filterEnabled: true,
|
||||
radicadoFilterText: "R1,R2",
|
||||
tiffEnabled: true,
|
||||
tiffStart: "1",
|
||||
tiffEnd: "3",
|
||||
};
|
||||
|
||||
describe("payload builders", () => {
|
||||
it("builds schema payload for radicados", () => {
|
||||
expect(buildSchemaPayload(baseForm)).toEqual({
|
||||
root_path: "/data/lote",
|
||||
doc_types: [{ name: "Factura", keyword: "factura" }],
|
||||
tiff_page_range: [1, 3],
|
||||
structure_mode: "radicados",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds schema payload for general mode with a document path", () => {
|
||||
expect(buildSchemaPayload({ ...baseForm, mode: "general" }, "/data/lote/factura.pdf")).toEqual({
|
||||
document_path: "/data/lote/factura.pdf",
|
||||
tiff_page_range: [1, 3],
|
||||
structure_mode: "general",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds batch payload with fields, aliases and filters", () => {
|
||||
expect(
|
||||
buildBatchPayload(baseForm, {
|
||||
fields: ["Factura/numero"],
|
||||
aliasMap: { "Factura/numero": "numero" },
|
||||
}),
|
||||
).toMatchObject({
|
||||
root_path: "/data/lote",
|
||||
fields: ["Factura/numero"],
|
||||
alias_map: { "Factura/numero": "numero" },
|
||||
retry_on_empty: true,
|
||||
radicado_filter: ["R1", "R2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("builds estimate payload for selected paths", () => {
|
||||
expect(
|
||||
buildEstimatePayload(
|
||||
baseForm,
|
||||
{ schema_by_type: { Factura: { numero: "str" } } },
|
||||
{ fields: ["Factura/numero"], aliasMap: {} },
|
||||
),
|
||||
).toMatchObject({
|
||||
root_path: "/data/lote",
|
||||
doc_types: [{ name: "Factura", keyword: "factura" }],
|
||||
selected_paths: ["Factura/numero"],
|
||||
});
|
||||
});
|
||||
|
||||
it("flattens multi-document schema", () => {
|
||||
expect(
|
||||
flattenSchema({
|
||||
schema_by_type: { Factura: { numero: "str" }, Soporte: { fecha: "date" } },
|
||||
sample_by_type: { Factura: { numero: "A" } },
|
||||
first_radicado: "R1",
|
||||
}).schema,
|
||||
).toEqual({ "Factura/numero": "str", "Soporte/fecha": "date" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { DocumentType, ExtractionFormState, FieldSelection, SchemaResponse } from "../../shared/types";
|
||||
import { splitKeywords, splitRadicados } from "./pathMapping";
|
||||
|
||||
export function parseTiffRange(form: Pick<ExtractionFormState, "tiffEnabled" | "tiffStart" | "tiffEnd">): [number, number] | undefined {
|
||||
if (!form.tiffEnabled) return undefined;
|
||||
const start = Number.parseInt(form.tiffStart || "0", 10);
|
||||
const end = Number.parseInt(form.tiffEnd || "0", 10);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < 0) {
|
||||
throw new Error("El rango TIFF debe usar numeros enteros mayores o iguales a 0.");
|
||||
}
|
||||
if (start === 0 && end === 0) return undefined;
|
||||
if (end < start) {
|
||||
throw new Error("La pagina final TIFF no puede ser menor que la inicial.");
|
||||
}
|
||||
return [start, end];
|
||||
}
|
||||
|
||||
export function validDocTypes(docTypes: DocumentType[]): DocumentType[] {
|
||||
return docTypes
|
||||
.map((item) => ({ name: item.name.trim(), keyword: item.keyword.trim() }))
|
||||
.filter((item) => item.name && item.keyword);
|
||||
}
|
||||
|
||||
export function buildSchemaPayload(form: ExtractionFormState, documentPath?: string): Record<string, unknown> {
|
||||
const tiffRange = parseTiffRange(form);
|
||||
if (form.mode === "general") {
|
||||
if (!documentPath) throw new Error("No se encontro un documento de muestra en la carpeta general.");
|
||||
return {
|
||||
document_path: documentPath,
|
||||
tiff_page_range: tiffRange,
|
||||
structure_mode: form.mode,
|
||||
};
|
||||
}
|
||||
|
||||
const docTypes = validDocTypes(form.docTypes);
|
||||
if (docTypes.length === 0) throw new Error("Agrega al menos un tipo de documento.");
|
||||
return {
|
||||
root_path: form.selectedContainerPath,
|
||||
doc_types: docTypes,
|
||||
tiff_page_range: tiffRange,
|
||||
structure_mode: form.mode,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildEstimatePayload(form: ExtractionFormState, schema: SchemaResponse | null, selection: FieldSelection): Record<string, unknown> {
|
||||
const base = {
|
||||
root_path: form.selectedContainerPath,
|
||||
structure_mode: form.mode,
|
||||
};
|
||||
if (form.mode === "radicados") {
|
||||
return {
|
||||
...base,
|
||||
doc_types: validDocTypes(form.docTypes),
|
||||
schema_by_type: schema?.schema_by_type,
|
||||
selected_paths: selection.fields,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
keywords: splitKeywords(form.keywordsText),
|
||||
schema: schema?.schema,
|
||||
selected_fields: selection.fields,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBatchPayload(form: ExtractionFormState, selection: FieldSelection): Record<string, unknown> {
|
||||
const tiffRange = parseTiffRange(form);
|
||||
const payload: Record<string, unknown> = {
|
||||
root_path: form.selectedContainerPath,
|
||||
fields: selection.fields,
|
||||
alias_map: selection.aliasMap,
|
||||
tiff_page_range: tiffRange,
|
||||
structure_mode: form.mode,
|
||||
retry_on_empty: form.retryOnEmpty,
|
||||
radicado_filter: form.filterEnabled ? splitRadicados(form.radicadoFilterText) : undefined,
|
||||
};
|
||||
|
||||
if (form.mode === "radicados") {
|
||||
payload.doc_types = validDocTypes(form.docTypes);
|
||||
} else {
|
||||
payload.keywords = splitKeywords(form.keywordsText);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function flattenSchema(response: SchemaResponse): { schema: Record<string, string>; sample: unknown } {
|
||||
if (response.schema) return { schema: response.schema, sample: response.sample };
|
||||
const schema: Record<string, string> = {};
|
||||
for (const [typeName, fields] of Object.entries(response.schema_by_type || {})) {
|
||||
for (const [path, kind] of Object.entries(fields || {})) {
|
||||
schema[`${typeName}/${path}`] = kind;
|
||||
}
|
||||
}
|
||||
const sample = {
|
||||
radicado: response.first_radicado,
|
||||
...(response.sample_by_type || {}),
|
||||
};
|
||||
return { schema, sample };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,567 @@
|
||||
:root {
|
||||
color: #18212f;
|
||||
background: #f4f6f8;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 1080px;
|
||||
min-height: 720px;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.app-frame {
|
||||
background: #f4f6f8;
|
||||
display: grid;
|
||||
grid-template-columns: 248px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: #111827;
|
||||
border-right: 1px solid #243145;
|
||||
color: #e5edf7;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
min-height: 100vh;
|
||||
padding: 18px 14px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 2px 4px 12px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
align-items: center;
|
||||
background: #2e7db2;
|
||||
border-radius: 8px;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
height: 36px;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.brand strong,
|
||||
.brand span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.brand span {
|
||||
color: #93a4b8;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.side-nav {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.side-nav button {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
color: #b8c4d4;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
min-height: 40px;
|
||||
padding: 0 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.side-nav button:hover,
|
||||
.side-nav button.active {
|
||||
background: #1d293a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.sidebar-status {
|
||||
border-top: 1px solid #263448;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding: 14px 4px 0;
|
||||
}
|
||||
|
||||
.sidebar-status > span {
|
||||
color: #93a4b8;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.panel {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8dee8;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
font-size: 22px;
|
||||
line-height: 1.1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.topbar p {
|
||||
color: #5d697b;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.grid.two {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.grid.one {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.grid.wide-left {
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr);
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
font-size: 15px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
label {
|
||||
color: #354154;
|
||||
display: grid;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
background: #fbfcfd;
|
||||
border: 1px solid #cfd7e4;
|
||||
border-radius: 6px;
|
||||
color: #1c2430;
|
||||
min-height: 34px;
|
||||
padding: 7px 9px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input[readonly] {
|
||||
background: #eef2f6;
|
||||
color: #566274;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.field-grid.compact {
|
||||
grid-template-columns: 96px 96px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.path-row {
|
||||
align-items: end;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.segmented {
|
||||
background: #eef2f7;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
margin-top: 12px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.segmented button {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: #4f5b6d;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.segmented button.active {
|
||||
background: #ffffff;
|
||||
color: #122033;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid #bfcbda;
|
||||
border-radius: 6px;
|
||||
color: #253348;
|
||||
display: inline-flex;
|
||||
gap: 7px;
|
||||
justify-content: center;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon-button.primary {
|
||||
background: #0f6da9;
|
||||
border-color: #0f6da9;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.icon-button.secondary {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.square {
|
||||
align-items: center;
|
||||
background: #fff3f2;
|
||||
border: 1px solid #e6b5b0;
|
||||
border-radius: 6px;
|
||||
color: #ad3429;
|
||||
display: inline-flex;
|
||||
height: 34px;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.switch-row,
|
||||
.schema-tools,
|
||||
.action-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.check {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.check input {
|
||||
min-height: auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.check.block {
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
|
||||
.doc-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.doc-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.schema-tools {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.schema-list {
|
||||
border: 1px solid #e2e7ef;
|
||||
border-radius: 8px;
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.schema-row {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e8edf4;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(180px, 0.6fr);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.schema-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.schema-row span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.schema-row small {
|
||||
color: #6b7788;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.json-box {
|
||||
background: #111827;
|
||||
border-radius: 8px;
|
||||
color: #e7edf7;
|
||||
font-family: "Cascadia Mono", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
height: 480px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.json-box.compact {
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
.content-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(360px, 0.8fr) minmax(0, 1.2fr);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.settings-stack {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
border: 1px solid #e2e7ef;
|
||||
border-radius: 8px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
border: 1px solid #e2e7ef;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.task-list.large {
|
||||
max-height: 520px;
|
||||
}
|
||||
|
||||
.task-row {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #e8edf4;
|
||||
color: #253348;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
min-height: 42px;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.task-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.task-row.active {
|
||||
background: #eef7ff;
|
||||
}
|
||||
|
||||
.task-row span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-status {
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.task-status.pending,
|
||||
.task-status.running {
|
||||
background: #eef2f7;
|
||||
color: #445063;
|
||||
}
|
||||
|
||||
.task-status.completed {
|
||||
background: #e7f7ef;
|
||||
color: #17613a;
|
||||
}
|
||||
|
||||
.task-status.failed {
|
||||
background: #fff0ed;
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid #e8edf4;
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f3f6fa;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.status-line,
|
||||
.error-line,
|
||||
.muted {
|
||||
color: #667286;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.error-line {
|
||||
align-items: center;
|
||||
color: #b42318;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
font-size: 12px;
|
||||
gap: 6px;
|
||||
max-width: 560px;
|
||||
overflow: hidden;
|
||||
padding: 6px 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar .status-pill {
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.status-pill.idle,
|
||||
.status-pill.busy {
|
||||
background: #eef2f7;
|
||||
color: #445063;
|
||||
}
|
||||
|
||||
.status-pill.ok {
|
||||
background: #e7f7ef;
|
||||
color: #17613a;
|
||||
}
|
||||
|
||||
.status-pill.error {
|
||||
background: #fff0ed;
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
window.desktopApi = {
|
||||
getRuntimeConfig: vi.fn().mockResolvedValue({ documentRootLocal: "C:\\docs", documentRootContainer: "/data" }),
|
||||
selectDirectory: vi.fn(),
|
||||
saveCsvDialog: vi.fn(),
|
||||
writeTextFile: vi.fn(),
|
||||
findFirstDocument: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
import type { DesktopBridge } from "../shared/types";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
desktopApi?: DesktopBridge;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
export type StructureMode = "radicados" | "general";
|
||||
|
||||
export interface DocumentType {
|
||||
name: string;
|
||||
keyword: string;
|
||||
}
|
||||
|
||||
export interface ExtractionFormState {
|
||||
apiBaseUrl: string;
|
||||
localRoot: string;
|
||||
containerRoot: string;
|
||||
selectedLocalPath: string;
|
||||
selectedContainerPath: string;
|
||||
mode: StructureMode;
|
||||
docTypes: DocumentType[];
|
||||
keywordsText: string;
|
||||
retryOnEmpty: boolean;
|
||||
filterEnabled: boolean;
|
||||
radicadoFilterText: string;
|
||||
tiffEnabled: boolean;
|
||||
tiffStart: string;
|
||||
tiffEnd: string;
|
||||
}
|
||||
|
||||
export interface FieldSelection {
|
||||
fields: string[];
|
||||
aliasMap: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SchemaResponse {
|
||||
schema?: Record<string, string>;
|
||||
sample?: unknown;
|
||||
schema_by_type?: Record<string, Record<string, string>>;
|
||||
sample_by_type?: Record<string, unknown>;
|
||||
first_radicado?: string;
|
||||
}
|
||||
|
||||
export interface ExtractionBatch {
|
||||
id: string;
|
||||
status: string;
|
||||
root_path?: string | null;
|
||||
request?: Record<string, unknown>;
|
||||
error?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
type: string;
|
||||
status: "pending" | "running" | "completed" | "failed" | string;
|
||||
title: string | null;
|
||||
request: Record<string, unknown>;
|
||||
result: Record<string, unknown>;
|
||||
error: string | null;
|
||||
owner_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface RowsResponse {
|
||||
task_id?: string;
|
||||
batch_id: string;
|
||||
rows: Array<Record<string, unknown>>;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface RuntimeConfig {
|
||||
documentRootLocal: string;
|
||||
documentRootContainer: string;
|
||||
}
|
||||
|
||||
export interface DesktopBridge {
|
||||
getRuntimeConfig(): Promise<RuntimeConfig>;
|
||||
selectDirectory(): Promise<string | null>;
|
||||
saveCsvDialog(defaultPath?: string): Promise<string | null>;
|
||||
writeTextFile(filePath: string, content: string): Promise<{ ok: true; filePath: string }>;
|
||||
findFirstDocument(rootPath: string, keywords: string[]): Promise<string | null>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["src/renderer/test/setup.ts"],
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
@@ -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