Herramientas Informaticas

Mes: septiembre 2026

🥁🤖 ¿Bateristas 3D hiperrealistas tocando tus pistas MIDI en tiempo real? Sí, ya es una realidad y el nivel de detalle vuela la cabeza. 🤯🔥

Entrada fija

No estamos hablando de animaciones pregrabadas en bucle donde el personaje “hace como que toca”. Son avatares digitales (como los MetaHumans de Unreal Engine) cuyos brazos, muñecas y baquetas se mueven de forma procedimental siguiendo cada golpe, matiz y dinámica exacta de un archivo MIDI o de una batería electrónica tocada en directo.

🧠 ¿Por qué se ven tan naturales?

  • ⏳ El truco de la anticipación (Look-ahead): En la vida real, levantas el brazo antes de golpear. El sistema lee el MIDI unos milisegundos antes del audio para calcular el recorrido previo del impacto.
  • 🎯 Cinemática Inversa (IK): Las puntas de las baquetas se dirigen a coordenadas milimétricas exactas en los parches y platillos sin desfasarse.
  • 💥 Sensibilidad al golpe (Velocity): A mayor valor MIDI, más amplio es el movimiento corporal y más vibran los platillos gracias a motores de físicas secundarias.

🛠️ ¿Cómo empezar a armar el tuyo?

Existen dos rutas principales según tu objetivo:

1. Ruta Render / Animación (Blender) 🎬

Ideal para crear videoclips o tomas cinemáticas sin programar motores de juego.

  1. Consigue tu modelo: Descarga o modela un personaje con rig completo (brazos, manos y pies con cadenas IK).
  2. Importa el MIDI: Usa scripts de Python comunitarios para Blender (herramientas de MIDI-to-animation o bakeo de curvas).
  3. Mapea huesos a notas: Asigna notas específicas (ej. C1 al bombo, D1 a la tarola) para generar keyframes automáticos en los huesos.

2. Ruta Tiempo Real / Interactivo (Unreal Engine 5) ⚡

Ideal para directos, streaming con batería electrónica o interactividad instantánea.

  1. Crea el personaje: Añade un MetaHuman gratuito desde Quixel Bridge a tu proyecto.
  2. Configura el Control Rig: Define cadenas IK para ambos brazos y piernas, fijando los objetivos (targets) en las baquetas y pedales.
  3. Coloca sockets en la batería 3D: Agrega puntos de anclaje espaciales en cada tambor y platillo para que sirvan de destino al impacto.
  4. Habilita el plugin MIDI: Activa el plugin oficial MIDI en Unreal Engine y vincula las entradas de notas con triggers en Blueprints para disparar trayectorias.
  5. Conecta tu fuente: Conecta tu batería electrónica por USB o envía notas desde tu DAW con un puerto MIDI virtual.

🖥️ Requerimientos mínimos recomendados

Mover modelos de alta fidelidad con cinemática y físicas exige equipo según la ruta elegida:

Para Blender (Render Offline):

  • 🧠 Procesador: 6 núcleos / 12 hilos (Ryzen 5 o Core i5 reciente).
  • 💾 Memoria RAM: 16 GB (32 GB recomendados para escenas con texturas pesadas).
  • 🎮 Tarjeta Gráfica: 6 GB VRAM dedicada (GTX 1660 Ti, RTX 2060 o similar).
  • 💽 Almacenamiento: Disco SSD para lectura rápida de assets.

Para Unreal Engine 5 + MetaHumans (Tiempo Real / En Vivo):

  • 🧠 Procesador: 8 núcleos / 16 hilos (Ryzen 7 5700X, Core i7 o superior) para no ahogar los cálculos de IK y físicas.
  • 💾 Memoria RAM: 32 GB indispensables (con 16 GB el editor colapsa fácil al cargar MetaHumans y texturas 4K).
  • 🎮 Tarjeta Gráfica: Mínimo 8 GB VRAM (RTX 3060 / 4060 o superior) para sostener iluminación Lumen y cinemática a buenos FPS.
  • 🔌 Extra: Un cable virtual MIDI (como loopMIDI en Windows, virmidi/Jack en Linux o IAC Bus en macOS) si envías notas desde tu DAW en la misma computadora.

¿Te imaginas montar un setlist completo y tener a un avatar hiperrealista ejecutando cada redoble y remate idéntico a tu ejecución real? 🚀

¿Lo usarías más para videoclips musicales o para visuales en shows en vivo? 👇

📲 Cómo convertir un chat de WhatsApp (con fotos y audios) en un PDF para crear tutoriales con IA 🤖✨

Entrada fija

¿Alguna vez has explicado un proceso completo por WhatsApp enviando capturas de pantalla, notas de voz y mensajes de texto… y pensaste: “Ojalá pudiera convertir esto en un tutorial sin tener que redactarlo todo desde cero”? 🤔💡

Cuando exportas un chat con archivos multimedia, WhatsApp te entrega un archivo .zip desordenado: un archivo .txt por un lado, carpetas llenas de fotos sueltas por otro, y audios en formato .opus que son imposibles de leer en un documento.

¡Hoy vamos a solucionar eso! 🚀 Veremos cómo transformar ese .zip en un PDF interactivo y visual con:

  • 📸 Imágenes incrustadas en el lugar exacto donde se enviaron.
  • 🎙️ Audios transcritos automáticamente a texto usando Inteligencia Artificial local.
  • 📄 Diseño limpio estilo WhatsApp listo para que una IA (como Gemini o ChatGPT) lo lea y redacte un tutorial profesional en segundos.

🛠️ ¿Qué necesitamos?

Solo necesitas tu computadora (preferiblemente con Linux o terminal) y unas pocas herramientas gratuitas:

  1. Python 3 instalado.
  2. FFmpeg (para procesar los archivos de audio).
  3. faster-whisper (un modelo de IA local ultrarrápido que convierte voz a texto sin enviar tus audios a internet).

🚀 Paso 1: Preparar el entorno

Para evitar conflictos de librerías en sistemas modernos, abrimos la terminal y preparamos nuestro entorno virtual en un par de comandos:

Bash

# 1. Instalar herramientas base
sudo apt update && sudo apt install -y ffmpeg python3-pip python3-venv

# 2. Crear y activar un entorno virtual
python3 -m venv env_whatsapp
source env_whatsapp/bin/activate

# 3. Instalar la IA de transcripción
pip install faster-whisper

🔒 Dato clave de privacidad: faster-whisper corre 100% en tu procesador. Tus conversaciones privadas nunca salen de tu máquina.

🐍 Paso 2: El script mágico

Creamos un archivo llamado exportar_chat_completo.py y pegamos un script que haga tres tareas automáticas:

  1. Leer el archivo .txt y armar las burbujas de diálogo.
  2. Detectar las imágenes .jpg o .png y convertirlas a Base64 para que queden incrustadas directamente dentro del HTML.
  3. Detectar las notas de voz .opus, transcribirlas con Whisper e insertar el texto dentro de la conversación.

Código del script:

Python

#!/usr/bin/env python3
import sys, os, re, zipfile, base64, html, tempfile
from faster_whisper import WhisperModel

if len(sys.argv) < 2:
    print("Uso: python exportar_chat_completo.py <archivo.zip>")
    sys.exit(1)

zip_path = sys.argv[1]
output_html = "chat_completo.html"

print("🧠 Cargando modelo de transcripción...")
whisper_model = WhisperModel("base", device="cpu", compute_type="int8")

with zipfile.ZipFile(zip_path, 'r') as z:
    file_list = z.namelist()
    txt_name = [f for f in file_list if f.endswith('.txt')][0]
    chat_content = z.read(txt_name).decode('utf-8-sig', errors='replace')
    
    # Regex para extraer mensajes
    msg_pattern = re.compile(r"^\[?(\d{1,2}/\d{1,2}/\d{2,4},?\s+\d{1,2}:\d{2}(?::\d{2})?(?:\s+[ap]\.?\s?m\.?)?)\]?\s*(?:-\s*)?([^:]+):\s*(.*)$", re.IGNORECASE)
    
    messages, senders, current_msg = [], set(), None
    for line in chat_content.splitlines():
        match = msg_pattern.match(line)
        if match:
            if current_msg: messages.append(current_msg)
            dt, sender, text = match.groups()
            senders.add(sender.strip())
            current_msg = {'time': dt.strip(), 'sender': sender.strip(), 'text': text.strip()}
        elif current_msg:
            current_msg['text'] += "\n" + line
    if current_msg: messages.append(current_msg)

    primary_sender = list(senders)[0] if senders else ""
    img_pattern = re.compile(r"([\w\-\.]+\.(?:jpg|jpeg|png|webp))", re.IGNORECASE)
    audio_pattern = re.compile(r"([\w\-\.]+\.(?:opus|m4a|mp3|ogg))", re.IGNORECASE)
    
    bubbles = []
    print(f"📦 Procesando {len(messages)} mensajes...")

    for idx, msg in enumerate(messages, start=1):
        is_me = (msg['sender'] == primary_sender)
        bubble_cls = "bubble outgoing" if is_me else "bubble incoming"
        raw_text = msg['text']

        # Incrustar fotos en Base64
        imgs_html = ""
        for img_name in img_pattern.findall(raw_text):
            target = [f for f in file_list if f.endswith(img_name)]
            if target:
                data = base64.b64encode(z.read(target[0])).decode('utf-8')
                imgs_html += f'<div class="img-box"><img src="data:image/jpeg;base64,{data}" /></div>'

        # Transcribir audios con IA
        audios_html = ""
        for aud_name in audio_pattern.findall(raw_text):
            target = [f for f in file_list if f.endswith(aud_name)]
            if target:
                print(f"🎙️ Transcribiendo audio: {aud_name}...")
                with tempfile.NamedTemporaryFile(suffix=os.path.splitext(aud_name)[1], delete=False) as tmp:
                    tmp.write(z.read(target[0]))
                    tmp_p = tmp.name
                try:
                    segs, _ = whisper_model.transcribe(tmp_p, language="es")
                    txt = " ".join([s.text for s in segs]).strip() or "(Audio sin voz clara)"
                except:
                    txt = "(Error en transcripción)"
                finally:
                    if os.path.exists(tmp_p): os.remove(tmp_p)
                audios_html += f'<div class="audio-box"><strong>🎙️ Transcripción:</strong> "{html.escape(txt)}"</div>'

        clean_text = html.escape(raw_text).replace('\n', '<br>')
        bubbles.append(f"""
        <div class="{bubble_cls}">
            <div class="sender">{html.escape(msg['sender'])}</div>
            {imgs_html}
            {audios_html}
            <div class="txt">{clean_text}</div>
            <div class="time">{html.escape(msg['time'])}</div>
        </div>
        """)

