Si trabajas en el departamento de Sistemas, TI o eres el consultor de cabecera de cualquier empresa que utilice SAP Business One, sabes con dolorosa exactitud cómo transcurre una mañana de lunes típica. ☕💥

Apenas abres los ojos y ya tienes tres mensajes en el chat corporativo:

  • 🤦‍♂️ «Oye, no me deja facturar la entrega del cliente X, me sale una barra roja abajo con un número entre corchetes [-10] y un texto que dice algo de las cuentas contables. ¡Urge porque el camión ya se va!»
  • 🤷‍♀️ «Oye, ¿por qué no puedo cancelar un pago recibido? Dice que el periodo contable está cerrado.»
  • 🤦‍♀️ «¿Cuál era la tabla de los pedidos de compra para sacar un reporte en el Generador de Consultas? ¿Era ORDR o cuál?»

Tu primera reacción humana —y perfectamente justificada— es respirar hondo y pensar: «Hice un manual de 80 páginas en PDF con capturas, flechas rojas y letra tamaño 14… ¿por qué nadie lo lee?». La respuesta es universal: nadie lee manuales. El usuario operativo no quiere buscar en un SharePoint o en una carpeta compartida de red; el usuario quiere preguntar en su chat de todos los días y recibir la respuesta exacta en 5 segundos.

Ahí surge la gran idea moderna: «Metamos Inteligencia Artificial». 💡

Pero en cuanto vas con la Dirección o con Finanzas y dices: «Oigan, mandemos las dudas a ChatGPT o Claude», la respuesta es un rotundo NO. Y tienen toda la razón:

  1. 🔒 Privacidad y secreto comercial: En las consultas de SAP van metidos nombres de clientes, listas de precios, costos, catálogos de cuentas contables, RFCs y folios fiscales. Mandar eso a una nube pública ajena es una pesadilla de cumplimiento y seguridad.
  2. 💸 Costos recurrentes: Pagar suscripciones mensuales por usuario o tokens por cada consulta donde adjunten una captura pesada de pantalla no es viable a largo plazo.
  3. 🏢 Infraestructura existente: En muchísimas empresas medianas e industriales, el canal oficial de comunicación interna no es Slack ni Teams; es Spark sobre Openfire (XMPP). Un sistema robusto, que corre en tu propio servidor Linux dentro de la red local (LAN/VPN), que jamás se cae y que no cobra licencias.

¿Es posible montar un asistente de IA autónomo, multimodal (que analice texto y capturas de pantalla de errores), con memoria vectorial (RAG), conectado a Spark/Openfire, que no alucine con las tablas de SAP y que aprenda de sus propios aciertos todos los días?

Sí, es totalmente posible. En este artículo te voy a mostrar la arquitectura completa, las trampas con las que casi me rompo la cabeza en el camino y absolutamente todo el código fuente listo para producción. 🛠️🔥

🏗️ 1. La Arquitectura: ¿Cómo encajan las piezas del rompecabezas?

Para que este bot fuera rápido, confiable y seguro, la premisa fundamental fue: cero dependencias de la nube pública. Todo corre dentro de tu propia infraestructura Linux.

                    [ 💻 Usuario en Spark IM ]
                               │
            (Texto o Captura)  │  (Streaming por párrafos)
                               ▼
                   [ 🌐 Servidor Openfire ]
                               │  (XMPP / HTTP File Upload :7070)
                               ▼
                 [ 🐍 bot_spark.py (Slixmpp) ]
                  │            │           │
                  │            │           ▼
                  │            │      [ 🗄️ SQLite WAL ]
                  │            │      Historial y Feedback
                  ▼            ▼
             [ 🧠 Ollama ]   [ 📚 ChromaDB ]
            - Qwen 2.5 7B    - manuales_sap (PDF/DOCX/TXT)
            - Qwen 2.5-VL    - casos_resueltos (Feedback SÍ)
            - Nomic Embed

Los componentes del sistema:

  1. El Cliente y Servidor de Chat (Spark + Openfire):
    • Openfire: Servidor XMPP escrito en Java corriendo en Ubuntu Server.
    • Spark: El cliente clásico de escritorio para Windows/Linux.
    • HTTP File Upload Plugin (XEP-0363): La pieza que salva vidas. Por defecto, Spark intenta transferir archivos de forma directa (P2P SOCKS5), lo que suele fallar estrepitosamente con bots en Python. Con este plugin, Spark sube la captura por HTTP al puerto 7070 y le pasa la URL limpia al bot.
  2. El Orquestador de Conexión (Slixmpp):
    • Es una librería de Python asíncrona moderna basada en asyncio. Permite gestionar la presencia del bot, escuchar eventos de mensajes, notificar que está “escribiendo…” y despachar respuestas en segundo plano sin congelar el hilo de red.
  3. El Motor de IA Local (Ollama):
    • qwen2.5:7b (Texto): Excelente capacidad para razonar en español, entender estructuras lógicas empresariales y redactar pasos técnicos claros.
    • qwen2.5vl:7b (Visión): Modelo multimodal que recibe la captura de pantalla del usuario, localiza la barra de estado inferior de SAP (el temido texto rojo), lee ventanas emergentes de error y transcribe el código del problema.
    • nomic-embed-text (Embeddings): Transforma cualquier texto o consulta en vectores matemáticos de 768 dimensiones para la búsqueda semántica.
  4. La Base de Datos Vectorial (ChromaDB con Doble Colección):
    • manuales_sap: Alberga la documentación oficial (manuales PDF, Word de procesos y el catálogo maestro de tablas).
    • casos_resueltos: Una colección viva y separada. Almacena las respuestas que los usuarios confirmaron con un “SÍ”. ¿Por qué separadas? Porque si mañana actualizas un PDF en los manuales oficiales y reindexas, no se borra la experiencia aprendida por el bot.
  5. La Memoria Transaccional (SQLite en modo WAL):
    • Guarda cada interacción con fecha, usuario, pregunta, respuesta y estado de feedback. El modo WAL (Write-Ahead Logging) garantiza que múltiples lecturas y escrituras simultáneas jamás bloqueen la base de datos.

🥊 2. Crónicas de guerra: Los 4 tropiezos que casi nos vuelven locos

Antes de ver el código final, vale la pena repasar los errores que surgieron en las pruebas reales. Conocerlos te ahorrará horas de depuración.

Tropiezo 1: El temido “Cold Start” (¿Por qué tarda 40 segundos en contestar?) ⏳❄️

Al hacer la primera prueba en vivo, un usuario escribió: “Hola” y el bot tardó casi 45 segundos en responder. Cualquier usuario asume que el sistema se murió y cierra la ventana.

  • La causa: Ollama, por defecto, descarga los modelos de la RAM si nadie los usa durante 5 minutos. Cuando entra un mensaje nuevo, el servidor tiene que leer 4.5 GB del disco, subirlos a la memoria, cargar el modelo de embeddings y recién ahí empezar a pensar.
  • La solución: Implementamos dos cosas:
    1. Un método de Warm-up automático al iniciar el bot que hace una mini-consulta silenciosa para dejar los modelos calientes.
    2. El parámetro keep_alive=-1 que le ordena a Ollama: «No bajes este modelo de la memoria jamás».

Tropiezo 2: El error misterioso time: missing unit in duration "-1" 🤦‍♂️

Al intentar configurar el keep-alive eterno, pusimos:

Python

ollama.chat(..., keep_alive="-1") # ❌ ERROR

La terminal explotó con un error 400. Resulta que Ollama está escrito en Go; si le mandas comillas, espera una unidad de tiempo (como "-1m" o "-1h"). Para indicarle eternidad, debe ser un número entero:

