lib/openagents_web/controllers/continual_learning_controller.ex

main at 58e6347eeb72 · 6 KB

defmodule OpenAgentsWeb.ContinualLearningController do
  @moduledoc """
  Operator-authenticated continual-learning jobs for the one named internal
  buyer (CONTINUAL-001).

  The endpoints are the whole lane: start a job over admitted licensed datasets,
  read it, cancel it, resume it from its surviving checkpoint, replay it as a new
  job, and export its evidence. Every refusal is a typed code, so a caller learns
  which bound it hit rather than reading a generic failure.
  """

  use OpenAgentsWeb, :controller

  alias OpenAgents.ContinualLearning

  def create(conn, params) do
    case ContinualLearning.start(conn.assigns.current_user, admission(params)) do
      {:ok, job} ->
        conn
        |> put_status(:created)
        |> json(%{"job" => ContinualLearning.projection(job)})

      {:error, reason} ->
        refusal(conn, reason)
    end
  end

  def index(conn, params) do
    case ContinualLearning.list(conn.assigns.current_user, limit(params)) do
      {:ok, jobs} ->
        json(conn, %{"jobs" => Enum.map(jobs, &ContinualLearning.projection/1)})

      {:error, reason} ->
        refusal(conn, reason)
    end
  end

  def show(conn, %{"id" => id}) do
    case ContinualLearning.get(conn.assigns.current_user, id) do
      {:ok, job} -> json(conn, %{"job" => ContinualLearning.projection(job)})
      {:error, reason} -> refusal(conn, reason)
    end
  end

  def cancel(conn, %{"id" => id}) do
    case ContinualLearning.cancel(conn.assigns.current_user, id) do
      {:ok, job} -> json(conn, %{"job" => ContinualLearning.projection(job)})
      {:error, reason} -> refusal(conn, reason)
    end
  end

  def resume(conn, %{"id" => id} = params) do
    case ContinualLearning.resume(conn.assigns.current_user, id, surface(params)) do
      {:ok, job} -> json(conn, %{"job" => ContinualLearning.projection(job)})
      {:error, reason} -> refusal(conn, reason)
    end
  end

  def replay(conn, %{"id" => id} = params) do
    case ContinualLearning.replay(conn.assigns.current_user, id, surface(params)) do
      {:ok, job} ->
        conn
        |> put_status(:created)
        |> json(%{"job" => ContinualLearning.projection(job)})

      {:error, reason} ->
        refusal(conn, reason)
    end
  end

  def evidence(conn, %{"id" => id}) do
    case ContinualLearning.export_evidence(conn.assigns.current_user, id) do
      {:ok, export} ->
        conn
        |> put_resp_header(
          "content-disposition",
          ~s(attachment; filename="continual-learning-evidence-#{id}.json")
        )
        |> json(export)

      {:error, reason} ->
        refusal(conn, reason)
    end
  end

  # The admission arrives as JSON with string keys. Only the admitted shape is
  # read across the boundary: nothing here turns caller text into an atom, and an
  # unexpected key is dropped rather than carried into the durable row.
  defp admission(params) do
    %{
      buyer_ref: params["buyer_ref"],
      objective: params["objective"],
      objective_version: params["objective_version"],
      base_model_ref: params["base_model_ref"],
      base_model_digest: params["base_model_digest"],
      configuration: params["configuration"] || %{},
      datasets: dataset_references(params["datasets"]),
      evaluation: evaluation(params["evaluation"] || %{}),
      budget: budget(params["budget"] || %{}),
      runtime_class: params["runtime_class"],
      stopping_policy: stopping_policy(params["stopping_policy"] || %{}),
      conversation_id: params["conversation_id"],
      owner_visitor_id: params["owner_visitor_id"]
    }
  end

  defp dataset_references(references) when is_list(references) do
    Enum.map(references, fn reference ->
      if is_map(reference),
        do: Map.take(reference, ["listing_id", "acceptance_ref"]),
        else: %{}
    end)
  end

  defp dataset_references(_references), do: []

  defp evaluation(evaluation) when is_map(evaluation) do
    %{
      corpus: dataset_references(evaluation["corpus"]),
      verifier: verifier(evaluation["verifier"] || %{}),
      separation_required: evaluation["separation_required"] == true,
      acceptance_criteria: evaluation["acceptance_criteria"],
      target_metric: evaluation["target_metric"],
      target_value: evaluation["target_value"],
      policy_version: evaluation["policy_version"] || 1
    }
  end

  defp verifier(verifier) when is_map(verifier) do
    %{
      id: verifier["id"],
      admitted: verifier["admitted"] == true,
      independent_of_producer: verifier["independent_of_producer"] == true
    }
  end

  defp budget(budget) when is_map(budget), do: %{usd_cents: budget["usd_cents"]}

  defp stopping_policy(policy) when is_map(policy) do
    %{
      maximum_rounds: policy["maximum_rounds"],
      minimum_improvement: policy["minimum_improvement"] || 0.0
    }
  end

  defp surface(params) do
    %{
      conversation_id: params["conversation_id"],
      owner_visitor_id: params["owner_visitor_id"]
    }
  end

  defp limit(params) do
    case Integer.parse(to_string(params["limit"] || "50")) do
      {value, ""} when value > 0 -> value
      _invalid -> 50
    end
  end

  defp refusal(conn, reason) do
    conn
    |> put_status(status_for(reason))
    |> json(%{"error" => code(reason)})
  end

  defp status_for(:not_found), do: :not_found
  defp status_for(:operator_required), do: :forbidden
  defp status_for(:continual_learning_disabled), do: :service_unavailable
  defp status_for(:continual_learning_at_capacity), do: :conflict
  defp status_for(:buyer_not_configured), do: :service_unavailable
  defp status_for(:buyer_not_admitted), do: :forbidden
  defp status_for(:training_code_not_pinned), do: :service_unavailable
  defp status_for(:not_cancellable), do: :conflict
  defp status_for(:not_resumable), do: :conflict
  defp status_for(:budget_exhausted), do: :conflict
  defp status_for(:checkpoint_lost), do: :conflict
  defp status_for(:checkpoint_missing), do: :conflict
  defp status_for(:stopping_policy_satisfied), do: :conflict
  defp status_for({:capacity_unavailable, _detail}), do: :service_unavailable
  defp status_for({:dataset_not_authorized, _detail}), do: :forbidden
  defp status_for({:dataset_unavailable, _detail}), do: :conflict
  defp status_for({:consent_missing, _listing_id}), do: :forbidden
  defp status_for({:use_not_licensed, _listing_id, _use}), do: :forbidden
  defp status_for({:unsupported_custody, _detail}), do: :forbidden
  defp status_for({:dataset_moved, _listing_id}), do: :conflict
  defp status_for(_reason), do: :unprocessable_entity

  defp code(reason) when is_atom(reason), do: Atom.to_string(reason)

  defp code({reason, detail}) when is_atom(reason) and (is_atom(detail) or is_binary(detail)),
    do: "#{reason}:#{detail}"

  defp code({reason, _detail}) when is_atom(reason), do: Atom.to_string(reason)

  defp code({reason, detail, extra}) when is_atom(reason),
    do: "#{reason}:#{detail}:#{extra}"

  defp code(%Ecto.Changeset{}), do: "invalid_continual_learning_job"
  defp code(_reason), do: "continual_learning_failed"
end