Current section
Files
Jump to
Current section
Files
lib/arcana/evaluation/generator.ex
defmodule Arcana.Evaluation.Generator do
@moduledoc """
Generates synthetic test cases from existing chunks.
Samples chunks and uses an LLM to generate questions that
should retrieve those chunks.
"""
import Ecto.Query
alias Arcana.{Chunk, LLM}
alias Arcana.Evaluation.TestCase
@default_prompt """
Given this text chunk from a document, generate a natural question that can ONLY be answered using information in this chunk.
Requirements:
- The question should be specific, not generic
- Someone searching for an answer would use similar words
- Return ONLY the question, nothing else
Text chunk:
{chunk_text}
"""
@doc """
Generates test cases from a sample of chunks.
## Options
* `:repo` - Ecto repo (required)
* `:llm` - LLM implementing Arcana.LLM protocol (required)
* `:sample_size` - Number of chunks to sample (default: 50)
* `:source_id` - Limit to chunks from specific source
* `:collection` - Limit to chunks from specific collection
* `:prompt` - Custom prompt template (must include {chunk_text})
"""
def generate(opts) do
repo = Keyword.fetch!(opts, :repo)
llm = Keyword.fetch!(opts, :llm)
sample_size = Keyword.get(opts, :sample_size, 50)
source_id = Keyword.get(opts, :source_id)
collection = Keyword.get(opts, :collection)
prompt_template = Keyword.get(opts, :prompt, @default_prompt)
chunks = sample_chunks(repo, sample_size, source_id, collection)
test_cases =
chunks
|> Enum.map(fn chunk ->
case generate_question(llm, chunk, prompt_template) do
{:ok, question} ->
create_test_case(repo, question, chunk)
{:error, _} ->
nil
end
end)
|> Enum.reject(&is_nil/1)
{:ok, test_cases}
end
@doc """
Returns the default prompt template.
"""
def default_prompt, do: @default_prompt
defp sample_chunks(repo, sample_size, source_id, collection) do
query =
from(c in Chunk,
join: d in assoc(c, :document),
order_by: fragment("RANDOM()"),
limit: ^sample_size
)
query =
if source_id do
from([c, d] in query, where: d.source_id == ^source_id)
else
query
end
query =
if collection do
from([c, d] in query,
join: col in assoc(d, :collection),
where: col.name == ^collection
)
else
query
end
repo.all(query)
end
defp generate_question(llm, chunk, prompt_template) do
prompt = String.replace(prompt_template, "{chunk_text}", chunk.text)
LLM.complete(llm, prompt, [], [])
end
defp create_test_case(repo, question, source_chunk) do
test_case =
%TestCase{}
|> TestCase.changeset(%{
question: String.trim(question),
source: :synthetic,
source_chunk_id: source_chunk.id
})
|> repo.insert!()
# Link the source chunk as a relevant chunk (convert UUIDs to binary for insert_all)
repo.insert_all("arcana_evaluation_test_case_chunks", [
%{
test_case_id: Ecto.UUID.dump!(test_case.id),
chunk_id: Ecto.UUID.dump!(source_chunk.id)
}
])
# Return with preloaded relevant_chunks
repo.preload(test_case, :relevant_chunks)
end
end