Python

ollama.chat(..., keep_alive=-1)   # ✅ CORRECTO

Tropiezo 3: La alucinación de las tablas de SAP B1 🌀

Cuando le preguntamos al bot: «¿Cuál es la tabla de los pedidos de compra?», respondió muy convencido:

“La tabla principal es PCHDR (Purchase Order Header) y el detalle es PCTLR (Purchase Order Line)…”

Cualquier consultor de SAP B1 que lea eso escupe el café. En SAP Business One las tablas no se llaman así. SAP B1 sigue una regla fija:

  • Las cabeceras inician con O + 3 letras (OPOR = Pedido de Compra, ORDR = Pedido de Venta, OINV = Factura de Clientes).
  • Las líneas pierden la O y terminan en 1 (POR1, RDR1, INV1).

El modelo, al no tener un diccionario explícito en los manuales, intentó traducir del inglés Purchase Order e inventó acrónimos falsos.

  • La solución: Creamos un archivo plano tablas_sap_business_one.txt con el catálogo real, lo indexamos en ChromaDB y ajustamos el prompt con reglas estrictas de nomenclatura. Nunca más volvió a inventar una tabla.

Tropiezo 4: El envío de imágenes en Spark 📸

En Spark hay dos formas de mandar fotos:

  1. El botón de la cámara (captura de pantalla tradicional): Usa transferencia directa P2P (SOCKS5 Bytestreams). En clientes modernos o con servidores detrás de NAT/firewall, casi siempre arroja: “You were unable to send the file”.
  2. Arrastrar la imagen o usar el botón del clip: Sube la imagen vía HTTP al puerto 7070 de Openfire (/httpfileupload/) y manda un enlace web.

Ajustamos Openfire para forzar el uso de HTTP en la IP local y el script de Python quedó configurado para detectar automáticamente cualquier URL de imagen mediante expresiones regulares, descargarla a un archivo temporal y mandarla a procesar con visión artificial.

📖 3. Paso 1: El Diccionario Maestro de Tablas de SAP B1

Este archivo es la “fuente de la verdad” para evitar que el bot invente tablas cuando los desarrolladores o contadores le pidan apoyo con queries SQL o HANA.

Crea la carpeta manuales/ dentro del directorio de tu bot y guarda este archivo:

📁 Ruta: ~/sap_bot/manuales/tablas_sap_business_one.txt

Plaintext

DICCIONARIO MAESTRO DE TABLAS ESTÁNDAR DE SAP BUSINESS ONE (SQL Y HANA)

REGLAS DE NOMENCLATURA INTERNA EN SAP B1:
- Tablas de encabezado / cabecera de documentos: Siempre inician con la letra 'O' seguida de 3 letras.
- Tablas de líneas / detalle del documento: Quitan la 'O' inicial y terminan con el número '1'.

1. MÓDULO DE COMPRAS (PROVEEDORES):
- Solicitud de Compra (Requisición): OPRQ (Cabecera), PRQ1 (Líneas)
- Oferta de Compra: OPQT (Cabecera), PQT1 (Líneas)
- Pedido de Compra (Orden de Compra): OPOR (Cabecera), POR1 (Líneas)
- Entrada de Mercancías por Compras: OPDN (Cabecera), PDN1 (Líneas)
- Devolución de Mercancías a Proveedor: ORPD (Cabecera), RPD1 (Líneas)
- Factura de Proveedores (Factura de Compra): OPCH (Cabecera), PCH1 (Líneas)
- Nota de Crédito de Proveedores: ORPC (Cabecera), RPC1 (Líneas)

2. MÓDULO DE VENTAS (CLIENTES):
- Oferta de Venta (Cotización): OQUT (Cabecera), QUT1 (Líneas)
- Pedido de Venta (Orden de Venta): ORDR (Cabecera), RDR1 (Líneas)
- Entrega (Remisión de Venta): ODLN (Cabecera), DLN1 (Líneas)
- Devolución de Clientes: ORDN (Cabecera), RDN1 (Líneas)
- Factura de Clientes (Factura de Venta): OINV (Cabecera), INV1 (Líneas)
- Factura de Reserva de Clientes: OINV (Cabecera con campo IsIns = 'Y'), INV1 (Líneas)
- Nota de Crédito de Clientes: ORIN (Cabecera), RIN1 (Líneas)

3. SOCIOS DE NEGOCIOS:
- Maestro de Socios de Negocios (Clientes y Proveedores): OCRD
- Personas de Contacto de Socios de Negocios: OCPR
- Direcciones de entrega y fiscales de Socios: CRD1
- Grupos de Socios de Negocios: OCRG
- Condiciones de Pago: OCTG

4. INVENTARIO Y ARTÍCULOS:
- Datos Maestros de Artículos: OITM
- Stock por Almacén de Artículos: OITW
- Almacenes (Bodegas): OWHS
- Grupos de Artículos: OITB
- Listas de Precios: OPLN (Cabecera), ITM1 (Precios por artículo)
- Solicitud de Traslado de Inventario: OWTQ (Cabecera), WTQ1 (Líneas)
- Transferencia de Stock (Traslado entre almacenes): OWTR (Cabecera), WTR1 (Líneas)
- Entrada de Mercancías (Inventario directo): OIGN (Cabecera), IGN1 (Líneas)
- Salida de Mercancías (Ajuste o merma de inventario): OIGE (Cabecera), IGE1 (Líneas)

5. FINANZAS Y GESTIÓN BANCARIA:
- Plan de Cuentas Contables: OACT
- Asientos Contables (Pólizas contables): OJDT (Cabecera), JDT1 (Líneas)
- Pagos Recibidos (Cobranza a clientes): ORCT (Cabecera), RCT2 (Facturas pagadas)
- Pagos Efectuados (Pagos a proveedores): OVPM (Cabecera), VPM2 (Facturas pagadas)

6. PRODUCCIÓN:
- Lista de Materiales (Receta / Estructura de producción): OITT (Cabecera), ITT1 (Líneas)
- Orden de Fabricación (Orden de Producción): OWOR (Cabecera), WOR1 (Líneas)

📥 4. Paso 2: El Pipeline de Ingestión Multimodal (indexar_manuales.py)

¿Qué pasa cuando un usuario hace un manual interno en Microsoft Word, pega 15 capturas de pantalla de SAP y apenas escribe dos líneas de texto? Los indexadores RAG tradicionales ignoran las imágenes y no indexan casi nada.

Este script resuelve ese problema de forma integral:

  • Abre archivos .pdf con fitz (PyMuPDF).
  • Abre documentos .docx con python-docx.
  • Lee archivos .txt planos.
  • Aplica OCR con Tesseract: Si detecta imágenes incrustadas con poco texto alrededor, ejecuta OCR sobre la captura, extrae los títulos de las ventanas y botones de SAP, y anexa ese texto al bloque para que sea buscable vectorialmente.
  • Extrae las imágenes a una carpeta estática (static_imgs/) para que el bot pueda devolverle al usuario un enlace directo para ver la captura original del manual si lo necesita.
  • Almacena todo en la colección manuales_sap en ChromaDB.

📁 Ruta: ~/sap_bot/indexar_manuales.py

Python

#!/usr/bin/env python3
"""
Indexador de Documentación Oficial SAP B1 (PDF, DOCX y TXT) con OCR y ChromaDB.
Guarda exclusivamente en la colección 'manuales_sap'.
"""
import os
import io
import re
import fitz  # PyMuPDF
import docx  # python-docx
import pytesseract
from PIL import Image
import chromadb
import ollama

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CARPETA_MANUALES = os.path.join(BASE_DIR, "manuales")
CARPETA_IMGS = os.path.join(BASE_DIR, "static_imgs")
DB_PATH = os.path.join(BASE_DIR, "chroma_sap_db")

