lib/omni/tools/web_fetch/strategy.ex

defmodule Omni.Tools.WebFetch.Strategy do
  @moduledoc """
  Behaviour for URL-specific content extraction strategies.

  A strategy controls how a URL is fetched and how the response is
  converted to text for LLM consumption. Strategies are matched against
  URLs in order — the first strategy whose `c:match?/2` returns `true`
  handles the request.

  ## Callbacks

    * `c:match?/2` (required) — returns `true` if this strategy handles
      the given URI.
    * `c:request/2` (optional) — modifies the `Req.Request` before
      execution (URL rewriting, custom headers, etc.).
    * `c:extract/2` (required) — converts the `Req.Response` into a
      content string.

  ## Example

      defmodule MyApp.WikiStrategy do
        @behaviour Omni.Tools.WebFetch.Strategy

        @impl true
        def match?(uri, _opts), do: uri.host == "en.wikipedia.org"

        @impl true
        def request(req, _opts) do
          # Use the mobile API for cleaner content
          Req.merge(req, headers: [{"accept", "text/html"}])
        end

        @impl true
        def extract(response, _opts) do
          Html2Markdown.convert(response.body)
        end
      end

  ## Usage

  Pass strategies to `Omni.Tools.WebFetch.new/1`:

      Omni.Tools.WebFetch.new(
        strategies: [
          {MyApp.WikiStrategy, []},
          MyApp.AnotherStrategy
        ]
      )
  """

  @doc "Returns `true` if this strategy handles the given URI."
  @callback match?(URI.t(), opts :: keyword()) :: boolean()

  @doc "Modifies the `Req.Request` before execution."
  @callback request(Req.Request.t(), opts :: keyword()) :: Req.Request.t()

  @doc "Extracts content from the response as a string."
  @callback extract(Req.Response.t(), opts :: keyword()) :: String.t()

  @optional_callbacks [request: 2]

  @doc """
  Normalizes a list of strategy specs into `{module, opts}` tuples.

  Accepts bare modules or `{module, opts}` tuples. Validates that each
  module implements the required callbacks. Raises `ArgumentError` on
  invalid input.

      Strategy.resolve([MyStrategy, {OtherStrategy, key: "val"}])
      #=> [{MyStrategy, []}, {OtherStrategy, [key: "val"]}]
  """
  @spec resolve([module() | {module(), keyword()}]) :: [{module(), keyword()}]
  def resolve(strategies) do
    Enum.map(strategies, fn
      {mod, opts} when is_atom(mod) and is_list(opts) ->
        validate_module!(mod)
        {mod, opts}

      mod when is_atom(mod) ->
        validate_module!(mod)
        {mod, []}

      other ->
        raise ArgumentError,
              "expected a strategy module or {module, opts} tuple, got: #{inspect(other)}"
    end)
  end

  @doc """
  Finds the first strategy matching the given URI.

  Returns `{module, opts}` for the first strategy whose `match?/2`
  returns `true`, or `nil` if none match.
  """
  @spec find([{module(), keyword()}], URI.t()) :: {module(), keyword()} | nil
  def find(strategies, uri) do
    Enum.find(strategies, fn {mod, opts} -> mod.match?(uri, opts) end)
  end

  defp validate_module!(mod) do
    case Code.ensure_loaded(mod) do
      {:module, ^mod} ->
        unless function_exported?(mod, :match?, 2) and function_exported?(mod, :extract, 2) do
          raise ArgumentError,
                "strategy module #{inspect(mod)} must implement match?/2 and extract/2"
        end

      {:error, reason} ->
        raise ArgumentError,
              "could not load strategy module #{inspect(mod)}: #{reason}"
    end
  end
end