From be10a888fb986237125269d44c9ca83f0cf3259b Mon Sep 17 00:00:00 2001 From: ronalds Date: Wed, 19 Nov 2025 01:09:25 -0300 Subject: [PATCH] first commit --- .env.prod.local | 17 - .env.production | 33 +- AI_FUNCTIONALITY.md | 170 +++++++ DEPLOYMENT.md | 193 +++++++ DEPLOY_DOCKPLOY.md | 256 ---------- backend/.dockerignore | 24 + backend/Dockerfile.prod | 27 + backend/add_questions.py | 35 +- backend/app/main.py | 430 +++++++++++++++- backend/app/models.py | 12 + backend/app/models_ai.py | 14 + backend/app/schemas.py | 30 ++ backend/init_db.py | 61 +++ backend/requirements.txt | 1 + build-and-push.ps1 | 87 ++++ build-and-push.sh | 88 ++++ docker-compose.hub.yml | 56 +++ docker-compose.prod.yml | 55 +- docker-compose.yml | 15 +- docker-stack.yml | 99 ++++ frontend/.dockerignore | 16 + frontend/Dockerfile.prod | 20 +- frontend/index.html | 3 +- frontend/nginx.conf | 26 +- frontend/src/App.jsx | 1036 ++++++++++++++++++++++++++++++++++---- frontend/src/Sidebar.jsx | 55 +- init-db.sh | 12 + init_users.py | 74 +++ 28 files changed, 2481 insertions(+), 464 deletions(-) delete mode 100644 .env.prod.local create mode 100644 AI_FUNCTIONALITY.md create mode 100644 DEPLOYMENT.md delete mode 100644 DEPLOY_DOCKPLOY.md create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile.prod create mode 100644 backend/app/models_ai.py create mode 100644 backend/init_db.py create mode 100644 build-and-push.ps1 create mode 100644 build-and-push.sh create mode 100644 docker-compose.hub.yml create mode 100644 docker-stack.yml create mode 100644 frontend/.dockerignore create mode 100644 init-db.sh create mode 100644 init_users.py diff --git a/.env.prod.local b/.env.prod.local deleted file mode 100644 index 73e4832..0000000 --- a/.env.prod.local +++ /dev/null @@ -1,17 +0,0 @@ -# Variables de Entorno para Prueba de Producción Local - -# Database -POSTGRES_DB=checklist_db -POSTGRES_USER=checklist_user -POSTGRES_PASSWORD=checklist_pass_2024_prod - -# Backend -SECRET_KEY=production-secret-key-super-segura-minimo-32-caracteres-123456 -OPENAI_API_KEY= -ENVIRONMENT=production - -# CORS - Para prueba local -ALLOWED_ORIGINS=http://localhost,http://localhost:80,http://127.0.0.1 - -# Frontend - Para prueba local (apunta al backend en puerto 8000) -VITE_API_URL=http://localhost:8000 diff --git a/.env.production b/.env.production index f7d4d5c..3f220b2 100644 --- a/.env.production +++ b/.env.production @@ -1,17 +1,22 @@ -# Variables de Entorno para Producción - Dockploy +# Producción - CAMBIAR TODOS ESTOS VALORES +POSTGRES_DB=syntria_db +POSTGRES_USER=syntria_user +POSTGRES_PASSWORD=syntria_db -# Database -POSTGRES_DB=checklist_db -POSTGRES_USER=checklist_user -POSTGRES_PASSWORD=CAMBIAR-PASSWORD-SUPER-SEGURA-AQUI +# Secret key para JWT (mínimo 32 caracteres) +SECRET_KEY=CHANGE_THIS_TO_RANDOM_STRING_MIN_32_CHARS_PRODUCTION -# Backend -SECRET_KEY=CAMBIAR-CLAVE-SECRETA-MINIMO-32-CARACTERES-SUPER-SEGURA -OPENAI_API_KEY=tu-openai-api-key-aqui +# OpenAI API Key (opcional, para IA) +OPENAI_API_KEY= + +# Docker Hub username +DOCKER_USERNAME=dymai + +# API URL para el frontend +API_URL=http://your-domain.com:8000 + +# Orígenes permitidos (CORS) +ALLOWED_ORIGINS=http://your-domain.com,https://your-domain.com + +# Environment ENVIRONMENT=production - -# CORS - URL del FRONTEND (de donde vienen las peticiones) -ALLOWED_ORIGINS=http://checklist-frontend-n5eten-9cb24a-72-61-106-199.traefik.me - -# Frontend - Vacío para usar URL relativa con proxy de Nginx -VITE_API_URL= diff --git a/AI_FUNCTIONALITY.md b/AI_FUNCTIONALITY.md new file mode 100644 index 0000000..4346d80 --- /dev/null +++ b/AI_FUNCTIONALITY.md @@ -0,0 +1,170 @@ +# Sistema de IA para Checklists Inteligentes + +## Modos de IA + +### 1. **OFF (Sin IA)** +- El mecánico completa manualmente todas las respuestas +- Sin sugerencias ni asistencia automática +- Control total del usuario + +### 2. **ASSISTED (IA Asistida)** +**Funcionalidades:** +- **Análisis de fotos**: Cuando el mecánico sube una foto, la IA analiza la imagen y sugiere: + - Estado del componente (bueno/malo) + - Nivel de criticidad (ok/minor/critical) + - Observaciones automáticas basadas en lo que detecta +- **Sugerencias contextuales**: Basándose en respuestas previas +- **Detección de anomalías**: Si detecta algo crítico, lo marca automáticamente +- **El mecánico puede aceptar o rechazar** las sugerencias + +**Ejemplo de flujo:** +``` +1. Mecánico sube foto de pastillas de freno +2. IA analiza: "Desgaste del 85%, menos de 2mm de material" +3. Sugiere: Estado=Crítico, Requiere reemplazo inmediato +4. Mecánico revisa y confirma o modifica +``` + +### 3. **FULL (IA Completa - Copilot)** +**Funcionalidades:** +- **Inspección automática por fotos**: El mecánico solo toma fotos +- **Análisis completo**: La IA responde todas las preguntas automáticamente +- **Generación de informe**: Crea observaciones y recomendaciones +- **Detección de problemas**: Marca automáticamente items críticos +- **El mecánico solo revisa y firma** al final + +**Ejemplo de flujo:** +``` +1. Mecánico toma 20 fotos del vehículo +2. IA procesa todas las fotos +3. Responde las 99 preguntas automáticamente +4. Genera observaciones detalladas +5. Marca 3 items como críticos +6. Mecánico revisa el informe completo +7. Ajusta si es necesario +8. Firma y completa +``` + +## Implementación Técnica + +### Análisis de Imágenes con OpenAI Vision +```javascript +// Frontend: Subir foto +const analyzeImage = async (imageFile, questionType) => { + const formData = new FormData() + formData.append('image', imageFile) + formData.append('question_id', questionId) + + const response = await fetch('/api/analyze-image', { + method: 'POST', + body: formData + }) + + return response.json() // { status, observations, confidence } +} +``` + +### Backend: Procesamiento con IA +```python +# Backend: Analizar imagen con OpenAI GPT-4 Vision +import openai +from PIL import Image + +async def analyze_vehicle_component(image_path: str, question: Question): + # Cargar imagen + with open(image_path, 'rb') as f: + image_data = f.read() + + # Prompt especializado según tipo de pregunta + prompts = { + "brakes": "Analiza el estado de las pastillas de freno. Indica desgaste, grietas, material restante.", + "tires": "Evalúa la banda de rodadura, presión aparente, desgaste irregular.", + "lights": "Verifica funcionamiento de luces, roturas, opacidad.", + # ... más prompts especializados + } + + # Llamada a OpenAI Vision API + response = openai.ChatCompletion.create( + model="gpt-4-vision-preview", + messages=[{ + "role": "user", + "content": [ + {"type": "text", "text": prompts.get(question.section, "Analiza este componente")}, + {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_data}"}} + ] + }], + max_tokens=300 + ) + + # Parsear respuesta + ai_analysis = response.choices[0].message.content + + # Determinar status según análisis + if "crítico" in ai_analysis.lower() or "falla" in ai_analysis.lower(): + status = "critical" + elif "menor" in ai_analysis.lower() or "atención" in ai_analysis.lower(): + status = "minor" + else: + status = "ok" + + return { + "status": status, + "observations": ai_analysis, + "confidence": 0.85, + "ai_analysis": { + "raw_response": ai_analysis, + "model": "gpt-4-vision", + "timestamp": datetime.now() + } + } +``` + +## Ventajas de cada modo + +### OFF +✅ Control total del mecánico +✅ Sin dependencia de conectividad +✅ Sin costos de API + +### ASSISTED +✅ Ayuda al mecánico a no olvidar detalles +✅ Documenta mejor con análisis de fotos +✅ Reduce errores humanos +✅ Aprende patrones comunes +⚠️ Requiere internet y API key + +### FULL +✅ Rapidez extrema (5-10 min vs 30-40 min) +✅ Consistencia en evaluaciones +✅ Ideal para inspecciones masivas +✅ Genera informes detallados automáticamente +⚠️ Requiere validación del mecánico +⚠️ Mayor costo de API + +## Casos de Uso Recomendados + +**OFF**: +- Talleres sin internet estable +- Inspecciones básicas +- Presupuesto limitado + +**ASSISTED**: +- Talleres medianos/grandes +- Inspecciones preventivas +- Documentación detallada requerida + +**FULL**: +- Flotas de vehículos +- Inspecciones pre-compra masivas +- Talleres de alto volumen +- Empresas de rent-a-car + +## Próximos pasos de implementación + +1. ✅ Estructura de base de datos preparada +2. ⏳ Endpoint `/api/analyze-image` para análisis +3. ⏳ Integración con OpenAI Vision API +4. ⏳ UI para mostrar sugerencias de IA +5. ⏳ Sistema de confianza (confidence score) +6. ⏳ Historial de sugerencias aceptadas/rechazadas +7. ⏳ Fine-tuning del modelo con datos reales diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..1eab75c --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,193 @@ +# Syntria - Despliegue en Producción + +## 📋 Requisitos Previos + +- Docker y Docker Compose instalados +- Cuenta en Docker Hub +- Servidor Linux/Windows con puertos 80, 8000 y 5432 disponibles + +## 🚀 Despliegue Rápido + +### 1. Construir y Publicar Imágenes + +**Windows (PowerShell):** +```powershell +.\build-and-push.ps1 +``` + +**Linux/Mac:** +```bash +chmod +x build-and-push.sh +./build-and-push.sh +``` + +### 2. Configurar Producción + +Edita `.env.production` con tus valores: + +```env +POSTGRES_PASSWORD=tu_password_seguro_aqui +SECRET_KEY=clave_secreta_minimo_32_caracteres +DOCKER_USERNAME=tu_usuario_dockerhub +API_URL=http://tu-dominio.com:8000 +ALLOWED_ORIGINS=http://tu-dominio.com,https://tu-dominio.com +``` + +### 3. Desplegar en Servidor + +En tu servidor de producción: + +```bash +# Clonar o subir archivos necesarios +# - docker-compose.prod.yml +# - .env.production +# - init-db.sh + +# Desplegar +docker-compose -f docker-compose.prod.yml --env-file .env.production up -d + +# Verificar estado +docker-compose -f docker-compose.prod.yml ps + +# Ver logs +docker-compose -f docker-compose.prod.yml logs -f +``` + +### 4. Inicializar Base de Datos + +```bash +# Crear usuarios iniciales +docker exec -it syntria-backend-prod python init_db.py +``` + +## 🔧 Mantenimiento + +### Ver Logs +```bash +# Todos los servicios +docker-compose -f docker-compose.prod.yml logs -f + +# Solo backend +docker-compose -f docker-compose.prod.yml logs -f backend + +# Solo frontend +docker-compose -f docker-compose.prod.yml logs -f frontend +``` + +### Actualizar Imágenes +```bash +# Pull nuevas versiones +docker-compose -f docker-compose.prod.yml pull + +# Reiniciar servicios +docker-compose -f docker-compose.prod.yml up -d +``` + +### Backup Base de Datos +```bash +# Crear backup +docker exec syntria-db-prod pg_dump -U syntria_user syntria_db > backup_$(date +%Y%m%d).sql + +# Restaurar backup +docker exec -i syntria-db-prod psql -U syntria_user syntria_db < backup_20241118.sql +``` + +## 🌐 Acceso + +- **Frontend:** http://tu-servidor +- **Backend API:** http://tu-servidor:8000 +- **Docs API:** http://tu-servidor:8000/docs + +## 🔒 Seguridad + +### Recomendaciones: + +1. **Cambiar passwords por defecto** + - PostgreSQL password + - SECRET_KEY (mínimo 32 caracteres aleatorios) + +2. **Usar HTTPS** + - Configurar certificado SSL + - Usar reverse proxy (Nginx/Traefik) + +3. **Firewall** + - Abrir solo puertos necesarios (80, 443) + - Cerrar puerto 5432 al público + +4. **Backups automáticos** + - Configurar cron job para backups diarios + +## 📊 Monitoreo + +### Health Checks + +```bash +# Backend +curl http://localhost:8000/health + +# Frontend +curl http://localhost + +# Database +docker exec syntria-db-prod pg_isready -U syntria_user +``` + +## 🐳 Imágenes Docker Hub + +Las imágenes están disponibles en: +- `usuario/syntria-backend:latest` +- `usuario/syntria-frontend:latest` + +## ⚙️ Variables de Entorno + +### Backend +- `DATABASE_URL` - PostgreSQL connection string +- `SECRET_KEY` - JWT secret key (32+ chars) +- `OPENAI_API_KEY` - OpenAI API key (opcional) +- `ENVIRONMENT` - `production` +- `ALLOWED_ORIGINS` - CORS origins + +### Frontend +- `VITE_API_URL` - Backend API URL + +## 🆘 Troubleshooting + +### Error: Connection refused +```bash +# Verificar que todos los servicios estén corriendo +docker-compose -f docker-compose.prod.yml ps + +# Verificar logs +docker-compose -f docker-compose.prod.yml logs backend +``` + +### Error: Database connection +```bash +# Verificar variables de entorno +docker exec syntria-backend-prod env | grep DATABASE_URL + +# Verificar que postgres esté healthy +docker inspect syntria-db-prod | grep Health +``` + +### Error: CORS +- Actualizar `ALLOWED_ORIGINS` en `.env.production` +- Incluir http:// y https:// si usas ambos + +## 📝 Usuarios por Defecto + +Después de `init_db.py`: +- **Admin:** admin / admin123 +- **Mecánico:** mechanic / mechanic123 + +⚠️ **Cambiar passwords en producción** + +## 🔄 Actualización de Versiones + +1. Hacer cambios en código +2. Ejecutar `build-and-push.ps1` con nueva versión +3. En servidor: `docker-compose pull && docker-compose up -d` + +## 📞 Soporte + +Para issues y preguntas: GitHub Issues diff --git a/DEPLOY_DOCKPLOY.md b/DEPLOY_DOCKPLOY.md deleted file mode 100644 index 0219d9a..0000000 --- a/DEPLOY_DOCKPLOY.md +++ /dev/null @@ -1,256 +0,0 @@ -# Guía de Deployment en Dockploy - -## Arquitectura de Conexión - -### Desarrollo (Docker Compose Local) -``` -Frontend (localhost:5173) → Backend (localhost:8000) → PostgreSQL (localhost:5432) -``` - -### Producción (Dockploy) -``` -Frontend (tudominio.com) → Backend (api.tudominio.com) → PostgreSQL (interno) -``` - ---- - -## 📋 Pasos para Deploy en Dockploy - -### 1. Preparar Variables de Entorno - -**Backend (.env)** -```bash -DATABASE_URL=postgresql://user:pass@postgres:5432/checklist_db -SECRET_KEY=tu-clave-secreta-minimo-32-caracteres-super-segura -OPENAI_API_KEY=tu-api-key-de-openai -ENVIRONMENT=production -ALLOWED_ORIGINS=https://tudominio.com,https://www.tudominio.com -``` - -**Frontend (.env)** -```bash -VITE_API_URL=https://api.tudominio.com -``` - -### 2. Actualizar CORS en Backend - -Editar `backend/app/main.py`: -```python -import os - -# CORS -allowed_origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:5173").split(",") -app.add_middleware( - CORSMiddleware, - allow_origins=allowed_origins, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) -``` - -### 3. Crear Dockerfile de Producción para Frontend - -Crear `frontend/Dockerfile.prod`: -```dockerfile -FROM node:18-alpine AS builder - -WORKDIR /app -COPY package*.json ./ -RUN npm ci --only=production - -COPY . . -ARG VITE_API_URL -ENV VITE_API_URL=$VITE_API_URL - -RUN npm run build - -# Servidor Nginx para servir archivos estáticos -FROM nginx:alpine -COPY --from=builder /app/dist /usr/share/nginx/html -COPY nginx.conf /etc/nginx/conf.d/default.conf -EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] -``` - -Crear `frontend/nginx.conf`: -```nginx -server { - listen 80; - server_name _; - root /usr/share/nginx/html; - index index.html; - - location / { - try_files $uri $uri/ /index.html; - } - - # Configuración de cache para assets - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { - expires 1y; - add_header Cache-Control "public, immutable"; - } -} -``` - -### 4. Estructura Recomendada en Dockploy - -**Opción A: Servicios Separados (Recomendado)** -- **Servicio 1**: PostgreSQL (Base de datos interna) -- **Servicio 2**: Backend API (api.tudominio.com) -- **Servicio 3**: Frontend (tudominio.com) - -**Opción B: Docker Compose en Dockploy** -Usar `docker-compose.prod.yml`: -```yaml -version: '3.8' - -services: - postgres: - image: postgres:15-alpine - environment: - POSTGRES_DB: ${POSTGRES_DB} - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - volumes: - - postgres_data:/var/lib/postgresql/data - networks: - - app-network - - backend: - build: ./backend - environment: - DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} - SECRET_KEY: ${SECRET_KEY} - OPENAI_API_KEY: ${OPENAI_API_KEY} - ENVIRONMENT: production - ALLOWED_ORIGINS: ${ALLOWED_ORIGINS} - ports: - - "8000:8000" - depends_on: - - postgres - networks: - - app-network - - frontend: - build: - context: ./frontend - dockerfile: Dockerfile.prod - args: - VITE_API_URL: ${VITE_API_URL} - ports: - - "80:80" - depends_on: - - backend - networks: - - app-network - -volumes: - postgres_data: - -networks: - app-network: - driver: bridge -``` - -### 5. Configuración de Dominios en Dockploy - -1. **Backend API**: - - Dominio: `api.tudominio.com` - - Puerto interno: `8000` - - Habilitar SSL/TLS - -2. **Frontend**: - - Dominio: `tudominio.com` - - Puerto interno: `80` - - Habilitar SSL/TLS - -3. **PostgreSQL**: - - Solo acceso interno (no exponer públicamente) - -### 6. Scripts de Migración - -Crear `backend/migrate.sh`: -```bash -#!/bin/bash -# Ejecutar migraciones en producción -python -c "from app.core.database import Base, engine; Base.metadata.create_all(bind=engine)" -``` - -### 7. Health Checks - -El backend ya tiene el endpoint de health. Dockploy puede monitorearlo: -``` -GET https://api.tudominio.com/docs -``` - ---- - -## 🔐 Seguridad en Producción - -1. **Variables de Entorno**: - - Usar secretos de Dockploy, NO hardcodear - - Cambiar `SECRET_KEY` a algo seguro (min 32 caracteres) - - No exponer `DATABASE_URL` públicamente - -2. **HTTPS**: - - Dockploy maneja SSL automáticamente con Let's Encrypt - - Asegurar que CORS solo acepte HTTPS en producción - -3. **Database**: - - NO exponer puerto 5432 públicamente - - Usar credenciales fuertes - - Configurar backups automáticos - ---- - -## 📝 Checklist Pre-Deploy - -- [ ] Cambiar `SECRET_KEY` en variables de entorno -- [ ] Configurar `VITE_API_URL` con dominio de producción -- [ ] Actualizar CORS con dominios de producción -- [ ] Crear Dockerfile.prod para frontend -- [ ] Configurar nginx.conf -- [ ] Probar build local: `docker-compose -f docker-compose.prod.yml up` -- [ ] Configurar dominios en Dockploy -- [ ] Habilitar SSL/TLS en ambos servicios -- [ ] Crear usuario admin inicial en producción -- [ ] Configurar backups de PostgreSQL - ---- - -## 🎯 Comandos Útiles - -**Build local de producción:** -```bash -docker-compose -f docker-compose.prod.yml build -docker-compose -f docker-compose.prod.yml up -d -``` - -**Ver logs:** -```bash -docker-compose -f docker-compose.prod.yml logs -f backend -docker-compose -f docker-compose.prod.yml logs -f frontend -``` - -**Crear usuario admin en producción:** -```bash -docker-compose -f docker-compose.prod.yml exec backend python -c " -from app.core.database import SessionLocal -from app.core.security import get_password_hash -from app.models import User - -db = SessionLocal() -admin = User( - username='admin', - email='admin@tudominio.com', - full_name='Administrador', - hashed_password=get_password_hash('tu-password-segura'), - role='admin', - is_active=True -) -db.add(admin) -db.commit() -print('Admin creado exitosamente') -" -``` diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..0cf03fd --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,24 @@ +# Backend .dockerignore +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +.venv/ +pip-log.txt +pip-delete-this-directory.txt +.pytest_cache/ +.coverage +htmlcov/ +*.log +.git +.gitignore +.dockerignore +Dockerfile +Dockerfile.prod +README.md +.env +.env.* +*.md diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod new file mode 100644 index 0000000..c2debc1 --- /dev/null +++ b/backend/Dockerfile.prod @@ -0,0 +1,27 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Instalar dependencias del sistema +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Copiar requirements +COPY requirements.txt . + +# Instalar dependencias de Python +RUN pip install --no-cache-dir -r requirements.txt + +# Copiar código +COPY . . + +# Crear directorio para uploads +RUN mkdir -p /app/uploads + +# Exponer puerto +EXPOSE 8000 + +# Comando de producción (múltiples workers) +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"] diff --git a/backend/add_questions.py b/backend/add_questions.py index f7a7ecc..5d25b87 100644 --- a/backend/add_questions.py +++ b/backend/add_questions.py @@ -1,22 +1,39 @@ """ Script para agregar preguntas a un checklist basado en el formato del PDF -Ejecutar: docker cp add_questions.py checklist-backend:/app/ && docker-compose exec -T backend python /app/add_questions.py +Ejecutar: docker-compose exec -T backend python /app/add_questions.py """ from app.core.database import SessionLocal -from app.models import Checklist, Question - -# ID del checklist al que quieres agregar preguntas -CHECKLIST_ID = 2 # Cambia este número según el ID de tu checklist +from app.models import Checklist, Question, User db = SessionLocal() -# Verificar que el checklist existe -checklist = db.query(Checklist).filter(Checklist.id == CHECKLIST_ID).first() -if not checklist: - print(f"❌ Checklist con ID {CHECKLIST_ID} no encontrado") +# Obtener el usuario admin +admin = db.query(User).filter(User.username == "admin").first() +if not admin: + print("❌ Usuario admin no encontrado") exit(1) +# Verificar si ya existe un checklist, si no, crearlo +checklist = db.query(Checklist).filter(Checklist.name == "Inspección Preventiva de Vehículos").first() +if not checklist: + print("📋 Creando checklist...") + checklist = Checklist( + name="Inspección Preventiva de Vehículos", + description="Checklist completo para inspección preventiva de vehículos", + ai_mode="off", + scoring_enabled=True, + created_by=admin.id + ) + db.add(checklist) + db.commit() + db.refresh(checklist) + print(f"✅ Checklist creado con ID: {checklist.id}") +else: + print(f"✅ Usando checklist existente: {checklist.name} (ID: {checklist.id})") + +CHECKLIST_ID = checklist.id + print(f"✅ Agregando preguntas al checklist: {checklist.name}") # Definir todas las preguntas por sección diff --git a/backend/app/main.py b/backend/app/main.py index a665f2b..c89aeea 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,6 @@ from datetime import datetime, timedelta from app.core.database import engine, get_db, Base from app.core.security import verify_password, get_password_hash, create_access_token, decode_access_token -from app.core.config import settings from app import models, schemas # Crear tablas @@ -17,18 +16,15 @@ Base.metadata.create_all(bind=engine) app = FastAPI(title="Checklist Inteligente API", version="1.0.0") -# CORS - Usar configuración de settings +# CORS app.add_middleware( CORSMiddleware, - allow_origins=settings.cors_origins, + allow_origins=["http://localhost:5173", "http://localhost:3000"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) -# Log para debug -print(f"🌐 CORS configured for origins: {settings.cors_origins}") - security = HTTPBearer() # Dependency para obtener usuario actual @@ -451,6 +447,428 @@ async def upload_photo( return media_file +# ============= AI ANALYSIS ============= +@app.get("/api/ai/models", response_model=List[schemas.AIModelInfo]) +def get_available_ai_models(current_user: models.User = Depends(get_current_user)): + """Obtener lista de modelos de IA disponibles""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="Solo administradores pueden ver modelos de IA") + + models_list = [ + # OpenAI Models + { + "id": "gpt-4o", + "name": "GPT-4o (Recomendado)", + "provider": "openai", + "description": "Modelo multimodal más avanzado de OpenAI, rápido y preciso para análisis de imágenes" + }, + { + "id": "gpt-4o-mini", + "name": "GPT-4o Mini", + "provider": "openai", + "description": "Versión compacta y económica de GPT-4o, ideal para análisis rápidos" + }, + { + "id": "gpt-4-turbo", + "name": "GPT-4 Turbo", + "provider": "openai", + "description": "Modelo potente con capacidades de visión y contexto amplio" + }, + { + "id": "gpt-4-vision-preview", + "name": "GPT-4 Vision (Preview)", + "provider": "openai", + "description": "Modelo específico para análisis de imágenes (versión previa)" + }, + # Gemini Models - Actualizados a versiones 2.0, 2.5 y 3.0 + { + "id": "gemini-3-pro-preview", + "name": "Gemini 3 Pro Preview (Último)", + "provider": "gemini", + "description": "Modelo de próxima generación en preview, máxima capacidad de análisis" + }, + { + "id": "gemini-2.5-pro", + "name": "Gemini 2.5 Pro (Recomendado)", + "provider": "gemini", + "description": "Último modelo estable con excelente análisis visual y razonamiento avanzado" + }, + { + "id": "gemini-2.5-flash", + "name": "Gemini 2.5 Flash", + "provider": "gemini", + "description": "Versión rápida del 2.5, ideal para inspecciones en tiempo real" + }, + { + "id": "gemini-2.0-flash", + "name": "Gemini 2.0 Flash", + "provider": "gemini", + "description": "Modelo rápido y eficiente de la generación 2.0" + }, + { + "id": "gemini-1.5-pro-latest", + "name": "Gemini 1.5 Pro Latest", + "provider": "gemini", + "description": "Versión estable 1.5 con contexto de 2M tokens" + }, + { + "id": "gemini-1.5-flash-latest", + "name": "Gemini 1.5 Flash Latest", + "provider": "gemini", + "description": "Modelo 1.5 rápido para análisis básicos" + } + ] + + return models_list + + +@app.get("/api/ai/configuration", response_model=schemas.AIConfiguration) +def get_ai_configuration( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """Obtener configuración de IA actual""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="Solo administradores pueden ver configuración de IA") + + config = db.query(models.AIConfiguration).filter( + models.AIConfiguration.is_active == True + ).first() + + if not config: + raise HTTPException(status_code=404, detail="No hay configuración de IA activa") + + return config + + +@app.post("/api/ai/configuration", response_model=schemas.AIConfiguration) +def create_ai_configuration( + config: schemas.AIConfigurationCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """Crear o actualizar configuración de IA""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="Solo administradores pueden configurar IA") + + # Desactivar configuraciones anteriores + db.query(models.AIConfiguration).update({"is_active": False}) + + # Crear nueva configuración + new_config = models.AIConfiguration( + provider=config.provider, + api_key=config.api_key, + model_name=config.model_name, + is_active=True + ) + + db.add(new_config) + db.commit() + db.refresh(new_config) + + return new_config + + +@app.put("/api/ai/configuration/{config_id}", response_model=schemas.AIConfiguration) +def update_ai_configuration( + config_id: int, + config_update: schemas.AIConfigurationUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """Actualizar configuración de IA existente""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="Solo administradores pueden actualizar configuración de IA") + + config = db.query(models.AIConfiguration).filter( + models.AIConfiguration.id == config_id + ).first() + + if not config: + raise HTTPException(status_code=404, detail="Configuración no encontrada") + + # Actualizar campos + for key, value in config_update.dict(exclude_unset=True).items(): + setattr(config, key, value) + + db.commit() + db.refresh(config) + + return config + + +@app.delete("/api/ai/configuration/{config_id}") +def delete_ai_configuration( + config_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """Eliminar configuración de IA""" + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="Solo administradores pueden eliminar configuración de IA") + + config = db.query(models.AIConfiguration).filter( + models.AIConfiguration.id == config_id + ).first() + + if not config: + raise HTTPException(status_code=404, detail="Configuración no encontrada") + + db.delete(config) + db.commit() + + return {"message": "Configuración eliminada correctamente"} + + +@app.post("/api/analyze-image") +async def analyze_image( + file: UploadFile = File(...), + question_id: int = None, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """ + Analiza una imagen usando IA para sugerir respuestas + Usa la configuración de IA activa (OpenAI o Gemini) + """ + # Obtener configuración de IA activa + ai_config = db.query(models.AIConfiguration).filter( + models.AIConfiguration.is_active == True + ).first() + + if not ai_config: + return { + "status": "disabled", + "message": "No hay configuración de IA activa. Configure en Settings." + } + + # Guardar imagen temporalmente + import base64 + + contents = await file.read() + image_b64 = base64.b64encode(contents).decode('utf-8') + + # Obtener contexto de la pregunta si se proporciona + question_obj = None + if question_id: + question_obj = db.query(models.Question).filter(models.Question.id == question_id).first() + + try: + # Construir prompt dinámico basado en la pregunta específica + if question_obj: + # Prompt altamente específico para la pregunta + question_text = question_obj.text + question_type = question_obj.type + section = question_obj.section + + system_prompt = f"""Eres un mecánico experto realizando una inspección vehicular. + +PREGUNTA ESPECÍFICA A RESPONDER: "{question_text}" +Sección: {section} + +Analiza la imagen ÚNICAMENTE para responder esta pregunta específica. +Sé directo y enfócate solo en lo que la pregunta solicita. + +Responde en formato JSON: +{{ + "status": "ok|minor|critical", + "observations": "Respuesta específica a: {question_text}", + "recommendation": "Acción si aplica", + "confidence": 0.0-1.0 +}} + +IMPORTANTE: +- Responde SOLO lo que la pregunta pide +- No des información genérica del vehículo +- Sé específico y técnico +- Si la pregunta es pass/fail, indica claramente si pasa o falla +- Si la pregunta es bueno/regular/malo, indica el estado específico del componente""" + + user_message = f"Inspecciona la imagen y responde específicamente: {question_text}" + else: + # Fallback para análisis general + system_prompt = """Eres un experto mecánico automotriz. Analiza la imagen y proporciona: +1. Estado del componente (bueno/regular/malo) +2. Nivel de criticidad (ok/minor/critical) +3. Observaciones técnicas breves +4. Recomendación de acción + +Responde en formato JSON: +{ + "status": "ok|minor|critical", + "observations": "descripción técnica", + "recommendation": "acción sugerida", + "confidence": 0.0-1.0 +}""" + user_message = "Analiza este componente del vehículo para la inspección general." + + if ai_config.provider == "openai": + import openai + openai.api_key = ai_config.api_key + + response = openai.ChatCompletion.create( + model=ai_config.model_name, + messages=[ + {"role": "system", "content": system_prompt}, + { + "role": "user", + "content": [ + { + "type": "text", + "text": user_message + }, + { + "type": "image_url", + "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"} + } + ] + } + ], + max_tokens=500 + ) + + ai_response = response.choices[0].message.content + + elif ai_config.provider == "gemini": + import google.generativeai as genai + from PIL import Image + from io import BytesIO + + genai.configure(api_key=ai_config.api_key) + model = genai.GenerativeModel(ai_config.model_name) + + # Convertir base64 a imagen PIL + image = Image.open(BytesIO(contents)) + + prompt = f"{system_prompt}\n\n{user_message}" + response = model.generate_content([prompt, image]) + ai_response = response.text + + else: + return { + "success": False, + "error": f"Provider {ai_config.provider} no soportado" + } + + # Intentar parsear como JSON, si falla, usar texto plano + try: + import json + import re + + # Limpiar markdown code blocks si existen + cleaned_response = ai_response.strip() + + # Remover ```json ... ``` si existe + if cleaned_response.startswith('```'): + # Extraer contenido entre ``` markers + match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', cleaned_response, re.DOTALL) + if match: + cleaned_response = match.group(1).strip() + + analysis = json.loads(cleaned_response) + except: + # Si no es JSON válido, crear estructura básica + analysis = { + "status": "ok", + "observations": ai_response, + "recommendation": "Revisar manualmente", + "confidence": 0.7 + } + + return { + "success": True, + "analysis": analysis, + "raw_response": ai_response, + "model": ai_config.model_name, + "provider": ai_config.provider + } + + except Exception as e: + print(f"Error en análisis AI: {e}") + import traceback + traceback.print_exc() + return { + "success": False, + "error": str(e), + "message": "Error analyzing image with AI. Please check AI configuration in Settings." + } + + try: + import openai + openai.api_key = settings.OPENAI_API_KEY + + # Prompt especializado para inspección vehicular + system_prompt = """Eres un experto mecánico automotriz. Analiza la imagen y proporciona: +1. Estado del componente (bueno/regular/malo) +2. Nivel de criticidad (ok/minor/critical) +3. Observaciones técnicas breves +4. Recomendación de acción + +Responde en formato JSON: +{ + "status": "ok|minor|critical", + "observations": "descripción técnica", + "recommendation": "acción sugerida", + "confidence": 0.0-1.0 +}""" + + response = openai.ChatCompletion.create( + model="gpt-4-vision-preview" if "gpt-4" in str(settings.OPENAI_API_KEY) else "gpt-4o", + messages=[ + { + "role": "system", + "content": system_prompt + }, + { + "role": "user", + "content": [ + { + "type": "text", + "text": f"Analiza este componente del vehículo.\n{question_context}" + }, + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{image_b64}" + } + } + ] + } + ], + max_tokens=500 + ) + + ai_response = response.choices[0].message.content + + # Intentar parsear como JSON, si falla, usar texto plano + try: + import json + analysis = json.loads(ai_response) + except: + # Si no es JSON válido, crear estructura básica + analysis = { + "status": "ok", + "observations": ai_response, + "recommendation": "Revisar manualmente", + "confidence": 0.7 + } + + return { + "success": True, + "analysis": analysis, + "raw_response": ai_response, + "model": "gpt-4-vision" + } + + except Exception as e: + print(f"Error en análisis AI: {e}") + return { + "success": False, + "error": str(e), + "message": "Error analyzing image with AI" + } + + # ============= HEALTH CHECK ============= @app.get("/") def root(): diff --git a/backend/app/models.py b/backend/app/models.py index 6dc8058..f51452c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -144,3 +144,15 @@ class MediaFile(Base): # Relationships answer = relationship("Answer", back_populates="media_files") + + +class AIConfiguration(Base): + __tablename__ = "ai_configurations" + + id = Column(Integer, primary_key=True, index=True) + provider = Column(String(50), nullable=False) # openai, gemini + api_key = Column(Text, nullable=False) + model_name = Column(String(100), nullable=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) diff --git a/backend/app/models_ai.py b/backend/app/models_ai.py new file mode 100644 index 0000000..b5dc4b2 --- /dev/null +++ b/backend/app/models_ai.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text +from sqlalchemy.sql import func +from app.core.database import Base + +class AIConfiguration(Base): + __tablename__ = "ai_configurations" + + id = Column(Integer, primary_key=True, index=True) + provider = Column(String(50), nullable=False) # openai, gemini + api_key = Column(Text, nullable=False) + model_name = Column(String(100), nullable=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index bd90f1e..d9be0e0 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -175,3 +175,33 @@ class InspectionDetail(Inspection): checklist: ChecklistWithQuestions mechanic: User answers: List[AnswerWithMedia] = [] + + +# AI Configuration Schemas +class AIConfigurationBase(BaseModel): + provider: str # openai, gemini + api_key: str + model_name: str + +class AIConfigurationCreate(AIConfigurationBase): + pass + +class AIConfigurationUpdate(BaseModel): + provider: Optional[str] = None + api_key: Optional[str] = None + model_name: Optional[str] = None + is_active: Optional[bool] = None + +class AIConfiguration(AIConfigurationBase): + id: int + is_active: bool + created_at: datetime + + class Config: + from_attributes = True + +class AIModelInfo(BaseModel): + id: str + name: str + provider: str + description: Optional[str] = None diff --git a/backend/init_db.py b/backend/init_db.py new file mode 100644 index 0000000..a6b4b5f --- /dev/null +++ b/backend/init_db.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +""" +Script para inicializar la base de datos con usuarios de prueba +""" +from app.core.database import SessionLocal, engine, Base +from app.core.security import get_password_hash +from app.models import User +from sqlalchemy import text + +def init_db(): + db = SessionLocal() + + try: + # Verificar conexión + db.execute(text("SELECT 1")) + print("✓ Conexión a la base de datos exitosa") + + # Verificar si ya existen usuarios + existing_users = db.query(User).count() + if existing_users > 0: + print(f"⚠ Ya existen {existing_users} usuario(s) en la base de datos") + return + + # Crear usuario administrador + admin = User( + username="admin", + email="admin@checklist.com", + full_name="Administrador", + password_hash=get_password_hash("admin123"), + role="admin", + is_active=True + ) + db.add(admin) + + # Crear usuario mecánico de prueba + mechanic = User( + username="mechanic", + email="mechanic@checklist.com", + full_name="Mecánico de Prueba", + password_hash=get_password_hash("mechanic123"), + role="mechanic", + is_active=True + ) + db.add(mechanic) + + db.commit() + + print("✓ Usuarios creados exitosamente:") + print(" - Admin: username='admin', password='admin123'") + print(" - Mechanic: username='mechanic', password='mechanic123'") + + except Exception as e: + print(f"✗ Error al inicializar la base de datos: {e}") + db.rollback() + finally: + db.close() + +if __name__ == "__main__": + print("Inicializando base de datos...") + init_db() + print("¡Inicialización completada!") diff --git a/backend/requirements.txt b/backend/requirements.txt index 8dd08ef..2347e38 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,6 +11,7 @@ passlib==1.7.4 bcrypt==4.0.1 python-multipart==0.0.6 openai==1.10.0 +google-generativeai==0.3.2 Pillow==10.2.0 reportlab==4.0.9 python-dotenv==1.0.0 diff --git a/build-and-push.ps1 b/build-and-push.ps1 new file mode 100644 index 0000000..b3deb47 --- /dev/null +++ b/build-and-push.ps1 @@ -0,0 +1,87 @@ +# Script PowerShell para construir y publicar imagenes de Syntria a Docker Hub + +$ErrorActionPreference = "Stop" + +Write-Host "🚀 Syntria - Build & Push to Docker Hub" -ForegroundColor Green +Write-Host "==========================================" -ForegroundColor Green +Write-Host "" + +# Verificar que estamos en el directorio correcto +if (-not (Test-Path "docker-compose.yml")) { + Write-Host "❌ Error: Ejecuta este script desde el directorio raiz del proyecto" -ForegroundColor Red + exit 1 +} + +# Leer variables de entorno +if (Test-Path ".env.production") { + Get-Content ".env.production" | ForEach-Object { + if ($_ -match "^([^#][^=]+)=(.*)$") { + Set-Item -Path "env:$($matches[1])" -Value $matches[2] + } + } +} + +# Verificar Docker Hub username +if (-not $env:DOCKER_USERNAME) { + $env:DOCKER_USERNAME = Read-Host "Ingresa tu username de Docker Hub" +} + +# Verificar version +$version = Read-Host "Version a construir (default: latest)" +if ([string]::IsNullOrWhiteSpace($version)) { + $version = "latest" +} + +Write-Host "" +Write-Host "Configuracion:" -ForegroundColor Green +Write-Host " Docker Hub User: $env:DOCKER_USERNAME" +Write-Host " Version: $version" +Write-Host "" + +# Confirmar +$confirm = Read-Host "Continuar? (y/n)" +if ($confirm -ne "y") { + Write-Host "Cancelado" -ForegroundColor Yellow + exit 0 +} + +# Login a Docker Hub +Write-Host "" +Write-Host "📦 Iniciando sesion en Docker Hub..." -ForegroundColor Green +docker login + +# Build Backend +Write-Host "" +Write-Host "🔨 Construyendo Backend..." -ForegroundColor Green +docker build -t "$env:DOCKER_USERNAME/syntria-backend:$version" -f backend/Dockerfile.prod backend/ +docker tag "$env:DOCKER_USERNAME/syntria-backend:$version" "$env:DOCKER_USERNAME/syntria-backend:latest" + +# Build Frontend +Write-Host "" +Write-Host "🔨 Construyendo Frontend..." -ForegroundColor Green +docker build -t "$env:DOCKER_USERNAME/syntria-frontend:$version" -f frontend/Dockerfile.prod frontend/ +docker tag "$env:DOCKER_USERNAME/syntria-frontend:$version" "$env:DOCKER_USERNAME/syntria-frontend:latest" + +# Push images +Write-Host "" +Write-Host "⬆️ Subiendo imagenes a Docker Hub..." -ForegroundColor Green +docker push "$env:DOCKER_USERNAME/syntria-backend:$version" +docker push "$env:DOCKER_USERNAME/syntria-backend:latest" +docker push "$env:DOCKER_USERNAME/syntria-frontend:$version" +docker push "$env:DOCKER_USERNAME/syntria-frontend:latest" + +Write-Host "" +Write-Host "✅ Imagenes publicadas exitosamente!" -ForegroundColor Green +Write-Host "" +Write-Host "Imagenes disponibles:" +Write-Host " 🐳 $env:DOCKER_USERNAME/syntria-backend:$version" +Write-Host " 🐳 $env:DOCKER_USERNAME/syntria-backend:latest" +Write-Host " 🐳 $env:DOCKER_USERNAME/syntria-frontend:$version" +Write-Host " 🐳 $env:DOCKER_USERNAME/syntria-frontend:latest" +Write-Host "" +Write-Host "Proximos pasos:" -ForegroundColor Yellow +Write-Host "1. Actualiza DOCKER_USERNAME en .env.production" +Write-Host "2. En tu servidor de produccion, ejecuta:" +Write-Host " docker-compose -f docker-compose.prod.yml --env-file .env.production up -d" + + diff --git a/build-and-push.sh b/build-and-push.sh new file mode 100644 index 0000000..ff32a34 --- /dev/null +++ b/build-and-push.sh @@ -0,0 +1,88 @@ +#!/bin/bash + +# Script para construir y publicar imágenes de Syntria a Docker Hub + +set -e + +# Colores para output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}🚀 Syntria - Build & Push to Docker Hub${NC}" +echo "==========================================" + +# Verificar que estamos en el directorio correcto +if [ ! -f "docker-compose.yml" ]; then + echo -e "${RED}❌ Error: Ejecuta este script desde el directorio raíz del proyecto${NC}" + exit 1 +fi + +# Leer variables de entorno +if [ -f ".env.production" ]; then + export $(cat .env.production | grep -v '^#' | xargs) +fi + +# Verificar Docker Hub username +if [ -z "$DOCKER_USERNAME" ]; then + echo -e "${YELLOW}⚠️ DOCKER_USERNAME no está configurado en .env.production${NC}" + read -p "Ingresa tu username de Docker Hub: " DOCKER_USERNAME +fi + +# Verificar versión +read -p "Versión a construir (default: latest): " VERSION +VERSION=${VERSION:-latest} + +echo "" +echo -e "${GREEN}Configuración:${NC}" +echo " Docker Hub User: $DOCKER_USERNAME" +echo " Versión: $VERSION" +echo "" + +# Confirmar +read -p "¿Continuar? (y/n) " -n 1 -r +echo +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo -e "${YELLOW}Cancelado${NC}" + exit 1 +fi + +# Login a Docker Hub +echo "" +echo -e "${GREEN}📦 Iniciando sesión en Docker Hub...${NC}" +docker login + +# Build Backend +echo "" +echo -e "${GREEN}🔨 Construyendo Backend...${NC}" +docker build -t $DOCKER_USERNAME/syntria-backend:$VERSION -f backend/Dockerfile.prod backend/ +docker tag $DOCKER_USERNAME/syntria-backend:$VERSION $DOCKER_USERNAME/syntria-backend:latest + +# Build Frontend +echo "" +echo -e "${GREEN}🔨 Construyendo Frontend...${NC}" +docker build -t $DOCKER_USERNAME/syntria-frontend:$VERSION -f frontend/Dockerfile.prod frontend/ +docker tag $DOCKER_USERNAME/syntria-frontend:$VERSION $DOCKER_USERNAME/syntria-frontend:latest + +# Push images +echo "" +echo -e "${GREEN}⬆️ Subiendo imágenes a Docker Hub...${NC}" +docker push $DOCKER_USERNAME/syntria-backend:$VERSION +docker push $DOCKER_USERNAME/syntria-backend:latest +docker push $DOCKER_USERNAME/syntria-frontend:$VERSION +docker push $DOCKER_USERNAME/syntria-frontend:latest + +echo "" +echo -e "${GREEN}✅ ¡Imágenes publicadas exitosamente!${NC}" +echo "" +echo "Imágenes disponibles:" +echo " 🐳 $DOCKER_USERNAME/syntria-backend:$VERSION" +echo " 🐳 $DOCKER_USERNAME/syntria-backend:latest" +echo " 🐳 $DOCKER_USERNAME/syntria-frontend:$VERSION" +echo " 🐳 $DOCKER_USERNAME/syntria-frontend:latest" +echo "" +echo -e "${YELLOW}Próximos pasos:${NC}" +echo "1. Actualiza DOCKER_USERNAME en .env.production" +echo "2. En tu servidor de producción, ejecuta:" +echo " docker-compose -f docker-compose.prod.yml --env-file .env.production up -d" diff --git a/docker-compose.hub.yml b/docker-compose.hub.yml new file mode 100644 index 0000000..aaf0683 --- /dev/null +++ b/docker-compose.hub.yml @@ -0,0 +1,56 @@ +version: '3.8' + +services: + db: + image: postgres:15 + container_name: syntria-db-prod + restart: always + environment: + POSTGRES_DB: syntria_db + POSTGRES_USER: syntria_user + POSTGRES_PASSWORD: syntria_secure_2024 + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - syntria-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U syntria_user -d syntria_db"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + image: dymai/syntria-backend:latest + container_name: syntria-backend-prod + restart: always + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgresql://syntria_user:syntria_secure_2024@db:5432/syntria_db + SECRET_KEY: tu_clave_secreta_super_segura_minimo_32_caracteres_prod + OPENAI_API_KEY: tu_api_key_de_openai + GEMINI_API_KEY: tu_api_key_de_gemini + ENVIRONMENT: production + ALLOWED_ORIGINS: http://localhost,http://localhost:80 + networks: + - syntria-network + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 + + frontend: + image: dymai/syntria-frontend:latest + container_name: syntria-frontend-prod + restart: always + depends_on: + - backend + ports: + - "80:80" + networks: + - syntria-network + +networks: + syntria-network: + driver: bridge + +volumes: + postgres_data: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e3d6612..3453500 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,64 +1,63 @@ -version: '3.8' - services: postgres: image: postgres:15-alpine - container_name: checklist-db-prod + container_name: syntria-db-prod environment: - POSTGRES_DB: ${POSTGRES_DB:-checklist_db} - POSTGRES_USER: ${POSTGRES_USER:-checklist_user} + POSTGRES_DB: ${POSTGRES_DB:-syntria_db} + POSTGRES_USER: ${POSTGRES_USER:-syntria_user} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + ports: + - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data - networks: - - app-network - restart: unless-stopped + - ./init-db.sh:/docker-entrypoint-initdb.d/init-db.sh healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-checklist_user}"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-syntria_user} -d ${POSTGRES_DB:-syntria_db}"] interval: 10s timeout: 5s retries: 5 + restart: always + networks: + - syntria-network backend: - build: - context: ./backend - dockerfile: Dockerfile - container_name: checklist-backend-prod + image: ${DOCKER_USERNAME}/syntria-backend:latest + container_name: syntria-backend-prod environment: - DATABASE_URL: postgresql://${POSTGRES_USER:-checklist_user}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-checklist_db} + DATABASE_URL: postgresql://${POSTGRES_USER:-syntria_user}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-syntria_db} SECRET_KEY: ${SECRET_KEY} OPENAI_API_KEY: ${OPENAI_API_KEY} ENVIRONMENT: production - ALLOWED_ORIGINS: ${ALLOWED_ORIGINS} + ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost,http://localhost:5173} ports: - "8000:8000" volumes: - - ./uploads:/app/uploads + - uploads_data:/app/uploads depends_on: postgres: condition: service_healthy + restart: always networks: - - app-network - restart: unless-stopped + - syntria-network + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 frontend: - build: - context: ./frontend - dockerfile: Dockerfile.prod - args: - VITE_API_URL: ${VITE_API_URL} - container_name: checklist-frontend-prod + image: ${DOCKER_USERNAME}/syntria-frontend:latest + container_name: syntria-frontend-prod ports: - "80:80" + environment: + - VITE_API_URL=${API_URL:-http://localhost:8000} + restart: always + networks: + - syntria-network depends_on: - backend - networks: - - app-network - restart: unless-stopped volumes: postgres_data: + uploads_data: networks: - app-network: + syntria-network: driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index 6db77ca..16ba8d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,19 +1,18 @@ -version: '3.8' - services: postgres: image: postgres:15-alpine container_name: checklist-db environment: - POSTGRES_DB: checklist_db - POSTGRES_USER: checklist_user - POSTGRES_PASSWORD: checklist_pass_2024 + POSTGRES_DB: ${POSTGRES_DB:-checklist_db} + POSTGRES_USER: ${POSTGRES_USER:-checklist_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-checklist_pass_2024} ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data + - ./init-db.sh:/docker-entrypoint-initdb.d/init-db.sh healthcheck: - test: ["CMD-SHELL", "pg_isready -U checklist_user"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-checklist_user} -d ${POSTGRES_DB:-checklist_db}"] interval: 10s timeout: 5s retries: 5 @@ -22,10 +21,10 @@ services: build: ./backend container_name: checklist-backend environment: - DATABASE_URL: postgresql://checklist_user:checklist_pass_2024@postgres:5432/checklist_db + DATABASE_URL: postgresql://${POSTGRES_USER:-checklist_user}:${POSTGRES_PASSWORD:-checklist_pass_2024}@postgres:5432/${POSTGRES_DB:-checklist_db} SECRET_KEY: ${SECRET_KEY:-your-secret-key-change-in-production-min-32-chars} OPENAI_API_KEY: ${OPENAI_API_KEY} - ENVIRONMENT: development + ENVIRONMENT: ${ENVIRONMENT:-development} ports: - "8000:8000" volumes: diff --git a/docker-stack.yml b/docker-stack.yml new file mode 100644 index 0000000..328fe18 --- /dev/null +++ b/docker-stack.yml @@ -0,0 +1,99 @@ +version: "3.8" + +services: + db: + image: postgres:15 + environment: + POSTGRES_DB: syntria_db + POSTGRES_USER: syntria_user + POSTGRES_PASSWORD: syntria_secure_2024 + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - syntria_network + deploy: + mode: replicated + replicas: 1 + placement: + constraints: [node.role == manager] + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 3 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U syntria_user -d syntria_db"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + image: dymai/syntria-backend:latest + environment: + DATABASE_URL: postgresql://syntria_user:syntria_secure_2024@db:5432/syntria_db + SECRET_KEY: tu_clave_secreta_super_segura_minimo_32_caracteres_prod + OPENAI_API_KEY: tu_api_key_de_openai + GEMINI_API_KEY: tu_api_key_de_gemini + ENVIRONMENT: production + ALLOWED_ORIGINS: http://localhost,https://syntria.tudominio.com + networks: + - syntria_network + - network_public + deploy: + mode: replicated + replicas: 2 + update_config: + parallelism: 1 + delay: 10s + order: start-first + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 3 + labels: + - "traefik.enable=true" + - "traefik.docker.network=network_public" + - "traefik.http.routers.syntria-api.rule=Host(`syntria.tudominio.com`) && PathPrefix(`/api`)" + - "traefik.http.routers.syntria-api.entrypoints=websecure" + - "traefik.http.routers.syntria-api.tls.certresolver=letsencryptresolver" + - "traefik.http.routers.syntria-api.service=syntria-api" + - "traefik.http.services.syntria-api.loadbalancer.server.port=8000" + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4 + + frontend: + image: dymai/syntria-frontend:latest + networks: + - syntria_network + - network_public + deploy: + mode: replicated + replicas: 2 + update_config: + parallelism: 1 + delay: 10s + order: start-first + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 3 + labels: + - "traefik.enable=true" + - "traefik.docker.network=network_public" + - "traefik.http.routers.syntria-web.rule=Host(`syntria.tudominio.com`)" + - "traefik.http.routers.syntria-web.entrypoints=websecure" + - "traefik.http.routers.syntria-web.priority=1" + - "traefik.http.routers.syntria-web.tls.certresolver=letsencryptresolver" + - "traefik.http.routers.syntria-web.service=syntria-web" + - "traefik.http.services.syntria-web.loadbalancer.server.port=80" + +networks: + syntria_network: + driver: overlay + attachable: true + network_public: + external: true + attachable: true + name: network_public + +volumes: + postgres_data: + driver: local diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..a05734e --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,16 @@ +# Frontend .dockerignore +node_modules +dist +.git +.gitignore +.dockerignore +Dockerfile +Dockerfile.prod +README.md +.env +.env.* +*.md +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.DS_Store diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod index f9b37ac..dd504d0 100644 --- a/frontend/Dockerfile.prod +++ b/frontend/Dockerfile.prod @@ -1,3 +1,4 @@ +# Build stage FROM node:18-alpine AS builder WORKDIR /app @@ -5,26 +6,19 @@ WORKDIR /app # Copiar package files COPY package*.json ./ -# Instalar todas las dependencias (necesitamos las dev para el build) +# Instalar dependencias RUN npm install -# Copiar código +# Copiar código fuente COPY . . -# Build argument para la URL de la API -ARG VITE_API_URL=http://checklist-rons-0e8a3a-63dbc4-72-61-106-199.traefik.me -ENV VITE_API_URL=$VITE_API_URL - -# Mostrar la URL que se está usando (para debug) -RUN echo "Building with VITE_API_URL=${VITE_API_URL}" - -# Construir la aplicación +# Build de producción RUN npm run build -# Etapa de producción con Nginx +# Production stage FROM nginx:alpine -# Copiar archivos construidos +# Copiar build al nginx COPY --from=builder /app/dist /usr/share/nginx/html # Copiar configuración de nginx @@ -33,5 +27,5 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf # Exponer puerto 80 EXPOSE 80 -# Comando para iniciar nginx +# Comando por defecto CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html index 8871bae..99a1d1b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,8 @@ - Checklist Inteligente - Sistema de Inspecciones + Syntria - Sistema Inteligente de Inspecciones +
diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 4873d00..fb3a476 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,7 +1,7 @@ server { listen 80; server_name _; - + root /usr/share/nginx/html; index index.html; @@ -11,24 +11,30 @@ server { gzip_min_length 1024; gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript; - # SPA routing - todas las rutas van a index.html + # Proxy al backend + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # SPA fallback location / { try_files $uri $uri/ /index.html; } - # Cache para assets estáticos + # Cache static assets location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; add_header Cache-Control "public, immutable"; } - # No cache para index.html - location = /index.html { - add_header Cache-Control "no-cache, no-store, must-revalidate"; - add_header Pragma "no-cache"; - add_header Expires 0; - } - # Security headers add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 424c9e5..0ac2732 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -52,7 +52,7 @@ function LoginPage({ setUser }) { setLoading(true) try { - const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' + const API_URL = import.meta.env.VITE_API_URL || '' const response = await fetch(`${API_URL}/api/auth/login`, { method: 'POST', headers: { @@ -81,63 +81,85 @@ function LoginPage({ setUser }) { } return ( -
-
-
-

Checklist Inteligente

-

Sistema de Inspecciones

+
+
+ {/* Header con Logo */} +
+
+ {/* Logo S de Syntria */} +
+ + + + + + + + + +
+
+

Syntria

+

Sistema Inteligente de Inspecciones

-
-
- - setUsername(e.target.value)} - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" - placeholder="admin" - required - /> -
- -
- - setPassword(e.target.value)} - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" - placeholder="••••••••" - required - /> -
- - {error && ( -
- {error} + {/* Formulario */} +
+ +
+ + setUsername(e.target.value)} + className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" + placeholder="Ingresa tu usuario" + required + />
- )} - - +
+ + setPassword(e.target.value)} + className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" + placeholder="••••••••" + required + /> +
-
-

