352 lines
13 KiB
Python
352 lines
13 KiB
Python
"""Pestaña Dashboard & Métricas de la aplicación GUI.
|
|
|
|
Muestra KPIs, gráficos y detalle de costos para Extracción, Normalización
|
|
y Post-Procesamiento.
|
|
"""
|
|
import customtkinter as ctk
|
|
import tkinter.ttk as ttk
|
|
from gui.styles import AppStyles
|
|
|
|
try:
|
|
import matplotlib
|
|
matplotlib.use("Agg")
|
|
import numpy as np
|
|
from matplotlib.figure import Figure
|
|
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
|
HAS_MPL = True
|
|
except Exception:
|
|
HAS_MPL = False
|
|
|
|
|
|
class MetricsTab:
|
|
"""Encapsula la UI de la pestaña Dashboard & Métricas."""
|
|
|
|
def __init__(self, parent_tab):
|
|
self.tab = parent_tab
|
|
self.fonts = AppStyles.get_fonts()
|
|
self.colors = AppStyles.get_colors()
|
|
self.spacing = AppStyles.get_spacing()
|
|
|
|
self._cost_line_points_x: list[int] = []
|
|
self._cost_line_points_y: list[float] = []
|
|
|
|
self._pp_history: list[dict] = []
|
|
|
|
self._setup_layout()
|
|
self._create_kpi_section()
|
|
self._create_pp_kpi_section()
|
|
self._create_charts_section()
|
|
self._create_comparison_section()
|
|
self._create_detail_section()
|
|
|
|
# -- Layout ----------------------------------------------------------------
|
|
|
|
def _setup_layout(self):
|
|
self.tab.grid_columnconfigure(0, weight=1)
|
|
self.tab.grid_rowconfigure(0, weight=0)
|
|
|
|
self.scroll = ctk.CTkScrollableFrame(self.tab, fg_color="transparent")
|
|
self.scroll.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
|
|
self.scroll.grid_columnconfigure(0, weight=1)
|
|
self.tab.grid_rowconfigure(0, weight=1)
|
|
|
|
# -- KPI cards helper ------------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _make_kpi_card(parent, title, row, col, accent=None):
|
|
card = ctk.CTkFrame(parent)
|
|
card.grid(row=row, column=col, sticky="nsew", padx=4, pady=4)
|
|
card.grid_columnconfigure(0, weight=1)
|
|
|
|
header = ctk.CTkFrame(card, height=6, fg_color=accent or ("#1f77b4", "#1f77b4"), corner_radius=0)
|
|
header.grid(row=0, column=0, sticky="ew")
|
|
header.grid_propagate(False)
|
|
|
|
lbl_title = ctk.CTkLabel(card, text=title, font=ctk.CTkFont(size=12, weight="bold"))
|
|
lbl_title.grid(row=1, column=0, sticky="w", padx=12, pady=(8, 2))
|
|
|
|
lbl_value = ctk.CTkLabel(card, text="—", font=ctk.CTkFont(family="Consolas", size=11), justify="left")
|
|
lbl_value.grid(row=2, column=0, sticky="w", padx=12, pady=(0, 8))
|
|
|
|
return lbl_value
|
|
|
|
# -- Extracción KPIs -------------------------------------------------------
|
|
|
|
def _create_kpi_section(self):
|
|
frame = ctk.CTkFrame(self.scroll)
|
|
frame.grid(row=0, column=0, sticky="ew", padx=2, pady=(2, 1))
|
|
frame.grid_columnconfigure((0, 1, 2, 3), weight=1)
|
|
|
|
lbl = ctk.CTkLabel(frame, text="Extracción de Documentos", font=ctk.CTkFont(size=15, weight="bold"))
|
|
lbl.grid(row=0, column=0, columnspan=4, sticky="w", padx=12, pady=(10, 4))
|
|
|
|
self.kpi_overall = self._make_kpi_card(frame, "Total Sesión", 1, 0, accent=("#2E86C1", "#2E86C1"))
|
|
self.kpi_extract = self._make_kpi_card(frame, "Fase: Extracción", 1, 1, accent=("#1f77b4", "#1f77b4"))
|
|
self.kpi_normalize = self._make_kpi_card(frame, "Fase: Normalización", 1, 2, accent=("#ff7f0e", "#ff7f0e"))
|
|
self.kpi_docs = self._make_kpi_card(frame, "Documentos", 1, 3, accent=("#27AE60", "#27AE60"))
|
|
|
|
zero = "input: 0\ncached: 0\noutput: 0\nUSD: $0.0000"
|
|
self.kpi_overall.configure(text=zero)
|
|
self.kpi_extract.configure(text=zero)
|
|
self.kpi_normalize.configure(text=zero)
|
|
self.kpi_docs.configure(text="Procesados: 0\nCosto prom: $0.0000")
|
|
|
|
# -- Post-Procesamiento KPIs ----------------------------------------------
|
|
|
|
def _create_pp_kpi_section(self):
|
|
frame = ctk.CTkFrame(self.scroll)
|
|
frame.grid(row=1, column=0, sticky="ew", padx=2, pady=1)
|
|
frame.grid_columnconfigure((0, 1, 2, 3), weight=1)
|
|
|
|
lbl = ctk.CTkLabel(frame, text="Post-Procesamiento (Agente Codex)", font=ctk.CTkFont(size=15, weight="bold"))
|
|
lbl.grid(row=0, column=0, columnspan=4, sticky="w", padx=12, pady=(10, 4))
|
|
|
|
self.kpi_pp_total = self._make_kpi_card(frame, "Total Acumulado", 1, 0, accent=("#8E44AD", "#8E44AD"))
|
|
self.kpi_pp_calls = self._make_kpi_card(frame, "Llamadas API", 1, 1, accent=("#9B59B6", "#9B59B6"))
|
|
self.kpi_pp_tokens = self._make_kpi_card(frame, "Tokens", 1, 2, accent=("#AF7AC5", "#AF7AC5"))
|
|
self.kpi_pp_last = self._make_kpi_card(frame, "Última ejecución", 1, 3, accent=("#D2B4DE", "#D2B4DE"))
|
|
|
|
self.kpi_pp_total.configure(text="USD: $0.0000")
|
|
self.kpi_pp_calls.configure(text="Total: 0\nReintentos: 0")
|
|
self.kpi_pp_tokens.configure(text="input: 0\noutput: 0")
|
|
self.kpi_pp_last.configure(text="—")
|
|
|
|
# -- Charts ----------------------------------------------------------------
|
|
|
|
def _create_charts_section(self):
|
|
frame = ctk.CTkFrame(self.scroll)
|
|
frame.grid(row=2, column=0, sticky="ew", padx=2, pady=1)
|
|
frame.grid_columnconfigure((0, 1), weight=1)
|
|
|
|
lbl = ctk.CTkLabel(frame, text="Gráficos", font=ctk.CTkFont(size=15, weight="bold"))
|
|
lbl.grid(row=0, column=0, columnspan=2, sticky="w", padx=12, pady=(10, 4))
|
|
|
|
if HAS_MPL:
|
|
bg = "#2b2b2b"
|
|
|
|
self.fig_line = Figure(figsize=(4.5, 2.8), dpi=100, facecolor=bg)
|
|
self.ax_line = self.fig_line.add_subplot(111)
|
|
self._style_axis(self.ax_line, "Costo Acumulado por Documento (USD)")
|
|
self._line_plot, = self.ax_line.plot([], [], marker="o", markersize=4, linewidth=1.5, color="#2ecc71")
|
|
self.fig_line.tight_layout(pad=1.5)
|
|
|
|
self.canvas_line = FigureCanvasTkAgg(self.fig_line, master=frame)
|
|
self.canvas_line.get_tk_widget().grid(row=1, column=0, padx=8, pady=(0, 8), sticky="nsew")
|
|
|
|
self.fig_bars = Figure(figsize=(4.5, 2.8), dpi=100, facecolor=bg)
|
|
self.ax_bars = self.fig_bars.add_subplot(111)
|
|
self._style_axis(self.ax_bars, "Tokens por Fase")
|
|
self.fig_bars.tight_layout(pad=1.5)
|
|
|
|
self.canvas_bars = FigureCanvasTkAgg(self.fig_bars, master=frame)
|
|
self.canvas_bars.get_tk_widget().grid(row=1, column=1, padx=8, pady=(0, 8), sticky="nsew")
|
|
else:
|
|
ctk.CTkLabel(frame, text="matplotlib no disponible — instálelo para gráficos").grid(
|
|
row=1, column=0, columnspan=2, pady=10,
|
|
)
|
|
|
|
@staticmethod
|
|
def _style_axis(ax, title):
|
|
bg = "#2b2b2b"
|
|
fg = "#cccccc"
|
|
ax.set_facecolor(bg)
|
|
ax.set_title(title, fontsize=10, color=fg, pad=8)
|
|
ax.tick_params(colors=fg, labelsize=8)
|
|
for spine in ax.spines.values():
|
|
spine.set_color("#555555")
|
|
ax.xaxis.label.set_color(fg)
|
|
ax.yaxis.label.set_color(fg)
|
|
|
|
# -- Comparison table ------------------------------------------------------
|
|
|
|
def _create_comparison_section(self):
|
|
frame = ctk.CTkFrame(self.scroll)
|
|
frame.grid(row=3, column=0, sticky="ew", padx=2, pady=1)
|
|
frame.grid_columnconfigure(0, weight=1)
|
|
|
|
lbl = ctk.CTkLabel(frame, text="Tarifas de Modelos (USD / 1M tokens)", font=ctk.CTkFont(size=15, weight="bold"))
|
|
lbl.grid(row=0, column=0, sticky="w", padx=12, pady=(10, 4))
|
|
|
|
style = ttk.Style()
|
|
style.configure("Metrics.Treeview", rowheight=26, font=("Consolas", 10))
|
|
style.configure("Metrics.Treeview.Heading", font=("Roboto", 11, "bold"))
|
|
|
|
self.tree_comparison = ttk.Treeview(
|
|
frame,
|
|
columns=("model", "input", "cached", "output"),
|
|
show="headings", height=6, style="Metrics.Treeview",
|
|
)
|
|
self.tree_comparison.heading("model", text="Modelo")
|
|
self.tree_comparison.heading("input", text="Input")
|
|
self.tree_comparison.heading("cached", text="Cached Input")
|
|
self.tree_comparison.heading("output", text="Output")
|
|
|
|
self.tree_comparison.column("model", width=180, anchor="w")
|
|
self.tree_comparison.column("input", width=110, anchor="e")
|
|
self.tree_comparison.column("cached", width=110, anchor="e")
|
|
self.tree_comparison.column("output", width=110, anchor="e")
|
|
|
|
self.tree_comparison.grid(row=1, column=0, sticky="ew", padx=12, pady=(0, 10))
|
|
|
|
# -- Detail log ------------------------------------------------------------
|
|
|
|
def _create_detail_section(self):
|
|
frame = ctk.CTkFrame(self.scroll)
|
|
frame.grid(row=4, column=0, sticky="ew", padx=2, pady=(1, 2))
|
|
frame.grid_columnconfigure(0, weight=1)
|
|
|
|
lbl = ctk.CTkLabel(frame, text="Detalle de uso (log)", font=ctk.CTkFont(size=15, weight="bold"))
|
|
lbl.grid(row=0, column=0, sticky="w", padx=12, pady=(10, 4))
|
|
|
|
self.usage_text = ctk.CTkTextbox(frame, height=160, font=self.fonts["mono_small"], wrap="none")
|
|
self.usage_text.grid(row=1, column=0, sticky="ew", padx=12, pady=(0, 10))
|
|
|
|
# =========================================================================
|
|
# API PÚBLICA
|
|
# =========================================================================
|
|
|
|
# -- Extracción KPIs -------------------------------------------------------
|
|
|
|
def update_kpis(self, overall: dict, extract_phase: dict, normalize_phase: dict) -> None:
|
|
def fmt(d):
|
|
return (
|
|
f"input: {d.get('input', 0):,}\n"
|
|
f"cached: {d.get('cached', 0):,}\n"
|
|
f"output: {d.get('output', 0):,}\n"
|
|
f"USD: ${d.get('usd', 0.0):.4f}"
|
|
)
|
|
self.kpi_overall.configure(text=fmt(overall))
|
|
self.kpi_extract.configure(text=fmt(extract_phase))
|
|
self.kpi_normalize.configure(text=fmt(normalize_phase))
|
|
|
|
def update_doc_kpi(self, processed: int, avg_cost: float) -> None:
|
|
self.kpi_docs.configure(text=f"Procesados: {processed}\nCosto prom: ${avg_cost:.4f}")
|
|
|
|
# -- Post-Procesamiento KPIs ----------------------------------------------
|
|
|
|
def update_pp_kpis(
|
|
self,
|
|
total_usd: float,
|
|
total_calls: int,
|
|
total_retries: int,
|
|
input_tokens: int,
|
|
output_tokens: int,
|
|
last_description: str,
|
|
) -> None:
|
|
self.kpi_pp_total.configure(text=f"USD: ${total_usd:.4f}")
|
|
self.kpi_pp_calls.configure(text=f"Total: {total_calls}\nReintentos: {total_retries}")
|
|
self.kpi_pp_tokens.configure(text=f"input: {input_tokens:,}\noutput: {output_tokens:,}")
|
|
self.kpi_pp_last.configure(text=last_description or "—")
|
|
|
|
# -- Charts ----------------------------------------------------------------
|
|
|
|
def update_cost_line(self, doc_index: int, total_cost_usd: float) -> None:
|
|
if not HAS_MPL:
|
|
return
|
|
try:
|
|
self._cost_line_points_x.append(doc_index)
|
|
self._cost_line_points_y.append(total_cost_usd)
|
|
self._line_plot.set_data(self._cost_line_points_x, self._cost_line_points_y)
|
|
self.ax_line.relim()
|
|
self.ax_line.autoscale_view()
|
|
self.canvas_line.draw_idle()
|
|
except Exception:
|
|
pass
|
|
|
|
def update_token_bars(
|
|
self,
|
|
input_extract: int, cached_extract: int, output_extract: int,
|
|
input_norm: int, cached_norm: int, output_norm: int,
|
|
input_pp: int = 0, output_pp: int = 0,
|
|
) -> None:
|
|
if not HAS_MPL:
|
|
return
|
|
try:
|
|
self.ax_bars.clear()
|
|
self._style_axis(self.ax_bars, "Tokens por Fase")
|
|
|
|
labels = ["Input", "Cached", "Output"]
|
|
extract_vals = [input_extract, cached_extract, output_extract]
|
|
norm_vals = [input_norm, cached_norm, output_norm]
|
|
pp_vals = [input_pp, 0, output_pp]
|
|
|
|
has_pp = input_pp > 0 or output_pp > 0
|
|
|
|
x = np.arange(len(labels))
|
|
width = 0.25 if has_pp else 0.35
|
|
offset = width
|
|
|
|
self.ax_bars.bar(x - offset, extract_vals, width, label="Extracción", color="#3498db")
|
|
self.ax_bars.bar(x, norm_vals, width, label="Normalización", color="#e67e22")
|
|
if has_pp:
|
|
self.ax_bars.bar(x + offset, pp_vals, width, label="Post-Proc.", color="#9b59b6")
|
|
|
|
self.ax_bars.set_xticks(x, labels)
|
|
leg = self.ax_bars.legend(fontsize=8, framealpha=0.6)
|
|
for text in leg.get_texts():
|
|
text.set_color("#cccccc")
|
|
self.fig_bars.tight_layout(pad=1.5)
|
|
self.canvas_bars.draw_idle()
|
|
except Exception:
|
|
pass
|
|
|
|
# -- Comparison table ------------------------------------------------------
|
|
|
|
def update_comparison_table(self, comparisons: list[dict]) -> None:
|
|
try:
|
|
for item in self.tree_comparison.get_children():
|
|
self.tree_comparison.delete(item)
|
|
for c in comparisons:
|
|
self.tree_comparison.insert("", "end", values=(
|
|
c.get("model", "N/D"),
|
|
f"${c.get('input', 0.0):.3f}",
|
|
f"${c.get('cached_input', 0.0):.4f}",
|
|
f"${c.get('output', 0.0):.2f}",
|
|
))
|
|
except Exception:
|
|
pass
|
|
|
|
# -- Detail log ------------------------------------------------------------
|
|
|
|
def append_usage(self, text: str) -> None:
|
|
self.usage_text.insert("end", text + "\n")
|
|
self.usage_text.see("end")
|
|
|
|
def append_usage_safe(self, text: str) -> None:
|
|
self.append_usage(text)
|
|
|
|
def append_usage_row(self, phase: str, radicado: str, usage: dict | None, cost: dict | None) -> None:
|
|
try:
|
|
usd = (cost or {}).get("total_usd", 0.0)
|
|
prompt = int((usage or {}).get("prompt_tokens", 0) or 0)
|
|
comp = int((usage or {}).get("completion_tokens", 0) or 0)
|
|
self.append_usage(
|
|
f"[{phase:<12}] {radicado:<22} | in={prompt:>7,} out={comp:>6,} | ${usd:.4f}"
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def clear(self) -> None:
|
|
self.usage_text.delete("1.0", "end")
|
|
|
|
zero = "input: 0\ncached: 0\noutput: 0\nUSD: $0.0000"
|
|
self.kpi_overall.configure(text=zero)
|
|
self.kpi_extract.configure(text=zero)
|
|
self.kpi_normalize.configure(text=zero)
|
|
self.kpi_docs.configure(text="Procesados: 0\nCosto prom: $0.0000")
|
|
|
|
if HAS_MPL:
|
|
self._cost_line_points_x.clear()
|
|
self._cost_line_points_y.clear()
|
|
self._line_plot.set_data([], [])
|
|
self.ax_line.relim()
|
|
self.ax_line.autoscale_view()
|
|
self.canvas_line.draw_idle()
|
|
|
|
self.ax_bars.clear()
|
|
self._style_axis(self.ax_bars, "Tokens por Fase")
|
|
self.canvas_bars.draw_idle()
|
|
|
|
def set_usage_totals(self, **kwargs):
|
|
pass
|