Landing AI integrado
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -45,6 +45,13 @@ class Settings(BaseSettings):
|
|||||||
GOOGLE_CLOUD_LOCATION: str = "us-central1"
|
GOOGLE_CLOUD_LOCATION: str = "us-central1"
|
||||||
GEMINI_MODEL: str = "gemini-2.0-flash"
|
GEMINI_MODEL: str = "gemini-2.0-flash"
|
||||||
|
|
||||||
|
# LandingAI configuración
|
||||||
|
LANDINGAI_API_KEY: str
|
||||||
|
LANDINGAI_ENVIRONMENT: str = "production" # "production" o "eu"
|
||||||
|
|
||||||
|
# Schemas storage
|
||||||
|
SCHEMAS_DIR: str = "./data/schemas"
|
||||||
|
|
||||||
@validator("AZURE_STORAGE_CONNECTION_STRING")
|
@validator("AZURE_STORAGE_CONNECTION_STRING")
|
||||||
def validate_azure_connection_string(cls, v):
|
def validate_azure_connection_string(cls, v):
|
||||||
"""Validar que el connection string de Azure esté presente"""
|
"""Validar que el connection string de Azure esté presente"""
|
||||||
@@ -94,6 +101,13 @@ class Settings(BaseSettings):
|
|||||||
raise ValueError("GOOGLE_CLOUD_PROJECT es requerido")
|
raise ValueError("GOOGLE_CLOUD_PROJECT es requerido")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
@validator("LANDINGAI_API_KEY")
|
||||||
|
def validate_landingai_api_key(cls, v):
|
||||||
|
"""Validar que la API key de LandingAI esté presente"""
|
||||||
|
if not v:
|
||||||
|
raise ValueError("LANDINGAI_API_KEY es requerido")
|
||||||
|
return v
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
case_sensitive = True
|
case_sensitive = True
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import logging
|
|||||||
from .routers.files import router as files_router
|
from .routers.files import router as files_router
|
||||||
from .routers.vectors import router as vectors_router
|
from .routers.vectors import router as vectors_router
|
||||||
from .routers.chunking import router as chunking_router
|
from .routers.chunking import router as chunking_router
|
||||||
|
from .routers.schemas import router as schemas_router
|
||||||
|
from .routers.chunking_landingai import router as chunking_landingai_router
|
||||||
from .core.config import settings
|
from .core.config import settings
|
||||||
# from routers.ai import router as ai_router # futuro con Azure OpenAI
|
# from routers.ai import router as ai_router # futuro con Azure OpenAI
|
||||||
|
|
||||||
@@ -112,6 +114,12 @@ app.include_router(
|
|||||||
tags=["chunking"]
|
tags=["chunking"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Schemas router (nuevo)
|
||||||
|
app.include_router(schemas_router)
|
||||||
|
|
||||||
|
# Chunking LandingAI router (nuevo)
|
||||||
|
app.include_router(chunking_landingai_router)
|
||||||
|
|
||||||
# Router para IA
|
# Router para IA
|
||||||
# app.include_router(
|
# app.include_router(
|
||||||
# ai_router,
|
# ai_router,
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
96
backend/app/models/schema_models.py
Normal file
96
backend/app/models/schema_models.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""
|
||||||
|
Modelos Pydantic para schemas personalizables.
|
||||||
|
Permite definir schemas dinámicos desde el frontend para extracción de datos.
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from typing import List, Optional
|
||||||
|
from enum import Enum
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class FieldType(str, Enum):
|
||||||
|
"""Tipos de campos soportados para extracción"""
|
||||||
|
STRING = "string"
|
||||||
|
INTEGER = "integer"
|
||||||
|
FLOAT = "float"
|
||||||
|
BOOLEAN = "boolean"
|
||||||
|
ARRAY_STRING = "array_string"
|
||||||
|
ARRAY_INTEGER = "array_integer"
|
||||||
|
ARRAY_FLOAT = "array_float"
|
||||||
|
DATE = "date"
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaField(BaseModel):
|
||||||
|
"""Definición de un campo del schema"""
|
||||||
|
name: str = Field(..., description="Nombre del campo (snake_case)", min_length=1)
|
||||||
|
type: FieldType = Field(..., description="Tipo de dato del campo")
|
||||||
|
description: str = Field(..., description="Descripción clara para el LLM sobre qué extraer", min_length=1)
|
||||||
|
required: bool = Field(default=False, description="¿Es obligatorio extraer este campo?")
|
||||||
|
|
||||||
|
# Validaciones opcionales
|
||||||
|
min_value: Optional[float] = Field(None, description="Valor mínimo (para integer/float)")
|
||||||
|
max_value: Optional[float] = Field(None, description="Valor máximo (para integer/float)")
|
||||||
|
pattern: Optional[str] = Field(None, description="Patrón regex para validar strings")
|
||||||
|
|
||||||
|
@field_validator('name')
|
||||||
|
@classmethod
|
||||||
|
def validate_field_name(cls, v: str) -> str:
|
||||||
|
"""Valida que el nombre del campo sea snake_case válido"""
|
||||||
|
if not v.replace('_', '').isalnum():
|
||||||
|
raise ValueError("El nombre del campo debe ser snake_case alfanumérico")
|
||||||
|
if v[0].isdigit():
|
||||||
|
raise ValueError("El nombre del campo no puede empezar con número")
|
||||||
|
return v.lower()
|
||||||
|
|
||||||
|
@field_validator('min_value', 'max_value')
|
||||||
|
@classmethod
|
||||||
|
def validate_numeric_constraints(cls, v: Optional[float], info) -> Optional[float]:
|
||||||
|
"""Valida que min/max solo se usen con tipos numéricos"""
|
||||||
|
if v is not None:
|
||||||
|
field_type = info.data.get('type')
|
||||||
|
if field_type not in [FieldType.INTEGER, FieldType.FLOAT, FieldType.ARRAY_INTEGER, FieldType.ARRAY_FLOAT]:
|
||||||
|
raise ValueError(f"min_value/max_value solo aplican a campos numéricos, no a {field_type}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class CustomSchema(BaseModel):
|
||||||
|
"""Schema personalizable por el usuario para extracción de datos"""
|
||||||
|
schema_id: Optional[str] = Field(None, description="ID único del schema (generado automáticamente si no se provee)")
|
||||||
|
schema_name: str = Field(..., description="Nombre descriptivo del schema", min_length=1, max_length=100)
|
||||||
|
description: str = Field(..., description="Descripción de qué extrae este schema", min_length=1, max_length=500)
|
||||||
|
fields: List[SchemaField] = Field(..., description="Lista de campos a extraer", min_items=1, max_items=50)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at: Optional[str] = Field(None, description="Timestamp de creación ISO")
|
||||||
|
updated_at: Optional[str] = Field(None, description="Timestamp de última actualización ISO")
|
||||||
|
tema: Optional[str] = Field(None, description="Tema asociado (si es específico de un tema)")
|
||||||
|
is_global: bool = Field(default=False, description="¿Disponible para todos los temas?")
|
||||||
|
|
||||||
|
@field_validator('fields')
|
||||||
|
@classmethod
|
||||||
|
def validate_unique_field_names(cls, v: List[SchemaField]) -> List[SchemaField]:
|
||||||
|
"""Valida que no haya nombres de campos duplicados"""
|
||||||
|
field_names = [field.name for field in v]
|
||||||
|
if len(field_names) != len(set(field_names)):
|
||||||
|
raise ValueError("Los nombres de campos deben ser únicos en el schema")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator('schema_name')
|
||||||
|
@classmethod
|
||||||
|
def validate_schema_name(cls, v: str) -> str:
|
||||||
|
"""Limpia y valida el nombre del schema"""
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaListResponse(BaseModel):
|
||||||
|
"""Response para listar schemas"""
|
||||||
|
schemas: List[CustomSchema]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaValidationResponse(BaseModel):
|
||||||
|
"""Response para validación de schema"""
|
||||||
|
valid: bool
|
||||||
|
message: str
|
||||||
|
json_schema: Optional[dict] = None
|
||||||
|
errors: Optional[List[str]] = None
|
||||||
4
backend/app/repositories/__init__.py
Normal file
4
backend/app/repositories/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
"""
|
||||||
|
Repositories for data persistence.
|
||||||
|
Implementa patrón Repository para abstraer la capa de datos.
|
||||||
|
"""
|
||||||
243
backend/app/repositories/schema_repository.py
Normal file
243
backend/app/repositories/schema_repository.py
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
"""
|
||||||
|
Schema Repository - Patrón Repository
|
||||||
|
Abstrae la persistencia de schemas, actualmente usando archivos JSON.
|
||||||
|
Fácil migrar a base de datos después.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from ..models.schema_models import CustomSchema
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaRepository:
|
||||||
|
"""
|
||||||
|
Repository para gestión de schemas.
|
||||||
|
Implementa patrón Repository para abstraer almacenamiento.
|
||||||
|
|
||||||
|
Actualmente usa archivos JSON en disco.
|
||||||
|
Para migrar a DB: solo cambiar esta clase, resto del código no cambia.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, schemas_dir: Path):
|
||||||
|
"""
|
||||||
|
Inicializa el repositorio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schemas_dir: Directorio donde se guardan los schemas
|
||||||
|
"""
|
||||||
|
self.schemas_dir = Path(schemas_dir)
|
||||||
|
self.schemas_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
logger.info(f"SchemaRepository inicializado en: {self.schemas_dir}")
|
||||||
|
|
||||||
|
def save(self, schema: CustomSchema) -> CustomSchema:
|
||||||
|
"""
|
||||||
|
Guarda o actualiza un schema.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema: Schema a guardar
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Schema guardado con timestamps actualizados
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
IOError: Si hay error escribiendo el archivo
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Actualizar timestamps
|
||||||
|
now = datetime.utcnow().isoformat()
|
||||||
|
if not schema.created_at:
|
||||||
|
schema.created_at = now
|
||||||
|
schema.updated_at = now
|
||||||
|
|
||||||
|
# Guardar archivo
|
||||||
|
file_path = self._get_file_path(schema.schema_id)
|
||||||
|
with open(file_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(schema.model_dump(), f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
logger.info(f"Schema guardado: {schema.schema_id} - {schema.schema_name}")
|
||||||
|
return schema
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error guardando schema {schema.schema_id}: {e}")
|
||||||
|
raise IOError(f"No se pudo guardar el schema: {str(e)}")
|
||||||
|
|
||||||
|
def get_by_id(self, schema_id: str) -> Optional[CustomSchema]:
|
||||||
|
"""
|
||||||
|
Obtiene un schema por su ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema_id: ID del schema
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Schema si existe, None si no
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Si el archivo está corrupto
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
file_path = self._get_file_path(schema_id)
|
||||||
|
if not file_path.exists():
|
||||||
|
logger.debug(f"Schema no encontrado: {schema_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
schema = CustomSchema(**data)
|
||||||
|
logger.debug(f"Schema cargado: {schema_id}")
|
||||||
|
return schema
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Archivo JSON corrupto para schema {schema_id}: {e}")
|
||||||
|
raise ValueError(f"Schema corrupto: {schema_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error cargando schema {schema_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def list_all(self) -> List[CustomSchema]:
|
||||||
|
"""
|
||||||
|
Lista todos los schemas disponibles.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de schemas ordenados por fecha de creación (más reciente primero)
|
||||||
|
"""
|
||||||
|
schemas = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for file_path in self.schemas_dir.glob("*.json"):
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
schema = CustomSchema(**data)
|
||||||
|
schemas.append(schema)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error cargando schema desde {file_path}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Ordenar por fecha de creación (más reciente primero)
|
||||||
|
schemas.sort(key=lambda s: s.created_at or "", reverse=True)
|
||||||
|
|
||||||
|
logger.info(f"Listados {len(schemas)} schemas")
|
||||||
|
return schemas
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listando schemas: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def list_by_tema(self, tema: str) -> List[CustomSchema]:
|
||||||
|
"""
|
||||||
|
Lista schemas disponibles para un tema específico.
|
||||||
|
Incluye schemas del tema + schemas globales.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tema: Nombre del tema
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de schemas aplicables al tema
|
||||||
|
"""
|
||||||
|
all_schemas = self.list_all()
|
||||||
|
|
||||||
|
filtered = [
|
||||||
|
schema for schema in all_schemas
|
||||||
|
if schema.tema == tema or schema.is_global
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(f"Encontrados {len(filtered)} schemas para tema '{tema}'")
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
def delete(self, schema_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Elimina un schema.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema_id: ID del schema a eliminar
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si se eliminó, False si no existía
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
file_path = self._get_file_path(schema_id)
|
||||||
|
if not file_path.exists():
|
||||||
|
logger.warning(f"Intento de eliminar schema inexistente: {schema_id}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
file_path.unlink()
|
||||||
|
logger.info(f"Schema eliminado: {schema_id}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error eliminando schema {schema_id}: {e}")
|
||||||
|
raise IOError(f"No se pudo eliminar el schema: {str(e)}")
|
||||||
|
|
||||||
|
def exists(self, schema_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Verifica si un schema existe.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema_id: ID del schema
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si existe, False si no
|
||||||
|
"""
|
||||||
|
file_path = self._get_file_path(schema_id)
|
||||||
|
return file_path.exists()
|
||||||
|
|
||||||
|
def count(self) -> int:
|
||||||
|
"""
|
||||||
|
Cuenta el número total de schemas.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Número de schemas
|
||||||
|
"""
|
||||||
|
return len(list(self.schemas_dir.glob("*.json")))
|
||||||
|
|
||||||
|
def _get_file_path(self, schema_id: str) -> Path:
|
||||||
|
"""
|
||||||
|
Obtiene la ruta del archivo para un schema.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema_id: ID del schema
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path del archivo
|
||||||
|
"""
|
||||||
|
# Sanitizar schema_id para evitar path traversal
|
||||||
|
safe_id = schema_id.replace("/", "_").replace("\\", "_")
|
||||||
|
return self.schemas_dir / f"{safe_id}.json"
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton factory pattern
|
||||||
|
_schema_repository: Optional[SchemaRepository] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_schema_repository() -> SchemaRepository:
|
||||||
|
"""
|
||||||
|
Factory para obtener instancia singleton del repositorio.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Instancia única de SchemaRepository
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: Si la configuración no está disponible
|
||||||
|
"""
|
||||||
|
global _schema_repository
|
||||||
|
|
||||||
|
if _schema_repository is None:
|
||||||
|
try:
|
||||||
|
from ..core.config import settings
|
||||||
|
|
||||||
|
schemas_dir = getattr(settings, 'SCHEMAS_DIR', None) or "./data/schemas"
|
||||||
|
_schema_repository = SchemaRepository(Path(schemas_dir))
|
||||||
|
|
||||||
|
logger.info("SchemaRepository singleton inicializado")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error inicializando SchemaRepository: {e}")
|
||||||
|
raise RuntimeError(f"No se pudo inicializar SchemaRepository: {str(e)}")
|
||||||
|
|
||||||
|
return _schema_repository
|
||||||
Binary file not shown.
Binary file not shown.
396
backend/app/routers/chunking_landingai.py
Normal file
396
backend/app/routers/chunking_landingai.py
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
"""
|
||||||
|
Router para procesamiento de PDFs con LandingAI.
|
||||||
|
Soporta dos modos: rápido (solo parse) y extracción (parse + extract con schema).
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List, Literal
|
||||||
|
|
||||||
|
from langchain_core.documents import Document
|
||||||
|
|
||||||
|
from ..services.landingai_service import get_landingai_service
|
||||||
|
from ..services.chunking_service import get_chunking_service
|
||||||
|
from ..repositories.schema_repository import get_schema_repository
|
||||||
|
from ..utils.chunking.token_manager import TokenManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/chunking-landingai", tags=["chunking-landingai"])
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessLandingAIRequest(BaseModel):
|
||||||
|
"""Request para procesar PDF con LandingAI"""
|
||||||
|
file_name: str = Field(..., description="Nombre del archivo PDF")
|
||||||
|
tema: str = Field(..., description="Tema/carpeta del archivo")
|
||||||
|
collection_name: str = Field(..., description="Colección de Qdrant")
|
||||||
|
|
||||||
|
# Modo de procesamiento
|
||||||
|
mode: Literal["quick", "extract"] = Field(
|
||||||
|
default="quick",
|
||||||
|
description="Modo: 'quick' (solo parse) o 'extract' (parse + datos estructurados)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Schema (obligatorio si mode='extract')
|
||||||
|
schema_id: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="ID del schema a usar (requerido si mode='extract')"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configuración de chunks
|
||||||
|
include_chunk_types: List[str] = Field(
|
||||||
|
default=["text", "table"],
|
||||||
|
description="Tipos de chunks a incluir: text, table, figure, etc."
|
||||||
|
)
|
||||||
|
max_tokens_per_chunk: int = Field(
|
||||||
|
default=1500,
|
||||||
|
ge=500,
|
||||||
|
le=3000,
|
||||||
|
description="Tokens máximos por chunk (flexible para tablas/figuras)"
|
||||||
|
)
|
||||||
|
merge_small_chunks: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Unir chunks pequeños de la misma página y tipo"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessLandingAIResponse(BaseModel):
|
||||||
|
"""Response del procesamiento con LandingAI"""
|
||||||
|
success: bool
|
||||||
|
mode: str
|
||||||
|
processing_time_seconds: float
|
||||||
|
collection_name: str
|
||||||
|
file_name: str
|
||||||
|
total_chunks: int
|
||||||
|
chunks_added: int
|
||||||
|
schema_used: Optional[str] = None
|
||||||
|
extracted_data: Optional[dict] = None
|
||||||
|
parse_metadata: dict
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/process", response_model=ProcessLandingAIResponse)
|
||||||
|
async def process_with_landingai(request: ProcessLandingAIRequest):
|
||||||
|
"""
|
||||||
|
Procesa un PDF con LandingAI y sube a Qdrant.
|
||||||
|
|
||||||
|
Flujo:
|
||||||
|
1. Descarga PDF de Azure Blob
|
||||||
|
2. Parse con LandingAI (siempre)
|
||||||
|
3. Extract con schema (solo si mode='extract')
|
||||||
|
4. Procesa chunks (filtrado, merge, control de tokens)
|
||||||
|
5. Genera embeddings (Azure OpenAI)
|
||||||
|
6. Sube a Qdrant con metadata rica
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Configuración del procesamiento
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Resultado del procesamiento con estadísticas
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 400: Si mode='extract' y no se provee schema_id
|
||||||
|
HTTPException 404: Si el PDF o schema no existen
|
||||||
|
HTTPException 500: Si hay error en el procesamiento
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"\n{'='*60}")
|
||||||
|
logger.info(f"INICIANDO PROCESAMIENTO CON LANDINGAI")
|
||||||
|
logger.info(f"{'='*60}")
|
||||||
|
logger.info(f"Archivo: {request.file_name}")
|
||||||
|
logger.info(f"Tema: {request.tema}")
|
||||||
|
logger.info(f"Modo: {request.mode}")
|
||||||
|
logger.info(f"Colección: {request.collection_name}")
|
||||||
|
|
||||||
|
# 1. Validar schema si es modo extract
|
||||||
|
custom_schema = None
|
||||||
|
if request.mode == "extract":
|
||||||
|
if not request.schema_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="schema_id es requerido cuando mode='extract'"
|
||||||
|
)
|
||||||
|
|
||||||
|
schema_repo = get_schema_repository()
|
||||||
|
custom_schema = schema_repo.get_by_id(request.schema_id)
|
||||||
|
|
||||||
|
if not custom_schema:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Schema no encontrado: {request.schema_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Schema seleccionado: {custom_schema.schema_name}")
|
||||||
|
|
||||||
|
# 2. Descargar PDF desde Azure Blob
|
||||||
|
logger.info("\n[1/5] Descargando PDF desde Azure Blob...")
|
||||||
|
chunking_service = get_chunking_service()
|
||||||
|
|
||||||
|
try:
|
||||||
|
pdf_bytes = await chunking_service.download_pdf_from_blob(
|
||||||
|
request.file_name,
|
||||||
|
request.tema
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error descargando PDF: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"No se pudo descargar el PDF: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Procesar con LandingAI
|
||||||
|
logger.info("\n[2/5] Procesando con LandingAI...")
|
||||||
|
landingai_service = get_landingai_service()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = landingai_service.process_pdf(
|
||||||
|
pdf_bytes=pdf_bytes,
|
||||||
|
file_name=request.file_name,
|
||||||
|
custom_schema=custom_schema,
|
||||||
|
include_chunk_types=request.include_chunk_types
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error en LandingAI: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error procesando con LandingAI: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
documents = result["chunks"]
|
||||||
|
|
||||||
|
if not documents:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="No se generaron chunks después del procesamiento"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Aplicar control flexible de tokens
|
||||||
|
logger.info("\n[3/5] Aplicando control de tokens...")
|
||||||
|
documents = _apply_flexible_token_control(
|
||||||
|
documents,
|
||||||
|
max_tokens=request.max_tokens_per_chunk,
|
||||||
|
merge_small=request.merge_small_chunks
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Generar embeddings
|
||||||
|
logger.info(f"\n[4/5] Generando embeddings para {len(documents)} chunks...")
|
||||||
|
texts = [doc.page_content for doc in documents]
|
||||||
|
|
||||||
|
try:
|
||||||
|
embeddings = await chunking_service.embedding_service.generate_embeddings_batch(texts)
|
||||||
|
logger.info(f"Embeddings generados: {len(embeddings)} vectores")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generando embeddings: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error generando embeddings: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6. Preparar chunks para Qdrant con IDs determinísticos
|
||||||
|
logger.info("\n[5/5] Subiendo a Qdrant...")
|
||||||
|
qdrant_chunks = []
|
||||||
|
|
||||||
|
for idx, (doc, embedding) in enumerate(zip(documents, embeddings)):
|
||||||
|
# ID determinístico
|
||||||
|
chunk_id = chunking_service._generate_deterministic_id(
|
||||||
|
file_name=request.file_name,
|
||||||
|
page=doc.metadata.get("page", 1),
|
||||||
|
chunk_index=doc.metadata.get("chunk_id", str(idx))
|
||||||
|
)
|
||||||
|
|
||||||
|
qdrant_chunks.append({
|
||||||
|
"id": chunk_id,
|
||||||
|
"vector": embedding,
|
||||||
|
"payload": {
|
||||||
|
"page_content": doc.page_content,
|
||||||
|
"metadata": doc.metadata # Metadata rica de LandingAI
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# 7. Subir a Qdrant
|
||||||
|
try:
|
||||||
|
upload_result = await chunking_service.vector_db.add_chunks(
|
||||||
|
request.collection_name,
|
||||||
|
qdrant_chunks
|
||||||
|
)
|
||||||
|
logger.info(f"Subida completada: {upload_result['chunks_added']} chunks")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error subiendo a Qdrant: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error subiendo a Qdrant: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tiempo total
|
||||||
|
processing_time = time.time() - start_time
|
||||||
|
|
||||||
|
logger.info(f"\n{'='*60}")
|
||||||
|
logger.info(f"PROCESAMIENTO COMPLETADO")
|
||||||
|
logger.info(f"{'='*60}")
|
||||||
|
logger.info(f"Tiempo: {processing_time:.2f}s")
|
||||||
|
logger.info(f"Chunks procesados: {len(documents)}")
|
||||||
|
logger.info(f"Chunks subidos: {upload_result['chunks_added']}")
|
||||||
|
|
||||||
|
return ProcessLandingAIResponse(
|
||||||
|
success=True,
|
||||||
|
mode=request.mode,
|
||||||
|
processing_time_seconds=round(processing_time, 2),
|
||||||
|
collection_name=request.collection_name,
|
||||||
|
file_name=request.file_name,
|
||||||
|
total_chunks=len(documents),
|
||||||
|
chunks_added=upload_result["chunks_added"],
|
||||||
|
schema_used=custom_schema.schema_id if custom_schema else None,
|
||||||
|
extracted_data=result.get("extracted_data"),
|
||||||
|
parse_metadata=result["parse_metadata"],
|
||||||
|
message=f"PDF procesado exitosamente en modo {request.mode}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error inesperado en procesamiento: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error inesperado: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_flexible_token_control(
|
||||||
|
documents: List[Document],
|
||||||
|
max_tokens: int,
|
||||||
|
merge_small: bool
|
||||||
|
) -> List[Document]:
|
||||||
|
"""
|
||||||
|
Aplica control flexible de tokens (Opción C del diseño).
|
||||||
|
|
||||||
|
- Permite chunks más grandes para tablas/figuras (50% extra)
|
||||||
|
- Mergea chunks pequeños de misma página y tipo
|
||||||
|
- Divide chunks muy grandes en sub-chunks
|
||||||
|
|
||||||
|
Args:
|
||||||
|
documents: Lista de Documents
|
||||||
|
max_tokens: Límite sugerido de tokens
|
||||||
|
merge_small: Si True, une chunks pequeños
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de Documents procesados
|
||||||
|
"""
|
||||||
|
token_manager = TokenManager()
|
||||||
|
processed = []
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
logger.info(f"Control de tokens: max={max_tokens}, merge={merge_small}")
|
||||||
|
|
||||||
|
while i < len(documents):
|
||||||
|
doc = documents[i]
|
||||||
|
tokens = token_manager.count_tokens(doc.page_content)
|
||||||
|
chunk_type = doc.metadata.get("chunk_type", "text")
|
||||||
|
|
||||||
|
# Límite flexible según tipo
|
||||||
|
if chunk_type in ["table", "figure"]:
|
||||||
|
max_allowed = int(max_tokens * 1.5) # 50% más para contenido estructurado
|
||||||
|
else:
|
||||||
|
max_allowed = max_tokens
|
||||||
|
|
||||||
|
# Si excede mucho el límite, dividir
|
||||||
|
if tokens > max_allowed * 1.2: # 20% de tolerancia
|
||||||
|
logger.warning(
|
||||||
|
f"Chunk muy grande ({tokens} tokens), dividiendo... "
|
||||||
|
f"(tipo: {chunk_type})"
|
||||||
|
)
|
||||||
|
sub_chunks = _split_large_chunk(doc, max_tokens, token_manager)
|
||||||
|
processed.extend(sub_chunks)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Intentar merge si es pequeño
|
||||||
|
if (
|
||||||
|
merge_small and
|
||||||
|
tokens < max_tokens * 0.5 and
|
||||||
|
i < len(documents) - 1
|
||||||
|
):
|
||||||
|
next_doc = documents[i + 1]
|
||||||
|
if _can_merge(doc, next_doc, max_tokens, token_manager):
|
||||||
|
logger.debug(f"Merging chunks {i} y {i+1}")
|
||||||
|
doc = _merge_documents(doc, next_doc)
|
||||||
|
i += 1 # Skip next
|
||||||
|
|
||||||
|
processed.append(doc)
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
logger.info(f"Tokens aplicados: {len(documents)} → {len(processed)} chunks")
|
||||||
|
return processed
|
||||||
|
|
||||||
|
|
||||||
|
def _split_large_chunk(
|
||||||
|
doc: Document,
|
||||||
|
max_tokens: int,
|
||||||
|
token_manager: TokenManager
|
||||||
|
) -> List[Document]:
|
||||||
|
"""Divide un chunk grande en sub-chunks"""
|
||||||
|
content = doc.page_content
|
||||||
|
words = content.split()
|
||||||
|
sub_chunks = []
|
||||||
|
current_chunk = []
|
||||||
|
current_tokens = 0
|
||||||
|
|
||||||
|
for word in words:
|
||||||
|
word_tokens = token_manager.count_tokens(word)
|
||||||
|
if current_tokens + word_tokens > max_tokens and current_chunk:
|
||||||
|
# Guardar chunk actual
|
||||||
|
sub_content = " ".join(current_chunk)
|
||||||
|
sub_doc = Document(
|
||||||
|
page_content=sub_content,
|
||||||
|
metadata={**doc.metadata, "is_split": True}
|
||||||
|
)
|
||||||
|
sub_chunks.append(sub_doc)
|
||||||
|
current_chunk = [word]
|
||||||
|
current_tokens = word_tokens
|
||||||
|
else:
|
||||||
|
current_chunk.append(word)
|
||||||
|
current_tokens += word_tokens
|
||||||
|
|
||||||
|
# Último chunk
|
||||||
|
if current_chunk:
|
||||||
|
sub_content = " ".join(current_chunk)
|
||||||
|
sub_doc = Document(
|
||||||
|
page_content=sub_content,
|
||||||
|
metadata={**doc.metadata, "is_split": True}
|
||||||
|
)
|
||||||
|
sub_chunks.append(sub_doc)
|
||||||
|
|
||||||
|
return sub_chunks
|
||||||
|
|
||||||
|
|
||||||
|
def _can_merge(
|
||||||
|
doc1: Document,
|
||||||
|
doc2: Document,
|
||||||
|
max_tokens: int,
|
||||||
|
token_manager: TokenManager
|
||||||
|
) -> bool:
|
||||||
|
"""Verifica si dos docs se pueden mergear"""
|
||||||
|
# Misma página
|
||||||
|
if doc1.metadata.get("page") != doc2.metadata.get("page"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Mismo tipo
|
||||||
|
if doc1.metadata.get("chunk_type") != doc2.metadata.get("chunk_type"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# No exceder límite
|
||||||
|
combined_text = f"{doc1.page_content}\n\n{doc2.page_content}"
|
||||||
|
combined_tokens = token_manager.count_tokens(combined_text)
|
||||||
|
|
||||||
|
return combined_tokens <= max_tokens
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_documents(doc1: Document, doc2: Document) -> Document:
|
||||||
|
"""Mergea dos documentos"""
|
||||||
|
merged_content = f"{doc1.page_content}\n\n{doc2.page_content}"
|
||||||
|
return Document(
|
||||||
|
page_content=merged_content,
|
||||||
|
metadata={**doc1.metadata, "is_merged": True}
|
||||||
|
)
|
||||||
288
backend/app/routers/schemas.py
Normal file
288
backend/app/routers/schemas.py
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
"""
|
||||||
|
Router para gestión de schemas personalizables.
|
||||||
|
Endpoints CRUD para crear, leer, actualizar y eliminar schemas.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from ..models.schema_models import (
|
||||||
|
CustomSchema,
|
||||||
|
SchemaListResponse,
|
||||||
|
SchemaValidationResponse
|
||||||
|
)
|
||||||
|
from ..repositories.schema_repository import get_schema_repository
|
||||||
|
from ..services.schema_builder_service import SchemaBuilderService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/schemas", tags=["schemas"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=CustomSchema, status_code=201)
|
||||||
|
async def create_schema(schema: CustomSchema):
|
||||||
|
"""
|
||||||
|
Crea un nuevo schema personalizado.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema: Definición del schema
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Schema creado con ID y timestamps
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 400: Si el schema es inválido
|
||||||
|
HTTPException 409: Si ya existe un schema con ese ID
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Generar ID si no viene
|
||||||
|
if not schema.schema_id:
|
||||||
|
schema.schema_id = f"schema_{uuid.uuid4().hex[:12]}"
|
||||||
|
|
||||||
|
# Verificar que no exista
|
||||||
|
repo = get_schema_repository()
|
||||||
|
if repo.exists(schema.schema_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Ya existe un schema con ID: {schema.schema_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validar que se puede construir el schema
|
||||||
|
builder = SchemaBuilderService()
|
||||||
|
validation = builder.validate_schema(schema)
|
||||||
|
|
||||||
|
if not validation["valid"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={
|
||||||
|
"message": "Schema inválido",
|
||||||
|
"errors": validation["errors"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Guardar
|
||||||
|
saved_schema = repo.save(schema)
|
||||||
|
|
||||||
|
logger.info(f"Schema creado: {saved_schema.schema_id} - {saved_schema.schema_name}")
|
||||||
|
return saved_schema
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creando schema: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error interno: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[CustomSchema])
|
||||||
|
async def list_schemas(
|
||||||
|
tema: Optional[str] = Query(None, description="Filtrar por tema (incluye globales)")
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Lista todos los schemas o filtrados por tema.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tema: Nombre del tema para filtrar (opcional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de schemas
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
repo = get_schema_repository()
|
||||||
|
|
||||||
|
if tema:
|
||||||
|
schemas = repo.list_by_tema(tema)
|
||||||
|
else:
|
||||||
|
schemas = repo.list_all()
|
||||||
|
|
||||||
|
return schemas
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listando schemas: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error interno: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{schema_id}", response_model=CustomSchema)
|
||||||
|
async def get_schema(schema_id: str):
|
||||||
|
"""
|
||||||
|
Obtiene un schema por su ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema_id: ID del schema
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Schema solicitado
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: Si el schema no existe
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
repo = get_schema_repository()
|
||||||
|
schema = repo.get_by_id(schema_id)
|
||||||
|
|
||||||
|
if not schema:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Schema no encontrado: {schema_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return schema
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error obteniendo schema {schema_id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error interno: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{schema_id}", response_model=CustomSchema)
|
||||||
|
async def update_schema(schema_id: str, schema: CustomSchema):
|
||||||
|
"""
|
||||||
|
Actualiza un schema existente.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema_id: ID del schema a actualizar
|
||||||
|
schema: Nueva definición del schema
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Schema actualizado
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: Si el schema no existe
|
||||||
|
HTTPException 400: Si el nuevo schema es inválido
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
repo = get_schema_repository()
|
||||||
|
|
||||||
|
# Verificar que existe
|
||||||
|
existing = repo.get_by_id(schema_id)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Schema no encontrado: {schema_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mantener el ID original
|
||||||
|
schema.schema_id = schema_id
|
||||||
|
schema.created_at = existing.created_at # Mantener fecha de creación
|
||||||
|
|
||||||
|
# Validar nuevo schema
|
||||||
|
builder = SchemaBuilderService()
|
||||||
|
validation = builder.validate_schema(schema)
|
||||||
|
|
||||||
|
if not validation["valid"]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail={
|
||||||
|
"message": "Schema inválido",
|
||||||
|
"errors": validation["errors"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Guardar
|
||||||
|
updated_schema = repo.save(schema)
|
||||||
|
|
||||||
|
logger.info(f"Schema actualizado: {schema_id}")
|
||||||
|
return updated_schema
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error actualizando schema {schema_id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error interno: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{schema_id}")
|
||||||
|
async def delete_schema(schema_id: str):
|
||||||
|
"""
|
||||||
|
Elimina un schema.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema_id: ID del schema a eliminar
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Mensaje de confirmación
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 404: Si el schema no existe
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
repo = get_schema_repository()
|
||||||
|
|
||||||
|
if not repo.delete(schema_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Schema no encontrado: {schema_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Schema eliminado: {schema_id}")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Schema {schema_id} eliminado exitosamente"
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error eliminando schema {schema_id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error interno: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/validate", response_model=SchemaValidationResponse)
|
||||||
|
async def validate_schema(schema: CustomSchema):
|
||||||
|
"""
|
||||||
|
Valida un schema sin guardarlo.
|
||||||
|
Útil para preview en el frontend antes de guardar.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schema: Schema a validar
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Resultado de validación con detalles
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
builder = SchemaBuilderService()
|
||||||
|
validation = builder.validate_schema(schema)
|
||||||
|
|
||||||
|
return SchemaValidationResponse(**validation)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error validando schema: {e}")
|
||||||
|
return SchemaValidationResponse(
|
||||||
|
valid=False,
|
||||||
|
message="Error en validación",
|
||||||
|
errors=[str(e)]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats/count")
|
||||||
|
async def get_schemas_count():
|
||||||
|
"""
|
||||||
|
Obtiene estadísticas de schemas.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Conteo de schemas total y por tema
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
repo = get_schema_repository()
|
||||||
|
all_schemas = repo.list_all()
|
||||||
|
|
||||||
|
# Contar por tema
|
||||||
|
tema_counts = {}
|
||||||
|
global_count = 0
|
||||||
|
|
||||||
|
for schema in all_schemas:
|
||||||
|
if schema.is_global:
|
||||||
|
global_count += 1
|
||||||
|
elif schema.tema:
|
||||||
|
tema_counts[schema.tema] = tema_counts.get(schema.tema, 0) + 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": len(all_schemas),
|
||||||
|
"global": global_count,
|
||||||
|
"by_tema": tema_counts
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error obteniendo estadísticas: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error interno: {str(e)}")
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
353
backend/app/services/landingai_service.py
Normal file
353
backend/app/services/landingai_service.py
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
"""
|
||||||
|
LandingAI Service - Servicio independiente
|
||||||
|
Maneja toda la interacción con LandingAI ADE API.
|
||||||
|
Usa parse() para extracción de chunks y extract() para datos estructurados.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
|
from langchain_core.documents import Document
|
||||||
|
|
||||||
|
from ..models.schema_models import CustomSchema
|
||||||
|
from ..services.schema_builder_service import SchemaBuilderService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LandingAIService:
|
||||||
|
"""
|
||||||
|
Servicio para procesamiento de PDFs con LandingAI.
|
||||||
|
|
||||||
|
Flujo:
|
||||||
|
1. Parse PDF → obtener chunks estructurados + markdown
|
||||||
|
2. Extract (opcional) → extraer datos según schema personalizado
|
||||||
|
3. Process chunks → filtrar, enriquecer, controlar tokens
|
||||||
|
4. Return Documents → listos para embeddings y Qdrant
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, environment: str = "production"):
|
||||||
|
"""
|
||||||
|
Inicializa el servicio LandingAI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: API key de LandingAI
|
||||||
|
environment: "production" o "eu"
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ImportError: Si landingai-ade no está instalado
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from landingai_ade import LandingAIADE
|
||||||
|
|
||||||
|
self.client = LandingAIADE(
|
||||||
|
apikey=api_key,
|
||||||
|
environment=environment,
|
||||||
|
timeout=480.0, # 8 minutos para PDFs grandes
|
||||||
|
max_retries=2
|
||||||
|
)
|
||||||
|
|
||||||
|
self.schema_builder = SchemaBuilderService()
|
||||||
|
|
||||||
|
logger.info(f"LandingAIService inicializado (environment: {environment})")
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
logger.error("landingai-ade no está instalado")
|
||||||
|
raise ImportError(
|
||||||
|
"Se requiere landingai-ade. Instalar con: pip install landingai-ade"
|
||||||
|
)
|
||||||
|
|
||||||
|
def process_pdf(
|
||||||
|
self,
|
||||||
|
pdf_bytes: bytes,
|
||||||
|
file_name: str,
|
||||||
|
custom_schema: Optional[CustomSchema] = None,
|
||||||
|
include_chunk_types: Optional[List[str]] = None,
|
||||||
|
model: str = "dpt-2-latest"
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Procesa un PDF con LandingAI (modo rápido o con extracción).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pdf_bytes: Contenido del PDF en bytes
|
||||||
|
file_name: Nombre del archivo
|
||||||
|
custom_schema: Schema personalizado para extract (None = modo rápido)
|
||||||
|
include_chunk_types: Tipos de chunks a incluir ["text", "table", "figure"]
|
||||||
|
model: Modelo de LandingAI a usar
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict con:
|
||||||
|
- chunks: List[Document] listos para embeddings
|
||||||
|
- parse_metadata: Metadata del parse (páginas, duración, etc.)
|
||||||
|
- extracted_data: Datos extraídos (si usó schema)
|
||||||
|
- file_name: Nombre del archivo
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: Si hay error en parse o extract
|
||||||
|
"""
|
||||||
|
logger.info(f"=== Procesando PDF con LandingAI: {file_name} ===")
|
||||||
|
logger.info(f" Modo: {'Extracción' if custom_schema else 'Rápido'}")
|
||||||
|
logger.info(f" Tipos incluidos: {include_chunk_types or 'todos'}")
|
||||||
|
|
||||||
|
# 1. Parse PDF
|
||||||
|
parse_result = self._parse_pdf(pdf_bytes, file_name, model)
|
||||||
|
|
||||||
|
# 2. Extract (si hay schema)
|
||||||
|
extracted_data = None
|
||||||
|
if custom_schema:
|
||||||
|
logger.info(f" Extrayendo datos con schema: {custom_schema.schema_name}")
|
||||||
|
extracted_data = self._extract_data(
|
||||||
|
parse_result["markdown"],
|
||||||
|
custom_schema
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Procesar chunks
|
||||||
|
documents = self._process_chunks(
|
||||||
|
parse_result,
|
||||||
|
extracted_data,
|
||||||
|
file_name,
|
||||||
|
include_chunk_types
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"=== Procesamiento completado: {len(documents)} chunks ===")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"chunks": documents,
|
||||||
|
"parse_metadata": parse_result["metadata"],
|
||||||
|
"extracted_data": extracted_data,
|
||||||
|
"file_name": file_name
|
||||||
|
}
|
||||||
|
|
||||||
|
def _parse_pdf(
|
||||||
|
self,
|
||||||
|
pdf_bytes: bytes,
|
||||||
|
file_name: str,
|
||||||
|
model: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Parse PDF con LandingAI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pdf_bytes: Contenido del PDF
|
||||||
|
file_name: Nombre del archivo
|
||||||
|
model: Modelo de LandingAI
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict con chunks, markdown, grounding y metadata
|
||||||
|
"""
|
||||||
|
logger.info(f" Parseando PDF con modelo {model}...")
|
||||||
|
|
||||||
|
# LandingAI requiere Path, crear archivo temporal
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
|
||||||
|
tmp.write(pdf_bytes)
|
||||||
|
tmp_path = Path(tmp.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Parse con LandingAI
|
||||||
|
response = self.client.parse(document=tmp_path, model=model)
|
||||||
|
|
||||||
|
# Procesar respuesta
|
||||||
|
chunks_data = []
|
||||||
|
for chunk in response.chunks:
|
||||||
|
# Obtener grounding info del chunk
|
||||||
|
grounding_info = {}
|
||||||
|
if hasattr(response, 'grounding') and hasattr(response.grounding, chunk.id):
|
||||||
|
ground = getattr(response.grounding, chunk.id)
|
||||||
|
grounding_info = {
|
||||||
|
"bbox": ground.bbox if hasattr(ground, 'bbox') else None,
|
||||||
|
"page": ground.page if hasattr(ground, 'page') else 1
|
||||||
|
}
|
||||||
|
|
||||||
|
page_num = grounding_info.get("page", 1) if grounding_info else 1
|
||||||
|
|
||||||
|
chunks_data.append({
|
||||||
|
"id": chunk.id,
|
||||||
|
"content": chunk.markdown,
|
||||||
|
"type": chunk.type,
|
||||||
|
"grounding": grounding_info,
|
||||||
|
"page": page_num
|
||||||
|
})
|
||||||
|
|
||||||
|
# Obtener metadata
|
||||||
|
metadata_dict = {}
|
||||||
|
if hasattr(response, 'metadata'):
|
||||||
|
metadata_dict = {
|
||||||
|
"page_count": getattr(response.metadata, 'page_count', None),
|
||||||
|
"duration_ms": getattr(response.metadata, 'duration_ms', None),
|
||||||
|
"version": getattr(response.metadata, 'version', None)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f" Parse completado: {len(chunks_data)} chunks, "
|
||||||
|
f"{metadata_dict.get('page_count', 'N/A')} páginas"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"chunks": chunks_data,
|
||||||
|
"markdown": response.markdown,
|
||||||
|
"grounding": response.grounding,
|
||||||
|
"metadata": metadata_dict
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Limpiar archivo temporal
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _extract_data(
|
||||||
|
self,
|
||||||
|
markdown: str,
|
||||||
|
custom_schema: CustomSchema
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Extrae datos estructurados del markdown usando schema personalizado.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
markdown: Markdown completo del documento
|
||||||
|
custom_schema: Schema personalizado
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict con extraction, extraction_metadata y schema_used
|
||||||
|
None si hay error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. Construir Pydantic schema
|
||||||
|
pydantic_schema = self.schema_builder.build_pydantic_schema(custom_schema)
|
||||||
|
|
||||||
|
# 2. Convertir a JSON schema
|
||||||
|
json_schema = self.schema_builder.to_json_schema(pydantic_schema)
|
||||||
|
|
||||||
|
# 3. Crear archivo temporal con markdown
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode='w',
|
||||||
|
suffix=".md",
|
||||||
|
delete=False,
|
||||||
|
encoding='utf-8'
|
||||||
|
) as tmp:
|
||||||
|
tmp.write(markdown)
|
||||||
|
tmp_path = Path(tmp.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 4. Extract con LandingAI
|
||||||
|
response = self.client.extract(
|
||||||
|
schema=json_schema,
|
||||||
|
markdown=tmp_path
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f" Extracción completada: {len(response.extraction)} campos")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"extraction": response.extraction,
|
||||||
|
"extraction_metadata": response.extraction_metadata,
|
||||||
|
"schema_used": custom_schema.schema_id
|
||||||
|
}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error en extract: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _process_chunks(
|
||||||
|
self,
|
||||||
|
parse_result: Dict[str, Any],
|
||||||
|
extracted_data: Optional[Dict[str, Any]],
|
||||||
|
file_name: str,
|
||||||
|
include_chunk_types: Optional[List[str]]
|
||||||
|
) -> List[Document]:
|
||||||
|
"""
|
||||||
|
Convierte chunks de LandingAI a Documents de LangChain con metadata rica.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parse_result: Resultado del parse
|
||||||
|
extracted_data: Datos extraídos (opcional)
|
||||||
|
file_name: Nombre del archivo
|
||||||
|
include_chunk_types: Tipos a incluir
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de Documents listos para embeddings
|
||||||
|
"""
|
||||||
|
documents = []
|
||||||
|
filtered_count = 0
|
||||||
|
|
||||||
|
for chunk in parse_result["chunks"]:
|
||||||
|
# Filtrar por tipo si se especificó
|
||||||
|
if include_chunk_types and chunk["type"] not in include_chunk_types:
|
||||||
|
filtered_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Construir metadata rica
|
||||||
|
metadata = {
|
||||||
|
"file_name": file_name,
|
||||||
|
"page": chunk["page"],
|
||||||
|
"chunk_id": chunk["id"],
|
||||||
|
"chunk_type": chunk["type"],
|
||||||
|
"bbox": chunk["grounding"].get("bbox"),
|
||||||
|
|
||||||
|
# Metadata del documento
|
||||||
|
"document_metadata": {
|
||||||
|
"page_count": parse_result["metadata"].get("page_count"),
|
||||||
|
"processing_duration_ms": parse_result["metadata"].get("duration_ms"),
|
||||||
|
"landingai_version": parse_result["metadata"].get("version"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Agregar datos extraídos si existen
|
||||||
|
if extracted_data:
|
||||||
|
metadata["extracted_data"] = extracted_data["extraction"]
|
||||||
|
metadata["extraction_metadata"] = extracted_data["extraction_metadata"]
|
||||||
|
metadata["schema_used"] = extracted_data["schema_used"]
|
||||||
|
|
||||||
|
# Crear Document
|
||||||
|
doc = Document(
|
||||||
|
page_content=chunk["content"],
|
||||||
|
metadata=metadata
|
||||||
|
)
|
||||||
|
documents.append(doc)
|
||||||
|
|
||||||
|
if filtered_count > 0:
|
||||||
|
logger.info(f" Filtrados {filtered_count} chunks por tipo")
|
||||||
|
|
||||||
|
logger.info(f" Generados {len(documents)} documents")
|
||||||
|
return documents
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton factory
|
||||||
|
_landingai_service: Optional[LandingAIService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_landingai_service() -> LandingAIService:
|
||||||
|
"""
|
||||||
|
Factory para obtener instancia singleton del servicio.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Instancia única de LandingAIService
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: Si la configuración no está disponible
|
||||||
|
"""
|
||||||
|
global _landingai_service
|
||||||
|
|
||||||
|
if _landingai_service is None:
|
||||||
|
try:
|
||||||
|
from ..core.config import settings
|
||||||
|
|
||||||
|
api_key = settings.LANDINGAI_API_KEY
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError("LANDINGAI_API_KEY no está configurada")
|
||||||
|
|
||||||
|
environment = getattr(settings, 'LANDINGAI_ENVIRONMENT', 'production')
|
||||||
|
|
||||||
|
_landingai_service = LandingAIService(
|
||||||
|
api_key=api_key,
|
||||||
|
environment=environment
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("LandingAIService singleton inicializado")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error inicializando LandingAIService: {e}")
|
||||||
|
raise RuntimeError(f"No se pudo inicializar LandingAIService: {str(e)}")
|
||||||
|
|
||||||
|
return _landingai_service
|
||||||
215
backend/app/services/schema_builder_service.py
Normal file
215
backend/app/services/schema_builder_service.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"""
|
||||||
|
Schema Builder Service - Patrón Builder
|
||||||
|
Construye schemas Pydantic dinámicamente desde definiciones JSON del frontend.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Type, get_origin, get_args
|
||||||
|
from pydantic import BaseModel, Field, create_model
|
||||||
|
from pydantic.fields import FieldInfo
|
||||||
|
|
||||||
|
from ..models.schema_models import CustomSchema, FieldType, SchemaField
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaBuilderService:
|
||||||
|
"""
|
||||||
|
Servicio para construir schemas Pydantic dinámicamente.
|
||||||
|
Implementa patrón Builder para construcción step-by-step.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_pydantic_schema(custom_schema: CustomSchema) -> Type[BaseModel]:
|
||||||
|
"""
|
||||||
|
Convierte un CustomSchema a una clase Pydantic dinámica.
|
||||||
|
|
||||||
|
Este método es el núcleo del patrón Builder, construyendo
|
||||||
|
una clase Pydantic válida que puede ser usada por LandingAI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
custom_schema: Schema personalizado del usuario
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Clase Pydantic generada dinámicamente
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: Si el schema es inválido
|
||||||
|
"""
|
||||||
|
logger.info(f"Construyendo Pydantic schema: {custom_schema.schema_name}")
|
||||||
|
|
||||||
|
field_definitions = {}
|
||||||
|
|
||||||
|
for field in custom_schema.fields:
|
||||||
|
try:
|
||||||
|
# 1. Mapear tipo Python
|
||||||
|
python_type = SchemaBuilderService._map_field_type(field.type)
|
||||||
|
|
||||||
|
# 2. Crear FieldInfo con validaciones
|
||||||
|
field_info = SchemaBuilderService._build_field_info(field)
|
||||||
|
|
||||||
|
# 3. Agregar al diccionario de definiciones
|
||||||
|
field_definitions[field.name] = (python_type, field_info)
|
||||||
|
|
||||||
|
logger.debug(f" Campo '{field.name}': {python_type} - {field.description[:50]}...")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error construyendo campo '{field.name}': {e}")
|
||||||
|
raise ValueError(f"Campo inválido '{field.name}': {str(e)}")
|
||||||
|
|
||||||
|
# 4. Crear clase dinámica
|
||||||
|
try:
|
||||||
|
# Nombre de clase válido (sin espacios ni caracteres especiales)
|
||||||
|
class_name = custom_schema.schema_name.replace(" ", "").replace("-", "")
|
||||||
|
if not class_name[0].isalpha():
|
||||||
|
class_name = "Schema" + class_name
|
||||||
|
|
||||||
|
DynamicSchema = create_model(
|
||||||
|
class_name,
|
||||||
|
**field_definitions
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Schema Pydantic creado exitosamente: {class_name} con {len(field_definitions)} campos")
|
||||||
|
return DynamicSchema
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creando modelo Pydantic: {e}")
|
||||||
|
raise ValueError(f"No se pudo crear el schema: {str(e)}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _map_field_type(field_type: FieldType) -> Type:
|
||||||
|
"""
|
||||||
|
Mapea FieldType a tipo Python nativo.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
field_type: Tipo de campo del schema
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tipo Python correspondiente
|
||||||
|
"""
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
type_mapping = {
|
||||||
|
FieldType.STRING: str,
|
||||||
|
FieldType.INTEGER: int,
|
||||||
|
FieldType.FLOAT: float,
|
||||||
|
FieldType.BOOLEAN: bool,
|
||||||
|
FieldType.ARRAY_STRING: List[str],
|
||||||
|
FieldType.ARRAY_INTEGER: List[int],
|
||||||
|
FieldType.ARRAY_FLOAT: List[float],
|
||||||
|
FieldType.DATE: str, # Dates como strings ISO 8601
|
||||||
|
}
|
||||||
|
|
||||||
|
if field_type not in type_mapping:
|
||||||
|
raise ValueError(f"Tipo de campo no soportado: {field_type}")
|
||||||
|
|
||||||
|
return type_mapping[field_type]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_field_info(field: SchemaField) -> FieldInfo:
|
||||||
|
"""
|
||||||
|
Construye FieldInfo con validaciones apropiadas.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
field: Definición del campo
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FieldInfo configurado
|
||||||
|
"""
|
||||||
|
# Configuración base
|
||||||
|
field_kwargs = {
|
||||||
|
"description": field.description,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default value según si es requerido
|
||||||
|
if field.required:
|
||||||
|
field_kwargs["default"] = ... # Ellipsis = required
|
||||||
|
else:
|
||||||
|
field_kwargs["default"] = None
|
||||||
|
|
||||||
|
# Validaciones numéricas
|
||||||
|
if field.min_value is not None:
|
||||||
|
field_kwargs["ge"] = field.min_value # greater or equal
|
||||||
|
|
||||||
|
if field.max_value is not None:
|
||||||
|
field_kwargs["le"] = field.max_value # less or equal
|
||||||
|
|
||||||
|
# Validaciones de string
|
||||||
|
if field.pattern:
|
||||||
|
field_kwargs["pattern"] = field.pattern
|
||||||
|
|
||||||
|
return Field(**field_kwargs)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def to_json_schema(pydantic_schema: Type[BaseModel]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Convierte un Pydantic schema a JSON Schema para LandingAI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pydantic_schema: Clase Pydantic
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON Schema dict compatible con LandingAI
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ImportError: Si landingai-ade no está instalado
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from landingai_ade.lib import pydantic_to_json_schema
|
||||||
|
|
||||||
|
json_schema = pydantic_to_json_schema(pydantic_schema)
|
||||||
|
logger.info("Schema convertido a JSON schema exitosamente")
|
||||||
|
return json_schema
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
logger.error("landingai-ade no está instalado")
|
||||||
|
raise ImportError(
|
||||||
|
"Se requiere landingai-ade para convertir a JSON schema. "
|
||||||
|
"Instalar con: pip install landingai-ade"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_schema(custom_schema: CustomSchema) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Valida que un schema se pueda construir correctamente.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
custom_schema: Schema a validar
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict con resultado de validación:
|
||||||
|
{
|
||||||
|
"valid": bool,
|
||||||
|
"message": str,
|
||||||
|
"json_schema": dict (si válido),
|
||||||
|
"errors": List[str] (si inválido)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Intentar construir el schema Pydantic
|
||||||
|
pydantic_schema = SchemaBuilderService.build_pydantic_schema(custom_schema)
|
||||||
|
|
||||||
|
# Intentar convertir a JSON schema
|
||||||
|
json_schema = SchemaBuilderService.to_json_schema(pydantic_schema)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": True,
|
||||||
|
"message": f"Schema '{custom_schema.schema_name}' es válido",
|
||||||
|
"json_schema": json_schema,
|
||||||
|
"errors": None
|
||||||
|
}
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
errors.append(f"Error de validación: {str(e)}")
|
||||||
|
except ImportError as e:
|
||||||
|
errors.append(f"Error de dependencias: {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Error inesperado: {str(e)}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"valid": False,
|
||||||
|
"message": f"Schema '{custom_schema.schema_name}' es inválido",
|
||||||
|
"json_schema": None,
|
||||||
|
"errors": errors
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ dependencies = [
|
|||||||
# WebSockets
|
# WebSockets
|
||||||
"websockets>=14.1",
|
"websockets>=14.1",
|
||||||
"langchain-text-splitters>=1.0.0",
|
"langchain-text-splitters>=1.0.0",
|
||||||
|
# LandingAI Document AI
|
||||||
|
"landingai-ade>=0.2.1",
|
||||||
]
|
]
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
dev = "uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
|
dev = "uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
|
||||||
|
|||||||
19
backend/uv.lock
generated
19
backend/uv.lock
generated
@@ -67,6 +67,7 @@ dependencies = [
|
|||||||
{ name = "azure-storage-blob" },
|
{ name = "azure-storage-blob" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "google-cloud-aiplatform" },
|
{ name = "google-cloud-aiplatform" },
|
||||||
|
{ name = "landingai-ade" },
|
||||||
{ name = "langchain" },
|
{ name = "langchain" },
|
||||||
{ name = "langchain-core" },
|
{ name = "langchain-core" },
|
||||||
{ name = "langchain-text-splitters" },
|
{ name = "langchain-text-splitters" },
|
||||||
@@ -88,6 +89,7 @@ requires-dist = [
|
|||||||
{ name = "azure-storage-blob", specifier = ">=12.26.0" },
|
{ name = "azure-storage-blob", specifier = ">=12.26.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.116.1" },
|
{ name = "fastapi", specifier = ">=0.116.1" },
|
||||||
{ name = "google-cloud-aiplatform", specifier = ">=1.77.0" },
|
{ name = "google-cloud-aiplatform", specifier = ">=1.77.0" },
|
||||||
|
{ name = "landingai-ade", specifier = ">=0.2.1" },
|
||||||
{ name = "langchain", specifier = ">=0.3.12" },
|
{ name = "langchain", specifier = ">=0.3.12" },
|
||||||
{ name = "langchain-core", specifier = ">=0.3.24" },
|
{ name = "langchain-core", specifier = ">=0.3.24" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=1.0.0" },
|
{ name = "langchain-text-splitters", specifier = ">=1.0.0" },
|
||||||
@@ -810,6 +812,23 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" },
|
{ url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "landingai-ade"
|
||||||
|
version = "0.20.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "distro" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "sniffio" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/46/05/8e94b262625545683db8b0206929691715a0905c8227b776866885be7fb6/landingai_ade-0.20.3.tar.gz", hash = "sha256:8fd95fabdf3f72b5a1c0d0b3b485631e41e1c25c6e5636de10fbec83078772d2", size = 107576, upload-time = "2025-11-05T02:03:46.808Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/d0/6daeb909475d2b49847ebb27d1093aa586154a1d910e9b14addb1b356007/landingai_ade-0.20.3-py3-none-any.whl", hash = "sha256:599a7ec1b31ca3923160c20037cef5f3afbf84485ce98b8ead028ac540d50bbd", size = 86222, upload-time = "2025-11-05T02:03:45.825Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langchain"
|
name = "langchain"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
|
|||||||
1615
frontend/package-lock.json
generated
1615
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
345
frontend/src/components/ChunkingConfigModalLandingAI.tsx
Normal file
345
frontend/src/components/ChunkingConfigModalLandingAI.tsx
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
/**
|
||||||
|
* Chunking Config Modal con LandingAI
|
||||||
|
* Reemplaza el modal anterior, ahora usa LandingAI con modos flexible y schemas
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { api } from '../services/api'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from './ui/dialog'
|
||||||
|
import { Button } from './ui/button'
|
||||||
|
import { Label } from './ui/label'
|
||||||
|
import { Input } from './ui/input'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'
|
||||||
|
import { AlertCircle, Loader2, Settings, Zap, Target, FileText, Table2, Image } from 'lucide-react'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'
|
||||||
|
import type { CustomSchema } from '@/types/schema'
|
||||||
|
|
||||||
|
interface ChunkingConfigModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
fileName: string
|
||||||
|
tema: string
|
||||||
|
collectionName: string
|
||||||
|
onProcess: (config: LandingAIConfig) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LandingAIConfig {
|
||||||
|
file_name: string
|
||||||
|
tema: string
|
||||||
|
collection_name: string
|
||||||
|
mode: 'quick' | 'extract'
|
||||||
|
schema_id?: string
|
||||||
|
include_chunk_types: string[]
|
||||||
|
max_tokens_per_chunk: number
|
||||||
|
merge_small_chunks: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChunkingConfigModalLandingAI({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
fileName,
|
||||||
|
tema,
|
||||||
|
collectionName,
|
||||||
|
onProcess,
|
||||||
|
}: ChunkingConfigModalProps) {
|
||||||
|
const [mode, setMode] = useState<'quick' | 'extract'>('quick')
|
||||||
|
const [schemas, setSchemas] = useState<CustomSchema[]>([])
|
||||||
|
const [selectedSchema, setSelectedSchema] = useState<string | undefined>()
|
||||||
|
const [chunkTypes, setChunkTypes] = useState<string[]>(['text', 'table'])
|
||||||
|
const [maxTokens, setMaxTokens] = useState(1500)
|
||||||
|
const [mergeSmall, setMergeSmall] = useState(true)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadSchemas()
|
||||||
|
}
|
||||||
|
}, [isOpen, tema])
|
||||||
|
|
||||||
|
const loadSchemas = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api.listSchemas(tema)
|
||||||
|
setSchemas(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading schemas:', err)
|
||||||
|
setError(err instanceof Error ? err.message : 'Error cargando schemas')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleChunkType = (type: string) => {
|
||||||
|
if (chunkTypes.includes(type)) {
|
||||||
|
setChunkTypes(chunkTypes.filter(t => t !== type))
|
||||||
|
} else {
|
||||||
|
setChunkTypes([...chunkTypes, type])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleProcess = () => {
|
||||||
|
if (mode === 'extract' && !selectedSchema) {
|
||||||
|
setError('Debes seleccionar un schema en modo extracción')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chunkTypes.length === 0) {
|
||||||
|
setError('Debes seleccionar al menos un tipo de contenido')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: LandingAIConfig = {
|
||||||
|
file_name: fileName,
|
||||||
|
tema: tema,
|
||||||
|
collection_name: collectionName,
|
||||||
|
mode: mode,
|
||||||
|
schema_id: selectedSchema,
|
||||||
|
include_chunk_types: chunkTypes,
|
||||||
|
max_tokens_per_chunk: maxTokens,
|
||||||
|
merge_small_chunks: mergeSmall,
|
||||||
|
}
|
||||||
|
|
||||||
|
onProcess(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setError(null)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedSchemaData = schemas.find(s => s.schema_id === selectedSchema)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Settings className="w-5 h-5" />
|
||||||
|
Configurar Procesamiento con LandingAI
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configura cómo se procesará <strong>{fileName}</strong> usando LandingAI Document AI
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-red-600 bg-red-50 p-4 rounded">
|
||||||
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-6 flex-1 overflow-y-auto">
|
||||||
|
{/* Modo de Procesamiento */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-base font-semibold">Modo de Procesamiento</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<ModeCard
|
||||||
|
icon={<Zap className="h-6 w-6" />}
|
||||||
|
title="Rápido"
|
||||||
|
description="Solo extracción de texto sin análisis estructurado"
|
||||||
|
time="~5-10 seg"
|
||||||
|
selected={mode === 'quick'}
|
||||||
|
onClick={() => setMode('quick')}
|
||||||
|
/>
|
||||||
|
<ModeCard
|
||||||
|
icon={<Target className="h-6 w-6" />}
|
||||||
|
title="Con Extracción"
|
||||||
|
description="Parse + extracción de datos estructurados con schema"
|
||||||
|
time="~15-30 seg"
|
||||||
|
selected={mode === 'extract'}
|
||||||
|
onClick={() => setMode('extract')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Schema Selector (solo en modo extract) */}
|
||||||
|
{mode === 'extract' && (
|
||||||
|
<div className="space-y-3 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||||
|
<Label className="text-base font-semibold">Schema a Usar</Label>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>Cargando schemas...</span>
|
||||||
|
</div>
|
||||||
|
) : schemas.length === 0 ? (
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
<p>No hay schemas disponibles para este tema.</p>
|
||||||
|
<p className="mt-1">Crea uno primero en la sección de Schemas.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Select value={selectedSchema} onValueChange={setSelectedSchema}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Selecciona un schema..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{schemas.map((schema) => (
|
||||||
|
<SelectItem key={schema.schema_id} value={schema.schema_id!}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{schema.schema_name}</span>
|
||||||
|
<span className="text-xs text-gray-500">{schema.description}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* Preview del schema seleccionado */}
|
||||||
|
{selectedSchemaData && (
|
||||||
|
<div className="mt-3 p-3 bg-white rounded border">
|
||||||
|
<p className="text-sm font-medium mb-2">Campos a extraer:</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{selectedSchemaData.fields.map((field, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded"
|
||||||
|
>
|
||||||
|
{field.name}
|
||||||
|
{field.required && <span className="ml-1 text-red-500">*</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tipos de Contenido */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-base font-semibold">Tipos de Contenido a Incluir</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<ChunkTypeOption
|
||||||
|
icon={<FileText className="h-5 w-5" />}
|
||||||
|
label="Texto"
|
||||||
|
selected={chunkTypes.includes('text')}
|
||||||
|
onClick={() => toggleChunkType('text')}
|
||||||
|
/>
|
||||||
|
<ChunkTypeOption
|
||||||
|
icon={<Table2 className="h-5 w-5" />}
|
||||||
|
label="Tablas"
|
||||||
|
selected={chunkTypes.includes('table')}
|
||||||
|
onClick={() => toggleChunkType('table')}
|
||||||
|
/>
|
||||||
|
<ChunkTypeOption
|
||||||
|
icon={<Image className="h-5 w-5" />}
|
||||||
|
label="Figuras"
|
||||||
|
selected={chunkTypes.includes('figure')}
|
||||||
|
onClick={() => toggleChunkType('figure')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Configuración Avanzada */}
|
||||||
|
<details className="border rounded-lg">
|
||||||
|
<summary className="px-4 py-3 cursor-pointer font-medium hover:bg-gray-50">
|
||||||
|
Configuración Avanzada
|
||||||
|
</summary>
|
||||||
|
<div className="p-4 space-y-4 border-t">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="maxTokens">
|
||||||
|
Tokens máximos por chunk: <strong>{maxTokens}</strong>
|
||||||
|
</Label>
|
||||||
|
<input
|
||||||
|
id="maxTokens"
|
||||||
|
type="range"
|
||||||
|
min="500"
|
||||||
|
max="3000"
|
||||||
|
step="100"
|
||||||
|
value={maxTokens}
|
||||||
|
onChange={(e) => setMaxTokens(parseInt(e.target.value))}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Tablas y figuras pueden exceder hasta 50% más
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="mergeSmall"
|
||||||
|
checked={mergeSmall}
|
||||||
|
onChange={(e) => setMergeSmall(e.target.checked)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="mergeSmall" className="cursor-pointer">
|
||||||
|
Unir chunks pequeños de la misma página
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex justify-between items-center pt-4 border-t">
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
Tiempo estimado: <strong>{mode === 'quick' ? '~5-10 seg' : '~15-30 seg'}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={handleClose}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleProcess} disabled={loading}>
|
||||||
|
Procesar con LandingAI
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModeCard({ icon, title, description, time, selected, onClick }: any) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={`
|
||||||
|
p-4 border-2 rounded-lg cursor-pointer transition-all
|
||||||
|
${selected
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className={selected ? 'text-blue-600' : 'text-gray-400'}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold">{title}</h4>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 mb-1">{description}</p>
|
||||||
|
<p className="text-xs text-gray-500">{time}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChunkTypeOption({ icon, label, selected, onClick }: any) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={`
|
||||||
|
p-3 border-2 rounded-lg cursor-pointer transition-all text-center
|
||||||
|
${selected
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-200 hover:border-gray-300'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className={`flex justify-center mb-2 ${selected ? 'text-blue-600' : 'text-gray-400'}`}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">{label}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,8 +17,7 @@ import { DeleteConfirmDialog } from './DeleteConfirmDialog'
|
|||||||
import { PDFPreviewModal } from './PDFPreviewModal'
|
import { PDFPreviewModal } from './PDFPreviewModal'
|
||||||
import { CollectionVerifier } from './CollectionVerifier'
|
import { CollectionVerifier } from './CollectionVerifier'
|
||||||
import { ChunkViewerModal } from './ChunkViewerModal'
|
import { ChunkViewerModal } from './ChunkViewerModal'
|
||||||
import { ChunkingConfigModal, type ChunkingConfig } from './ChunkingConfigModal'
|
import { ChunkingConfigModalLandingAI, type LandingAIConfig } from './ChunkingConfigModalLandingAI'
|
||||||
import { ChunkPreviewPanel } from './ChunkPreviewPanel'
|
|
||||||
import {
|
import {
|
||||||
Upload,
|
Upload,
|
||||||
Download,
|
Download,
|
||||||
@@ -30,7 +29,11 @@ import {
|
|||||||
Scissors
|
Scissors
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export function Dashboard() {
|
interface DashboardProps {
|
||||||
|
onProcessingChange?: (isProcessing: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dashboard({ onProcessingChange }: DashboardProps = {}) {
|
||||||
const {
|
const {
|
||||||
selectedTema,
|
selectedTema,
|
||||||
files,
|
files,
|
||||||
@@ -67,9 +70,7 @@ export function Dashboard() {
|
|||||||
const [chunkingFileName, setChunkingFileName] = useState('')
|
const [chunkingFileName, setChunkingFileName] = useState('')
|
||||||
const [chunkingFileTema, setChunkingFileTema] = useState('')
|
const [chunkingFileTema, setChunkingFileTema] = useState('')
|
||||||
const [chunkingCollectionName, setChunkingCollectionName] = useState('')
|
const [chunkingCollectionName, setChunkingCollectionName] = useState('')
|
||||||
|
const [processing, setProcessing] = useState(false)
|
||||||
const [chunkPreviewOpen, setChunkPreviewOpen] = useState(false)
|
|
||||||
const [chunkingConfig, setChunkingConfig] = useState<ChunkingConfig | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadFiles()
|
loadFiles()
|
||||||
@@ -215,29 +216,41 @@ export function Dashboard() {
|
|||||||
setChunkingConfigOpen(true)
|
setChunkingConfigOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePreviewChunking = (config: ChunkingConfig) => {
|
const handleProcessWithLandingAI = async (config: LandingAIConfig) => {
|
||||||
setChunkingConfig(config)
|
setProcessing(true)
|
||||||
|
onProcessingChange?.(true)
|
||||||
setChunkingConfigOpen(false)
|
setChunkingConfigOpen(false)
|
||||||
setChunkPreviewOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAcceptChunking = async (config: ChunkingConfig) => {
|
|
||||||
try {
|
try {
|
||||||
const result = await api.processChunkingFull(config)
|
const result = await api.processWithLandingAI(config)
|
||||||
alert(`Procesamiento completado: ${result.chunks_added} chunks agregados a ${result.collection_name}`)
|
|
||||||
// Recargar archivos para actualizar el estado
|
// Mensaje detallado
|
||||||
loadFiles()
|
let message = `Completado\n\n`
|
||||||
} catch (error) {
|
message += `• Modo: ${result.mode === 'quick' ? 'Rápido' : 'Con Extracción'}\n`
|
||||||
console.error('Error processing PDF:', error)
|
message += `• Chunks procesados: ${result.total_chunks}\n`
|
||||||
throw error
|
message += `• Chunks agregados: ${result.chunks_added}\n`
|
||||||
}
|
message += `• Colección: ${result.collection_name}\n`
|
||||||
|
message += `• Tiempo: ${result.processing_time_seconds}s\n`
|
||||||
|
|
||||||
|
if (result.schema_used) {
|
||||||
|
message += `• Schema usado: ${result.schema_used}\n`
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancelChunking = () => {
|
if (result.extracted_data) {
|
||||||
setChunkPreviewOpen(false)
|
message += `\nDatos extraídos disponibles en metadata`
|
||||||
setChunkingConfig(null)
|
}
|
||||||
// Opcionalmente volver al modal de configuración
|
|
||||||
// setChunkingConfigOpen(true)
|
alert(message)
|
||||||
|
|
||||||
|
// Recargar archivos
|
||||||
|
loadFiles()
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error processing with LandingAI:', error)
|
||||||
|
alert(`❌ Error: ${error.message}`)
|
||||||
|
} finally {
|
||||||
|
setProcessing(false)
|
||||||
|
onProcessingChange?.(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredFiles = files.filter(file =>
|
const filteredFiles = files.filter(file =>
|
||||||
@@ -282,6 +295,18 @@ export function Dashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-white">
|
<div className="flex flex-col h-full bg-white">
|
||||||
|
{/* Processing Banner */}
|
||||||
|
{processing && (
|
||||||
|
<div className="bg-blue-50 border-b border-blue-200 px-6 py-3">
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
|
||||||
|
<p className="text-sm font-medium text-blue-900">
|
||||||
|
Procesando archivo con LandingAI... Por favor no navegues ni realices otras acciones.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="border-b border-gray-200 p-6">
|
<div className="border-b border-gray-200 p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -295,7 +320,7 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={() => setUploadDialogOpen(true)}>
|
<Button onClick={() => setUploadDialogOpen(true)} disabled={processing}>
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
Subir archivo
|
Subir archivo
|
||||||
</Button>
|
</Button>
|
||||||
@@ -311,6 +336,7 @@ export function Dashboard() {
|
|||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="pl-10"
|
className="pl-10"
|
||||||
|
disabled={processing}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -320,7 +346,7 @@ export function Dashboard() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleDownloadMultiple}
|
onClick={handleDownloadMultiple}
|
||||||
disabled={downloading}
|
disabled={downloading || processing}
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<Download className="w-4 h-4 mr-2" />
|
||||||
{downloading ? 'Descargando...' : `Descargar (${selectedFiles.size})`}
|
{downloading ? 'Descargando...' : `Descargar (${selectedFiles.size})`}
|
||||||
@@ -329,6 +355,7 @@ export function Dashboard() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleDeleteMultiple}
|
onClick={handleDeleteMultiple}
|
||||||
|
disabled={processing}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
Eliminar ({selectedFiles.size})
|
Eliminar ({selectedFiles.size})
|
||||||
@@ -358,7 +385,7 @@ export function Dashboard() {
|
|||||||
<TableHead className="w-12">
|
<TableHead className="w-12">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedFiles.size === filteredFiles.length && filteredFiles.length > 0}
|
checked={selectedFiles.size === filteredFiles.length && filteredFiles.length > 0}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked: boolean) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
selectAllFiles()
|
selectAllFiles()
|
||||||
} else {
|
} else {
|
||||||
@@ -499,23 +526,14 @@ export function Dashboard() {
|
|||||||
tema={chunkFileTema}
|
tema={chunkFileTema}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal de configuración de chunking */}
|
{/* Modal de configuración de chunking con LandingAI */}
|
||||||
<ChunkingConfigModal
|
<ChunkingConfigModalLandingAI
|
||||||
isOpen={chunkingConfigOpen}
|
isOpen={chunkingConfigOpen}
|
||||||
onClose={() => setChunkingConfigOpen(false)}
|
onClose={() => setChunkingConfigOpen(false)}
|
||||||
fileName={chunkingFileName}
|
fileName={chunkingFileName}
|
||||||
tema={chunkingFileTema}
|
tema={chunkingFileTema}
|
||||||
collectionName={chunkingCollectionName}
|
collectionName={chunkingCollectionName}
|
||||||
onPreview={handlePreviewChunking}
|
onProcess={handleProcessWithLandingAI}
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Panel de preview de chunks */}
|
|
||||||
<ChunkPreviewPanel
|
|
||||||
isOpen={chunkPreviewOpen}
|
|
||||||
onClose={() => setChunkPreviewOpen(false)}
|
|
||||||
config={chunkingConfig}
|
|
||||||
onAccept={handleAcceptChunking}
|
|
||||||
onCancel={handleCancelChunking}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,32 +4,65 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Menu } from 'lucide-react'
|
import { Menu } from 'lucide-react'
|
||||||
import { Sidebar } from './Sidebar'
|
import { Sidebar } from './Sidebar'
|
||||||
import { Dashboard } from './Dashboard'
|
import { Dashboard } from './Dashboard'
|
||||||
|
import { SchemaManagement } from '@/pages/SchemaManagement'
|
||||||
|
|
||||||
|
type View = 'dashboard' | 'schemas'
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
const [currentView, setCurrentView] = useState<View>('dashboard')
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
|
|
||||||
|
const handleNavigateToSchemas = () => {
|
||||||
|
if (isProcessing) {
|
||||||
|
alert('No puedes navegar mientras se está procesando un archivo. Por favor espera a que termine.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCurrentView('schemas')
|
||||||
|
setSidebarOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNavigateToDashboard = () => {
|
||||||
|
if (isProcessing) {
|
||||||
|
alert('No puedes navegar mientras se está procesando un archivo. Por favor espera a que termine.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCurrentView('dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex bg-gray-50">
|
<div className="h-screen flex bg-gray-50">
|
||||||
{/* Desktop Sidebar */}
|
{/* Desktop Sidebar */}
|
||||||
<div className="hidden md:flex md:w-64 md:flex-col">
|
<div className="hidden md:flex md:w-64 md:flex-col">
|
||||||
<Sidebar />
|
<Sidebar onNavigateToSchemas={handleNavigateToSchemas} disabled={isProcessing} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Sidebar */}
|
{/* Mobile Sidebar */}
|
||||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="md:hidden fixed top-4 left-4 z-40">
|
<Button variant="ghost" size="icon" className="md:hidden fixed top-4 left-4 z-40" disabled={isProcessing}>
|
||||||
<Menu className="h-6 w-6" />
|
<Menu className="h-6 w-6" />
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="left" className="w-64 p-0">
|
<SheetContent side="left" className="w-64 p-0">
|
||||||
<Sidebar />
|
<Sidebar onNavigateToSchemas={handleNavigateToSchemas} disabled={isProcessing} />
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
<Dashboard />
|
{currentView === 'dashboard' ? (
|
||||||
|
<Dashboard onProcessingChange={setIsProcessing} />
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<SchemaManagement />
|
||||||
|
<div className="fixed bottom-6 right-6">
|
||||||
|
<Button onClick={handleNavigateToDashboard} disabled={isProcessing}>
|
||||||
|
{isProcessing ? 'Procesando...' : 'Volver al Dashboard'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useFileStore } from '@/stores/fileStore'
|
import { useFileStore } from '@/stores/fileStore'
|
||||||
import { api } from '@/services/api'
|
import { api } from '@/services/api'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { FolderIcon, FileText } from 'lucide-react'
|
import { FolderIcon, FileText, Trash2, Database } from 'lucide-react'
|
||||||
|
|
||||||
export function Sidebar() {
|
interface SidebarProps {
|
||||||
|
onNavigateToSchemas?: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ onNavigateToSchemas, disabled = false }: SidebarProps = {}) {
|
||||||
const {
|
const {
|
||||||
temas,
|
temas,
|
||||||
selectedTema,
|
selectedTema,
|
||||||
@@ -14,6 +19,8 @@ export function Sidebar() {
|
|||||||
setLoading
|
setLoading
|
||||||
} = useFileStore()
|
} = useFileStore()
|
||||||
|
|
||||||
|
const [deletingTema, setDeletingTema] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTemas()
|
loadTemas()
|
||||||
}, [])
|
}, [])
|
||||||
@@ -34,26 +41,74 @@ export function Sidebar() {
|
|||||||
setSelectedTema(tema)
|
setSelectedTema(tema)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDeleteTema = async (tema: string, e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
e.stopPropagation() // Evitar que se seleccione el tema al hacer clic en el icono
|
||||||
|
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`¿Estás seguro de que deseas eliminar el tema "${tema}"?\n\n` +
|
||||||
|
`Esto eliminará:\n` +
|
||||||
|
`• Todos los archivos del tema en Azure Blob Storage\n` +
|
||||||
|
`• La colección "${tema}" en Qdrant (si existe)\n\n` +
|
||||||
|
`Esta acción no se puede deshacer.`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setDeletingTema(tema)
|
||||||
|
|
||||||
|
// 1. Eliminar todos los archivos del tema en Azure Blob Storage
|
||||||
|
await api.deleteTema(tema)
|
||||||
|
|
||||||
|
// 2. Intentar eliminar la colección en Qdrant (si existe)
|
||||||
|
try {
|
||||||
|
const collectionExists = await api.checkCollectionExists(tema)
|
||||||
|
if (collectionExists.exists) {
|
||||||
|
await api.deleteCollection(tema)
|
||||||
|
console.log(`Colección "${tema}" eliminada de Qdrant`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`No se pudo eliminar la colección "${tema}" de Qdrant:`, error)
|
||||||
|
// Continuar aunque falle la eliminación de la colección
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Actualizar la lista de temas
|
||||||
|
await loadTemas()
|
||||||
|
|
||||||
|
// 4. Si el tema eliminado estaba seleccionado, deseleccionar
|
||||||
|
if (selectedTema === tema) {
|
||||||
|
setSelectedTema(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error eliminando tema "${tema}":`, error)
|
||||||
|
alert(`Error al eliminar el tema: ${error instanceof Error ? error.message : 'Error desconocido'}`)
|
||||||
|
} finally {
|
||||||
|
setDeletingTema(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border-r border-gray-200 flex flex-col h-full">
|
<div className="bg-white border-r border-gray-200 flex flex-col h-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-6 border-b border-gray-200">
|
<div className="p-6 border-b border-gray-200">
|
||||||
<h1 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
|
<h1 className="text-xl font-semibold text-gray-900 flex items-center gap-2">
|
||||||
<FileText className="h-6 w-6" />
|
<FileText className="h-6 w-6" />
|
||||||
DoRa Banorte
|
DoRa Luma
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Temas List */}
|
{/* Temas List */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-sm font-medium text-gray-500 mb-3">TEMAS</h2>
|
<h2 className="text-sm font-medium text-gray-500 mb-3">Collections</h2>
|
||||||
|
|
||||||
{/* Todos los archivos */}
|
{/* Todos los archivos */}
|
||||||
<Button
|
<Button
|
||||||
variant={selectedTema === null ? "secondary" : "ghost"}
|
variant={selectedTema === null ? "secondary" : "ghost"}
|
||||||
className="w-full justify-start"
|
className="w-full justify-start"
|
||||||
onClick={() => handleTemaSelect(null)}
|
onClick={() => handleTemaSelect(null)}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<FolderIcon className="mr-2 h-4 w-4" />
|
<FolderIcon className="mr-2 h-4 w-4" />
|
||||||
Todos los archivos
|
Todos los archivos
|
||||||
@@ -64,27 +119,49 @@ export function Sidebar() {
|
|||||||
<div className="text-sm text-gray-500 px-3 py-2">Cargando...</div>
|
<div className="text-sm text-gray-500 px-3 py-2">Cargando...</div>
|
||||||
) : (
|
) : (
|
||||||
temas.map((tema) => (
|
temas.map((tema) => (
|
||||||
|
<div key={tema} className="relative group">
|
||||||
<Button
|
<Button
|
||||||
key={tema}
|
|
||||||
variant={selectedTema === tema ? "secondary" : "ghost"}
|
variant={selectedTema === tema ? "secondary" : "ghost"}
|
||||||
className="w-full justify-start"
|
className="w-full justify-start pr-10"
|
||||||
onClick={() => handleTemaSelect(tema)}
|
onClick={() => handleTemaSelect(tema)}
|
||||||
|
disabled={deletingTema === tema || disabled}
|
||||||
>
|
>
|
||||||
<FolderIcon className="mr-2 h-4 w-4" />
|
<FolderIcon className="mr-2 h-4 w-4" />
|
||||||
{tema}
|
{tema}
|
||||||
</Button>
|
</Button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDeleteTema(tema, e)}
|
||||||
|
disabled={deletingTema === tema || disabled}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded hover:bg-red-100 opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-50"
|
||||||
|
title="Eliminar tema y colección"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="p-4 border-t border-gray-200">
|
<div className="p-4 border-t border-gray-200 space-y-2">
|
||||||
|
{onNavigateToSchemas && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={onNavigateToSchemas}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Database className="mr-2 h-4 w-4" />
|
||||||
|
Gestionar Schemas
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={loadTemas}
|
onClick={loadTemas}
|
||||||
disabled={loading}
|
disabled={loading || disabled}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
Actualizar temas
|
Actualizar temas
|
||||||
|
|||||||
340
frontend/src/components/schemas/SchemaBuilder.tsx
Normal file
340
frontend/src/components/schemas/SchemaBuilder.tsx
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
/**
|
||||||
|
* Schema Builder Component
|
||||||
|
* Permite crear y editar schemas personalizados desde el frontend
|
||||||
|
*/
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Trash2, Plus, AlertCircle, CheckCircle2 } from 'lucide-react'
|
||||||
|
import type { CustomSchema, SchemaField, FieldType, FieldTypeOption } from '@/types/schema'
|
||||||
|
import { FIELD_TYPE_OPTIONS } from '@/types/schema'
|
||||||
|
|
||||||
|
interface SchemaBuilderProps {
|
||||||
|
initialSchema?: CustomSchema
|
||||||
|
tema?: string
|
||||||
|
onSave: (schema: CustomSchema) => Promise<void>
|
||||||
|
onCancel?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SchemaBuilder({ initialSchema, tema, onSave, onCancel }: SchemaBuilderProps) {
|
||||||
|
const [schema, setSchema] = useState<CustomSchema>(
|
||||||
|
initialSchema || {
|
||||||
|
schema_name: '',
|
||||||
|
description: '',
|
||||||
|
fields: [],
|
||||||
|
tema: tema,
|
||||||
|
is_global: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const addField = () => {
|
||||||
|
setSchema({
|
||||||
|
...schema,
|
||||||
|
fields: [
|
||||||
|
...schema.fields,
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
type: 'string',
|
||||||
|
description: '',
|
||||||
|
required: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateField = (index: number, updates: Partial<SchemaField>) => {
|
||||||
|
const newFields = [...schema.fields]
|
||||||
|
newFields[index] = { ...newFields[index], ...updates }
|
||||||
|
setSchema({ ...schema, fields: newFields })
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeField = (index: number) => {
|
||||||
|
setSchema({
|
||||||
|
...schema,
|
||||||
|
fields: schema.fields.filter((_, i) => i !== index)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
// Validaciones básicas
|
||||||
|
if (!schema.schema_name.trim()) {
|
||||||
|
setError('El nombre del schema es requerido')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!schema.description.trim()) {
|
||||||
|
setError('La descripción es requerida')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.fields.length === 0) {
|
||||||
|
setError('Debe agregar al menos un campo')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar campos
|
||||||
|
for (let i = 0; i < schema.fields.length; i++) {
|
||||||
|
const field = schema.fields[i]
|
||||||
|
if (!field.name.trim()) {
|
||||||
|
setError(`El campo ${i + 1} necesita un nombre`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!field.description.trim()) {
|
||||||
|
setError(`El campo "${field.name}" necesita una descripción`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await onSave(schema)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Error guardando schema')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold">
|
||||||
|
{initialSchema ? 'Editar Schema' : 'Crear Nuevo Schema'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
Define los campos que quieres extraer de los documentos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-lg text-red-800">
|
||||||
|
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||||
|
<span className="text-sm">{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="space-y-4 p-6 bg-white rounded-lg border">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="schema_name">Nombre del Schema *</Label>
|
||||||
|
<Input
|
||||||
|
id="schema_name"
|
||||||
|
value={schema.schema_name}
|
||||||
|
onChange={(e) => setSchema({ ...schema, schema_name: e.target.value })}
|
||||||
|
placeholder="Ej: Contrato Legal, Factura Comercial"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="description">Descripción *</Label>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
value={schema.description}
|
||||||
|
onChange={(e) => setSchema({ ...schema, description: e.target.value })}
|
||||||
|
placeholder="¿Qué información extrae este schema?"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="is_global"
|
||||||
|
checked={schema.is_global}
|
||||||
|
onChange={(e) => setSchema({ ...schema, is_global: e.target.checked })}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="is_global" className="cursor-pointer">
|
||||||
|
Disponible para todos los temas (global)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!schema.is_global && (
|
||||||
|
<div className="text-sm text-gray-600 bg-blue-50 p-3 rounded">
|
||||||
|
Este schema solo estará disponible para el tema: <strong>{tema || 'actual'}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fields */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Campos a Extraer</h3>
|
||||||
|
<p className="text-sm text-gray-600">Define qué datos quieres que la IA extraiga</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={addField} size="sm" variant="outline">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Agregar Campo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{schema.fields.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-gray-50 rounded-lg border-2 border-dashed">
|
||||||
|
<p className="text-gray-600 mb-3">No hay campos definidos</p>
|
||||||
|
<Button onClick={addField} variant="outline">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Agregar Primer Campo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{schema.fields.map((field, index) => (
|
||||||
|
<SchemaFieldRow
|
||||||
|
key={index}
|
||||||
|
field={field}
|
||||||
|
index={index}
|
||||||
|
onUpdate={(updates) => updateField(index, updates)}
|
||||||
|
onRemove={() => removeField(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 justify-end pt-4 border-t">
|
||||||
|
{onCancel && (
|
||||||
|
<Button variant="outline" onClick={onCancel} disabled={saving}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<>Guardando...</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||||
|
Guardar Schema
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SchemaFieldRow({
|
||||||
|
field,
|
||||||
|
index,
|
||||||
|
onUpdate,
|
||||||
|
onRemove
|
||||||
|
}: {
|
||||||
|
field: SchemaField
|
||||||
|
index: number
|
||||||
|
onUpdate: (updates: Partial<SchemaField>) => void
|
||||||
|
onRemove: () => void
|
||||||
|
}) {
|
||||||
|
const selectedTypeOption = FIELD_TYPE_OPTIONS.find(opt => opt.value === field.type)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-white rounded-lg border hover:border-blue-300 transition-colors">
|
||||||
|
<div className="grid grid-cols-12 gap-3 items-start">
|
||||||
|
{/* Field Number */}
|
||||||
|
<div className="col-span-1 flex items-center justify-center">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-100 text-blue-700 font-semibold flex items-center justify-center text-sm">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Field Name */}
|
||||||
|
<div className="col-span-3">
|
||||||
|
<Label className="text-xs text-gray-600">Nombre del campo *</Label>
|
||||||
|
<Input
|
||||||
|
value={field.name}
|
||||||
|
onChange={(e) => onUpdate({ name: e.target.value.toLowerCase().replace(/\s+/g, '_') })}
|
||||||
|
placeholder="nombre_campo"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Field Type */}
|
||||||
|
<div className="col-span-2">
|
||||||
|
<Label className="text-xs text-gray-600">Tipo *</Label>
|
||||||
|
<select
|
||||||
|
value={field.type}
|
||||||
|
onChange={(e) => onUpdate({ type: e.target.value as FieldType })}
|
||||||
|
className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{FIELD_TYPE_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="col-span-4">
|
||||||
|
<Label className="text-xs text-gray-600">Descripción para IA *</Label>
|
||||||
|
<Input
|
||||||
|
value={field.description}
|
||||||
|
onChange={(e) => onUpdate({ description: e.target.value })}
|
||||||
|
placeholder="¿Qué debe extraer la IA?"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Required Checkbox */}
|
||||||
|
<div className="col-span-1 flex items-end pb-2">
|
||||||
|
<label className="flex items-center gap-1 text-xs cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={field.required}
|
||||||
|
onChange={(e) => onUpdate({ required: e.target.checked })}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span>Requerido</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Button */}
|
||||||
|
<div className="col-span-1 flex items-end justify-center pb-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Min/Max Values (for numeric types) */}
|
||||||
|
{selectedTypeOption?.supportsMinMax && (
|
||||||
|
<div className="mt-3 pt-3 border-t grid grid-cols-2 gap-3 ml-12">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600">Valor Mínimo (opcional)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={field.min_value ?? ''}
|
||||||
|
onChange={(e) => onUpdate({ min_value: e.target.value ? parseFloat(e.target.value) : undefined })}
|
||||||
|
placeholder="Sin límite"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600">Valor Máximo (opcional)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={field.max_value ?? ''}
|
||||||
|
onChange={(e) => onUpdate({ max_value: e.target.value ? parseFloat(e.target.value) : undefined })}
|
||||||
|
placeholder="Sin límite"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
151
frontend/src/components/schemas/SchemaList.tsx
Normal file
151
frontend/src/components/schemas/SchemaList.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Schema List Component
|
||||||
|
* Lista y gestiona schemas existentes
|
||||||
|
*/
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Trash2, Edit, Globe, FolderClosed } from 'lucide-react'
|
||||||
|
import type { CustomSchema } from '@/types/schema'
|
||||||
|
import { api } from '@/services/api'
|
||||||
|
|
||||||
|
interface SchemaListProps {
|
||||||
|
schemas: CustomSchema[]
|
||||||
|
onEdit: (schema: CustomSchema) => void
|
||||||
|
onDelete: (schemaId: string) => void
|
||||||
|
onRefresh: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SchemaList({ schemas, onEdit, onDelete, onRefresh }: SchemaListProps) {
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleDelete = async (schema: CustomSchema) => {
|
||||||
|
if (!schema.schema_id) return
|
||||||
|
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`¿Estás seguro de eliminar el schema "${schema.schema_name}"?\n\nEsta acción no se puede deshacer.`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setDeletingId(schema.schema_id)
|
||||||
|
try {
|
||||||
|
await api.deleteSchema(schema.schema_id)
|
||||||
|
onDelete(schema.schema_id)
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(`Error eliminando schema: ${error.message}`)
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schemas.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 bg-gray-50 rounded-lg border-2 border-dashed">
|
||||||
|
<p className="text-gray-600 mb-2">No hay schemas creados</p>
|
||||||
|
<p className="text-sm text-gray-500">Crea tu primer schema para empezar</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{schemas.map((schema) => (
|
||||||
|
<SchemaCard
|
||||||
|
key={schema.schema_id}
|
||||||
|
schema={schema}
|
||||||
|
onEdit={() => onEdit(schema)}
|
||||||
|
onDelete={() => handleDelete(schema)}
|
||||||
|
isDeleting={deletingId === schema.schema_id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SchemaCard({
|
||||||
|
schema,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
isDeleting
|
||||||
|
}: {
|
||||||
|
schema: CustomSchema
|
||||||
|
onEdit: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
isDeleting: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-white rounded-lg border hover:border-blue-300 transition-colors">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h3 className="text-lg font-semibold">{schema.schema_name}</h3>
|
||||||
|
{schema.is_global ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded-full">
|
||||||
|
<Globe className="h-3 w-3" />
|
||||||
|
Global
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-100 text-gray-700 text-xs rounded-full">
|
||||||
|
<FolderClosed className="h-3 w-3" />
|
||||||
|
{schema.tema}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-sm text-gray-600 mb-3">{schema.description}</p>
|
||||||
|
|
||||||
|
{/* Fields Summary */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{schema.fields.slice(0, 5).map((field, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="inline-flex items-center px-2 py-1 bg-gray-50 text-gray-700 text-xs rounded border"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{field.name}</span>
|
||||||
|
<span className="text-gray-500 ml-1">({field.type})</span>
|
||||||
|
{field.required && (
|
||||||
|
<span className="ml-1 text-red-500">*</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{schema.fields.length > 5 && (
|
||||||
|
<span className="inline-flex items-center px-2 py-1 text-gray-500 text-xs">
|
||||||
|
+{schema.fields.length - 5} más
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
{schema.created_at && (
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
Creado: {new Date(schema.created_at).toLocaleDateString('es-ES')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 ml-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onEdit}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
frontend/src/pages/SchemaManagement.tsx
Normal file
127
frontend/src/pages/SchemaManagement.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Schema Management Page
|
||||||
|
* Página principal para gestionar schemas personalizados
|
||||||
|
*/
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Plus, ArrowLeft } from 'lucide-react'
|
||||||
|
import { SchemaBuilder } from '@/components/schemas/SchemaBuilder'
|
||||||
|
import { SchemaList } from '@/components/schemas/SchemaList'
|
||||||
|
import { useFileStore } from '@/stores/fileStore'
|
||||||
|
import { api } from '@/services/api'
|
||||||
|
import type { CustomSchema } from '@/types/schema'
|
||||||
|
|
||||||
|
type View = 'list' | 'create' | 'edit'
|
||||||
|
|
||||||
|
export function SchemaManagement() {
|
||||||
|
const { selectedTema } = useFileStore()
|
||||||
|
const [view, setView] = useState<View>('list')
|
||||||
|
const [schemas, setSchemas] = useState<CustomSchema[]>([])
|
||||||
|
const [selectedSchema, setSelectedSchema] = useState<CustomSchema | undefined>()
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSchemas()
|
||||||
|
}, [selectedTema])
|
||||||
|
|
||||||
|
const loadSchemas = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await api.listSchemas(selectedTema || undefined)
|
||||||
|
setSchemas(data)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error loading schemas:', error)
|
||||||
|
alert('Error cargando schemas: ' + error.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async (schema: CustomSchema) => {
|
||||||
|
try {
|
||||||
|
if (selectedSchema?.schema_id) {
|
||||||
|
// Update existing
|
||||||
|
await api.updateSchema(selectedSchema.schema_id, schema)
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
await api.createSchema(schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadSchemas()
|
||||||
|
setView('list')
|
||||||
|
setSelectedSchema(undefined)
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (schema: CustomSchema) => {
|
||||||
|
setSelectedSchema(schema)
|
||||||
|
setView('edit')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (schemaId: string) => {
|
||||||
|
await loadSchemas()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setView('list')
|
||||||
|
setSelectedSchema(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<div className="max-w-7xl mx-auto p-6">
|
||||||
|
{/* Header */}
|
||||||
|
{view === 'list' ? (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Schemas Personalizados</h1>
|
||||||
|
<p className="text-gray-600 mt-1">
|
||||||
|
{selectedTema
|
||||||
|
? `Schemas para el tema: ${selectedTema}`
|
||||||
|
: 'Todos los schemas disponibles'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setView('create')}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Crear Nuevo Schema
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mb-6">
|
||||||
|
<Button variant="ghost" onClick={handleCancel} className="mb-4">
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Volver a la lista
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="bg-white rounded-lg border p-6">
|
||||||
|
{loading && view === 'list' ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-600">Cargando schemas...</p>
|
||||||
|
</div>
|
||||||
|
) : view === 'list' ? (
|
||||||
|
<SchemaList
|
||||||
|
schemas={schemas}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onRefresh={loadSchemas}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SchemaBuilder
|
||||||
|
initialSchema={selectedSchema}
|
||||||
|
tema={selectedTema || undefined}
|
||||||
|
onSave={handleSave}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -420,4 +420,96 @@ export const api = {
|
|||||||
return response.json()
|
return response.json()
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Schemas API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Crear schema
|
||||||
|
createSchema: async (schema: any): Promise<any> => {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/schemas/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(schema)
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail?.message || 'Error creando schema')
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
// Listar schemas
|
||||||
|
listSchemas: async (tema?: string): Promise<any[]> => {
|
||||||
|
const url = tema
|
||||||
|
? `${API_BASE_URL}/schemas/?tema=${encodeURIComponent(tema)}`
|
||||||
|
: `${API_BASE_URL}/schemas/`
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) throw new Error('Error listando schemas')
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
// Obtener schema por ID
|
||||||
|
getSchema: async (schema_id: string): Promise<any> => {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/schemas/${schema_id}`)
|
||||||
|
if (!response.ok) throw new Error('Error obteniendo schema')
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
// Actualizar schema
|
||||||
|
updateSchema: async (schema_id: string, schema: any): Promise<any> => {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/schemas/${schema_id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(schema)
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Error actualizando schema')
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
// Eliminar schema
|
||||||
|
deleteSchema: async (schema_id: string): Promise<void> => {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/schemas/${schema_id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Error eliminando schema')
|
||||||
|
},
|
||||||
|
|
||||||
|
// Validar schema
|
||||||
|
validateSchema: async (schema: any): Promise<any> => {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/schemas/validate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(schema)
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Error validando schema')
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LandingAI Processing
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Procesar con LandingAI
|
||||||
|
processWithLandingAI: async (config: {
|
||||||
|
file_name: string
|
||||||
|
tema: string
|
||||||
|
collection_name: string
|
||||||
|
mode: 'quick' | 'extract'
|
||||||
|
schema_id?: string
|
||||||
|
include_chunk_types?: string[]
|
||||||
|
max_tokens_per_chunk?: number
|
||||||
|
merge_small_chunks?: boolean
|
||||||
|
}): Promise<any> => {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/chunking-landingai/process`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(config)
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.detail || 'Error procesando con LandingAI')
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
|
||||||
}
|
}
|
||||||
60
frontend/src/types/schema.ts
Normal file
60
frontend/src/types/schema.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Tipos TypeScript para schemas personalizables
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type FieldType =
|
||||||
|
| 'string'
|
||||||
|
| 'integer'
|
||||||
|
| 'float'
|
||||||
|
| 'boolean'
|
||||||
|
| 'array_string'
|
||||||
|
| 'array_integer'
|
||||||
|
| 'array_float'
|
||||||
|
| 'date'
|
||||||
|
|
||||||
|
export interface SchemaField {
|
||||||
|
name: string
|
||||||
|
type: FieldType
|
||||||
|
description: string
|
||||||
|
required: boolean
|
||||||
|
min_value?: number
|
||||||
|
max_value?: number
|
||||||
|
pattern?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomSchema {
|
||||||
|
schema_id?: string
|
||||||
|
schema_name: string
|
||||||
|
description: string
|
||||||
|
fields: SchemaField[]
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
tema?: string
|
||||||
|
is_global: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchemaValidationResponse {
|
||||||
|
valid: boolean
|
||||||
|
message: string
|
||||||
|
json_schema?: any
|
||||||
|
errors?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opciones para el selector de tipo
|
||||||
|
export interface FieldTypeOption {
|
||||||
|
value: FieldType
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
supportsMinMax: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FIELD_TYPE_OPTIONS: FieldTypeOption[] = [
|
||||||
|
{ value: 'string', label: 'Texto', description: 'Texto simple', supportsMinMax: false },
|
||||||
|
{ value: 'integer', label: 'Número Entero', description: 'Número sin decimales', supportsMinMax: true },
|
||||||
|
{ value: 'float', label: 'Número Decimal', description: 'Número con decimales', supportsMinMax: true },
|
||||||
|
{ value: 'boolean', label: 'Verdadero/Falso', description: 'Sí o No', supportsMinMax: false },
|
||||||
|
{ value: 'array_string', label: 'Lista de Textos', description: 'Múltiples textos', supportsMinMax: false },
|
||||||
|
{ value: 'array_integer', label: 'Lista de Números', description: 'Múltiples números enteros', supportsMinMax: true },
|
||||||
|
{ value: 'array_float', label: 'Lista de Decimales', description: 'Múltiples números decimales', supportsMinMax: true },
|
||||||
|
{ value: 'date', label: 'Fecha', description: 'Fecha en formato ISO', supportsMinMax: false },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user