Skip to content

Talk to your data

A type-safe analytics agent that answers questions about a dataset and checks the arithmetic. It threads three ideas into one workflow: signatures, a code-writing agent, and prompt optimization with a grounded metric.

The model's intent is a domain value

The agent never lets the model emit a free-form answer. It first asks the model to produce a QueryPlan: a validated Scala value describing the computation. The same type constrains the model's output, decodes its reply, drives the optimizer's metric, and renders the report.

final case class QueryPlan(
    agg       : Agg,
    column    : Option[String],
    groupBy   : List[String],
    filters   : List[Filter],
    timeRange : Option[TimeRange],
    sort      : Option[Sort],
    limit     : Option[Int],
    answerKind: AnswerKind
) derives Schema

Fields like agg are enums, so the model can only pick a defined operation. The prompt cannot drift between "avg", "mean", and "average":

enum Agg derives Schema, CanEqual:
  case Count, Sum, Average, Min, Max, Median

Plan, then compute in code

The dataset is thousands of CSV rows, too large to put in a prompt. So the agent runs in two stages. Plan asks the model for a QueryPlan. Act hands the plan and the data to an RLM, which writes and runs Python in a sandbox to compute the answer:

def plan(question: String, instructions: String)(using RuntimeContext): Either[DspyError, QueryPlan] =
  planner(instructions)(Question(question, Dataset.schemaDescription)).map(_.output)

def act(plan: QueryPlan, question: String, feedback: String)(using
    RuntimeContext
): Either[DspyError, AnalysisResult] =
  executor(ActInput(Dataset.csv, describePlan(plan), question, feedback)).map(_.output)

At the iteration limit, RLM asks the model for the outputs with no more code, so the model can then write the values itself.

A third stage, verify, re-computes the same plan independently on the JVM with a plain Scala query engine and requires the two answers to agree, also for such values. A mismatch triggers a refine attempt.

Optimization with a grounded metric

The planner's instruction starts vague. GEPA evolves it against a metric that is the deterministic Scala engine compared to an answer key. The answer key comes from hand-written plans, which are labels, and from the same engine. The offline check compares some answers with a direct computation. The metric thus gives the optimizer a score from code, with no LLM judge.

Running it

OPENAI_API_KEY=sk-... sbt "examples/runMain dspy4s.examples.tutorials.talk_to_your_data.talkToYourDataMain"

GEPA optimization needs only a language model. The full plan-act-verify agent also needs Deno on the path for the sandbox. An offline foundation check (tytdSelfCheckMain) runs with neither.

Notes

This is a multi-file example: schemas, the dataset and query engine, the agent, and the optimization setup each live in their own file. Full source: talk_to_your_data.