From 55eff3389a2ed5ba2aa73aa658726fef32a03ca3 Mon Sep 17 00:00:00 2001 From: Enmanuel Date: Fri, 6 Mar 2026 17:11:00 +0000 Subject: [PATCH] feat: add repetition detection fallback to orchestrator pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Se añade un mecanismo de detección de repetición para cortar conversaciones circulares entre agentes cuando hablan sin parar. - Nuevo campo RepetitionThreshold en OrchestrationCfg (schema.go). - Función detectRepetition() compara cada nueva respuesta con las anteriores usando similitud de bigramas (Dice coefficient). - Si la similitud supera el umbral (default 0.6), el pipeline se detiene inmediatamente con un log de warning, antes de gastar una llamada LLM en la evaluación de calidad. - Funciones auxiliares: similarity() y makeBigrams() para el cálculo. Co-Authored-By: Claude Opus 4.6 --- internal/config/schema.go | 9 +-- shell/orchestration/orchestrator.go | 86 +++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/internal/config/schema.go b/internal/config/schema.go index e26b59c..35b5502 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -418,10 +418,11 @@ type SpecialMeta struct { // OrchestrationCfg configures the multi-bot orchestrator. type OrchestrationCfg struct { - MaxIterations int `yaml:"max_iterations"` - QualityThreshold float64 `yaml:"quality_threshold"` - DelegationTimeout time.Duration `yaml:"delegation_timeout"` - Rooms []OrchestratedRoomCfg `yaml:"rooms"` + MaxIterations int `yaml:"max_iterations"` + QualityThreshold float64 `yaml:"quality_threshold"` + DelegationTimeout time.Duration `yaml:"delegation_timeout"` + RepetitionThreshold float64 `yaml:"repetition_threshold"` // 0-1: similarity ratio to detect circular conversations + Rooms []OrchestratedRoomCfg `yaml:"rooms"` } // OrchestratedRoomCfg defines a room managed by the orchestrator. diff --git a/shell/orchestration/orchestrator.go b/shell/orchestration/orchestrator.go index 39d23de..4e34dfb 100644 --- a/shell/orchestration/orchestrator.go +++ b/shell/orchestration/orchestrator.go @@ -331,6 +331,15 @@ func (o *Orchestrator) Route(ctx context.Context, msgCtx decision.MessageContext "iteration", i, ) + // Fallback: detect circular conversations before quality evaluation + if o.detectRepetition(responses) { + o.logger.Warn("repetition detected, stopping pipeline to prevent circular conversation", + "iteration", i+1, + "total_responses", len(responses), + ) + break + } + // Evaluate quality (Fase 3) score := o.evaluate(ctx, msgCtx.Content, response) o.logger.Info("quality evaluated", @@ -462,6 +471,83 @@ func (o *Orchestrator) buildParticipantsList(botIDs []string, exclude string) st return sb.String() } +// detectRepetition checks if a new response is too similar to previous responses, +// indicating a circular conversation that should be stopped. +// Returns true if the conversation should be terminated. +func (o *Orchestrator) detectRepetition(responses []orchestration.BotResponse) bool { + if len(responses) < 2 { + return false + } + + threshold := o.cfg.Orchestration.RepetitionThreshold + if threshold <= 0 { + threshold = 0.6 // default + } + + latest := responses[len(responses)-1].Text + for i := 0; i < len(responses)-1; i++ { + if similarity(latest, responses[i].Text) >= threshold { + return true + } + } + return false +} + +// similarity computes a simple bigram-based similarity ratio between two strings. +// Returns a value between 0.0 (completely different) and 1.0 (identical). +func similarity(a, b string) float64 { + if a == b { + return 1.0 + } + a = strings.ToLower(strings.TrimSpace(a)) + b = strings.ToLower(strings.TrimSpace(b)) + if a == b { + return 1.0 + } + if len(a) < 2 || len(b) < 2 { + return 0.0 + } + + bigramsA := makeBigrams(a) + bigramsB := makeBigrams(b) + + // Count intersection + intersection := 0 + for bg, countA := range bigramsA { + if countB, ok := bigramsB[bg]; ok { + if countA < countB { + intersection += countA + } else { + intersection += countB + } + } + } + + totalA := 0 + for _, c := range bigramsA { + totalA += c + } + totalB := 0 + for _, c := range bigramsB { + totalB += c + } + + if totalA+totalB == 0 { + return 0.0 + } + return float64(2*intersection) / float64(totalA+totalB) +} + +func makeBigrams(s string) map[string]int { + runes := []rune(s) + bgs := make(map[string]int, len(runes)) + for i := 0; i < len(runes)-1; i++ { + bg := string(runes[i : i+2]) + bgs[bg]++ + } + return bgs +} + func truncate(s string, n int) string { runes := []rune(s) if len(runes) <= n {