CHUNK_SIZE = 900
CHUNK_OVERLAP = 150
MODELO_EMBED = "nomic-embed-text"

def limpiar_nombre(nombre: str) -> str:
    """Sanea los nombres de archivo para usarlos de forma segura en rutas e IDs."""
    return re.sub(r'[^a-zA-Z0-9_\-\.]', '_', nombre)

def fragmentar_texto(texto: str, tamano: int = CHUNK_SIZE, solapamiento: int = CHUNK_OVERLAP) -> list[str]:
    """Divide textos extensos en bloques con solapamiento para mantener contexto."""
    fragmentos = []
    inicio = 0
    while inicio < len(texto):
        fin = inicio + tamano
        fragmentos.append(texto[inicio:fin])
        inicio += tamano - solapamiento
    return fragmentos

def obtener_vector(texto: str) -> list[float]:
    """Genera el embedding usando el modelo local nomic-embed-text con keep_alive=-1."""
    res = ollama.embeddings(model=MODELO_EMBED, prompt=texto, keep_alive=-1)
    return res["embedding"]

def procesar_txt(ruta_txt: str, archivo: str, coleccion, contador_inicio: int) -> int:
    """Procesa e indexa archivos de texto plano."""
    try:
        with open(ruta_txt, "r", encoding="utf-8", errors="replace") as f:
            contenido = f.read().strip()
    except Exception as e:
        print(f"[-] Error leyendo TXT {archivo}: {e}")
        return contador_inicio

    if len(contenido) < 15:
        return contador_inicio

    bloques = fragmentar_texto(contenido)
    contador = contador_inicio

    for idx, bloque in enumerate(bloques):
        try:
            vector = obtener_vector(bloque)
            chunk_id = f"{archivo}_sec_{idx}_{contador}"
            coleccion.add(
                ids=[chunk_id],
                embeddings=[vector],
                documents=[bloque],
                metadatas=[{
                    "archivo": archivo,
                    "pagina": f"Sección {idx + 1}",
                    "imagenes": "",
                    "tipo": "manual_oficial"
                }]
            )
            contador += 1
        except Exception as err:
            print(f"[-] Error en vector TXT: {err}")

    return contador

def procesar_pdf(ruta_pdf: str, archivo: str, coleccion, contador_inicio: int) -> int:
    """Extrae texto, extrae imágenes incrustadas, aplica OCR e indexa PDFs."""
    doc = fitz.open(ruta_pdf)
    contador = contador_inicio
    nombre_base = limpiar_nombre(os.path.splitext(archivo)[0])

    for num_pagina in range(len(doc)):
        pagina = doc[num_pagina]
        texto = pagina.get_text().strip()
        imagenes = pagina.get_images(full=True)
        imagenes_guardadas = []

        if imagenes:
            for idx_img, img_info in enumerate(imagenes):
                try:
                    xref = img_info[0]
                    img_dict = doc.extract_image(xref)
                    img_pil = Image.open(io.BytesIO(img_dict["image"]))
                    
                    # Filtrar íconos decorativos pequeños
                    if img_pil.width > 120 and img_pil.height > 120:
                        nombre_img = f"{nombre_base}_p{num_pagina + 1}_img{idx_img}.png"
                        img_pil.save(os.path.join(CARPETA_IMGS, nombre_img))
                        imagenes_guardadas.append(nombre_img)

                        # Si la página tiene poco texto pero tiene una captura, aplicar OCR
                        if len(texto) < 120:
                            ocr = pytesseract.image_to_string(img_pil, lang="spa")
                            if ocr.strip():
                                texto += f"\n[Texto en captura {nombre_img}]:\n" + ocr.strip()
                except Exception:
                    continue

        if len(texto.strip()) < 30 and not imagenes_guardadas:
            continue

        bloques = fragmentar_texto(texto)
        str_imgs = ",".join(imagenes_guardadas)

        for idx, bloque in enumerate(bloques):
            try:
                vector = obtener_vector(bloque)
                chunk_id = f"{archivo}_p{num_pagina + 1}_b{idx}_{contador}"
                coleccion.add(
                    ids=[chunk_id],
                    embeddings=[vector],
                    documents=[bloque],
                    metadatas=[{
                        "archivo": archivo,
                        "pagina": str(num_pagina + 1),
                        "imagenes": str_imgs,
                        "tipo": "manual_oficial"
                    }]
                )
                contador += 1
            except Exception as err:
                print(f"[-] Error en vector PDF: {err}")

    return contador

def procesar_docx(ruta_docx: str, archivo: str, coleccion, contador_inicio: int) -> int:
    """Extrae texto, tablas formateadas e imágenes con OCR de documentos Word."""
    doc = docx.Document(ruta_docx)
    contador = contador_inicio
    nombre_base = limpiar_nombre(os.path.splitext(archivo)[0])
    contenido = []
    imagenes_guardadas = []

    for p in doc.paragraphs:
        if p.text.strip():
            contenido.append(p.text.strip())

    for t_idx, tabla in enumerate(doc.tables):
        filas = [" | ".join(c.text.strip() for c in f.cells if c.text.strip()) for f in tabla.rows]
        if filas:
            contenido.append(f"\n[Tabla {t_idx + 1}]:\n" + "\n".join(filas))

    idx_img = 0
    for part in doc.part.related_parts.values():
        if getattr(part, "content_type", "").startswith("image/"):
            try:
                img_pil = Image.open(io.BytesIO(part.blob))
                if img_pil.width > 120 and img_pil.height > 120:
                    nombre_img = f"{nombre_base}_word_img{idx_img}.png"
                    img_pil.save(os.path.join(CARPETA_IMGS, nombre_img))
                    imagenes_guardadas.append(nombre_img)
                    idx_img += 1

                    ocr = pytesseract.image_to_string(img_pil, lang="spa")
                    if ocr.strip():
                        contenido.append(f"\n[Texto en captura {nombre_img}]:\n" + ocr.strip())
            except Exception:
                continue

    texto_total = "\n\n".join(contenido).strip()
    if len(texto_total) < 30 and not imagenes_guardadas:
        return contador

    bloques = fragmentar_texto(texto_total)
    str_imgs = ",".join(imagenes_guardadas)

    for idx, bloque in enumerate(bloques):
        try:
            vector = obtener_vector(bloque)
            chunk_id = f"{archivo}_sec_{idx}_{contador}"
            coleccion.add(
                ids=[chunk_id],
                embeddings=[vector],
                documents=[bloque],
                metadatas=[{
                    "archivo": archivo,
                    "pagina": f"Sección {idx + 1}",
                    "imagenes": str_imgs,
                    "tipo": "manual_oficial"
                }]
            )
            contador += 1
        except Exception as err:
            print(f"[-] Error en vector Word: {err}")

    return contador

