lib/openagents/memory/evidence.ex

main at 58e6347eeb72 · 7 KB

defmodule OpenAgents.Memory.Evidence do
  @moduledoc "Host-validated historical evidence derived from one frozen recall source."

  alias OpenAgents.Conversations.Conversation
  alias OpenAgents.Memory.{LexicalRecall, RecallSnapshot}

  @classifications ~w(applicable weak stale conflicting irrelevant)
  @dispositions [:direct, :weak, :irrelevant]
  @maximum_claim_bytes 800
  @maximum_relevance_bytes 500
  @maximum_related_refs 8
  @stale_after_days 365

  @enforce_keys [
    :source_ref,
    :source_scope,
    :observed_at,
    :recalled_at,
    :claim,
    :relevance,
    :classification,
    :corroborates,
    :conflicts_with
  ]
  defstruct @enforce_keys

  @type classification :: :applicable | :weak | :stale | :conflicting | :irrelevant
  @type t :: %__MODULE__{
          source_ref: String.t(),
          source_scope: map(),
          observed_at: DateTime.t(),
          recalled_at: DateTime.t(),
          claim: String.t(),
          relevance: String.t(),
          classification: classification(),
          corroborates: [String.t()],
          conflicts_with: [String.t()]
        }

  @spec build(Conversation.t(), RecallSnapshot.t(), String.t(), keyword()) ::
          {:ok, t()} | {:error, atom()}
  def build(conversation, snapshot, source_ref, options \\ [])

  def build(
        %Conversation{id: conversation_id} = conversation,
        %RecallSnapshot{conversation_id: conversation_id} = snapshot,
        source_ref,
        options
      ) do
    disposition = Keyword.get(options, :disposition, :direct)
    relevance = Keyword.get(options, :relevance, "Exact source selected for historical context.")
    corroborates = Keyword.get(options, :corroborates, [])
    conflicts_with = Keyword.get(options, :conflicts_with, [])
    recalled_at = Keyword.get(options, :recalled_at, DateTime.utc_now())

    with true <- disposition in @dispositions,
         :ok <- validate_relevance(relevance),
         :ok <- validate_recalled_at(recalled_at),
         {:ok, source} <- exact_source(conversation, snapshot, source_ref),
         {:ok, corroborates} <- validate_related(conversation, snapshot, corroborates),
         {:ok, conflicts_with} <- validate_related(conversation, snapshot, conflicts_with) do
      classification =
        classify(disposition, source.observed_at, recalled_at, conflicts_with)

      {:ok,
       %__MODULE__{
         source_ref: source.source_ref,
         source_scope: %{
           "kind" => "browser_conversation",
           "ref" => "conversation:#{conversation_id}",
           "snapshot_ref" => RecallSnapshot.ref(snapshot)
         },
         observed_at: source.observed_at,
         recalled_at: recalled_at,
         claim: bounded_claim(source.content),
         relevance: relevance,
         classification: classification,
         corroborates: corroborates,
         conflicts_with: conflicts_with
       }}
    else
      false -> {:error, :invalid_evidence_disposition}
      {:error, reason} -> {:error, reason}
    end
  end

  def build(%Conversation{}, %RecallSnapshot{}, _source_ref, _options),
    do: {:error, :scope_refused}

  @spec to_output(t()) :: map()
  def to_output(%__MODULE__{} = evidence) do
    %{
      "schema" => "sarah.memory_evidence.v1",
      "source_ref" => evidence.source_ref,
      "source_scope" => evidence.source_scope,
      "observed_at" => DateTime.to_iso8601(evidence.observed_at),
      "recalled_at" => DateTime.to_iso8601(evidence.recalled_at),
      "claim" => evidence.claim,
      "relevance" => evidence.relevance,
      "classification" => Atom.to_string(evidence.classification),
      "corroborates" => evidence.corroborates,
      "conflicts_with" => evidence.conflicts_with
    }
  end

  @spec usage_item(t()) :: map()
  def usage_item(%__MODULE__{} = evidence) do
    %{
      "source_ref" => evidence.source_ref,
      "classification" => Atom.to_string(evidence.classification)
    }
  end

  @spec normalize_usage_items(term()) :: {:ok, [map()]} | {:error, atom()}
  def normalize_usage_items(items) when is_list(items) and length(items) <= 20 do
    if Enum.all?(items, &valid_usage_item?/1) do
      {:ok, Enum.uniq_by(items, &{&1["source_ref"], &1["classification"]})}
    else
      {:error, :invalid_memory_evidence_usage}
    end
  end

  def normalize_usage_items(_items), do: {:error, :invalid_memory_evidence_usage}

  def valid_usage_ledger?(%{
        "schema" => "sarah.memory_evidence_usage.v1",
        "items" => items
      }) do
    match?({:ok, ^items}, normalize_usage_items(items)) and
      byte_size(Jason.encode!(items)) <= 4_096
  end

  def valid_usage_ledger?(_ledger), do: false

  defp exact_source(conversation, snapshot, source_ref) do
    case LexicalRecall.read(conversation, snapshot, source_ref, before: 0, after: 0) do
      {:ok, %{messages: [source]}} -> {:ok, source}
      {:error, reason} -> {:error, reason}
    end
  end

  defp validate_related(_conversation, _snapshot, refs)
       when not is_list(refs) or length(refs) > @maximum_related_refs,
       do: {:error, :invalid_evidence_refs}

  defp validate_related(conversation, snapshot, refs) do
    refs
    |> Enum.reduce_while({:ok, []}, fn ref, {:ok, validated} ->
      case exact_source(conversation, snapshot, ref) do
        {:ok, _source} -> {:cont, {:ok, [ref | validated]}}
        {:error, _reason} -> {:halt, {:error, :invalid_evidence_refs}}
      end
    end)
    |> case do
      {:ok, validated} -> {:ok, validated |> Enum.reverse() |> Enum.uniq()}
      error -> error
    end
  end

  defp classify(_disposition, _observed_at, _recalled_at, [_conflict | _rest]),
    do: :conflicting

  defp classify(:irrelevant, _observed_at, _recalled_at, []), do: :irrelevant

  defp classify(disposition, observed_at, recalled_at, []) do
    if DateTime.diff(recalled_at, observed_at, :day) > @stale_after_days,
      do: :stale,
      else: classify_current(disposition)
  end

  defp classify_current(:weak), do: :weak
  defp classify_current(:direct), do: :applicable

  defp validate_relevance(relevance)
       when is_binary(relevance) and byte_size(relevance) in 1..@maximum_relevance_bytes,
       do: :ok

  defp validate_relevance(_relevance), do: {:error, :invalid_evidence_relevance}

  defp validate_recalled_at(%DateTime{}), do: :ok
  defp validate_recalled_at(_recalled_at), do: {:error, :invalid_recalled_at}

  defp bounded_claim(content) when byte_size(content) <= @maximum_claim_bytes, do: content

  defp bounded_claim(content) do
    content
    |> String.graphemes()
    |> Enum.reduce_while("", fn grapheme, accumulated ->
      if byte_size(accumulated) + byte_size(grapheme) <= @maximum_claim_bytes,
        do: {:cont, accumulated <> grapheme},
        else: {:halt, accumulated}
    end)
  end

  defp valid_usage_item?(%{
         "source_ref" => "message:" <> message_id,
         "classification" => classification
       }) do
    match?({:ok, _id}, Ecto.UUID.cast(message_id)) and classification in @classifications
  end

  # Durable tool-step sources returned by conversation recall carry the same
  # typed refs the voice evidence window uses (turn-tool-step / voice-tool-step).
  defp valid_usage_item?(%{
         "source_ref" => "turn-tool-step:" <> step_id,
         "classification" => classification
       }) do
    match?({:ok, _id}, Ecto.UUID.cast(step_id)) and classification in @classifications
  end

  defp valid_usage_item?(%{
         "source_ref" => "voice-tool-step:" <> step_id,
         "classification" => classification
       }) do
    match?({:ok, _id}, Ecto.UUID.cast(step_id)) and classification in @classifications
  end

  defp valid_usage_item?(_item), do: false
end