# Estilos CSS listos para imprimir a PDF
html_doc = f"""<!DOCTYPE html><html><head><meta charset="utf-8">
<style>
  body {{ background: #efeae2; font-family: sans-serif; display: flex; justify-content: center; padding: 20px; }}
  .chat {{ width: 100%; max-width: 780px; display: flex; flex-direction: column; gap: 8px; }}
  .bubble {{ max-width: 75%; padding: 8px 12px; border-radius: 8px; font-size: 14px; display: flex; flex-direction: column; }}
  .incoming {{ background: #fff; align-self: flex-start; }}
  .outgoing {{ background: #d9fdd3; align-self: flex-end; }}
  .sender {{ font-weight: bold; font-size: 11px; color: #128c7e; margin-bottom: 3px; }}
  .outgoing .sender {{ display: none; }}
  .img-box img {{ max-width: 100%; border-radius: 6px; margin: 5px 0; }}
  .audio-box {{ background: rgba(0,0,0,0.05); border-left: 3px solid #00a884; padding: 6px 10px; margin: 5px 0; font-style: italic; }}
  .time {{ font-size: 10px; color: #667781; align-self: flex-end; margin-top: 4px; }}
  @media print {{ body {{ background: white; }} .bubble {{ page-break-inside: avoid; border: 1px solid #ccc; }} }}
</style></head><body><div class="chat">{''.join(bubbles)}</div></body></html>"""

with open(output_html, "w", encoding="utf-8") as f:
    f.write(html_doc)
print(f"🎉 ¡Listo! Archivo '{output_html}' generado con éxito.")

⚡ Paso 3: Ejecutar y exportar a PDF

  1. Corre el script con tu chat:Bashpython exportar_chat_completo.py "MiChatExportado.zip"
  2. Abre el archivo generado (chat_completo.html) en tu navegador favorito.
  3. Presiona Ctrl + P (Imprimir).
  4. Elige Guardar como PDF y asegúrate de marcar la casilla “Gráficos de fondo” para que conserve los colores originales.

🧠 Paso 4: La magia de la IA para armar tu tutorial

¡Ahora viene lo mejor! Sube tu nuevo archivo PDF a una IA multimodal y dale esta instrucción:

“Actúa como redactor técnico y diseñador instruccional. Analiza este documento PDF que contiene capturas, mensajes y transcripciones de notas de voz. Descarta las charlas casuales y crea un tutorial paso a paso numerado, explicando cada acción y detallando qué se observa en cada captura de pantalla para guiar al usuario.”

En cuestión de segundos tendrás un manual técnico o una guía de uso impecable, estructurada y sin haber pasado horas transcribiendo audios ni acomodando capturas en Word. 🎯👏

¿Qué te parece este flujo de trabajo? ¡Pruébalo con tu próxima explicación técnica y cuéntanos en los comentarios cómo te fue! 👇💬

Cómo construí un Asistente de IA Local para SAP Business One en Spark y Openfire (Sin gastar un solo centavo en APIs y sin regalarle tus datos a nadie) 🚀🤖💼

Entrada fija

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.2.150"
PASSWORD_BOT = "BothJulio"
HOST_SERVIDOR = "192.168.2.150"
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. 🚀💻

🚀 Guía Definitiva: Convierte las entradas de tu blog de WordPress en PDFs profesionales, limpios y a color con Python

Entrada fija

¡Hola a todos! 👋 Si alguna vez has querido descargar todas las entradas de tu blog de WordPress para tener un respaldo local impecable, leer tus artículos sin conexión, o compartirlos de manera elegante como documentos independientes, estás en el lugar correcto.

Hoy te traigo una herramienta completa en Python diseñada desde cero para automatizar este proceso. Este script no solo extrae el contenido de tu API de WordPress, sino que soluciona los problemas más comunes al generar PDFs: coloca la imagen de portada arriba del todo, incrusta las imágenes de forma segura para evitar bloqueos del servidor, y aplica un resaltado de sintaxis a color increíble en tus bloques de código.

✨ Características Principales

  • 📸 Portada Inteligente con Doble Rescate: Busca automáticamente la imagen destacada oficial de WordPress (incluso consultando directamente el endpoint de medios si la API la oculta). Si un post no tiene imagen destacada, toma inteligentemente la primera imagen del cuerpo del artículo y la coloca arriba del título.
  • 🔒 Incrustación Base64 contra Bloqueos: Descarga las imágenes utilizando cabeceras personalizadas y las convierte a formato data:image/...;base64. Esto evita que firewalls, Cloudflare o configuraciones de red locales bloqueen las peticiones internas de WeasyPrint.
  • 🎨 Resaltado de Código Automático (Pygments): Analiza los bloques de código (<pre> y <code>), detecta automáticamente el lenguaje de programación (Python, Bash, PHP, JavaScript, etc.) o lo adivina de forma inteligente, aplicando un esquema de colores profesional (friendly) adaptado perfectamente para impresión.
  • 📂 Nombres de Archivos Seguros: Limpia títulos largos, elimina emojis y caracteres especiales convirtiéndolos en slugs limpios organizados por fecha (YYYY-MM-DD_nombre-del-post.pdf).

🛠️ Requisitos del Sistema y Entorno Virtual

Debido a las políticas de seguridad de las distribuciones modernas de Linux (que protegen el gestor global de paquetes de Python mediante PEP 668), lo más limpio, profesional y recomendado es trabajar dentro de un entorno virtual (venv).

1. Instalar dependencias del sistema operativo

Primero, asegúrate de tener instaladas las librerías tipográficas y de renderizado (necesarias para que WeasyPrint procese fuentes y emojis correctamente):

  • En Arch Linux / CachyOS:Bashsudo pacman -S pango noto-fonts-emoji ttf-liberation
  • En Linux Mint / Ubuntu:Bashsudo apt install libpango-1.0-0 libpangoft2-1.0-0 fonts-noto-color-emoji

2. Configurar el Entorno Virtual de Python

Abre tu terminal en la carpeta donde quieras trabajar y ejecuta los siguientes comandos:

Bash

# 1. Crear el entorno virtual llamado 'venv'
python3 -m venv venv

# 2. Activar el entorno virtual
source venv/bin/activate

(Verás que tu terminal cambia para mostrar (venv) al inicio, indicando que estás dentro del entorno aislado).

3. Instalar las librerías de Python

Con el entorno activo, instala las dependencias necesarias:

Bash

pip install requests beautifulsoup4 weasyprint pygments

💻 El Script Completo (blog_a_pdf.py)

Crea un archivo llamado blog_a_pdf.py, cópiale el siguiente contenido y guárdalo en tu directorio de trabajo:

Python

#!/usr/bin/env python3
"""
Exporta cada entrada de un blog WordPress (de un año dado) a un PDF individual
con portada arriba, incrustación Base64 y resaltado de sintaxis a color.

Uso:
    python3 blog_a_pdf.py              # año actual
    python3 blog_a_pdf.py --year 2026
    python3 blog_a_pdf.py --year 2026 --out ./pdfs

Dependencias (dentro de tu venv):
    pip install requests beautifulsoup4 weasyprint pygments
"""
import argparse
import base64
import html
import re
import time
import unicodedata
from datetime import datetime
from pathlib import Path
from urllib.parse import unquote, urljoin

import requests
from bs4 import BeautifulSoup
from weasyprint import HTML

from pygments import highlight
from pygments.formatters import HtmlFormatter
from pygments.lexers import get_lexer_by_name, guess_lexer
from pygments.lexers.special import TextLexer
from pygments.util import ClassNotFound

# CONFIGURACIÓN DE TU SITIO WEB
SITE = "https://cesarsystems.com.mx"
API = f"{SITE}/wp-json/wp/v2/posts"
HEADERS = {"User-Agent": "Mozilla/5.0 (blog-a-pdf)"}

# Generar automáticamente los estilos CSS de colores para Pygments (Tema: friendly)
PYGMENTS_CSS = HtmlFormatter(style="friendly").get_style_defs('.highlight')

