"""Resumen de una linea de las transiciones accionables de la flota. Toma el dict `by_classification` que produce `drain_fleet_events` y lo condensa en una sola linea inyectable por un hook `UserPromptSubmit` al orquestador-Claude de flota. Funcion pura, determinista y defensiva ante datos mal formados. """ # Categorias accionables (clave del dict -> etiqueta de salida), en orden de render. _ACTIONABLE: list[tuple[str, str]] = [ ("DICE_TERMINADO", "terminados"), ("RECLAMA", "reclaman"), ("ESTANCADO", "estancados"), ] _GOAL_MAXLEN = 40 _SID_LEN = 8 _NO_SID = "????????" _NO_GOAL = "(sin objetivo)" def _short_sid(session_id) -> str: """Primeros 8 caracteres del session_id, o `????????` si falta/vacio.""" if not isinstance(session_id, str) or session_id == "": return _NO_SID return session_id[:_SID_LEN] def _trunc_goal(goal) -> str: """Goal truncado a 40 caracteres con elipsis, o `(sin objetivo)` si falta.""" if not isinstance(goal, str) or goal == "": return _NO_GOAL if len(goal) > _GOAL_MAXLEN: return goal[:_GOAL_MAXLEN] + "…" return goal def _dedup_events(raw) -> list[dict]: """Filtra a dicts validos y deduplica por session_id quedandose con la ultima aparicion. Mantiene el orden de la ultima aparicion de cada session_id. Eventos sin session_id valido (None/vacio/ausente) NO se deduplican entre si: cada uno cuenta como entrada propia. """ if not isinstance(raw, list): return [] ordered: list[dict] = [] index_by_sid: dict[str, int] = {} for ev in raw: if not isinstance(ev, dict): continue sid = ev.get("session_id") if isinstance(sid, str) and sid != "": if sid in index_by_sid: # Reemplaza la entrada previa por la mas reciente, conservando # la posicion de la ultima aparicion. ordered[index_by_sid[sid]] = None # type: ignore[call-overload] index_by_sid[sid] = len(ordered) ordered.append(ev) else: ordered.append(ev) return [ev for ev in ordered if ev is not None] def summarize_fleet_transitions(by_classification: dict, max_items: int = 6) -> str: """Resume en una linea las transiciones accionables de la flota para el orquestador. Args: by_classification: dict agrupado por campo `to` (salida de drain_fleet_events). Solo se consideran las claves accionables DICE_TERMINADO, RECLAMA y ESTANCADO; el resto (TRABAJANDO, GONE, MAL_LANZADO) se ignora. max_items: limite total de eventos mostrados sumando las tres categorias. Si se supera, se truncan los sobrantes y se añade ` (+N mas)`. El recorte respeta el orden de categorias terminados -> reclaman -> estancados (se llenan en ese orden hasta agotar el cupo). Returns: Una cadena de una sola linea. `FLEET-STATE: sin cambios` si no hay nada accionable; en caso contrario el bloque con las categorias no vacias. """ if not isinstance(by_classification, dict) or not by_classification: return "FLEET-STATE: sin cambios" # Deduplicar y normalizar cada categoria accionable. deduped: dict[str, list[dict]] = {} total_available = 0 for key, _label in _ACTIONABLE: events = _dedup_events(by_classification.get(key)) deduped[key] = events total_available += len(events) if total_available == 0: return "FLEET-STATE: sin cambios" # Aplicar el limite total recorriendo categorias en orden, llenando el cupo. limit = max_items if isinstance(max_items, int) and max_items >= 0 else 0 shown_total = 0 rendered_segments: list[str] = [] for key, label in _ACTIONABLE: events = deduped[key] if not events: continue remaining = limit - shown_total if remaining <= 0: break take = events[:remaining] shown_total += len(take) items = [f"{_short_sid(ev.get('session_id'))}:{_trunc_goal(ev.get('goal'))}" for ev in take] rendered_segments.append(f"{label}=[{', '.join(items)}]") omitted = total_available - shown_total # Caso limite: max_items == 0 (u omite todo) pero habia eventos disponibles. if not rendered_segments: return f"FLEET-STATE: (+{omitted} mas) (drain con ./fn run drain_fleet_events para consumir)" body = " ".join(rendered_segments) overflow = f" (+{omitted} mas)" if omitted > 0 else "" return f"FLEET-STATE: {body}{overflow} (drain con ./fn run drain_fleet_events para consumir)"