def ejecutar_indexacion():
    os.makedirs(CARPETA_MANUALES, exist_ok=True)
    os.makedirs(CARPETA_IMGS, exist_ok=True)
    client = chromadb.PersistentClient(path=DB_PATH)

    formatos = (".pdf", ".docx", ".txt")
    archivos = [f for f in os.listdir(CARPETA_MANUALES) if f.lower().endswith(formatos)]

    # Reiniciar ÚNICAMENTE la colección de manuales oficiales
    try:
        client.delete_collection(name="manuales_sap")
    except Exception:
        pass
    coleccion = client.create_collection(name="manuales_sap")

    if not archivos:
        print("[!] No hay archivos en 'manuales/'. Colección 'manuales_sap' creada vacía.")
        return

    contador = 0
    for archivo in archivos:
        ruta = os.path.join(CARPETA_MANUALES, archivo)
        print(f"[+] Indexando: {archivo}")
        if archivo.lower().endswith(".pdf"):
            contador = procesar_pdf(ruta, archivo, coleccion, contador)
        elif archivo.lower().endswith(".docx"):
            contador = procesar_docx(ruta, archivo, coleccion, contador)
        elif archivo.lower().endswith(".txt"):
            contador = procesar_txt(ruta, archivo, coleccion, contador)

    print(f"\n[✓] Indexación completada. Total de fragmentos indexados: {contador}")

if __name__ == "__main__":
    ejecutar_indexacion()

🤖 5. Paso 3: El Bot Asistente Principal (bot_spark.py)

Este es el script central que se conecta a Openfire como un usuario normal de Spark.

¿Qué hace exactamente cuando alguien le habla?

  1. Acuse de recibo en 0.1 segundos:
    • Si mandan texto: ⏳ *Déjeme checarlo.* Un momento mientras consulto la información de SAP B1...
    • Si mandan foto: ⏳ *Recibí tu captura, déjeme checarla.* Estoy analizando la pantalla y consultando los manuales de SAP...
  2. Streaming por párrafos: Va leyendo los tokens de Ollama y, en cuanto detecta un párrafo completo (\n\n) o una frase larga de más de 80 caracteres con un punto final, la dispara al chat de Spark. El usuario ve cómo van llegando los bloques de texto uno tras otro en tiempo real.
  3. Flujo visual inteligente: Cuando llega una imagen, el modelo qwen2.5vl:7b analiza la captura, extrae el texto del error y la última línea genera un término de búsqueda (BUSCAR_EN_RAG: <error>). Con ese término exacto, el bot va a ChromaDB, rescata la solución oficial del manual y redacta los pasos de resolución.
  4. Retroalimentación al vuelo: Al terminar la explicación, el bot remata:
    • 📌 *¿Te sirvió esta respuesta? Responde con SÍ o NO.*
    • Si el usuario contesta “Sí”, “Muchas gracias”, “Me sirvió”, el bot actualiza SQLite con feedback = 'UTIL'.
    • Si el usuario escribe “No”, “Está mal” o incluso “No la tabla es tal”, el bot marca feedback = 'NO_UTIL' para no incluir esa respuesta en su aprendizaje nocturno.

📁 Ruta: ~/sap_bot/bot_spark.py

Python

#!/usr/bin/env python3
"""
Bot Asistente SAP B1 para Spark IM (XMPP + Ollama + ChromaDB + SQLite).
Flujo dual con acuse inmediato, visión multimodal, streaming por párrafos,
doble RAG (manuales + autoaprendizaje) y retroalimentación interactiva (SÍ/NO).
"""
import os
import re
import ssl
import asyncio
import sqlite3
import tempfile
from datetime import datetime
import requests
import urllib3
import slixmpp
import chromadb
import ollama

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

# ================= CONFIGURACIÓN DEL SERVIDOR =================
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(BASE_DIR, "chroma_sap_db")
DB_LOGS_PATH = os.path.join(BASE_DIR, "historial_conversaciones.db")

# Datos de conexión Openfire / Spark (Modifica con tus credenciales)
JID_BOT = "BothJulio@192.168.0.176"
PASSWORD_BOT = "BothJulio"
HOST_SERVIDOR = "192.168.0.176"
PUERTO_XMPP = 5222

# Servidor HTTP para servir capturas extraídas de manuales
URL_BASE_IMGS = f"http://{HOST_SERVIDOR}:8080"

# Modelos en Ollama
MODELO_LLM = "qwen2.5:7b"          # Razonamiento técnico y redacción
MODELO_VISION = "qwen2.5vl:7b"      # OCR y diagnóstico visual de capturas
MODELO_EMBED = "nomic-embed-text"  # Vectorización
# ==============================================================

REGEX_URL_IMAGEN = r'(https?://[^\s]+\.(?:png|jpg|jpeg|bmp|webp)(?:\?[^\s]*)?|https?://[^\s]+/httpfileupload/[^\s]+)'

def inicializar_bd_logs():
    """Crea la tabla de SQLite con soporte WAL y columnas de retroalimentación."""
    with sqlite3.connect(DB_LOGS_PATH, timeout=10.0) as conn:
        cursor = conn.cursor()
        cursor.execute("PRAGMA journal_mode=WAL;")
        cursor.execute("""
            CREATE TABLE IF NOT EXISTS conversaciones (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                fecha_hora TEXT,
                usuario_spark TEXT,
                pregunta TEXT,
                tipo_fuente TEXT,
                respuesta TEXT,
                feedback TEXT DEFAULT NULL,
                indexado INTEGER DEFAULT 0
            )
        """)
        try:
            cursor.execute("ALTER TABLE conversaciones ADD COLUMN feedback TEXT DEFAULT NULL")
        except sqlite3.OperationalError:
            pass
        try:
            cursor.execute("ALTER TABLE conversaciones ADD COLUMN indexado INTEGER DEFAULT 0")
        except sqlite3.OperationalError:
            pass
        conn.commit()

def registrar_log(usuario: str, pregunta: str, tipo_fuente: str, respuesta: str) -> int:
    """Inserta la conversación en SQLite y devuelve el ID generado."""
    try:
        with sqlite3.connect(DB_LOGS_PATH, timeout=10.0) as conn:
            cursor = conn.cursor()
            cursor.execute("""
                INSERT INTO conversaciones (fecha_hora, usuario_spark, pregunta, tipo_fuente, respuesta)
                VALUES (?, ?, ?, ?, ?)
            """, (
                datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
                usuario,
                pregunta,
                tipo_fuente,
                respuesta
            ))
            conn.commit()
            return cursor.lastrowid
    except Exception as err:
        print(f"[-] Error registrando log SQLite: {err}")
        return 0

def actualizar_feedback(id_conversacion: int, valor: str):
    """Actualiza la calificación (UTIL / NO_UTIL) de una respuesta específica."""
    try:
        with sqlite3.connect(DB_LOGS_PATH, timeout=10.0) as conn:
            cursor = conn.cursor()
            cursor.execute("UPDATE conversaciones SET feedback = ? WHERE id = ?", (valor, id_conversacion))
            conn.commit()
    except Exception as err:
        print(f"[-] Error guardando feedback: {err}")

