CouncilEx.AutoCouncil (CouncilEx v0.1.0)

Copy Markdown View Source

A council that picks itself.

AutoCouncil is opt-in sugar on top of regular councils. From the caller's perspective it behaves like any other runnable: pass it to CouncilEx.run/3 and you get a Result back. Internally a strategy decides which static council module or %DynamicCouncil{} actually handles the prompt.

Existing callers that hardcode a council (CouncilEx.run(MyApp.Councils.SEO, input)) are unaffected — AutoCouncil is purely additive.

Quick start

auto =
  CouncilEx.AutoCouncil.new(
    name:     "general-routing",
    strategy: :rules,
    catalog:  [
      %{id: "seo",  council: MyApp.Councils.SEO,        match: ~r/seo|sitemap/i, desc: "SEO audit"},
      %{id: "code", council: MyApp.Councils.CodeReview, match: ~r/code|PR/i,     desc: "Code review"}
    ]
  )

{:ok, result} = CouncilEx.run(auto, %{question: "audit my SEO"})
result.metadata.auto
# => %{strategy: :rules, kind: :static, catalog_id: "seo", reason: "...", ...}

Catalog sources

Two equivalent forms — pick whichever fits your workflow:

# Inline (explicit, easy to test, lives with calling code)
AutoCouncil.new(strategy: :rules, catalog: [...])

# Registry-backed (shared, hot-reloadable, UI-editable)
AutoCouncil.new(strategy: :rules, catalog: {:registry, :council})

Register entries in config or at runtime:

config :council_ex, :registry,
  councils: %{
    "seo" => %{council: MyApp.Councils.SEO, match: ~r/seo/i, desc: "SEO audit"}
  }

CouncilEx.Registry.register_council("editorial", %{
  council: my_dynamic_council,
  match:   ~r/editorial|copy/i,
  desc:    "Editorial review"
})

Strategies

  • :rules — first regex/fun match wins. Cheap, deterministic, no LLM cost.
  • :cascade — try a chain of strategies; first success wins.
  • :embeddingstub. Cosine similarity over precomputed vectors.
  • :llm_classifystub. Cheap LLM picks an id from the catalog.
  • :llm_buildstub. LLM synthesizes a fresh DynamicCouncil.
  • {module, opts} — bring your own.

On no match

Configure :on_no_match to control fallback behaviour:

  • :error (default) — CouncilEx.run/3 returns {:error, :no_match}.
  • {:fallback, council_or_id} — use the named council instead.

Inspecting a decision

resolve/2 returns the routing decision without running it. Useful for UIs, dry-runs, and tests:

{:ok, decision, meta} = AutoCouncil.resolve(auto, "audit my SEO")
decision  # => {:static, MyApp.Councils.SEO}
meta      # => %{strategy: :rules, kind: :static, ...}

Composability

Because AutoCouncil is a council from the runner's perspective, it composes: an AutoCouncil may resolve to another AutoCouncil, to a DynamicCouncil whose members are sub-councils, etc.

Summary

Types

Where the strategy reads its catalog from.

Catalog entry. Only :council is required by the resolver.

Resolved decision. Caller can inspect what was picked.

Metadata about the routing decision. Stored in Result.metadata.auto.

Behaviour when no catalog entry matches.

Strategy reference. Atom = built-in. Tuple = custom.

t()

Functions

Build an AutoCouncil. See moduledoc for full options.

Resolve the routing decision for prompt without running it. Useful for UIs and tests. Emits the same telemetry as a full run.

Types

catalog()

@type catalog() :: [catalog_entry()] | {:registry, :council}

Where the strategy reads its catalog from.

catalog_entry()

@type catalog_entry() :: %{
  :council => module() | CouncilEx.DynamicCouncil.t(),
  optional(:id) => String.t(),
  optional(:desc) => String.t(),
  optional(:match) => Regex.t() | (binary() -> boolean()),
  optional(:vector) => [float()],
  optional(:tags) => [atom()]
}

Catalog entry. Only :council is required by the resolver.

decision()

@type decision() ::
  {:static, module()}
  | {:dynamic, CouncilEx.DynamicCouncil.t()}
  | {:built, CouncilEx.DynamicCouncil.t()}

Resolved decision. Caller can inspect what was picked.

meta()

@type meta() :: %{
  strategy: atom(),
  kind: :static | :dynamic | :built,
  catalog_id: String.t() | nil,
  reason: String.t() | nil,
  score: float() | nil,
  latency_ms: non_neg_integer()
}

Metadata about the routing decision. Stored in Result.metadata.auto.

on_no_match()

@type on_no_match() ::
  :error | {:fallback, module() | String.t() | CouncilEx.DynamicCouncil.t()}

Behaviour when no catalog entry matches.

strategy()

@type strategy() ::
  :rules
  | :cascade
  | :embedding
  | :llm_classify
  | :llm_build
  | {module(), keyword()}

Strategy reference. Atom = built-in. Tuple = custom.

t()

@type t() :: %CouncilEx.AutoCouncil{
  catalog: catalog() | nil,
  name: String.t() | nil,
  on_no_match: on_no_match() | nil,
  options: keyword(),
  provider_check: boolean(),
  strategy: strategy()
}

Functions

new(opts)

@spec new(keyword()) :: t()

Build an AutoCouncil. See moduledoc for full options.

Required

  • :strategy

Optional

  • :catalog — inline list or {:registry, :council}. Required by most strategies; some (e.g. :llm_build) can synthesize without one.
  • :on_no_match:error (default) or {:fallback, council_or_id}.
  • :name — human label. Surfaces in telemetry.
  • :options — strategy-specific keyword opts.

resolve(auto, prompt)

@spec resolve(t(), term()) :: {:ok, decision(), meta()} | {:error, term()}

Resolve the routing decision for prompt without running it. Useful for UIs and tests. Emits the same telemetry as a full run.

Returns {:ok, decision, meta} or {:error, reason}.