Skip to main content

lib/kagi/summary.ex

defmodule Kagi.Summary do
  @moduledoc """
  Summary response returned by `Kagi.summarize/1..3`.

  Contains the Markdown returned by Kagi Summarizer.

  ## Fields

    * `:summary` - Markdown summary text.
  """

  alias Kagi.Client
  alias Kagi.Error
  alias Kagi.HTTP

  @typedoc "Summary style requested via the `:type` option."
  @type summary_type :: :summary | :takeaway

  @typedoc "A parsed Kagi summarizer response."
  @type t :: %__MODULE__{summary: String.t()}

  defstruct [:summary]

  @url "https://kagi.com/mother/summary_labs"

  @doc false
  @spec request(Client.t(), String.t(), keyword()) :: {:ok, t()} | {:error, Error.t()}
  def request(%Client{} = client, url, options) when is_binary(url) and is_list(options) do
    with {:ok, params} <- query_params(url, options),
         {:ok, %{body: body}} <-
           HTTP.get(client, @url,
             params: params,
             headers: [
               {"accept", "application/vnd.kagi.stream"},
               {"cookie", "kagi_session=#{client.session_token}"},
               {"referer", "https://kagi.com/summarizer"}
             ]
           ),
         {:ok, body} <- normalize_body(body) do
      parse_stream(body)
    end
  end

  @doc false
  @spec parse_stream(binary()) :: {:ok, t()} | {:error, Error.t()}
  def parse_stream(<<>>) do
    {:error, Error.new(:parse_error, "Empty response from summarizer")}
  end

  def parse_stream(body) when is_binary(body) do
    with {:ok, chunk} <- last_data_chunk(body),
         {:ok, json} <- decode_chunk(chunk),
         :ok <- detect_summary_error(json),
         {:ok, markdown} <- extract_markdown(json) do
      {:ok, %__MODULE__{summary: markdown}}
    end
  end

  @spec normalize_body(term()) :: {:ok, binary()} | {:error, Error.t()}
  defp normalize_body(body) when is_binary(body), do: {:ok, body}

  defp normalize_body(body) do
    {:error,
     Error.new(:parse_error, "expected summary response body to be binary, got: #{inspect(body)}")}
  end

  @spec query_params(String.t(), keyword()) :: {:ok, keyword()} | {:error, Error.t()}
  defp query_params(url, options) do
    with {:ok, summary_type} <- summary_type(Keyword.get(options, :type, :summary)) do
      {:ok,
       [
         url: url,
         stream: "1",
         target_language: Keyword.get(options, :lang, "EN"),
         summary_type: summary_type
       ]}
    end
  end

  @spec summary_type(term()) :: {:ok, String.t()} | {:error, Error.t()}
  defp summary_type(:summary), do: {:ok, "summary"}
  defp summary_type(:takeaway), do: {:ok, "takeaway"}

  defp summary_type(value) do
    {:error, Error.new(:invalid_option, "invalid summary type: #{inspect(value)}")}
  end

  @spec last_data_chunk(binary()) :: {:ok, String.t()} | {:error, Error.t()}
  defp last_data_chunk(body) do
    body
    |> :binary.split(<<0>>, [:global])
    |> Enum.reverse()
    |> Enum.find(fn chunk -> chunk |> String.trim() |> Kernel.!==("") end)
    |> case do
      nil -> {:error, Error.new(:parse_error, "No data chunks in response")}
      chunk -> {:ok, String.trim(chunk)}
    end
  end

  @spec decode_chunk(String.t()) :: {:ok, map()} | {:error, Error.t()}
  defp decode_chunk(chunk) do
    json =
      cond do
        String.starts_with?(chunk, "final:") ->
          chunk |> String.replace_prefix("final:", "") |> String.trim()

        String.starts_with?(chunk, "new_message.json:") ->
          chunk |> String.replace_prefix("new_message.json:", "") |> String.trim()

        true ->
          chunk
      end

    case JSON.decode(json) do
      {:ok, value} when is_map(value) ->
        {:ok, value}

      {:ok, value} ->
        {:error,
         Error.new(:parse_error, "summary JSON must be an object, got: #{inspect(value)}")}

      {:error, reason} ->
        {:error, Error.new(:parse_error, "Failed to parse summary JSON: #{inspect(reason)}")}
    end
  end

  @spec detect_summary_error(map()) :: :ok | {:error, Error.t()}
  defp detect_summary_error(%{"state" => "error"} = json) do
    {:error,
     Error.new(:parse_error, "Summarizer error: #{Map.get(json, "reply", "Unknown error")}")}
  end

  defp detect_summary_error(_json), do: :ok

  @spec extract_markdown(map()) :: {:ok, String.t()} | {:error, Error.t()}
  defp extract_markdown(json) do
    markdown = json["md"] || get_in(json, ["output_data", "markdown"])

    cond do
      not is_binary(markdown) ->
        {:error, Error.new(:parse_error, "Missing markdown in response")}

      markdown == "" ->
        {:error, Error.new(:parse_error, "Empty summary returned")}

      true ->
        {:ok, markdown}
    end
  end
end