135 lines
5.1 KiB
Python
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
|