dfernandezl12 commited on
Commit
55db7ff
·
verified ·
1 Parent(s): 8158ef1

Upload 6 files

Browse files
Files changed (6) hide show
  1. Dockerfile.dockerfile +27 -0
  2. main.py +187 -0
  3. requirements.txt +13 -0
  4. static/app.js +123 -0
  5. static/index.html +67 -0
  6. static/style.css +52 -0
Dockerfile.dockerfile ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10
2
+
3
+ WORKDIR /app
4
+
5
+ # Instalar tesseract en el sistema
6
+ RUN apt-get update && apt-get install -y \
7
+ tesseract-ocr \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ # Instalar dependencias para OpenCV
11
+ RUN apt-get update && apt-get install -y \
12
+ libgl1 \
13
+ libglib2.0-0 \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+ # Copiar e instalar las dependencias de Python
17
+ COPY requirements.txt .
18
+ RUN pip install --no-cache-dir -r requirements.txt
19
+
20
+ # Copiar el código del proyecto
21
+ COPY . .
22
+
23
+ # Exponer el puerto que usa Hugging Face Spaces
24
+ EXPOSE 7860
25
+
26
+ # Comando para arrancar la aplicación con Uvicorn
27
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
main.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from unittest import result
2
+
3
+ from fastapi import FastAPI, File, UploadFile, HTTPException
4
+ from google import genai
5
+ from httpcore import request
6
+ from google.genai import types
7
+ from dotenv import load_dotenv
8
+ from pydantic import BaseModel
9
+ from fastapi.staticfiles import StaticFiles
10
+ from fastapi.responses import FileResponse
11
+ from transformers import pipeline
12
+ import cv2
13
+ from paddleocr import PaddleOCR
14
+ import numpy as np
15
+ import time
16
+ import os
17
+
18
+ MODEL_ID = "models/gemini-2.5-flash"
19
+ MODEL_FALLBACK = "models/gemini-3.1-flash-lite-preview"
20
+ modelo_local = pipeline("text2text-generation", model="google/flan-t5-small")
21
+
22
+ load_dotenv()
23
+
24
+ api_key = os.environ.get("GEMINI_API_KEY")
25
+
26
+ client = genai.Client(api_key=api_key)
27
+
28
+
29
+ config = types.GenerateContentConfig(
30
+ temperature=0,
31
+ )
32
+
33
+ class ChatRequest(BaseModel):
34
+ pregunta: str
35
+ contexto: str = ""
36
+ historial: list[str] = []
37
+
38
+ class ChatResponse(BaseModel):
39
+ respuesta: str
40
+ historial: list[str] = []
41
+
42
+ class OCRResponse(BaseModel):
43
+ texto: str
44
+
45
+ app = FastAPI(title="OCR API", version="1.0.0")
46
+ ocr = PaddleOCR(use_angle_cls=True, lang="es")
47
+
48
+ try:
49
+ local_model = pipeline("text2text-generation", model="google/flan-t5-small")
50
+ except Exception as e:
51
+ print(f"Aviso: No se pudo cargar el modelo local: {e}")
52
+ local_model = None
53
+
54
+ #@app.get("/")
55
+ #def home():
56
+ #return FileResponse("static/index.html")
57
+
58
+
59
+
60
+ @app.post("/api/chat", response_model=ChatResponse)
61
+ def prompt(request: ChatRequest):
62
+ max_reintentos = 2
63
+ segundos_espera = 10
64
+ e = "Error desconocido"
65
+
66
+ try:
67
+ pregunta = request.pregunta
68
+ historial = request.historial
69
+ texto_ocr = request.contexto # <--- Tomamos el texto enviado por el frontend
70
+
71
+ texto_prompt = f"""
72
+ Eres un asistente que responde preguntas usando SOLO información del documento.
73
+
74
+ REGLAS IMPORTANTES:
75
+ - No copies el documento completo.
76
+ - No repitas texto largo del documento.
77
+ - Extrae SOLO la información necesaria.
78
+ - Si la respuesta no está en el documento, di: "No aparece en el documento".
79
+ - Responde de forma breve y directa.
80
+
81
+ DOCUMENTO:
82
+ \"\"\"{texto_ocr}\"\"\" <--- Cambiado: ahora usa el OCR
83
+
84
+ HISTORIAL:
85
+ {historial}
86
+
87
+ PREGUNTA:
88
+ {pregunta}
89
+
90
+ RESPUESTA:
91
+ """
92
+
93
+ for intento in range(max_reintentos):
94
+ try:
95
+ response = client.models.generate_content(
96
+ model=MODEL_ID,
97
+ contents=texto_prompt,
98
+ config=config
99
+ )
100
+ return ChatResponse(respuesta=response.text)
101
+ except Exception as ex:
102
+ print(f"{intento+1} - Error Gemini 2.5: {ex}")
103
+ e = ex
104
+ time.sleep(segundos_espera)
105
+
106
+ try:
107
+ response = client.models.generate_content(
108
+ model=MODEL_FALLBACK,
109
+ contents=texto_prompt,
110
+ config=config
111
+ )
112
+ return ChatResponse(respuesta=response.text)
113
+ except Exception as e_fallback:
114
+ print(f"Error en Gemini 1.5 Fallback: {e_fallback}")
115
+ try:
116
+ # Simplificamos el prompt para el modelo local pequeño
117
+ res_local = modelo_local(f"question: {pregunta} context: {texto_ocr}", max_new_tokens=50)
118
+ return ChatResponse(respuesta=res_local[0]['generated_text'])
119
+ except Exception as e_final:
120
+ raise HTTPException(status_code=500, detail="Error en todos los modelos (incluyendo local)")
121
+
122
+ except Exception as e:
123
+ if isinstance(e, HTTPException): raise e
124
+ raise HTTPException(status_code=500, detail=str(e))
125
+
126
+ except Exception as e:
127
+ if isinstance(e, HTTPException): raise e
128
+ raise HTTPException(status_code=500, detail=str(e))
129
+
130
+
131
+ def extraer_texto(resultado_ocr):
132
+ textos = []
133
+
134
+ # PaddleOCR devuelve una lista de páginas.
135
+ # Validamos que el resultado no sea None y que la primera página tenga contenido.
136
+ if resultado_ocr and resultado_ocr[0] is not None:
137
+ for linea in resultado_ocr[0]:
138
+ # linea[1][0] es donde reside el texto detectado
139
+ textos.append(linea[1][0])
140
+
141
+ return " ".join(textos)
142
+
143
+
144
+ def preprocesar_imagen(image_bytes):
145
+ # Convertir bytes a imagen
146
+ nparr = np.frombuffer(image_bytes, np.uint8)
147
+ img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
148
+
149
+ if img is None:
150
+ raise ValueError("No se pudo decodificar la imagen")
151
+
152
+ # PaddleOCR maneja internamente el binarizado,
153
+ # es mejor enviarle la imagen limpia o solo en gris.
154
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
155
+ return gray
156
+
157
+ @app.post("/api/ocr", response_model=OCRResponse)
158
+ async def ocr_image(file: UploadFile = File(...)):
159
+ if file.content_type not in {"image/png", "image/jpeg", "image/jpg", "image/webp"}:
160
+ raise HTTPException(status_code=400, detail="Formato no soportado")
161
+
162
+ try:
163
+ # Leemos los bytes del archivo
164
+ contenido = await file.read()
165
+
166
+ # Preprocesamos y ejecutamos OCR
167
+ img = preprocesar_imagen(contenido)
168
+
169
+ # cls=True activa la clasificación de ángulo si se configuró en la instancia
170
+ result = ocr.ocr(img, cls=True)
171
+
172
+ texto_extraido = extraer_texto(result)
173
+
174
+ if not texto_extraido.strip():
175
+ return OCRResponse(texto="No se detectó texto en la imagen.")
176
+
177
+ return OCRResponse(texto=texto_extraido)
178
+
179
+ except ValueError as e:
180
+ raise HTTPException(status_code=400, detail=str(e))
181
+ except Exception as e:
182
+ raise HTTPException(status_code=500, detail=f"Error OCR: {str(e)}")
183
+
184
+ app.mount("/", StaticFiles(directory="static", html=True), name="static")
185
+
186
+
187
+
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.136.1
2
+ uvicorn==0.46.0
3
+ python-multipart==0.0.27
4
+ opencv-python-headless==4.13.0.92
5
+ numpy==2.2.6
6
+ paddleocr==2.10.0
7
+ paddlepaddle==2.6.2
8
+ pytesseract==0.3.13
9
+ ultralytics==8.4.45
10
+ google-genai==1.73.1
11
+ huggingface_hub==1.12.2
12
+ transformers==5.7.0
13
+ requests==2.33.1
static/app.js ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let historial = [];
2
+ let textoOCR = "";
3
+
4
+ // Elementos
5
+ const imageInput = document.getElementById('imageInput');
6
+ const imagePreview = document.getElementById('imagePreview');
7
+ const extractedTextDiv = document.getElementById('extractedText');
8
+ const chatLog = document.getElementById('chatLog');
9
+ const inputPregunta = document.getElementById('pregunta');
10
+ const btnEnviar = document.getElementById('btnEnviar');
11
+ const ocrBadge = document.getElementById('ocrBadge');
12
+ const previewWrapper = document.getElementById('previewWrapper');
13
+
14
+ // --- 1. Manejo de OCR ---
15
+ imageInput.addEventListener('change', async (e) => {
16
+ const file = e.target.files[0];
17
+ if (!file) return;
18
+
19
+ chatLog.innerHTML = ""; // Limpiar chat
20
+ historial = []; // Limpiar historial
21
+
22
+ // Mostrar preview
23
+ const url = URL.createObjectURL(file);
24
+ imagePreview.src = url;
25
+ previewWrapper.classList.remove('hidden');
26
+ ocrBadge.textContent = "Procesando...";
27
+ ocrBadge.style.background = "#eab308"; // Amarillo
28
+
29
+ const formData = new FormData();
30
+ formData.append('file', file);
31
+
32
+ try {
33
+ const res = await fetch('/api/ocr', {
34
+ method: 'POST',
35
+ body: formData
36
+ });
37
+ const data = await res.json();
38
+
39
+ textoOCR = data.texto;
40
+ extractedTextDiv.textContent = textoOCR;
41
+ ocrBadge.textContent = "OCR Listo";
42
+ ocrBadge.style.background = "#22c55e"; // Verde
43
+
44
+ // Habilitar chat
45
+ inputPregunta.disabled = false;
46
+ btnEnviar.disabled = false;
47
+ inputPregunta.focus();
48
+
49
+ agregarMensaje('assistant', 'He leído la imagen. ¿Qué quieres saber sobre ella?');
50
+ } catch (err) {
51
+ ocrBadge.textContent = "Error OCR";
52
+ ocrBadge.style.background = "#ef4444"; // Rojo
53
+ }
54
+ });
55
+
56
+ // --- 2. Manejo de Chat ---
57
+ async function enviarPregunta() {
58
+ const pregunta = inputPregunta.value.trim();
59
+ if (!pregunta) return;
60
+
61
+ agregarMensaje('user', pregunta);
62
+ inputPregunta.value = "";
63
+
64
+ // Mostramos estado de carga
65
+ const typingId = agregarMensaje('assistant', '<i class="fa-solid fa-ellipsis fa-beat"></i> Pensando...');
66
+
67
+ try {
68
+ const res = await fetch('/api/chat', {
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({
72
+ pregunta: pregunta,
73
+ contexto: textoOCR, // <--- CAMBIO: Usamos la variable global 'textoOCR' definida arriba
74
+ historial: historial
75
+ })
76
+ });
77
+
78
+ const data = await res.json();
79
+
80
+ // Eliminamos indicador de carga y ponemos respuesta
81
+ const loader = document.getElementById(typingId);
82
+ if (loader) loader.remove();
83
+
84
+ if (data.respuesta) {
85
+ agregarMensaje('assistant', data.respuesta);
86
+
87
+ // Actualizamos historial
88
+ historial.push(`USER: ${pregunta}`, `ASSISTANT: ${data.respuesta}`);
89
+ if(historial.length > 10) historial.splice(0, 2);
90
+ } else {
91
+ agregarMensaje('assistant', 'La IA no pudo generar una respuesta clara.');
92
+ }
93
+
94
+ } catch (err) {
95
+ const loader = document.getElementById(typingId);
96
+ if (loader) loader.remove();
97
+ agregarMensaje('assistant', 'Lo siento, ha ocurrido un error al conectar con el servidor.');
98
+ console.error(err);
99
+ }
100
+ }
101
+
102
+ function agregarMensaje(rol, texto) {
103
+ const div = document.createElement('div');
104
+ const id = 'msg-' + Date.now() + '-' + Math.floor(Math.random() * 1000);
105
+ div.id = id;
106
+ div.className = `message ${rol}`;
107
+ div.innerHTML = texto;
108
+ chatLog.appendChild(div);
109
+ chatLog.scrollTop = chatLog.scrollHeight;
110
+ return id;
111
+ }
112
+
113
+ // Eventos
114
+ btnEnviar.addEventListener('click', enviarPregunta);
115
+ inputPregunta.addEventListener('keypress', (e) => {
116
+ if (e.key === 'Enter') enviarPregunta();
117
+ });
118
+
119
+ document.getElementById('btnLimpiar').addEventListener('click', () => {
120
+ chatLog.innerHTML = "";
121
+ historial = [];
122
+ agregarMensaje('system', 'Chat reiniciado.');
123
+ });
static/index.html ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>VisionAI | OCR & LLM Assistant</title>
7
+ <link rel="stylesheet" href="/static/style.css?v=1.1">
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
9
+ </head>
10
+ <body>
11
+ <div class="app-container">
12
+ <!-- Sidebar: Control de Imagen -->
13
+ <aside class="sidebar">
14
+ <div class="logo">
15
+ <i class="fa-solid fa-eye"></i>
16
+ <span>VisionAI</span>
17
+ </div>
18
+
19
+ <div class="upload-box" id="dropZone">
20
+ <input type="file" id="imageInput" accept="image/*" hidden>
21
+ <label for="imageInput" class="upload-label">
22
+ <i class="fa-solid fa-cloud-arrow-up"></i>
23
+ <span>Sube una imagen o arrastra</span>
24
+ <small>PNG, JPG hasta 10MB</small>
25
+ </label>
26
+ </div>
27
+
28
+ <div id="previewWrapper" class="hidden">
29
+ <div class="preview-card">
30
+ <img id="imagePreview" src="" alt="Preview">
31
+ <div id="ocrBadge" class="badge">Procesando...</div>
32
+ </div>
33
+ <div class="ocr-text-container">
34
+ <label>Texto extraído:</label>
35
+ <div id="extractedText">Sube una imagen para ver el texto...</div>
36
+ </div>
37
+ </div>
38
+ </aside>
39
+
40
+ <!-- Main: Chat Interface -->
41
+ <main class="chat-area">
42
+ <div class="chat-header">
43
+ <h2>Asistente de Documentos</h2>
44
+ <button id="btnLimpiar" class="btn-icon" title="Limpiar chat">
45
+ <i class="fa-solid fa-trash-can"></i>
46
+ </button>
47
+ </div>
48
+
49
+ <div id="chatLog" class="chat-log">
50
+ <div class="message system">
51
+ ¡Bienvenido! Sube una imagen con texto para empezar a hacerme preguntas sobre ella.
52
+ </div>
53
+ </div>
54
+
55
+ <div class="input-container">
56
+ <div class="input-wrapper">
57
+ <input type="text" id="pregunta" placeholder="Escribe tu duda aquí..." disabled>
58
+ <button id="btnEnviar" disabled>
59
+ <i class="fa-solid fa-paper-plane"></i>
60
+ </button>
61
+ </div>
62
+ </div>
63
+ </main>
64
+ </div>
65
+ <script src="/static/app.js"></script>
66
+ </body>
67
+ </html>
static/style.css ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --primary: #2563eb;
3
+ --primary-hover: #1d4ed8;
4
+ --bg-app: #f8fafc;
5
+ --bg-sidebar: #ffffff;
6
+ --text-main: #1e293b;
7
+ --text-muted: #64748b;
8
+ --border: #e2e8f0;
9
+ }
10
+
11
+ * { box-sizing: border-box; margin: 0; padding: 0; }
12
+ body { font-family: 'Inter', system-ui, sans-serif; background: var(--bg-app); color: var(--text-main); height: 100vh; overflow: hidden; }
13
+
14
+ .app-container { display: flex; height: 100vh; }
15
+
16
+ /* Sidebar */
17
+ .sidebar { width: 350px; background: var(--bg-sidebar); border-right: 1px solid var(--border); padding: 24px; display: flex; flex-direction: column; gap: 20px; }
18
+ .logo { display: flex; align-items: center; gap: 12px; font-size: 1.5rem; font-weight: 700; color: var(--primary); margin-bottom: 10px; }
19
+
20
+ .upload-box { border: 2px dashed var(--border); border-radius: 12px; padding: 30px 20px; text-align: center; transition: all 0.3s; cursor: pointer; }
21
+ .upload-box:hover { border-color: var(--primary); background: #f0f7ff; }
22
+ .upload-label { cursor: pointer; display: flex; flex-direction: column; gap: 8px; color: var(--text-muted); }
23
+ .upload-label i { font-size: 2rem; color: var(--primary); }
24
+
25
+ .preview-card { position: relative; margin-top: 10px; border-radius: 8px; overflow: hidden; border: 1px solid var(--border); }
26
+ #imagePreview { width: 100%; display: block; max-height: 200px; object-fit: contain; background: #000; }
27
+ .badge { position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,0.7); color: white; padding: 4px 10px; border-radius: 20px; font-size: 0.7rem; }
28
+
29
+ .ocr-text-container { margin-top: 15px; font-size: 0.85rem; }
30
+ #extractedText { background: #f1f5f9; padding: 12px; border-radius: 8px; height: 120px; overflow-y: auto; color: var(--text-muted); margin-top: 5px; border: 1px solid var(--border); line-height: 1.4; }
31
+
32
+ /* Chat Area */
33
+ .chat-area { flex: 1; display: flex; flex-direction: column; position: relative; }
34
+ .chat-header { padding: 20px 30px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: white; }
35
+ .chat-log { flex: 1; padding: 30px; overflow-y: auto; display: flex; flex-direction: column; gap: 20px; }
36
+
37
+ .message { max-width: 80%; padding: 12px 16px; border-radius: 12px; line-height: 1.5; font-size: 0.95rem; }
38
+ .message.system { align-self: center; background: #f1f5f9; color: var(--text-muted); font-size: 0.8rem; }
39
+ .message.user { align-self: flex-end; background: var(--primary); color: white; border-bottom-right-radius: 2px; }
40
+ .message.assistant { align-self: flex-start; background: white; border: 1px solid var(--border); border-bottom-left-radius: 2px; box-shadow: 0 2px 5px rgba(0,0,0,0.05); }
41
+
42
+ /* Input */
43
+ .input-container { padding: 30px; background: linear-gradient(transparent, var(--bg-app) 20%); }
44
+ .input-wrapper { display: flex; gap: 12px; background: white; padding: 8px; border-radius: 15px; box-shadow: 0 4px 15px rgba(0,0,0,0.08); border: 1px solid var(--border); }
45
+ input[type="text"] { flex: 1; border: none; padding: 10px 15px; outline: none; font-size: 1rem; }
46
+ button#btnEnviar { background: var(--primary); color: white; border: none; width: 45px; height: 45px; border-radius: 12px; cursor: pointer; transition: 0.3s; }
47
+ button#btnEnviar:disabled { background: var(--border); cursor: not-allowed; }
48
+ button#btnEnviar:hover:not(:disabled) { background: var(--primary-hover); transform: scale(1.05); }
49
+
50
+ .hidden { display: none; }
51
+ .btn-icon { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 1.1rem; }
52
+ .btn-icon:hover { color: #ef4444; }