# `Arcana.Agent`
[🔗](https://github.com/georgeguimaraes/arcana/blob/main/lib/arcana/agent.ex#L1)

Pipeline-based agentic RAG for Arcana.

Compose steps via pipes with a context struct flowing through each transformation:

    Arcana.Agent.new(question, llm: llm_fn)
    |> Arcana.Agent.search()
    |> Arcana.Agent.answer()

## Context

The `Arcana.Agent.Context` struct flows through the pipeline, accumulating
results at each step. Each step transforms the context and passes it on.

## Steps

- `new/1,2` - Initialize context with question and options
- `search/2` - Execute search, populate results
- `answer/1` - Generate final answer from results

## Configuration

Set defaults in your config to avoid passing options every time:

    config :arcana,
      repo: MyApp.Repo,
      llm: &MyApp.LLM.complete/1

## Example

    ctx =
      Arcana.Agent.new("What is Elixir?")
      |> Arcana.Agent.search()
      |> Arcana.Agent.answer()

    ctx.answer
    # => "Generated answer"

# `answer`

Generates the final answer from search results.

Collects all chunks from results, deduplicates by ID, and prompts the LLM
to generate an answer based on the context.

## Options

- `:answerer` - Custom answerer module or function (default: `Arcana.Agent.Answerer.LLM`)
- `:prompt` - Custom prompt function `fn question, chunks -> prompt_string end`
- `:llm` - Override the LLM function for this step
- `:self_correct` - Enable self-correcting answers (default: false)
- `:max_corrections` - Max correction attempts (default: 2)

## Example

    ctx
    |> Agent.search()
    |> Agent.answer()

    ctx.answer
    # => "The answer based on retrieved context..."

## Custom Answerer

    # Module implementing Arcana.Agent.Answerer behaviour
    Agent.answer(ctx, answerer: MyApp.TemplateAnswerer)

    # Inline function
    Agent.answer(ctx, answerer: fn question, chunks, opts ->
      llm = Keyword.fetch!(opts, :llm)
      prompt = "Q: " <> question <> "
Context: " <> inspect(chunks)
      Arcana.LLM.complete(llm, prompt, [], [])
    end)

# `decompose`

Breaks a complex question into simpler sub-questions.

Uses the LLM to analyze the question and split it into parts that can
be searched independently. Simple questions are returned unchanged.

## Options

- `:decomposer` - Custom decomposer module or function (default: `Arcana.Agent.Decomposer.LLM`)
- `:prompt` - Custom prompt function `fn question -> prompt_string end`
- `:llm` - Override the LLM function for this step

## Example

    ctx
    |> Agent.decompose()
    |> Agent.search()
    |> Agent.answer()

The sub-questions are stored in `ctx.sub_questions` and used by `search/2`.

## Custom Decomposer

    # Module implementing Arcana.Agent.Decomposer behaviour
    Agent.decompose(ctx, decomposer: MyApp.KeywordDecomposer)

    # Inline function
    Agent.decompose(ctx, decomposer: fn question, _opts ->
      {:ok, [question]}  # No decomposition
    end)

# `expand`

Expands the query with synonyms and related terms.

Uses the LLM to add related terms and synonyms that may help
find more relevant documents. The expanded query is used by `search/2`
if present.

## Options

- `:expander` - Custom expander module or function (default: `Arcana.Agent.Expander.LLM`)
- `:prompt` - Custom prompt function `fn question -> prompt_string end`
- `:llm` - Override the LLM function for this step

## Example

    ctx
    |> Agent.expand()
    |> Agent.search()
    |> Agent.answer()

The expanded query is stored in `ctx.expanded_query` and used by `search/2`.

## Custom Expander

    # Module implementing Arcana.Agent.Expander behaviour
    Agent.expand(ctx, expander: MyApp.ThesaurusExpander)

    # Inline function
    Agent.expand(ctx, expander: fn question, _opts ->
      {:ok, question <> " programming development"}
    end)

# `gate`

Decides whether retrieval is needed for the question.

Uses the LLM to determine if the question can be answered from general
knowledge or if it requires searching the knowledge base. Questions
about basic facts, math, or general knowledge can skip retrieval.

Sets `skip_retrieval: true` on the context if retrieval can be skipped,
which causes `answer/2` to generate a response without context.

## Options

- `:prompt` - Custom prompt function `fn question -> prompt_string end`
- `:llm` - Override the LLM function for this step

## Example

    ctx
    |> Agent.gate()      # Decides if retrieval is needed
    |> Agent.search()    # Skipped if skip_retrieval is true
    |> Agent.answer()    # Uses no-context prompt if skip_retrieval

# `new`

Creates a new agent context.

## Options

- `:repo` - The Ecto repo to use (defaults to `Application.get_env(:arcana, :repo)`)
- `:llm` - Function that takes a prompt and returns `{:ok, response}` or `{:error, reason}`
  (defaults to `Application.get_env(:arcana, :llm)`)
- `:limit` - Maximum chunks to retrieve (default: 5)
- `:threshold` - Minimum similarity threshold (default: 0.5)

## Example

    # With config defaults
    config :arcana, repo: MyApp.Repo, llm: &MyApp.LLM.complete/1

    Agent.new("What is Elixir?")

    # Or with explicit options
    Agent.new("What is Elixir?", repo: MyApp.Repo, llm: &my_llm/1)

# `reason`

Evaluates if search results are sufficient and searches again if not.

This step implements multi-hop reasoning by:
1. Asking the LLM if current results can answer the question
2. If not, getting a follow-up query and searching again
3. Repeating until sufficient or max iterations reached

Tracks `queries_tried` to prevent searching the same query twice.

## Options

- `:max_iterations` - Maximum additional searches (default: 2)
- `:prompt` - Custom prompt function `fn question, chunks -> prompt_string end`
- `:llm` - Override the LLM function for this step

## Example

    ctx
    |> Agent.search()
    |> Agent.reason()    # Multi-hop if needed
    |> Agent.rerank()
    |> Agent.answer()

# `rerank`

Re-ranks search results to improve quality before answering.

Scores each chunk based on relevance to the question, filters by threshold,
and re-sorts by score. Uses `Arcana.Reranker.LLM` by default.

## Options

- `:reranker` - Custom reranker module or function (default: `Arcana.Reranker.LLM`)
- `:threshold` - Minimum score to keep (default: 7, range 0-10)
- `:prompt` - Custom prompt function for LLM reranker `fn question, chunk_text -> prompt end`

## Example

    ctx
    |> Agent.search()
    |> Agent.rerank()
    |> Agent.answer()

## Custom Reranker

    # Module implementing Arcana.Reranker behaviour
    Agent.rerank(ctx, reranker: MyApp.CrossEncoderReranker)

    # Inline function
    Agent.rerank(ctx, reranker: fn question, chunks, opts ->
      {:ok, my_rerank(question, chunks)}
    end)

The reranked results replace `ctx.results`, and scores are stored in `ctx.rerank_scores`.

# `rewrite`

Rewrites conversational input into a clear search query.

Uses the LLM to remove conversational noise (greetings, filler phrases)
while preserving the core question and all important terms.

This step should run before `expand/2` and `decompose/2` to clean up
the input before further transformations.

## Options

- `:rewriter` - Custom rewriter module or function (default: `Arcana.Agent.Rewriter.LLM`)
- `:prompt` - Custom prompt function `fn question -> prompt_string end`
- `:llm` - Override the LLM function for this step

## Example

    ctx
    |> Agent.rewrite()   # "Hey, tell me about Elixir" → "about Elixir"
    |> Agent.expand()
    |> Agent.search()
    |> Agent.answer()

## Custom Rewriter

    # Module implementing Arcana.Agent.Rewriter behaviour
    Agent.rewrite(ctx, rewriter: MyApp.RegexRewriter)

    # Inline function
    Agent.rewrite(ctx, rewriter: fn question, _opts ->
      {:ok, String.downcase(question)}
    end)

# `search`

Executes search and populates results in the context.

Uses `sub_questions` if present (from decompose step), otherwise uses the original question.

## Collection Selection

Collections are determined in this priority order:
1. `:collection` or `:collections` option passed to this function
2. `ctx.collections` (set by `select/2` if LLM selection was used)
3. Falls back to `"default"` collection

This allows you to explicitly specify a collection without using LLM-based selection:

    # Search a specific collection
    ctx |> Agent.search(collection: "technical_docs")

    # Search multiple specific collections
    ctx |> Agent.search(collections: ["docs", "faq"])

## Options

- `:searcher` - Custom searcher module or function (default: `Arcana.Agent.Searcher.Arcana`)
- `:collection` - Single collection name to search (string)
- `:collections` - List of collection names to search
- `:self_correct` - Enable self-correcting search (default: false)
- `:max_iterations` - Max retry attempts for self-correct (default: 3)
- `:sufficient_prompt` - Custom prompt function `fn question, chunks -> prompt_string end`
- `:rewrite_prompt` - Custom prompt function `fn question, chunks -> prompt_string end`

## Examples

    # Basic search (uses default collection)
    ctx |> Agent.search() |> Agent.answer()

    # Search specific collection
    ctx |> Agent.search(collection: "products") |> Agent.answer()

    # With pipeline options
    ctx
    |> Agent.expand()
    |> Agent.search(collection: "docs", self_correct: true)
    |> Agent.answer()

## Custom Searcher

    # Module implementing Arcana.Agent.Searcher behaviour
    Agent.search(ctx, searcher: MyApp.ElasticsearchSearcher)

    # Inline function
    Agent.search(ctx, searcher: fn question, collection, opts ->
      {:ok, my_search(question, collection, opts)}
    end)

## Self-correcting search

When `self_correct: true`, the agent will:
1. Execute the search
2. Ask the LLM if results are sufficient
3. If not, rewrite the query and retry
4. Repeat until sufficient or max_iterations reached

# `select`

Selects which collection(s) to search for the question.

By default, uses the LLM to decide which collection(s) are most relevant.
You can provide a custom selector module or function for deterministic routing.

Collection descriptions are automatically fetched from the database
and passed to the selector.

## Options

- `:collections` (required) - List of available collection names
- `:selector` - Custom selector module or function (default: `Arcana.Agent.Selector.LLM`)
- `:prompt` - Custom prompt function for LLM selector
- `:context` - User context map passed to custom selectors

## Example

    # LLM-based selection (default)
    ctx
    |> Agent.select(collections: ["docs", "api", "support"])
    |> Agent.search()

    # Custom selector module
    ctx
    |> Agent.select(
      collections: ["docs", "api"],
      selector: MyApp.TeamBasedSelector,
      context: %{team: user.team}
    )

    # Inline selector function
    ctx
    |> Agent.select(
      collections: ["docs", "api"],
      selector: fn question, _collections, _opts ->
        if question =~ "API", do: {:ok, ["api"], "API query"}, else: {:ok, ["docs"], nil}
      end
    )

The selected collections are stored in `ctx.collections` and used by `search/2`.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