class SparkAIBot(slixmpp.ClientXMPP):
    def __init__(self, jid: str, password: str):
        super().__init__(jid, password)
        self.add_event_handler("session_start", self.on_start)
        self.add_event_handler("message", self.on_message)

        # Aceptar certificados SSL locales o autofirmados de Openfire
        self.ssl_context = ssl.create_default_context()
        self.ssl_context.check_hostname = False
        self.ssl_context.verify_mode = ssl.CERT_NONE

        # Cargar las dos colecciones independientes de ChromaDB
        self.chroma_client = chromadb.PersistentClient(path=DB_PATH)
        self.col_manuales = self.chroma_client.get_or_create_collection(name="manuales_sap")
        self.col_casos = self.chroma_client.get_or_create_collection(name="casos_resueltos")

        # Diccionario para asociar cada usuario con su última respuesta pendiente de feedback
        self.pendientes_feedback = {}

    async def on_start(self, event):
        self.send_presence(pstatus="Asistente SAP B1 En Línea")
        await self.get_roster()
        print(f"\n[✓] Asistente conectado a Openfire como: {self.boundjid.bare}")
        asyncio.create_task(self.precargar_modelos())

    async def precargar_modelos(self):
        """Warm-up: Precarga los modelos en memoria al iniciar para eliminar el cold-start."""
        print("[*] Precargando modelos en memoria (Warm-up)...")
        try:
            await asyncio.to_thread(
                ollama.embeddings,
                model=MODELO_EMBED,
                prompt="arranque",
                keep_alive=-1
            )
            await asyncio.to_thread(
                ollama.chat,
                model=MODELO_LLM,
                messages=[{"role": "user", "content": "hola"}],
                options={"num_predict": 1},
                keep_alive=-1
            )
            print("[✓] Modelos fijados en memoria. Respuestas inmediatas activas.")
        except Exception as e:
            print(f"[!] Advertencia al precargar: {e}")

    def notificar_escribiendo(self, destinatario: str):
        try:
            msg = self.make_message(mto=destinatario, mtype='chat')
            msg['chat_state'] = 'composing'
            msg.send()
        except Exception:
            pass

    def notificar_inactivo(self, destinatario: str):
        try:
            msg = self.make_message(mto=destinatario, mtype='chat')
            msg['chat_state'] = 'active'
            msg.send()
        except Exception:
            pass

    def enviar_mensaje_directo(self, destinatario: str, texto: str):
        if not texto.strip():
            return
        try:
            msg = self.make_message(mto=destinatario, mbody=texto.strip(), mtype='chat')
            msg.send()
        except Exception as e:
            print(f"[-] Error enviando mensaje a Spark: {e}")

    async def mantener_escribiendo(self, destinatario: str):
        """Mantiene activo el estado 'Escribiendo...' cada 4 segundos mientras el modelo procesa."""
        try:
            while True:
                self.notificar_escribiendo(destinatario)
                await asyncio.sleep(4)
        except asyncio.CancelledError:
            pass

    def extraer_url_captura(self, msg) -> str | None:
        """Detecta capturas subidas a través de HTTP File Upload o pegadas como URL."""
        try:
            url_oob = msg['oob']['url']
            if url_oob:
                return url_oob
        except Exception:
            pass

        texto = msg["body"].strip()
        coincidencias = re.findall(REGEX_URL_IMAGEN, texto, re.IGNORECASE)
        if coincidencias:
            return coincidencias[0]

        return None

    def descargar_captura_temp(self, url: str) -> str | None:
        """Descarga la captura de Openfire a un archivo temporal local."""
        try:
            res = requests.get(url, timeout=12, verify=False)
            if res.status_code == 200:
                tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".png")
                tmp.write(res.content)
                tmp.close()
                return tmp.name
        except Exception as e:
            print(f"[-] Error descargando captura: {e}")
        return None

    async def on_message(self, msg):
        if msg["type"] not in ("chat", "normal"):
            return

        cuerpo = msg["body"].strip()
        remitente = msg["from"].bare
        url_imagen = self.extraer_url_captura(msg)

        if not cuerpo and not url_imagen:
            return

        # -----------------------------------------------------------------
        # 1. EVALUAR FEEDBACK (SÍ / NO) DE LA RESPUESTA ANTERIOR
        # -----------------------------------------------------------------
        cuerpo_limpio = cuerpo.lower().strip()
        id_anterior = self.pendientes_feedback.get(remitente)

        if id_anterior and not url_imagen:
            respuestas_si = ("si", "sí", "yes", "sirvio", "sirvió", "me sirvio", "me sirvió", "gracias", "funciono", "funcionó", "excelente", "ok", "listo", "correcto", "exacto")
            respuestas_no = ("no", "nel", "no sirvio", "no sirvió", "no me sirvio", "no funciono", "no funcionó", "tampoco", "incorrecto", "mal", "falso")

            if cuerpo_limpio in respuestas_si:
                actualizar_feedback(id_anterior, "UTIL")
                del self.pendientes_feedback[remitente]
                msg.reply("✅ *¡Excelente!* He guardado esta solución para recordarla y ayudar a otros compañeros en el futuro.").send()
                return

            elif cuerpo_limpio in respuestas_no or cuerpo_limpio.startswith("no ") or cuerpo_limpio.startswith("no,"):
                actualizar_feedback(id_anterior, "NO_UTIL")
                del self.pendientes_feedback[remitente]
                msg.reply("❌ *Entendido.* He marcado la respuesta anterior como incorrecta para que no vuelva a sugerirse. Gracias por corregirme.").send()
                return

        # -----------------------------------------------------------------
        # 2. PROCESAMIENTO DE CONSULTA NORMAL (CON ACUSE DE RECIBO)
        # -----------------------------------------------------------------
        print(f"[+] Consulta de [{remitente}] | Tiene imagen: {bool(url_imagen)}")

        # Acuse de recibo instantáneo (< 0.2 seg)
        if url_imagen:
            self.enviar_mensaje_directo(
                remitente,
                "⏳ *Recibí tu captura, déjeme checarla.* Estoy analizando la pantalla y consultando los manuales de SAP..."
            )
        else:
            self.enviar_mensaje_directo(
                remitente,
                "⏳ *Déjeme checarlo.* Un momento mientras consulto la información de SAP B1..."
            )

        tarea_escribiendo = asyncio.create_task(self.mantener_escribiendo(remitente))
        loop = asyncio.get_running_loop()

        def callback_envio(parrafo: str):
            loop.call_soon_threadsafe(self.enviar_mensaje_directo, remitente, parrafo)

        try:
            id_conversacion = await asyncio.to_thread(
                self.procesar_consulta_router, remitente, cuerpo, url_imagen, callback_envio
            )
            # Solicitar feedback interactivo si se generó una respuesta
            if id_conversacion:
                self.pendientes_feedback[remitente] = id_conversacion
                await asyncio.sleep(1)
                self.enviar_mensaje_directo(
                    remitente,
                    "📌 *¿Te sirvió esta respuesta? Responde con SÍ o NO.*"
                )
        finally:
            tarea_escribiendo.cancel()
            self.notificar_inactivo(remitente)

    def procesar_consulta_router(self, remitente: str, pregunta: str, url_imagen: str | None, callback_envio) -> int:
        if url_imagen:
            return self.procesar_con_captura(remitente, pregunta, url_imagen, callback_envio)
        else:
            return self.procesar_solo_texto(remitente, pregunta, callback_envio)

    def transmitir_en_parrafos(self, generador_stream, callback_envio) -> str:
        """Agrupa tokens y los despacha a Spark por bloques lógicos y párrafos."""
        buffer = ""
        texto_acumulado = ""
        primer_envio_hecho = False

        for chunk in generador_stream:
            delta = chunk.get("message", {}).get("content", "")
            buffer += delta
            texto_acumulado += delta

            # Despacho rápido de la primera frase para romper la latencia percibida
            if not primer_envio_hecho and len(buffer) > 80:
                if "\n" in buffer or ". " in buffer:
                    separador = "\n" if "\n" in buffer else ". "
                    parte, buffer = buffer.split(separador, 1)
                    if separador == ". ":
                        parte += "."
                    if parte.strip():
                        callback_envio(parte.strip())
                        primer_envio_hecho = True

            while "\n\n" in buffer:
                parrafo, buffer = buffer.split("\n\n", 1)
                parrafo = parrafo.strip()
                if parrafo:
                    callback_envio(parrafo)
                    primer_envio_hecho = True

        if buffer.strip():
            callback_envio(buffer.strip())

        return texto_acumulado.strip()

    def consultar_rag_hibrido(self, texto_busqueda: str) -> tuple[str, list[str], list[str]]:
        """Consulta simultáneamente la documentación oficial y los casos aprendidos."""
        fragmentos = []
        referencias = []
        imagenes = []

        res_emb = ollama.embeddings(model=MODELO_EMBED, prompt=texto_busqueda, keep_alive=-1)
        vector = res_emb["embedding"]

        # 1. Buscar en manuales oficiales
        total_manuales = self.col_manuales.count()
        if total_manuales > 0:
            k = min(3, total_manuales)
            coincidencias = self.col_manuales.query(query_embeddings=[vector], n_results=k)
            if coincidencias and coincidencias["documents"] and coincidencias["documents"][0]:
                for doc_txt, meta in zip(coincidencias["documents"][0], coincidencias["metadatas"][0]):
                    fragmentos.append(f"[DOCUMENTO OFICIAL: {meta.get('archivo', 'Manual')}]:\n{doc_txt}")
                    referencias.append(f"{meta.get('archivo', 'Manual')} (Pág. {meta.get('pagina', 'N/A')})")
                    imgs_str = meta.get("imagenes", "")
                    if imgs_str:
                        for img in imgs_str.split(","):
                            if img.strip():
                                imagenes.append(img.strip())

        # 2. Buscar en casos resueltos por usuarios (Autoaprendizaje)
        total_casos = self.col_casos.count()
        if total_casos > 0:
            k_casos = min(2, total_casos)
            coincidencias_casos = self.col_casos.query(query_embeddings=[vector], n_results=k_casos)
            if coincidencias_casos and coincidencias_casos["documents"] and coincidencias_casos["documents"][0]:
                for doc_txt, meta in zip(coincidencias_casos["documents"][0], coincidencias_casos["metadatas"][0]):
                    fragmentos.append(f"[CASO RESUELTO PREVIAMENTE ({meta.get('pagina', '')})]:\n{doc_txt}")
                    referencias.append(f"Experiencia interna ({meta.get('pagina', '')})")

        contexto = "\n---\n".join(fragmentos) if fragmentos else "NO_HAY_MANUALES_DISPONIBLES"
        return contexto, referencias, imagenes

    # ==============================================================
    # FLUJO 1: CONSULTAS DE TEXTO
    # ==============================================================
    def procesar_solo_texto(self, remitente: str, pregunta: str, callback_envio) -> int:
        try:
            contexto, referencias, imagenes_adjuntas = self.consultar_rag_hibrido(pregunta)

            prompt_sistema = (
                "Eres un consultor senior experto en SAP Business One de la empresa.\n"
                "Tu objetivo es resolver dudas operativas, funcionales y técnicas sobre SAP B1, SQL/HANA y flujos del sistema.\n\n"
                "REGLAS ESTRICTAS DE TABLAS EN SAP B1:\n"
                "- Las tablas de cabecera SIEMPRE inician con 'O' seguida de 3 letras (ej: OPOR para Pedidos de Compra, ORDR para Pedidos de Venta, OINV para Facturas de Venta, OPCH para Facturas de Compra, ODLN para Entregas, OPDN para Entradas de Mercancía).\n"
                "- Las tablas de líneas pierden la 'O' y terminan en 1 (ej: POR1, RDR1, INV1, PCH1, DLN1, PDN1).\n"
                "- NUNCA inventes nombres de tablas como PCHDR, PCTLR, DRFD o términos en inglés inventados. Si no conoces la tabla con certeza o no está en la documentación, dilo honestamente sin inventar.\n\n"
                "REGLAS GENERALES:\n"
                "1. Si la DOCUMENTACIÓN CONOCIDA contiene la respuesta, responde con base en ella con total precisión.\n"
                "2. Si la DOCUMENTACIÓN CONOCIDA indica 'NO_HAY_MANUALES_DISPONIBLES' o no contiene la respuesta, responde usando tu conocimiento estándar de SAP B1, pero inicia con:\n"
                "   '⚠️ *Nota: Esta respuesta se basa en el estándar general de SAP B1 y no en un manual interno documentado.*'\n"
                "3. Si la pregunta no tiene relación con temas de trabajo o sistemas de la empresa, recházala amablemente.\n\n"
                f"DOCUMENTACIÓN CONOCIDA (OFICIAL Y CASOS RESUELTOS):\n{contexto}"
            )

            stream_chat = ollama.chat(
                model=MODELO_LLM,
                messages=[
                    {"role": "system", "content": prompt_sistema},
                    {"role": "user", "content": pregunta}
                ],
                options={"temperature": 0.15, "num_ctx": 2048},
                keep_alive=-1,
                stream=True
            )

            cuerpo = self.transmitir_en_parrafos(stream_chat, callback_envio)

            cierre = ""
            if "⚠️ *Nota:" in cuerpo:
                tipo_fuente = "ESTANDAR_SAP"
            elif referencias and contexto != "NO_HAY_MANUALES_DISPONIBLES":
                tipo_fuente = "MANUAL_O_CASO"
                refs_unicas = list(dict.fromkeys(referencias))
                cierre += f"\n📖 *Fuente de consulta:* {', '.join(refs_unicas)}"

                if imagenes_adjuntas:
                    imgs_unicas = list(dict.fromkeys(imagenes_adjuntas))[:2]
                    cierre += "\n📸 *Capturas del manual:*"
                    for img_name in imgs_unicas:
                        cierre += f"\n• {URL_BASE_IMGS}/{img_name}"
            else:
                tipo_fuente = "GENERAL_O_RECHAZO"

            if cierre:
                callback_envio(cierre.strip())
                cuerpo += "\n\n" + cierre

            return registrar_log(remitente, pregunta, tipo_fuente, cuerpo)

        except Exception as err:
            error_msg = f"Error al procesar la consulta: {str(err)}"
            callback_envio(error_msg)
            registrar_log(remitente, pregunta, "ERROR", error_msg)
            return 0

    # ==============================================================
    # FLUJO 2: CONSULTAS CON CAPTURA DE PANTALLA (MULTIMODAL)
    # ==============================================================
    def procesar_con_captura(self, remitente: str, texto_usuario: str, url_imagen: str, callback_envio) -> int:
        ruta_temp = None
        try:
            ruta_temp = self.descargar_captura_temp(url_imagen)
            if not ruta_temp:
                callback_envio("No fue posible descargar la captura de pantalla para analizarla. Intenta reenviarla.")
                return 0

            # Paso A: El modelo de visión extrae el error de la imagen
            prompt_extraccion = (
                "Eres un especialista en soporte técnico de SAP Business One.\n"
                "Examina con atención esta captura de pantalla:\n"
                "1. Localiza el mensaje de error o bloqueo. Revisa la barra de estado inferior (texto en rojo), "
                "ventanas modales de alerta o mensajes en cuadrícula.\n"
                "2. Transcribe el texto exacto del error y cualquier código numérico (ej: [-10], 131-183, etc.).\n"
                "3. En la última línea de tu respuesta escribe exactamente:\n"
                "   BUSCAR_EN_RAG: <código o términos clave del error>"
            )
            if texto_usuario:
                prompt_extraccion += f"\nMensaje adjunto del usuario: {texto_usuario}"

            res_vision = ollama.chat(
                model=MODELO_VISION,
                messages=[{
                    "role": "user",
                    "content": prompt_extraccion,
                    "images": [ruta_temp]
                }],
                options={"temperature": 0.1},
                keep_alive=-1
            )

            diagnostico_visual = res_vision["message"]["content"].strip()

            termino_rag = ""
            for linea in diagnostico_visual.splitlines():
                if "BUSCAR_EN_RAG:" in linea:
                    termino_rag = linea.replace("BUSCAR_EN_RAG:", "").strip()

            if not termino_rag:
                termino_rag = diagnostico_visual[:150]

            # Paso B: Buscar en RAG con los términos extraídos de la pantalla
            consulta_combinada = f"{texto_usuario} {termino_rag}".strip()
            contexto, referencias, _ = self.consultar_rag_hibrido(consulta_combinada)

            # Paso C: Redacción de la solución estructurada
            prompt_solucion = (
                "Eres un consultor senior de soporte SAP Business One.\n"
                "Tu trabajo es explicar el error que ves en el análisis de la pantalla y dar la solución inmediata.\n\n"
                "REGLAS DE TABLAS EN SAP B1:\n"
                "- Si mencionas tablas, respeta estrictamente los prefijos estándar (OPOR/POR1, ORDR/RDR1, OINV/INV1, OPCH/PCH1, etc.). No inventes nombres.\n\n"
                "ESTRUCTURA OBLIGATORIA (Usa saltos de línea dobles entre secciones):\n"
                "🛑 *Error Detectado*: Mensaje o código de error presente en la captura.\n\n"
                "🔍 *Causa Probable*: Razón del bloqueo (ej: período cerrado, falta de stock, autorizaciones, cuenta contable sin definir).\n\n"
                "🛠️ *Solución Recomendada*: Ruta de menú en SAP B1 y pasos exactos para solucionarlo.\n\n"
                "REGLAS:\n"
                "1. Si la DOCUMENTACIÓN CONOCIDA tiene la solución para este error, síguela al pie de la letra.\n"
                "2. Si no hay documentación interna para este caso, inicia la respuesta con:\n"
                "   '⚠️ *Nota: Esta respuesta se basa en el estándar general de SAP B1 y no en un manual interno documentado.*'\n\n"
                f"DOCUMENTACIÓN CONOCIDA:\n{contexto}"
            )

            contenido_user = f"Consulta del usuario: {texto_usuario if texto_usuario else 'Revisa el error de la captura adjunta.'}\n"
            contenido_user += f"\n[Lectura del error en la captura]:\n{diagnostico_visual}"

            stream_final = ollama.chat(
                model=MODELO_LLM,
                messages=[
                    {"role": "system", "content": prompt_solucion},
                    {"role": "user", "content": contenido_user}
                ],
                options={"temperature": 0.15, "num_ctx": 4096},
                keep_alive=-1,
                stream=True
            )

            cuerpo = self.transmitir_en_parrafos(stream_final, callback_envio)

            cierre = ""
            if "⚠️ *Nota:" in cuerpo:
                tipo_fuente = "ESTANDAR_SAP"
            elif referencias and contexto != "NO_HAY_MANUALES_DISPONIBLES":
                tipo_fuente = "MANUAL_O_CASO"
                refs_unicas = list(dict.fromkeys(referencias))
                cierre += f"\n📖 *Fuente de consulta:* {', '.join(refs_unicas)}"
            else:
                tipo_fuente = "ERROR_VISUAL"

            if cierre:
                callback_envio(cierre)
                cuerpo += "\n\n" + cierre

            return registrar_log(remitente, f"[CAPTURA] {texto_usuario}", tipo_fuente, cuerpo)

        except Exception as err:
            error_msg = f"Error al analizar la captura: {str(err)}"
            callback_envio(error_msg)
            registrar_log(remitente, texto_usuario, "ERROR", error_msg)
            return 0
        finally:
            if ruta_temp and os.path.exists(ruta_temp):
                try:
                    os.remove(ruta_temp)
                except Exception:
                    pass

