lib/llm_core/structured.ex

defmodule LlmCore.Structured do
  @moduledoc """
  Utilities for extracting structured data from LLM responses.

  Provides lightweight JSON-mode extraction and schema validation so
  consuming applications can request structured output without pulling
  in heavy dependencies like Instructor.

  ## Supported Formats

    * `{:json_schema, schema}` — Decode JSON from the response, validate against a schema map
    * `{:json_schema, schema, opts}` — Same, with custom validator and options
    * `{:custom, fun}` — Apply a custom extraction function `(Response.t() -> {:ok, term()} | {:error, term()})`

  ## Usage

  Structured output is typically requested via `response_format` in the dispatch opts:

      {:ok, response} = LlmCore.send(prompt, :extraction,
        response_format: {:json_schema, %{type: "object", properties: %{name: %{type: "string"}}}}
      )

      response.structured
      #=> %{"name" => "value"}

  Or process a response directly:

      {:ok, response} = Structured.process({:ok, raw_response}, {:json_schema, schema})

  ## Custom Validators

      {:json_schema, schema, validator: fn value ->
        if value["score"] > 0.5, do: {:ok, value}, else: {:error, "score too low"}
      end}
  """

  alias LlmCore.LLM.Response
  alias LlmCore.Structured.{JsonMode, Validator}

  @type response_format ::
          {:json_schema, term()}
          | {:json_schema, term(), keyword()}
          | {:custom, (Response.t() -> {:ok, term()} | {:error, term()})}

  @spec process({:ok, Response.t()} | {:error, term()}, response_format() | nil) ::
          {:ok, Response.t()} | {:error, term()}
  def process(result, nil), do: result
  def process({:error, _} = error, _format), do: error

  def process({:ok, %Response{} = response}, {:json_schema, schema}) do
    process({:ok, response}, {:json_schema, schema, []})
  end

  def process({:ok, %Response{} = response}, {:json_schema, schema, opts}) do
    with {:ok, decoded} <- JsonMode.decode(response),
         {:ok, value} <- Validator.validate(decoded, schema, opts),
         {:ok, coerced} <- run_custom_validator(value, Keyword.get(opts, :validator), opts) do
      metadata = Map.put(response.metadata || %{}, :structured_format, :json_schema)
      {:ok, %{response | structured: coerced, metadata: metadata}}
    else
      {:error, reason} -> {:error, {:structured_output_error, reason}}
    end
  end

  def process({:ok, %Response{} = response}, {:custom, fun}) when is_function(fun, 1) do
    case fun.(response) do
      {:ok, value} ->
        metadata = Map.put(response.metadata || %{}, :structured_format, :custom)
        {:ok, %{response | structured: value, metadata: metadata}}

      {:error, reason} ->
        {:error, {:structured_output_error, reason}}
    end
  end

  def process({:ok, response}, _format) do
    {:ok, response}
  end

  defp run_custom_validator(value, nil, _opts), do: {:ok, value}

  defp run_custom_validator(value, fun, _opts) when is_function(fun, 1) do
    fun.(value)
  end

  defp run_custom_validator(value, fun, opts) when is_function(fun, 2) do
    fun.(value, opts)
  end

  defp run_custom_validator(value, _fun, _opts), do: {:ok, value}
end