Semantics of calculation return value

Hello,

I hope someone can clarify the semantics of calculation return value. The following calculation of a last response from an LLM that is stored in a relationship works:

  calculations do
    calculate :llm_response, :string do
      calculation fn llm_response, _context ->
        [llm_response] = Ash.load!(llm_response, run: [:messages])
        last_message = llm_response.run.messages |> Enum.at(-1)
        [last_message.content]
      end
    end
  end

It loads a nested relationship and extract the content of the last message. After the definitions above, calling

{:ok, run} = LLMRun |> Ash.get("27face7e-6443-43bd-bb69-e7529eedd20c", load: [:llm_response])

will return a run resource where run.llm_response is a string, as expected.

However, I don’t fully understand why I should wrap the content (which is a string) in a list, given that I defined the return type as a :string in calculate :llm_response, :string? (If I don’t wrap it in a list, I get (Protocol.UndefinedError) protocol Enumerable not implemented for type BitString.; I decided to experiment with wrapping the return value in a list and it worked, to my surprise).

I’m using AshSqlite.DataLayer (not sure if that matters or not).

Thank you!

Calculations operate on a list of records for efficiency reasons. The calculate function is passed in a list of records and must return a list of calculated terms.

For example, if you were selecting 100 records, and needed to load some associated data from the database for each record, this could be done in a single load instead of 100.

2 Likes

Also worth pointing out that that only works correctly because you are using Ash.get and so only have a single record.

If you did a_list_of_records |> Ash.load(:llm_response) your [single_thing] = ... match would fail.

1 Like

Thank you both, it’s very clear now!

Indeed, I changed my code (after refactoring some attribute names) to

  calculations do
    calculate :llm_response, :string do
      calculation fn runs, _context ->
        runs
        |> Ash.load!(chain: [:messages])
        |> Enum.map(fn run ->
          last_message = run.chain.messages |> List.last()
          last_message.content
        end)
      end
    end
  end

and now the above Ash.get as well as

LLMRun |> Ash.Query.for_read(:read) |> Ash.read!(load: :llm_response)

work as expected.

1 Like

You can optimize this further:

  calculations do
    calculate :llm_response, :string do
      calculation fn runs, _context ->
        message_query =
          Message
          |> Ash.Query.sort(created_at: :desc)
          |> Ash.Query.limit(1)

        runs
        |> Ash.load!(chain: [messages: message_query])
        |> Enum.map(fn run ->
          last_message = run.chain.messages |> List.last()
          last_message.content
        end)
      end
    end
  end

Furthermore, if you break it out into a module-based calculation, you can take advantage of the more optimized dependency loading for calculations.

defmodule MyApp.MyDomain.MyResource.Calculations.LastRun do
  use Ash.Resource.Calculation

  def load(_, _, _) do
    message_query =
      Message
      |> Ash.Query.sort(created_at: :desc)
      |> Ash.Query.limit(1)

    [chain: [messages: message_query]]
  end

  def calculate(_, _, _) do
    Enum.map(runs, fn run ->
      last_message = run.chain.messages |> List.last()
      last_message.content
    end)
  end
end

Then you can do calculate :llm_response, :string, MyApp.MyDomain.MyResource.Calculations.LastRun

1 Like

Great suggestion, thank you! Reading the documentation more closely, I see that anonymous lambdas are not recommended, and I should be really using module-based approach everywhere.

1 Like

And while we are on this topic: I assume that I cannot use expressions here because of the need to refer to (nested) relationships?

In this case, actually, you can :slight_smile:

calculate :llm_response, :string, expr(first(chain.messages, field: :content, query: [sort: [created_at: :desc]]))

Something like that should work.

1 Like

Wow! I keep getting impressed by Ash.