Files
INFRAESTRUCTURA/scripts/monitor_estado/resumen.py
2026-10-03 17:13:39 +02:00

135 lines
5.1 KiB
Python

# -*- coding: utf-8 -*-
"""resumen.py — Resumen IA del estado y clasificacion "requiere atencion" (RF-11/12/13).
Construye un prompt con las novedades del ciclo y llama al gateway IA ya configurado
en el entorno (OmniRoute, endpoint OpenAI-compatible en localhost). La IA devuelve un
JSON con el resumen en espanol y la lista de elementos que requieren atencion.
Si la IA no esta disponible, no se pierde nada: se archiva igualmente lo recogido y se
deja constancia del fallo (`fallo_ia=True`) para que el orquestador avise (RF-13).
El nucleo es testeable inyectando `_ia` (funcion prompt->texto) sin tocar la red.
"""
from __future__ import annotations
import json
import re
from datetime import datetime
from typing import Callable
import requests
from . import config
_SYSTEM = (
"Eres el asistente de estado de un jefe de desarrollo. Recibes las novedades del dia "
"(mensajes de Telegram, correos y tickets de Jira DS). Devuelve SOLO un JSON valido, sin "
"texto adicional, con esta forma: "
'{"resumen_md": "<resumen en espanol, breve, indicando fuente y autor>", '
'"requiere_atencion": [{"fuente": "...", "quien": "...", "texto": "...", "motivo": "..."}]}. '
"Marca en requiere_atencion solo incidencias, bloqueos, errores, paros, solicitudes urgentes, "
"tickets sin asignar o cambios de estado relevantes. Si no hay nada relevante, deja la lista vacia."
)
def _texto_novedad(fuente: str, item) -> str:
"""Convierte un elemento de una fuente en una linea legible para el prompt."""
if not isinstance(item, dict):
return f"[{fuente}] {item}"
if fuente.startswith("telegram"):
return f"[{fuente}] {item.get('quien', '?')}: {item.get('texto', '')}"
if fuente == "correo":
return f"[correo] {item.get('de', '?')}: {item.get('asunto', '')} — {item.get('snippet', '')}"
if fuente == "jira":
return f"[jira] {item.get('key', '?')} ({item.get('estado', '')}): {item.get('resumen', '')}"
return f"[{fuente}] {item}"
def construir_prompt(novedades: dict, errores: list) -> str:
"""Texto del prompt a partir de las novedades y errores del ciclo."""
lineas: list[str] = []
for fuente, items in novedades.items():
if not items:
continue
lineas.append(f"## {fuente}")
for it in items:
lineas.append("- " + _texto_novedad(fuente, it))
if errores:
lineas.append("## errores de recogida")
for fuente, err in errores:
lineas.append(f"- [{fuente}] {err}")
return "\n".join(lineas)
def _extraer_json(texto: str) -> dict:
"""Extrae el primer objeto JSON del texto (tolerando fences de markdown)."""
limpio = re.sub(r"^```(?:json)?|```$", "", texto.strip(), flags=re.MULTILINE).strip()
inicio = limpio.find("{")
fin = limpio.rfind("}")
if inicio == -1 or fin == -1:
raise ValueError("la IA no devolvio JSON")
return json.loads(limpio[inicio:fin + 1])
def _llamar_ia(prompt: str, cfg: dict) -> str:
"""Llama al gateway IA (OmniRoute, OpenAI-compatible) y devuelve el texto."""
ia = cfg.get("ia", {})
base_url = ia.get("base_url", "http://localhost:20128/v1").rstrip("/")
modelo = ia.get("modelo", "auto/fast")
headers = {"Content-Type": "application/json"}
api_key = ia.get("api_key")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
body = {
"model": modelo,
"messages": [
{"role": "system", "content": _SYSTEM},
{"role": "user", "content": prompt},
],
"temperature": 0.2,
"stream": False,
}
r = requests.post(f"{base_url}/chat/completions", json=body, headers=headers, timeout=90)
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
def generar(
novedades: dict,
errores: list,
cfg: dict | None = None,
_ia: Callable[[str], str] | None = None,
) -> dict:
"""Genera el resumen del ciclo.
Devuelve un dict con: generado, fuentes_con_novedad, errores, fallo_ia,
requiere_atencion[] y resumen_md.
"""
cfg = cfg or config.cargar()
fuentes_con_novedad = [k for k, v in novedades.items() if v]
resultado = {
"generado": datetime.now().isoformat(),
"fuentes_con_novedad": fuentes_con_novedad,
"errores": [{"fuente": f, "error": e} for f, e in errores],
"fallo_ia": False,
"requiere_atencion": [],
"resumen_md": "",
}
if not fuentes_con_novedad and not errores:
resultado["resumen_md"] = "Sin novedades."
return resultado
prompt = construir_prompt(novedades, errores)
try:
texto = _ia(prompt) if _ia is not None else _llamar_ia(prompt, cfg)
datos = _extraer_json(texto)
resultado["resumen_md"] = datos.get("resumen_md", "") or ""
atencion = datos.get("requiere_atencion", []) or []
resultado["requiere_atencion"] = atencion if isinstance(atencion, list) else []
except Exception as e: # RF-13: no perder lo recogido y dejar constancia
resultado["fallo_ia"] = True
resultado["resumen_md"] = f"No se pudo generar el resumen IA: {e}"
return resultado