Packages

fnord

0.9.38

AI code archaeology

Current section

Files

Jump to
fnord lib frobs prompt.ex
Raw

lib/frobs/prompt.ex

defmodule Frobs.Prompt do
@moduledoc """
UI-driven prompting for frob parameters. This module uses `UI` to prompt the
user for each property defined in a frob's `spec.json` and returns a map of
coerced values. It relies on `AI.Tools.Params` for validation/coercion.
Supports JSON Schema composition keywords (`anyOf`, `oneOf`, `allOf`) via
schema resolution: nullable unions prompt for the non-null type, simple
multi-type unions offer a type chooser, `allOf` merges and prompts the merged
schema, and anything else falls back to raw JSON input.
"""
@doc """
Prompt the user for parameters described by `spec`.
Returns `{:ok, map}` or `{:error, reason}`.
"""
@spec prompt_for_params(map(), module()) :: {:ok, map()} | {:error, term()}
def prompt_for_params(spec, ui \\ UI) do
with {:ok, normalized} <- AI.Tools.Params.normalize_spec(spec),
{:ok, params} <- AI.Tools.Params.param_list(normalized) do
if not ui.is_tty?() or ui.quiet?() do
non_interactive_collect(params, normalized)
else
interactive_collect(params, normalized, ui)
end
end
end
defp non_interactive_collect(params, normalized) do
defaults = collect_defaults(params)
missing =
params
|> Enum.filter(fn {name, _schema, req} -> req and not Map.has_key?(defaults, name) end)
|> Enum.map(fn {name, _, _} -> name end)
if missing != [] do
{:error, {:non_interactive_missing_required, missing}}
else
AI.Tools.Params.validate_all_args(normalized, defaults)
end
end
defp collect_defaults(list) do
Enum.reduce(list, %{}, fn {name, schema, _req}, acc ->
case Map.get(schema, "default") do
nil -> acc
v -> Map.put(acc, name, v)
end
end)
end
defp interactive_collect(params, normalized, ui) do
result =
Enum.reduce_while(params, {:ok, %{}}, fn {name, schema, required?}, {:ok, acc} ->
case prompt_property(name, schema, required?, ui) do
{:ok, v} -> {:cont, {:ok, Map.put(acc, name, v)}}
{:error, :user_cancelled} -> {:halt, {:error, :user_cancelled}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
case result do
{:ok, answers} ->
case AI.Tools.Params.validate_all_args(normalized, answers) do
{:ok, coerced} -> {:ok, coerced}
{:error, reason} -> {:error, reason}
end
other ->
other
end
end
# ---------------------------------------------------------------------------
# Property prompting -- dispatches by schema resolution
# ---------------------------------------------------------------------------
defp prompt_property(name, schema, _required?, ui) do
label = name |> to_string() |> String.replace("_", " ")
desc = Map.get(schema, "description", "")
unless ui.quiet?() do
if desc != "", do: ui.puts(desc)
end
case AI.Tools.Params.resolve_schema_type(schema) do
{:ok, type} ->
prompt_by_type(type, name, label, schema, ui)
{:composition, keyword, sub_schemas} ->
prompt_composition(keyword, sub_schemas, name, label, schema, ui)
{:error, :unresolvable} ->
prompt_raw_json(label, schema, ui)
end
end
# ---------------------------------------------------------------------------
# Type-based prompting
# ---------------------------------------------------------------------------
defp prompt_by_type(_type, _name, label, %{"enum" => choices} = _schema, ui) do
display = Enum.map(choices, &to_string/1)
sel = ui.choose(label, display)
idx = Enum.find_index(display, &(&1 == sel)) || 0
{:ok, Enum.at(choices, idx)}
end
defp prompt_by_type("boolean", _name, label, _schema, ui) do
sel = ui.choose(label, ["Yes", "No"]) || "No"
{:ok, sel in ["Yes", "yes", "Y", "y"]}
end
defp prompt_by_type(type, _name, label, schema, ui)
when type in ["string", "integer", "number"] do
default = Map.get(schema, "default")
prompt_text = if default, do: "#{label} (default: #{inspect(default)})", else: label
raw = ui.prompt(prompt_text)
val = if raw in [nil, ""], do: default, else: raw
{:ok, val}
end
defp prompt_by_type("array", name, _label, schema, ui) do
items_schema = Map.get(schema, "items") || %{"type" => "string"}
collect_array_items(name, items_schema, ui, [])
end
defp prompt_by_type("object", _name, _label, schema, ui) do
nested_spec = %{
"parameters" => %{
"type" => "object",
"properties" => Map.get(schema, "properties", %{}),
"required" => Map.get(schema, "required", [])
}
}
case prompt_for_params(nested_spec, ui) do
{:ok, m} -> {:ok, m}
other -> other
end
end
defp prompt_by_type(_type, _name, label, schema, ui) do
prompt_raw_json(label, schema, ui)
end
# ---------------------------------------------------------------------------
# Composition prompting
# ---------------------------------------------------------------------------
defp prompt_composition(keyword, sub_schemas, name, label, schema, ui) do
case AI.Tools.Params.nullable_schema?(keyword, sub_schemas) do
{:nullable, non_null_schema} ->
prompt_nullable(name, label, non_null_schema, ui)
:not_nullable when keyword in ["anyOf", "oneOf"] ->
if AI.Tools.Params.all_simple_types?(sub_schemas) do
prompt_type_chooser(sub_schemas, label, ui)
else
prompt_raw_json(label, schema, ui)
end
:not_nullable when keyword == "allOf" ->
merged = AI.Tools.Params.merge_schemas(sub_schemas)
prompt_property(name, merged, false, ui)
_ ->
prompt_raw_json(label, schema, ui)
end
end
defp prompt_nullable(name, label, non_null_schema, ui) do
case AI.Tools.Params.resolve_schema_type(non_null_schema) do
{:ok, type} ->
prompt_text = "#{label} (optional, enter blank to skip)"
raw = ui.prompt(prompt_text)
if raw in [nil, ""] do
{:ok, nil}
else
case AI.Tools.Params.validate_and_coerce_param(non_null_schema, raw) do
{:ok, coerced} -> {:ok, coerced}
{:error, _, _} -> prompt_by_type(type, name, label, non_null_schema, ui)
end
end
_ ->
# Non-null sub-schema is itself complex; fall back to raw JSON
prompt_text = "#{label} (optional, enter blank to skip, or enter JSON)"
raw = ui.prompt(prompt_text)
if raw in [nil, ""] do
{:ok, nil}
else
parse_and_validate_json(raw, non_null_schema, label, ui)
end
end
end
defp prompt_type_chooser(sub_schemas, label, ui) do
types = Enum.map(sub_schemas, fn s -> Map.get(s, "type") end)
sel = ui.choose("#{label} (type)", types)
idx = Enum.find_index(types, &(&1 == sel)) || 0
chosen_schema = Enum.at(sub_schemas, idx)
prompt_text = "#{label} (#{sel})"
raw = ui.prompt(prompt_text)
default = Map.get(chosen_schema, "default")
val = if raw in [nil, ""], do: default, else: raw
case AI.Tools.Params.validate_and_coerce_param(chosen_schema, val) do
{:ok, coerced} -> {:ok, coerced}
{:error, _, msg} -> {:error, {:coercion_failed, msg}}
end
end
# ---------------------------------------------------------------------------
# Raw JSON fallback
# ---------------------------------------------------------------------------
defp prompt_raw_json(label, schema, ui) do
prompt_text = "#{label} (enter as JSON)"
raw = ui.prompt(prompt_text)
default = Map.get(schema, "default")
if raw in [nil, ""] and default != nil do
{:ok, default}
else
parse_and_validate_json(raw, schema, label, ui)
end
end
defp parse_and_validate_json(raw, schema, label, ui) do
case SafeJson.decode(raw || "") do
{:ok, parsed} ->
case AI.Tools.Params.validate_and_coerce_param(schema, parsed) do
{:ok, coerced} ->
{:ok, coerced}
{:error, _, msg} ->
ui.puts("Invalid value: #{msg}")
prompt_raw_json(label, schema, ui)
end
{:error, _} ->
ui.puts("Invalid JSON. Please enter a valid JSON value.")
prompt_raw_json(label, schema, ui)
end
end
# ---------------------------------------------------------------------------
# Array item collection
# ---------------------------------------------------------------------------
defp collect_array_items(_name, items_schema, ui, acc) do
idx = length(acc) + 1
prompt_text = "Item ##{idx} (enter blank to finish)"
raw = ui.prompt(prompt_text)
cond do
raw in [nil, ""] and acc == [] ->
{:ok, []}
raw in [nil, ""] ->
{:ok, acc}
true ->
case AI.Tools.Params.validate_and_coerce_param(items_schema, raw) do
{:ok, coerced} ->
collect_array_items(nil, items_schema, ui, acc ++ [coerced])
{:error, _reason, _msg} ->
ui.puts("Invalid item, try again")
collect_array_items(nil, items_schema, ui, acc)
end
end
end
end