defmodule Symbiont.Router do @moduledoc """ Task classifier — routes incoming tasks to the cheapest capable model tier. Uses Haiku to classify task complexity, then returns a routing decision: - Tier 1 (Haiku): simple extraction, formatting, classification - Tier 2 (Sonnet): content writing, code gen, moderate reasoning - Tier 3 (Opus): complex reasoning, strategy, full-context QA The classifier prompt is intentionally concise to minimize routing cost. """ @type tier :: :haiku | :sonnet | :opus @type routing :: %{tier: tier, confidence: String.t(), reason: String.t()} @tier_costs %{ haiku: 0.008, sonnet: 0.04, opus: 0.15 } @classifier_prompt "You are a task complexity classifier. Analyze the task and respond with ONLY valid JSON: " <> ~s({"tier": 1|2|3, "confidence": "low"|"medium"|"high", "reason": "brief explanation"}\n\n) <> "Tier 1 (Haiku): Simple extraction, formatting, classification, short Q&A\n" <> "Tier 2 (Sonnet): Content writing, code generation, analysis, moderate reasoning\n" <> "Tier 3 (Opus): Complex multi-step reasoning, strategy, architecture, edge cases\n\n" <> "Task: " @doc "Classify a task and return the recommended tier." @spec classify(String.t()) :: {:ok, routing()} | {:error, term()} def classify(task) when is_binary(task) do prompt = @classifier_prompt <> task case Symbiont.Dispatcher.invoke(:haiku, prompt) do {:ok, %{result: result_text} = result} -> case parse_classification(result_text) do {:ok, classification} -> {:ok, Map.merge(classification, %{routing_cost: result.estimated_cost_usd})} {:error, _reason} -> # If parsing fails, default to Sonnet (safe middle ground) {:ok, %{tier: :sonnet, confidence: "low", reason: "classification parse failed"}} end {:error, reason} -> {:error, {:classifier_failed, reason}} end end @doc "Route and execute a task end-to-end." @spec route_and_execute(String.t(), keyword()) :: {:ok, map()} | {:error, term()} def route_and_execute(task, opts \\ []) do force_tier = Keyword.get(opts, :force_tier) tier = if force_tier do normalize_tier(force_tier) else case classify(task) do {:ok, %{tier: tier}} -> tier {:error, _} -> :sonnet end end Symbiont.Dispatcher.invoke(tier, task) end @doc "Return the approximate cost per call for a tier." @spec tier_cost(tier()) :: float() def tier_cost(tier), do: Map.get(@tier_costs, tier, 0.04) # -- Private -- defp parse_classification(text) do # Extract JSON from the response (may have surrounding text) case Regex.run(~r/\{[^}]+\}/, text) do [json_str] -> case Jason.decode(json_str) do {:ok, %{"tier" => tier_num} = data} -> {:ok, %{ tier: tier_from_number(tier_num), confidence: data["confidence"] || "medium", reason: data["reason"] || "classified" }} _ -> {:error, :invalid_json} end nil -> {:error, :no_json_found} end end defp tier_from_number(1), do: :haiku defp tier_from_number(2), do: :sonnet defp tier_from_number(3), do: :opus defp tier_from_number(_), do: :sonnet defp normalize_tier("haiku"), do: :haiku defp normalize_tier("sonnet"), do: :sonnet defp normalize_tier("opus"), do: :opus defp normalize_tier(tier) when tier in [:haiku, :sonnet, :opus], do: tier defp normalize_tier(_), do: :sonnet end