defmodule SyntropyWeb.Api.AgentRequest do @moduledoc false @type parsed_request :: %{name: String.t(), perspective: String.t(), opts: keyword()} @score_keys ~w(knowledge synthesis connections) @spec parse(map()) :: {:ok, parsed_request()} | {:error, [map()]} def parse(params) do with {:ok, name} <- parse_required_string(Map.get(params, "name"), "name"), {:ok, perspective} <- parse_required_string(Map.get(params, "perspective"), "perspective"), {:ok, id} <- parse_id(Map.get(params, "id")), {:ok, knowledge_items} <- parse_knowledge_items(Map.get(params, "knowledge_items")), {:ok, score_vector} <- parse_score_vector(Map.get(params, "score_vector")), {:ok, position} <- parse_unit_float(Map.get(params, "position"), "position") do {:ok, %{ name: name, perspective: perspective, opts: [knowledge_items: knowledge_items] |> maybe_put(:id, id) |> maybe_put(:score_vector, score_vector) |> maybe_put(:position, position) }} end end defp maybe_put(opts, _key, nil), do: opts defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value) defp parse_required_string(value, field) when is_binary(value) do trimmed = String.trim(value) if trimmed == "" do {:error, [error(field, "must be a non-empty string")]} else {:ok, trimmed} end end defp parse_required_string(_value, field), do: {:error, [error(field, "is required")]} defp parse_id(nil), do: {:ok, nil} defp parse_id(id) when is_binary(id) do trimmed = String.trim(id) cond do trimmed == "" -> {:ok, nil} Regex.match?(~r/^[a-z0-9][a-z0-9_-]*$/, trimmed) -> {:ok, trimmed} true -> {:error, [error("id", "must contain only lowercase letters, digits, - or _")]} end end defp parse_id(_id), do: {:error, [error("id", "must be a string")]} defp parse_knowledge_items(nil), do: {:ok, []} defp parse_knowledge_items(items) when is_list(items) do if Enum.all?(items, &is_binary/1) do normalized = items |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) |> Enum.uniq() {:ok, normalized} else {:error, [error("knowledge_items", "must be an array of strings")]} end end defp parse_knowledge_items(_items), do: {:error, [error("knowledge_items", "must be an array of strings")]} defp parse_score_vector(nil), do: {:ok, nil} defp parse_score_vector(score_vector) when is_map(score_vector) do parsed = Enum.reduce_while(@score_keys, %{}, fn key, acc -> case parse_unit_float(Map.get(score_vector, key), "score_vector.#{key}") do {:ok, nil} -> {:cont, Map.put(acc, String.to_existing_atom(key), 0.0)} {:ok, value} -> {:cont, Map.put(acc, String.to_existing_atom(key), value)} {:error, errors} -> {:halt, {:error, errors}} end end) case parsed do {:error, errors} -> {:error, errors} vector -> {:ok, vector} end end defp parse_score_vector(_score_vector), do: {:error, [error("score_vector", "must be an object")]} defp parse_unit_float(nil, _field), do: {:ok, nil} defp parse_unit_float(value, field) when is_number(value) do if value >= 0 and value <= 1 do {:ok, value / 1} else {:error, [error(field, "must be a number between 0 and 1")]} end end defp parse_unit_float(_value, field), do: {:error, [error(field, "must be a number between 0 and 1")]} defp error(field, message), do: %{field: field, message: message} end