From b5910eb5fcd28c2145f691fd545df061da1b5c8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8thomas=C2=A8?= Date: Fri, 19 Dec 2025 02:11:44 -0300 Subject: [PATCH 1/6] feat: Implemento smart routing, inteligencia de costos y comandos de stats/cache (#50) --- PLAN_SMART_ROUTING.md | 85 ++++ SMART_ROUTING.md | 398 ++++++++++++++++++ cmd/main.go | 4 + internal/cli/command/cache/cache.go | 46 ++ internal/cli/command/config/init.go | 4 +- internal/cli/command/config/show_test.go | 2 +- internal/cli/command/stats/stats.go | 130 ++++++ internal/config/ai.go | 17 +- internal/config/config.go | 9 +- internal/domain/models/commit.go | 2 +- internal/domain/models/issue_generation.go | 2 +- internal/domain/models/pr.go | 2 +- internal/domain/models/release.go | 2 +- internal/domain/models/usage.go | 12 +- internal/domain/ports/ai_provider.go | 23 + internal/i18n/locales/active.en.toml | 67 +++ internal/i18n/locales/active.es.toml | 70 ++- internal/infrastructure/ai/ARCHITECTURE.md | 229 ++++++++++ internal/infrastructure/ai/cost_wrapper.go | 227 ++++++++++ internal/infrastructure/ai/gemini/base.go | 43 ++ .../ai/gemini/commit_summarizer_service.go | 74 ++-- internal/infrastructure/ai/gemini/helper.go | 34 +- .../ai/gemini/issue_content_generator.go | 66 ++- .../pull_requests_summarizer_service.go | 66 +-- .../ai/gemini/release_generator.go | 76 ++-- internal/infrastructure/cache/cache.go | 131 ++++++ internal/services/cost/calculator.go | 97 +++++ internal/services/cost/manager.go | 203 +++++++++ .../services/pull_request_service_test.go | 2 +- internal/services/routing/model_selector.go | 42 ++ internal/ui/token_stats.go | 35 +- 31 files changed, 2061 insertions(+), 139 deletions(-) create mode 100644 PLAN_SMART_ROUTING.md create mode 100644 SMART_ROUTING.md create mode 100644 internal/cli/command/cache/cache.go create mode 100644 internal/cli/command/stats/stats.go create mode 100644 internal/domain/ports/ai_provider.go create mode 100644 internal/infrastructure/ai/ARCHITECTURE.md create mode 100644 internal/infrastructure/ai/cost_wrapper.go create mode 100644 internal/infrastructure/ai/gemini/base.go create mode 100644 internal/infrastructure/cache/cache.go create mode 100644 internal/services/cost/calculator.go create mode 100644 internal/services/cost/manager.go create mode 100644 internal/services/routing/model_selector.go diff --git a/PLAN_SMART_ROUTING.md b/PLAN_SMART_ROUTING.md new file mode 100644 index 0000000..d63a85b --- /dev/null +++ b/PLAN_SMART_ROUTING.md @@ -0,0 +1,85 @@ +# Plan de Implementación: Smart Routing & Control de Costos 🧉 + +Este documento detalla cómo vamos a encarar la **Issue #50** para darle a MateCommit inteligencia financiera y de ruteo, aprovechando la salida de **Gemini 3.0 Flash**. + +--- + +## 🚀 La Nueva Estrella: Gemini 3.0 Flash + +Che, la gran novedad es que integramos este modelo que rompe todo. Es rápido como el 2.5 pero razona casi como un Pro. + +### 💸 Tabla de Precios (Oficial) +| Tipo de Token | Precio (por 1M) | Detalle | +| :--- | :--- | :--- | +| **Input (Entrada)** | **$0.50** | Lo que le mandamos (diffs, contexto) | +| **Output (Salida)** | **$3.00** | Lo que nos responde (el commit, resumen) | + +> **Ojo al piojo:** La salida es 6 veces más cara. Por eso nuestra estimación tiene que ser fina ahí. + +--- + +## 🧠 Smart Routing: El Cerebro + +La idea es que no gastes pólvora en chimangos. El sistema va a decidir solo (o sugerirte): + +1. **Diffs Chicos (< 1k tokens):** Se van por **Gemini 2.5 Flash**. Es barato y sobra paño. +2. **Diffs Grandes (> 10k tokens):** Activamos **Gemini 3.0 Flash**. ¿Por qué? Porque tiene mejor contexto y no alucina cuando le tiras un choclo de código. +3. **Caching Local:** Si ya hiciste esta pregunta exacta, la sacamos de tu disco. **Costo: $0**. + +--- + +## 🔄 Flujo del Usuario (User Journey) + +Así va a ser la experiencia cuando tires un comando: + +1. Vos tirás: `matecommit summarize pr --n 50` +2. MateCommit **cuenta los tokens** (sin cobrarte nada todavía). +3. Te canta la justa: + > "Che, analizar este PR te va a salir **~$0.01 USD**. ¿Le mandamos mecha?" [Y/n] +4. Si decís que sí, recién ahí llamamos a la API. +5. Al final, te tiramos la posta: "Costo final real: **$0.0098**". + +--- + +## 🛠️ Cambios Técnicos (Lo que vamos a codear) + +### 1. Configuración y Modelos +* Modificar `internal/config/ai.go` para agregar `gemini-3.0-flash`. + +### 2. El "Calculator Service" (`internal/domain/services/cost/`) +Vamos a crear un servicio nuevo que se encargue de: +* `CountTokens()`: Usar la API para contar exacto. +* `EstimateCost()`: Calcular $$ basado en la tabla de arriba. +* `CheckBudget()`: Si tenés un límite diario (ej. $2 USD) y te vas a pasar, te avisamos. +* **Historial de Actividad Completo**: Guardamos un JSON súper detallado en `~/.matecommit/history.json`: + * `timestamp`: Cuándo fue. + * `model`: Qué modelo usaste (para ver si el 3.0 te rinde más). + * `latency_ms`: Cuánto tardó (para medir velocidad). + * `cost_usd`: La dolorosa. + * `tokens_saved`: Si hubo caché, cuánto te ahorraste. + +### 3. Caché Local (Anti-Crisis) +* **¿Qué es?** Un archivo en tu compu. +* **¿Cómo funciona?** Calculamos una "huella digital" (hash) de tu código. Si volvés a pedir lo mismo, leemos el archivo local. +* **Diferencia con Gemini Cache:** Google ofrece "Context Caching" pero te cobra por guardar. Nosotros hacemos **Caché de Respuesta** en tu disco, que es gratis y más rápido. + +### 4. Integración Global +Esto no es solo para PRs, eh. Lo vamos a meter en **todos** los comandos: +* [ ] `summarize pr` +* [ ] `suggest commits` (el clásico) +* [ ] `generate release` +* [ ] `generate issue` + +### 5. Nuevo Comando: `matecommit cost` +Para ver tu resumen mensual: "Este mes gastaste $0.45 USD en 15 PRs". + +--- + +## ✅ Plan de Pruebas + +Para estar seguros que no le erramos al vizcachazo: +1. **Test Unitario:** Verificar que 1 millón de tokens de entrada nos de exactamente $0.50. +2. **Dry Run:** Correr la CLI, ver la estimación, y compararla con lo que realmente nos cobra Google en el dashboard. + +--- +*Documento generado automáticamente por tu asistente de IA favorito.* 😉 diff --git a/SMART_ROUTING.md b/SMART_ROUTING.md new file mode 100644 index 0000000..6404e74 --- /dev/null +++ b/SMART_ROUTING.md @@ -0,0 +1,398 @@ +# Smart Routing & Control de Costos - Guía de Usuario + +Este documento explica las nuevas features de inteligencia de costos implementadas en MateCommit. + +## 🧠 Smart Routing Automático + +MateCommit ahora selecciona automáticamente el modelo óptimo según la complejidad de la tarea: + +### Estrategia de Selección + +| Operación | Tokens | Modelo Seleccionado | Razón | +|-----------|--------|---------------------|-------| +| `suggest-commits` | < 1,000 | Gemini 2.5 Flash-Lite | Económico para cambios pequeños | +| `suggest-commits` | 1,000-10,000 | Gemini 2.5 Flash | Balance costo/calidad | +| `suggest-commits` | > 10,000 | Gemini 3.0 Flash | Mejor contexto, evita alucinaciones | +| `summarize-pr` | Cualquiera | Según tokens | Mismo criterio que commits | +| `generate-release` | Cualquiera | Gemini 3.0 Flash | Máxima calidad de redacción | +| `generate-issue` | Cualquiera | Gemini 3.0 Flash | Claridad y detalle | + +### Ejemplo de Sugerencia + +Si estás usando Gemini 2.5 Flash pero tienes un diff grande (> 10k tokens), verás: + +``` +💡 Sugerencia: Operación grande (> 10k tokens), requiere mejor manejo de contexto + Modelo sugerido: gemini-3.0-flash (actualmente usando: gemini-2.5-flash) +``` + +Esta es solo una sugerencia. Puedes cambiar el modelo en tu configuración si lo prefieres. + +--- + +## 💰 Confirmación de Costo + +Para operaciones que cuestan más de **$0.005 USD**, MateCommit pedirá confirmación: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +💰 Estimación de Costo +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📊 Tokens de entrada estimados: 12500 +📤 Tokens de salida estimados: 800 +💵 Costo estimado: $0.0077 USD +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +¿Desea continuar? [Y/n]: +``` + +### Respuestas Aceptadas + +- **Continuar:** presiona `Enter`, `Y`, `y`, `yes`, `si`, o `s` +- **Cancelar:** presiona `N` o `n` + +### Deshabilitar Confirmación + +Si no quieres que te pregunte (útil para CI/CD), puedes: + +1. **Configuración global:** Agregar a `~/.matecommit/config.toml`: + ```toml + [ai_config] + skip_confirmation = true + ``` + +2. **Variable de entorno:** + ```bash + export MATECOMMIT_SKIP_CONFIRMATION=true + ``` + +--- + +## 🚨 Alertas de Presupuesto + +Si configuraste un presupuesto diario, verás alertas progresivas: + +### Alerta al 50% (Amarillo) + +``` +⚠️ Has usado 52% de tu presupuesto diario ($0.52 / $1.00) +``` + +### Alerta al 75% (Amarillo Bold) + +``` +⚠️ ¡Cuidado! Has usado 78% de tu presupuesto diario + Total gastado: $0.78 / $1.00 +``` + +### Alerta al 90% (Rojo Bold) + +``` +🚨 ¡ALERTA! Has usado 93% de tu presupuesto diario + Total gastado: $0.93 / $1.00 + Quedan solo: $0.07 +``` + +### Presupuesto Excedido + +Si una operación excedería tu presupuesto: + +``` +❌ Presupuesto diario excedido + Gastado hoy: $0.98 + Costo estimado: $0.05 + Total sería: $1.03 + Límite diario: $1.00 + Exceso: $0.03 + +Error: presupuesto diario excedido... +``` + +### Configurar Presupuesto + +Edita `~/.matecommit/config.toml`: + +```toml +[ai_config] +budget_daily = 2.00 # $2 USD por día +``` + +O al crear la configuración: + +```bash +matecommit config init +# Cuando pregunte por el presupuesto diario, ingresa: 2.00 +``` + +**Sin presupuesto:** Si no configuras `budget_daily` o lo pones en `0`, no habrá límites. + +--- + +## 📊 Ver Estadísticas + +### Estadísticas Diarias + +```bash +matecommit stats +``` + +Salida: +``` +📊 Estadísticas Diarias +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +10:30 - suggest-commits: $0.0003 +11:45 - summarize-pr: $0.0012 +14:20 - generate-release: $0.0045 [CACHE] + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Total Hoy: $0.0060 USD +``` + +**[CACHE]** indica que la respuesta salió del caché local (costo $0). + +### Estadísticas Mensuales + +```bash +matecommit stats --monthly +``` + +Salida: +``` +📅 Estadísticas Mensuales - December 2025 +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +2025-12-17: $0.0234 +2025-12-18: $0.0567 +2025-12-19: $0.0060 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Total Este Mes: $0.0861 USD +``` + +### Alias + +Puedes usar `cost` como alias: + +```bash +matecommit cost # = matecommit stats +matecommit cost -m # = matecommit stats --monthly +``` + +--- + +## 💾 Caché Local + +### Beneficios + +El caché guarda respuestas por **24 horas**: + +- **Costo:** $0.00 (gratis) +- **Velocidad:** Instantáneo +- **Ubicación:** `~/.matecommit/cache/` + +### Cuándo se usa + +Si ejecutas **exactamente el mismo comando** dos veces: + +```bash +# Primera vez: llama a la API, cuesta $0.0003 +matecommit suggest + +# Segunda vez (< 24h): lee del caché, cuesta $0 +matecommit suggest +``` + +El hash incluye: +- Proveedor (gemini) +- Modelo (gemini-2.5-flash) +- Prompt completo (diff + contexto) + +**Cambió algo?** → Nuevo hash → No usa caché + +### Limpiar Caché + +```bash +matecommit cache clean +``` + +Salida: +``` +✓ Caché limpiado exitosamente +``` + +Esto elimina todos los archivos en `~/.matecommit/cache/`. + +--- + +## 🎯 Ejemplos de Uso + +### Ejemplo 1: Commit Pequeño + +```bash +# Cambio de 3 líneas en un archivo +git add file.go +matecommit suggest +``` + +Salida: +``` +💡 Sugerencia: Operación pequeña (< 1k tokens), modelo económico suficiente + Modelo sugerido: gemini-2.5-flash-lite (actualmente usando: gemini-2.5-flash) + +[Genera sugerencias sin pedir confirmación porque cuesta < $0.005] +``` + +### Ejemplo 2: PR Grande + +```bash +# PR con 50 archivos modificados +matecommit summarize-pr --n 123 +``` + +Salida: +``` +💡 Sugerencia: Operación grande (> 10k tokens), requiere mejor manejo de contexto + Modelo sugerido: gemini-3.0-flash (actualmente usando: gemini-2.5-flash) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +💰 Estimación de Costo +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📊 Tokens de entrada estimados: 15800 +📤 Tokens de salida estimados: 500 +💵 Costo estimado: $0.0129 USD +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +¿Desea continuar? [Y/n]: y + +[Genera el resumen del PR] +``` + +### Ejemplo 3: Presupuesto Casi Agotado + +```bash +# Ya gastaste $0.95 de $1.00 hoy +matecommit suggest +``` + +Salida: +``` +🚨 ¡ALERTA! Has usado 95% de tu presupuesto diario + Total gastado: $0.95 / $1.00 + Quedan solo: $0.05 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +💰 Estimación de Costo +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📊 Tokens de entrada estimados: 800 +📤 Tokens de salida estimados: 500 +💵 Costo estimado: $0.0015 USD +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +¿Desea continuar? [Y/n]: +``` + +--- + +## 📁 Archivos de Datos + +### Historial + +**Ubicación:** `~/.matecommit/history.json` + +```json +[ + { + "timestamp": "2025-12-19T10:30:00Z", + "command": "suggest-commits", + "provider": "gemini", + "model": "gemini-2.5-flash", + "tokens_input": 450, + "tokens_output": 120, + "cost_usd": 0.0003, + "duration_ms": 1250, + "cache_hit": false, + "hash": "abc123..." + } +] +``` + +### Caché + +**Ubicación:** `~/.matecommit/cache/[hash].json` + +```json +{ + "hash": "abc123...", + "response": { ... }, + "created_at": "2025-12-19T10:30:00Z" +} +``` + +**TTL:** 24 horas (auto-limpieza al iniciar) + +--- + +## 💡 Tips para Ahorrar + +1. **Usa caché:** Si no cambiaste nada, la segunda ejecución es gratis +2. **Configura presupuesto:** Te protege de gastos inesperados +3. **Presta atención a las sugerencias:** Si te sugiere Flash-Lite, probablemente no necesitas Pro +4. **Limpia archivos grandes antes de commit:** `go.sum`, `package-lock.json` no aportan al análisis + +--- + +## ⚙️ Configuración Avanzada + +### Deshabilitar Smart Routing + +Si prefieres controlar manualmente el modelo: + +```toml +# ~/.matecommit/config.toml +[ai_config] +model = "gemini-3.0-flash" # Siempre usa este +``` + +MateCommit seguirá sugiriendo, pero usará el modelo que configuraste. + +### Ajustar Umbral de Confirmación + +Actualmente hardcodeado en `$0.005`, pero podrías modificar en: + +`internal/infrastructure/ai/cost_wrapper.go:116` + +```go +if estimatedCost > 0.010 && !w.skipConfirmation { // Cambiar de 0.005 a 0.010 +``` + +### Cambiar TTL del Caché + +`internal/domain/services/cache/cache.go` al construir: + +```go +cache.NewCache(48 * time.Hour) // Cambiar de 24h a 48h +``` + +--- + +## 🐛 Troubleshooting + +### "Presupuesto excedido" pero no configuré ninguno + +Verifica `~/.matecommit/config.toml`: + +```toml +[ai_config] +budget_daily = 0 # 0 = sin límite +``` + +### El caché no funciona + +1. Verifica que `~/.matecommit/cache/` existe +2. Chequea permisos: `chmod 755 ~/.matecommit/cache` +3. Limpia y reinicia: `matecommit cache clean` + +### Sugerencias de modelo equivocadas + +Abre un issue en GitHub con: +- Comando ejecutado +- Cantidad de tokens estimados +- Modelo sugerido vs esperado diff --git a/cmd/main.go b/cmd/main.go index 73a8df7..a244c68 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -6,12 +6,14 @@ import ( "log" "os" + "github.com/Tomas-vilte/MateCommit/internal/cli/command/cache" "github.com/Tomas-vilte/MateCommit/internal/cli/command/completion" "github.com/Tomas-vilte/MateCommit/internal/cli/command/config" "github.com/Tomas-vilte/MateCommit/internal/cli/command/handler" "github.com/Tomas-vilte/MateCommit/internal/cli/command/issues" "github.com/Tomas-vilte/MateCommit/internal/cli/command/pull_requests" "github.com/Tomas-vilte/MateCommit/internal/cli/command/release" + "github.com/Tomas-vilte/MateCommit/internal/cli/command/stats" "github.com/Tomas-vilte/MateCommit/internal/cli/command/suggests_commits" "github.com/Tomas-vilte/MateCommit/internal/cli/command/update" "github.com/Tomas-vilte/MateCommit/internal/cli/registry" @@ -133,6 +135,8 @@ func initializeApp() (*cli.Command, error) { commands := registerCommand.CreateCommands() commands = append(commands, completion.NewCompletionCommand(translations)) + commands = append(commands, stats.NewStatsCommand().CreateCommand(translations, cfgApp)) + commands = append(commands, cache.NewCacheCommand().CreateCommand(translations, cfgApp)) helpCommand := &cli.Command{ Name: "help", diff --git a/internal/cli/command/cache/cache.go b/internal/cli/command/cache/cache.go new file mode 100644 index 0000000..324ace2 --- /dev/null +++ b/internal/cli/command/cache/cache.go @@ -0,0 +1,46 @@ +package cache + +import ( + "context" + "fmt" + "time" + + "github.com/Tomas-vilte/MateCommit/internal/config" + "github.com/Tomas-vilte/MateCommit/internal/i18n" + "github.com/Tomas-vilte/MateCommit/internal/infrastructure/cache" + "github.com/fatih/color" + "github.com/urfave/cli/v3" +) + +type CacheCommand struct{} + +func NewCacheCommand() *CacheCommand { + return &CacheCommand{} +} + +func (c *CacheCommand) CreateCommand(t *i18n.Translations, _ *config.Config) *cli.Command { + return &cli.Command{ + Name: "cache", + Usage: t.GetMessage("cache.usage", 0, nil), + Commands: []*cli.Command{ + { + Name: "clean", + Usage: t.GetMessage("cache.clean_usage", 0, nil), + Action: func(ctx context.Context, cmd *cli.Command) error { + cacheService, err := cache.NewCache(24 * time.Hour) + if err != nil { + return fmt.Errorf(t.GetMessage("cache.error_init", 0, nil)+": %w", err) + } + + if err := cacheService.Clean(); err != nil { + return fmt.Errorf(t.GetMessage("cache.error_clean", 0, nil)+": %w", err) + } + + green := color.New(color.FgGreen, color.Bold) + _, _ = green.Printf("✓ %s\n", t.GetMessage("cache.cleaned", 0, nil)) + return nil + }, + }, + }, + } +} diff --git a/internal/cli/command/config/init.go b/internal/cli/command/config/init.go index 6050828..8da0742 100644 --- a/internal/cli/command/config/init.go +++ b/internal/cli/command/config/init.go @@ -158,7 +158,7 @@ func configureWelcome(ctx context.Context, reader *bufio.Reader, cfg *config.Con cfg.AIProviders["gemini"] = config.AIProviderConfig{ APIKey: apiKey, - Model: selectedModel, + Model: string(config.ModelGeminiV15Flash), Temperature: 0.3, MaxTokens: 10000, } @@ -366,7 +366,7 @@ func validateGeminiAPIKey(ctx context.Context, apiKey string, t *i18n.Translatio AIProviders: map[string]config.AIProviderConfig{ "gemini": { APIKey: apiKey, - Model: string(config.ModelGeminiV25Flash), + Model: string(config.ModelGeminiV15Flash), Temperature: 0.3, MaxTokens: 10000, }, diff --git a/internal/cli/command/config/show_test.go b/internal/cli/command/config/show_test.go index 831dd73..17c0bbf 100644 --- a/internal/cli/command/config/show_test.go +++ b/internal/cli/command/config/show_test.go @@ -76,7 +76,7 @@ func TestShowCommand(t *testing.T) { cfg.AIConfig = config.AIConfig{ ActiveAI: config.AIGemini, Models: map[config.AI]config.Model{ - config.AIGemini: config.ModelGeminiV25Flash, + config.AIGemini: config.ModelGeminiV15Flash, config.AIOpenAI: config.ModelGPTV4o, }, } diff --git a/internal/cli/command/stats/stats.go b/internal/cli/command/stats/stats.go new file mode 100644 index 0000000..e5892b0 --- /dev/null +++ b/internal/cli/command/stats/stats.go @@ -0,0 +1,130 @@ +package stats + +import ( + "context" + "fmt" + "time" + + "github.com/Tomas-vilte/MateCommit/internal/config" + "github.com/Tomas-vilte/MateCommit/internal/i18n" + "github.com/Tomas-vilte/MateCommit/internal/services/cost" + "github.com/fatih/color" + "github.com/urfave/cli/v3" +) + +type StatsCommand struct{} + +func NewStatsCommand() *StatsCommand { + return &StatsCommand{} +} + +func (c *StatsCommand) CreateCommand(t *i18n.Translations, _ *config.Config) *cli.Command { + return &cli.Command{ + Name: "stats", + Aliases: []string{"cost"}, + Usage: t.GetMessage("stats.usage", 0, nil), + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "monthly", + Aliases: []string{"m"}, + Usage: t.GetMessage("stats.monthly_flag", 0, nil), + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + manager, err := cost.NewManager(0, t) + if err != nil { + return fmt.Errorf(t.GetMessage("stats.error_init", 0, nil)+": %w", err) + } + + showMonthly := cmd.Bool("monthly") + + if showMonthly { + return c.showMonthlyStats(manager, t) + } + return c.showDailyStats(manager, t) + }, + } +} + +func (c *StatsCommand) showDailyStats(manager *cost.Manager, t *i18n.Translations) error { + total, err := manager.GetDailyTotal() + if err != nil { + return err + } + records, err := manager.GetHistory() + if err != nil { + return err + } + today := time.Now().Format("2006-01-02") + var todayRecords []cost.ActivityRecord + for _, r := range records { + if r.Timestamp.Format("2006-01-02") == today { + todayRecords = append(todayRecords, r) + } + } + cyan := color.New(color.FgCyan, color.Bold) + green := color.New(color.FgGreen) + yellow := color.New(color.FgYellow) + _, _ = cyan.Printf("\n📊 %s\n", t.GetMessage("stats.daily_title", 0, nil)) + fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + if len(todayRecords) == 0 { + fmt.Printf("\n%s\n\n", t.GetMessage("stats.no_activity", 0, nil)) + return nil + } + for _, record := range todayRecords { + cacheIndicator := "" + if record.CacheHit { + cacheIndicator = green.Sprint(" [CACHE]") + } + fmt.Printf("%s - %s: %s%s\n", + record.Timestamp.Format("15:04"), + record.Command, + yellow.Sprintf("$%.4f", record.CostUSD), + cacheIndicator, + ) + } + fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + _, _ = cyan.Printf("%s: ", t.GetMessage("stats.total_today", 0, nil)) + _, _ = yellow.Printf("$%.4f USD\n\n", total) + return nil +} + +func (c *StatsCommand) showMonthlyStats(manager *cost.Manager, t *i18n.Translations) error { + total, err := manager.GetMonthlyTotal() + if err != nil { + return err + } + records, err := manager.GetHistory() + if err != nil { + return err + } + currentMonth := time.Now().Format("2006-01") + var monthRecords []cost.ActivityRecord + for _, r := range records { + if r.Timestamp.Format("2006-01") == currentMonth { + monthRecords = append(monthRecords, r) + } + } + cyan := color.New(color.FgCyan, color.Bold) + yellow := color.New(color.FgYellow) + _, _ = cyan.Printf("\n📅 %s\n", t.GetMessage("stats.monthly_title", 0, map[string]interface{}{ + "Month": time.Now().Format("January 2006"), + })) + fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + if len(monthRecords) == 0 { + fmt.Printf("\n%s\n\n", t.GetMessage("stats.no_activity", 0, nil)) + return nil + } + dailyTotals := make(map[string]float64) + for _, record := range monthRecords { + day := record.Timestamp.Format("2006-01-02") + dailyTotals[day] += record.CostUSD + } + for day, dayTotal := range dailyTotals { + fmt.Printf("%s: %s\n", day, yellow.Sprintf("$%.4f", dayTotal)) + } + fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + _, _ = cyan.Printf("%s: ", t.GetMessage("stats.total_month", 0, nil)) + _, _ = yellow.Printf("$%.4f USD\n\n", total) + return nil +} diff --git a/internal/config/ai.go b/internal/config/ai.go index bd88d4a..fccefff 100644 --- a/internal/config/ai.go +++ b/internal/config/ai.go @@ -10,13 +10,14 @@ const ( type Model string const ( - ModelGeminiV25Pro Model = "gemini-2.5-pro" - ModelGeminiV25Flash Model = "gemini-2.5-flash" - ModelGeminiV25FlashLite Model = "gemini-2.5-flash-lite" + ModelGeminiV15Pro Model = "gemini-1.5-pro" + ModelGeminiV15Flash Model = "gemini-1.5-flash" + ModelGeminiV25Flash Model = "gemini-2.5-flash" + ModelGeminiV3Pro Model = "gemini-3-pro-preview" + ModelGeminiV3Flash Model = "gemini-3-flash-preview" // TODO: Agregar mas modelos para openai o otros... - ModelGPTV4o Model = "gpt-4o" - ModelGPTV4oMini Model = "gpt-4o-mini" + ModelGPTV4o Model = "gpt-4o" ) func SupportedAIs() []AI { @@ -29,9 +30,11 @@ func ModelsForAI(ai AI) []Model { switch ai { case AIGemini: return []Model{ - ModelGeminiV25Pro, + ModelGeminiV15Flash, ModelGeminiV25Flash, - ModelGeminiV25FlashLite, + ModelGeminiV3Flash, + ModelGeminiV15Pro, + ModelGeminiV3Pro, } default: return []Model{} diff --git a/internal/config/config.go b/internal/config/config.go index b2a6c7d..b7937ee 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,7 +29,6 @@ type ( VersionPattern string `json:"version_pattern,omitempty"` } - // AIProviderConfig contiene la configuración específica de cada proveedor de IA AIProviderConfig struct { APIKey string `json:"api_key"` Model string `json:"model,omitempty"` @@ -37,18 +36,18 @@ type ( MaxTokens int `json:"max_tokens,omitempty"` } - // TicketProviderConfig contiene la configuración específica de cada proveedor de tickets TicketProviderConfig struct { APIKey string `json:"api_key"` BaseURL string `json:"base_url,omitempty"` Email string `json:"email,omitempty"` Username string `json:"username,omitempty"` - Extra map[string]string `json:"extra,omitempty"` // Para configuraciones específicas del proveedor + Extra map[string]string `json:"extra,omitempty"` } AIConfig struct { - ActiveAI AI `json:"active_ai"` - Models map[AI]Model `json:"models"` + ActiveAI AI `json:"active_ai"` + Models map[AI]Model `json:"models"` + BudgetDaily *float64 `json:"budget_daily,omitempty"` } VCSConfig struct { diff --git a/internal/domain/models/commit.go b/internal/domain/models/commit.go index 41ca2d8..52ba7e7 100644 --- a/internal/domain/models/commit.go +++ b/internal/domain/models/commit.go @@ -28,7 +28,7 @@ type ( Files []string CodeAnalysis CodeAnalysis RequirementsAnalysis RequirementsAnalysis - Usage *UsageMetadata + Usage *TokenUsage } CodeAnalysis struct { diff --git a/internal/domain/models/issue_generation.go b/internal/domain/models/issue_generation.go index 0a17327..1cbf280 100644 --- a/internal/domain/models/issue_generation.go +++ b/internal/domain/models/issue_generation.go @@ -34,7 +34,7 @@ type IssueGenerationResult struct { Assignees []string // Usage contiene los metadatos de uso de tokens de la IA - Usage *UsageMetadata + Usage *TokenUsage } // DiffAnalysis contiene el análisis estructurado del diff para inferencia de labels. diff --git a/internal/domain/models/pr.go b/internal/domain/models/pr.go index ab2a377..4b06289 100644 --- a/internal/domain/models/pr.go +++ b/internal/domain/models/pr.go @@ -23,6 +23,6 @@ type ( Title string Body string Labels []string - Usage *UsageMetadata + Usage *TokenUsage } ) diff --git a/internal/domain/models/release.go b/internal/domain/models/release.go index 2da430b..94ee673 100644 --- a/internal/domain/models/release.go +++ b/internal/domain/models/release.go @@ -88,7 +88,7 @@ type ( BreakingChanges []string Comparisons []Comparison Links map[string]string - Usage *UsageMetadata + Usage *TokenUsage } // CodeExample representa un ejemplo de código con descripción diff --git a/internal/domain/models/usage.go b/internal/domain/models/usage.go index c11f638..34cd719 100644 --- a/internal/domain/models/usage.go +++ b/internal/domain/models/usage.go @@ -1,7 +1,11 @@ package models -type UsageMetadata struct { - InputTokens int - OutputTokens int - TotalTokens int +type TokenUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` + CostUSD float64 `json:"cost_usd,omitempty"` + Model string `json:"model,omitempty"` + CacheHit bool `json:"cache_hit,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` } diff --git a/internal/domain/ports/ai_provider.go b/internal/domain/ports/ai_provider.go new file mode 100644 index 0000000..e39cf19 --- /dev/null +++ b/internal/domain/ports/ai_provider.go @@ -0,0 +1,23 @@ +package ports + +import ( + "context" +) + +// CostAwareAIProvider define la interfaz para proveedores de IA que soportan tracking de costos. +type CostAwareAIProvider interface { + // CountTokens cuenta los tokens de un prompt sin hacer la llamada real al modelo. + // Esto permite estimar el costo antes de ejecutar la generación. + CountTokens(ctx context.Context, prompt string) (int, error) + + // GetModelName retorna el nombre del modelo actual (ej: "gemini-2.5-flash") + GetModelName() string + + // GetProviderName retorna el nombre del proveedor (ej: "gemini", "openai", "anthropic") + GetProviderName() string +} + +// TokenCounter es una interfaz más simple para proveedores que solo necesitan contar tokens. +type TokenCounter interface { + CountTokens(ctx context.Context, content string) (int, error) +} diff --git a/internal/i18n/locales/active.en.toml b/internal/i18n/locales/active.en.toml index 9ecfd47..126d01a 100644 --- a/internal/i18n/locales/active.en.toml +++ b/internal/i18n/locales/active.en.toml @@ -524,6 +524,12 @@ detected_issue = "Detected issue #{{.Number}}" fetching_pr_info = "Fetching PR #{{.Number}} information..." error_generating_pr_summary = "Error generating PR summary" pr_updated_successfully = "PR #{{.Number}} updated: {{.Title}}" +token_usage = "Token Usage" +input = "Input" +output = "Output" +total = "Total" +cost = "Cost" +duration = "Duration" # UI - Errors with suggestions [ui_error] @@ -923,3 +929,64 @@ custom_description_help = "Describe your issue" custom_description_placeholder = "Please provide details..." custom_additional_label = "Additional information" custom_additional_help = "Any other relevant details" + +[stats] +usage = "Show cost and usage statistics" +monthly_flag = "Show monthly statistics instead of daily" +daily_title = "Daily Statistics" +monthly_title = "Monthly Statistics - {{.Month}}" +no_activity = "No activity recorded" +total_today = "Total Today" +total_month = "Total This Month" +error_init = "Error initializing cost manager" + +[cache] +usage = "Manage local response cache" +clean_usage = "Clean all cached responses" +error_init = "Error initializing cache" +error_clean = "Error cleaning cache" +cleaned = "Cache cleaned successfully" + +[cost] +estimating = "Estimating cost..." +estimated_cost = "Estimated cost: ${{.Cost}} USD" +tokens_detected = "Tokens detected: {{.Tokens}}" +model_selected = "Model selected: {{.Model}}" +confirm_prompt = "Continue?" +budget_warning = "⚠️ Warning: This exceeds your daily budget limit" +budget_exceeded = "Budget exceeded. Operation cancelled" +cache_hit = "✓ Response found in cache (Cost: $0.00)" + +# Confirmation Dialog +confirmation_header = "💰 Cost Estimation" +confirmation_separator = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +confirmation_input_tokens = "📊 Estimated input tokens: {{.Tokens}}" +confirmation_output_tokens = "📤 Estimated output tokens: {{.Tokens}}" +confirmation_estimated_cost = "💵 Estimated cost: {{.Cost}} USD" +confirmation_prompt = "Do you want to continue? [Y/n]:" +confirmation_use_suggested = "Use suggested model and continue? [Y/s/c]:" +confirmation_use_suggested_help = "(Y: yes, use suggested | s: stay with current | c: cancel)" + +# Budget Alerts +budget_alert_50 = "⚠️ You have used {{.Percent}}% of your daily budget (${{.Spent}} / ${{.Limit}})" +budget_alert_75_title = "⚠️ Warning! You have used {{.Percent}}% of your daily budget" +budget_alert_75_spent = " Total spent: ${{.Spent}} / ${{.Limit}}" +budget_alert_90_title = "🚨 ALERT! You have used {{.Percent}}% of your daily budget" +budget_alert_90_spent = " Total spent: ${{.Spent}} / ${{.Limit}}" +budget_alert_90_remaining = " Only remaining: ${{.Remaining}}" +budget_exceeded_title = "❌ Daily budget exceeded" +budget_exceeded_spent_today = " Spent today: ${{.Spent}}" +budget_exceeded_estimated = " Estimated cost: ${{.Cost}}" +budget_exceeded_total = " Total would be: ${{.Total}}" +budget_exceeded_limit = " Daily limit: ${{.Limit}}" +budget_exceeded_excess = " Excess: ${{.Excess}}" + +# Smart Routing +routing_suggestion = "💡 Suggestion: {{.Rationale}}" +routing_suggested_model = " Suggested model: {{.Suggested}} (currently using: {{.Current}})" +[routing] +reason_small = "Small operation (< 1k tokens), sufficient economical model" +reason_high_quality = "High quality operation, requires better writing" +reason_large = "Large operation (> 10k tokens), requires better context handling" +reason_balance = "Optimal balance between cost and quality" +reason_default = "Default model" diff --git a/internal/i18n/locales/active.es.toml b/internal/i18n/locales/active.es.toml index 2135a4b..c2ddb30 100644 --- a/internal/i18n/locales/active.es.toml +++ b/internal/i18n/locales/active.es.toml @@ -540,6 +540,7 @@ other = "⚠️ Breaking changes detectados en {{.Count}} commits" [pr_test_plan_generated] other = "📋 Test plan generado automáticamente" # UI - Spinners y Feedback + [ui] generating_suggestions_banner = "Generando Sugerencias de Commit" generating_with_ai = "Generando sugerencias con IA..." @@ -555,6 +556,12 @@ detected_issue = "Detectado issue #{{.Number}}" fetching_pr_info = "Obteniendo información del PR #{{.Number}}..." error_generating_pr_summary = "Error al generar resumen del PR" pr_updated_successfully = "PR #{{.Number}} actualizado: {{.Title}}" +token_usage = "Uso de Tokens" +input = "Entrada" +output = "Salida" +total = "Total" +cost = "Costo" +duration = "Duración" # UI - Errors con sugerencias [ui_error] @@ -952,4 +959,65 @@ custom_description_label = "Descripción" custom_description_help = "Contame de qué se trata" custom_description_placeholder = "Quisiera proponer/reportar/discutir..." custom_additional_label = "Información adicional" -custom_additional_help = "Cualquier detalle extra que sea relevante" \ No newline at end of file +custom_additional_help = "Cualquier detalle extra que sea relevante" + +[stats] +usage = "Mostrar estadísticas de costo y uso" +monthly_flag = "Mostrar estadísticas mensuales en lugar de diarias" +daily_title = "Estadísticas Diarias" +monthly_title = "Estadísticas Mensuales - {{.Month}}" +no_activity = "No hay actividad registrada" +total_today = "Total Hoy" +total_month = "Total Este Mes" +error_init = "Error inicializando gestor de costos" + +[cache] +usage = "Gestionar caché local de respuestas" +clean_usage = "Limpiar todas las respuestas cacheadas" +error_init = "Error inicializando caché" +error_clean = "Error limpiando caché" +cleaned = "Caché limpiado exitosamente" + +[cost] +estimating = "Estimando costo..." +estimated_cost = "Costo estimado: ${{.Cost}} USD" +tokens_detected = "Tokens detectados: {{.Tokens}}" +model_selected = "Modelo seleccionado: {{.Model}}" +confirm_prompt = "¿Continuar?" +budget_warning = "⚠️ Advertencia: Esto excede tu límite de presupuesto diario" +budget_exceeded = "Presupuesto excedido. Operación cancelada" +cache_hit = "✓ Respuesta encontrada en caché (Costo: $0.00)" + +# Diálogo de Confirmación +confirmation_header = "💰 Estimación de Costo" +confirmation_separator = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +confirmation_input_tokens = "📊 Tokens de entrada estimados: {{.Tokens}}" +confirmation_output_tokens = "📤 Tokens de salida estimados: {{.Tokens}}" +confirmation_estimated_cost = "💵 Costo estimado: {{.Cost}} USD" +confirmation_prompt = "¿Desea continuar? [Y/n]:" +confirmation_use_suggested = "¿Usar el modelo sugerido y continuar? [Y/m/c]:" +confirmation_use_suggested_help = "(Y: sí, usar sugerido | m: mantener actual | c: cancelar)" + +# Alertas de Presupuesto +budget_alert_50 = "⚠️ Has usado {{.Percent}}% de tu presupuesto diario (${{.Spent}} / ${{.Limit}})" +budget_alert_75_title = "⚠️ ¡Cuidado! Has usado {{.Percent}}% de tu presupuesto diario" +budget_alert_75_spent = " Total gastado: ${{.Spent}} / ${{.Limit}}" +budget_alert_90_title = "🚨 ¡ALERTA! Has usado {{.Percent}}% de tu presupuesto diario" +budget_alert_90_spent = " Total gastado: ${{.Spent}} / ${{.Limit}}" +budget_alert_90_remaining = " Quedan solo: ${{.Remaining}}" +budget_exceeded_title = "❌ Presupuesto diario excedido" +budget_exceeded_spent_today = " Gastado hoy: ${{.Spent}}" +budget_exceeded_estimated = " Costo estimado: ${{.Cost}}" +budget_exceeded_total = " Total sería: ${{.Total}}" +budget_exceeded_limit = " Límite diario: ${{.Limit}}" +budget_exceeded_excess = " Exceso: ${{.Excess}}" + +# Smart Routing +routing_suggestion = "💡 Sugerencia: {{.Rationale}}" +routing_suggested_model = " Modelo sugerido: {{.Suggested}} (actualmente usando: {{.Current}})" +[routing] +reason_small = "Operación pequeña (< 1k tokens), modelo económico suficiente" +reason_high_quality = "Operación de alta calidad, requiere mejor redacción" +reason_large = "Operación grande (> 10k tokens), requiere mejor manejo de contexto" +reason_balance = "Balance óptimo entre costo y calidad" +reason_default = "Modelo por defecto" diff --git a/internal/infrastructure/ai/ARCHITECTURE.md b/internal/infrastructure/ai/ARCHITECTURE.md new file mode 100644 index 0000000..47ac60a --- /dev/null +++ b/internal/infrastructure/ai/ARCHITECTURE.md @@ -0,0 +1,229 @@ +# Arquitectura de Proveedores de IA + +Este documento explica la arquitectura de proveedores de IA en MateCommit y cómo agregar nuevos proveedores. + +## Diseño Actual + +### Componentes Principales + +1. **`ports.CostAwareAIProvider`** (interfaz): Define el contrato que todos los proveedores deben cumplir +2. **`ai.CostAwareWrapper`**: Wrapper agnóstico que maneja caché, presupuesto y tracking de costos +3. **Provider específico** (ej: `gemini.GeminiProvider`): Implementación base de la interfaz para cada proveedor +4. **Servicios específicos** (ej: `gemini.GeminiCommitSummarizer`): Lógica de negocio que usa el provider + +### Flujo de Datos + +``` +Usuario → Servicio (CommitSummarizer) → CostAwareWrapper → Provider (Gemini) → API Externa + ↓ + Caché + Presupuesto + Tracking +``` + +## Patrón de Implementación + +### 1. Proveedor Actual: Gemini + +```go +// internal/infrastructure/ai/gemini/base.go +type GeminiProvider struct { + Client *genai.Client + model string +} + +func (g *GeminiProvider) CountTokens(ctx context.Context, prompt string) (int, error) { + // Implementación específica de Gemini +} + +func (g *GeminiProvider) GetModelName() string { return g.model } +func (g *GeminiProvider) GetProviderName() string { return "gemini" } +``` + +### 2. Servicio que usa el Provider + +```go +type GeminiCommitSummarizer struct { + *GeminiProvider // Embedding: hereda los métodos de la interfaz + wrapper *ai.CostAwareWrapper + config *config.Config + trans *i18n.Translations +} + +func NewGeminiCommitSummarizer(ctx context.Context, cfg *config.Config, trans *i18n.Translations) (*GeminiCommitSummarizer, error) { + client, _ := genai.NewClient(...) + + // Crear servicio con provider embedado + service := &GeminiCommitSummarizer{ + GeminiProvider: NewGeminiProvider(client, modelName), + config: cfg, + trans: trans, + } + + // Crear wrapper pasando el servicio como provider + wrapper, _ := ai.NewCostAwareWrapper(ai.WrapperConfig{ + Provider: service, // Implementa CostAwareAIProvider vía embedding + BudgetDaily: budgetDaily, + Trans: trans, + EstimatedOutputTokens: 800, + }) + + service.wrapper = wrapper + return service, nil +} +``` + +## Cómo Agregar un Nuevo Proveedor + +### Ejemplo: OpenAI + +#### Paso 1: Crear el Provider Base + +```go +// internal/infrastructure/ai/openai/base.go +package openai + +import ( + "context" + "github.com/Tomas-vilte/MateCommit/internal/domain/ports" + "github.com/openai/openai-go" +) + +type OpenAIProvider struct { + Client *openai.Client + model string +} + +func NewOpenAIProvider(client *openai.Client, model string) *OpenAIProvider { + return &OpenAIProvider{ + Client: client, + model: model, + } +} + +// Implementar la interfaz ports.CostAwareAIProvider + +func (o *OpenAIProvider) CountTokens(ctx context.Context, prompt string) (int, error) { + // Usar tiktoken o la API de OpenAI para contar tokens + // Implementación específica de OpenAI +} + +func (o *OpenAIProvider) GetModelName() string { + return o.model +} + +func (o *OpenAIProvider) GetProviderName() string { + return "openai" +} + +// Verificar que implementa la interfaz +var _ ports.CostAwareAIProvider = (*OpenAIProvider)(nil) +``` + +#### Paso 2: Crear un Servicio + +```go +// internal/infrastructure/ai/openai/commit_summarizer_service.go +package openai + +import ( + "github.com/Tomas-vilte/MateCommit/internal/infrastructure/ai" + // ... otros imports +) + +type OpenAICommitSummarizer struct { + *OpenAIProvider // Embedding del provider base + wrapper *ai.CostAwareWrapper + config *config.Config + trans *i18n.Translations +} + +func NewOpenAICommitSummarizer(ctx context.Context, cfg *config.Config, trans *i18n.Translations) (*OpenAICommitSummarizer, error) { + client := openai.NewClient(cfg.AIProviders["openai"].APIKey) + modelName := string(cfg.AIConfig.Models[config.AIOpenAI]) + + service := &OpenAICommitSummarizer{ + OpenAIProvider: NewOpenAIProvider(client, modelName), + config: cfg, + trans: trans, + } + + wrapper, err := ai.NewCostAwareWrapper(ai.WrapperConfig{ + Provider: service, + BudgetDaily: cfg.AIConfig.BudgetDaily, + Trans: trans, + EstimatedOutputTokens: 800, + }) + if err != nil { + return nil, err + } + + service.wrapper = wrapper + return service, nil +} + +func (s *OpenAICommitSummarizer) GenerateSuggestions(ctx context.Context, info models.CommitInfo, count int) ([]models.CommitSuggestion, error) { + prompt := s.generatePrompt(info, count) + + // Función de generación específica de OpenAI + generateFn := func(ctx context.Context, p string) (interface{}, *models.TokenUsage, error) { + resp, err := s.Client.Chat.Completions.Create(ctx, openai.ChatCompletionCreateParams{ + Model: s.GetModelName(), + Messages: []openai.ChatCompletionMessageParam{ + {Role: "user", Content: p}, + }, + }) + + usage := &models.TokenUsage{ + InputTokens: resp.Usage.PromptTokens, + OutputTokens: resp.Usage.CompletionTokens, + TotalTokens: resp.Usage.TotalTokens, + } + + return resp, usage, nil + } + + // El wrapper maneja caché, presupuesto y tracking + resp, usage, err := s.wrapper.WrapGenerate(ctx, "suggest-commits", prompt, generateFn) + if err != nil { + return nil, err + } + + // Parsear respuesta de OpenAI... +} +``` + +#### Paso 3: Actualizar la Tabla de Precios + +```go +// internal/domain/services/cost/calculator.go +var pricing = ProviderPricing{ + "gemini": { + "gemini-2.5-flash": {InputPricePerMillion: 0.30, OutputPricePerMillion: 2.50}, + "gemini-3.0-flash": {InputPricePerMillion: 0.50, OutputPricePerMillion: 3.00}, + }, + "openai": { // NUEVO + "gpt-4o": {InputPricePerMillion: 2.50, OutputPricePerMillion: 10.00}, + "gpt-4o-mini": {InputPricePerMillion: 0.15, OutputPricePerMillion: 0.60}, + }, +} +``` + +## Ventajas de Esta Arquitectura + +✅ **Extensibilidad**: Agregar un proveedor nuevo es agregar un package con base.go +✅ **DRY**: La lógica de caché, presupuesto y tracking está en un solo lugar (CostAwareWrapper) +✅ **Type-safe**: Las interfaces garantizan que todos los providers tengan los métodos necesarios +✅ **Testeable**: Fácil mockear la interfaz `CostAwareAIProvider` para tests +✅ **Idiomático**: Usa composición (embedding) en lugar de herencia + +## Trade-offs Aceptados + +⚠️ **Construcción en dos pasos**: El servicio se crea, luego el wrapper, luego se asigna al servicio +⚠️ **Type assertions**: `WrapGenerate` retorna `interface{}`, requiere cast al tipo específico +⚠️ **Sin genéricos**: Se priorizó simplicidad sobre type-safety completa + +## Cuándo Usar Qué + +- **Nuevo proveedor completo** (OpenAI, Anthropic): Crear `internal/infrastructure/ai/[provider]/base.go` +- **Nuevo servicio en proveedor existente**: Embedar el provider base existente +- **Modificar precios**: Actualizar `internal/domain/services/cost/calculator.go` +- **Cambiar lógica de caché/presupuesto**: Modificar `internal/infrastructure/ai/cost_wrapper.go` diff --git a/internal/infrastructure/ai/cost_wrapper.go b/internal/infrastructure/ai/cost_wrapper.go new file mode 100644 index 0000000..cea36e0 --- /dev/null +++ b/internal/infrastructure/ai/cost_wrapper.go @@ -0,0 +1,227 @@ +package ai + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/Tomas-vilte/MateCommit/internal/domain/models" + "github.com/Tomas-vilte/MateCommit/internal/domain/ports" + "github.com/Tomas-vilte/MateCommit/internal/i18n" + "github.com/Tomas-vilte/MateCommit/internal/infrastructure/cache" + cost2 "github.com/Tomas-vilte/MateCommit/internal/services/cost" + "github.com/Tomas-vilte/MateCommit/internal/services/routing" + "github.com/fatih/color" +) + +type GenerateFunc func(ctx context.Context, modelName string, prompt string) (interface{}, *models.TokenUsage, error) + +type CostAwareWrapper struct { + provider ports.CostAwareAIProvider + calculator *cost2.Calculator + manager *cost2.Manager + cache *cache.Cache + modelSelector *routing.ModelSelector + trans *i18n.Translations + estimatedOutputTokens int + skipConfirmation bool +} + +type WrapperConfig struct { + Provider ports.CostAwareAIProvider + BudgetDaily float64 + Trans *i18n.Translations + EstimatedOutputTokens int + SkipConfirmation bool +} + +// NewCostAwareWrapper crea un wrapper agnóstico de proveedor +func NewCostAwareWrapper(cfg WrapperConfig) (*CostAwareWrapper, error) { + manager, err := cost2.NewManager(cfg.BudgetDaily, cfg.Trans) + if err != nil { + return nil, fmt.Errorf("error creando cost manager: %w", err) + } + + cacheService, err := cache.NewCache(24 * time.Hour) + if err != nil { + return nil, fmt.Errorf("error creando cache: %w", err) + } + + return &CostAwareWrapper{ + provider: cfg.Provider, + calculator: cost2.NewCalculator(), + manager: manager, + cache: cacheService, + modelSelector: routing.NewModelSelector(), + trans: cfg.Trans, + estimatedOutputTokens: cfg.EstimatedOutputTokens, + skipConfirmation: cfg.SkipConfirmation, + }, nil +} + +// WrapGenerate envuelve cualquier función de generación con tracking +func (w *CostAwareWrapper) WrapGenerate( + ctx context.Context, + command string, + prompt string, + generateFn GenerateFunc, +) (interface{}, *models.TokenUsage, error) { + startTime := time.Now() + + providerName := w.provider.GetProviderName() + originalModel := w.provider.GetModelName() + modelToUse := originalModel + + contentHash := w.cache.GenerateHash(providerName + originalModel + prompt) + + if cachedData, hit, err := w.cache.Get(contentHash); err == nil && hit { + var cachedResp interface{} + if err := json.Unmarshal(cachedData, &cachedResp); err == nil { + usage := &models.TokenUsage{ + CacheHit: true, + CostUSD: 0, + DurationMs: time.Since(startTime).Milliseconds(), + Model: originalModel, + } + return cachedResp, usage, nil + } + } + + var inputTokens int + tokens, err := w.provider.CountTokens(ctx, prompt) + if err == nil { + inputTokens = tokens + } + + suggestedModel := w.modelSelector.SelectBestModel(command, inputTokens) + hasSuggestion := suggestedModel != originalModel + + estimatedCost := w.calculator.EstimateCost(providerName, originalModel, inputTokens, w.estimatedOutputTokens) + + if hasSuggestion && !w.skipConfirmation { + rationaleKey := w.modelSelector.GetRationale(command, suggestedModel) + rationale := w.trans.GetMessage(rationaleKey, 0, nil) + yellow := color.New(color.FgYellow) + _, _ = yellow.Println(w.trans.GetMessage("cost.routing_suggestion", 0, map[string]interface{}{ + "Rationale": rationale, + })) + _, _ = yellow.Println(w.trans.GetMessage("cost.routing_suggested_model", 0, map[string]interface{}{ + "Suggested": suggestedModel, + "Current": originalModel, + })) + + estimatedCost = w.calculator.EstimateCost(providerName, suggestedModel, inputTokens, w.estimatedOutputTokens) + } + + if err := w.manager.CheckBudget(estimatedCost); err != nil { + return nil, nil, fmt.Errorf("%s: %w", w.trans.GetMessage("cost.budget_exceeded", 0, nil), err) + } + + if (estimatedCost > 0.005 || hasSuggestion) && !w.skipConfirmation { + choice, proceed := w.askUserConfirmation(estimatedCost, inputTokens, w.estimatedOutputTokens, suggestedModel) + if !proceed { + return nil, nil, fmt.Errorf("operación cancelada por el usuario") + } + if choice == "suggested" { + modelToUse = suggestedModel + } + } + + resp, usage, err := generateFn(ctx, modelToUse, prompt) + if err != nil { + return nil, nil, err + } + + _ = w.cache.Set(contentHash, resp) + + if usage != nil { + usage.Model = modelToUse + usage.CostUSD = w.calculator.EstimateCost(providerName, modelToUse, usage.InputTokens, usage.OutputTokens) + usage.DurationMs = time.Since(startTime).Milliseconds() + usage.CacheHit = false + + _ = w.manager.SaveActivity(cost2.ActivityRecord{ + Timestamp: time.Now(), + Command: command, + Provider: providerName, + Model: modelToUse, + TokensInput: usage.InputTokens, + TokensOutput: usage.OutputTokens, + CostUSD: usage.CostUSD, + DurationMs: usage.DurationMs, + CacheHit: false, + Hash: contentHash, + }) + } + + return resp, usage, nil +} + +// askUserConfirmation pregunta al usuario si desea continuar y permite cambiar al modelo sugerido +func (w *CostAwareWrapper) askUserConfirmation(estimatedCost float64, inputTokens, outputTokens int, suggestedModel string) (string, bool) { + cyan := color.New(color.FgCyan, color.Bold) + yellow := color.New(color.FgYellow) + originalModel := w.provider.GetModelName() + hasSuggestion := suggestedModel != "" && suggestedModel != originalModel + + fmt.Println() + _, _ = cyan.Println(w.trans.GetMessage("cost.confirmation_separator", 0, nil)) + _, _ = cyan.Println(w.trans.GetMessage("cost.confirmation_header", 0, nil)) + _, _ = cyan.Println(w.trans.GetMessage("cost.confirmation_separator", 0, nil)) + + fmt.Println(w.trans.GetMessage("cost.confirmation_input_tokens", 0, map[string]interface{}{ + "Tokens": yellow.Sprintf("%d", inputTokens), + })) + fmt.Println(w.trans.GetMessage("cost.confirmation_output_tokens", 0, map[string]interface{}{ + "Tokens": yellow.Sprintf("%d", outputTokens), + })) + + costLabel := w.trans.GetMessage("cost.confirmation_estimated_cost", 0, map[string]interface{}{ + "Cost": yellow.Sprintf("$%.4f", estimatedCost), + }) + if hasSuggestion { + fmt.Printf("%s (%s)\n", costLabel, suggestedModel) + } else { + fmt.Println(costLabel) + } + + _, _ = cyan.Println(w.trans.GetMessage("cost.confirmation_separator", 0, nil)) + + if hasSuggestion { + fmt.Printf("%s ", w.trans.GetMessage("cost.confirmation_use_suggested", 0, nil)) + fmt.Printf("%s\n", color.HiBlackString(w.trans.GetMessage("cost.confirmation_use_suggested_help", 0, nil))) + } else { + fmt.Printf("%s ", w.trans.GetMessage("cost.confirmation_prompt", 0, nil)) + } + + reader := bufio.NewReader(os.Stdin) + response, err := reader.ReadString('\n') + if err != nil { + return "", false + } + + response = strings.TrimSpace(strings.ToLower(response)) + + if !hasSuggestion { + proceed := response == "" || response == "y" || response == "yes" || response == "si" || response == "s" + if proceed { + return "original", true + } + return "", false + } + + switch response { + case "", "y", "yes", "si", "s": + return "suggested", true + case "m", "stay": + return "original", true + case "c", "cancel", "n", "no": + return "", false + default: + return "", false + } +} diff --git a/internal/infrastructure/ai/gemini/base.go b/internal/infrastructure/ai/gemini/base.go new file mode 100644 index 0000000..30844f9 --- /dev/null +++ b/internal/infrastructure/ai/gemini/base.go @@ -0,0 +1,43 @@ +package gemini + +import ( + "context" + + "github.com/Tomas-vilte/MateCommit/internal/domain/ports" + "google.golang.org/genai" +) + +var _ ports.CostAwareAIProvider = (*GeminiProvider)(nil) + +// GeminiProvider es una base compartida para todos los servicios de gemini que implementa la interfaz ports.CostAwareAIProvider +type GeminiProvider struct { + Client *genai.Client + model string +} + +// NewGeminiProvider crea una nueva instancia de GeminiProvider +func NewGeminiProvider(client *genai.Client, model string) *GeminiProvider { + return &GeminiProvider{ + Client: client, + model: model, + } +} + +// CountTokens implementa ports.CostAwareAIProvider +func (g *GeminiProvider) CountTokens(ctx context.Context, prompt string) (int, error) { + resp, err := g.Client.Models.CountTokens(ctx, g.model, genai.Text(prompt), nil) + if err != nil { + return 0, err + } + return int(resp.TotalTokens), nil +} + +// GetModelName implementa ports.CostAwareAIProvider +func (g *GeminiProvider) GetModelName() string { + return g.model +} + +// GetProviderName implementa ports.CostAwareAIProvider +func (g *GeminiProvider) GetProviderName() string { + return "gemini" +} diff --git a/internal/infrastructure/ai/gemini/commit_summarizer_service.go b/internal/infrastructure/ai/gemini/commit_summarizer_service.go index 81693a7..8d9c45c 100644 --- a/internal/infrastructure/ai/gemini/commit_summarizer_service.go +++ b/internal/infrastructure/ai/gemini/commit_summarizer_service.go @@ -9,15 +9,19 @@ import ( "github.com/Tomas-vilte/MateCommit/internal/config" "github.com/Tomas-vilte/MateCommit/internal/domain/models" + "github.com/Tomas-vilte/MateCommit/internal/domain/ports" "github.com/Tomas-vilte/MateCommit/internal/i18n" "github.com/Tomas-vilte/MateCommit/internal/infrastructure/ai" "google.golang.org/genai" ) +var _ ports.CommitSummarizer = (*GeminiCommitSummarizer)(nil) + type GeminiCommitSummarizer struct { - client *genai.Client - config *config.Config - trans *i18n.Translations + *GeminiProvider + wrapper *ai.CostAwareWrapper + config *config.Config + trans *i18n.Translations } type ( @@ -60,11 +64,32 @@ func NewGeminiCommitSummarizer(ctx context.Context, cfg *config.Config, trans *i return nil, fmt.Errorf("%s", msg) } - return &GeminiCommitSummarizer{ - client: client, - config: cfg, - trans: trans, - }, nil + modelName := string(cfg.AIConfig.Models[config.AIGemini]) + + budgetDaily := 0.0 + if cfg.AIConfig.BudgetDaily != nil { + budgetDaily = *cfg.AIConfig.BudgetDaily + } + + service := &GeminiCommitSummarizer{ + GeminiProvider: NewGeminiProvider(client, modelName), + config: cfg, + trans: trans, + } + + wrapper, err := ai.NewCostAwareWrapper(ai.WrapperConfig{ + Provider: service, + BudgetDaily: budgetDaily, + Trans: trans, + EstimatedOutputTokens: 800, + }) + if err != nil { + return nil, fmt.Errorf("error creando wrapper: %w", err) + } + + service.wrapper = wrapper + + return service, nil } func (s *GeminiCommitSummarizer) GenerateSuggestions(ctx context.Context, info models.CommitInfo, count int) ([]models.CommitSuggestion, error) { @@ -79,16 +104,20 @@ func (s *GeminiCommitSummarizer) GenerateSuggestions(ctx context.Context, info m } prompt := s.generatePrompt(s.config.Language, info, count) - modelName := string(s.config.AIConfig.Models[config.AIGemini]) - genConfig := &genai.GenerateContentConfig{ - Temperature: float32Ptr(0.3), - MaxOutputTokens: int32(10000), - ResponseMIMEType: "application/json", - MediaResolution: genai.MediaResolutionHigh, + generateFn := func(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { + genConfig := GetGenerateConfig(mName, "application/json") + + resp, err := s.Client.Models.GenerateContent(ctx, mName, genai.Text(p), genConfig) + if err != nil { + return nil, nil, err + } + + usage := extractUsage(resp) + return resp, usage, nil } - resp, err := s.client.Models.GenerateContent(ctx, modelName, genai.Text(prompt), genConfig) + resp, usage, err := s.wrapper.WrapGenerate(ctx, "suggest-commits", prompt, generateFn) if err != nil { msg := s.trans.GetMessage("error_generating_content", 0, map[string]interface{}{ "Error": err.Error(), @@ -96,9 +125,10 @@ func (s *GeminiCommitSummarizer) GenerateSuggestions(ctx context.Context, info m return nil, fmt.Errorf("%s", msg) } - suggestions, err := s.parseSuggestionsJSON(resp) + geminiResp := resp.(*genai.GenerateContentResponse) + suggestions, err := s.parseSuggestionsJSON(geminiResp) if err != nil { - rawResp := formatResponse(resp) + rawResp := formatResponse(geminiResp) respLen := len(rawResp) preview := rawResp if respLen > 500 { @@ -107,21 +137,15 @@ func (s *GeminiCommitSummarizer) GenerateSuggestions(ctx context.Context, info m return nil, fmt.Errorf("error al parsear respuesta JSON de la IA (longitud: %d caracteres): %w\nPrimeros caracteres: %s", respLen, err, preview) } - - usage := extractUsage(resp) - if len(suggestions) == 0 { return nil, fmt.Errorf("la IA no generó ninguna sugerencia") } - for i := range suggestions { suggestions[i].Usage = usage } - if info.IssueInfo != nil && info.IssueInfo.Number > 0 { suggestions = s.ensureIssueReference(suggestions, info.IssueInfo.Number) } - return suggestions, nil } @@ -310,7 +334,3 @@ func (s *GeminiCommitSummarizer) ensureIssueReference(suggestions []models.Commi return suggestions } - -func float32Ptr(f float32) *float32 { - return &f -} diff --git a/internal/infrastructure/ai/gemini/helper.go b/internal/infrastructure/ai/gemini/helper.go index 65bf114..d32a44e 100644 --- a/internal/infrastructure/ai/gemini/helper.go +++ b/internal/infrastructure/ai/gemini/helper.go @@ -1,18 +1,46 @@ package gemini import ( + "strings" + "github.com/Tomas-vilte/MateCommit/internal/domain/models" "google.golang.org/genai" ) // extractUsage extrae los metadatos de uso de la respuesta de Gemini -func extractUsage(resp *genai.GenerateContentResponse) *models.UsageMetadata { - if resp.UsageMetadata == nil { +func extractUsage(resp *genai.GenerateContentResponse) *models.TokenUsage { + if resp == nil || resp.UsageMetadata == nil { return nil } - return &models.UsageMetadata{ + return &models.TokenUsage{ InputTokens: int(resp.UsageMetadata.PromptTokenCount), OutputTokens: int(resp.UsageMetadata.CandidatesTokenCount), TotalTokens: int(resp.UsageMetadata.TotalTokenCount), } } + +// GetGenerateConfig retorna la configuración óptima para el modelo, activando Thinking Mode si es compatible. +func GetGenerateConfig(modelName string, responseType string) *genai.GenerateContentConfig { + config := &genai.GenerateContentConfig{ + Temperature: float32Ptr(0.3), + MaxOutputTokens: int32(10000), + MediaResolution: genai.MediaResolutionHigh, + } + + if responseType == "application/json" { + config.ResponseMIMEType = "application/json" + } + + if strings.HasPrefix(modelName, "gemini-3") { + config.ThinkingConfig = &genai.ThinkingConfig{ + IncludeThoughts: true, + ThinkingLevel: genai.ThinkingLevelHigh, + } + } + + return config +} + +func float32Ptr(f float32) *float32 { + return &f +} diff --git a/internal/infrastructure/ai/gemini/issue_content_generator.go b/internal/infrastructure/ai/gemini/issue_content_generator.go index eb19f49..5c495f8 100644 --- a/internal/infrastructure/ai/gemini/issue_content_generator.go +++ b/internal/infrastructure/ai/gemini/issue_content_generator.go @@ -15,9 +15,10 @@ import ( ) type GeminiIssueContentGenerator struct { - client *genai.Client - config *config.Config - trans *i18n.Translations + *GeminiProvider + wrapper *ai.CostAwareWrapper + config *config.Config + trans *i18n.Translations } var _ ports.IssueContentGenerator = (*GeminiIssueContentGenerator)(nil) @@ -40,40 +41,67 @@ func NewGeminiIssueContentGenerator(ctx context.Context, cfg *config.Config, tra return nil, fmt.Errorf("%s", msg) } - return &GeminiIssueContentGenerator{ - client: client, - config: cfg, - trans: trans, - }, nil + modelName := string(cfg.AIConfig.Models[config.AIGemini]) + + budgetDaily := 0.0 + if cfg.AIConfig.BudgetDaily != nil { + budgetDaily = *cfg.AIConfig.BudgetDaily + } + + service := &GeminiIssueContentGenerator{ + GeminiProvider: NewGeminiProvider(client, modelName), + config: cfg, + trans: trans, + } + + wrapper, err := ai.NewCostAwareWrapper(ai.WrapperConfig{ + Provider: service, + BudgetDaily: budgetDaily, + Trans: trans, + EstimatedOutputTokens: 600, + }) + if err != nil { + return nil, fmt.Errorf("error creando wrapper: %w", err) + } + + service.wrapper = wrapper + + return service, nil } // GenerateIssueContent genera contenido de issue usando Gemini AI. func (s *GeminiIssueContentGenerator) GenerateIssueContent(ctx context.Context, request models.IssueGenerationRequest) (*models.IssueGenerationResult, error) { prompt := s.buildIssuePrompt(request) - modelName := string(s.config.AIConfig.Models[config.AIGemini]) - genConfig := &genai.GenerateContentConfig{ - Temperature: float32Ptr(0.3), - MaxOutputTokens: int32(10000), - ResponseMIMEType: "application/json", - MediaResolution: genai.MediaResolutionHigh, + generateFn := func(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { + genConfig := GetGenerateConfig(mName, "application/json") + + resp, err := s.Client.Models.GenerateContent(ctx, mName, genai.Text(p), genConfig) + if err != nil { + return nil, nil, err + } + + usage := extractUsage(resp) + return resp, usage, nil } - resp, err := s.client.Models.GenerateContent(ctx, modelName, genai.Text(prompt), genConfig) + resp, usage, err := s.wrapper.WrapGenerate(ctx, "generate-issue", prompt, generateFn) if err != nil { - return nil, fmt.Errorf("error al generar contenido de la issue: %w", err) + return nil, fmt.Errorf("error generando contenido de issue: %w", err) } - if len(resp.Candidates) == 0 { + geminiResp := resp.(*genai.GenerateContentResponse) + + if len(geminiResp.Candidates) == 0 { return nil, fmt.Errorf("ningún contenido generado por IA") } - result, err := s.parseIssueResponse(resp) + result, err := s.parseIssueResponse(geminiResp) if err != nil { return nil, fmt.Errorf("error al parsear la respuesta de la IA: %w", err) } - result.Usage = extractUsage(resp) + result.Usage = usage return result, nil } diff --git a/internal/infrastructure/ai/gemini/pull_requests_summarizer_service.go b/internal/infrastructure/ai/gemini/pull_requests_summarizer_service.go index 12fb08d..1e8fab3 100644 --- a/internal/infrastructure/ai/gemini/pull_requests_summarizer_service.go +++ b/internal/infrastructure/ai/gemini/pull_requests_summarizer_service.go @@ -17,9 +17,10 @@ import ( var _ ports.PRSummarizer = (*GeminiPRSummarizer)(nil) type GeminiPRSummarizer struct { - client *genai.Client - config *config.Config - trans *i18n.Translations + *GeminiProvider + wrapper *ai.CostAwareWrapper + config *config.Config + trans *i18n.Translations } type PRSummaryJSON struct { @@ -34,7 +35,6 @@ func NewGeminiPRSummarizer(ctx context.Context, cfg *config.Config, trans *i18n. msg := trans.GetMessage("error_missing_api_key", 0, map[string]interface{}{"Provider": "gemini"}) return nil, fmt.Errorf("%s", msg) } - client, err := genai.NewClient(ctx, &genai.ClientConfig{ APIKey: providerCfg.APIKey, Backend: genai.BackendGeminiAPI, @@ -45,47 +45,68 @@ func NewGeminiPRSummarizer(ctx context.Context, cfg *config.Config, trans *i18n. }) return nil, fmt.Errorf("%s", msg) } + modelName := string(cfg.AIConfig.Models[config.AIGemini]) - return &GeminiPRSummarizer{ - client: client, - config: cfg, - trans: trans, - }, nil + budgetDaily := 0.0 + if cfg.AIConfig.BudgetDaily != nil { + budgetDaily = *cfg.AIConfig.BudgetDaily + } + + service := &GeminiPRSummarizer{ + GeminiProvider: NewGeminiProvider(client, modelName), + config: cfg, + trans: trans, + } + + wrapper, err := ai.NewCostAwareWrapper(ai.WrapperConfig{ + Provider: service, + BudgetDaily: budgetDaily, + Trans: trans, + EstimatedOutputTokens: 500, + }) + if err != nil { + return nil, fmt.Errorf("error creando wrapper: %w", err) + } + + service.wrapper = wrapper + + return service, nil } func (gps *GeminiPRSummarizer) GeneratePRSummary(ctx context.Context, prContent string) (models.PRSummary, error) { - modelName := string(gps.config.AIConfig.Models[config.AIGemini]) - prompt := gps.generatePRPrompt(prContent) - genConfig := &genai.GenerateContentConfig{ - Temperature: float32Ptr(0.3), - MaxOutputTokens: int32(10000), - ResponseMIMEType: "application/json", - MediaResolution: genai.MediaResolutionHigh, + generateFn := func(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { + genConfig := GetGenerateConfig(mName, "application/json") + + resp, err := gps.Client.Models.GenerateContent(ctx, mName, genai.Text(p), genConfig) + if err != nil { + return nil, nil, err + } + + usage := extractUsage(resp) + return resp, usage, nil } - resp, err := gps.client.Models.GenerateContent(ctx, modelName, genai.Text(prompt), genConfig) + resp, usage, err := gps.wrapper.WrapGenerate(ctx, "summarize-pr", prompt, generateFn) if err != nil { return models.PRSummary{}, fmt.Errorf("error al generar resumen de PR: %w", err) } - responseText := formatResponse(resp) + geminiResp := resp.(*genai.GenerateContentResponse) + responseText := formatResponse(geminiResp) if responseText == "" { return models.PRSummary{}, fmt.Errorf("respuesta vacía de la IA") } - responseText = strings.TrimSpace(responseText) responseText = strings.TrimPrefix(responseText, "```json") responseText = strings.TrimPrefix(responseText, "```") responseText = strings.TrimSuffix(responseText, "```") responseText = strings.TrimSpace(responseText) - var jsonSummary PRSummaryJSON if err := json.Unmarshal([]byte(responseText), &jsonSummary); err != nil { return models.PRSummary{}, fmt.Errorf("error al parsear JSON de PR: %w", err) } - if strings.TrimSpace(jsonSummary.Title) == "" { respLen := len(responseText) preview := responseText @@ -94,9 +115,6 @@ func (gps *GeminiPRSummarizer) GeneratePRSummary(ctx context.Context, prContent } return models.PRSummary{}, fmt.Errorf("la IA no generó un título para el PR. Respuesta (longitud: %d): %s", respLen, preview) } - - usage := extractUsage(resp) - return models.PRSummary{ Title: jsonSummary.Title, Body: jsonSummary.Body, diff --git a/internal/infrastructure/ai/gemini/release_generator.go b/internal/infrastructure/ai/gemini/release_generator.go index 5cb8d87..00b3582 100644 --- a/internal/infrastructure/ai/gemini/release_generator.go +++ b/internal/infrastructure/ai/gemini/release_generator.go @@ -17,12 +17,12 @@ import ( var _ ports.ReleaseNotesGenerator = (*ReleaseNotesGenerator)(nil) type ReleaseNotesGenerator struct { - client *genai.Client - trans *i18n.Translations - model string - lang string - owner string - repo string + *GeminiProvider + wrapper *ai.CostAwareWrapper + trans *i18n.Translations + lang string + owner string + repo string } type ReleaseNotesJSON struct { @@ -54,45 +54,67 @@ func NewReleaseNotesGenerator(ctx context.Context, cfg *config.Config, trans *i1 } modelName := string(cfg.AIConfig.Models[config.AIGemini]) - return &ReleaseNotesGenerator{ - client: client, - model: modelName, - lang: cfg.Language, - trans: trans, - owner: owner, - repo: repo, - }, nil + + budgetDaily := 0.0 + if cfg.AIConfig.BudgetDaily != nil { + budgetDaily = *cfg.AIConfig.BudgetDaily + } + + service := &ReleaseNotesGenerator{ + GeminiProvider: NewGeminiProvider(client, modelName), + lang: cfg.Language, + trans: trans, + owner: owner, + repo: repo, + } + + wrapper, err := ai.NewCostAwareWrapper(ai.WrapperConfig{ + Provider: service, + BudgetDaily: budgetDaily, + Trans: trans, + EstimatedOutputTokens: 700, + }) + if err != nil { + return nil, fmt.Errorf("error creando wrapper: %w", err) + } + + service.wrapper = wrapper + + return service, nil } func (g *ReleaseNotesGenerator) GenerateNotes(ctx context.Context, release *models.Release) (*models.ReleaseNotes, error) { prompt := g.buildPrompt(release) - genConfig := &genai.GenerateContentConfig{ - Temperature: float32Ptr(0.3), - MaxOutputTokens: int32(10000), - ResponseMIMEType: "application/json", - MediaResolution: genai.MediaResolutionHigh, + generateFn := func(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { + genConfig := GetGenerateConfig(mName, "application/json") + + resp, err := g.Client.Models.GenerateContent(ctx, mName, genai.Text(p), genConfig) + if err != nil { + return nil, nil, err + } + + usage := extractUsage(resp) + return resp, usage, nil } - resp, err := g.client.Models.GenerateContent(ctx, g.model, genai.Text(prompt), genConfig) + resp, usage, err := g.wrapper.WrapGenerate(ctx, "generate-release", prompt, generateFn) if err != nil { - msg := g.trans.GetMessage("ai_service.error_generating_release_notes", 0, map[string]interface{}{ - "Error": err, - }) - return nil, fmt.Errorf("%s", msg) + return nil, fmt.Errorf("error generando release notes: %w", err) } - if len(resp.Candidates) == 0 { + geminiResp := resp.(*genai.GenerateContentResponse) + + if len(geminiResp.Candidates) == 0 { msg := g.trans.GetMessage("ai_service.error_no_ai_response", 0, nil) return nil, fmt.Errorf("%s", msg) } content := "" - for _, part := range resp.Candidates[0].Content.Parts { + for _, part := range geminiResp.Candidates[0].Content.Parts { content += part.Text } - usage := extractUsage(resp) notes, err := g.parseJSONResponse(content, release) if err != nil { return nil, fmt.Errorf("error al parsear respuesta JSON de release notes: %w", err) diff --git a/internal/infrastructure/cache/cache.go b/internal/infrastructure/cache/cache.go new file mode 100644 index 0000000..4ab2cf3 --- /dev/null +++ b/internal/infrastructure/cache/cache.go @@ -0,0 +1,131 @@ +package cache + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +type CachedResponse struct { + Hash string `json:"hash"` + Response json.RawMessage `json:"response"` + CreatedAt time.Time `json:"created_at"` +} + +type Cache struct { + cacheDir string + ttl time.Duration +} + +func NewCache(ttl time.Duration) (*Cache, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("error obteniendo home directory: %w", err) + } + + cacheDir := filepath.Join(homeDir, ".matecommit", "cache") + if err := os.MkdirAll(cacheDir, 0755); err != nil { + return nil, fmt.Errorf("error creando directorio de caché: %w", err) + } + + cache := &Cache{ + cacheDir: cacheDir, + ttl: ttl, + } + + _ = cache.CleanExpired() + + return cache, nil +} + +// GenerateHash genera un hash SHA256 del contenido +func (c *Cache) GenerateHash(content string) string { + hash := sha256.Sum256([]byte(content)) + return hex.EncodeToString(hash[:]) +} + +// Get obtiene una respuesta del caché +func (c *Cache) Get(hash string) (json.RawMessage, bool, error) { + filePath := filepath.Join(c.cacheDir, hash+".json") + + data, err := os.ReadFile(filePath) + if err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + return nil, false, fmt.Errorf("error leyendo caché: %w", err) + } + + var cached CachedResponse + if err := json.Unmarshal(data, &cached); err != nil { + return nil, false, fmt.Errorf("error deserializando caché: %w", err) + } + + if time.Since(cached.CreatedAt) > c.ttl { + _ = os.Remove(filePath) + return nil, false, nil + } + + return cached.Response, true, nil +} + +// Set guarda una respuesta en el caché +func (c *Cache) Set(hash string, response interface{}) error { + responseData, err := json.Marshal(response) + if err != nil { + return fmt.Errorf("error serializando respuesta: %w", err) + } + + cached := CachedResponse{ + Hash: hash, + Response: responseData, + CreatedAt: time.Now(), + } + + data, err := json.MarshalIndent(cached, "", " ") + if err != nil { + return fmt.Errorf("error serializando caché: %w", err) + } + + filePath := filepath.Join(c.cacheDir, hash+".json") + if err := os.WriteFile(filePath, data, 0644); err != nil { + return fmt.Errorf("error guardando caché: %w", err) + } + + return nil +} + +// CleanExpired elimina archivos de caché expirados +func (c *Cache) CleanExpired() error { + entries, err := os.ReadDir(c.cacheDir) + if err != nil { + return fmt.Errorf("error leyendo directorio de caché: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + filePath := filepath.Join(c.cacheDir, entry.Name()) + info, err := entry.Info() + if err != nil { + continue + } + + if time.Since(info.ModTime()) > c.ttl { + _ = os.Remove(filePath) + } + } + + return nil +} + +// Clean elimina todo el cache +func (c *Cache) Clean() error { + return os.RemoveAll(c.cacheDir) +} diff --git a/internal/services/cost/calculator.go b/internal/services/cost/calculator.go new file mode 100644 index 0000000..7fa08ff --- /dev/null +++ b/internal/services/cost/calculator.go @@ -0,0 +1,97 @@ +package cost + +import ( + "fmt" + "strings" +) + +type PricingTable struct { + InputPricePerMillion float64 + OutputPricePerMillion float64 +} + +type ProviderPricing map[string]map[string]PricingTable + +// https://ai.google.dev/gemini-api/docs/pricing +var pricing = ProviderPricing{ + "gemini": { + "gemini-1.5-flash": {InputPricePerMillion: 0.075, OutputPricePerMillion: 0.30}, + "gemini-1.5-pro": {InputPricePerMillion: 1.25, OutputPricePerMillion: 5.00}, + "gemini-2.5-flash": {InputPricePerMillion: 0.10, OutputPricePerMillion: 0.40}, + "gemini-3-flash-preview": {InputPricePerMillion: 0.50, OutputPricePerMillion: 3.00}, + "gemini-3-pro-preview": {InputPricePerMillion: 2.00, OutputPricePerMillion: 12.00}, + }, + "openai": { + "gpt-4o": {InputPricePerMillion: 2.50, OutputPricePerMillion: 10.00}, + "gpt-4o-mini": {InputPricePerMillion: 0.15, OutputPricePerMillion: 0.60}, + "gpt-4-turbo": {InputPricePerMillion: 10.00, OutputPricePerMillion: 30.00}, + }, + "anthropic": { + "claude-3-5-sonnet": {InputPricePerMillion: 3.00, OutputPricePerMillion: 15.00}, + "claude-3-haiku": {InputPricePerMillion: 0.25, OutputPricePerMillion: 1.25}, + }, +} + +type Calculator struct{} + +func NewCalculator() *Calculator { + return &Calculator{} +} + +// EstimateCost calcula el costo estimado basado en proveedor, modelo y tokens +func (c *Calculator) EstimateCost(provider, model string, inputTokens, outputTokens int) float64 { + provider = strings.ToLower(provider) + model = strings.ToLower(model) + + providerPricing, exists := pricing[provider] + if !exists { + return 0 + } + + modelPricing, exists := providerPricing[model] + if !exists { + for modelName, prices := range providerPricing { + if strings.Contains(model, modelName) { + modelPricing = prices + break + } + } + if modelPricing.InputPricePerMillion == 0 { + return 0 + } + } + + inputCost := (float64(inputTokens) / 1_000_000) * modelPricing.InputPricePerMillion + outputCost := (float64(outputTokens) / 1_000_000) * modelPricing.OutputPricePerMillion + + return inputCost + outputCost +} + +// GetPricing retorna la tabla de precios para un proveedor y modelo +func (c *Calculator) GetPricing(provider, model string) (PricingTable, error) { + provider = strings.ToLower(provider) + model = strings.ToLower(model) + + providerPricing, exists := pricing[provider] + if !exists { + return PricingTable{}, fmt.Errorf("proveedor %s no encontrado", provider) + } + + modelPricing, exists := providerPricing[model] + if !exists { + return PricingTable{}, fmt.Errorf("modelo %s no encontrado para proveedor %s", model, provider) + } + + return modelPricing, nil +} + +// AddPricing permite agregar precios dinámicamente (útil para testing o nuevos modelos) +func (c *Calculator) AddPricing(provider, model string, table PricingTable) { + provider = strings.ToLower(provider) + model = strings.ToLower(model) + + if _, exists := pricing[provider]; !exists { + pricing[provider] = make(map[string]PricingTable) + } + pricing[provider][model] = table +} diff --git a/internal/services/cost/manager.go b/internal/services/cost/manager.go new file mode 100644 index 0000000..aa019eb --- /dev/null +++ b/internal/services/cost/manager.go @@ -0,0 +1,203 @@ +package cost + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/Tomas-vilte/MateCommit/internal/i18n" + "github.com/fatih/color" +) + +type ActivityRecord struct { + Timestamp time.Time `json:"timestamp"` + Command string `json:"command"` + Provider string `json:"provider"` + Model string `json:"model"` + TokensInput int `json:"tokens_input"` + TokensOutput int `json:"tokens_output"` + CostUSD float64 `json:"cost_usd"` + DurationMs int64 `json:"duration_ms"` + CacheHit bool `json:"cache_hit"` + Hash string `json:"hash"` +} + +type Manager struct { + historyPath string + budgetDaily float64 + trans *i18n.Translations +} + +func NewManager(budgetDaily float64, trans *i18n.Translations) (*Manager, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("error obteniendo home directory: %w", err) + } + + matecommitDir := filepath.Join(homeDir, ".matecommit") + if err := os.MkdirAll(matecommitDir, 0755); err != nil { + return nil, fmt.Errorf("error creando directorio .matecommit: %w", err) + } + + return &Manager{ + historyPath: filepath.Join(matecommitDir, "history.json"), + budgetDaily: budgetDaily, + trans: trans, + }, nil +} + +// SaveActivity guarda un registro de actividad +func (m *Manager) SaveActivity(record ActivityRecord) error { + records, err := m.loadHistory() + if err != nil { + records = []ActivityRecord{} + } + + records = append(records, record) + + data, err := json.MarshalIndent(records, "", " ") + if err != nil { + return fmt.Errorf("error serializando historial: %w", err) + } + + if err := os.WriteFile(m.historyPath, data, 0644); err != nil { + return fmt.Errorf("error guardando historial: %w", err) + } + + return nil +} + +// CheckBudget verifica si el costo estimado excede el presupuesto diario +// y muestra alertas visuales cuando se acerca al límite +func (m *Manager) CheckBudget(estimatedCost float64) error { + if m.budgetDaily <= 0 { + return nil + } + + todayTotal, err := m.GetDailyTotal() + if err != nil { + return err + } + + percentUsed := (todayTotal / m.budgetDaily) * 100 + newPercent := ((todayTotal + estimatedCost) / m.budgetDaily) * 100 + + if percentUsed >= 50 && percentUsed < 75 { + yellow := color.New(color.FgYellow) + _, _ = yellow.Println(m.trans.GetMessage("cost.budget_alert_50", 0, map[string]interface{}{ + "Percent": fmt.Sprintf("%.0f", percentUsed), + "Spent": fmt.Sprintf("%.4f", todayTotal), + "Limit": fmt.Sprintf("%.2f", m.budgetDaily), + })) + } else if percentUsed >= 75 && percentUsed < 90 { + yellow := color.New(color.FgYellow, color.Bold) + _, _ = yellow.Println(m.trans.GetMessage("cost.budget_alert_75_title", 0, map[string]interface{}{ + "Percent": fmt.Sprintf("%.0f", percentUsed), + })) + _, _ = yellow.Println(m.trans.GetMessage("cost.budget_alert_75_spent", 0, map[string]interface{}{ + "Spent": fmt.Sprintf("%.4f", todayTotal), + "Limit": fmt.Sprintf("%.2f", m.budgetDaily), + })) + } else if percentUsed >= 90 { + red := color.New(color.FgRed, color.Bold) + _, _ = red.Println(m.trans.GetMessage("cost.budget_alert_90_title", 0, map[string]interface{}{ + "Percent": fmt.Sprintf("%.0f", percentUsed), + })) + _, _ = red.Println(m.trans.GetMessage("cost.budget_alert_90_spent", 0, map[string]interface{}{ + "Spent": fmt.Sprintf("%.4f", todayTotal), + "Limit": fmt.Sprintf("%.2f", m.budgetDaily), + })) + _, _ = red.Println(m.trans.GetMessage("cost.budget_alert_90_remaining", 0, map[string]interface{}{ + "Remaining": fmt.Sprintf("%.4f", m.budgetDaily-todayTotal), + })) + } + + if newPercent > 100 { + red := color.New(color.FgRed, color.Bold) + fmt.Println() + _, _ = red.Println(m.trans.GetMessage("cost.budget_exceeded_title", 0, nil)) + fmt.Println(m.trans.GetMessage("cost.budget_exceeded_spent_today", 0, map[string]interface{}{ + "Spent": fmt.Sprintf("%.4f", todayTotal), + })) + fmt.Println(m.trans.GetMessage("cost.budget_exceeded_estimated", 0, map[string]interface{}{ + "Cost": fmt.Sprintf("%.4f", estimatedCost), + })) + fmt.Println(m.trans.GetMessage("cost.budget_exceeded_total", 0, map[string]interface{}{ + "Total": fmt.Sprintf("%.4f", todayTotal+estimatedCost), + })) + fmt.Println(m.trans.GetMessage("cost.budget_exceeded_limit", 0, map[string]interface{}{ + "Limit": fmt.Sprintf("%.2f", m.budgetDaily), + })) + fmt.Println(m.trans.GetMessage("cost.budget_exceeded_excess", 0, map[string]interface{}{ + "Excess": fmt.Sprintf("%.4f", (todayTotal+estimatedCost)-m.budgetDaily), + })) + fmt.Println() + + return fmt.Errorf("presupuesto diario excedido: actual $%.4f + estimado $%.4f > límite $%.2f", + todayTotal, estimatedCost, m.budgetDaily) + } + + return nil +} + +// GetDailyTotal obtiene el total gastado hoy +func (m *Manager) GetDailyTotal() (float64, error) { + records, err := m.loadHistory() + if err != nil { + return 0, nil + } + + today := time.Now().Format("2006-01-02") + var total float64 + + for _, record := range records { + if record.Timestamp.Format("2006-01-02") == today { + total += record.CostUSD + } + } + + return total, nil +} + +// GetMonthlyTotal obtiene el total gastado este mes +func (m *Manager) GetMonthlyTotal() (float64, error) { + records, err := m.loadHistory() + if err != nil { + return 0, nil + } + + currentMonth := time.Now().Format("2006-01") + var total float64 + + for _, record := range records { + if record.Timestamp.Format("2006-01") == currentMonth { + total += record.CostUSD + } + } + + return total, nil +} + +// GetHistory obtiene todos los registros +func (m *Manager) GetHistory() ([]ActivityRecord, error) { + return m.loadHistory() +} + +func (m *Manager) loadHistory() ([]ActivityRecord, error) { + data, err := os.ReadFile(m.historyPath) + if err != nil { + if os.IsNotExist(err) { + return []ActivityRecord{}, nil + } + return nil, fmt.Errorf("error leyendo historial: %w", err) + } + + var records []ActivityRecord + if err := json.Unmarshal(data, &records); err != nil { + return nil, fmt.Errorf("error deserializando historial: %w", err) + } + + return records, nil +} diff --git a/internal/services/pull_request_service_test.go b/internal/services/pull_request_service_test.go index a518ee0..b03ab38 100644 --- a/internal/services/pull_request_service_test.go +++ b/internal/services/pull_request_service_test.go @@ -360,7 +360,7 @@ func setupServices(t *testing.T, testConfig TestConfig) (*PRService, error) { }, AIConfig: config.AIConfig{ Models: map[config.AI]config.Model{ - config.AIGemini: config.ModelGeminiV25Flash, + config.AIGemini: config.ModelGeminiV15Flash, }, }, } diff --git a/internal/services/routing/model_selector.go b/internal/services/routing/model_selector.go new file mode 100644 index 0000000..ff4b74a --- /dev/null +++ b/internal/services/routing/model_selector.go @@ -0,0 +1,42 @@ +package routing + +type ModelSelector struct{} + +func NewModelSelector() *ModelSelector { + return &ModelSelector{} +} + +// SelectBestModel selecciona el modelo óptimo basado en la operación y cantidad de tokens +// +// Estrategia de Smart Routing: +// - Operaciones pequeñas (< 1k tokens): Flash-Lite (más económico) +// - Operaciones medianas (1k-10k tokens): Flash (balance costo/calidad) +// - Operaciones grandes (> 10k tokens): 3.0 Flash (mejor contexto, evita alucinaciones) +// - Releases/Issues: 3.0 Flash (máxima calidad de redacción) +// +// SelectBestModel selecciona el modelo óptimo basado en la operación y cantidad de tokens +func (m *ModelSelector) SelectBestModel(operation string, estimatedTokens int) string { + if operation == "generate-release" || operation == "generate-issue" { + return "gemini-3-pro-preview" + } + + if estimatedTokens > 15000 { + return "gemini-3-flash-preview" + } + + return "gemini-2.5-flash" +} + +// GetRationale retorna la clave de traducción que explica por qué se eligió un modelo +func (m *ModelSelector) GetRationale(operation string, selectedModel string) string { + switch selectedModel { + case "gemini-1.5-flash": + return "routing.reason_balance" + case "gemini-3-flash-preview": + return "routing.reason_large" + case "gemini-3-pro-preview": + return "routing.reason_high_quality" + default: + return "routing.reason_default" + } +} diff --git a/internal/ui/token_stats.go b/internal/ui/token_stats.go index 38b5783..2d18f2b 100644 --- a/internal/ui/token_stats.go +++ b/internal/ui/token_stats.go @@ -4,23 +4,30 @@ import ( "fmt" "github.com/Tomas-vilte/MateCommit/internal/domain/models" "github.com/Tomas-vilte/MateCommit/internal/i18n" + "github.com/fatih/color" ) -func PrintTokenUsage(usage *models.UsageMetadata, trans *i18n.Translations) { +func PrintTokenUsage(usage *models.TokenUsage, t *i18n.Translations) { if usage == nil { return } - - header := trans.GetMessage("token_usage.header", 0, nil) - inputLabel := trans.GetMessage("token_usage.input", 0, nil) - outputLabel := trans.GetMessage("token_usage.output", 0, nil) - totalLabel := trans.GetMessage("token_usage.total", 0, nil) - - fmt.Println() - PrintSectionBanner(header) - - PrintKeyValue(inputLabel, fmt.Sprintf("%d", usage.InputTokens)) - PrintKeyValue(outputLabel, fmt.Sprintf("%d", usage.OutputTokens)) - PrintKeyValue(totalLabel, fmt.Sprintf("%d", usage.TotalTokens)) - fmt.Println() + cyan := color.New(color.FgCyan) + yellow := color.New(color.FgYellow) + green := color.New(color.FgGreen) + _, _ = cyan.Print("📊 ") + fmt.Printf("%s: ", t.GetMessage("ui.token_usage", 0, nil)) + fmt.Printf("%s %d | ", t.GetMessage("ui.input", 0, nil), usage.InputTokens) + fmt.Printf("%s %d | ", t.GetMessage("ui.output", 0, nil), usage.OutputTokens) + fmt.Printf("%s %d\n", t.GetMessage("ui.total", 0, nil), usage.TotalTokens) + if usage.CostUSD > 0 { + _, _ = yellow.Print("💰 ") + fmt.Printf("%s: ", t.GetMessage("ui.cost", 0, nil)) + _, _ = yellow.Printf("$%.4f USD\n", usage.CostUSD) + } + if usage.CacheHit { + _, _ = green.Printf("✓ %s\n", t.GetMessage("cost.cache_hit", 0, nil)) + } + if usage.DurationMs > 0 { + fmt.Printf("⏱️ %s: %dms\n", t.GetMessage("ui.duration", 0, nil), usage.DurationMs) + } } From 8246c015efd5f180ff1eba822b9aa1404b21dbf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8thomas=C2=A8?= Date: Fri, 19 Dec 2025 02:12:39 -0300 Subject: [PATCH 2/6] delete .mds --- PLAN_SMART_ROUTING.md | 85 ----- SMART_ROUTING.md | 398 --------------------- internal/infrastructure/ai/ARCHITECTURE.md | 229 ------------ 3 files changed, 712 deletions(-) delete mode 100644 PLAN_SMART_ROUTING.md delete mode 100644 SMART_ROUTING.md delete mode 100644 internal/infrastructure/ai/ARCHITECTURE.md diff --git a/PLAN_SMART_ROUTING.md b/PLAN_SMART_ROUTING.md deleted file mode 100644 index d63a85b..0000000 --- a/PLAN_SMART_ROUTING.md +++ /dev/null @@ -1,85 +0,0 @@ -# Plan de Implementación: Smart Routing & Control de Costos 🧉 - -Este documento detalla cómo vamos a encarar la **Issue #50** para darle a MateCommit inteligencia financiera y de ruteo, aprovechando la salida de **Gemini 3.0 Flash**. - ---- - -## 🚀 La Nueva Estrella: Gemini 3.0 Flash - -Che, la gran novedad es que integramos este modelo que rompe todo. Es rápido como el 2.5 pero razona casi como un Pro. - -### 💸 Tabla de Precios (Oficial) -| Tipo de Token | Precio (por 1M) | Detalle | -| :--- | :--- | :--- | -| **Input (Entrada)** | **$0.50** | Lo que le mandamos (diffs, contexto) | -| **Output (Salida)** | **$3.00** | Lo que nos responde (el commit, resumen) | - -> **Ojo al piojo:** La salida es 6 veces más cara. Por eso nuestra estimación tiene que ser fina ahí. - ---- - -## 🧠 Smart Routing: El Cerebro - -La idea es que no gastes pólvora en chimangos. El sistema va a decidir solo (o sugerirte): - -1. **Diffs Chicos (< 1k tokens):** Se van por **Gemini 2.5 Flash**. Es barato y sobra paño. -2. **Diffs Grandes (> 10k tokens):** Activamos **Gemini 3.0 Flash**. ¿Por qué? Porque tiene mejor contexto y no alucina cuando le tiras un choclo de código. -3. **Caching Local:** Si ya hiciste esta pregunta exacta, la sacamos de tu disco. **Costo: $0**. - ---- - -## 🔄 Flujo del Usuario (User Journey) - -Así va a ser la experiencia cuando tires un comando: - -1. Vos tirás: `matecommit summarize pr --n 50` -2. MateCommit **cuenta los tokens** (sin cobrarte nada todavía). -3. Te canta la justa: - > "Che, analizar este PR te va a salir **~$0.01 USD**. ¿Le mandamos mecha?" [Y/n] -4. Si decís que sí, recién ahí llamamos a la API. -5. Al final, te tiramos la posta: "Costo final real: **$0.0098**". - ---- - -## 🛠️ Cambios Técnicos (Lo que vamos a codear) - -### 1. Configuración y Modelos -* Modificar `internal/config/ai.go` para agregar `gemini-3.0-flash`. - -### 2. El "Calculator Service" (`internal/domain/services/cost/`) -Vamos a crear un servicio nuevo que se encargue de: -* `CountTokens()`: Usar la API para contar exacto. -* `EstimateCost()`: Calcular $$ basado en la tabla de arriba. -* `CheckBudget()`: Si tenés un límite diario (ej. $2 USD) y te vas a pasar, te avisamos. -* **Historial de Actividad Completo**: Guardamos un JSON súper detallado en `~/.matecommit/history.json`: - * `timestamp`: Cuándo fue. - * `model`: Qué modelo usaste (para ver si el 3.0 te rinde más). - * `latency_ms`: Cuánto tardó (para medir velocidad). - * `cost_usd`: La dolorosa. - * `tokens_saved`: Si hubo caché, cuánto te ahorraste. - -### 3. Caché Local (Anti-Crisis) -* **¿Qué es?** Un archivo en tu compu. -* **¿Cómo funciona?** Calculamos una "huella digital" (hash) de tu código. Si volvés a pedir lo mismo, leemos el archivo local. -* **Diferencia con Gemini Cache:** Google ofrece "Context Caching" pero te cobra por guardar. Nosotros hacemos **Caché de Respuesta** en tu disco, que es gratis y más rápido. - -### 4. Integración Global -Esto no es solo para PRs, eh. Lo vamos a meter en **todos** los comandos: -* [ ] `summarize pr` -* [ ] `suggest commits` (el clásico) -* [ ] `generate release` -* [ ] `generate issue` - -### 5. Nuevo Comando: `matecommit cost` -Para ver tu resumen mensual: "Este mes gastaste $0.45 USD en 15 PRs". - ---- - -## ✅ Plan de Pruebas - -Para estar seguros que no le erramos al vizcachazo: -1. **Test Unitario:** Verificar que 1 millón de tokens de entrada nos de exactamente $0.50. -2. **Dry Run:** Correr la CLI, ver la estimación, y compararla con lo que realmente nos cobra Google en el dashboard. - ---- -*Documento generado automáticamente por tu asistente de IA favorito.* 😉 diff --git a/SMART_ROUTING.md b/SMART_ROUTING.md deleted file mode 100644 index 6404e74..0000000 --- a/SMART_ROUTING.md +++ /dev/null @@ -1,398 +0,0 @@ -# Smart Routing & Control de Costos - Guía de Usuario - -Este documento explica las nuevas features de inteligencia de costos implementadas en MateCommit. - -## 🧠 Smart Routing Automático - -MateCommit ahora selecciona automáticamente el modelo óptimo según la complejidad de la tarea: - -### Estrategia de Selección - -| Operación | Tokens | Modelo Seleccionado | Razón | -|-----------|--------|---------------------|-------| -| `suggest-commits` | < 1,000 | Gemini 2.5 Flash-Lite | Económico para cambios pequeños | -| `suggest-commits` | 1,000-10,000 | Gemini 2.5 Flash | Balance costo/calidad | -| `suggest-commits` | > 10,000 | Gemini 3.0 Flash | Mejor contexto, evita alucinaciones | -| `summarize-pr` | Cualquiera | Según tokens | Mismo criterio que commits | -| `generate-release` | Cualquiera | Gemini 3.0 Flash | Máxima calidad de redacción | -| `generate-issue` | Cualquiera | Gemini 3.0 Flash | Claridad y detalle | - -### Ejemplo de Sugerencia - -Si estás usando Gemini 2.5 Flash pero tienes un diff grande (> 10k tokens), verás: - -``` -💡 Sugerencia: Operación grande (> 10k tokens), requiere mejor manejo de contexto - Modelo sugerido: gemini-3.0-flash (actualmente usando: gemini-2.5-flash) -``` - -Esta es solo una sugerencia. Puedes cambiar el modelo en tu configuración si lo prefieres. - ---- - -## 💰 Confirmación de Costo - -Para operaciones que cuestan más de **$0.005 USD**, MateCommit pedirá confirmación: - -``` -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -💰 Estimación de Costo -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -📊 Tokens de entrada estimados: 12500 -📤 Tokens de salida estimados: 800 -💵 Costo estimado: $0.0077 USD -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -¿Desea continuar? [Y/n]: -``` - -### Respuestas Aceptadas - -- **Continuar:** presiona `Enter`, `Y`, `y`, `yes`, `si`, o `s` -- **Cancelar:** presiona `N` o `n` - -### Deshabilitar Confirmación - -Si no quieres que te pregunte (útil para CI/CD), puedes: - -1. **Configuración global:** Agregar a `~/.matecommit/config.toml`: - ```toml - [ai_config] - skip_confirmation = true - ``` - -2. **Variable de entorno:** - ```bash - export MATECOMMIT_SKIP_CONFIRMATION=true - ``` - ---- - -## 🚨 Alertas de Presupuesto - -Si configuraste un presupuesto diario, verás alertas progresivas: - -### Alerta al 50% (Amarillo) - -``` -⚠️ Has usado 52% de tu presupuesto diario ($0.52 / $1.00) -``` - -### Alerta al 75% (Amarillo Bold) - -``` -⚠️ ¡Cuidado! Has usado 78% de tu presupuesto diario - Total gastado: $0.78 / $1.00 -``` - -### Alerta al 90% (Rojo Bold) - -``` -🚨 ¡ALERTA! Has usado 93% de tu presupuesto diario - Total gastado: $0.93 / $1.00 - Quedan solo: $0.07 -``` - -### Presupuesto Excedido - -Si una operación excedería tu presupuesto: - -``` -❌ Presupuesto diario excedido - Gastado hoy: $0.98 - Costo estimado: $0.05 - Total sería: $1.03 - Límite diario: $1.00 - Exceso: $0.03 - -Error: presupuesto diario excedido... -``` - -### Configurar Presupuesto - -Edita `~/.matecommit/config.toml`: - -```toml -[ai_config] -budget_daily = 2.00 # $2 USD por día -``` - -O al crear la configuración: - -```bash -matecommit config init -# Cuando pregunte por el presupuesto diario, ingresa: 2.00 -``` - -**Sin presupuesto:** Si no configuras `budget_daily` o lo pones en `0`, no habrá límites. - ---- - -## 📊 Ver Estadísticas - -### Estadísticas Diarias - -```bash -matecommit stats -``` - -Salida: -``` -📊 Estadísticas Diarias -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -10:30 - suggest-commits: $0.0003 -11:45 - summarize-pr: $0.0012 -14:20 - generate-release: $0.0045 [CACHE] - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -Total Hoy: $0.0060 USD -``` - -**[CACHE]** indica que la respuesta salió del caché local (costo $0). - -### Estadísticas Mensuales - -```bash -matecommit stats --monthly -``` - -Salida: -``` -📅 Estadísticas Mensuales - December 2025 -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -2025-12-17: $0.0234 -2025-12-18: $0.0567 -2025-12-19: $0.0060 - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -Total Este Mes: $0.0861 USD -``` - -### Alias - -Puedes usar `cost` como alias: - -```bash -matecommit cost # = matecommit stats -matecommit cost -m # = matecommit stats --monthly -``` - ---- - -## 💾 Caché Local - -### Beneficios - -El caché guarda respuestas por **24 horas**: - -- **Costo:** $0.00 (gratis) -- **Velocidad:** Instantáneo -- **Ubicación:** `~/.matecommit/cache/` - -### Cuándo se usa - -Si ejecutas **exactamente el mismo comando** dos veces: - -```bash -# Primera vez: llama a la API, cuesta $0.0003 -matecommit suggest - -# Segunda vez (< 24h): lee del caché, cuesta $0 -matecommit suggest -``` - -El hash incluye: -- Proveedor (gemini) -- Modelo (gemini-2.5-flash) -- Prompt completo (diff + contexto) - -**Cambió algo?** → Nuevo hash → No usa caché - -### Limpiar Caché - -```bash -matecommit cache clean -``` - -Salida: -``` -✓ Caché limpiado exitosamente -``` - -Esto elimina todos los archivos en `~/.matecommit/cache/`. - ---- - -## 🎯 Ejemplos de Uso - -### Ejemplo 1: Commit Pequeño - -```bash -# Cambio de 3 líneas en un archivo -git add file.go -matecommit suggest -``` - -Salida: -``` -💡 Sugerencia: Operación pequeña (< 1k tokens), modelo económico suficiente - Modelo sugerido: gemini-2.5-flash-lite (actualmente usando: gemini-2.5-flash) - -[Genera sugerencias sin pedir confirmación porque cuesta < $0.005] -``` - -### Ejemplo 2: PR Grande - -```bash -# PR con 50 archivos modificados -matecommit summarize-pr --n 123 -``` - -Salida: -``` -💡 Sugerencia: Operación grande (> 10k tokens), requiere mejor manejo de contexto - Modelo sugerido: gemini-3.0-flash (actualmente usando: gemini-2.5-flash) - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -💰 Estimación de Costo -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -📊 Tokens de entrada estimados: 15800 -📤 Tokens de salida estimados: 500 -💵 Costo estimado: $0.0129 USD -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -¿Desea continuar? [Y/n]: y - -[Genera el resumen del PR] -``` - -### Ejemplo 3: Presupuesto Casi Agotado - -```bash -# Ya gastaste $0.95 de $1.00 hoy -matecommit suggest -``` - -Salida: -``` -🚨 ¡ALERTA! Has usado 95% de tu presupuesto diario - Total gastado: $0.95 / $1.00 - Quedan solo: $0.05 - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -💰 Estimación de Costo -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -📊 Tokens de entrada estimados: 800 -📤 Tokens de salida estimados: 500 -💵 Costo estimado: $0.0015 USD -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -¿Desea continuar? [Y/n]: -``` - ---- - -## 📁 Archivos de Datos - -### Historial - -**Ubicación:** `~/.matecommit/history.json` - -```json -[ - { - "timestamp": "2025-12-19T10:30:00Z", - "command": "suggest-commits", - "provider": "gemini", - "model": "gemini-2.5-flash", - "tokens_input": 450, - "tokens_output": 120, - "cost_usd": 0.0003, - "duration_ms": 1250, - "cache_hit": false, - "hash": "abc123..." - } -] -``` - -### Caché - -**Ubicación:** `~/.matecommit/cache/[hash].json` - -```json -{ - "hash": "abc123...", - "response": { ... }, - "created_at": "2025-12-19T10:30:00Z" -} -``` - -**TTL:** 24 horas (auto-limpieza al iniciar) - ---- - -## 💡 Tips para Ahorrar - -1. **Usa caché:** Si no cambiaste nada, la segunda ejecución es gratis -2. **Configura presupuesto:** Te protege de gastos inesperados -3. **Presta atención a las sugerencias:** Si te sugiere Flash-Lite, probablemente no necesitas Pro -4. **Limpia archivos grandes antes de commit:** `go.sum`, `package-lock.json` no aportan al análisis - ---- - -## ⚙️ Configuración Avanzada - -### Deshabilitar Smart Routing - -Si prefieres controlar manualmente el modelo: - -```toml -# ~/.matecommit/config.toml -[ai_config] -model = "gemini-3.0-flash" # Siempre usa este -``` - -MateCommit seguirá sugiriendo, pero usará el modelo que configuraste. - -### Ajustar Umbral de Confirmación - -Actualmente hardcodeado en `$0.005`, pero podrías modificar en: - -`internal/infrastructure/ai/cost_wrapper.go:116` - -```go -if estimatedCost > 0.010 && !w.skipConfirmation { // Cambiar de 0.005 a 0.010 -``` - -### Cambiar TTL del Caché - -`internal/domain/services/cache/cache.go` al construir: - -```go -cache.NewCache(48 * time.Hour) // Cambiar de 24h a 48h -``` - ---- - -## 🐛 Troubleshooting - -### "Presupuesto excedido" pero no configuré ninguno - -Verifica `~/.matecommit/config.toml`: - -```toml -[ai_config] -budget_daily = 0 # 0 = sin límite -``` - -### El caché no funciona - -1. Verifica que `~/.matecommit/cache/` existe -2. Chequea permisos: `chmod 755 ~/.matecommit/cache` -3. Limpia y reinicia: `matecommit cache clean` - -### Sugerencias de modelo equivocadas - -Abre un issue en GitHub con: -- Comando ejecutado -- Cantidad de tokens estimados -- Modelo sugerido vs esperado diff --git a/internal/infrastructure/ai/ARCHITECTURE.md b/internal/infrastructure/ai/ARCHITECTURE.md deleted file mode 100644 index 47ac60a..0000000 --- a/internal/infrastructure/ai/ARCHITECTURE.md +++ /dev/null @@ -1,229 +0,0 @@ -# Arquitectura de Proveedores de IA - -Este documento explica la arquitectura de proveedores de IA en MateCommit y cómo agregar nuevos proveedores. - -## Diseño Actual - -### Componentes Principales - -1. **`ports.CostAwareAIProvider`** (interfaz): Define el contrato que todos los proveedores deben cumplir -2. **`ai.CostAwareWrapper`**: Wrapper agnóstico que maneja caché, presupuesto y tracking de costos -3. **Provider específico** (ej: `gemini.GeminiProvider`): Implementación base de la interfaz para cada proveedor -4. **Servicios específicos** (ej: `gemini.GeminiCommitSummarizer`): Lógica de negocio que usa el provider - -### Flujo de Datos - -``` -Usuario → Servicio (CommitSummarizer) → CostAwareWrapper → Provider (Gemini) → API Externa - ↓ - Caché + Presupuesto + Tracking -``` - -## Patrón de Implementación - -### 1. Proveedor Actual: Gemini - -```go -// internal/infrastructure/ai/gemini/base.go -type GeminiProvider struct { - Client *genai.Client - model string -} - -func (g *GeminiProvider) CountTokens(ctx context.Context, prompt string) (int, error) { - // Implementación específica de Gemini -} - -func (g *GeminiProvider) GetModelName() string { return g.model } -func (g *GeminiProvider) GetProviderName() string { return "gemini" } -``` - -### 2. Servicio que usa el Provider - -```go -type GeminiCommitSummarizer struct { - *GeminiProvider // Embedding: hereda los métodos de la interfaz - wrapper *ai.CostAwareWrapper - config *config.Config - trans *i18n.Translations -} - -func NewGeminiCommitSummarizer(ctx context.Context, cfg *config.Config, trans *i18n.Translations) (*GeminiCommitSummarizer, error) { - client, _ := genai.NewClient(...) - - // Crear servicio con provider embedado - service := &GeminiCommitSummarizer{ - GeminiProvider: NewGeminiProvider(client, modelName), - config: cfg, - trans: trans, - } - - // Crear wrapper pasando el servicio como provider - wrapper, _ := ai.NewCostAwareWrapper(ai.WrapperConfig{ - Provider: service, // Implementa CostAwareAIProvider vía embedding - BudgetDaily: budgetDaily, - Trans: trans, - EstimatedOutputTokens: 800, - }) - - service.wrapper = wrapper - return service, nil -} -``` - -## Cómo Agregar un Nuevo Proveedor - -### Ejemplo: OpenAI - -#### Paso 1: Crear el Provider Base - -```go -// internal/infrastructure/ai/openai/base.go -package openai - -import ( - "context" - "github.com/Tomas-vilte/MateCommit/internal/domain/ports" - "github.com/openai/openai-go" -) - -type OpenAIProvider struct { - Client *openai.Client - model string -} - -func NewOpenAIProvider(client *openai.Client, model string) *OpenAIProvider { - return &OpenAIProvider{ - Client: client, - model: model, - } -} - -// Implementar la interfaz ports.CostAwareAIProvider - -func (o *OpenAIProvider) CountTokens(ctx context.Context, prompt string) (int, error) { - // Usar tiktoken o la API de OpenAI para contar tokens - // Implementación específica de OpenAI -} - -func (o *OpenAIProvider) GetModelName() string { - return o.model -} - -func (o *OpenAIProvider) GetProviderName() string { - return "openai" -} - -// Verificar que implementa la interfaz -var _ ports.CostAwareAIProvider = (*OpenAIProvider)(nil) -``` - -#### Paso 2: Crear un Servicio - -```go -// internal/infrastructure/ai/openai/commit_summarizer_service.go -package openai - -import ( - "github.com/Tomas-vilte/MateCommit/internal/infrastructure/ai" - // ... otros imports -) - -type OpenAICommitSummarizer struct { - *OpenAIProvider // Embedding del provider base - wrapper *ai.CostAwareWrapper - config *config.Config - trans *i18n.Translations -} - -func NewOpenAICommitSummarizer(ctx context.Context, cfg *config.Config, trans *i18n.Translations) (*OpenAICommitSummarizer, error) { - client := openai.NewClient(cfg.AIProviders["openai"].APIKey) - modelName := string(cfg.AIConfig.Models[config.AIOpenAI]) - - service := &OpenAICommitSummarizer{ - OpenAIProvider: NewOpenAIProvider(client, modelName), - config: cfg, - trans: trans, - } - - wrapper, err := ai.NewCostAwareWrapper(ai.WrapperConfig{ - Provider: service, - BudgetDaily: cfg.AIConfig.BudgetDaily, - Trans: trans, - EstimatedOutputTokens: 800, - }) - if err != nil { - return nil, err - } - - service.wrapper = wrapper - return service, nil -} - -func (s *OpenAICommitSummarizer) GenerateSuggestions(ctx context.Context, info models.CommitInfo, count int) ([]models.CommitSuggestion, error) { - prompt := s.generatePrompt(info, count) - - // Función de generación específica de OpenAI - generateFn := func(ctx context.Context, p string) (interface{}, *models.TokenUsage, error) { - resp, err := s.Client.Chat.Completions.Create(ctx, openai.ChatCompletionCreateParams{ - Model: s.GetModelName(), - Messages: []openai.ChatCompletionMessageParam{ - {Role: "user", Content: p}, - }, - }) - - usage := &models.TokenUsage{ - InputTokens: resp.Usage.PromptTokens, - OutputTokens: resp.Usage.CompletionTokens, - TotalTokens: resp.Usage.TotalTokens, - } - - return resp, usage, nil - } - - // El wrapper maneja caché, presupuesto y tracking - resp, usage, err := s.wrapper.WrapGenerate(ctx, "suggest-commits", prompt, generateFn) - if err != nil { - return nil, err - } - - // Parsear respuesta de OpenAI... -} -``` - -#### Paso 3: Actualizar la Tabla de Precios - -```go -// internal/domain/services/cost/calculator.go -var pricing = ProviderPricing{ - "gemini": { - "gemini-2.5-flash": {InputPricePerMillion: 0.30, OutputPricePerMillion: 2.50}, - "gemini-3.0-flash": {InputPricePerMillion: 0.50, OutputPricePerMillion: 3.00}, - }, - "openai": { // NUEVO - "gpt-4o": {InputPricePerMillion: 2.50, OutputPricePerMillion: 10.00}, - "gpt-4o-mini": {InputPricePerMillion: 0.15, OutputPricePerMillion: 0.60}, - }, -} -``` - -## Ventajas de Esta Arquitectura - -✅ **Extensibilidad**: Agregar un proveedor nuevo es agregar un package con base.go -✅ **DRY**: La lógica de caché, presupuesto y tracking está en un solo lugar (CostAwareWrapper) -✅ **Type-safe**: Las interfaces garantizan que todos los providers tengan los métodos necesarios -✅ **Testeable**: Fácil mockear la interfaz `CostAwareAIProvider` para tests -✅ **Idiomático**: Usa composición (embedding) en lugar de herencia - -## Trade-offs Aceptados - -⚠️ **Construcción en dos pasos**: El servicio se crea, luego el wrapper, luego se asigna al servicio -⚠️ **Type assertions**: `WrapGenerate` retorna `interface{}`, requiere cast al tipo específico -⚠️ **Sin genéricos**: Se priorizó simplicidad sobre type-safety completa - -## Cuándo Usar Qué - -- **Nuevo proveedor completo** (OpenAI, Anthropic): Crear `internal/infrastructure/ai/[provider]/base.go` -- **Nuevo servicio en proveedor existente**: Embedar el provider base existente -- **Modificar precios**: Actualizar `internal/domain/services/cost/calculator.go` -- **Cambiar lógica de caché/presupuesto**: Modificar `internal/infrastructure/ai/cost_wrapper.go` From 5960d2773025295c3cf25eae14ad13367ecc61cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8thomas=C2=A8?= Date: Fri, 19 Dec 2025 16:20:43 -0300 Subject: [PATCH 3/6] =?UTF-8?q?feat(ai):=20implementar=20smart=20routing,?= =?UTF-8?q?=20control=20de=20costos=20y=20gesti=C3=B3n=20de=20spinners=20(?= =?UTF-8?q?#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/infrastructure/ai/cost_wrapper.go | 14 +++- .../ai/gemini/commit_summarizer_service.go | 68 ++++++++++--------- internal/infrastructure/ai/gemini/helper.go | 22 ++++++ internal/infrastructure/ai/prompts.go | 16 ++++- internal/services/routing/model_selector.go | 2 +- internal/ui/ui.go | 48 +++++++++++-- 6 files changed, 126 insertions(+), 44 deletions(-) diff --git a/internal/infrastructure/ai/cost_wrapper.go b/internal/infrastructure/ai/cost_wrapper.go index cea36e0..456d3d0 100644 --- a/internal/infrastructure/ai/cost_wrapper.go +++ b/internal/infrastructure/ai/cost_wrapper.go @@ -15,6 +15,7 @@ import ( "github.com/Tomas-vilte/MateCommit/internal/infrastructure/cache" cost2 "github.com/Tomas-vilte/MateCommit/internal/services/cost" "github.com/Tomas-vilte/MateCommit/internal/services/routing" + "github.com/Tomas-vilte/MateCommit/internal/ui" "github.com/fatih/color" ) @@ -63,6 +64,11 @@ func NewCostAwareWrapper(cfg WrapperConfig) (*CostAwareWrapper, error) { }, nil } +// SetSkipConfirmation permite activar o desactivar la confirmación del usuario manual. +func (w *CostAwareWrapper) SetSkipConfirmation(skip bool) { + w.skipConfirmation = skip +} + // WrapGenerate envuelve cualquier función de generación con tracking func (w *CostAwareWrapper) WrapGenerate( ctx context.Context, @@ -103,7 +109,9 @@ func (w *CostAwareWrapper) WrapGenerate( estimatedCost := w.calculator.EstimateCost(providerName, originalModel, inputTokens, w.estimatedOutputTokens) if hasSuggestion && !w.skipConfirmation { - rationaleKey := w.modelSelector.GetRationale(command, suggestedModel) + ui.SuspendActiveSpinner() + fmt.Println() + rationaleKey := w.modelSelector.GetRationale(suggestedModel) rationale := w.trans.GetMessage(rationaleKey, 0, nil) yellow := color.New(color.FgYellow) _, _ = yellow.Println(w.trans.GetMessage("cost.routing_suggestion", 0, map[string]interface{}{ @@ -121,8 +129,10 @@ func (w *CostAwareWrapper) WrapGenerate( return nil, nil, fmt.Errorf("%s: %w", w.trans.GetMessage("cost.budget_exceeded", 0, nil), err) } - if (estimatedCost > 0.005 || hasSuggestion) && !w.skipConfirmation { + if (estimatedCost > 0.0001 || hasSuggestion) && !w.skipConfirmation { + ui.SuspendActiveSpinner() choice, proceed := w.askUserConfirmation(estimatedCost, inputTokens, w.estimatedOutputTokens, suggestedModel) + ui.ResumeSuspendedSpinner() if !proceed { return nil, nil, fmt.Errorf("operación cancelada por el usuario") } diff --git a/internal/infrastructure/ai/gemini/commit_summarizer_service.go b/internal/infrastructure/ai/gemini/commit_summarizer_service.go index 8d9c45c..ae5b062 100644 --- a/internal/infrastructure/ai/gemini/commit_summarizer_service.go +++ b/internal/infrastructure/ai/gemini/commit_summarizer_service.go @@ -19,9 +19,10 @@ var _ ports.CommitSummarizer = (*GeminiCommitSummarizer)(nil) type GeminiCommitSummarizer struct { *GeminiProvider - wrapper *ai.CostAwareWrapper - config *config.Config - trans *i18n.Translations + wrapper *ai.CostAwareWrapper + generateFn ai.GenerateFunc + config *config.Config + trans *i18n.Translations } type ( @@ -88,10 +89,23 @@ func NewGeminiCommitSummarizer(ctx context.Context, cfg *config.Config, trans *i } service.wrapper = wrapper + service.generateFn = service.defaultGenerate return service, nil } +func (s *GeminiCommitSummarizer) defaultGenerate(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { + genConfig := GetGenerateConfig(mName, "application/json") + + resp, err := s.Client.Models.GenerateContent(ctx, mName, genai.Text(p), genConfig) + if err != nil { + return nil, nil, err + } + + usage := extractUsage(resp) + return resp, usage, nil +} + func (s *GeminiCommitSummarizer) GenerateSuggestions(ctx context.Context, info models.CommitInfo, count int) ([]models.CommitSuggestion, error) { if count <= 0 { msg := s.trans.GetMessage("error_invalid_suggestion_count", 0, nil) @@ -105,19 +119,7 @@ func (s *GeminiCommitSummarizer) GenerateSuggestions(ctx context.Context, info m prompt := s.generatePrompt(s.config.Language, info, count) - generateFn := func(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { - genConfig := GetGenerateConfig(mName, "application/json") - - resp, err := s.Client.Models.GenerateContent(ctx, mName, genai.Text(p), genConfig) - if err != nil { - return nil, nil, err - } - - usage := extractUsage(resp) - return resp, usage, nil - } - - resp, usage, err := s.wrapper.WrapGenerate(ctx, "suggest-commits", prompt, generateFn) + resp, usage, err := s.wrapper.WrapGenerate(ctx, "suggest-commits", prompt, s.generateFn) if err != nil { msg := s.trans.GetMessage("error_generating_content", 0, map[string]interface{}{ "Error": err.Error(), @@ -125,14 +127,23 @@ func (s *GeminiCommitSummarizer) GenerateSuggestions(ctx context.Context, info m return nil, fmt.Errorf("%s", msg) } - geminiResp := resp.(*genai.GenerateContentResponse) - suggestions, err := s.parseSuggestionsJSON(geminiResp) + var responseText string + if geminiResp, ok := resp.(*genai.GenerateContentResponse); ok { + responseText = formatResponse(geminiResp) + } else if s, ok := resp.(string); ok { + responseText = s + } + + if responseText == "" { + return nil, fmt.Errorf("respuesta vacía de la IA") + } + + suggestions, err := s.parseSuggestionsJSON(responseText) if err != nil { - rawResp := formatResponse(geminiResp) - respLen := len(rawResp) - preview := rawResp + respLen := len(responseText) + preview := responseText if respLen > 500 { - preview = rawResp[:500] + "..." + preview = responseText[:500] + "..." } return nil, fmt.Errorf("error al parsear respuesta JSON de la IA (longitud: %d caracteres): %w\nPrimeros caracteres: %s", respLen, err, preview) @@ -149,21 +160,12 @@ func (s *GeminiCommitSummarizer) GenerateSuggestions(ctx context.Context, info m return suggestions, nil } -func (s *GeminiCommitSummarizer) parseSuggestionsJSON(resp *genai.GenerateContentResponse) ([]models.CommitSuggestion, error) { - if resp == nil || len(resp.Candidates) == 0 { - return nil, fmt.Errorf("respuesta vacía de la IA") - } - - responseText := formatResponse(resp) +func (s *GeminiCommitSummarizer) parseSuggestionsJSON(responseText string) ([]models.CommitSuggestion, error) { if responseText == "" { return nil, fmt.Errorf("texto de respuesta vacío de la IA") } - responseText = strings.TrimSpace(responseText) - responseText = strings.TrimPrefix(responseText, "```json") - responseText = strings.TrimPrefix(responseText, "```") - responseText = strings.TrimSuffix(responseText, "```") - responseText = strings.TrimSpace(responseText) + responseText = ExtractJSON(responseText) var jsonSuggestions []CommitSuggestionJSON if err := json.Unmarshal([]byte(responseText), &jsonSuggestions); err != nil { diff --git a/internal/infrastructure/ai/gemini/helper.go b/internal/infrastructure/ai/gemini/helper.go index d32a44e..5d5ce18 100644 --- a/internal/infrastructure/ai/gemini/helper.go +++ b/internal/infrastructure/ai/gemini/helper.go @@ -1,6 +1,7 @@ package gemini import ( + "regexp" "strings" "github.com/Tomas-vilte/MateCommit/internal/domain/models" @@ -41,6 +42,27 @@ func GetGenerateConfig(modelName string, responseType string) *genai.GenerateCon return config } +// ExtractJSON intenta extraer un bloque JSON válido de un texto, manejando bloques de código markdown +// y posible texto extra que los modelos con "Thinking" pueden generar. +func ExtractJSON(text string) string { + text = strings.TrimSpace(text) + + re := regexp.MustCompile("(?s)```(?:json)?\n?(.*?)```") + matches := re.FindStringSubmatch(text) + if len(matches) > 1 { + return strings.TrimSpace(matches[1]) + } + + startIdx := strings.IndexAny(text, "{[") + lastIdx := strings.LastIndexAny(text, "}]") + + if startIdx != -1 && lastIdx != -1 && startIdx < lastIdx { + return text[startIdx : lastIdx+1] + } + + return text +} + func float32Ptr(f float32) *float32 { return &f } diff --git a/internal/infrastructure/ai/prompts.go b/internal/infrastructure/ai/prompts.go index 0410dd8..20c345f 100644 --- a/internal/infrastructure/ai/prompts.go +++ b/internal/infrastructure/ai/prompts.go @@ -101,10 +101,15 @@ const ( - ❌ BAD: "fix: various fixes in login" (Too vague) - ✅ GOOD: "fix(auth): handle null token error (#42)" (Precise) 3. **Scope:** If you touched 'ui' files, scope is (ui). If 'api', then (api). - 4. **Style:** + 4. **Style:** - Title: Imperative mood ("add", not "added"). - Description: First person, professional tone ("I optimized the query..."). - 5. **Validation:** Analyze changes against ticket criteria. + 5. **Requirements Validation (IMPORTANT):** + - Analyze ONLY the current diff changes against ticket criteria. + - Mark as "missing" ONLY requirements that are NOT visible in the diff. + - If recent history shows something was implemented in previous commits, do NOT mark it as missing. + - If you see file names or function names in the diff indicating prior implementation (e.g., "stats.go", "CountTokens"), assume it exists. + - Focus on what's missing NOW in the current commit context, not in the entire project. # Output Format Respond with ONLY valid JSON array (no markdown). @@ -148,7 +153,12 @@ const ( - ✅ BIEN: "fix(auth): manejo de error en token nulo (#42)" (Preciso) 3. **Scope:** Si tocaste archivos de 'ui', el scope es (ui). Si es 'api', es (api). Si son muchos, no uses scope. 4. **Primera Persona:** La descripción ("desc") escribila como si le contaras a un colega (ej: "Optimicé la query para mejorar el tiempo de respuesta"). - 5. **Validación:** Analiza los cambios contra los criterios del ticket. + 5. **Validación de Requerimientos (IMPORTANTE):** + - Analiza SOLO los cambios del diff actual contra los criterios del ticket. + - Marca como "missing" ÚNICAMENTE requisitos que NO están visibles en el diff. + - Si el historial reciente muestra que algo ya se implementó en commits anteriores, NO lo marques como faltante. + - Si ves nombres de archivos o funciones en el diff que indican implementación previa (ej: "stats.go", "CountTokens"), asume que ya existe. + - Enfocate en lo que falta AHORA en el contexto del commit actual, no en el proyecto completo. # Formato de Salida IMPORTANTE: Responde en ESPAÑOL. Todo el contenido del JSON debe estar en español. diff --git a/internal/services/routing/model_selector.go b/internal/services/routing/model_selector.go index ff4b74a..271aeea 100644 --- a/internal/services/routing/model_selector.go +++ b/internal/services/routing/model_selector.go @@ -28,7 +28,7 @@ func (m *ModelSelector) SelectBestModel(operation string, estimatedTokens int) s } // GetRationale retorna la clave de traducción que explica por qué se eligió un modelo -func (m *ModelSelector) GetRationale(operation string, selectedModel string) string { +func (m *ModelSelector) GetRationale(selectedModel string) string { switch selectedModel { case "gemini-1.5-flash": return "routing.reason_balance" diff --git a/internal/ui/ui.go b/internal/ui/ui.go index b84c432..aa8f4be 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -29,6 +29,9 @@ var ( StatsEmoji = Accent.Sprint("📊") ) +var activeSpinner *SmartSpinner +var suspendedSpinner *SmartSpinner + // SmartSpinner es un spinner con capacidades mejoradas type SmartSpinner struct { spinner *spinner.Spinner @@ -45,12 +48,47 @@ func NewSmartSpinner(initialMessage string) *SmartSpinner { return &SmartSpinner{spinner: s} } +// Start inicia el spinner y lo registra como el spinner activo globalmente. func (s *SmartSpinner) Start() { + activeSpinner = s s.spinner.Start() } +// Stop detiene el spinner y limpia el registro del spinner activo. func (s *SmartSpinner) Stop() { s.spinner.Stop() + if activeSpinner == s { + activeSpinner = nil + } + if suspendedSpinner == s { + suspendedSpinner = nil + } +} + +// StopActiveSpinner detiene el spinner que esté activo actualmente en la sesión de terminal. +func StopActiveSpinner() { + if activeSpinner != nil { + activeSpinner.Stop() + } +} + +// SuspendActiveSpinner detiene temporalmente el spinner activo sin borrar su referencia, +// permitiendo que sea reanudado después de una interacción del usuario. +func SuspendActiveSpinner() { + if activeSpinner != nil { + suspendedSpinner = activeSpinner + activeSpinner.spinner.Stop() + activeSpinner = nil + } +} + +// ResumeSuspendedSpinner reanuda el spinner que fue suspendido previamente. +func ResumeSuspendedSpinner() { + if suspendedSpinner != nil { + activeSpinner = suspendedSpinner + activeSpinner.spinner.Start() + suspendedSpinner = nil + } } func (s *SmartSpinner) UpdateMessage(msg string) { @@ -58,24 +96,24 @@ func (s *SmartSpinner) UpdateMessage(msg string) { } func (s *SmartSpinner) Success(msg string) { - s.spinner.Stop() + s.Stop() PrintSuccess(msg) } func (s *SmartSpinner) Error(msg string) { - s.spinner.Stop() + s.Stop() PrintError(msg) } func (s *SmartSpinner) Warning(msg string) { - s.spinner.Stop() + s.Stop() PrintWarning(msg) } func (s *SmartSpinner) Log(msg string) { - s.spinner.Stop() + s.Stop() fmt.Println(msg) - s.spinner.Start() + s.Start() } // SpinnerBuilder permite construir spinners con configuración flexible From 78d11e62c74610ccedea0f4bd37ae140154c4f8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8thomas=C2=A8?= Date: Fri, 19 Dec 2025 16:26:48 -0300 Subject: [PATCH 4/6] feat(ai): mejorar robustez de parsing JSON y ampliar cobertura de tests (#50) --- internal/cli/command/config/init_test.go | 11 +- .../gemini/commit_summarizer_service_test.go | 124 +++++++++++++++++- internal/infrastructure/ai/gemini/helper.go | 105 +++++++++++++-- .../ai/gemini/release_generator.go | 58 ++++---- internal/services/release_service_test.go | 116 +++++++++++++++- 5 files changed, 356 insertions(+), 58 deletions(-) diff --git a/internal/cli/command/config/init_test.go b/internal/cli/command/config/init_test.go index 58e989e..d0de39d 100644 --- a/internal/cli/command/config/init_test.go +++ b/internal/cli/command/config/init_test.go @@ -62,7 +62,6 @@ func runInitCommandTest(t *testing.T, userInput string, fullMode bool) (output s factory := &ConfigCommandFactory{} cmd := factory.newInitCommand(translations, cfg) - // Create app with the command to properly handle flags app := &cli.Command{ Name: "test", Commands: []*cli.Command{cmd}, @@ -92,7 +91,7 @@ func TestInitCommand(t *testing.T) { "my-gemini-api-key", "gemini-1.5-flash", "en", "yes", "my-github-token", "yes", "https://myjira.atlassian.net", "user@example.com", "my-jira-token", "no", }, "\n") + "\n" - output, finalCfg := runInitCommandTest(t, userInput, true) // Use full mode + output, finalCfg := runInitCommandTest(t, userInput, true) assert.Contains(t, output, "Introduce tu API Key de") assert.Contains(t, output, "Introduce tu token de acceso de GitHub") assert.Contains(t, output, "URL base de tu instancia de Jira") @@ -108,7 +107,7 @@ func TestInitCommand(t *testing.T) { userInput := strings.Join([]string{ "test-api-key", "", "", "no", "n", "no", }, "\n") + "\n" - output, finalCfg := runInitCommandTest(t, userInput, true) // Use full mode + output, finalCfg := runInitCommandTest(t, userInput, true) assert.Contains(t, output, "Servicio de tickets deshabilitado") assert.NotContains(t, output, "Introduce tu token de acceso de GitHub") @@ -116,7 +115,7 @@ func TestInitCommand(t *testing.T) { if finalCfg.AIProviders != nil && finalCfg.AIProviders["gemini"].APIKey != "" { assert.Equal(t, "test-api-key", finalCfg.AIProviders["gemini"].APIKey) } - assert.Equal(t, config.Model("gemini-2.5-pro"), finalCfg.AIConfig.Models[config.AIGemini]) + assert.Equal(t, config.ModelGeminiV15Flash, finalCfg.AIConfig.Models[config.AIGemini]) assert.Equal(t, "", finalCfg.ActiveVCSProvider) assert.False(t, finalCfg.UseTicket) }) @@ -125,7 +124,7 @@ func TestInitCommand(t *testing.T) { userInput := strings.Join([]string{ "", "", "fr", "no", "no", "no", }, "\n") + "\n" - output, finalCfg := runInitCommandTest(t, userInput, true) // Use full mode + output, finalCfg := runInitCommandTest(t, userInput, true) assert.Contains(t, output, "Idioma inválido. Por favor ingresa 'en' o 'es'.") assert.Equal(t, "es", finalCfg.Language) }) @@ -135,7 +134,7 @@ func TestInitCommand(t *testing.T) { "first-run-key", "", "es", "no", "no", "yes", "second-run-key", "gemini-pro", "en", "no", "no", "no", }, "\n") + "\n" - output, finalCfg := runInitCommandTest(t, userInput, true) // Use full mode + output, finalCfg := runInitCommandTest(t, userInput, true) assert.Equal(t, 2, strings.Count(output, "Introduce tu API Key de")) assert.Equal(t, 2, strings.Count(output, "Resumen de configuración")) diff --git a/internal/infrastructure/ai/gemini/commit_summarizer_service_test.go b/internal/infrastructure/ai/gemini/commit_summarizer_service_test.go index 1f9ff37..524e3e3 100644 --- a/internal/infrastructure/ai/gemini/commit_summarizer_service_test.go +++ b/internal/infrastructure/ai/gemini/commit_summarizer_service_test.go @@ -3,6 +3,7 @@ package gemini import ( "context" "fmt" + "os" "testing" "github.com/Tomas-vilte/MateCommit/internal/config" @@ -157,7 +158,7 @@ func TestGeminiCommitSummarizer(t *testing.T) { } // act - suggestions, err := service.parseSuggestionsJSON(resp) + suggestions, err := service.parseSuggestionsJSON(formatResponse(resp)) // assert assert.NoError(t, err) @@ -303,12 +304,12 @@ func TestGeminiCommitSummarizer(t *testing.T) { resp := (*genai.GenerateContentResponse)(nil) // act - suggestions, err := service.parseSuggestionsJSON(resp) + suggestions, err := service.parseSuggestionsJSON(formatResponse(resp)) // assert assert.Error(t, err) assert.Nil(t, suggestions) - assert.Contains(t, err.Error(), "respuesta vacía") + assert.Contains(t, err.Error(), "texto de respuesta vacío") }) t.Run("parseSuggestionsJSON with empty candidates", func(t *testing.T) { @@ -319,12 +320,12 @@ func TestGeminiCommitSummarizer(t *testing.T) { } // act - suggestions, err := service.parseSuggestionsJSON(resp) + suggestions, err := service.parseSuggestionsJSON(formatResponse(resp)) // assert assert.Error(t, err) assert.Nil(t, suggestions) - assert.Contains(t, err.Error(), "respuesta vacía") + assert.Contains(t, err.Error(), "texto de respuesta vacío") }) t.Run("parseSuggestionsJSON with invalid JSON", func(t *testing.T) { @@ -343,7 +344,7 @@ func TestGeminiCommitSummarizer(t *testing.T) { } // act - suggestions, err := service.parseSuggestionsJSON(resp) + suggestions, err := service.parseSuggestionsJSON(formatResponse(resp)) // assert assert.Error(t, err) @@ -387,7 +388,7 @@ func TestGeminiCommitSummarizer(t *testing.T) { } // act - suggestions, err := service.parseSuggestionsJSON(resp) + suggestions, err := service.parseSuggestionsJSON(formatResponse(resp)) // assert assert.NoError(t, err) @@ -395,4 +396,113 @@ func TestGeminiCommitSummarizer(t *testing.T) { assert.Equal(t, tc.expectedStatus, suggestions[0].RequirementsAnalysis.CriteriaStatus, "Fallo passthrough para: %s", tc.inputStatus) } }) + + t.Run("ensureIssueReference", func(t *testing.T) { + service := &GeminiCommitSummarizer{} + issueNum := 123 + suggestions := []models.CommitSuggestion{ + {CommitTitle: "feat: something"}, + {CommitTitle: "fix: bug (#123)"}, + {CommitTitle: "docs: update (#456)"}, + {CommitTitle: "refactor: code fixes #123"}, + } + + result := service.ensureIssueReference(suggestions, issueNum) + + assert.Equal(t, "feat: something (#123)", result[0].CommitTitle) + assert.Equal(t, "fix: bug (#123)", result[1].CommitTitle) + assert.Equal(t, "docs: update (#123)", result[2].CommitTitle) + assert.Equal(t, "refactor: code fixes #123", result[3].CommitTitle) + }) +} + +func TestGenerateSuggestions_HappyPath(t *testing.T) { + tmpHome, err := os.MkdirTemp("", "mate-commit-test-suggestions-*") + assert.NoError(t, err) + defer func() { + if err := os.RemoveAll(tmpHome); err != nil { + return + } + }() + oldHome := os.Getenv("HOME") + _ = os.Setenv("HOME", tmpHome) + defer func() { + if err := os.Setenv("HOME", oldHome); err != nil { + return + } + }() + + ctx := context.Background() + trans, _ := i18n.NewTranslations("en", "../../../i18n/locales/") + cfg := &config.Config{ + AIProviders: map[string]config.AIProviderConfig{"gemini": {APIKey: "test"}}, + AIConfig: config.AIConfig{Models: map[config.AI]config.Model{config.AIGemini: "gemini-pro"}}, + Language: "en", + } + service, _ := NewGeminiCommitSummarizer(ctx, cfg, trans) + service.wrapper.SetSkipConfirmation(true) + + t.Run("successful suggestions generation", func(t *testing.T) { + service.generateFn = func(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { + return &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{ + {Content: &genai.Content{Parts: []*genai.Part{{Text: responseJSON}}}}, + }, + }, &models.TokenUsage{TotalTokens: 200}, nil + } + + info := models.CommitInfo{ + Files: []string{"main.go"}, + Diff: "some diff", + } + suggestions, err := service.GenerateSuggestions(ctx, info, 1) + + assert.NoError(t, err) + assert.NotEmpty(t, suggestions) + assert.Equal(t, 1, len(suggestions)) + assert.Contains(t, suggestions[0].CommitTitle, "Mejoras") + }) +} + +func TestGeneratePrompt_WithCriteria(t *testing.T) { + trans, _ := i18n.NewTranslations("en", "../../../i18n/locales/") + cfg := &config.Config{ + AIProviders: map[string]config.AIProviderConfig{"gemini": {APIKey: "test"}}, + } + service := &GeminiCommitSummarizer{trans: trans, config: cfg} + + t.Run("formats criteria correctly in English", func(t *testing.T) { + info := models.CommitInfo{ + Files: []string{"main.go"}, + Diff: "diff", + TicketInfo: &models.TicketInfo{ + TicketTitle: "Test Ticket", + TitleDesc: "Test Description", + Criteria: []string{"Crit 1", "Crit 2"}, + }, + } + prompt := service.generatePrompt("en", info, 3) + + assert.Contains(t, prompt, "**Title:** Test Ticket") + assert.Contains(t, prompt, "**Acceptance Criteria:**") + assert.Contains(t, prompt, "- Crit 1") + assert.Contains(t, prompt, "- Crit 2") + }) + + t.Run("formats criteria correctly in Spanish", func(t *testing.T) { + info := models.CommitInfo{ + Files: []string{"main.go"}, + Diff: "diff", + TicketInfo: &models.TicketInfo{ + TicketTitle: "Ticket Test", + TitleDesc: "Desc Test", + Criteria: []string{"Crit 1"}, + }, + } + prompt := service.generatePrompt("es", info, 3) + + assert.Contains(t, prompt, "**Título:** Ticket Test") + assert.Contains(t, prompt, "**Criterios de Aceptación:**") + assert.Contains(t, prompt, "- Crit 1") + }) } diff --git a/internal/infrastructure/ai/gemini/helper.go b/internal/infrastructure/ai/gemini/helper.go index 5d5ce18..ab4be8f 100644 --- a/internal/infrastructure/ai/gemini/helper.go +++ b/internal/infrastructure/ai/gemini/helper.go @@ -1,6 +1,7 @@ package gemini import ( + "encoding/json" "regexp" "strings" @@ -47,20 +48,108 @@ func GetGenerateConfig(modelName string, responseType string) *genai.GenerateCon func ExtractJSON(text string) string { text = strings.TrimSpace(text) + // 1. Intentar encontrar JSON en bloques de código markdown re := regexp.MustCompile("(?s)```(?:json)?\n?(.*?)```") - matches := re.FindStringSubmatch(text) - if len(matches) > 1 { - return strings.TrimSpace(matches[1]) + matches := re.FindAllStringSubmatch(text, -1) + var bestMarkdown string + for _, m := range matches { + if len(m) > 1 { + content := strings.TrimSpace(m[1]) + sanitized := SanitizeJSON(content) + if json.Valid([]byte(sanitized)) { + if len(sanitized) > len(bestMarkdown) { + bestMarkdown = sanitized + } + } + } + } + if bestMarkdown != "" { + return bestMarkdown } - startIdx := strings.IndexAny(text, "{[") - lastIdx := strings.LastIndexAny(text, "}]") + // 2. Buscar bloques balanceados ({...} o [...]) y quedarse con el más largo que sea JSON válido + var bestBlock string + for i := 0; i < len(text); { + startIdx := strings.IndexAny(text[i:], "{[") + if startIdx == -1 { + break + } + startIdx += i + + opener := text[startIdx] + var closer byte + if opener == '{' { + closer = '}' + } else { + closer = ']' + } + + count := 0 + inString := false + escaped := false + foundEnd := false + endIdx := -1 - if startIdx != -1 && lastIdx != -1 && startIdx < lastIdx { - return text[startIdx : lastIdx+1] + for j := startIdx; j < len(text); j++ { + char := text[j] + if escaped { + escaped = false + continue + } + if char == '\\' { + escaped = true + continue + } + if char == '"' { + inString = !inString + continue + } + + if !inString { + if char == opener { + count++ + } else if char == closer { + count-- + if count == 0 { + foundEnd = true + endIdx = j + break + } + } + } + } + + if foundEnd { + block := text[startIdx : endIdx+1] + sanitized := SanitizeJSON(block) + if json.Valid([]byte(sanitized)) { + if len(sanitized) > len(bestBlock) { + bestBlock = sanitized + } + } + i = endIdx + 1 + } else { + i = startIdx + 1 + } + } + + if bestBlock != "" { + return bestBlock } - return text + // Fallback: si nada funcionó, sanear y devolver el texto original + return SanitizeJSON(text) +} + +var jsonStringRegex = regexp.MustCompile(`"(?:\\.|[^"\\])*"`) + +// SanitizeJSON limpia el JSON malformado que a veces generan los LLMs, +// como saltos de línea sin escapar dentro de Literales de Cadena. +func SanitizeJSON(s string) string { + // Reemplazar saltos de línea crudos dentro de los strings JSON por \n escapados + return jsonStringRegex.ReplaceAllStringFunc(s, func(m string) string { + return strings.ReplaceAll(m, "\n", "\\n") + }) } func float32Ptr(f float32) *float32 { diff --git a/internal/infrastructure/ai/gemini/release_generator.go b/internal/infrastructure/ai/gemini/release_generator.go index 00b3582..fc2c976 100644 --- a/internal/infrastructure/ai/gemini/release_generator.go +++ b/internal/infrastructure/ai/gemini/release_generator.go @@ -18,11 +18,12 @@ var _ ports.ReleaseNotesGenerator = (*ReleaseNotesGenerator)(nil) type ReleaseNotesGenerator struct { *GeminiProvider - wrapper *ai.CostAwareWrapper - trans *i18n.Translations - lang string - owner string - repo string + wrapper *ai.CostAwareWrapper + generateFn ai.GenerateFunc + trans *i18n.Translations + lang string + owner string + repo string } type ReleaseNotesJSON struct { @@ -79,43 +80,44 @@ func NewReleaseNotesGenerator(ctx context.Context, cfg *config.Config, trans *i1 } service.wrapper = wrapper + service.generateFn = service.defaultGenerate return service, nil } -func (g *ReleaseNotesGenerator) GenerateNotes(ctx context.Context, release *models.Release) (*models.ReleaseNotes, error) { - prompt := g.buildPrompt(release) +func (g *ReleaseNotesGenerator) defaultGenerate(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { + genConfig := GetGenerateConfig(mName, "application/json") - generateFn := func(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { - genConfig := GetGenerateConfig(mName, "application/json") + resp, err := g.Client.Models.GenerateContent(ctx, mName, genai.Text(p), genConfig) + if err != nil { + return nil, nil, err + } - resp, err := g.Client.Models.GenerateContent(ctx, mName, genai.Text(p), genConfig) - if err != nil { - return nil, nil, err - } + usage := extractUsage(resp) + return resp, usage, nil +} - usage := extractUsage(resp) - return resp, usage, nil - } +func (g *ReleaseNotesGenerator) GenerateNotes(ctx context.Context, release *models.Release) (*models.ReleaseNotes, error) { + prompt := g.buildPrompt(release) - resp, usage, err := g.wrapper.WrapGenerate(ctx, "generate-release", prompt, generateFn) + resp, usage, err := g.wrapper.WrapGenerate(ctx, "generate-release", prompt, g.generateFn) if err != nil { return nil, fmt.Errorf("error generando release notes: %w", err) } - geminiResp := resp.(*genai.GenerateContentResponse) + var responseText string + if geminiResp, ok := resp.(*genai.GenerateContentResponse); ok { + responseText = formatResponse(geminiResp) + } else if str, ok := resp.(string); ok { + responseText = str + } - if len(geminiResp.Candidates) == 0 { + if responseText == "" { msg := g.trans.GetMessage("ai_service.error_no_ai_response", 0, nil) return nil, fmt.Errorf("%s", msg) } - content := "" - for _, part := range geminiResp.Candidates[0].Content.Parts { - content += part.Text - } - - notes, err := g.parseJSONResponse(content, release) + notes, err := g.parseJSONResponse(responseText, release) if err != nil { return nil, fmt.Errorf("error al parsear respuesta JSON de release notes: %w", err) } @@ -243,11 +245,7 @@ func (g *ReleaseNotesGenerator) formatChangesForPrompt(release *models.Release) } func (g *ReleaseNotesGenerator) parseJSONResponse(content string, release *models.Release) (*models.ReleaseNotes, error) { - content = strings.TrimSpace(content) - content = strings.TrimPrefix(content, "```json") - content = strings.TrimPrefix(content, "```") - content = strings.TrimSuffix(content, "```") - content = strings.TrimSpace(content) + content = ExtractJSON(content) var jsonNotes ReleaseNotesJSON if err := json.Unmarshal([]byte(content), &jsonNotes); err != nil { diff --git a/internal/services/release_service_test.go b/internal/services/release_service_test.go index fe28c6c..5d4242c 100644 --- a/internal/services/release_service_test.go +++ b/internal/services/release_service_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "github.com/Tomas-vilte/MateCommit/internal/config" @@ -471,7 +472,6 @@ func TestReleaseService_EnrichReleaseContext(t *testing.T) { func TestReleaseService_UpdateAppVersion(t *testing.T) { t.Run("should update version in default file", func(t *testing.T) { dir := t.TempDir() - // Simulate cmd/main.go in temp dir cmdDir := filepath.Join(dir, "cmd") err := os.MkdirAll(cmdDir, 0755) require.NoError(t, err) @@ -485,7 +485,6 @@ var ( err = os.WriteFile(mainGoPath, []byte(initialContent), 0644) require.NoError(t, err) - // Override default config cfg := &config.Config{ VersionFile: mainGoPath, } @@ -498,7 +497,6 @@ var ( newContent, err := os.ReadFile(mainGoPath) require.NoError(t, err) - // verify stripped v and updated value assert.Contains(t, string(newContent), `"1.1.0"`) }) @@ -526,9 +524,6 @@ const CurrentVersion = "0.0.1" require.NoError(t, err) assert.Contains(t, string(newContent), `CurrentVersion = "0.0.2"`) - // Also verify spaces are preserved/stripped as per replacement logic. - // Our replacement logic: match[:startQuote] + "newVal" + match[endQuote:] - // So `CurrentVersion = "0.0.1"` -> `CurrentVersion = "0.0.2"` }) t.Run("should fail if pattern not found", func(t *testing.T) { @@ -542,6 +537,113 @@ const CurrentVersion = "0.0.1" err = service.UpdateAppVersion("v1.0.0") assert.Error(t, err) - assert.Contains(t, err.Error(), "no se encontró el patrón") + }) +} + +func TestReleaseService_CategorizeCommits_AllTypes(t *testing.T) { + service := &ReleaseService{} + release := &models.Release{ + AllCommits: []models.Commit{ + {Message: "feat: new feature"}, + {Message: "fix: bug fix"}, + {Message: "docs: update readme"}, + {Message: "style: linting"}, + {Message: "refactor: clean code"}, + {Message: "perf: optimize"}, + {Message: "test: add tests"}, + {Message: "chore: update deps"}, + {Message: "build: update build script"}, + {Message: "ci: fix pipeline"}, + {Message: "unknown: something"}, + }, + } + + service.categorizeCommits(release) + + assert.Len(t, release.Features, 1) + assert.Len(t, release.BugFixes, 1) + assert.Len(t, release.Documentation, 1) + assert.Len(t, release.Improvements, 2) + assert.Len(t, release.Other, 6) +} + +func TestReleaseService_CalculateVersion_Exhaustive(t *testing.T) { + service := &ReleaseService{} + + tests := []struct { + name string + currentTag string + release *models.Release + expVersion string + expBump models.VersionBump + }{ + { + name: "Major bump due to breaking change", + currentTag: "v1.2.3", + release: &models.Release{ + Breaking: []models.ReleaseItem{{Type: "feat", Breaking: true}}, + }, + expVersion: "v2.0.0", + expBump: models.MajorBump, + }, + { + name: "Minor bump due to feature", + currentTag: "v1.2.3", + release: &models.Release{ + Features: []models.ReleaseItem{{Type: "feat"}}, + }, + expVersion: "v1.3.0", + expBump: models.MinorBump, + }, + { + name: "Patch bump due to fix", + currentTag: "v1.2.3", + release: &models.Release{ + BugFixes: []models.ReleaseItem{{Type: "fix"}}, + }, + expVersion: "v1.2.4", + expBump: models.PatchBump, + }, + { + name: "Patch bump due to improvement", + currentTag: "v1.2.3", + release: &models.Release{ + Improvements: []models.ReleaseItem{{Type: "perf"}}, + }, + expVersion: "v1.2.4", + expBump: models.PatchBump, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + version, bump := service.calculateVersion(tt.currentTag, tt.release) + assert.Equal(t, tt.expVersion, version) + assert.Equal(t, tt.expBump, bump) + }) + } +} + +func TestReleaseService_PrependToChangelog(t *testing.T) { + service := &ReleaseService{} + dir := t.TempDir() + changelogPath := filepath.Join(dir, "CHANGELOG.md") + + t.Run("Create new changelog with content", func(t *testing.T) { + err := service.prependToChangelog(changelogPath, "## [1.0.0]\nNew version") + assert.NoError(t, err) + + content, _ := os.ReadFile(changelogPath) + assert.Contains(t, string(content), "## [1.0.0]") + }) + + t.Run("Prepend to existing changelog", func(t *testing.T) { + err := service.prependToChangelog(changelogPath, "## [1.1.0]\nNewer version\n") + assert.NoError(t, err) + + content, _ := os.ReadFile(changelogPath) + assert.Contains(t, string(content), "## [1.1.0]") + assert.Contains(t, string(content), "## [1.0.0]") + assert.True(t, strings.HasPrefix(string(content), "# Changelog")) }) } From face64508fe3353f2443ea1eca1a0dd9f6014d21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8thomas=C2=A8?= Date: Fri, 19 Dec 2025 16:30:02 -0300 Subject: [PATCH 5/6] feat(ai): mejorar robustez de parsing JSON y ampliar cobertura de tests (#50) --- internal/cli/command/config/show_test.go | 3 +- internal/infrastructure/ai/gemini/helper.go | 4 - .../ai/gemini/issue_content_generator.go | 51 ++++--- .../ai/gemini/issue_content_generator_test.go | 52 ++++++- .../pull_requests_summarizer_service.go | 46 +++--- .../pull_requests_summarizer_service_test.go | 62 ++++++++ .../ai/gemini/release_generator_test.go | 138 ++++++++++++++++++ .../services/issue_template_service_test.go | 31 ++++ internal/services/version_checker_test.go | 51 +++---- 9 files changed, 357 insertions(+), 81 deletions(-) diff --git a/internal/cli/command/config/show_test.go b/internal/cli/command/config/show_test.go index 17c0bbf..40fd0a7 100644 --- a/internal/cli/command/config/show_test.go +++ b/internal/cli/command/config/show_test.go @@ -111,8 +111,7 @@ func TestShowCommand(t *testing.T) { assert.Contains(t, output, "Servicio de tickets habilitado: jira") assert.Contains(t, output, "Configuración de Jira - BaseURL: https://example.atlassian.net, Email: user@example.com") - // Check AI models - assert.Contains(t, output, "gemini: gemini-2.5-flash") + assert.Contains(t, output, "gemini: gemini-1.5-flash") assert.Contains(t, output, "openai: gpt-4o") }) } diff --git a/internal/infrastructure/ai/gemini/helper.go b/internal/infrastructure/ai/gemini/helper.go index ab4be8f..1f1f1c4 100644 --- a/internal/infrastructure/ai/gemini/helper.go +++ b/internal/infrastructure/ai/gemini/helper.go @@ -48,7 +48,6 @@ func GetGenerateConfig(modelName string, responseType string) *genai.GenerateCon func ExtractJSON(text string) string { text = strings.TrimSpace(text) - // 1. Intentar encontrar JSON en bloques de código markdown re := regexp.MustCompile("(?s)```(?:json)?\n?(.*?)```") matches := re.FindAllStringSubmatch(text, -1) var bestMarkdown string @@ -67,7 +66,6 @@ func ExtractJSON(text string) string { return bestMarkdown } - // 2. Buscar bloques balanceados ({...} o [...]) y quedarse con el más largo que sea JSON válido var bestBlock string for i := 0; i < len(text); { startIdx := strings.IndexAny(text[i:], "{[") @@ -137,7 +135,6 @@ func ExtractJSON(text string) string { return bestBlock } - // Fallback: si nada funcionó, sanear y devolver el texto original return SanitizeJSON(text) } @@ -146,7 +143,6 @@ var jsonStringRegex = regexp.MustCompile(`"(?:\\.|[^"\\])*"`) // SanitizeJSON limpia el JSON malformado que a veces generan los LLMs, // como saltos de línea sin escapar dentro de Literales de Cadena. func SanitizeJSON(s string) string { - // Reemplazar saltos de línea crudos dentro de los strings JSON por \n escapados return jsonStringRegex.ReplaceAllStringFunc(s, func(m string) string { return strings.ReplaceAll(m, "\n", "\\n") }) diff --git a/internal/infrastructure/ai/gemini/issue_content_generator.go b/internal/infrastructure/ai/gemini/issue_content_generator.go index 5c495f8..d10af4a 100644 --- a/internal/infrastructure/ai/gemini/issue_content_generator.go +++ b/internal/infrastructure/ai/gemini/issue_content_generator.go @@ -16,9 +16,10 @@ import ( type GeminiIssueContentGenerator struct { *GeminiProvider - wrapper *ai.CostAwareWrapper - config *config.Config - trans *i18n.Translations + wrapper *ai.CostAwareWrapper + generateFn ai.GenerateFunc + config *config.Config + trans *i18n.Translations } var _ ports.IssueContentGenerator = (*GeminiIssueContentGenerator)(nil) @@ -65,38 +66,44 @@ func NewGeminiIssueContentGenerator(ctx context.Context, cfg *config.Config, tra } service.wrapper = wrapper + service.generateFn = service.defaultGenerate return service, nil } -// GenerateIssueContent genera contenido de issue usando Gemini AI. -func (s *GeminiIssueContentGenerator) GenerateIssueContent(ctx context.Context, request models.IssueGenerationRequest) (*models.IssueGenerationResult, error) { - prompt := s.buildIssuePrompt(request) +func (s *GeminiIssueContentGenerator) defaultGenerate(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { + genConfig := GetGenerateConfig(mName, "application/json") - generateFn := func(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { - genConfig := GetGenerateConfig(mName, "application/json") + resp, err := s.Client.Models.GenerateContent(ctx, mName, genai.Text(p), genConfig) + if err != nil { + return nil, nil, err + } - resp, err := s.Client.Models.GenerateContent(ctx, mName, genai.Text(p), genConfig) - if err != nil { - return nil, nil, err - } + usage := extractUsage(resp) + return resp, usage, nil +} - usage := extractUsage(resp) - return resp, usage, nil - } +// GenerateIssueContent genera contenido de issue usando Gemini AI. +func (s *GeminiIssueContentGenerator) GenerateIssueContent(ctx context.Context, request models.IssueGenerationRequest) (*models.IssueGenerationResult, error) { + prompt := s.buildIssuePrompt(request) - resp, usage, err := s.wrapper.WrapGenerate(ctx, "generate-issue", prompt, generateFn) + resp, usage, err := s.wrapper.WrapGenerate(ctx, "generate-issue", prompt, s.generateFn) if err != nil { return nil, fmt.Errorf("error generando contenido de issue: %w", err) } - geminiResp := resp.(*genai.GenerateContentResponse) + var responseText string + if geminiResp, ok := resp.(*genai.GenerateContentResponse); ok { + responseText = formatResponse(geminiResp) + } else if str, ok := resp.(string); ok { + responseText = str + } - if len(geminiResp.Candidates) == 0 { + if responseText == "" { return nil, fmt.Errorf("ningún contenido generado por IA") } - result, err := s.parseIssueResponse(geminiResp) + result, err := s.parseIssueResponse(responseText) if err != nil { return nil, fmt.Errorf("error al parsear la respuesta de la IA: %w", err) } @@ -139,12 +146,12 @@ func (s *GeminiIssueContentGenerator) buildIssuePrompt(request models.IssueGener } // parseIssueResponse parsea la respuesta JSON de Gemini. -func (s *GeminiIssueContentGenerator) parseIssueResponse(resp *genai.GenerateContentResponse) (*models.IssueGenerationResult, error) { - if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 { +func (s *GeminiIssueContentGenerator) parseIssueResponse(content string) (*models.IssueGenerationResult, error) { + if content == "" { return nil, fmt.Errorf("empty response from AI") } - content := formatResponse(resp) + content = ExtractJSON(content) var jsonResult struct { Title string `json:"title"` diff --git a/internal/infrastructure/ai/gemini/issue_content_generator_test.go b/internal/infrastructure/ai/gemini/issue_content_generator_test.go index ca57a78..274ec18 100644 --- a/internal/infrastructure/ai/gemini/issue_content_generator_test.go +++ b/internal/infrastructure/ai/gemini/issue_content_generator_test.go @@ -2,6 +2,7 @@ package gemini import ( "context" + "os" "testing" "github.com/Tomas-vilte/MateCommit/internal/config" @@ -105,7 +106,7 @@ func TestParseIssueResponse(t *testing.T) { }, } - result, err := gen.parseIssueResponse(resp) + result, err := gen.parseIssueResponse(formatResponse(resp)) assert.NoError(t, err) assert.Equal(t, "Bug Fix", result.Title) assert.Equal(t, "Fixed a bug", result.Description) @@ -125,7 +126,7 @@ func TestParseIssueResponse(t *testing.T) { }, } - result, err := gen.parseIssueResponse(resp) + result, err := gen.parseIssueResponse(formatResponse(resp)) assert.NoError(t, err) assert.Equal(t, "Generated Issue", result.Title) assert.Equal(t, "This is not JSON but raw text", result.Description) @@ -136,7 +137,7 @@ func TestParseIssueResponse(t *testing.T) { Candidates: []*genai.Candidate{}, } - result, err := gen.parseIssueResponse(resp) + result, err := gen.parseIssueResponse(formatResponse(resp)) assert.Error(t, err) assert.Nil(t, result) }) @@ -174,3 +175,48 @@ func TestCleanLabels(t *testing.T) { }) } } + +func TestGenerateIssueContent_HappyPath(t *testing.T) { + // Setup temp home + tmpHome, err := os.MkdirTemp("", "mate-commit-test-issue-*") + assert.NoError(t, err) + defer func() { + if err := os.RemoveAll(tmpHome); err != nil { + return + } + }() + oldHome := os.Getenv("HOME") + _ = os.Setenv("HOME", tmpHome) + defer func() { + if err := os.Setenv("HOME", oldHome); err != nil { + return + } + }() + + ctx := context.Background() + trans, _ := i18n.NewTranslations("en", "../../../i18n/locales/") + cfg := &config.Config{ + AIProviders: map[string]config.AIProviderConfig{"gemini": {APIKey: "test"}}, + AIConfig: config.AIConfig{Models: map[config.AI]config.Model{config.AIGemini: "gemini-pro"}}, + } + gen, _ := NewGeminiIssueContentGenerator(ctx, cfg, trans) + gen.wrapper.SetSkipConfirmation(true) + + t.Run("successful issue content generation", func(t *testing.T) { + expectedJSON := `{"title": "Issue Title", "description": "Issue Description", "labels": ["fix"]}` + gen.generateFn = func(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { + return &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{ + {Content: &genai.Content{Parts: []*genai.Part{{Text: expectedJSON}}}}, + }, + }, &models.TokenUsage{TotalTokens: 30}, nil + } + + result, err := gen.GenerateIssueContent(ctx, models.IssueGenerationRequest{}) + + assert.NoError(t, err) + assert.Equal(t, "Issue Title", result.Title) + assert.Equal(t, "Issue Description", result.Description) + assert.Contains(t, result.Labels, "fix") + }) +} diff --git a/internal/infrastructure/ai/gemini/pull_requests_summarizer_service.go b/internal/infrastructure/ai/gemini/pull_requests_summarizer_service.go index 1e8fab3..2295066 100644 --- a/internal/infrastructure/ai/gemini/pull_requests_summarizer_service.go +++ b/internal/infrastructure/ai/gemini/pull_requests_summarizer_service.go @@ -18,9 +18,10 @@ var _ ports.PRSummarizer = (*GeminiPRSummarizer)(nil) type GeminiPRSummarizer struct { *GeminiProvider - wrapper *ai.CostAwareWrapper - config *config.Config - trans *i18n.Translations + wrapper *ai.CostAwareWrapper + generateFn ai.GenerateFunc + config *config.Config + trans *i18n.Translations } type PRSummaryJSON struct { @@ -69,40 +70,41 @@ func NewGeminiPRSummarizer(ctx context.Context, cfg *config.Config, trans *i18n. } service.wrapper = wrapper + service.generateFn = service.defaultGenerate return service, nil } -func (gps *GeminiPRSummarizer) GeneratePRSummary(ctx context.Context, prContent string) (models.PRSummary, error) { - prompt := gps.generatePRPrompt(prContent) +func (gps *GeminiPRSummarizer) defaultGenerate(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { + genConfig := GetGenerateConfig(mName, "application/json") - generateFn := func(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { - genConfig := GetGenerateConfig(mName, "application/json") + resp, err := gps.Client.Models.GenerateContent(ctx, mName, genai.Text(p), genConfig) + if err != nil { + return nil, nil, err + } - resp, err := gps.Client.Models.GenerateContent(ctx, mName, genai.Text(p), genConfig) - if err != nil { - return nil, nil, err - } + usage := extractUsage(resp) + return resp, usage, nil +} - usage := extractUsage(resp) - return resp, usage, nil - } +func (gps *GeminiPRSummarizer) GeneratePRSummary(ctx context.Context, prContent string) (models.PRSummary, error) { + prompt := gps.generatePRPrompt(prContent) - resp, usage, err := gps.wrapper.WrapGenerate(ctx, "summarize-pr", prompt, generateFn) + resp, usage, err := gps.wrapper.WrapGenerate(ctx, "summarize-pr", prompt, gps.generateFn) if err != nil { return models.PRSummary{}, fmt.Errorf("error al generar resumen de PR: %w", err) } - geminiResp := resp.(*genai.GenerateContentResponse) - responseText := formatResponse(geminiResp) + var responseText string + if geminiResp, ok := resp.(*genai.GenerateContentResponse); ok { + responseText = formatResponse(geminiResp) + } else if s, ok := resp.(string); ok { + responseText = s + } if responseText == "" { return models.PRSummary{}, fmt.Errorf("respuesta vacía de la IA") } - responseText = strings.TrimSpace(responseText) - responseText = strings.TrimPrefix(responseText, "```json") - responseText = strings.TrimPrefix(responseText, "```") - responseText = strings.TrimSuffix(responseText, "```") - responseText = strings.TrimSpace(responseText) + responseText = ExtractJSON(responseText) var jsonSummary PRSummaryJSON if err := json.Unmarshal([]byte(responseText), &jsonSummary); err != nil { return models.PRSummary{}, fmt.Errorf("error al parsear JSON de PR: %w", err) diff --git a/internal/infrastructure/ai/gemini/pull_requests_summarizer_service_test.go b/internal/infrastructure/ai/gemini/pull_requests_summarizer_service_test.go index 9c6bdb6..2695606 100644 --- a/internal/infrastructure/ai/gemini/pull_requests_summarizer_service_test.go +++ b/internal/infrastructure/ai/gemini/pull_requests_summarizer_service_test.go @@ -2,6 +2,7 @@ package gemini import ( "context" + "os" "testing" "github.com/Tomas-vilte/MateCommit/internal/config" @@ -125,3 +126,64 @@ func TestGeminiPRSummarizer(t *testing.T) { assert.Equal(t, "", result, "formatResponse con candidatos vacíos debería retornar string vacío") }) } + +func TestGeneratePRSummary_HappyPath(t *testing.T) { + tmpHome, err := os.MkdirTemp("", "mate-commit-test-pr-*") + assert.NoError(t, err) + defer func() { + if err := os.RemoveAll(tmpHome); err != nil { + return + } + }() + oldHome := os.Getenv("HOME") + _ = os.Setenv("HOME", tmpHome) + defer func() { + if err := os.Setenv("HOME", oldHome); err != nil { + return + } + }() + + ctx := context.Background() + trans, _ := i18n.NewTranslations("en", "../../../i18n/locales/") + cfg := &config.Config{ + AIProviders: map[string]config.AIProviderConfig{"gemini": {APIKey: "test"}}, + AIConfig: config.AIConfig{Models: map[config.AI]config.Model{config.AIGemini: "gemini-pro"}}, + } + summarizer, _ := NewGeminiPRSummarizer(ctx, cfg, trans) + summarizer.wrapper.SetSkipConfirmation(true) + + t.Run("successful PR summary", func(t *testing.T) { + expectedJSON := `{"title": "Awesome Feature", "body": "This PR adds awesome feature", "labels": ["feature"]}` + summarizer.generateFn = func(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { + return &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{ + {Content: &genai.Content{Parts: []*genai.Part{{Text: expectedJSON}}}}, + }, + }, &models.TokenUsage{TotalTokens: 50}, nil + } + + summary, err := summarizer.GeneratePRSummary(ctx, "content") + + assert.NoError(t, err) + assert.Equal(t, "Awesome Feature", summary.Title) + assert.Equal(t, "This PR adds awesome feature", summary.Body) + assert.Contains(t, summary.Labels, "feature") + }) + + t.Run("empty title error", func(t *testing.T) { + expectedJSON := `{"title": "", "body": "no title", "labels": []}` + summarizer.generateFn = func(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { + return &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{ + {Content: &genai.Content{Parts: []*genai.Part{{Text: expectedJSON}}}}, + }, + }, &models.TokenUsage{}, nil + } + + summary, err := summarizer.GeneratePRSummary(ctx, "content") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "respuesta vacía") + assert.Empty(t, summary.Title) + }) +} diff --git a/internal/infrastructure/ai/gemini/release_generator_test.go b/internal/infrastructure/ai/gemini/release_generator_test.go index bef613c..4196e3f 100644 --- a/internal/infrastructure/ai/gemini/release_generator_test.go +++ b/internal/infrastructure/ai/gemini/release_generator_test.go @@ -2,12 +2,15 @@ package gemini import ( "context" + "fmt" + "os" "testing" "github.com/Tomas-vilte/MateCommit/internal/config" "github.com/Tomas-vilte/MateCommit/internal/domain/models" "github.com/Tomas-vilte/MateCommit/internal/i18n" "github.com/stretchr/testify/assert" + "google.golang.org/genai" ) func TestNewReleaseNotesGenerator(t *testing.T) { @@ -114,6 +117,56 @@ func TestBuildPrompt(t *testing.T) { assert.NotContains(t, prompt, "BUG FIXES:") assert.NotContains(t, prompt, "IMPROVEMENTS:") }) + + t.Run("formats complex release with all sections", func(t *testing.T) { + // Arrange + generator := &ReleaseNotesGenerator{lang: "en", owner: "owner", repo: "repo"} + release := &models.Release{ + Version: "v2.0.0", + PreviousVersion: "v1.5.0", + VersionBump: "major", + ClosedIssues: []models.Issue{ + {Number: 1, Title: "Issue 1", Author: "user1"}, + }, + MergedPRs: []models.PullRequest{ + {Number: 10, Title: "PR 10", Author: "user2", Description: "Long description\nwith multiple lines"}, + }, + Contributors: []string{"user1", "user2"}, + NewContributors: []string{"user2"}, + FileStats: models.FileStatistics{ + FilesChanged: 5, + Insertions: 100, + Deletions: 20, + TopFiles: []models.FileChange{ + {Path: "main.go", Additions: 50, Deletions: 10}, + }, + }, + Dependencies: []models.DependencyChange{ + {Name: "dep1", OldVersion: "1.0", NewVersion: "1.1", Type: "updated"}, + {Name: "dep2", NewVersion: "2.0", Type: "added"}, + {Name: "dep3", OldVersion: "0.5", Type: "removed"}, + }, + } + + // Act + prompt := generator.buildPrompt(release) + + // Assert + assert.Contains(t, prompt, "CLOSED ISSUES:") + assert.Contains(t, prompt, "- #1: Issue 1 (by @user1)") + assert.Contains(t, prompt, "MERGED PULL REQUESTS:") + assert.Contains(t, prompt, "- #10: PR 10 (by @user2)") + assert.Contains(t, prompt, "Description: Long description") + assert.Contains(t, prompt, "CONTRIBUTORS (2 total):") + assert.Contains(t, prompt, "New contributors: user2") + assert.Contains(t, prompt, "FILE STATISTICS:") + assert.Contains(t, prompt, "- Files changed: 5") + assert.Contains(t, prompt, "- main.go (+50/-10)") + assert.Contains(t, prompt, "DEPENDENCY UPDATES:") + assert.Contains(t, prompt, "- dep1: 1.0 → 1.1") + assert.Contains(t, prompt, "- Added: dep2 2.0") + assert.Contains(t, prompt, "- Removed: dep3 0.5") + }) } func TestParseJSONResponse(t *testing.T) { @@ -194,4 +247,89 @@ func TestParseJSONResponse(t *testing.T) { assert.Equal(t, "Test", notes.Title) assert.Equal(t, "Summary", notes.Summary) }) + + t.Run("handles N/A contributors", func(t *testing.T) { + content := `{"title": "T", "summary": "S", "highlights": [], "breaking_changes": [], "contributors": "N/A"}` + notes, err := generator.parseJSONResponse(content, release) + assert.NoError(t, err) + assert.Empty(t, notes.Links["Contributors"]) + }) +} + +func TestGenerateNotes(t *testing.T) { + tmpHome, err := os.MkdirTemp("", "mate-commit-test-gen-notes-*") + assert.NoError(t, err) + defer func() { + if err := os.RemoveAll(tmpHome); err != nil { + return + } + }() + oldHome := os.Getenv("HOME") + _ = os.Setenv("HOME", tmpHome) + defer func() { + if err := os.Setenv("HOME", oldHome); err != nil { + return + } + }() + + ctx := context.Background() + trans, _ := i18n.NewTranslations("en", "../../../i18n/locales/") + cfg := &config.Config{ + AIProviders: map[string]config.AIProviderConfig{"gemini": {APIKey: "test"}}, + AIConfig: config.AIConfig{Models: map[config.AI]config.Model{config.AIGemini: "gemini-pro"}}, + } + generator, _ := NewReleaseNotesGenerator(ctx, cfg, trans, "owner", "repo") + generator.wrapper.SetSkipConfirmation(true) + + t.Run("successful generation", func(t *testing.T) { + // Arrange + expectedJSON := `{"title": "Release v1.0.0", "summary": "Summary", "highlights": ["H1"], "breaking_changes": []}` + generator.generateFn = func(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { + return &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{ + {Content: &genai.Content{Parts: []*genai.Part{{Text: expectedJSON}}}}, + }, + UsageMetadata: &genai.GenerateContentResponseUsageMetadata{TotalTokenCount: 100}, + }, &models.TokenUsage{TotalTokens: 100}, nil + } + + // Act + notes, err := generator.GenerateNotes(ctx, &models.Release{Version: "v1.0.0"}) + + // Assert + assert.NoError(t, err) + assert.Equal(t, "Release v1.0.0", notes.Title) + assert.Equal(t, 100, notes.Usage.TotalTokens) + }) + + t.Run("AI returns error", func(t *testing.T) { + // Arrange + generator.generateFn = func(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { + return nil, nil, fmt.Errorf("AI error") + } + + // Act + notes, err := generator.GenerateNotes(ctx, &models.Release{}) + + // Assert + assert.Error(t, err) + assert.Nil(t, notes) + assert.Contains(t, err.Error(), "AI error") + }) + + t.Run("no candidates from AI", func(t *testing.T) { + // Arrange + generator.generateFn = func(ctx context.Context, mName string, p string) (interface{}, *models.TokenUsage, error) { + return &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{}, + }, &models.TokenUsage{}, nil + } + + // Act + _, err := generator.GenerateNotes(ctx, &models.Release{}) + + // Assert + assert.Error(t, err) + assert.Contains(t, err.Error(), "No response from AI") + }) } diff --git a/internal/services/issue_template_service_test.go b/internal/services/issue_template_service_test.go index 2845d20..2f693a0 100644 --- a/internal/services/issue_template_service_test.go +++ b/internal/services/issue_template_service_test.go @@ -254,3 +254,34 @@ func TestIssueTemplateService_MergeWithGeneratedContent(t *testing.T) { assert.Contains(t, result.Labels, "ui") }) } +func TestIssueTemplateService_GetTemplateByName_NotFound(t *testing.T) { + cfg := &config.Config{ActiveVCSProvider: "github"} + service := NewIssueTemplateService(cfg, nil) + _, err := service.GetTemplateByName("ghost") + assert.Error(t, err) +} + +func TestIssueTemplateService_MergeWithGeneratedContent_Realistic(t *testing.T) { + cfg := &config.Config{} + service := NewIssueTemplateService(cfg, nil) + template := &models.IssueTemplate{ + Title: "[BUG] ", + Body: []interface{}{ + map[string]interface{}{ + "type": "textarea", + "id": "repro", + "attributes": map[string]interface{}{ + "label": "Steps to reproduce", + }, + }, + }, + } + generated := &models.IssueGenerationResult{ + Title: "Server error 500", + Description: "- Go to /home\n- Click login", + } + + result := service.MergeWithGeneratedContent(template, generated) + assert.Equal(t, "[BUG] Server error 500", result.Title) + assert.Contains(t, result.Description, "- Go to /home") +} diff --git a/internal/services/version_checker_test.go b/internal/services/version_checker_test.go index c870231..262e8c9 100644 --- a/internal/services/version_checker_test.go +++ b/internal/services/version_checker_test.go @@ -122,8 +122,6 @@ func TestDetectInstallMethod(t *testing.T) { updater := NewVersionUpdater("v1.0.0", trans) method := updater.detectInstallMethod() - // El test real depende del PATH del ejecutable actual - // Solo verificamos que retorna un método válido assert.Contains(t, []string{"go", "brew", "binary", "unknown"}, method) }) } @@ -135,15 +133,9 @@ func TestDetectInstallMethod_Binary(t *testing.T) { t.Setenv("GOPATH", "") t.Setenv("GOBIN", "") - // We can't easily unset homebrew paths if they are in the executable path, - // but in a temp dir environment it should default to binary. method := updater.detectInstallMethod() - // It relies on os.Executable() which we can't mock easily. - // We assert it is at least one of the valid values, and if not go/brew, it should be binary. - // Since we unset GOPATH/GOBIN, it shouldn't be "go". - // Unless the test binary is in /Cellar/..., it shouldn't be "brew". - if method != "brew" { // Start condition + if method != "brew" { assert.Equal(t, "binary", method) } } @@ -155,9 +147,6 @@ func TestUpdateCLI(t *testing.T) { t.Run("calls appropriate method", func(t *testing.T) { t.Setenv("GOPATH", "") t.Setenv("GOBIN", "") - // Should detect as binary and try update, failing due to no credentials/network - // or "binary update not supported" if we reverted to that logic (we didn't). - // It should fail with an error. err := updater.UpdateCLI(context.Background()) assert.Error(t, err) }) @@ -167,7 +156,6 @@ func TestCacheOperations(t *testing.T) { trans, _ := i18n.NewTranslations("en", "") updater := NewVersionUpdater("v1.0.0", trans) - // Test save and load cache := UpdateCache{ LastCheck: time.Now(), LatestKnown: "v1.0.1", @@ -182,7 +170,6 @@ func TestCacheOperations(t *testing.T) { assert.Equal(t, cache.LatestKnown, loaded.LatestKnown) assert.WithinDuration(t, cache.LastCheck, loaded.LastCheck, time.Second) - // Cleanup cacheDir, _ := updater.getCacheDir() _ = os.RemoveAll(cacheDir) } @@ -193,10 +180,8 @@ func TestCheckForUpdates_WithDisableEnvVar(t *testing.T) { t.Setenv("MATECOMMIT_DISABLE_UPDATE_CHECK", "1") - // Should return immediately without error updater.CheckForUpdates(context.Background()) - // Verify no cache was created _, err := updater.loadCache() assert.Error(t, err, "cache should not exist when checks are disabled") } @@ -205,7 +190,6 @@ func TestCheckForUpdates_WithCache(t *testing.T) { trans, _ := i18n.NewTranslations("en", "") updater := NewVersionUpdater("v1.0.0", trans) - // Setup cache from 1 hour ago cache := UpdateCache{ LastCheck: time.Now().Add(-1 * time.Hour), LatestKnown: "v1.0.1", @@ -214,14 +198,11 @@ func TestCheckForUpdates_WithCache(t *testing.T) { err := updater.saveCache(cache) require.NoError(t, err) - // Should use cache and not hit GitHub ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() - // Capture output to verify notification updater.CheckForUpdates(ctx) - // Cleanup cacheDir, _ := updater.getCacheDir() _ = os.RemoveAll(cacheDir) } @@ -238,7 +219,6 @@ func TestExtractZip(t *testing.T) { trans, _ := i18n.NewTranslations("en", "") updater := NewVersionUpdater("v1.0.0", trans) - // Create a dummy zip file tmpDir := t.TempDir() zipPath := filepath.Join(tmpDir, "test.zip") @@ -247,27 +227,23 @@ func TestExtractZip(t *testing.T) { w := zip.NewWriter(f) - // Add a file fileW, err := w.Create("matecommit.exe") require.NoError(t, err) _, err = fileW.Write([]byte("dummy content")) require.NoError(t, err) - // Add a directory (should be handled) _, err = w.Create("some-dir/") require.NoError(t, err) require.NoError(t, w.Close()) require.NoError(t, f.Close()) - // Test extraction destDir := t.TempDir() binPath, err := updater.extractZip(zipPath, destDir) require.NoError(t, err) assert.Equal(t, filepath.Join(destDir, "matecommit.exe"), binPath) - // Verify content content, err := os.ReadFile(binPath) require.NoError(t, err) assert.Equal(t, "dummy content", string(content)) @@ -277,7 +253,6 @@ func TestExtractTarGz(t *testing.T) { trans, _ := i18n.NewTranslations("en", "") updater := NewVersionUpdater("v1.0.0", trans) - // Create a dummy tar.gz file tmpDir := t.TempDir() tarPath := filepath.Join(tmpDir, "test.tar.gz") @@ -302,15 +277,35 @@ func TestExtractTarGz(t *testing.T) { require.NoError(t, gw.Close()) require.NoError(t, f.Close()) - // Test extraction destDir := t.TempDir() binPath, err := updater.extractTarGz(tarPath, destDir) require.NoError(t, err) assert.Equal(t, filepath.Join(destDir, "matecommit"), binPath) - // Verify content content, err := os.ReadFile(binPath) require.NoError(t, err) assert.Equal(t, "dummy content", string(content)) } +func TestVersionUpdater_IsUpdateAvailable_EdgeCases(t *testing.T) { + trans, _ := i18n.NewTranslations("en", "") + updater := NewVersionUpdater("v1.0.0", trans) + + assert.False(t, updater.isUpdateAvailable("v1.0.0-rc.1")) + assert.True(t, NewVersionUpdater("v1.0.0-rc.1", trans).isUpdateAvailable("v1.0.0")) +} + +func TestVersionUpdater_LoadCache_InvalidJSON(t *testing.T) { + trans, _ := i18n.NewTranslations("en", "") + updater := NewVersionUpdater("v1.0.0", trans) + + cacheDir, _ := updater.getCacheDir() + _ = os.MkdirAll(cacheDir, 0755) + cacheFile := filepath.Join(cacheDir, "update_cache.json") + _ = os.WriteFile(cacheFile, []byte("invalid json"), 0644) + + _, err := updater.loadCache() + assert.Error(t, err) + + _ = os.RemoveAll(cacheDir) +} From c73f15c78a29d151b33617afabf4e21fc5748a69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8thomas=C2=A8?= Date: Fri, 19 Dec 2025 16:31:01 -0300 Subject: [PATCH 6/6] test(feature): agrega pruebas unitarias para inteligencia de costos y ruteo (#50) --- internal/cli/command/stats/stats_test.go | 487 ++++++++++++++++++ .../infrastructure/ai/cost_wrapper_test.go | 282 ++++++++++ .../ai/gemini/gemini_factory_test.go | 67 +++ .../infrastructure/ai/gemini/helper_test.go | 119 +++++ internal/infrastructure/cache/cache_test.go | 227 ++++++++ internal/services/cost/calculator_test.go | 177 +++++++ internal/services/cost/manager_test.go | 237 +++++++++ .../services/routing/model_selector_test.go | 125 +++++ 8 files changed, 1721 insertions(+) create mode 100644 internal/cli/command/stats/stats_test.go create mode 100644 internal/infrastructure/ai/cost_wrapper_test.go create mode 100644 internal/infrastructure/ai/gemini/gemini_factory_test.go create mode 100644 internal/infrastructure/ai/gemini/helper_test.go create mode 100644 internal/infrastructure/cache/cache_test.go create mode 100644 internal/services/cost/calculator_test.go create mode 100644 internal/services/cost/manager_test.go create mode 100644 internal/services/routing/model_selector_test.go diff --git a/internal/cli/command/stats/stats_test.go b/internal/cli/command/stats/stats_test.go new file mode 100644 index 0000000..1fe8af7 --- /dev/null +++ b/internal/cli/command/stats/stats_test.go @@ -0,0 +1,487 @@ +package stats + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Tomas-vilte/MateCommit/internal/i18n" + "github.com/Tomas-vilte/MateCommit/internal/services/cost" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewStatsCommand(t *testing.T) { + // Arrange & Act + cmd := NewStatsCommand() + + // Assert + assert.NotNil(t, cmd, "NewStatsCommand debería retornar una instancia no nula") + assert.IsType(t, &StatsCommand{}, cmd, "debería retornar un puntero a StatsCommand") +} + +func TestShowDailyStats_NoActivity(t *testing.T) { + // Arrange + tempDir := t.TempDir() + manager := setupTestManager(t, tempDir, []cost.ActivityRecord{}) + trans := setupTestTranslations(t) + cmd := NewStatsCommand() + + var buf bytes.Buffer + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Act + err := cmd.showDailyStats(manager, trans) + + _ = w.Close() + os.Stdout = oldStdout + _, _ = io.Copy(&buf, r) + + output := buf.String() + + // Assert + assert.NoError(t, err, "showDailyStats no debería retornar error con datos vacíos") + assert.Contains(t, output, "No activity recorded", "debería indicar que no hay actividad") + assert.Contains(t, output, "━", "debería contener el separador de la tabla") +} + +func TestShowDailyStats_WithActivity(t *testing.T) { + // Arrange + tempDir := t.TempDir() + now := time.Now() + + records := []cost.ActivityRecord{ + { + Timestamp: now, + Command: "suggest", + Provider: "gemini", + Model: "gemini-2.5-flash", + TokensInput: 100, + TokensOutput: 50, + CostUSD: 0.0015, + DurationMs: 1500, + CacheHit: false, + }, + { + Timestamp: now.Add(-1 * time.Hour), + Command: "summarize-pr", + Provider: "gemini", + Model: "gemini-3.0-flash", + TokensInput: 500, + TokensOutput: 200, + CostUSD: 0.0085, + DurationMs: 2300, + CacheHit: false, + }, + { + Timestamp: now.AddDate(0, 0, -1), + Command: "suggest", + Provider: "gemini", + Model: "gemini-2.5-flash", + TokensInput: 100, + TokensOutput: 50, + CostUSD: 0.0010, + DurationMs: 1000, + CacheHit: false, + }, + } + + manager := setupTestManager(t, tempDir, records) + trans := setupTestTranslations(t) + cmd := NewStatsCommand() + + var buf bytes.Buffer + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Act + err := cmd.showDailyStats(manager, trans) + + _ = w.Close() + os.Stdout = oldStdout + _, _ = io.Copy(&buf, r) + + outputStr := buf.String() + + // Assert + assert.NoError(t, err, "showDailyStats no debería retornar error") + assert.Contains(t, outputStr, "suggest", "debería mostrar el comando suggest") + assert.Contains(t, outputStr, "summarize-pr", "debería mostrar el comando summarize-pr") + assert.Contains(t, outputStr, "$0.0015", "debería mostrar el costo del primer comando") + assert.Contains(t, outputStr, "$0.0085", "debería mostrar el costo del segundo comando") + assert.Contains(t, outputStr, "━", "debería contener separadores visuales") + + total, err := manager.GetDailyTotal() + assert.NoError(t, err) + assert.Equal(t, 0.0100, total, "el total calculado debería ser 0.0100") +} + +func TestShowDailyStats_WithCacheHits(t *testing.T) { + // Arrange + tempDir := t.TempDir() + now := time.Now() + + records := []cost.ActivityRecord{ + { + Timestamp: now, + Command: "suggest", + Provider: "gemini", + Model: "gemini-2.5-flash", + TokensInput: 100, + TokensOutput: 50, + CostUSD: 0.0000, + DurationMs: 50, + CacheHit: true, + }, + { + Timestamp: now.Add(-30 * time.Minute), + Command: "suggest", + Provider: "gemini", + Model: "gemini-2.5-flash", + TokensInput: 100, + TokensOutput: 50, + CostUSD: 0.0015, + DurationMs: 1500, + CacheHit: false, + }, + } + + manager := setupTestManager(t, tempDir, records) + trans := setupTestTranslations(t) + cmd := NewStatsCommand() + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Act + err := cmd.showDailyStats(manager, trans) + + _ = w.Close() + os.Stdout = oldStdout + + output, _ := io.ReadAll(r) + outputStr := string(output) + + // Assert + assert.NoError(t, err) + assert.Contains(t, outputStr, "[CACHE]", "debería indicar cuándo hay un cache hit") + assert.Contains(t, outputStr, "$0.0000", "debería mostrar costo cero para cache hit") + assert.Contains(t, outputStr, "$0.0015", "debería mostrar el total correcto") +} + +func TestShowMonthlyStats_NoActivity(t *testing.T) { + // Arrange + tempDir := t.TempDir() + manager := setupTestManager(t, tempDir, []cost.ActivityRecord{}) + trans := setupTestTranslations(t) + cmd := NewStatsCommand() + + var buf bytes.Buffer + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Act + err := cmd.showMonthlyStats(manager, trans) + + _ = w.Close() + os.Stdout = oldStdout + _, _ = io.Copy(&buf, r) + + output := buf.String() + + // Assert + assert.NoError(t, err) + assert.Contains(t, output, "No activity recorded", "debería indicar que no hay actividad") + assert.Contains(t, output, "━", "debería contener separadores visuales") +} + +func TestShowMonthlyStats_WithActivity(t *testing.T) { + // Arrange + tempDir := t.TempDir() + now := time.Now() + + records := []cost.ActivityRecord{ + // Día 1 + { + Timestamp: time.Date(now.Year(), now.Month(), 1, 10, 0, 0, 0, time.Local), + Command: "suggest", + CostUSD: 0.0015, + }, + { + Timestamp: time.Date(now.Year(), now.Month(), 1, 15, 0, 0, 0, time.Local), + Command: "summarize-pr", + CostUSD: 0.0025, + }, + // Día 2 + { + Timestamp: time.Date(now.Year(), now.Month(), 2, 10, 0, 0, 0, time.Local), + Command: "suggest", + CostUSD: 0.0010, + }, + { + Timestamp: now.AddDate(0, -1, 0), + Command: "suggest", + CostUSD: 0.0050, + }, + } + + manager := setupTestManager(t, tempDir, records) + trans := setupTestTranslations(t) + cmd := NewStatsCommand() + + var buf bytes.Buffer + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Act + err := cmd.showMonthlyStats(manager, trans) + + _ = w.Close() + os.Stdout = oldStdout + _, _ = io.Copy(&buf, r) + + outputStr := buf.String() + + // Assert + assert.NoError(t, err) + + day1 := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.Local).Format("2006-01-02") + day2 := time.Date(now.Year(), now.Month(), 2, 0, 0, 0, 0, time.Local).Format("2006-01-02") + + assert.Contains(t, outputStr, day1, "debería mostrar el día 1") + assert.Contains(t, outputStr, day2, "debería mostrar el día 2") + assert.Contains(t, outputStr, "$0.0040", "debería mostrar el total del día 1 (0.0015 + 0.0025)") + assert.Contains(t, outputStr, "$0.0010", "debería mostrar el total del día 2") + assert.Contains(t, outputStr, "━", "debería contener separadores visuales") + + total, err := manager.GetMonthlyTotal() + assert.NoError(t, err) + assert.Equal(t, 0.0050, total, "el total mensual debería ser 0.0050") +} + +func TestShowMonthlyStats_GroupsByDay(t *testing.T) { + // Arrange + tempDir := t.TempDir() + now := time.Now() + sameDay := time.Date(now.Year(), now.Month(), 15, 0, 0, 0, 0, time.Local) + + records := []cost.ActivityRecord{ + {Timestamp: sameDay.Add(1 * time.Hour), Command: "suggest", CostUSD: 0.0010}, + {Timestamp: sameDay.Add(5 * time.Hour), Command: "suggest", CostUSD: 0.0020}, + {Timestamp: sameDay.Add(10 * time.Hour), Command: "summarize-pr", CostUSD: 0.0030}, + {Timestamp: sameDay.Add(20 * time.Hour), Command: "suggest", CostUSD: 0.0005}, + } + + manager := setupTestManager(t, tempDir, records) + trans := setupTestTranslations(t) + cmd := NewStatsCommand() + + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Act + err := cmd.showMonthlyStats(manager, trans) + + _ = w.Close() + os.Stdout = oldStdout + + output, _ := io.ReadAll(r) + outputStr := string(output) + + // Assert + assert.NoError(t, err) + + expectedDay := sameDay.Format("2006-01-02") + assert.Contains(t, outputStr, expectedDay, "debería mostrar el día agrupado") + assert.Contains(t, outputStr, "$0.0065", "debería sumar todos los costos del día (0.0010+0.0020+0.0030+0.0005)") +} + +func TestShowDailyStats_FormatsTime(t *testing.T) { + // Arrange + tempDir := t.TempDir() + now := time.Now() + specificTime := time.Date(now.Year(), now.Month(), now.Day(), 14, 30, 0, 0, time.Local) + + records := []cost.ActivityRecord{ + { + Timestamp: specificTime, + Command: "suggest", + Provider: "gemini", + Model: "gemini-2.5-flash", + CostUSD: 0.0015, + CacheHit: false, + }, + } + + manager := setupTestManager(t, tempDir, records) + trans := setupTestTranslations(t) + cmd := NewStatsCommand() + + var buf bytes.Buffer + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + // Act + err := cmd.showDailyStats(manager, trans) + + _ = w.Close() + os.Stdout = oldStdout + _, _ = io.Copy(&buf, r) + + // Assert + assert.NoError(t, err) + assert.Contains(t, buf.String(), "14:30", "debería formatear la hora como HH:MM") + assert.Contains(t, buf.String(), "suggest", "debería mostrar el comando") +} + +func setupTestManager(t *testing.T, tempDir string, records []cost.ActivityRecord) *cost.Manager { + t.Helper() + + originalHome := os.Getenv("HOME") + _ = os.Setenv("HOME", tempDir) + t.Cleanup(func() { + _ = os.Setenv("HOME", originalHome) + }) + + trans := setupTestTranslations(t) + manager, err := cost.NewManager(0, trans) + require.NoError(t, err, "no debería fallar al crear el manager de prueba") + + for _, record := range records { + err := manager.SaveActivity(record) + require.NoError(t, err, "no debería fallar al guardar actividad de prueba") + } + + return manager +} + +func setupTestTranslations(t *testing.T) *i18n.Translations { + t.Helper() + + localesPath := filepath.Join("..", "..", "..", "i18n", "locales") + trans, err := i18n.NewTranslations("en", localesPath) + require.NoError(t, err, "no debería fallar al crear traducciones de prueba") + + return trans +} + +func TestStatsCommand_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Saltando test de integración en modo short") + } + + // Arrange + tempDir := t.TempDir() + originalHome := os.Getenv("HOME") + _ = os.Setenv("HOME", tempDir) + t.Cleanup(func() { + _ = os.Setenv("HOME", originalHome) + }) + + trans := setupTestTranslations(t) + manager, err := cost.NewManager(10.0, trans) + require.NoError(t, err) + + now := time.Now() + testRecords := []cost.ActivityRecord{ + { + Timestamp: now, + Command: "suggest", + Provider: "gemini", + Model: "gemini-2.5-flash", + TokensInput: 100, + TokensOutput: 50, + CostUSD: 0.0015, + DurationMs: 1500, + CacheHit: false, + Hash: "test-hash-1", + }, + } + + for _, record := range testRecords { + err := manager.SaveActivity(record) + require.NoError(t, err) + } + + cmd := NewStatsCommand() + + // Act - Daily Stats + var dailyBuf bytes.Buffer + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + errDaily := cmd.showDailyStats(manager, trans) + + _ = w.Close() + os.Stdout = oldStdout + _, _ = io.Copy(&dailyBuf, r) + + // Assert + assert.NoError(t, errDaily) + assert.NotEmpty(t, dailyBuf.String(), "debería generar output para estadísticas diarias") + + // Act - Monthly Stats + var monthlyBuf bytes.Buffer + r2, w2, _ := os.Pipe() + os.Stdout = w2 + + errMonthly := cmd.showMonthlyStats(manager, trans) + + _ = w2.Close() + os.Stdout = oldStdout + _, _ = io.Copy(&monthlyBuf, r2) + + // Assert + assert.NoError(t, errMonthly) + assert.NotEmpty(t, monthlyBuf.String(), "debería generar output para estadísticas mensuales") +} + +func TestShowDailyStats_HandlesManagerErrors(t *testing.T) { + // Arrange + tempDir := t.TempDir() + trans := setupTestTranslations(t) + cmd := NewStatsCommand() + + manager := setupTestManager(t, tempDir, []cost.ActivityRecord{}) + + historyPath := filepath.Join(tempDir, ".matecommit", "history.json") + err := os.WriteFile(historyPath, []byte("invalid json{{{"), 0644) + require.NoError(t, err) + + // Act + err = cmd.showDailyStats(manager, trans) + + // Assert + assert.Error(t, err, "debería retornar error cuando el historial está corrupto") +} + +func TestShowMonthlyStats_HandlesManagerErrors(t *testing.T) { + // Arrange + tempDir := t.TempDir() + trans := setupTestTranslations(t) + cmd := NewStatsCommand() + + manager := setupTestManager(t, tempDir, []cost.ActivityRecord{}) + + historyPath := filepath.Join(tempDir, ".matecommit", "history.json") + err := os.WriteFile(historyPath, []byte("corrupted"), 0644) + require.NoError(t, err) + + // Act + err = cmd.showMonthlyStats(manager, trans) + + // Assert + assert.Error(t, err, "debería retornar error cuando el historial está corrupto") +} diff --git a/internal/infrastructure/ai/cost_wrapper_test.go b/internal/infrastructure/ai/cost_wrapper_test.go new file mode 100644 index 0000000..9adbbb2 --- /dev/null +++ b/internal/infrastructure/ai/cost_wrapper_test.go @@ -0,0 +1,282 @@ +package ai + +import ( + "context" + "io" + "os" + "testing" + "time" + + "github.com/Tomas-vilte/MateCommit/internal/domain/models" + "github.com/Tomas-vilte/MateCommit/internal/i18n" + "github.com/Tomas-vilte/MateCommit/internal/services/cost" + "github.com/stretchr/testify/mock" +) + +type mockProvider struct { + mock.Mock +} + +func (m *mockProvider) GenerateCommitMessage(ctx context.Context, diff string) (string, error) { + args := m.Called(ctx, diff) + return args.String(0), args.Error(1) +} + +func (m *mockProvider) GenerateReleaseNotes(ctx context.Context, tag string, commits []models.Commit) (string, error) { + args := m.Called(ctx, tag, commits) + return args.String(0), args.Error(1) +} + +func (m *mockProvider) GeneratePullRequestDescription(ctx context.Context, pr models.PullRequest) (string, error) { + args := m.Called(ctx, pr) + return args.String(0), args.Error(1) +} + +func (m *mockProvider) GetProviderName() string { + args := m.Called() + return args.String(0) +} + +func (m *mockProvider) GetModelName() string { + args := m.Called() + return args.String(0) +} + +func (m *mockProvider) CountTokens(ctx context.Context, text string) (int, error) { + args := m.Called(ctx, text) + return args.Int(0), args.Error(1) +} + +func setupTestWrapper(t *testing.T, budget float64) (*CostAwareWrapper, *mockProvider, string) { + tempHome, err := os.MkdirTemp("", "matecommit-home-*") + if err != nil { + t.Fatalf("failed to create temp home: %v", err) + } + + oldHome := os.Getenv("HOME") + _ = os.Setenv("HOME", tempHome) + t.Cleanup(func() { + _ = os.Setenv("HOME", oldHome) + if err := os.RemoveAll(tempHome); err != nil { + t.Errorf("failed to remove temp home: %v", err) + } + }) + + trans, err := i18n.NewTranslations("en", "") + if err != nil { + t.Fatalf("failed to load translations: %v", err) + } + + mockP := new(mockProvider) + + cfg := WrapperConfig{ + Provider: mockP, + BudgetDaily: budget, + Trans: trans, + EstimatedOutputTokens: 200, + SkipConfirmation: true, + } + + wrapper, err := NewCostAwareWrapper(cfg) + if err != nil { + t.Fatalf("NewCostAwareWrapper() error = %v", err) + } + + return wrapper, mockP, tempHome +} + +func TestNewCostAwareWrapper(t *testing.T) { + w, _, _ := setupTestWrapper(t, 1.0) + if w == nil { + t.Fatal("expected wrapper to be non-nil") + } +} + +func TestCostAwareWrapper_WrapGenerate_CacheHit(t *testing.T) { + // Arrange + w, mockP, _ := setupTestWrapper(t, 1.0) + ctx := context.Background() + prompt := "test prompt" + command := "test-cmd" + expectedResp := "cached response" + + mockP.On("GetProviderName").Return("gemini") + mockP.On("GetModelName").Return("gemini-1.5-flash") + + contentHash := w.cache.GenerateHash("gemini" + "gemini-1.5-flash" + prompt) + _ = w.cache.Set(contentHash, expectedResp) + + // Act + resp, usage, err := w.WrapGenerate(ctx, command, prompt, func(ctx context.Context, model, p string) (interface{}, *models.TokenUsage, error) { + t.Fatal("generateFn should not be called on cache hit") + return nil, nil, nil + }) + + // Assert + if err != nil { + t.Fatalf("WrapGenerate() error = %v", err) + } + if !usage.CacheHit { + t.Error("expected CacheHit to be true") + } + if resp.(string) != expectedResp { + t.Errorf("expected resp %q, got %v", expectedResp, resp) + } + mockP.AssertExpectations(t) +} + +func TestCostAwareWrapper_WrapGenerate_NormalFlow(t *testing.T) { + // Arrange + w, mockP, _ := setupTestWrapper(t, 1.0) + ctx := context.Background() + prompt := "test prompt" + command := "test-cmd" + expectedResp := "fresh response" + expectedUsage := &models.TokenUsage{InputTokens: 50, OutputTokens: 100} + + mockP.On("GetProviderName").Return("gemini") + mockP.On("GetModelName").Return("gemini-1.5-flash") + mockP.On("CountTokens", mock.Anything, mock.Anything).Return(100, nil) + + // Act + resp, usage, err := w.WrapGenerate(ctx, command, prompt, func(ctx context.Context, model, p string) (interface{}, *models.TokenUsage, error) { + return expectedResp, expectedUsage, nil + }) + + // Assert + if err != nil { + t.Fatalf("WrapGenerate() error = %v", err) + } + if usage.CacheHit { + t.Error("expected CacheHit to be false") + } + if resp.(string) != expectedResp { + t.Errorf("expected resp %q, got %v", expectedResp, resp) + } + if usage.InputTokens != 50 || usage.OutputTokens != 100 { + t.Errorf("unexpected usage: %+v", usage) + } + + contentHash := w.cache.GenerateHash("gemini" + "gemini-1.5-flash" + prompt) + _, hit, _ := w.cache.Get(contentHash) + if !hit { + t.Error("expected response to be cached") + } + mockP.AssertExpectations(t) +} + +func TestCostAwareWrapper_WrapGenerate_BudgetExceeded(t *testing.T) { + // Arrange + w, mockP, _ := setupTestWrapper(t, 0.0001) + ctx := context.Background() + + mockP.On("GetProviderName").Return("gemini") + mockP.On("GetModelName").Return("gemini-1.5-flash") + mockP.On("CountTokens", mock.Anything, mock.Anything).Return(100, nil) + + _ = w.manager.SaveActivity(cost.ActivityRecord{ + Timestamp: time.Now(), + CostUSD: 1.0, + }) + + // Act + _, _, err := w.WrapGenerate(ctx, "cmd", "prompt", func(ctx context.Context, model, p string) (interface{}, *models.TokenUsage, error) { + return "should not run", nil, nil + }) + + // Assert + if err == nil { + t.Error("expected error due to budget exceeded, got nil") + } + mockP.AssertExpectations(t) +} + +func TestCostAwareWrapper_AskUserConfirmation(t *testing.T) { + w, mockP, _ := setupTestWrapper(t, 1.0) + w.skipConfirmation = false + + tests := []struct { + name string + input string + wantModel string + wantProc bool + }{ + {"Accept suggested", "y\n", "suggested", true}, + {"Accept suggested caps", "YES\n", "suggested", true}, + {"Stay with original", "stay\n", "original", true}, + {"Cancel", "n\n", "", false}, + {"Invalid stays as cancel", "random\n", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockP.ExpectedCalls = nil + mockP.On("GetModelName").Return("gemini-1.5-flash") + + r, win, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + oldStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = oldStdin }() + + go func() { + _, _ = io.WriteString(win, tt.input) + _ = win.Close() + }() + + model, proceed := w.askUserConfirmation(0.01, 100, 100, "gemini-3-flash-preview") + + if model != tt.wantModel { + t.Errorf("got model %q, want %q", model, tt.wantModel) + } + if proceed != tt.wantProc { + t.Errorf("got proceed %v, want %v", proceed, tt.wantProc) + } + mockP.AssertExpectations(t) + }) + } +} + +func TestCostAwareWrapper_WrapGenerate_SuggestedModel(t *testing.T) { + // Arrange + w, mockP, _ := setupTestWrapper(t, 1.0) + w.skipConfirmation = false + ctx := context.Background() + prompt := "large prompt" + + mockP.On("GetProviderName").Return("gemini") + mockP.On("GetModelName").Return("gemini-1.5-flash") + mockP.On("CountTokens", mock.Anything, mock.Anything).Return(20000, nil) + + r, win, _ := os.Pipe() + oldStdin := os.Stdin + os.Stdin = r + defer func() { os.Stdin = oldStdin }() + go func() { + _, _ = io.WriteString(win, "y\n") + _ = win.Close() + }() + + // Act + var usedModel string + _, usage, err := w.WrapGenerate(ctx, "summarize", prompt, func(ctx context.Context, model, p string) (interface{}, *models.TokenUsage, error) { + usedModel = model + return "ok", &models.TokenUsage{InputTokens: 20000, OutputTokens: 200}, nil + }) + + // Assert + if err != nil { + t.Fatalf("WrapGenerate() error = %v", err) + } + expectedModel := "gemini-3-flash-preview" + if usedModel != expectedModel { + t.Errorf("expected suggested model %q to be used, got %q", expectedModel, usedModel) + } + if usage.Model != expectedModel { + t.Errorf("expected usage model %q, got %q", expectedModel, usage.Model) + } + mockP.AssertExpectations(t) +} diff --git a/internal/infrastructure/ai/gemini/gemini_factory_test.go b/internal/infrastructure/ai/gemini/gemini_factory_test.go new file mode 100644 index 0000000..1081a36 --- /dev/null +++ b/internal/infrastructure/ai/gemini/gemini_factory_test.go @@ -0,0 +1,67 @@ +package gemini + +import ( + "context" + "testing" + + "github.com/Tomas-vilte/MateCommit/internal/config" + "github.com/Tomas-vilte/MateCommit/internal/i18n" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGeminiProviderFactory(t *testing.T) { + factory := NewGeminiProviderFactory() + trans, err := i18n.NewTranslations("en", "../../../i18n/locales/") + require.NoError(t, err) + + t.Run("Name", func(t *testing.T) { + assert.Equal(t, "gemini", factory.Name()) + }) + + t.Run("ValidateConfig - Valid", func(t *testing.T) { + cfg := &config.Config{ + AIProviders: map[string]config.AIProviderConfig{ + "gemini": {APIKey: "test-key"}, + }, + } + err := factory.ValidateConfig(cfg) + assert.NoError(t, err) + }) + + t.Run("ValidateConfig - Missing Provider", func(t *testing.T) { + cfg := &config.Config{ + AIProviders: map[string]config.AIProviderConfig{}, + } + err := factory.ValidateConfig(cfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "configuracion de gemini no encontrada") + }) + + t.Run("ValidateConfig - Missing API Key", func(t *testing.T) { + cfg := &config.Config{ + AIProviders: map[string]config.AIProviderConfig{ + "gemini": {APIKey: ""}, + }, + } + err := factory.ValidateConfig(cfg) + assert.Error(t, err) + assert.Contains(t, err.Error(), "gemini API key es requerida") + }) + + t.Run("CreateServices - Missing API Key Errors", func(t *testing.T) { + cfg := &config.Config{ + AIProviders: map[string]config.AIProviderConfig{}, + } + ctx := context.Background() + + _, err := factory.CreateCommitSummarizer(ctx, cfg, trans) + assert.Error(t, err) + + _, err = factory.CreatePRSummarizer(ctx, cfg, trans) + assert.Error(t, err) + + _, err = factory.CreateIssueContentGenerator(ctx, cfg, trans) + assert.Error(t, err) + }) +} diff --git a/internal/infrastructure/ai/gemini/helper_test.go b/internal/infrastructure/ai/gemini/helper_test.go new file mode 100644 index 0000000..bd232dc --- /dev/null +++ b/internal/infrastructure/ai/gemini/helper_test.go @@ -0,0 +1,119 @@ +package gemini + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "google.golang.org/genai" +) + +func TestExtractUsage(t *testing.T) { + t.Run("nil response", func(t *testing.T) { + assert.Nil(t, extractUsage(nil)) + }) + + t.Run("nil UsageMetadata", func(t *testing.T) { + resp := &genai.GenerateContentResponse{} + assert.Nil(t, extractUsage(resp)) + }) + + t.Run("valid UsageMetadata", func(t *testing.T) { + resp := &genai.GenerateContentResponse{ + UsageMetadata: &genai.GenerateContentResponseUsageMetadata{ + PromptTokenCount: 10, + CandidatesTokenCount: 20, + TotalTokenCount: 30, + }, + } + usage := extractUsage(resp) + assert.NotNil(t, usage) + assert.Equal(t, 10, usage.InputTokens) + assert.Equal(t, 20, usage.OutputTokens) + assert.Equal(t, 30, usage.TotalTokens) + }) +} + +func TestGetGenerateConfig(t *testing.T) { + t.Run("default config", func(t *testing.T) { + cfg := GetGenerateConfig("gemini-1.5-flash", "") + assert.NotNil(t, cfg) + assert.Equal(t, float32(0.3), *cfg.Temperature) + assert.Empty(t, cfg.ResponseMIMEType) + assert.Nil(t, cfg.ThinkingConfig) + }) + + t.Run("json response type", func(t *testing.T) { + cfg := GetGenerateConfig("gemini-1.5-flash", "application/json") + assert.Equal(t, "application/json", cfg.ResponseMIMEType) + }) + + t.Run("Thinking Mode for gemini-3", func(t *testing.T) { + cfg := GetGenerateConfig("gemini-3-flash-preview", "") + assert.NotNil(t, cfg.ThinkingConfig) + assert.True(t, cfg.ThinkingConfig.IncludeThoughts) + assert.Equal(t, genai.ThinkingLevelHigh, cfg.ThinkingConfig.ThinkingLevel) + }) +} + +func TestExtractJSON(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "pure JSON object", + input: `{"key": "value"}`, + expected: `{"key": "value"}`, + }, + { + name: "pure JSON array", + input: `[{"key": "value"}]`, + expected: `[{"key": "value"}]`, + }, + { + name: "markdown code block with json tag", + input: "Sure, here is the JSON:\n```json\n{\"key\": \"value\"}\n```\nHope it helps!", + expected: `{"key": "value"}`, + }, + { + name: "markdown code block without tag", + input: "```\n[1, 2, 3]\n```", + expected: `[1, 2, 3]`, + }, + { + name: "text before and after JSON object", + input: "Some thinking content... {\"title\": \"fix bug\"} more text", + expected: `{"title": "fix bug"}`, + }, + { + name: "text before and after JSON array", + input: "Reasoning: ... [{\"title\": \"feat\"}] end", + expected: `[{"title": "feat"}]`, + }, + { + name: "balanced matching with stray brackets in prose", + input: "Thoughts [about stuff]: {\"key\": \"value\"} More text [end]", + expected: `{"key": "value"}`, + }, + { + name: "unescaped newlines in JSON string", + input: `{"desc": "This is a +multi-line +description"}`, + expected: `{"desc": "This is a\nmulti-line\ndescription"}`, + }, + { + name: "empty input", + input: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExtractJSON(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/infrastructure/cache/cache_test.go b/internal/infrastructure/cache/cache_test.go new file mode 100644 index 0000000..aebc591 --- /dev/null +++ b/internal/infrastructure/cache/cache_test.go @@ -0,0 +1,227 @@ +package cache + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func setupTestCache(t *testing.T, ttl time.Duration) (*Cache, string) { + tempDir, err := os.MkdirTemp("", "matecommit-cache-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + c := &Cache{ + cacheDir: tempDir, + ttl: ttl, + } + + return c, tempDir +} + +func TestNewCache(t *testing.T) { + // Act + c, err := NewCache(1 * time.Hour) + + // Assert + if err != nil { + t.Fatalf("NewCache() error = %v", err) + } + if c == nil { + t.Fatal("NewCache() returned nil") + } + if _, err := os.Stat(c.cacheDir); os.IsNotExist(err) { + t.Errorf("cache directory %s was not created", c.cacheDir) + } +} + +func TestCache_GenerateHash(t *testing.T) { + // Arrange + c := &Cache{} + content := "test content" + + // Act + hash1 := c.GenerateHash(content) + hash2 := c.GenerateHash(content) + hash3 := c.GenerateHash("different content") + + // Assert + if hash1 != hash2 { + t.Errorf("GenerateHash() returned different results for same content") + } + if hash1 == hash3 { + t.Errorf("GenerateHash() returned same result for different content") + } + if len(hash1) != 64 { + t.Errorf("GenerateHash() length = %d, want 64", len(hash1)) + } +} + +func TestCache_SetAndGet(t *testing.T) { + // Arrange + c, tempDir := setupTestCache(t, 1*time.Hour) + defer func() { + if err := os.RemoveAll(tempDir); err != nil { + t.Errorf("RemoveAll() error = %v", err) + } + }() + type testData struct { + Name string `json:"name"` + } + data := testData{Name: "MateCommit"} + hash := c.GenerateHash("matecommit-key") + + // Act - Set + err := c.Set(hash, data) + if err != nil { + t.Fatalf("Set() error = %v", err) + } + + // Act - Get + resp, found, err := c.Get(hash) + + // Assert + if err != nil { + t.Fatalf("Get() error = %v", err) + } + if !found { + t.Fatal("Get() returned found = false, want true") + } + + var got testData + _ = json.Unmarshal(resp, &got) + if got.Name != data.Name { + t.Errorf("Get() data = %v, want %v", got.Name, data.Name) + } +} + +func TestCache_Get_NotFound(t *testing.T) { + // Arrange + c, tempDir := setupTestCache(t, 1*time.Hour) + defer func() { + if err := os.RemoveAll(tempDir); err != nil { + t.Errorf("RemoveAll() error = %v", err) + } + }() + // Act + _, found, err := c.Get("non-existent-hash") + + // Assert + if err != nil { + t.Errorf("Get() error = %v, want nil", err) + } + if found { + t.Errorf("Get() found = true, want false") + } +} + +func TestCache_Get_Expired(t *testing.T) { + // Arrange + c, tempDir := setupTestCache(t, 10*time.Millisecond) + defer func() { + if err := os.RemoveAll(tempDir); err != nil { + t.Errorf("RemoveAll() error = %v", err) + } + }() + hash := "expired-hash" + _ = c.Set(hash, "some data") + + time.Sleep(20 * time.Millisecond) + + // Act + _, found, err := c.Get(hash) + + // Assert + if err != nil { + t.Errorf("Get() error = %v, want nil", err) + } + if found { + t.Errorf("Get() found = true, want false for expired cache") + } + + filePath := filepath.Join(tempDir, hash+".json") + if _, err := os.Stat(filePath); !os.IsNotExist(err) { + t.Errorf("expired cache file was not deleted") + } +} + +func TestCache_CleanExpired(t *testing.T) { + // Arrange + c, tempDir := setupTestCache(t, 1*time.Hour) + defer func() { + if err := os.RemoveAll(tempDir); err != nil { + t.Errorf("RemoveAll() error = %v", err) + } + }() + _ = c.Set("fresh", "data") + + oldHash := "old" + _ = c.Set(oldHash, "data") + oldFilePath := filepath.Join(tempDir, oldHash+".json") + oldTime := time.Now().Add(-2 * time.Hour) + _ = os.Chtimes(oldFilePath, oldTime, oldTime) + + // Act + err := c.CleanExpired() + + // Assert + if err != nil { + t.Errorf("CleanExpired() error = %v", err) + } + + if _, err := os.Stat(oldFilePath); !os.IsNotExist(err) { + t.Errorf("old file was not cleaned up") + } + + freshFilePath := filepath.Join(tempDir, "fresh.json") + if _, err := os.Stat(freshFilePath); os.IsNotExist(err) { + t.Errorf("fresh file was incorrectly cleaned up") + } +} + +func TestCache_Clean(t *testing.T) { + // Arrange + c, tempDir := setupTestCache(t, 1*time.Hour) + + _ = c.Set("hash1", "data") + _ = c.Set("hash2", "data") + + // Act + err := c.Clean() + + // Assert + if err != nil { + t.Errorf("Clean() error = %v", err) + } + + if _, err := os.Stat(tempDir); !os.IsNotExist(err) { + t.Errorf("cache directory was not removed by Clean()") + } +} + +func TestCache_Get_UnmarshalError(t *testing.T) { + // Arrange + c, tempDir := setupTestCache(t, 1*time.Hour) + defer func() { + if err := os.RemoveAll(tempDir); err != nil { + t.Errorf("RemoveAll() error = %v", err) + } + }() + hash := "corrupt-hash" + filePath := filepath.Join(tempDir, hash+".json") + _ = os.WriteFile(filePath, []byte("invalid json{"), 0644) + + // Act + _, found, err := c.Get(hash) + + // Assert + if err == nil { + t.Error("Get() error = nil, want error for invalid JSON") + } + if found { + t.Error("Get() found = true, want false for invalid JSON") + } +} diff --git a/internal/services/cost/calculator_test.go b/internal/services/cost/calculator_test.go new file mode 100644 index 0000000..4361f8f --- /dev/null +++ b/internal/services/cost/calculator_test.go @@ -0,0 +1,177 @@ +package cost + +import ( + "strings" + "testing" +) + +func TestNewCalculator(t *testing.T) { + // Act + calc := NewCalculator() + + // Assert + if calc == nil { + t.Fatal("NewCalculator() returned nil") + } +} + +func TestCalculator_EstimateCost(t *testing.T) { + tests := []struct { + name string + provider string + model string + inputTokens int + outputTokens int + want float64 + }{ + { + name: "Gemini 1.5 Flash - exact match", + provider: "gemini", + model: "gemini-1.5-flash", + inputTokens: 1_000_000, + outputTokens: 1_000_000, + want: 0.075 + 0.30, + }, + { + name: "Gemini 1.5 Flash - case insensitive", + provider: "GEMINI", + model: "GEMINI-1.5-FLASH", + inputTokens: 1_000_000, + outputTokens: 1_000_000, + want: 0.075 + 0.30, + }, + { + name: "Gemini - partial model match (pro-preview)", + provider: "gemini", + model: "gemini-3-pro-preview-001", + inputTokens: 1_000_000, + outputTokens: 1_000_000, + want: 2.00 + 12.00, + }, + { + name: "OpenAI - GPT-4o mini", + provider: "openai", + model: "gpt-4o-mini", + inputTokens: 1_000_000, + outputTokens: 1_000_000, + want: 0.15 + 0.60, + }, + { + name: "Anthropic - Claude 3.5 Sonnet", + provider: "anthropic", + model: "claude-3-5-sonnet", + inputTokens: 1_000_000, + outputTokens: 1_000_000, + want: 3.00 + 15.00, + }, + { + name: "Unknown provider", + provider: "unknown", + model: "model", + inputTokens: 1000, + outputTokens: 1000, + want: 0, + }, + { + name: "Unknown model for known provider", + provider: "gemini", + model: "non-existent-model", + inputTokens: 1000, + outputTokens: 1000, + want: 0, + }, + { + name: "Zero tokens should result in zero cost", + provider: "gemini", + model: "gemini-1.5-flash", + inputTokens: 0, + outputTokens: 0, + want: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Arrange + c := NewCalculator() + + // Act + got := c.EstimateCost(tt.provider, tt.model, tt.inputTokens, tt.outputTokens) + + // Assert + if got != tt.want { + t.Errorf("Calculator.EstimateCost() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCalculator_GetPricing(t *testing.T) { + // Arrange + c := NewCalculator() + + t.Run("Valid provider and model", func(t *testing.T) { + // Act + pricing, err := c.GetPricing("gemini", "gemini-1.5-flash") + + // Assert + if err != nil { + t.Errorf("GetPricing() error = %v, wantErr %v", err, false) + } + if pricing.InputPricePerMillion != 0.075 { + t.Errorf("GetPricing() InputPricePerMillion = %v, want %v", pricing.InputPricePerMillion, 0.075) + } + }) + + t.Run("Invalid provider", func(t *testing.T) { + // Act + _, err := c.GetPricing("invalid", "model") + + // Assert + if err == nil { + t.Error("GetPricing() error nil, want error for invalid provider") + } + if !strings.Contains(err.Error(), "proveedor") { + t.Errorf("Error message %q doesn't mention provider", err.Error()) + } + }) + + t.Run("Invalid model", func(t *testing.T) { + // Act + _, err := c.GetPricing("gemini", "invalid-model") + + // Assert + if err == nil { + t.Error("GetPricing() error nil, want error for invalid model") + } + if !strings.Contains(err.Error(), "modelo") { + t.Errorf("Error message %q doesn't mention model", err.Error()) + } + }) +} + +func TestCalculator_AddPricing(t *testing.T) { + // Arrange + c := NewCalculator() + provider := "custom-provider" + model := "custom-model" + table := PricingTable{InputPricePerMillion: 1.0, OutputPricePerMillion: 2.0} + + // Act + c.AddPricing(provider, model, table) + + // Assert + gotTable, err := c.GetPricing(provider, model) + if err != nil { + t.Fatalf("Failed to get added pricing: %v", err) + } + if gotTable != table { + t.Errorf("GetPricing() = %v, want %v", gotTable, table) + } + + cost := c.EstimateCost(provider, model, 1_000_000, 1_000_000) + wantCost := 1.0 + 2.0 + if cost != wantCost { + t.Errorf("EstimateCost() after AddPricing = %v, want %v", cost, wantCost) + } +} diff --git a/internal/services/cost/manager_test.go b/internal/services/cost/manager_test.go new file mode 100644 index 0000000..32ca8c7 --- /dev/null +++ b/internal/services/cost/manager_test.go @@ -0,0 +1,237 @@ +package cost + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Tomas-vilte/MateCommit/internal/i18n" +) + +func setupTestManager(t *testing.T, budgetDaily float64) (*Manager, string) { + tempDir, err := os.MkdirTemp("", "matecommit-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + + trans, err := i18n.NewTranslations("en", "") + if err != nil { + t.Fatalf("failed to load translations: %v", err) + } + + m := &Manager{ + historyPath: filepath.Join(tempDir, "history.json"), + budgetDaily: budgetDaily, + trans: trans, + } + + return m, tempDir +} + +func TestNewManager(t *testing.T) { + // Act + m, err := NewManager(1.0, nil) + + // Assert + if err != nil { + t.Fatalf("NewManager() error = %v", err) + } + if m == nil { + t.Fatal("NewManager() returned nil") + } + if m.budgetDaily != 1.0 { + t.Errorf("expected budget 1.0, got %v", m.budgetDaily) + } +} + +func TestManager_SaveAndLoadActivity(t *testing.T) { + // Arrange + m, tempDir := setupTestManager(t, 1.0) + defer func() { + if err := os.RemoveAll(tempDir); err != nil { + t.Errorf("RemoveAll() error = %v", err) + } + }() + record := ActivityRecord{ + Timestamp: time.Now(), + Command: "generate-commit", + Provider: "gemini", + Model: "gemini-1.5-flash", + TokensInput: 1000, + TokensOutput: 500, + CostUSD: 0.001, + } + + // Act + err := m.SaveActivity(record) + if err != nil { + t.Fatalf("SaveActivity() error = %v", err) + } + + history, err := m.GetHistory() + + // Assert + if err != nil { + t.Fatalf("GetHistory() error = %v", err) + } + if len(history) != 1 { + t.Fatalf("expected 1 record, got %v", len(history)) + } + if history[0].Command != record.Command { + t.Errorf("expected command %s, got %s", record.Command, history[0].Command) + } +} + +func TestManager_Totals(t *testing.T) { + // Arrange + m, tempDir := setupTestManager(t, 10.0) + defer func() { + if err := os.RemoveAll(tempDir); err != nil { + t.Errorf("RemoveAll() error = %v", err) + } + }() + now := time.Now() + todayRecord := ActivityRecord{Timestamp: now, CostUSD: 1.5} + yesterdayRecord := ActivityRecord{Timestamp: now.AddDate(0, 0, -1), CostUSD: 2.0} + lastMonthRecord := ActivityRecord{Timestamp: now.AddDate(0, -1, 0), CostUSD: 5.0} + + records := []ActivityRecord{todayRecord, yesterdayRecord, lastMonthRecord} + data, _ := json.Marshal(records) + _ = os.WriteFile(m.historyPath, data, 0644) + + // Act + dailyTotal, errDaily := m.GetDailyTotal() + monthlyTotal, errMonthly := m.GetMonthlyTotal() + + // Assert + if errDaily != nil { + t.Errorf("GetDailyTotal() error = %v", errDaily) + } + if dailyTotal != 1.5 { + t.Errorf("dailyTotal = %v, want 1.5", dailyTotal) + } + + if errMonthly != nil { + t.Errorf("GetMonthlyTotal() error = %v", errMonthly) + } + expectedMonthly := 1.5 + if yesterdayRecord.Timestamp.Format("2006-01") == now.Format("2006-01") { + expectedMonthly += 2.0 + } + + if monthlyTotal != expectedMonthly { + t.Errorf("monthlyTotal = %v, want %v", monthlyTotal, expectedMonthly) + } +} + +func TestManager_CheckBudget(t *testing.T) { + tests := []struct { + name string + budget float64 + existingSpend float64 + estimated float64 + wantErr bool + }{ + { + name: "Budget not exceeded", + budget: 1.0, + existingSpend: 0.1, + estimated: 0.1, + wantErr: false, + }, + { + name: "Budget exceeded exactly", + budget: 1.0, + existingSpend: 0.5, + estimated: 0.6, + wantErr: true, + }, + { + name: "Budget exceeded by far", + budget: 1.0, + existingSpend: 1.1, + estimated: 0.1, + wantErr: true, + }, + { + name: "Zero budget disables check", + budget: 0, + existingSpend: 10.0, + estimated: 1.0, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Arrange + m, tempDir := setupTestManager(t, tt.budget) + defer func() { + if err := os.RemoveAll(tempDir); err != nil { + t.Errorf("RemoveAll() error = %v", err) + } + }() + if tt.existingSpend > 0 { + record := ActivityRecord{Timestamp: time.Now(), CostUSD: tt.existingSpend} + _ = m.SaveActivity(record) + } + + // Act + err := m.CheckBudget(tt.estimated) + + // Assert + if (err != nil) != tt.wantErr { + t.Errorf("CheckBudget() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestManager_CheckBudgetAlerts(t *testing.T) { + tests := []struct { + name string + budget float64 + existingSpend float64 + }{ + { + name: "Budget usage 55% (50-75 range)", + budget: 10.0, + existingSpend: 5.5, + }, + { + name: "Budget usage 80% (75-90 range)", + budget: 10.0, + existingSpend: 8.0, + }, + { + name: "Budget usage 95% (90+ range)", + budget: 10.0, + existingSpend: 9.5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Arrange + m, tempDir := setupTestManager(t, tt.budget) + defer func() { + if err := os.RemoveAll(tempDir); err != nil { + t.Errorf("RemoveAll() error = %v", err) + } + }() + + record := ActivityRecord{Timestamp: time.Now(), CostUSD: tt.existingSpend} + _ = m.SaveActivity(record) + + // Act + err := m.CheckBudget(0.01) + + // Assert + if err != nil { + t.Errorf("CheckBudget() unexpected error = %v", err) + } + }) + } +} diff --git a/internal/services/routing/model_selector_test.go b/internal/services/routing/model_selector_test.go new file mode 100644 index 0000000..438c7c9 --- /dev/null +++ b/internal/services/routing/model_selector_test.go @@ -0,0 +1,125 @@ +package routing + +import ( + "testing" +) + +func TestNewModelSelector(t *testing.T) { + // Act + selector := NewModelSelector() + + // Assert + if selector == nil { + t.Fatal("NewModelSelector() returned nil") + } +} + +func TestModelSelector_SelectBestModel(t *testing.T) { + tests := []struct { + name string + operation string + estimatedTokens int + want string + }{ + { + name: "Generate release operation should return high quality model", + operation: "generate-release", + estimatedTokens: 100, + want: "gemini-3-pro-preview", + }, + { + name: "Generate issue operation should return high quality model", + operation: "generate-issue", + estimatedTokens: 100, + want: "gemini-3-pro-preview", + }, + { + name: "High token count should return flash-preview model", + operation: "summarize", + estimatedTokens: 20000, + want: "gemini-3-flash-preview", + }, + { + name: "Boundary token count should return flash-preview model", + operation: "summarize", + estimatedTokens: 15001, + want: "gemini-3-flash-preview", + }, + { + name: "Exact boundary token count should return default model", + operation: "summarize", + estimatedTokens: 15000, + want: "gemini-2.5-flash", + }, + { + name: "Small token count should return default model", + operation: "summarize", + estimatedTokens: 500, + want: "gemini-2.5-flash", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Arrange + m := &ModelSelector{} + + // Act + got := m.SelectBestModel(tt.operation, tt.estimatedTokens) + + // Assert + if got != tt.want { + t.Errorf("ModelSelector.SelectBestModel() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestModelSelector_GetRationale(t *testing.T) { + tests := []struct { + name string + selectedModel string + want string + }{ + { + name: "High quality model rationale", + selectedModel: "gemini-3-pro-preview", + want: "routing.reason_high_quality", + }, + { + name: "Large context model rationale", + selectedModel: "gemini-3-flash-preview", + want: "routing.reason_large", + }, + { + name: "Balance model rationale (from comments, even if mismatched in code constant)", + selectedModel: "gemini-1.5-flash", + want: "routing.reason_balance", + }, + { + name: "Unknown model should return default rationale", + selectedModel: "unknown-model", + want: "routing.reason_default", + }, + { + name: "Default model used in SelectBestModel should return default rationale", + selectedModel: "gemini-2.5-flash", + want: "routing.reason_default", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Arrange + m := &ModelSelector{} + + // Act + got := m.GetRationale(tt.selectedModel) + + // Assert + if got != tt.want { + t.Errorf("ModelSelector.GetRationale() = %v, want %v", got, tt.want) + } + }) + } +}