CSS = f"""
@page {{
    size: Letter;
    margin: 2cm 1.8cm;
    @bottom-center {{ content: counter(page) " / " counter(pages); font-size: 9pt; color: #777; }}
}}
body {{ font-family: "Liberation Sans", "DejaVu Sans", "Noto Color Emoji", sans-serif;
       font-size: 10.5pt; line-height: 1.5; color: #222; }}
h1 {{ font-size: 20pt; margin: 0.4em 0 .2em; color: #12355b; }}
h2 {{ font-size: 15pt; margin-top: 1.4em; color: #12355b; border-bottom: 1px solid #ccd; padding-bottom: 2px; }}
h3 {{ font-size: 12.5pt; margin-top: 1.2em; }}
.meta {{ color: #666; font-size: 9pt; margin-bottom: 1.2em; }}
.meta a {{ color: #666; word-break: break-all; }}
img {{ max-width: 100%; height: auto; }}
img.portada {{ display: block; width: 100%; max-height: 9cm; object-fit: cover;
              border-radius: 4px; margin: 0 0 1.2em; }}
pre {{ background: #f8f9fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 12px;
      font-size: 8.5pt; white-space: pre-wrap; word-wrap: break-word; page-break-inside: auto; }}
code {{ font-family: "DejaVu Sans Mono", "Liberation Mono", monospace; font-size: 9pt; }}
p code, li code {{ background: #eef0f3; padding: 2px 5px; border-radius: 4px; color: #d63384; }}
table {{ border-collapse: collapse; width: 100%; margin: 1em 0; }}
th, td {{ border: 1px solid #bbb; padding: 4px 6px; font-size: 9.5pt; vertical-align: top; }}
th {{ background: #eef0f3; }}
blockquote {{ border-left: 3px solid #99a; margin-left: 0; padding-left: 10px; color: #444; }}
a {{ color: #1a5fb4; }}

/* Estilos de resaltado de código Pygments */
{PYGMENTS_CSS}
.highlight pre {{ background: transparent; border: none; padding: 0; margin: 0; }}
"""

PYGMENTS_FORMATTER = HtmlFormatter(cssclass="highlight")


def slugify(texto: str, max_len: int = 70) -> str:
    """Genera nombres de archivos seguros sin acentos, espacios ni caracteres extraños."""
    texto = unicodedata.normalize("NFKD", texto).encode("ascii", "ignore").decode()
    texto = re.sub(r"[^a-zA-Z0-9]+", "-", texto).strip("-").lower()
    return texto[:max_len].strip("-")


def obtener_posts(year: int) -> list[dict]:
    """Descarga de forma paginada todas las entradas publicadas en el año indicado."""
    posts, page = [], 1
    while True:
        params = {
            "after": f"{year}-01-01T00:00:00",
            "before": f"{year + 1}-01-01T00:00:00",
            "per_page": 100,
            "page": page,
            "orderby": "date",
            "order": "asc",
            "_embed": "wp:featuredmedia",
            "_fields": "id,date,slug,link,title,content,featured_media,_embedded",
        }
        r = requests.get(API, params=params, headers=HEADERS, timeout=60)
        if r.status_code == 400:  # Fin de paginación
            break
        r.raise_for_status()
        lote = r.json()
        if not lote:
            break
        posts.extend(lote)
        total_pages = int(r.headers.get("X-WP-TotalPages", 1))
        if page >= total_pages:
            break
        page += 1
    return posts


def limpiar_contenido(html_contenido: str) -> str:
    """Limpia etiquetas innecesarias, corrige lazy-loads y aplica colores al código."""
    soup = BeautifulSoup(html_contenido, "html.parser")

    for tag in soup(["script", "style", "iframe", "noscript", "form"]):
        tag.decompose()

    for img in soup.find_all("img"):
        for attr in ("data-src", "data-lazy-src", "data-orig-file"):
            if img.get(attr):
                img["src"] = img[attr]
                break
        for attr in ("srcset", "sizes", "loading"):
            img.attrs.pop(attr, None)

    # Procesar bloques de código con Pygments
    for pre in soup.find_all("pre"):
        code_tag = pre.find("code")
        code_text = code_tag.get_text() if code_tag else pre.get_text()
        
        lang = None
        classes = pre.get("class", []) + (code_tag.get("class", []) if code_tag else [])
        for c in classes:
            if c.startswith("language-") or c.startswith("brush:") or c.startswith("lang-"):
                lang = c.replace("language-", "").replace("brush:", "").replace("lang-", "").strip()
        
        lexer = None
        if lang:
            try:
                lexer = get_lexer_by_name(lang)
            except ClassNotFound:
                pass
        
        if not lexer:
            try:
                lexer = guess_lexer(code_text)
            except Exception:
                lexer = TextLexer()
        
        highlighted_code = highlight(code_text, lexer, PYGMENTS_FORMATTER)
        new_tag = BeautifulSoup(highlighted_code, "html.parser")
        pre.clear()
        pre.append(new_tag)

    return str(soup)


def obtener_url_portada(post: dict) -> str | None:
    """Rescata la imagen destacada oficial (vía embebidos o API de medios) o usa la del contenido."""
    media_id = post.get("featured_media", 0)

    if media_id > 0:
        # 1. Intentar desde datos embebidos
        try:
            media = post["_embedded"]["wp:featuredmedia"][0]
            if isinstance(media, dict):
                sizes = media.get("media_details", {}).get("sizes", {})
                for nombre in ("large", "full"):
                    if nombre in sizes and sizes[nombre].get("source_url"):
                        return sizes[nombre]["source_url"]
                if media.get("source_url"):
                    return media["source_url"]
        except (KeyError, IndexError, TypeError):
            pass

        # 2. Consultar directamente el endpoint de medios si lo anterior falló
        try:
            r_media = requests.get(f"{SITE}/wp-json/wp/v2/media/{media_id}", headers=HEADERS, timeout=10, verify=False)
            if r_media.status_code == 200:
                data = r_media.json()
                sizes = data.get("media_details", {}).get("sizes", {})
                for nombre in ("large", "full"):
                    if nombre in sizes and sizes[nombre].get("source_url"):
                        return sizes[nombre]["source_url"]
                if data.get("source_url"):
                    return data["source_url"]
        except Exception:
            pass

    # 3. Fallback: primera imagen dentro del cuerpo del texto
    soup = BeautifulSoup(post["content"]["rendered"], "html.parser")
    img_tag = soup.find("img")
    if img_tag:
        for attr in ("src", "data-src", "data-lazy-src", "data-orig-file"):
            url = img_tag.get(attr)
            if url:
                return urljoin(SITE, url)

    return None


def url_a_base64(url: str) -> str | None:
    """Descarga la imagen remota y la convierte a Data URI en Base64."""
    try:
        r = requests.get(url, headers=HEADERS, timeout=15, verify=False)
        if r.status_code == 200:
            content_type = r.headers.get("Content-Type", "image/jpeg")
            encoded = base64.b64encode(r.content).decode("utf-8")
            return f"data:{content_type};base64,{encoded}"
    except Exception:
        pass
    return None


def armar_html(post: dict) -> str:
    """Ensambla el HTML final colocando la portada arriba, seguida del título, metadatos y contenido."""
    titulo = html.unescape(post["title"]["rendered"])
    fecha = datetime.fromisoformat(post["date"]).strftime("%d/%m/%Y")
    cuerpo = limpiar_contenido(post["content"]["rendered"])
    
    url_img = obtener_url_portada(post)
    portada = ""
    if url_img:
        img_b64 = url_a_base64(url_img)
        if img_b64:
            portada = f'<img class="portada" src="{img_b64}">'

    return f"""<!DOCTYPE html>
<html lang="es"><head><meta charset="utf-8"><title>{html.escape(titulo)}</title></head>
<body>
{portada}
<h1>{html.escape(titulo)}</h1>
<div class="meta">{fecha} &middot; <a href="{post['link']}">{unquote(post['link'])}</a></div>
{cuerpo}
</body></html>"""


def main():
    import urllib3
    urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

    ap = argparse.ArgumentParser()
    ap.add_argument("--year", type=int, default=datetime.now().year)
    ap.add_argument("--out", default="pdfs_blog")
    args = ap.parse_args()

    out = Path(args.out)
    out.mkdir(parents=True, exist_ok=True)

    print(f"[*] Descargando entradas de {args.year} desde {SITE} ...")
    posts = obtener_posts(args.year)
    print(f"[✓] {len(posts)} entradas encontradas.\n")

    for i, post in enumerate(posts, 1):
        titulo = html.unescape(post["title"]["rendered"])
        nombre = f"{post['date'][:10]}_{slugify(titulo) or post['id']}.pdf"
        destino = out / nombre

        if destino.exists():
            print(f"[{i}/{len(posts)}] Ya existe, se omite: {nombre}")
            continue

        print(f"[{i}/{len(posts)}] Procesando: {nombre}")
        try:
            HTML(string=armar_html(post), base_url=SITE).write_pdf(
                destino, stylesheets=[__import__("weasyprint").CSS(string=CSS)]
            )
        except Exception as err:
            print(f"    [-] Error al generar PDF: {err}")
        time.sleep(0.5)

    print(f"\n[✓] ¡Listo! Todos los PDFs se guardaron en: {out.resolve()}")


if __name__ == "__main__":
    main()

🕹️ Guía de Uso del Script

Una vez que tengas configurado tu entorno virtual y tu archivo listo, puedes ejecutar el script con diferentes opciones según tus necesidades:

  • Exportar las entradas del año actual por defecto:Bashpython3 blog_a_pdf.py
  • Exportar un año en específico (ejemplo, año 2026):Bashpython3 blog_a_pdf.py --year 2026
  • Guardar los PDFs en una carpeta personalizada:Bashpython3 blog_a_pdf.py --year 2026 --out ./mis_respaldos_pdf

Cuando termines de trabajar, simplemente recuerda desactivar tu entorno virtual escribiendo:

