# -*- 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": "", ' '"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