diff --git a/README.md b/README.md index 5853a7568..e21fb4983 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,12 @@ candidate models, their harnesses, and the router name sent with each recorded decision. Feedback investigations can compare the selection with that original menu without querying the router again. +Claude and Codex routing reasons summarize the task profile in plain language, such as +"This task has a clear target and limited scope." A separate sentence explains when +model availability requires a higher tier. Custom gateway notices are retained, and +older or unrecognized responses keep their original wording. Diagnostic records keep +the displayed reason in `rationale` and the gateway's full explanation in `raw_rationale`. + On Windows, Claude smart routing uses subagent hooks only. If first-prompt routing is enabled, ug warns and falls back to subagent routing because the first-prompt wrapper requires a Unix terminal. diff --git a/src/ucode/smart_routing/rationale.py b/src/ucode/smart_routing/rationale.py new file mode 100644 index 000000000..78cafebad --- /dev/null +++ b/src/ucode/smart_routing/rationale.py @@ -0,0 +1,64 @@ +"""Plain-language explanations of structured task-router decisions.""" + +_PROFILE_REASONS = ( + ( + "localization_burden", + "open_search", + "This task needs investigation to find what needs attention.", + ), + ("scope", "cross_cutting", "This task spans several components."), + ("environment_burden", "live_external", "This task depends on live systems or data."), + ("reasoning_depth", "open_ended", "Solving this task requires exploring possible approaches."), + ("verification_strength", "weak", "There is no clear way to check the result of this task."), + ("scope", "connected", "This task involves several related components."), + ("reasoning_depth", "multi_step", "This task involves several dependent steps."), + ("localization_burden", "bounded_search", "This task needs a focused search."), +) + + +def format_rationale(selection: dict, rationale: str) -> str: + """Summarize task_v3 profiles, preserving unfamiliar responses and gateway notices.""" + params = selection.get("params") + if not isinstance(params, dict) or params.get("router_version") != "task_v3": + return rationale + task_tier, served_tier = params.get("task_tier"), params.get("served_tier") + tiers = ("Low", "Medium", "High") + if task_tier not in tiers or served_tier not in tiers: + return rationale + if tiers.index(served_tier) < tiers.index(task_tier): + return rationale + + evidence = params.get("evidence_spans") + if not isinstance(evidence, list) or len(evidence) != 1 or not isinstance(evidence[0], str): + return rationale + ending = f" Request: “{evidence[0]}”." + if task_tier != served_tier: + ending += f" Your configured model availability requires service at {served_tier}." + # Only replace the known generated portion; custom gateway notices follow it. + _, separator, notice = rationale.partition(ending) + if not separator: + return rationale + + profile = params.get("task_profile") + if params.get("profiler_status") == "unavailable": + if task_tier != "High" or served_tier != "High": + return rationale + reason = "The task could not be assessed, so a high-capability model was selected." + elif params.get("profiler_status") == "ok" and isinstance(profile, dict): + reason = next( + (text for field, value, text in _PROFILE_REASONS if profile.get(field) == value), + "", + ) + if ( + not reason + and profile.get("localization_burden") == "explicit_target" + and profile.get("scope") == "localized" + ): + reason = "This task has a clear target and limited scope." + if not reason: + return rationale + else: + return rationale + if task_tier != served_tier: + reason += " A higher-tier model was selected because the requested tier isn't available." + return " ".join(part for part in (reason, notice.strip()) if part) diff --git a/src/ucode/smart_routing/routing.py b/src/ucode/smart_routing/routing.py index efc352c4d..6286c767d 100644 --- a/src/ucode/smart_routing/routing.py +++ b/src/ucode/smart_routing/routing.py @@ -23,6 +23,8 @@ from pathlib import Path from typing import Any +from ucode.smart_routing.rationale import format_rationale + ROUTER_NAME = "task_v3" ROUTER_NAME_ENV_VAR = "SMART_ROUTER_NAME" ROUTING_PATH = "/ai-gateway/routing/v1/routes:select" @@ -94,6 +96,7 @@ class RoutingDecision: rationale: str = "" route_options: tuple[tuple[str, str | None], ...] = () router_name: str = "" + raw_rationale: str | None = None def display_message( self, @@ -262,14 +265,17 @@ def select_route( model = resolve(raw_model) if model is None: return None, f"router selected unsupported model {raw_model!r}" - rationale = payload.get("rationale") if isinstance(payload, dict) else None + raw_rationale = payload.get("rationale") + raw_rationale = raw_rationale if isinstance(raw_rationale, str) else "" + rationale = format_rationale(payload["route_selection"][0], raw_rationale) return ( RoutingDecision( model=model, raw_model=raw_model, - rationale=rationale if isinstance(rationale, str) else "", + rationale=rationale, route_options=offered_routes, router_name=router_name, + raw_rationale=raw_rationale if rationale != raw_rationale else None, ), None, ) @@ -424,6 +430,9 @@ def write_decision_record( # Persisted for diagnosis: an empty value means the gateway returned # no rationale (vs. a placement bug in how we surface it). "rationale": decision.rationale, + "raw_rationale": decision.raw_rationale + if decision.raw_rationale is not None + else decision.rationale, "at": time.time(), }, ) diff --git a/tests/test_routing_rationale.py b/tests/test_routing_rationale.py new file mode 100644 index 000000000..03b4add21 --- /dev/null +++ b/tests/test_routing_rationale.py @@ -0,0 +1,26 @@ +from ucode.smart_routing.rationale import format_rationale + + +def test_plain_language_reason(): + selection = { + "params": { + "router_version": "task_v3", + "task_tier": "Low", + "served_tier": "Low", + "profiler_status": "ok", + "evidence_spans": ["Inspect the parser."], + "task_profile": {"localization_burden": "explicit_target", "scope": "localized"}, + } + } + rationale = ( + "Routed to Low because its task-only profile is low: " + "an explicit target, localized scope, direct reasoning. Request: “Inspect the parser.”." + ) + assert ( + format_rationale(selection, rationale) == "This task has a clear target and limited scope." + ) + + +def test_unknown_response_is_preserved(): + rationale = "A custom routing explanation." + assert format_rationale({}, rationale) == rationale