Files
OpenJarvis/tests/learning/test_router.py
T
555e982f51 feat: query complexity analyzer for local/cloud routing (#102)
* feat: add query complexity analyzer with CLI and UI integration

Classify incoming queries by difficulty (trivial→very_complex) to
suggest appropriate token budgets for local vs. cloud routing.

- Add score_complexity() with weighted signals (length, code, math,
  reasoning, multi-step, creative) and token tier mapping
- Wire into `jarvis ask`: auto-suggest max_tokens when not set by user,
  show complexity in --profile output, log at DEBUG level
- Add complexity metadata to /v1/chat/completions API response
- Display complexity tier and score in frontend XRayFooter
- Extend RoutingContext with complexity_score, suggested_max_tokens,
  has_reasoning fields
- Update HeuristicRouter to route on complexity_score instead of
  raw query_length
- Remove duplicated regex patterns from router.py (use complexity
  module as single source of truth)
- Add 30 unit tests for complexity module
- Fix existing router tests for new complexity-based routing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: pass temperature and max_tokens from UI settings to backend

The settings page stores temperature and max_tokens in the frontend
store, but these values were never included in the chat API request.
The backend Pydantic model defaults max_tokens to 1024 when the field
is absent, which is too low for thinking models like qwen3.5 — they
consume all tokens on reasoning and return empty content.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: pass UI settings to backend and auto-bump max_tokens from complexity

- Pass temperature and max_tokens from frontend Settings store to the
  backend API (cherry-picked from fix/ui-max-tokens-passthrough)
- Server-side: bump max_tokens when the complexity analyzer suggests a
  higher budget (e.g. for thinking models on complex queries), never
  reduce below the client-requested value
- Fixes empty responses with thinking models (e.g. qwen3.5) that
  consumed all tokens on internal reasoning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: raise token budget tiers to prevent empty responses on thinking models

Double all tier budgets (trivial: 512→1024, simple: 1024→2048, etc.)
so that thinking models like qwen3.5 have enough headroom for internal
chain-of-thought plus visible output, even on simple queries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: CI lint and test failures

- Break long lines in routes.py to satisfy 88-char limit (E501)
- Add intelligence.max_tokens and temperature to mocked config in
  test_ask_router.py so complexity analyzer can compare against int
  instead of MagicMock

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: line too long in test_complexity.py (E501)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:27:23 -07:00

162 lines
4.9 KiB
Python

"""Tests for the heuristic model router (canonical location)."""
from __future__ import annotations
from openjarvis.core.registry import ModelRegistry
from openjarvis.core.types import ModelSpec
from openjarvis.learning._stubs import RoutingContext
from openjarvis.learning.routing.router import (
HeuristicRouter,
build_routing_context,
)
def _register_models() -> None:
"""Register a small set of models for testing."""
ModelRegistry.register_value(
"small",
ModelSpec(
model_id="small",
name="Small",
parameter_count_b=3.0,
context_length=4096,
),
)
ModelRegistry.register_value(
"large",
ModelSpec(
model_id="large",
name="Large",
parameter_count_b=70.0,
context_length=131072,
),
)
ModelRegistry.register_value(
"coder",
ModelSpec(
model_id="coder",
name="DeepSeek Coder",
parameter_count_b=16.0,
context_length=131072,
),
)
class TestBuildRoutingContext:
def test_code_detection(self) -> None:
ctx = build_routing_context("def hello():\n pass")
assert ctx.has_code is True
assert ctx.has_math is False
def test_math_detection(self) -> None:
ctx = build_routing_context("solve the integral of x^2")
assert ctx.has_math is True
assert ctx.has_code is False
def test_length(self) -> None:
ctx = build_routing_context("Hi")
assert ctx.query_length == 2
def test_urgency_default(self) -> None:
ctx = build_routing_context("test")
assert ctx.urgency == 0.5
class TestHeuristicRouter:
def test_short_query_prefers_small(self) -> None:
_register_models()
router = HeuristicRouter(
available_models=["small", "large", "coder"],
)
ctx = RoutingContext(query="Hi", query_length=2)
assert router.select_model(ctx) == "small"
def test_code_prefers_coder(self) -> None:
_register_models()
router = HeuristicRouter(
available_models=["small", "large", "coder"],
)
ctx = RoutingContext(
query="def foo():",
query_length=10,
has_code=True,
)
assert router.select_model(ctx) == "coder"
def test_math_prefers_large(self) -> None:
_register_models()
router = HeuristicRouter(
available_models=["small", "large", "coder"],
)
ctx = RoutingContext(
query="solve x",
query_length=7,
has_math=True,
)
assert router.select_model(ctx) == "large"
def test_high_complexity_prefers_large(self) -> None:
_register_models()
router = HeuristicRouter(
available_models=["small", "large", "coder"],
)
ctx = RoutingContext(query="x" * 501, query_length=501, complexity_score=0.7)
assert router.select_model(ctx) == "large"
def test_high_urgency_overrides_to_small(self) -> None:
_register_models()
router = HeuristicRouter(
available_models=["small", "large", "coder"],
)
ctx = RoutingContext(
query="x" * 501,
query_length=501,
urgency=0.9,
)
assert router.select_model(ctx) == "small"
def test_fallback_chain(self) -> None:
_register_models()
router = HeuristicRouter(
available_models=["small", "large"],
default_model="large",
fallback_model="small",
)
# Medium complexity, no code/math, no reasoning → falls to default
ctx = RoutingContext(
query="Tell me about cats",
query_length=60,
complexity_score=0.35,
)
assert router.select_model(ctx) == "large"
def test_no_available_models(self) -> None:
router = HeuristicRouter(
available_models=[],
default_model="fallback-model",
)
ctx = RoutingContext(query="test", query_length=4)
assert router.select_model(ctx) == "fallback-model"
def test_reasoning_keywords_prefer_large(self) -> None:
_register_models()
router = HeuristicRouter(available_models=["small", "large"])
query = (
"Please explain step by step how the process"
" of photosynthesis works in plants"
)
ctx = build_routing_context(query)
assert router.select_model(ctx) == "large"
def test_code_without_coder_falls_to_large(self) -> None:
_register_models()
router = HeuristicRouter(
available_models=["small", "large"],
)
ctx = RoutingContext(
query="def foo():",
query_length=10,
has_code=True,
)
assert router.select_model(ctx) == "large"