def main():
    inicializar_bd_logs()
    bot = SparkAIBot(JID_BOT, PASSWORD_BOT)
    bot.register_plugin("xep_0030")  # Service Discovery
    bot.register_plugin("xep_0199")  # XMPP Ping
    bot.register_plugin("xep_0085")  # Estado 'Escribiendo...' (Chat States)
    bot.register_plugin("xep_0066")  # Recepción de URLs Out-of-Band (Capturas)

    print(f"[*] Conectando a Openfire en {HOST_SERVIDOR}:{PUERTO_XMPP}...")
    bot.connect(host=HOST_SERVIDOR, port=PUERTO_XMPP)

    loop = getattr(bot, "loop", None) or asyncio.get_event_loop()
    try:
        loop.run_forever()
    except KeyboardInterrupt:
        bot.disconnect()

if __name__ == "__main__":
    main()

🌙 6. Paso 4: El Ciclo de Autoaprendizaje Nocturno (retroalimentar_diario.py)

Hacer un fine-tuning diario de un LLM es impensable: necesitarías servidores con 4 tarjetas gráficas A100 y horas de cómputo. La forma en que las grandes empresas resuelven el aprendizaje continuo en producción es a través de memoria episódica indexada.

Cada noche a las 2:00 AM, este script se ejecuta mediante una tarea Cron en Ubuntu:

  1. Conecta con SQLite y extrae todos los casos donde el usuario respondió afirmativamente (feedback = 'UTIL') y que tengan indexado = 0.
  2. Estructura el problema y la solución en un formato limpio de “Caso Resuelto”.
  3. Genera los vectores con nomic-embed-text y los inyecta en la colección casos_resueltos.
  4. Marca el registro como indexado = 1 para no duplicarlo jamás.

