lib/openagents/voice/response_context.ex

58e6347eeb72 · 3 KB

defmodule OpenAgents.Voice.ResponseContext do
  @moduledoc "Immutable evidence and program capture for one spoken user response cycle."

  use Ecto.Schema
  import Ecto.Changeset

  @digest_regex ~r/\A[0-9a-f]{64}\z/
  @memory_snapshot_regex ~r/\Amessage:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
  @profile_memory_snapshot_regex ~r/\Aprofile-memory-snapshot:v1:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/

  @primary_key {:id, :binary_id, autogenerate: true}
  @foreign_key_type :binary_id
  @timestamps_opts [type: :utc_datetime_usec]

  schema "voice_response_contexts" do
    belongs_to :voice_session, OpenAgents.Voice.Session
    belongs_to :user_message, OpenAgents.Conversations.Message
    field :generation, :integer
    field :provider_input_item_id, :string
    field :instructions, :string
    field :instruction_digest, :string
    field :memory_snapshot_ref, :string
    field :profile_memory_snapshot_ref, :string
    field :selected_evidence, :map
    field :selected_source_refs, {:array, :string}, default: []
    field :program_artifact_id, :string
    field :program_artifact_digest, :string
    field :program_artifact_receipt, :map
    field :captured_at, :utc_datetime_usec
    timestamps(updated_at: false)
  end

  def create_changeset(context, attributes) do
    context
    |> cast(attributes, [
      :voice_session_id,
      :user_message_id,
      :generation,
      :provider_input_item_id,
      :instructions,
      :instruction_digest,
      :memory_snapshot_ref,
      :profile_memory_snapshot_ref,
      :selected_evidence,
      :selected_source_refs,
      :program_artifact_id,
      :program_artifact_digest,
      :program_artifact_receipt,
      :captured_at
    ])
    |> validate_required([
      :voice_session_id,
      :user_message_id,
      :generation,
      :provider_input_item_id,
      :instructions,
      :instruction_digest,
      :memory_snapshot_ref,
      :profile_memory_snapshot_ref,
      :selected_evidence,
      :selected_source_refs,
      :program_artifact_receipt,
      :captured_at
    ])
    |> validate_number(:generation, greater_than: 0)
    |> validate_length(:provider_input_item_id, min: 1, max: 512)
    |> validate_length(:instructions, min: 1, max: 65_536)
    |> validate_format(:instruction_digest, @digest_regex)
    |> validate_format(:memory_snapshot_ref, @memory_snapshot_regex)
    |> validate_format(:profile_memory_snapshot_ref, @profile_memory_snapshot_regex)
    |> validate_optional_digest(:program_artifact_digest)
    |> validate_refs()
    |> validate_map(:selected_evidence, 65_536)
    |> validate_map(:program_artifact_receipt, 4_096)
    |> foreign_key_constraint(:voice_session_id)
    |> foreign_key_constraint(:user_message_id)
    |> unique_constraint([:voice_session_id, :generation, :provider_input_item_id],
      name: :voice_response_context_input_item_index
    )
    |> unique_constraint(:user_message_id)
  end

  defp validate_optional_digest(changeset, field) do
    case get_field(changeset, field) do
      nil -> changeset
      _digest -> validate_format(changeset, field, @digest_regex)
    end
  end

  defp validate_refs(changeset) do
    validate_change(changeset, :selected_source_refs, fn :selected_source_refs, refs ->
      if is_list(refs) and length(refs) <= 100 and
           Enum.all?(refs, &(is_binary(&1) and byte_size(&1) in 1..256)) and
           length(refs) == MapSet.size(MapSet.new(refs)),
         do: [],
         else: [selected_source_refs: "must contain unique bounded references"]
    end)
  end

  defp validate_map(changeset, field, maximum_bytes) do
    validate_change(changeset, field, fn ^field, value ->
      case Jason.encode(value) do
        {:ok, encoded} when byte_size(encoded) <= maximum_bytes -> []
        _invalid -> [{field, "is invalid or too large"}]
      end
    end)
  end
end