Packages

A Fair Source multi-agent runtime with deterministic agent scoring and replayable run history.

Current section

Files

Jump to
syntropy lib syntropy_web controllers api listing.ex
Raw

lib/syntropy_web/controllers/api/listing.ex

defmodule SyntropyWeb.Api.Listing do
@moduledoc """
Shared helpers for paginated, filterable list endpoints.
List endpoints accept `limit`, `cursor`, `q`, and resource-specific filter
params. When persistence is enabled the controllers page through Postgres;
otherwise these helpers paginate the bounded in-memory rings with the same
opaque-cursor contract.
"""
alias Syntropy.Persistence
alias SyntropyWeb.Api.ControllerHelpers
@type page :: Persistence.page()
@filter_params %{status: "status", kind: "kind", q: "q", perspective: "perspective"}
@doc """
Parses shared list params (`limit`, `cursor`, plus the allowed filters).
Returns `{:error, :invalid_cursor}` when a cursor is present but malformed.
"""
@spec parse_opts(map(), [atom()]) :: {:ok, keyword()} | {:error, :invalid_cursor}
def parse_opts(params, allowed_filters) do
base = [limit: ControllerHelpers.limit(params)]
filters =
Enum.reduce(allowed_filters, base, fn filter, acc ->
case Map.get(params, Map.fetch!(@filter_params, filter)) do
value when is_binary(value) and value != "" -> Keyword.put(acc, filter, value)
_other -> acc
end
end)
case Map.get(params, "cursor") do
nil ->
{:ok, filters}
cursor ->
case Persistence.decode_cursor(cursor) do
{:ok, decoded} -> {:ok, Keyword.put(filters, :cursor, decoded)}
:error -> {:error, :invalid_cursor}
end
end
end
@doc """
Applies cursor + limit pagination to an already-filtered in-memory list.
Entries must be sorted newest-first. `timestamp_key` selects the field used
for cursor ordering; the entry `:id` (a string) breaks ties.
"""
@spec paginate(list(map()), keyword(), atom()) :: page()
def paginate(entries, opts, timestamp_key) do
limit = Keyword.get(opts, :limit, 100)
remaining =
case Keyword.get(opts, :cursor) do
nil -> entries
cursor -> Enum.filter(entries, &before_cursor?(&1, cursor, timestamp_key))
end
page_entries = Enum.take(remaining, limit)
has_more = length(remaining) > limit
next_cursor =
case {has_more, List.last(page_entries)} do
{true, %{} = last} ->
Persistence.encode_cursor({sort_timestamp(last, timestamp_key), last.id})
_other ->
nil
end
%{entries: page_entries, has_more: has_more, next_cursor: next_cursor}
end
@doc """
Filters in-memory run envelopes by `:status`, `:kind`, and `:q`.
"""
@spec filter_runs(list(map()), keyword()) :: list(map())
def filter_runs(runs, opts) do
runs
|> filter_equal(:status, opts[:status])
|> filter_equal(:kind, opts[:kind])
|> filter_query(opts[:q], fn run ->
[Map.get(run, :id), Map.get(run, :prompt)]
end)
end
@doc """
Filters in-memory snapshots by `:kind` (trigger kind) and `:q`.
"""
@spec filter_snapshots(list(map()), keyword()) :: list(map())
def filter_snapshots(snapshots, opts) do
snapshots
|> filter_equal(:trigger_kind, opts[:kind])
|> filter_query(opts[:q], fn snapshot ->
[Map.get(snapshot, :id), Map.get(snapshot, :task_id)]
end)
end
@doc """
Filters in-memory recommendations by `:status`, `:kind`, and `:q`.
"""
@spec filter_recommendations(list(map()), keyword()) :: list(map())
def filter_recommendations(recommendations, opts) do
recommendations
|> filter_equal(:status, opts[:status])
|> filter_equal(:kind, opts[:kind])
|> filter_query(opts[:q], fn recommendation ->
[
Map.get(recommendation, :id)
| Map.get(recommendation, :candidate_agent_ids) || []
]
end)
end
@doc """
Filters agent exports by `:perspective` and `:q` (id, name, or perspective).
"""
@spec filter_agents(list(map()), keyword()) :: list(map())
def filter_agents(agents, opts) do
agents
|> filter_equal(:perspective, opts[:perspective])
|> filter_query(opts[:q], fn agent ->
[
Map.get(agent, :id),
Map.get(agent, :runtime_id),
Map.get(agent, :name),
Map.get(agent, :perspective)
]
end)
end
@doc """
Builds the `meta` object appended to paginated list responses.
"""
@spec meta(page()) :: map()
def meta(page) do
%{has_more: page.has_more, next_cursor: page.next_cursor}
end
defp filter_equal(entries, _key, nil), do: entries
defp filter_equal(entries, key, value) do
Enum.filter(entries, &(Map.get(&1, key) == value))
end
defp filter_query(entries, nil, _fields_fun), do: entries
defp filter_query(entries, "", _fields_fun), do: entries
defp filter_query(entries, q, fields_fun) do
needle = String.downcase(q)
Enum.filter(entries, fn entry ->
entry
|> fields_fun.()
|> Enum.any?(fn
value when is_binary(value) -> String.contains?(String.downcase(value), needle)
_other -> false
end)
end)
end
defp before_cursor?(entry, {cursor_timestamp, cursor_id}, timestamp_key) do
timestamp = sort_timestamp(entry, timestamp_key)
case DateTime.compare(timestamp, cursor_timestamp) do
:lt -> true
:eq -> to_string(Map.get(entry, :id)) < cursor_id
:gt -> false
end
end
defp sort_timestamp(entry, timestamp_key) do
case Map.get(entry, timestamp_key) do
%DateTime{} = timestamp -> timestamp
_other -> DateTime.from_unix!(0)
end
end
end