Bash

deactivate

Cómo instalar y compilar obs-face-tracker en Linux (Arch / CachyOS) para OBS 30+ y Qt6

Entrada fija

Tener un encuadre dinámico que siga tus movimientos o haga zoom al rostro automáticamente al hablar aporta dinamismo a cualquier transmisión o grabación. En Windows existen herramientas privativas de control PTZ virtual, pero en distribuciones basadas en Arch Linux (como CachyOS, Manjaro o EndeavourOS) el estándar abierto es el plugin obs-face-tracker.

Al compilar este complemento en sistemas actualizados con GCC moderno, OBS 30+ y Qt6, surgen dos problemas críticos: advertencias tratadas como errores en la librería interna dlib, y un fallo de segmentación (SIGSEGV) al iniciar OBS debido a llamadas incompatibles con la interfaz gráfica de docks.

En esta guía resolveremos ambos errores compilando, parcheando e instalando el plugin desde el código fuente, optimizado y probado en entornos Wayland con procesadores AMD Ryzen.

1. Requisitos previos y dependencias

Instala las herramientas de compilación, las cabeceras de desarrollo de OBS Studio y la librería de visión artificial OpenCV:

Bash

sudo pacman -S --needed base-devel cmake git opencv obs-studio

Verifica que OpenCV esté accesible en tu terminal:

Bash

opencv_version

2. Clonar el repositorio con submódulos

El proyecto requiere submódulos internos (como dlib y librerías VISCA). Clónalo con el parámetro --recursive:

Bash

cd ~
git clone --recursive https://github.com/norihiro/obs-face-tracker.git
cd obs-face-tracker

3. Parchear el fallo de segmentación (SIGSEGV en Qt6)

El plugin incluye código heredado para crear un Dock (panel flotante para cámaras PTZ por hardware). En versiones modernas de OBS que corren sobre Qt6, la llamada ft_docks_init() provoca un cierre inmediato (SIGSEGV) de OBS al iniciar.

Como solo necesitamos el filtro de seguimiento facial en la cámara, desactivamos la inicialización del dock haciendo que el módulo reporte una carga exitosa directa:

Bash

# Modificar src/module-main.c para omitir la carga del dock problemático
sed -i 's/ft_docks_init();/return true;/g' src/module-main.c
sed -i 's/ft_docks_destroy();/\/\/ ft_docks_destroy();/g' src/module-main.c

Para comprobar que el cambio se aplicó correctamente, ejecuta:

Bash

git diff src/module-main.c

Deberás ver que return ft_docks_init(); fue sustituido por return true;.

4. Configurar CMake y compilar sin bloqueos de advertencias

Por defecto, el compilador en CachyOS trata cualquier variable no usada dentro de dlib como un error fatal (-Werror). Pasamos el parámetro -w tanto para C como para C++ en CMake para silenciar advertencias en código de terceros y compilar con todos los hilos del CPU:

Bash

# Configuración del proyecto
cmake -B build -S . \
  -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_INSTALL_PREFIX=/usr \
  -DCMAKE_C_FLAGS="-w" \
  -DCMAKE_CXX_FLAGS="-w"

# Compilación paralela
cmake --build build -j$(nproc)

La terminal debe finalizar con:

Plaintext

[100%] Built target obs-face-tracker

5. Instalación manual en las rutas estándar de OBS

El instalador por defecto de este repositorio coloca archivos en rutas obsoletas (/usr/obs-plugins/). Para que OBS Studio reconozca el plugin en distribuciones basadas en Arch, copia los binarios y datos a las rutas del sistema:

Bash

# Copiar la librería compartida
sudo cp build/obs-face-tracker.so /usr/lib/obs-plugins/