Usuarios de prueba:

-
-

Admin: admin / admin123

-

Mecánico: mecanico1 / mecanico123

-
+ {error && ( +
+ ⚠️ + {error} +
+ )} + + +
@@ -160,10 +182,17 @@ function DashboardPage({ user, setUser }) { const loadData = async () => { try { const token = localStorage.getItem('token') - const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' + const API_URL = import.meta.env.VITE_API_URL || '' console.log('Token:', token ? 'exists' : 'missing') + if (!token) { + console.warn('No token found, redirecting to login') + setUser(null) + setLoading(false) + return + } + // Cargar checklists const checklistsRes = await fetch(`${API_URL}/api/checklists?active_only=true`, { headers: { @@ -173,6 +202,15 @@ function DashboardPage({ user, setUser }) { console.log('Checklists response:', checklistsRes.status) + if (checklistsRes.status === 401) { + console.warn('Token expired or invalid, logging out') + localStorage.removeItem('token') + localStorage.removeItem('user') + setUser(null) + setLoading(false) + return + } + if (checklistsRes.ok) { const checklistsData = await checklistsRes.json() console.log('Checklists data:', checklistsData) @@ -216,7 +254,7 @@ function DashboardPage({ user, setUser }) { } return ( -
+
{/* Sidebar */} {/* Header */} -
+
-

- {activeTab === 'checklists' && 'Checklists'} - {activeTab === 'inspections' && 'Inspecciones'} - {activeTab === 'users' && 'Usuarios'} - {activeTab === 'reports' && 'Reportes'} -

+
+ {/* Logo y Nombre del Sistema */} +
+
+ + + + + + + + + +
+
+

Syntria

+

Sistema Inteligente de Inspecciones

+
+
+ + {/* Sección Activa */} +
+ + {activeTab === 'checklists' && '📋'} + {activeTab === 'inspections' && '🔍'} + {activeTab === 'users' && '👥'} + {activeTab === 'reports' && '📊'} + {activeTab === 'settings' && '⚙️'} + + + {activeTab === 'checklists' && 'Checklists'} + {activeTab === 'inspections' && 'Inspecciones'} + {activeTab === 'users' && 'Usuarios'} + {activeTab === 'reports' && 'Reportes'} + {activeTab === 'settings' && 'Configuración'} + +
+
{/* Content */}
-
+
{loading ? (
@@ -258,6 +332,8 @@ function DashboardPage({ user, setUser }) { /> ) : activeTab === 'inspections' ? ( + ) : activeTab === 'settings' ? ( + ) : activeTab === 'users' ? (
👥
@@ -290,8 +366,553 @@ function DashboardPage({ user, setUser }) { ) } +function SettingsTab({ user }) { + const [aiConfig, setAiConfig] = useState(null) + const [availableModels, setAvailableModels] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [formData, setFormData] = useState({ + provider: 'openai', + api_key: '', + model_name: 'gpt-4o' + }) + + useEffect(() => { + loadSettings() + }, []) + + const loadSettings = async () => { + try { + const token = localStorage.getItem('token') + const API_URL = import.meta.env.VITE_API_URL || '' + + // Cargar modelos disponibles + const modelsRes = await fetch(`${API_URL}/api/ai/models`, { + headers: { 'Authorization': `Bearer ${token}` } + }) + + if (modelsRes.ok) { + const models = await modelsRes.json() + setAvailableModels(models) + } + + // Cargar configuración actual + const configRes = await fetch(`${API_URL}/api/ai/configuration`, { + headers: { 'Authorization': `Bearer ${token}` } + }) + + if (configRes.ok) { + const config = await configRes.json() + setAiConfig(config) + setFormData({ + provider: config.provider, + api_key: config.api_key, + model_name: config.model_name + }) + } + + setLoading(false) + } catch (error) { + console.error('Error loading settings:', error) + setLoading(false) + } + } + + const handleSave = async (e) => { + e.preventDefault() + setSaving(true) + + try { + const token = localStorage.getItem('token') + const API_URL = import.meta.env.VITE_API_URL || '' + + const response = await fetch(`${API_URL}/api/ai/configuration`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(formData), + }) + + if (response.ok) { + alert('Configuración guardada correctamente') + loadSettings() + } else { + alert('Error al guardar configuración') + } + } catch (error) { + console.error('Error:', error) + alert('Error al guardar configuración') + } finally { + setSaving(false) + } + } + + const filteredModels = availableModels.filter(m => m.provider === formData.provider) + + return ( +
+
+

Configuración de IA

+

+ Configura el proveedor y modelo de IA para análisis de imágenes +

+
+ + {loading ? ( +
+
Cargando configuración...
+
+ ) : ( +
+ {/* Provider Selection */} +
+

Proveedor de IA

+ +
+ + + +
+
+ + {/* API Key */} +
+

API Key

+ +
+ + setFormData({ ...formData, api_key: e.target.value })} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder={formData.provider === 'openai' ? 'sk-...' : 'AIza...'} + required + /> +

+ {formData.provider === 'openai' ? ( + <>Obtén tu API key en OpenAI Platform + ) : ( + <>Obtén tu API key en Google AI Studio + )} +

+
+
+ + {/* Model Selection */} +
+

Modelo de IA

+ +
+ {filteredModels.map((model) => ( + + ))} +
+
+ + {/* Current Status */} + {aiConfig && ( +
+
+ +
+
Configuración Activa
+
+ Proveedor: {aiConfig.provider} | + Modelo: {aiConfig.model_name} +
+
+ Configurado el {new Date(aiConfig.created_at).toLocaleDateString('es-ES')} +
+
+
+
+ )} + + {/* Save Button */} +
+ +
+
+ )} +
+ ) +} + +function QuestionsManagerModal({ checklist, onClose }) { + const [questions, setQuestions] = useState([]) + const [loading, setLoading] = useState(true) + const [showCreateForm, setShowCreateForm] = useState(false) + const [formData, setFormData] = useState({ + section: '', + text: '', + type: 'pass_fail', + points: 1, + allow_photos: true, + max_photos: 3, + requires_comment_on_fail: false + }) + + useEffect(() => { + loadQuestions() + }, []) + + const loadQuestions = async () => { + try { + const token = localStorage.getItem('token') + const API_URL = import.meta.env.VITE_API_URL || '' + + const response = await fetch(`${API_URL}/api/checklists/${checklist.id}`, { + headers: { 'Authorization': `Bearer ${token}` } + }) + + if (response.ok) { + const data = await response.json() + setQuestions(data.questions || []) + } + setLoading(false) + } catch (error) { + console.error('Error loading questions:', error) + setLoading(false) + } + } + + const handleCreateQuestion = async (e) => { + e.preventDefault() + + try { + const token = localStorage.getItem('token') + const API_URL = import.meta.env.VITE_API_URL || '' + + const response = await fetch(`${API_URL}/api/questions`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ...formData, + checklist_id: checklist.id + }), + }) + + if (response.ok) { + setShowCreateForm(false) + setFormData({ + section: '', + text: '', + type: 'pass_fail', + points: 1, + allow_photos: true, + max_photos: 3, + requires_comment_on_fail: false + }) + loadQuestions() + } else { + alert('Error al crear pregunta') + } + } catch (error) { + console.error('Error:', error) + alert('Error al crear pregunta') + } + } + + const handleDeleteQuestion = async (questionId) => { + if (!confirm('¿Estás seguro de eliminar esta pregunta?')) return + + try { + const token = localStorage.getItem('token') + const API_URL = import.meta.env.VITE_API_URL || '' + + const response = await fetch(`${API_URL}/api/questions/${questionId}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` } + }) + + if (response.ok) { + loadQuestions() + } else { + alert('Error al eliminar pregunta') + } + } catch (error) { + console.error('Error:', error) + alert('Error al eliminar pregunta') + } + } + + const questionsBySection = questions.reduce((acc, q) => { + const section = q.section || 'Sin sección' + if (!acc[section]) acc[section] = [] + acc[section].push(q) + return acc + }, {}) + + return ( +
+
+ {/* Header */} +
+
+
+

Gestionar Preguntas

+

{checklist.name}

+
+ +
+
+ + {/* Content */} +
+
+
+

+ Total de preguntas: {questions.length} | + Puntuación máxima: {questions.reduce((sum, q) => sum + (q.points || 0), 0)} +

+
+ +
+ + {/* Create Form */} + {showCreateForm && ( +
+
+
+
+ + setFormData({ ...formData, section: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500" + placeholder="Ej: Motor, Frenos, Documentación" + /> +
+
+ + +
+
+ +
+ + setFormData({ ...formData, text: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500" + placeholder="Ej: Estado de las pastillas de freno" + required + /> +
+ +
+
+ + setFormData({ ...formData, points: parseInt(e.target.value) })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500" + /> +
+
+ setFormData({ ...formData, allow_photos: e.target.checked })} + className="w-4 h-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500" + /> + +
+
+ + setFormData({ ...formData, max_photos: parseInt(e.target.value) })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500" + disabled={!formData.allow_photos} + /> +
+
+ + +
+
+ )} + + {/* Questions List */} + {loading ? ( +
+
Cargando preguntas...
+
+ ) : questions.length === 0 ? ( +
+

No hay preguntas en este checklist

+

Crea la primera pregunta para comenzar

+
+ ) : ( +
+ {Object.entries(questionsBySection).map(([section, sectionQuestions]) => ( +
+
+

{section}

+

+ {sectionQuestions.length} preguntas | {sectionQuestions.reduce((sum, q) => sum + (q.points || 0), 0)} puntos +

+
+
+ {sectionQuestions.map((question) => ( +
+
+
+ #{question.id} +
+

{question.text}

+
+ + {question.type} + + {question.points} pts + {question.allow_photos && ( + 📷 Máx {question.max_photos} fotos + )} +
+
+
+
+ +
+ ))} +
+
+ ))} +
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ ) +} + function ChecklistsTab({ checklists, user, onChecklistCreated, onStartInspection }) { const [showCreateModal, setShowCreateModal] = useState(false) + const [showQuestionsModal, setShowQuestionsModal] = useState(false) + const [selectedChecklist, setSelectedChecklist] = useState(null) const [creating, setCreating] = useState(false) const [formData, setFormData] = useState({ name: '', @@ -306,7 +927,7 @@ function ChecklistsTab({ checklists, user, onChecklistCreated, onStartInspection try { const token = localStorage.getItem('token') - const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' + const API_URL = import.meta.env.VITE_API_URL || '' const response = await fetch(`${API_URL}/api/checklists`, { method: 'POST', @@ -338,7 +959,7 @@ function ChecklistsTab({ checklists, user, onChecklistCreated, onStartInspection
@@ -373,19 +994,44 @@ function ChecklistsTab({ checklists, user, onChecklistCreated, onStartInspection
- {user.role === 'mechanic' && ( - - )} +
+ {user.role === 'admin' && ( + + )} + {user.role === 'mechanic' && ( + + )} +
)) )} + {/* Modal Gestionar Preguntas */} + {showQuestionsModal && selectedChecklist && ( + { + setShowQuestionsModal(false) + setSelectedChecklist(null) + onChecklistCreated() + }} + /> + )} + {/* Modal Crear Checklist */} {showCreateModal && (
@@ -430,10 +1076,34 @@ function ChecklistsTab({ checklists, user, onChecklistCreated, onStartInspection onChange={(e) => setFormData({ ...formData, ai_mode: e.target.value })} className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" > - - - + + + + + {/* Descripción del modo seleccionado */} +
+ {formData.ai_mode === 'off' && ( +
+ Sin IA: El mecánico completa manualmente todas las respuestas. + Sin dependencia de internet o API. +
+ )} + {formData.ai_mode === 'assisted' && ( +
+ IA Asistida: Cuando se suben fotos, la IA analiza y sugiere + estado, criticidad y observaciones. El mecánico acepta o modifica. +
⚠️ Requiere OPENAI_API_KEY configurada
+
+ )} + {formData.ai_mode === 'full' && ( +
+ IA Completa: El mecánico solo toma fotos y la IA responde + automáticamente todas las preguntas. Ideal para inspecciones rápidas masivas. +
⚠️ Requiere OPENAI_API_KEY configurada
+
+ )} +
@@ -491,7 +1161,7 @@ function InspectionDetailModal({ inspection, onClose }) { const loadInspectionDetails = async () => { try { const token = localStorage.getItem('token') - const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' + const API_URL = import.meta.env.VITE_API_URL || '' // Cargar la inspección completa con respuestas y checklist const response = await fetch(`${API_URL}/api/inspections/${inspection.id}`, { @@ -850,6 +1520,7 @@ function InspectionModal({ checklist, user, onClose, onComplete }) { // Answers data const [answers, setAnswers] = useState({}) const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0) + const [aiAnalyzing, setAiAnalyzing] = useState(false) // Signature canvas const mechanicSigRef = useRef(null) @@ -859,7 +1530,7 @@ function InspectionModal({ checklist, user, onClose, onComplete }) { const loadQuestions = async () => { try { const token = localStorage.getItem('token') - const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' + const API_URL = import.meta.env.VITE_API_URL || '' console.log('Loading questions for checklist:', checklist.id) @@ -905,7 +1576,7 @@ function InspectionModal({ checklist, user, onClose, onComplete }) { try { const token = localStorage.getItem('token') - const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' + const API_URL = import.meta.env.VITE_API_URL || '' console.log('Creating inspection with data:', vehicleData) @@ -966,7 +1637,7 @@ function InspectionModal({ checklist, user, onClose, onComplete }) { try { const token = localStorage.getItem('token') - const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' + const API_URL = import.meta.env.VITE_API_URL || '' // Determine status based on answer value let status = 'ok' @@ -1044,7 +1715,7 @@ function InspectionModal({ checklist, user, onClose, onComplete }) { try { const token = localStorage.getItem('token') - const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' + const API_URL = import.meta.env.VITE_API_URL || '' // Get mechanic signature as base64 const mechanicSig = mechanicSigRef.current ? mechanicSigRef.current.toDataURL() : null @@ -1093,14 +1764,159 @@ function InspectionModal({ checklist, user, onClose, onComplete }) { } } - const handlePhotoChange = (questionId, files) => { + const handlePhotoChange = async (questionId, files) => { + const filesArray = Array.from(files) + + // Update photos immediately setAnswers({ ...answers, [questionId]: { ...answers[questionId], - photos: Array.from(files) + photos: filesArray } }) + + // If AI mode is assisted or full, analyze the photos + if ((checklist.ai_mode === 'assisted' || checklist.ai_mode === 'full') && filesArray.length > 0) { + await analyzePhotosWithAI(questionId, filesArray) + } + } + + const analyzePhotosWithAI = async (questionId, files) => { + const question = questions.find(q => q.id === questionId) + if (!question) return + + setAiAnalyzing(true) + + try { + const token = localStorage.getItem('token') + const API_URL = import.meta.env.VITE_API_URL || '' + + console.log(`🤖 Analizando ${files.length} foto(s) con IA para pregunta: ${question.text}`) + + // Analyze each photo + const analyses = [] + for (const file of files) { + const formData = new FormData() + formData.append('file', file) + formData.append('question_id', question.id.toString()) + + const response = await fetch(`${API_URL}/api/analyze-image`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formData + }) + + if (response.ok) { + const result = await response.json() + + // Check if AI analysis was successful + if (result.success && result.analysis) { + analyses.push(result) + console.log('✅ Análisis IA:', result) + } else { + console.warn('⚠️ Error en análisis IA:', result.error || result.message) + // Show user-friendly error + if (result.error && result.error.includes('No AI configuration')) { + alert('⚙️ Por favor configura tu API key en Configuración primero.') + } + } + } else { + console.warn('⚠️ Error HTTP en análisis IA:', response.status, await response.text()) + } + } + + if (analyses.length > 0) { + // Process analysis results + const firstResult = analyses[0] + const analysis = firstResult.analysis + let suggestedAnswer = null + let observationsText = '' + + // Check if analysis is an object (structured JSON response) + if (typeof analysis === 'object' && analysis !== null) { + // Extract structured information + const status = analysis.status || 'ok' + const observations = analysis.observations || '' + const recommendation = analysis.recommendation || '' + const confidence = analysis.confidence || 0.7 + + // Build observations text + observationsText = `🤖 Análisis IA (${(confidence * 100).toFixed(0)}% confianza):\n${observations}` + if (recommendation) { + observationsText += `\n\n💡 Recomendación: ${recommendation}` + } + + // Map status to answer based on question type + if (question.type === 'pass_fail') { + if (status === 'ok') { + suggestedAnswer = 'pass' + } else if (status === 'critical' || status === 'minor') { + suggestedAnswer = 'fail' + } + } else if (question.type === 'good_bad') { + if (status === 'ok') { + suggestedAnswer = 'good' + } else if (status === 'minor') { + suggestedAnswer = 'regular' + } else if (status === 'critical') { + suggestedAnswer = 'bad' + } + } + } else if (typeof analysis === 'string') { + // Fallback for plain text responses + observationsText = `🤖 Análisis IA:\n${analysis}` + const analysisLower = analysis.toLowerCase() + + if (question.type === 'pass_fail') { + if (analysisLower.includes('pasa') || analysisLower.includes('correcto') || analysisLower.includes('bueno') || analysisLower.includes('ok')) { + suggestedAnswer = 'pass' + } else if (analysisLower.includes('falla') || analysisLower.includes('incorrecto') || analysisLower.includes('problema') || analysisLower.includes('critical')) { + suggestedAnswer = 'fail' + } + } else if (question.type === 'good_bad') { + if (analysisLower.includes('bueno') || analysisLower.includes('excelente')) { + suggestedAnswer = 'good' + } else if (analysisLower.includes('regular') || analysisLower.includes('aceptable')) { + suggestedAnswer = 'regular' + } else if (analysisLower.includes('malo') || analysisLower.includes('deficiente')) { + suggestedAnswer = 'bad' + } + } + } + + // In FULL mode, auto-fill the answer + if (checklist.ai_mode === 'full' && suggestedAnswer) { + setAnswers({ + ...answers, + [questionId]: { + ...answers[questionId], + value: suggestedAnswer, + observations: observationsText, + photos: files + } + }) + console.log(`🤖 FULL MODE: Respuesta auto-completada con: ${suggestedAnswer}`) + } + // In ASSISTED mode, suggest in observations + else if (checklist.ai_mode === 'assisted') { + setAnswers({ + ...answers, + [questionId]: { + ...answers[questionId], + observations: `${suggestedAnswer ? `[IA Sugiere: ${suggestedAnswer}]\n` : ''}${observationsText}`, + photos: files + } + }) + console.log(`🤖 ASSISTED MODE: Sugerencia agregada a observaciones`) + } + } + } catch (error) { + console.error('❌ Error al analizar fotos con IA:', error) + // Don't block the user if AI fails + } finally { + setAiAnalyzing(false) + } } const currentQuestion = questions[currentQuestionIndex] @@ -1110,7 +1926,7 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
{/* Header */} -
+

Nueva Inspección: {checklist.name}

@@ -1122,6 +1938,33 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
+ {/* AI Mode Banner */} + {checklist.ai_mode !== 'off' && ( +
+
+ 🤖 +
+

+ {checklist.ai_mode === 'full' ? 'Modo IA COMPLETO activado' : 'Modo IA ASISTIDO activado'} +

+

+ {checklist.ai_mode === 'full' + ? 'La IA completará automáticamente las respuestas al subir fotos. Revisa y ajusta si es necesario.' + : 'La IA sugerirá respuestas en las observaciones al subir fotos.'} +

+
+
+
+ )} + {/* Progress indicator */}
@@ -1397,6 +2240,11 @@ function InspectionModal({ checklist, user, onClose, onComplete }) {
handlePhotoChange(currentQuestion.id, e.target.files)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" + disabled={aiAnalyzing} /> - {answers[currentQuestion.id]?.photos?.length > 0 && ( + + {aiAnalyzing && ( +
+
+
+ Analizando imagen con IA... +
+
+ )} + + {answers[currentQuestion.id]?.photos?.length > 0 && !aiAnalyzing && (
{answers[currentQuestion.id].photos.length} foto(s) seleccionada(s) + {checklist.ai_mode === 'full' && answers[currentQuestion.id]?.value && ( + ✓ Analizada + )} + {checklist.ai_mode === 'assisted' && answers[currentQuestion.id]?.observations.includes('[IA Sugiere') && ( + ✓ Sugerencia generada + )}
)}
@@ -1499,3 +2364,4 @@ function InspectionModal({ checklist, user, onClose, onComplete }) { export default App + diff --git a/frontend/src/Sidebar.jsx b/frontend/src/Sidebar.jsx index c6e38ff..7d6151c 100644 --- a/frontend/src/Sidebar.jsx +++ b/frontend/src/Sidebar.jsx @@ -1,12 +1,19 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, setSidebarOpen, onLogout }) { return ( -