Al día siguiente, cuando un compañero de otro departamento tenga un problema idéntico o similar, el bot buscará en ChromaDB, encontrará la solución que funcionó ayer y responderá:

📖 Fuente de consulta: Experiencia interna (Caso #42).

📁 Ruta: ~/sap_bot/retroalimentar_diario.py

Python

#!/usr/bin/env python3
"""
Script diario de autoaprendizaje para el Asistente SAP B1.
Toma los casos validados con 'SÍ' en SQLite y los agrega a 'casos_resueltos'.
"""
import os
import sqlite3
import chromadb
import ollama

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(BASE_DIR, "chroma_sap_db")
DB_LOGS_PATH = os.path.join(BASE_DIR, "historial_conversaciones.db")
MODELO_EMBED = "nomic-embed-text"

def ejecutar_retroalimentacion():
    print("[*] Iniciando proceso de retroalimentación diaria...")

    if not os.path.exists(DB_LOGS_PATH):
        print("[!] No existe la base de datos de logs aún.")
        return

    # 1. Obtener casos validados como útiles que no han sido indexados
    with sqlite3.connect(DB_LOGS_PATH, timeout=10.0) as conn:
        cursor = conn.cursor()
        cursor.execute("""
            SELECT id, fecha_hora, usuario_spark, pregunta, respuesta
            FROM conversaciones
            WHERE feedback = 'UTIL' AND indexado = 0
        """)
        casos = cursor.fetchall()

    if not casos:
        print("[✓] No hay nuevos casos validados pendientes por indexar.")
        return

    print(f"[+] Se encontraron {len(casos)} casos exitosos validados por usuarios.")

    # 2. Conectar a la colección independiente de casos resueltos
    chroma_client = chromadb.PersistentClient(path=DB_PATH)
    col_casos = chroma_client.get_or_create_collection(name="casos_resueltos")

    ids_actualizados = []

    for id_conv, fecha, usuario, pregunta, respuesta in casos:
        try:
            texto_caso = (
                f"CASO REAL RESUELTO ({fecha}):\n"
                f"Problema / Error reportado: {pregunta}\n"
                f"Solución comprobada que funcionó:\n{respuesta}"
            )

            res = ollama.embeddings(model=MODELO_EMBED, prompt=texto_caso, keep_alive=-1)
            chunk_id = f"caso_aprendido_{id_conv}"

            col_casos.add(
                ids=[chunk_id],
                embeddings=[res["embedding"]],
                documents=[texto_caso],
                metadatas=[{
                    "archivo": "Casos Resueltos en Vivo",
                    "pagina": f"Caso #{id_conv}",
                    "usuario": usuario,
                    "tipo": "auto_aprendizaje"
                }]
            )
            ids_actualizados.append(id_conv)
            print(f" [✓] Caso #{id_conv} indexado exitosamente.")
        except Exception as e:
            print(f" [-] Error indexando caso #{id_conv}: {e}")

    # 3. Marcar como indexados en SQLite
    if ids_actualizados:
        with sqlite3.connect(DB_LOGS_PATH, timeout=10.0) as conn:
            cursor = conn.cursor()
            cursor.execute(f"""
                UPDATE conversaciones 
                SET indexado = 1 
                WHERE id IN ({','.join('?' for _ in ids_actualizados)})
            """, ids_actualizados)
            conn.commit()

    print(f"[✓] Proceso completado. Se integraron {len(ids_actualizados)} nuevas soluciones a ChromaDB.\n")

if __name__ == "__main__":
    ejecutar_retroalimentacion()

⚙️ 7. Paso 5: Puesta en Producción en Ubuntu Server

Para que todo esto funcione de forma permanente sin necesidad de dejar una terminal abierta con python3 bot_spark.py, lo configuramos como un servicio nativo del sistema con systemd y programamos el Cron.

1. Dar permisos de ejecución a los scripts

Bash

chmod +x ~/sap_bot/indexar_manuales.py
chmod +x ~/sap_bot/bot_spark.py
chmod +x ~/sap_bot/retroalimentar_diario.py

2. Indexar por primera vez

Asegúrate de haber copiado tus manuales (PDF, Word) y el archivo tablas_sap_business_one.txt a la carpeta manuales/ y ejecuta:

Bash

python3 ~/sap_bot/indexar_manuales.py

Verás cómo procesa los documentos, ejecuta OCR donde hace falta y te reporta el número total de fragmentos vectorizados.

3. Programar el Cron nocturno

Abre el editor de cron en tu usuario de Ubuntu:

Bash

crontab -e

Agrega esta línea al final del archivo para que la retroalimentación se ejecute a las 2:00 AM todos los días:

Bash

0 2 * * * /usr/bin/python3 /home/ubuntu/sap_bot/retroalimentar_diario.py >> /home/ubuntu/sap_bot/retroalimentacion.log 2>&1

4. Crear el servicio systemd (sap_bot.service)

Crea el archivo de servicio para que Linux inicie el bot tras cualquier reinicio del servidor y lo reviva automáticamente si se cae:

Bash

sudo nano /etc/systemd/system/sap_bot.service

Pega la siguiente configuración:

Ini, TOML

[Unit]
Description=Asistente IA para SAP Business One en Spark/XMPP
After=network.target ollama.service

[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/sap_bot
ExecStart=/usr/bin/python3 /home/ubuntu/sap_bot/bot_spark.py
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1

[Install]
WantedBy=multi-user.target

Habilita y arranca el servicio:

Bash

sudo systemctl daemon-reload
sudo systemctl enable sap_bot
sudo systemctl start sap_bot

Para verificar que el bot está conectado y operando en tiempo real:

Bash

sudo journalctl -u sap_bot -f

Deberías ver la línea que confirma la conexión:

Plaintext

[✓] Asistente conectado a Openfire como: bothjulio@192.168.0.176
[*] Precargando modelos en memoria (Warm-up)...
[✓] Modelos fijados en memoria. Respuestas inmediatas activas.

💬 8. La Prueba de Fuego: Una conversación real en Spark

Así se ve una interacción real en la ventana de chat de un usuario cualquiera en la empresa:

Usuario (10:15 AM):

¿Cuál es la tabla de los pedidos de compra en SAP?

Bot (10:15 AM – 0.1s después):

⏳ Déjeme checarlo. Un momento mientras consulto la información de SAP B1…

Bot (10:15 AM – 3s después – Streaming):

En SAP Business One, los Pedidos de Compra (también conocidos como Órdenes de Compra a Proveedor) se gestionan en las siguientes tablas:

OPOR: Tabla de encabezado / cabecera (almacena proveedor, fechas, totales, moneda).

POR1: Tabla de detalle / líneas (almacena artículos, cantidades, precios unitarios, almacén).

📖 Fuente de consulta: tablas_sap_business_one.txt (Sección 1)

Bot (10:15 AM):

📌 ¿Te sirvió esta respuesta? Responde con SÍ o NO.

Usuario (10:16 AM):

Sí muchas gracias

Bot (10:16 AM):

✅ ¡Excelente! He guardado esta solución para recordarla y ayudar a otros compañeros en el futuro.

Y cuando un usuario le arrastra una captura de pantalla donde aparece la temida barra roja con el error:

[-10] La cuenta de mayor para costos de ventas no se ha definido:

Bot (10:20 AM):

⏳ Recibí tu captura, déjeme checarla. Estoy analizando la pantalla y consultando los manuales de SAP…

Bot (10:20 AM – Streaming):

🛑 Error Detectado: [-10] La cuenta de mayor para costos de ventas no se ha definido.

🔍 Causa Probable: El artículo que intentas mover o facturar está configurado para determinar cuentas contables a nivel de Almacén o Grupo de Artículos, pero falta asignar la cuenta contable de Costo de Ventas en las definiciones contables correspondientes.

🛠️ Solución Recomendada:

1. Ve al menú principal de SAP B1: Gestión > Definiciones > Finanzas > Determinación de cuentas de mayor.

2. Dirígete a la pestaña ‘Inventario’.

3. Revisa la sub-pestaña ‘General’ y localiza la fila ‘Coste de mercancías vendidas’ (Cost of Goods Sold).

4. Asigna la cuenta contable de costo correspondiente y presiona el botón ‘Actualizar’.

📖 Fuente de consulta: MANUAL_DE_CONFIGURACION_CONTABLE.pdf (Pág. 14)

📌 ¿Te sirvió esta respuesta? Responde con SÍ o NO.

🎯 Conclusiones del Proyecto

Desplegar Inteligencia Artificial en el mundo corporativo real no consiste en conectar la API más cara del mercado para presumir que usas IA; consiste en resolver problemas reales de soporte reduciendo la fricción para los usuarios y blindando la seguridad de los datos.

Al combinar herramientas de código abierto probadas como Openfire/Spark, la versatilidad de Slixmpp, la potencia local de Ollama (Qwen 2.5 y Qwen 2.5-VL) y una arquitectura de RAG híbrido con autoaprendizaje en ChromaDB, logramos:

  • 📉 Reducir en más de un 70% los tickets repetitivos de soporte de primer nivel en SAP Business One.
  • 🛡️ Mantener la información fiscal, operativa y contable 100% dentro de la red corporativa.
  • ⚡ Dar respuestas en segundos a cualquier hora del día.
  • 📈 Conseguir un sistema que, gracias a la retroalimentación directa de los usuarios con un simple “SÍ”, se vuelve un poco más inteligente cada noche a las dos de la mañana.

Si tienes un servidor Linux libre en tu empresa y la gente sigue perdiendo tiempo preguntando qué tabla usar o qué significa un error de SAP, ya tienes la solución completa lista para implementar. 🚀💻