# Crear la carpeta de datos y copiar las traducciones
sudo mkdir -p /usr/share/obs/obs-plugins/obs-face-tracker/locale
sudo cp -r data/locale/* /usr/share/obs/obs-plugins/obs-face-tracker/locale/

Comprueba que el archivo exista:

Bash

ls -lh /usr/lib/obs-plugins/obs-face-tracker.so

6. Configuración del filtro en OBS Studio

Inicia OBS Studio (puedes lanzarlo desde la terminal con el comando obs para verificar que no emita advertencias):

  1. Añadir el filtro:
    • En tu escena, localiza la fuente de tu cámara web (Dispositivo de captura de video (V4L2)).
    • Haz clic derecho sobre ella y selecciona Filtros.
    • En la sección inferior izquierda (Filtros de efecto), presiona el botón + y elige Face Tracker.
  2. Calibrar los parámetros:
    • Zoom / Scale: Ajusta el nivel de acercamiento deseado (un valor moderado permite reencuadrar cabeza y hombros sin perder nitidez).
    • Smoothing (Suavizado): Configúralo entre 400 ms y 600 ms. Un valor bajo causará micro-temblores con cada parpadeo; un valor muy alto creará retraso al moverte.
    • Deadzone (Zona muerta): Establécelo entre 10% y 15% para que pequeños cambios de postura no desplacen el lienzo innecesariamente.

7. Configurar un atajo de teclado para activar el zoom

Para usar el acercamiento como un efecto dinámico durante transmisiones o videos explicativos:

  1. Ve a Ajustes > Atajos.
  2. Escribe el nombre de tu cámara en el buscador.
  3. Localiza los campos:
    • Mostrar filtro ‘Face Tracker’
    • Ocultar filtro ‘Face Tracker’
  4. Asigna la misma tecla rápida (por ejemplo, F9 o una combinación de tu teclado/stream deck) para alternar el zoom a demanda.

Ficha técnica y metadatos para WordPress

  • Título SEO: Cómo compilar obs-face-tracker en Arch Linux / CachyOS (Solución SIGSEGV y Qt6)
  • Slug sugerido: instalar-obs-face-tracker-arch-linux-cachyos
  • Categorías: Linux, Streaming & OBS, Software Libre
  • Etiquetas: OBS Studio, CachyOS, Arch Linux, Face Tracking, OpenCV, AMD Ryzen
  • Extracto: Guía paso a paso para compilar y hacer funcionar el plugin obs-face-tracker en Linux moderno, resolviendo los errores de compilación con GCC y el cierre por SIGSEGV en entornos Qt6 y Wayland.

Cómo crear un Asistente de IA para SAP Business One en Spark / Openfire con RAG local y Ollama

Entrada fija

¿Cuántas horas a la semana invierte el área de soporte o consultoría en responder las mismas preguntas sobre rutas de menús, códigos de error, tablas de base de datos o procedimientos de SAP Business One?

Para solucionar esto sin comprometer la privacidad corporativa ni pagar costosas suscripciones de APIs en la nube, implementamos un asistente de soporte 100% local. El bot se conecta directamente como un usuario al servidor corporativo de mensajería instantánea Openfire / Spark, lee manuales en PDF y Word (.docx) mediante RAG (Retrieval-Augmented Generation), extrae texto de diagramas o capturas de pantalla con OCR, muestra el indicador animado de «Escribiendo…» y entrega respuestas precisas con enlaces directos a las capturas del sistema.

A continuación, te mostramos la arquitectura y el paso a paso para construirlo en Ubuntu Server.

1. Arquitectura de la Solución

El sistema opera completamente dentro de la red corporativa bajo el siguiente flujo:

  1. Indexación previa (Off-line): Un script en Python escanea la carpeta de manuales (.pdf y .docx), extrae texto estructurado, tablas y aplica OCR con Tesseract a capturas de pantalla. El texto se vectoriza con nomic-embed-text y se guarda en ChromaDB.
  2. Servidor HTTP de imágenes: Las capturas de pantalla extraídas quedan disponibles en un microservidor web local para que los usuarios puedan abrirlas en alta resolución con un solo clic.
  3. Bot XMPP (slixmpp): Se autentica en Openfire como un usuario de soporte (ej. asistente@xmpp.empresa.local).
  4. Respuesta contextualizada (LLM): Al recibir un mensaje, avisa a Spark que está escribiendo (XEP-0085), recupera los fragmentos del manual más relevantes y consulta a Qwen 2.5:7b acelerado por GPU local.
  5. Auditoría (SQLite): Cada consulta, tipo de respuesta (manual interno vs. estándar de SAP) y usuario queda registrada para auditoría y mejora continua de la documentación.

2. Preparación del Servidor y Dependencias

En tu máquina con Ubuntu Server, instala las herramientas del sistema, el motor de OCR y las librerías de Python necesarias:

Bash

# 1. Herramientas del sistema y OCR en español
sudo apt update && sudo apt install -y tesseract-ocr tesseract-ocr-spa libgl1 python3-pip sqlite3

# 2. Librerías de Python requeridas
pip install --break-system-packages slixmpp chromadb pymupdf python-docx pytesseract pillow ollama

# 3. Modelos locales con Ollama
ollama pull nomic-embed-text
ollama pull qwen2.5:7b

# 4. Estructura de directorios
mkdir -p ~/sap_bot/manuales
mkdir -p ~/sap_bot/static_imgs
cd ~/sap_bot

Nota: Coloca tus guías operativas, diccionarios de datos y manuales en formato .pdf o .docx dentro de la carpeta ~/sap_bot/manuales/.

3. Servidor Web Local para Capturas de Pantalla

Para que el bot pueda adjuntar enlaces a las capturas de los manuales y los usuarios de Spark puedan verlas en el navegador sin fricción, creamos un servicio ligero con Python:

Bash

sudo tee /etc/systemd/system/sap-imgs.service << EOF
[Unit]
Description=Servidor HTTP para Capturas de Manuales SAP
After=network.target

[Service]
Type=simple
User=$USER
WorkingDirectory=$HOME/sap_bot/static_imgs
ExecStart=/usr/bin/python3 -m http.server 8080 --bind 0.0.0.0
Restart=always

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now sap-imgs.service

4. Script de Indexación Inteligente con OCR (indexar_manuales.py)

Este script se encarga de analizar los manuales, separar el texto en fragmentos con solapamiento (chunking), extraer tablas, guardar capturas de pantalla y procesar imágenes que contengan texto con Tesseract.

Crea el archivo ~/sap_bot/indexar_manuales.py:

Python

#!/usr/bin/env python3
"""
Indexador de Documentación SAP B1 (PDF y DOCX) con OCR y ChromaDB.
"""
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:
    return re.sub(r'[^a-zA-Z0-9_\-\.]', '_', nombre)

def fragmentar_texto(texto: str, tamano: int = CHUNK_SIZE, solapamiento: int = CHUNK_OVERLAP) -> list[str]:
    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]:
    res = ollama.embeddings(model=MODELO_EMBED, prompt=texto)
    return res["embedding"]

def procesar_pdf(ruta_pdf: str, archivo: str, coleccion, contador_inicio: int) -> int:
    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 viñetas o elementos menores a 120px
                    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)

                        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
                    }]
                )
                contador += 1
            except Exception as err:
                print(f"[-] Error en vector: {err}")

    return contador

def procesar_docx(ruta_docx: str, archivo: str, coleccion, contador_inicio: int) -> int:
    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
                }]
            )
            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")
    archivos = [f for f in os.listdir(CARPETA_MANUALES) if f.lower().endswith(formatos)]

    if not archivos:
        print("[!] No hay archivos para indexar. Creando base de datos vacía...")
        client.get_or_create_collection(name="manuales_sap")
        return

    try:
        client.delete_collection(name="manuales_sap")
    except Exception:
        pass
    coleccion = client.create_collection(name="manuales_sap")

    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)

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

if __name__ == "__main__":
    ejecutar_indexacion()

5. El Bot XMPP para Spark (bot_spark.py)

Este script se encarga de:

  1. Conectarse de forma asíncrona al servidor Openfire.
  2. Emitir el paquete XEP-0085 para mostrar «Escribiendo…» de inmediato en Spark.
  3. Consultar la base vectorial sin bloquear el hilo principal.
  4. Generar respuestas en GPU y registrar la interacción en SQLite.

Crea el archivo ~/sap_bot/bot_spark.py:

Python

#!/usr/bin/env python3
"""
Bot Asistente SAP B1 para Spark (XMPP + Ollama + ChromaDB + SQLite).
"""
import os
import ssl
import asyncio
import sqlite3
from datetime import datetime
import slixmpp
import chromadb
import ollama

# ================= 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 genéricos de conexión Openfire / Spark
JID_BOT = "asistente_sap@xmpp.empresa.local"
PASSWORD_BOT = "PasswordSeguro123"
HOST_SERVIDOR = "192.168.1.50"
PUERTO_XMPP = 5222

# Enlace base para servir las capturas de pantalla
URL_BASE_IMGS = f"http://{HOST_SERVIDOR}:8080"

MODELO_LLM = "qwen2.5:7b"
MODELO_EMBED = "nomic-embed-text"
# ==============================================================

def inicializar_bd_logs():
    with sqlite3.connect(DB_LOGS_PATH) as conn:
        cursor = conn.cursor()
        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
            )
        """)
        conn.commit()

def registrar_log(usuario: str, pregunta: str, tipo_fuente: str, respuesta: str):
    try:
        with sqlite3.connect(DB_LOGS_PATH) 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()
    except Exception as err:
        print(f"[-] Error en log SQLite: {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 locales/autofirmados
        self.ssl_context = ssl.create_default_context()
        self.ssl_context.check_hostname = False
        self.ssl_context.verify_mode = ssl.CERT_NONE

        self.chroma_client = chromadb.PersistentClient(path=DB_PATH)

    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}")

    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

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

        pregunta = msg["body"].strip()
        remitente = msg["from"].bare
        print(f"[+] Consulta de [{remitente}]: {pregunta}")

        self.notificar_escribiendo(remitente)
        respuesta = await asyncio.to_thread(self.procesar_consulta, remitente, pregunta)
        msg.reply(respuesta).send()
        self.notificar_inactivo(remitente)

    def procesar_consulta(self, remitente: str, pregunta: str) -> str:
        try:
            coleccion = self.chroma_client.get_or_create_collection(name="manuales_sap")
            total_manuales = coleccion.count()
            fragmentos = []
            referencias = []
            imagenes_adjuntas = []

            if total_manuales > 0:
                res_emb = ollama.embeddings(model=MODELO_EMBED, prompt=pregunta)
                num_a_buscar = min(3, total_manuales)
                coincidencias = coleccion.query(query_embeddings=[res_emb["embedding"]], n_results=num_a_buscar)

                if coincidencias and coincidencias["documents"] and coincidencias["documents"][0]:
                    for doc_txt, meta in zip(coincidencias["documents"][0], coincidencias["metadatas"][0]):
                        fragmentos.append(doc_txt)
                        referencias.append(f"{meta['archivo']} (Pág. {meta['pagina']})")
                        imgs_str = meta.get("imagenes", "")
                        if imgs_str:
                            for img in imgs_str.split(","):
                                if img.strip():
                                    imagenes_adjuntas.append(img.strip())

            contexto = "\n---\n".join(fragmentos) if fragmentos else "NO_HAY_MANUALES_DISPONIBLES"

            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:\n"
                "1. Si la DOCUMENTACIÓN OFICIAL contiene la respuesta, responde con base en ella con total precisión.\n"
                "2. Si la DOCUMENTACIÓN OFICIAL 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 OFICIAL:\n{contexto}"
            )

            res_chat = ollama.chat(
                model=MODELO_LLM,
                messages=[
                    {"role": "system", "content": prompt_sistema},
                    {"role": "user", "content": pregunta}
                ],
                options={"temperature": 0.15, "num_ctx": 2048}
            )

            cuerpo = res_chat["message"]["content"].strip()

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

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

            registrar_log(remitente, pregunta, tipo_fuente, cuerpo)
            return cuerpo

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

def main():
    inicializar_bd_logs()
    bot = SparkAIBot(JID_BOT, PASSWORD_BOT)
    bot.register_plugin("xep_0030")
    bot.register_plugin("xep_0199")
    bot.register_plugin("xep_0085")  # Soporte de estado 'Escribiendo...'

    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. Ejecución como Servicio Continuo (systemd)

Para que el asistente se inicie automáticamente tras cualquier reinicio del servidor:

Bash

sudo tee /etc/systemd/system/sap-bot.service << EOF
[Unit]
Description=Servicio Bot Asistente SAP B1 para Spark
After=network.target ollama.service

[Service]
Type=simple
User=$USER
WorkingDirectory=$HOME/sap_bot
ExecStart=/usr/bin/python3 $HOME/sap_bot/bot_spark.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now sap-bot.service

7. Ventajas y Resultados Obtenidos

  • Privacidad total: Ningún dato, consulta contable o captura de pantalla sale de la red local.
  • Cero costo operativo recurrente: No se depende de tokens ni de APIs de terceros.
  • Experiencia de usuario nativa: El usuario no requiere abrir una página web extra ni loguearse; simplemente abre una ventana de chat en su cliente Spark cotidiano.
  • Mantenimiento simplificado: Para actualizar la base de conocimientos, basta con soltar nuevos manuales en la carpeta y ejecutar python3 indexar_manuales.py. El bot detecta la colección actualizada de forma dinámica sin necesidad de reiniciar el servicio.

🚨 ¿Te están cobrando seguros fantasma en tu tarjeta? La verdad sobre las Fintech y cómo hackear tus fechas de pago 💳⚡

Entrada fija

¿Te ha pasado que tramitas una tarjeta “sin anualidad y con cashback jugoso”, pero de repente tu saldo no cuadra? 🤔 Bienvenido al club de los débitos silenciosos.

Mientras las apps nos deslumbran con colores pastel y trámites en 3 minutos, detrás de escena algunas aplican patrones oscuros (dark patterns) para colar seguros, coberturas y membresías automáticas (como el famoso Plata Protege o Plata+ en Plata Card, o las asistencias “de cortesía” de la banca tradicional) 💸👻.

🕵️‍♂️ La trampa maestra: ¿Por qué tu celular nunca vibra?

Seguro piensas: “Si me cobraran algo raro, me llegaría una notificación push al instante”. Falso. ❌

  • 🛒 Cuando compras en el súper: La transacción viaja por Visa o Mastercard y la app te manda un aviso en milisegundos.
  • 📋 Cuando te cobran un seguro o comisión: Es un asiento contable interno del propio banco. No pasa por la red de pagos externa, así que no detona alertas en tu teléfono.
  • 📄 El truco legal: Por ley, solo están obligados a plasmarlo en tu estado de cuenta en PDF. Si eres de los que nunca descarga el PDF mensual… felicidades, llevas meses pagando la fiesta del banco sin enterarte 🥂.

🗓️ El Hack definitivo: Cómo estirar tus compras hasta 50+ días sin pagar intereses 🧠💡

Si vas a usar tarjetas de crédito, que el financiamiento trabaje para ti, no al revés. La matemática de calendario es simple:

  • 🔴 Comprar el día de corte (o 1 día antes): Pésima idea. Entra en el corte inmediato y tienes apenas 20 días para liquidar.
  • 🟢 Comprar el día posterior al corte (Corte + 1): ¡Modo genio activado! 🚀 Tu compra no entra en el ciclo actual, sino en el siguiente. Ganas los 30 días del nuevo ciclo + los 20 días de gracia. ¡Hasta 50 o 55 días de financiamiento libre de intereses!

💻 Nuestra solución: Un Auditor Forense con interfaz Cyberpunk en Python 🦾👾

Para no perder tiempo abriendo PDFs aburridos ni arriesgar contraseñas bancarias en apps sospechosas, armamos una herramienta de escritorio local en Python con estética retro neón y motor dual:

┌─────────────────────────────────────────────────────────┐
│     NEO-FINANCE // AUDITOR FORENSE & OPTIMIZADOR        │
│       Arrastra y suelta tus PDFs (con o sin RFC)       │
└────────────────────────────┬────────────────────────────┘
                             │
            ┌────────────────┴────────────────┐
            ▼                                 ▼
   🤖 IA Local (Ollama)              ⚡ Motor Mecánico
   Qwen 2.5 (14B / 7B)              Expresiones Regulares
   Razonamiento semántico           Cero consumo de CPU/GPU

¿Qué hace este script? 🛠️

  1. 📂 Arrastrar y soltar (Drag & Drop): Arrastras de golpe los PDFs de Banorte, Santander, Citibanamex, Nu, Stori o Plata Card directo al programa.
  2. 🔓 Desencriptado inteligente: Si el banco le metió candado con tu RFC, la terminal abre un portal retro seguro, lo descifra en memoria y recuerda la clave para el lote.
  3. 🎯 Semáforo diario de tarjetas: Te dice exactamente cuál tarjeta te da más días libres de pago hoy (marcando en neón cian el plástico ganador y en rojo alerta las que están por cortar).
  4. 🚨 Visor forense de cargos fantasma: Escanea renglón por renglón y separa las compras reales de cobros indebidos (anualidades no avisadas, seguros y membresías), dejando fuera los intereses habituales.
  5. 🌐 100% privado y offline: Tus datos no tocan ningún servidor externo; todo se procesa en tu propia máquina.

🛡️ Checklist de supervivencia financiera (¡Aplícalo hoy!)

  • [ ] Entra a tu app bancaria y apaga cualquier interruptor de asistencia vial, seguro de vida o protección de saldo.
  • [ ] Descarga religiosamente tus estados de cuenta cada mes y busca la palabra “Comisiones”.
  • [ ] Si ves cobros no autorizados, mete aclaración ante la UNE del banco: por ley tienes hasta 90 días naturales para exigir tu reembolso completo.
  • [ ] Programa recordatorios en tu calendario para comprar solo a partir de tu Corte + 1.

🚀 De 1 Video Largo a 15 Shorts Virales: Mi Pipeline 100% Local con Python, Whisper y Qwen 2.5

Entrada fija

¿Terminaste de grabar un tutorial técnico de más de una hora y te da pereza abrir Kdenlive o DaVinci Resolve para sacar clips? 😫✂️

Editar clips verticales manualmente para YouTube Shorts o TikTok consume horas de trabajo repetitivo: buscar momentos interesantes, recortar silencios, reencuadrar a formato vertical 9:16, desenfocar el fondo y subtitular palabra por palabra. Las herramientas SaaS en la nube como OpusClip cobran suscripciones mensuales recurrentes y suben tus grabaciones privadas a servidores externos. 💸🔒

La alternativa definitiva es montar tu propio pipeline automatizado de código abierto que corre al 100% en local. Con un solo script en Python, el sistema:

  1. Detecta si tu grabación se dividió en dos partes y las une sin pérdida de calidad.
  2. Analiza los canales de audio y aísla la pista limpia de tu micrófono (pista 2 de OBS).
  3. Elimina pausas muertas con auto-editor.
  4. Transcribe todo el audio con faster-whisper.
  5. Envía la transcripción completa a qwen2.5:14b vía Ollama para extraer de 10 a 15 ganchos virales en JSON estricto.
  6. Renderiza en lote los videos en formato vertical (9:16), con fondo desenfocado, título superior, subtítulos amarillos y metadatos incrustados.

📦 Requisitos previos en el sistema

Antes de ejecutar el script en tu distribución Linux, asegúrate de tener las dependencias listas:

Bash

# Herramientas base y modelos
sudo pacman -S ffmpeg   # En CachyOS / Arch (o apt install ffmpeg en Ubuntu/Debian)
pip install faster-whisper auto-editor

# Descarga del modelo en Ollama
ollama pull qwen2.5:14b

💻 El Código Completo (pipeline_ia_shorts.py)

Guarda este script en la carpeta donde tengas tus grabaciones (video.mp4 o video2.mp4):

Python

#!/usr/bin/env python3
import json
import os
import re
import subprocess
import urllib.request
from faster_whisper import WhisperModel

# --- CONFIGURACIÓN ---
VIDEO_BASE = "video.mp4" if os.path.exists("video.mp4") else "1.mp4"
VIDEO_EXTRA = "video2.mp4" if os.path.exists("video2.mp4") else ("2.mp4" if os.path.exists("2.mp4") else None)
VIDEO_PREPARED = "unido.mp4"
VIDEO_CLEAN = "video_ALTERED.mp4"
SRT_OUTPUT = "video_ALTERED.srt"
OUT_DIR = "/home/julioc/Automatizaciones/shorts/global/tutoriales"
OLLAMA_MODEL = "qwen2.5:14b"
OLLAMA_URL = "http://localhost:11434/api/generate"

os.makedirs(OUT_DIR, exist_ok=True)

# 0. DETECCIÓN DE PISTAS DE AUDIO Y UNIÓN DE VIDEOS
print("=" * 60)
print("[0/4] Verificando pistas de audio y videos a unir...")
print("=" * 60)

def count_audio_streams(file_path):
    try:
        cmd = [
            "ffprobe", "-v", "error", "-select_streams", "a",
            "-show_entries", "stream=index", "-of", "csv=p=0", file_path
        ]
        out = subprocess.check_output(cmd).decode().strip()
        return len(out.splitlines()) if out else 0
    except Exception:
        return 1

num_audio = count_audio_streams(VIDEO_BASE)
audio_map = "0:a:1" if num_audio >= 2 else "0:a:0"
print(f"[*] Pistas de audio detectadas: {num_audio} -> Seleccionada: {audio_map} ({'Micrófono pista 2' if audio_map == '0:a:1' else 'Pista principal'})")

if not os.path.exists(VIDEO_PREPARED) and not os.path.exists(VIDEO_CLEAN):
    if VIDEO_EXTRA and os.path.exists(VIDEO_EXTRA):
        print(f"[*] Detectado video adicional: {VIDEO_EXTRA}. Uniendo archivos...")
        with open("lista_unir.txt", "w", encoding="utf-8") as f:
            f.write(f"file '{VIDEO_BASE}'\nfile '{VIDEO_EXTRA}'\n")
        
        cmd_concat = [
            "ffmpeg", "-y", "-f", "concat", "-safe", "0",
            "-i", "lista_unir.txt",
            "-map", "0:v:0", "-map", audio_map,
            "-c", "copy", VIDEO_PREPARED
        ]
        subprocess.run(cmd_concat, check=True)
        os.remove("lista_unir.txt")
    else:
        print(f"[*] Procesando solo {VIDEO_BASE} con audio {audio_map}...")
        cmd_extract = [
            "ffmpeg", "-y", "-i", VIDEO_BASE,
            "-map", "0:v:0", "-map", audio_map,
            "-c", "copy", VIDEO_PREPARED
        ]
        subprocess.run(cmd_extract, check=True)
else:
    print(f"[!] Reutilizando archivo preparado existente...")

# 1. CORTE DE SILENCIOS CON AUTO-EDITOR
print("\n" + "=" * 60)
print("[1/4] Cortando silencios (>5s) con auto-editor...")
print("=" * 60)

input_for_autoeditor = VIDEO_PREPARED if os.path.exists(VIDEO_PREPARED) else VIDEO_BASE
if not os.path.exists(VIDEO_CLEAN):
    cmd_autoeditor = [
        "auto-editor", input_for_autoeditor,
        "--edit", "audio:threshold=4%,mincut=5s",
        "-o", VIDEO_CLEAN
    ]
    subprocess.run(cmd_autoeditor, check=True)
else:
    print(f"[!] Archivo {VIDEO_CLEAN} ya existe, reutilizando...")

# 2. TRANSCRIPCIÓN CON FASTER-WHISPER
print("\n" + "=" * 60)
print("[2/4] Verificando / generando subtítulos...")
print("=" * 60)

srt_text_dump = []
if not os.path.exists(SRT_OUTPUT):
    whisper_model = WhisperModel("small", device="cpu", compute_type="int8")
    segments, _ = whisper_model.transcribe(VIDEO_CLEAN, language="es")

    srt_entries = []
    for idx, seg in enumerate(segments, 1):
        t_start = f"{int(seg.start//3600):02d}:{int((seg.start%3600)//60):02d}:{int(seg.start%60):02d},000"
        t_end = f"{int(seg.end//3600):02d}:{int((seg.end%3600)//60):02d}:{int(seg.end%60):02d},000"
        srt_entries.append(f"{idx}\n{t_start} --> {t_end}\n{seg.text.strip()}\n")
        
        clean_start = f"{int(seg.start//3600):02d}:{int((seg.start%3600)//60):02d}:{int(seg.start%60):02d}"
        srt_text_dump.append(f"[{clean_start}] {seg.text.strip()}")

    with open(SRT_OUTPUT, "w", encoding="utf-8") as f:
        f.write("\n".join(srt_entries))
else:
    print(f"[!] Usando {SRT_OUTPUT} existente...")
    with open(SRT_OUTPUT, "r", encoding="utf-8") as f:
        lines = f.readlines()
    for i, line in enumerate(lines):
        if "-->" in line and i + 1 < len(lines):
            t_raw = line.split("-->")[0].strip().split(",")[0]
            txt = lines[i+1].strip()
            if txt:
                srt_text_dump.append(f"[{t_raw}] {txt}")

# 3. EXTRACCIÓN MASIVA CON QWEN 2.5 (10 A 15 CLIPS)
print("\n" + "=" * 60)
print(f"[3/4] Extrayendo entre 10 y 15 momentos virales con {OLLAMA_MODEL}...")
print("=" * 60)

prompt_analysis = f"""Eres un editor profesional de contenido en formato vertical (Shorts/TikTok).
Analiza detalladamente toda la transcripción y extrae OBLIGATORIAMENTE entre 10 y 15 momentos clave.

INSTRUCCIONES CRÍTICAS:
1. Cuota obligatoria: Mínimo 10 clips, máximo 15 clips. NO entregues menos de 10.
2. Cada clip debe durar entre 30 y 75 segundos.
3. Cubre TODO el video: extrae clips del inicio, de la parte media y del final.
4. Formato de timestamps: "HH:MM:SS" (estricto).
5. Títulos: En MAYÚSCULAS, breves, sin dos puntos ni comillas.
6. Campo filename: alfanumérico con guiones bajos (ej. "01_TITULO", "02_TITULO").

Devuelve EXCLUSIVAMENTE el arreglo JSON:
[
  {{
    "start": "00:00:45",
    "end": "00:01:35",
    "filename": "01_TITULO_RESUMEN",
    "title": "TITULO EN MAYUSCULAS",
    "desc": "Descripcion corta para metadatos"
  }}
]

Transcripción:
{chr(10).join(srt_text_dump)}
"""

payload = {
    "model": OLLAMA_MODEL,
    "prompt": prompt_analysis,
    "stream": False,
    "format": "json",
    "options": {
        "num_ctx": 16384,
        "num_predict": 4096,
        "temperature": 0.2
    }
}

req = urllib.request.Request(
    OLLAMA_URL,
    data=json.dumps(payload).encode("utf-8"),
    headers={"Content-Type": "application/json"}
)

with urllib.request.urlopen(req) as resp:
    res_data = json.loads(resp.read().decode("utf-8"))
    raw_response = res_data.get("response", "[]").strip()

if raw_response.startswith("```"):
    raw_response = re.sub(r"^```(?:json)?\s*", "", raw_response)
    raw_response = re.sub(r"\s*```$", "", raw_response)

try:
    parsed = json.loads(raw_response)
    clips = parsed.get("clips", parsed) if isinstance(parsed, dict) else parsed
except Exception as err:
    print(f"[!] Error procesando JSON de Ollama: {err}")
    print("Respuesta recibida:\n", raw_response)
    exit(1)

print(f"[✓] La IA detectó con éxito {len(clips)} segmentos.")

# 4. RENDERIZADO VERTICAL CON FFMPEG
print("\n" + "=" * 60)
print(f"[4/4] Renderizando {len(clips)} shorts en {OUT_DIR}...")
print("=" * 60)

for idx, clip in enumerate(clips, 1):
    start = clip["start"]
    end = clip["end"]
    fname = re.sub(r'[^a-zA-Z0-9_-]', '_', clip["filename"])
    title = clip["title"].replace(":", "\\:").replace("'", "").replace("%", "%%")
    desc = clip.get("desc", title)
    out_file = os.path.join(OUT_DIR, f"{fname}.mp4")

    print(f"\n[>] Short {idx}/{len(clips)}: {clip['title']}")
    print(f"    {start} -> {end}")

    filter_complex = (
        "[0:v]split=2[bg][fg];"
        "[bg]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,avgblur=30[bg_b];"
        "[fg]scale=1080:-1[fg_s];"
        "[bg_b][fg_s]overlay=(W-w)/2:(H-h)/2,"
        f"drawtext=expansion=none:text='{title}':font='Liberation Sans\\:style=Bold':fontcolor=white:fontsize=38:bordercolor=black:borderw=4:shadowcolor=black@0.6:shadowx=2:shadowy=2:x=(w-text_w)/2:y=240,"
        f"subtitles='{SRT_OUTPUT}':force_style='PlayResX=1080,PlayResY=1920,FontSize=42,FontName=Liberation Sans,Bold=1,PrimaryColour=&H0000FFFF,OutlineColour=&H00000000,BackColour=&H80000000,BorderStyle=1,Outline=3,Shadow=2,Alignment=2,MarginV=180',"
        "setpts=PTS-STARTPTS[v]"
    )

    cmd_ffmpeg = [
        "ffmpeg", "-y",
        "-ss", start,
        "-to", end,
        "-copyts",
        "-i", VIDEO_CLEAN,
        "-filter_complex", filter_complex,
        "-map", "[v]",
        "-map", "0:a:0",
        "-af", "asetpts=PTS-STARTPTS",
        "-c:v", "libx264",
        "-preset", "veryfast",
        "-crf", "18",
        "-c:a", "aac",
        "-metadata", f"title={clip['title']}",
        "-metadata", f"description={desc}",
        "-metadata", "artist=Julio Cesar",
        "-metadata", "genre=Technology / Linux Tutorial",
        out_file
    ]
    subprocess.run(cmd_ffmpeg, check=True)

print("\n" + "=" * 60)
print(f"[✓] Proceso completado: se generaron {len(clips)} shorts.")
print(f"[✓] Ruta: {OUT_DIR}")
print("=" * 60)

🔍 Los 3 Trucos Técnicos que Hacen Funcionar este Script

  1. Auto-detección con ffprobe: Grabar con OBS suele generar la pista 1 (escritorio/audio del juego) y la pista 2 (micrófono). La función count_audio_streams() analiza los metadatos y asegura que Whisper y los videos procesen la voz limpia.
  2. Ventana de contexto de 16k y num_predict: 4096: Por defecto, Ollama utiliza ventanas reducidas que truncan la respuesta y provocan que solo se genere 1 clip. Ajustar estos dos parámetros permite al modelo leer transcripciones largas y devolver un archivo JSON con más de 10 elementos sin romperse.
  3. Filtro FFmpeg 9:16 con escape seguro: Los dos puntos (:) y porcentajes (%) rompen la sintaxis de drawtext. El script sanitiza automáticamente los títulos para que ningún carácter especial interrumpa el renderizado.

Ejecuta el script directamente en tu terminal:

Bash

python3 pipeline_ia_shorts.py

Al terminar, tendrás hasta 15 videos verticales listos en tu carpeta con títulos llamativos, subtítulos integrados y metadatos completos para subir a YouTube Shorts y TikTok sin pagar suscripciones. 🐧🚀

🤖 El Boom de la Robótica: ¿Ciencia Ficción o la Mayor Revolución Industrial de Nuestra Era? 🚀

Entrada fija

Seguramente has visto videos de robots humanoides caminando por fábricas, doblando ropa o bailando con una agilidad impresionante. Pero detrás del espectáculo viral, hay una realidad contundente: estamos pasando de la robótica fija tradicional a la era de la Inteligencia Artificial Física (Embodied AI).

¿Serán tan comunes como los smartphones? ¿Podremos arreglarlos en casa? ¿En qué conviene poner el ojo hoy para no quedarse atrás? Vamos a desglosarlo punto por punto de forma clara y sin tecnicismos aburridos. 👇

1. 📱 ¿Costarán lo mismo que un celular? (La realidad del precio) 🏷️

La tentación es pensar que los robots bajarán de precio a la velocidad vertiginosa de los teléfonos móviles. Sin embargo, hay una diferencia crucial:

  • Un celular es silicio y cristal: Hacer un microchip más potente y pequeño abarata el costo por transistor.
  • Un robot es masa física y mecánica: Hablamos de entre 40 y 70 kg de aleaciones metálicas, imanes de neodimio, cobre, baterías pesadas y más de 30 motores de alta precisión.

Por eso, el precio de un robot humanoide completo no será el de un teléfono de $800 USD, sino que seguirá el camino de una motocicleta o un coche compacto, rondando entre los $10,000 y $25,000 USD cuando la producción masiva se consolide.

💡 ¿Cómo llegarán a nuestras casas entonces? Probablemente mediante esquemas de suscripción mensual (RaaS o Robots as a Service), donde pagas una renta que incluye el mantenimiento, las piezas de recambio y las actualizaciones continuas de software en la nube.

2. 🔁 El bucle infinito: ¿Robots fabricando robots y abaratando todo? ⛏️

Una de las ideas más fascinantes es el concepto de autómatas autorreplicantes:

  1. Máquinas autónomas extraen hierro, cobre y litio en minas sin intervención humana directa.
  2. Camiones sin conductor llevan los minerales a fundiciones automatizadas.
  3. Brazos robóticos en fábricas “a oscuras” (lights-out) ensamblan nuevos robots día y noche.

Al eliminar gran parte de la mano de obra en cada eslabón, el costo marginal de construir un robot se desplomará hasta chocar casi únicamente con el costo de la energía y la materia prima. Además, mediante simulaciones en tiempo real (gemelos digitales), los robots aprenden trayectorias óptimas y comparten ese aprendizaje con toda la flota al instante.

3. 🛠️ ¿Tecnología cerrada o podremos repararlos nosotros mismos? 🔓

Aquí veremos dos mundos muy claros, tal como ocurrió con el software:

  • El mundo comercial cerrado (tipo Apple o coches modernos): Fabricantes como Tesla o Figure tenderán a ecosistemas donde cada pieza requiere calibración digital propietaria y claves criptográficas. Si cambias un actuador en tu cochera, el sistema central no lo reconocerá por motivos de garantía, seguridad física y responsabilidad legal.
  • El mundo Open Source y DIY (tipo Linux y Android): Proyectos como K-Scale Labs, LeRobot (de Hugging Face) o iniciativas universitarias están liberando planos CAD para impresión 3D, esquemáticos y controladores abiertos. Habrá robots modulares que podrás imprimir, armar y reparar tú mismo con herramientas de taller estándar y software libre.

4. 📈 ¿En qué conviene INVERTIR de cara al futuro? 💼

Si quieres subirte a esta ola como inversionista, la estrategia más inteligente suele ser “vender palas y picos en lugar de buscar pepitas de oro”:

  • ⚙️ Componentes críticos de movimiento: Reductores de velocidad de precisión (Harmonic Drive, Nabtesco). Son los engranajes sin holgura indispensables para cada articulación.
  • ⚡ Semiconductores de control y potencia: Líderes como Texas Instruments (TXN) o STMicroelectronics, que producen los microcontroladores y chips analógicos necesarios para mover motores y gestionar baterías.
  • 🧠 Cerebros de IA e inferencia perimetral: NVIDIA (NVDA) con su arquitectura Jetson y plataformas de simulación física (Isaac Sim).
  • 🌐 ETFs diversificados: Si prefieres evitar el riesgo de elegir acciones individuales, fondos como BOTZ, ROBO o IRBO cubren desde automatización industrial hasta componentes de visión artificial.

5. 🧠 ¿Qué conviene APRENDER para surfear esta ola? 📚

Para quienes buscan posicionarse profesionalmente, el valor se está desplazando hacia la intersección entre software y física:

  • 💻 ROS 2 (Robot Operating System): El estándar de facto en la industria para comunicar nodos de sensores, motores y cámaras.
  • 🐍 Python, C++ y Linux Embebido: La base sobre la que corren los sistemas de control en tiempo real y los buses de comunicación industrial (CAN bus, EtherCAT).
  • 👁️ Visión artificial y modelos VLA (Vision-Language-Action): Entender cómo los modelos de IA procesan imágenes en vivo para tomar decisiones de movimiento espacial.
  • 🖨️ CAD y fabricación aditiva: Dominar software de diseño mecánico (Fusion 360, FreeCAD) e impresión 3D para diseñar y prototipar piezas funcionales.

🌟 La conclusión: La robótica del futuro no será mágica de la noche a la mañana, pero sí transformará la industria, los servicios y los hogares a un ritmo imparable. ¡El mejor momento para entenderla, aprender de ella o invertir con cabeza es justo ahora que la infraestructura se está construyendo!

🚀 Cómo Dejar de Seguir Páginas en Facebook Automáticamente (Sin Instalar Programas Raros)

Entrada fija

¿Entras a Facebook y tu muro parece una pared interminable de anuncios, memes reciclados y páginas de negocios que ni recuerdas cuándo seguiste? 😩

Con los años acumulamos cientos de páginas que ahogan las publicaciones de nuestros amigos y familiares. Quitarlas una por una dando clic, esperando que abra el menú y confirmando puede tomarte toda una tarde.

La buena noticia es que no necesitas instalar ninguna extensión sospechosa que robe tus contraseñas ni pagar por herramientas mágicas. Puedes automatizar la limpieza directamente desde tu navegador con un script seguro y transparente. 🧹✨

⚠️ El peligro de las extensiones para “limpieza masiva”

En internet abundan extensiones de navegador que prometen “Unfollow masivo en un clic”. El problema:

  • 🛑 Piden acceso total a tu sesión: Muchas leen tus cookies privadas y pueden acceder a tus cuentas.
  • 🛑 Te pueden bloquear: Dan clics a velocidad sobrehumana (un clic cada medio segundo), lo que activa las alarmas anti-bot de Facebook y bloquea tu cuenta por actividad inusual.

El método que verás aquí corre en la consola oficial de tu propio navegador (Chrome, Firefox, Edge o Brave), no envía datos a nadie e incluye pausas de 4 a 6 segundos entre cada página para simular el comportamiento de una persona real. 🛡️

📋 Paso a paso: Cómo limpiar tus páginas en 3 minutos

1. Abre tu lista de páginas en la computadora

Inicia sesión en Facebook desde tu navegador de escritorio y ve a tu sección de páginas seguidas o que te gustan:

👉 Ve a tu Perfil ➔ pestaña Más ➔ Me gusta (o entra a [facebook.com/me/following](https://facebook.com/me/following)).

Asegúrate de ver en pantalla las tarjetas con los nombres de las páginas y su menú de tres puntos.

2. Abre la consola de desarrollo del navegador

  • En Firefox: Presiona Ctrl + Shift + K
  • En Chrome / Brave / Edge: Presiona Ctrl + Shift + J (o presiona la tecla F12)

(Nota para usuarios de Firefox: Si es tu primera vez pegando código, la consola te mostrará una advertencia roja de seguridad. Escribe la frase permitir pegar en la consola, presiona Enter y ya podrás pegar normalmente).

3. Pega el código y presiona Enter

Copia el siguiente bloque de código completo, pégalo en la consola y pulsa la tecla Enter:

JavaScript

(async () => {
  const sleep = (ms) => new Promise(res => setTimeout(res, ms));
  const MAX_LIMIT = 50; // Límite seguro por tanda
  let count = 0;

  console.log("%cIniciando desuscripción de páginas...", "color: #1877F2; font-weight: bold;");

  const getOptionButtons = () => {
    return Array.from(document.querySelectorAll('div[role="button"], button')).filter(el => {
      const label = (el.getAttribute('aria-label') || '').toLowerCase();
      return label.includes('más opciones para') || label.includes('more options for');
    });
  };

  const buttons = getOptionButtons();
  if (buttons.length === 0) {
    console.warn("No se encontraron botones de opciones en pantalla.");
    return;
  }

  for (const btn of buttons) {
    if (count >= MAX_LIMIT) {
      console.log(`%cSe alcanzó el límite de ${MAX_LIMIT}. Deteniendo.`, "color: #FE2C55; font-weight: bold;");
      break;
    }

    btn.scrollIntoView({ behavior: 'smooth', block: 'center' });
    await sleep(600);
    btn.click();
    await sleep(1200);

    // Buscar la opción dentro del menú emergente desplegado
    const menuItems = Array.from(document.querySelectorAll('div[role="menuitem"], div[role="button"], span'));
    const targetAction = menuItems.find(el => {
      const txt = (el.innerText || el.textContent || '').trim().toLowerCase();
      return txt === 'dejar de seguir' || 
             txt.includes('dejar de seguir') || 
             txt === 'ya no me gusta' || 
             txt.includes('ya no me gusta');
    });

    if (targetAction) {
      targetAction.click();
      count++;
      console.log(`%c[${count}/${MAX_LIMIT}] Acción aplicada a la página.`, "color: #00FF66; font-weight: bold;");
      await sleep(1200);

      // Si se abre un modal pidiendo confirmación
      const confirmBtn = Array.from(document.querySelectorAll('button, div[role="button"]')).find(el => {
        const txt = (el.innerText || el.textContent || '').trim().toLowerCase();
        return txt === 'confirmar' || txt === 'aceptar';
      });
      if (confirmBtn) {
        confirmBtn.click();
        await sleep(600);
      }
    } else {
      console.warn("No se localizó 'Dejar de seguir' en el menú; cerrando...");
      document.body.click(); // Clic fuera para cerrar el menú desplegado
      await sleep(600);
    }

    // Pausa de 4 a 6 segundos entre páginas para evitar bloqueos
    const waitTime = Math.floor(Math.random() * 2000) + 4000;
    await sleep(waitTime);
  }

  console.log(`%cProceso terminado. Total procesados: ${count}`, "color: #1877F2; font-weight: bold;");
})();

🧠 ¿Qué hace este script exactamente?

  • Detecta los menús: Localiza el botón de tres puntos de cada página (Más opciones para...).
  • Despliega y busca la opción: Hace clic en el menú y selecciona automáticamente "Dejar de seguir" o "Ya no me gusta".
  • Confirma por ti: Si Facebook abre una ventana emergente preguntando “¿Seguro que quieres dejar de seguir?”, el código pulsa “Confirmar” automáticamente.
  • Ritmo anti-bloqueo: Espera entre 4 y 6 segundos entre cada página para que Meta no interprete la acción como spam.
  • Control de tanda: Al llegar a 50 páginas procesadas (MAX_LIMIT = 50), se detiene solo.

💡 Consejo para limpiar cientos de páginas

Si tienes más de 50 páginas acumuladas, una vez que termine la primera tanda:

  1. Haz scroll hacia abajo en tu pantalla para que Facebook cargue más páginas en la lista.
  2. Vuelve a pegar el script (o presiona la tecla de flecha hacia arriba ↑ en la consola para recargar el comando) y pulsa Enter.
  3. Para mantener tu cuenta protegida, es recomendable procesar una o dos tandas de 50 por día, dejando descansar la cuenta entre sesiones.

Creado con WordPress & Tema de Anders Norén