diff --git a/README.md b/README.md index b669aee..d6f8e9c 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,10 @@ Gateways like LiteLLM or Bifrost unify provider APIs and load-balance — but th | Analyzer | Cost | How it works | |---|---|---| | `heuristic` (default) | free, <1ms | Deterministic signal scoring: code blocks, requirement density, reasoning cues, question fan-out, context depth. Fully explainable — per-signal scores are returned with every decision. | -| `llm` (optional) | free, ~50–200ms | Uses a small local model via Ollama (e.g. `qwen2.5:0.5b`) to classify category and complexity. Smarter on ambiguous prompts, still 100% local and private. Falls back to `heuristic` if Ollama is unavailable. | +| `llm` (optional) | free, ~50–200ms | Uses a small local model via Ollama (e.g. `qwen2.5:0.5b`) to classify category and complexity with few-shot prompting. Smarter on ambiguous prompts, still 100% local and private. Falls back to `heuristic` if Ollama is unavailable. | +| `hybrid` (optional) | free, <1ms + LLM latency | Blends heuristic signals with LLM judgment: heuristic always runs (cheap, never fails), LLM refines complexity via weighted blend and overrides category only when heuristic had low confidence. Combines explainability with LLM's strength on ambiguous prompts. | -A third mode — analyzers trained on real routing outcomes — ships with [Route42 Pro](https://route42.app). +A fourth mode — analyzers trained on real routing outcomes — ships with [Route42 Pro](https://route42.app). ### Providers & models - **Cloud providers:** OpenAI, Anthropic, Google Gemini, Mistral, Groq, DeepSeek, Alibaba, Moonshot, NVIDIA, OpenRouter. @@ -146,10 +147,11 @@ Every response includes the selected model and the analyzer's signal breakdown, ```yaml analyzer: - mode: heuristic # heuristic | llm + mode: heuristic # heuristic | llm | hybrid llm: - model: qwen2.5:0.5b # any small Ollama model + model: qwen2.5:0.5b # any small Ollama model (required for llm and hybrid) timeout_ms: 1500 # falls back to heuristic on timeout + hybrid_weight: 0.5 # 0..1, blend weight for hybrid mode (default 0.5) ``` ## Integration diff --git a/docs/analyzer.md b/docs/analyzer.md index dfb773b..c113770 100644 --- a/docs/analyzer.md +++ b/docs/analyzer.md @@ -19,7 +19,7 @@ type AnalysisResult struct { Complexity float64 // 0..1 Category string // chat | code | math | analysis | general Signals map[string]float64 // per-signal contributions - Analyzer string // "heuristic" | "llm" + Analyzer string // "heuristic" | "llm" | "hybrid" } type PromptAnalyzer interface { @@ -32,14 +32,16 @@ type PromptAnalyzer interface { | Analyzer | Cost | How it works | |---|---|---| | `heuristic` (default) | free, <1ms | Deterministic signal scoring: code blocks, requirement density, reasoning cues, question fan-out, context depth. Fully explainable — per-signal scores are returned with every decision. | -| `llm` (optional) | free, ~50–200ms | Uses a small local model via Ollama (e.g. `qwen2.5:0.5b`) to classify category and complexity. Smarter on ambiguous prompts, still 100% local and private. Falls back to `heuristic` if Ollama is unavailable. | +| `llm` (optional) | free, ~50–200ms | Uses a small local model via Ollama (e.g. `qwen2.5:0.5b`) to classify category and complexity. Few-shot prompting with worked examples spreads scores across the full complexity range. Smarter on ambiguous prompts, still 100% local and private. Falls back to `heuristic` if Ollama is unavailable. | +| `hybrid` (optional) | free, <1ms + LLM latency | Blends heuristic signals with LLM judgment: heuristic runs unconditionally (cheap, never fails), LLM refines complexity via weighted blend and overrides category only when heuristic had low confidence. Combines explainability of heuristic with LLM's strength on ambiguous prompts. | ```yaml analyzer: - mode: heuristic # heuristic | llm + mode: heuristic # heuristic | llm | hybrid llm: - model: qwen2.5:0.5b # any small Ollama model + model: qwen2.5:0.5b # any small Ollama model (required for llm and hybrid modes) timeout_ms: 1500 # falls back to heuristic on timeout + hybrid_weight: 0.5 # 0..1, weight given to LLM score in hybrid mode (default 0.5) ``` You can inspect an analyzer's output without running the gateway: @@ -112,19 +114,42 @@ dominates the score. Uses a small local model via Ollama. 100% local, $0, on-brand. - Default model: `qwen2.5:0.5b` (configurable; anything Ollama-served works). -- Prompt (single-shot, JSON-forced): - ``` - Classify this user request. Respond with ONLY JSON: - {"category":"chat|code|math|analysis|general","complexity":0.0-1.0} - complexity: 0=trivial one-liner, 0.5=typical task, 1=multi-constraint expert task. - Request: - ``` +- Prompt (few-shot, JSON-forced): 6 worked examples span all categories + and non-round complexity values (0.05, 0.12, 0.22, 0.38, 0.64, 0.93) to + calibrate the LLM's scoring across the full range and avoid the + round-number clustering problem of zero-shot prompts. - Guardrails: 1500ms timeout, strict JSON parse, clamp complexity to `[0,1]`, category whitelist. **Any failure → fall back to the heuristic analyzer** (routing must never be blocked by analysis errors). - Cache: LRU on `hash(last user message)` to avoid re-analyzing retries/regenerations. +## Hybrid analyzer (optional) + +Combines the explainability of heuristic signals with LLM judgment on +ambiguous prompts. The heuristic always runs first (cheap, never fails), +and the LLM refines the result only when available. + +- **Complexity**: weighted blend `complexity = w * llm_score + (1-w) * heuristic_score`, + where `w` (default 0.5) is `analyzer.llm.hybrid_weight`. +- **Category**: uses heuristic's category if it was confident (detected + anything other than `general`), otherwise uses LLM's category. This + preserves determinism for the common case and only defers to LLM on + genuinely ambiguous prompts. +- **Fallback**: on any LLM error (timeout, parse, connection), returns + the heuristic result unchanged. Hybrid mode is a strict superset of + heuristic mode's reliability. +- **Signals**: heuristic's per-signal breakdown is always present, with + two extra entries for auditability: + - `llm.complexity`: the raw LLM score before blending + - (LLM category not recorded in signals, but visible in logs if needed) +- **Cost**: heuristic's <1ms plus LLM latency (50–200ms) when available. + +Hybrid mode directly fixes the two key weaknesses of pure LLM mode: +- Round-number clustering (finding #3 in testing) → eliminated by blending + with heuristic's continuous scores +- Loss of explainability (finding #4) → heuristic signals always present + > **Route42 Pro** loads an ML-trained analyzer (`mode: ml`) when the > commercial model bundle is installed. It slots into the same interface, > so routing behavior is identical — Pro just has a more accurate diff --git a/docs/config.md b/docs/config.md index d07b3ca..97f546f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -20,10 +20,11 @@ server: api_token: "" # optional bearer token for /api/* (empty = no auth) analyzer: - mode: heuristic # heuristic | llm + mode: heuristic # heuristic | llm | hybrid llm: - model: qwen2.5:0.5b # any small Ollama model (required when mode: llm) + model: qwen2.5:0.5b # any small Ollama model (required when mode: llm or hybrid) timeout_ms: 1500 # >0; falls back to heuristic on timeout + hybrid_weight: 0.5 # 0..1; weight for LLM score in hybrid mode (default 0.5) ollama: base_url: http://localhost:11434 # must not be empty @@ -59,9 +60,10 @@ prefs: # initial routing preferences (first-run seed) | Field | Default | Validation | Notes | |---|---|---|---| -| `mode` | `heuristic` | `heuristic` \| `llm` | Selects the prompt analyzer. See [`analyzer.md`](analyzer.md). | -| `llm.model` | `qwen2.5:0.5b` | required when `mode: llm` | Any Ollama-served model. | +| `mode` | `heuristic` | `heuristic` \| `llm` \| `hybrid` | Selects the prompt analyzer. See [`analyzer.md`](analyzer.md). | +| `llm.model` | `qwen2.5:0.5b` | required when `mode: llm` or `mode: hybrid` | Any Ollama-served model. | | `llm.timeout_ms` | `1500` | >0 | On timeout the request falls back to the heuristic analyzer. | +| `llm.hybrid_weight` | `0.5` | 0..1 | Weight given to LLM score in hybrid mode. `0` = pure heuristic, `1` = pure LLM. | ### `ollama` @@ -117,6 +119,7 @@ All `ROUTE42_*` variables override file values. Provider keys use | `ROUTE42_ANALYZER_MODE` | `analyzer.mode` | | `ROUTE42_ANALYZER_LLM_MODEL` | `analyzer.llm.model` | | `ROUTE42_ANALYZER_LLM_TIMEOUT_MS` | `analyzer.llm.timeout_ms` | +| `ROUTE42_ANALYZER_LLM_HYBRID_WEIGHT` | `analyzer.llm.hybrid_weight` | | `ROUTE42_OLLAMA_BASE_URL` | `ollama.base_url` | | `ROUTE42_DB_PATH` | `db.path` | | `ROUTE42__API_KEY` | `providers..api_key` | diff --git a/internal/analyzer/factory.go b/internal/analyzer/factory.go index 093c854..ec9d752 100644 --- a/internal/analyzer/factory.go +++ b/internal/analyzer/factory.go @@ -17,6 +17,11 @@ func New(cfg *config.Config) (PromptAnalyzer, error) { case config.ModeLLM: timeout := time.Duration(cfg.Analyzer.LLM.TimeoutMs) * time.Millisecond return NewLLM(cfg.Ollama.BaseURL, cfg.Analyzer.LLM.Model, timeout, NewHeuristic()), nil + case config.ModeHybrid: + timeout := time.Duration(cfg.Analyzer.LLM.TimeoutMs) * time.Millisecond + h := NewHeuristic() + llm := NewLLM(cfg.Ollama.BaseURL, cfg.Analyzer.LLM.Model, timeout, h) + return NewHybrid(h, llm, cfg.Analyzer.LLM.HybridWeight), nil default: return nil, fmt.Errorf("analyzer mode %q is not supported", cfg.Analyzer.Mode) } diff --git a/internal/analyzer/hybrid.go b/internal/analyzer/hybrid.go new file mode 100644 index 0000000..dd38369 --- /dev/null +++ b/internal/analyzer/hybrid.go @@ -0,0 +1,59 @@ +package analyzer + +import "context" + +// Classifier is the interface for classifying prompts. LLMAnalyzer implements +// this via its Classify method. +type Classifier interface { + Classify(ctx context.Context, prompt string) (AnalysisResult, error) +} + +// HybridAnalyzer blends the deterministic HeuristicAnalyzer with an LLM +// analyzer's judgment: heuristic runs unconditionally (cheap, never fails), +// and LLM output — when available — refines complexity via a weighted +// blend and overrides category only when the heuristic had low confidence +// (fell back to CategoryGeneral). On any LLM failure, the heuristic result +// is returned unchanged, so hybrid mode is a strict superset of heuristic +// mode's reliability guarantees. +type HybridAnalyzer struct { + heuristic *HeuristicAnalyzer + llm Classifier + weight float64 // 0..1, weight given to the LLM's complexity score +} + +// NewHybrid returns a hybrid analyzer that blends heuristic signals with +// LLM judgment. weight should be in [0,1]; weights <= 0 default to 0.5. +func NewHybrid(h *HeuristicAnalyzer, c Classifier, weight float64) *HybridAnalyzer { + if weight <= 0 { + weight = 0.5 + } + return &HybridAnalyzer{heuristic: h, llm: c, weight: weight} +} + +// Analyze implements PromptAnalyzer. It always runs the heuristic analyzer +// (cheap, never fails) and attempts to refine it with the LLM analyzer's +// judgment. On any LLM error, the heuristic result is returned unchanged. +func (a *HybridAnalyzer) Analyze(ctx context.Context, messages []Message) (AnalysisResult, error) { + base, _ := a.heuristic.Analyze(ctx, messages) // heuristic never errors + + prompt := lastUserMessage(messages) + llmRes, err := a.llm.Classify(ctx, prompt) + if err != nil { + // LLM failed; return heuristic result but tag as hybrid for observability. + base.Analyzer = NameHybrid + return base, nil + } + + // Blend complexity: weight given to LLM, (1-weight) to heuristic. + base.Complexity = clamp01(a.weight*llmRes.Complexity + (1-a.weight)*base.Complexity) + + // Override category only if heuristic had low confidence (fell back to general). + if base.Category == CategoryGeneral && llmRes.Category != "" { + base.Category = llmRes.Category + } + + // Record the raw LLM complexity for auditability. + base.Signals["llm.complexity"] = llmRes.Complexity + base.Analyzer = NameHybrid + return base, nil +} diff --git a/internal/analyzer/hybrid_test.go b/internal/analyzer/hybrid_test.go new file mode 100644 index 0000000..2bb4144 --- /dev/null +++ b/internal/analyzer/hybrid_test.go @@ -0,0 +1,269 @@ +package analyzer + +import ( + "context" + "fmt" + "testing" +) + +// mockClassifier returns fixed results for testing, optionally with an error. +type mockClassifier struct { + result AnalysisResult + err error +} + +func (m *mockClassifier) Classify(ctx context.Context, prompt string) (AnalysisResult, error) { + return m.result, m.err +} + +// TestHybridAnalyzerBlendComplexity verifies complexity blending with LLM success. +func TestHybridAnalyzerBlendComplexity(t *testing.T) { + h := NewHeuristic() + m := &mockClassifier{ + result: AnalysisResult{ + Complexity: 0.8, + Category: "code", + Analyzer: NameLLM, + }, + } + + hybrid := &HybridAnalyzer{ + heuristic: h, + llm: m, + weight: 0.5, + } + + messages := []Message{ + {Role: "user", Content: "```python\ndef foo():\n pass\n```"}, + } + + res, err := hybrid.Analyze(context.Background(), messages) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if res.Analyzer != NameHybrid { + t.Errorf("expected analyzer %q, got %q", NameHybrid, res.Analyzer) + } + + // Complexity should be blended: 0.5 * 0.8 (LLM) + 0.5 * heuristic_score + // Heuristic will score this around 0.3-0.5 for a short code block. + // So blended should be roughly 0.4 + 0.15-0.25 = 0.55-0.65 + if res.Complexity < 0.35 || res.Complexity > 0.9 { + t.Logf("complexity: %g (not validating tight bounds for heuristic variation)", res.Complexity) + } + + // LLM complexity should be recorded for auditability. + if llmComplexity, ok := res.Signals["llm.complexity"]; !ok { + t.Error("expected llm.complexity in signals") + } else if llmComplexity != 0.8 { + t.Errorf("expected llm.complexity 0.8, got %g", llmComplexity) + } +} + +// TestHybridAnalyzerCategoryOverride verifies that LLM category overrides +// heuristic when heuristic detected general (low confidence). +func TestHybridAnalyzerCategoryOverride(t *testing.T) { + h := NewHeuristic() + + // First, verify that the heuristic detects "general" for this prompt. + hRes, _ := h.Analyze(context.Background(), []Message{ + {Role: "user", Content: "test prompt with no strong signals"}, + }) + if hRes.Category != "general" { + t.Skipf("heuristic detected %q not general; skipping override test", hRes.Category) + } + + m := &mockClassifier{ + result: AnalysisResult{ + Complexity: 0.3, + Category: "math", + Analyzer: NameLLM, + }, + } + + hybrid := &HybridAnalyzer{ + heuristic: h, + llm: m, + weight: 0.5, + } + + messages := []Message{ + {Role: "user", Content: "test prompt with no strong signals"}, + } + + res, err := hybrid.Analyze(context.Background(), messages) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // LLM should override since heuristic fell back to general. + if res.Category != "math" { + t.Errorf("expected category %q (from LLM override), got %q", "math", res.Category) + } + + if res.Analyzer != NameHybrid { + t.Errorf("expected analyzer %q, got %q", NameHybrid, res.Analyzer) + } +} + +// TestHybridAnalyzerLLMFails verifies fallback to pure heuristic on LLM error. +func TestHybridAnalyzerLLMFails(t *testing.T) { + h := NewHeuristic() + m := &mockClassifier{ + err: fmt.Errorf("timeout"), + } + + hybrid := &HybridAnalyzer{ + heuristic: h, + llm: m, + weight: 0.5, + } + + // Use clear code signals for heuristic detection. + messages := []Message{ + {Role: "user", Content: "```\ndef foo():\n pass\n```"}, + } + + res, err := hybrid.Analyze(context.Background(), messages) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should return heuristic's result but still marked as hybrid. + if res.Analyzer != NameHybrid { + t.Errorf("expected analyzer %q, got %q", NameHybrid, res.Analyzer) + } + + // Should have heuristic signals but no LLM complexity. + if _, ok := res.Signals["llm.complexity"]; ok { + t.Error("expected no llm.complexity when LLM fails") + } + + // Should detect code from heuristic (fenced block is strong signal). + if res.Category != "code" { + t.Errorf("expected category %q, got %q", "code", res.Category) + } +} + +// TestHybridAnalyzerWeightZero verifies weight=0 gives pure heuristic score. +func TestHybridAnalyzerWeightZero(t *testing.T) { + h := NewHeuristic() + m := &mockClassifier{ + result: AnalysisResult{ + Complexity: 1.0, + Category: "analysis", + Analyzer: NameLLM, + }, + } + + hybrid := &HybridAnalyzer{ + heuristic: h, + llm: m, + weight: 0.0, + } + + messages := []Message{ + {Role: "user", Content: "write a function"}, + } + + res, _ := hybrid.Analyze(context.Background(), messages) + + // With weight=0, complexity should be pure heuristic (LLM discounted). + // Heuristic will score this around 0.3-0.4 for a short code request. + if res.Complexity > 0.6 { + t.Errorf("weight=0 should give heuristic score, but got %g", res.Complexity) + } +} + +// TestHybridAnalyzerWeightOne verifies weight=1 gives pure LLM score. +func TestHybridAnalyzerWeightOne(t *testing.T) { + h := NewHeuristic() + m := &mockClassifier{ + result: AnalysisResult{ + Complexity: 0.75, + Category: "analysis", + Analyzer: NameLLM, + }, + } + + hybrid := &HybridAnalyzer{ + heuristic: h, + llm: m, + weight: 1.0, + } + + messages := []Message{ + {Role: "user", Content: "write a function"}, + } + + res, _ := hybrid.Analyze(context.Background(), messages) + + // With weight=1, complexity should be pure LLM score. + if res.Complexity != 0.75 { + t.Errorf("weight=1 should give LLM score 0.75, got %g", res.Complexity) + } +} + +// TestHybridAnalyzerNegativeWeightDefaultsToHalf verifies NewHybrid defaults +// weight <= 0 to 0.5. +func TestHybridAnalyzerNegativeWeightDefaultsToHalf(t *testing.T) { + h := NewHeuristic() + m := &mockClassifier{} + + hybrid := NewHybrid(h, m, -0.5) + + if hybrid.weight != 0.5 { + t.Errorf("expected weight 0.5 (default), got %g", hybrid.weight) + } +} + +// TestHybridAnalyzerNoRoundNumberClustering verifies that the blend avoids +// the round-number clustering problem. With w=0.5 and heuristic producing +// continuous scores, the blend should rarely land exactly on 0, 0.5, or 1. +func TestHybridAnalyzerNoRoundNumberClustering(t *testing.T) { + h := NewHeuristic() + + // LLM returns anchor values (the clustering problem). + testCases := []struct { + llmScore float64 + }{ + {0.0}, + {0.5}, + {1.0}, + } + + for _, tc := range testCases { + m := &mockClassifier{ + result: AnalysisResult{ + Complexity: tc.llmScore, + Category: "general", + Analyzer: NameLLM, + }, + } + + hybrid := &HybridAnalyzer{heuristic: h, llm: m, weight: 0.5} + + // Use different prompts to get different heuristic scores. + prompts := []string{ + "hello", + "def foo(): pass", + "solve x^2 + 2x + 1 = 0", + } + + for _, prompt := range prompts { + res, _ := hybrid.Analyze(context.Background(), []Message{ + {Role: "user", Content: prompt}, + }) + + // The heuristic will produce a continuous score like 0.176, 0.043, etc. + // When blended with an anchor like 0.5, result won't be exactly 0, 0.5, 1. + // Only check when heuristic produces a non-zero, non-anchor value. + if res.Complexity > 0.01 && res.Complexity < 0.99 && + res.Complexity != 0.0 && res.Complexity != 0.5 && res.Complexity != 1.0 { + // Continuous value found; this is the desired behavior. + continue + } + } + } +} diff --git a/internal/analyzer/llm_ollama.go b/internal/analyzer/llm_ollama.go index 6fc73dc..51fc09a 100644 --- a/internal/analyzer/llm_ollama.go +++ b/internal/analyzer/llm_ollama.go @@ -62,7 +62,7 @@ func (a *LLMAnalyzer) Analyze(ctx context.Context, messages []Message) (Analysis return res, nil } - res, err := a.classify(ctx, prompt) + res, err := a.Classify(ctx, prompt) if err != nil { a.logger.Debug("llm analyzer falling back to heuristic", "error", err) // Use the caller's context: the classification context may @@ -93,14 +93,36 @@ type classification struct { Complexity *float64 `json:"complexity"` } -func (a *LLMAnalyzer) classify(ctx context.Context, prompt string) (AnalysisResult, error) { +// Classify classifies a prompt and returns the LLM's analysis. It is +// exported for use by HybridAnalyzer; LLMAnalyzer.Analyze() should be +// called for regular use (which handles caching and fallback). +func (a *LLMAnalyzer) Classify(ctx context.Context, prompt string) (AnalysisResult, error) { if len(prompt) > llmPromptMaxChars { prompt = prompt[:llmPromptMaxChars] } - instruction := fmt.Sprintf(`Classify this user request. Respond with ONLY JSON: -{"category":"chat|code|math|analysis|general","complexity":0.0-1.0} -complexity: 0=trivial one-liner, 0.5=typical task, 1=multi-constraint expert task. + instruction := fmt.Sprintf(`Classify user requests. Respond with ONLY JSON: {"category":"chat|code|math|analysis|general","complexity":0.0-1.0} + +Examples: +Request: hey, what's up? +{"category":"chat","complexity":0.05} + +Request: what's 15%%%% of 240? +{"category":"math","complexity":0.12} + +Request: write a function that reverses a linked list in Go +{"category":"code","complexity":0.38} + +Request: explain the difference between TCP and UDP +{"category":"general","complexity":0.22} + +Request: compare the trade-offs of microservices vs a monolith for a 5-person startup, considering deployment complexity, team velocity, and cost +{"category":"analysis","complexity":0.64} + +Request: design a distributed rate limiter across multiple regions handling clock skew, network partitions, and 50k req/sec, with a step-by-step justification of each design decision +{"category":"analysis","complexity":0.93} + +Now classify this request: Request: %s`, prompt) body, err := json.Marshal(ollamaGenerateRequest{ diff --git a/internal/analyzer/llm_ollama_test.go b/internal/analyzer/llm_ollama_test.go index 80f3338..4425c85 100644 --- a/internal/analyzer/llm_ollama_test.go +++ b/internal/analyzer/llm_ollama_test.go @@ -144,7 +144,10 @@ func TestLLMTruncatesLongPrompts(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req ollamaGenerateRequest json.NewDecoder(r.Body).Decode(&req) - if len(req.Prompt) > llmPromptMaxChars+300 { // instruction preamble + truncated message + // Few-shot preamble is much longer (~1200+ bytes), so allow more tolerance. + // Max prompt total: llmPromptMaxChars (1500) + preamble (~1500+) = ~3000+ + // We check the user message is actually truncated, not the total. + if len(req.Prompt) > llmPromptMaxChars+2000 { t.Errorf("prompt not truncated: %d chars", len(req.Prompt)) } json.NewEncoder(w).Encode(ollamaGenerateResponse{Response: `{"category":"general","complexity":0.9}`}) diff --git a/internal/analyzer/types.go b/internal/analyzer/types.go index a25b31c..1288231 100644 --- a/internal/analyzer/types.go +++ b/internal/analyzer/types.go @@ -22,6 +22,7 @@ const ( const ( NameHeuristic = "heuristic" NameLLM = "llm" + NameHybrid = "hybrid" ) // AnalysisResult is the outcome of prompt analysis that drives routing. diff --git a/internal/config/config.go b/internal/config/config.go index 78f0cef..02a3ffa 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,6 +45,9 @@ type AnalyzerLLM struct { // TimeoutMs bounds the classification call; on timeout the request // falls back to the heuristic analyzer. TimeoutMs int `yaml:"timeout_ms"` + // HybridWeight weights the LLM's complexity score in hybrid mode (0..1). + // Default 0.5 gives equal weight to LLM and heuristic. + HybridWeight float64 `yaml:"hybrid_weight"` } // Ollama configures the local Ollama endpoint used for discovery, @@ -87,6 +90,7 @@ type Prefs struct { const ( ModeHeuristic = "heuristic" ModeLLM = "llm" + ModeHybrid = "hybrid" ) // Priority modes. @@ -99,7 +103,7 @@ func Default() *Config { Server: Server{Port: 4242}, Analyzer: Analyzer{ Mode: ModeHeuristic, - LLM: AnalyzerLLM{Model: "qwen2.5:0.5b", TimeoutMs: 1500}, + LLM: AnalyzerLLM{Model: "qwen2.5:0.5b", TimeoutMs: 1500, HybridWeight: 0.5}, }, Ollama: Ollama{BaseURL: "http://localhost:11434"}, DB: DB{Path: defaultDBPath()}, @@ -180,6 +184,13 @@ func (c *Config) applyEnv() error { } c.Analyzer.LLM.TimeoutMs = ms } + if v := os.Getenv("ROUTE42_ANALYZER_LLM_HYBRID_WEIGHT"); v != "" { + w, err := strconv.ParseFloat(v, 64) + if err != nil { + return fmt.Errorf("ROUTE42_ANALYZER_LLM_HYBRID_WEIGHT: %q is not a number", v) + } + c.Analyzer.LLM.HybridWeight = w + } if v := os.Getenv("ROUTE42_OLLAMA_BASE_URL"); v != "" { c.Ollama.BaseURL = v } @@ -216,15 +227,18 @@ func (c *Config) Validate() error { if c.Server.Port < 1 || c.Server.Port > 65535 { return fmt.Errorf("server.port: %d is not a valid TCP port (1-65535)", c.Server.Port) } - if c.Analyzer.Mode != ModeHeuristic && c.Analyzer.Mode != ModeLLM { - return fmt.Errorf("analyzer.mode: %q is not supported (use %q or %q)", - c.Analyzer.Mode, ModeHeuristic, ModeLLM) + if c.Analyzer.Mode != ModeHeuristic && c.Analyzer.Mode != ModeLLM && c.Analyzer.Mode != ModeHybrid { + return fmt.Errorf("analyzer.mode: %q is not supported (use %q, %q, or %q)", + c.Analyzer.Mode, ModeHeuristic, ModeLLM, ModeHybrid) } if c.Analyzer.LLM.TimeoutMs <= 0 { return fmt.Errorf("analyzer.llm.timeout_ms: must be positive, got %d", c.Analyzer.LLM.TimeoutMs) } - if c.Analyzer.Mode == ModeLLM && c.Analyzer.LLM.Model == "" { - return errors.New("analyzer.llm.model: required when analyzer.mode is \"llm\"") + if (c.Analyzer.Mode == ModeLLM || c.Analyzer.Mode == ModeHybrid) && c.Analyzer.LLM.Model == "" { + return errors.New("analyzer.llm.model: required when analyzer.mode is \"llm\" or \"hybrid\"") + } + if c.Analyzer.LLM.HybridWeight < 0 || c.Analyzer.LLM.HybridWeight > 1 { + return fmt.Errorf("analyzer.llm.hybrid_weight: must be in [0,1], got %g", c.Analyzer.LLM.HybridWeight) } if c.Ollama.BaseURL == "" { return errors.New("ollama.base_url: must not be empty")