My app is LangChain4j: what do I put in my repo?¶
One HTTP endpoint on your side, and three files in an eval/ directory. digline
never imports your application — it posts a question and reads the answer — so
nothing about your build, your framework or your deployment has to change.
There are two services here standing in for yours — app-spring/ on Spring
Boot, app-quarkus/ on Quarkus — and they share one prompt file, one model and
one endpoint contract. The suite, the cases, the stub and the baseline do not
know which of them answered, and do not change when you swap one for the other.
That is the claim this example is making: the framework is not the contract;
the endpoint is.
stub.py answers the same shape without a JVM, so everything below runs with no
Java and no API key.
1. One endpoint¶
digline needs three things back, and each is something it cannot work out for itself once the model call happens on your side of HTTP.
{
"data": "Order 4821 left our warehouse on Tuesday. — Northwind Support",
"usage": { "cost_usd": 0.00021, "elapsed_ms": 380.0 },
"config": { "provider": "openai", "model": "gpt-4o-mini",
"temperature": 0.0, "max_tokens": 512 }
}
data— what the assistant said. This is what gets judged. A string, or an object if that is what your endpoint returns.usage— what the call cost and how long it took. digline cannot price a call it did not make, so you report it. The price list lives in your code (SupportService.java, in either service), dated, because a price is a fact about a day.config— which model answered and how it was set up. Without this a run records nothing about the system under test, and the day somebody bumps the model the comparison says the configuration is unchanged. With it, the report says "this drop coincides with model gpt-4o-mini → gpt-4o".
The keys under config are a closed set: provider, model, max_tokens,
temperature, top_p, top_k, seed, region, base_url, response_format,
json_mode. provider and model are required; the rest are optional and a
null means "we did not send it". An unknown key is refused by name rather than
recorded — an open bag of fields is where a customer identifier ends up.
The whole integration is about forty lines that call a service you already
have. In app-spring/ it is EvaluationController.java; in app-quarkus/ it
is EvaluationResource.java. Spring MVC and JAX-RS, same three fields, same
JSON, and neither one imports anything from digline.
2. Three files in eval/¶
Here they are at the top level, because this directory is the eval directory.
In your repository they go in eval/ beside src/, and they are the only
Python you own.
cases.json — your questions. Plain data; keep adding to it. Every failure
worth not repeating becomes a case here.
suite.py — about forty lines, and three of them are yours. They are marked
EDIT in the file:
URL— where your service listens.request=— the body your endpoint expects, built from the case.output_path/cost_path/latency_from_response/config_path— where those four things sit in your answer, written as dotted paths.
Everything else is the checks. Read them once and change them when you have a reason:
(Contains(needle="Northwind Support"),) # the sign-off the prompt requires
(NotContains(needle="As an AI"),) # the hedging that creeps in
(Length(minimum=12, maximum=60, unit="words"),)
(CostBudget(max_usd=0.002, tolerance=0.05),)
(LatencyBudget(max_ms=2000.0, tolerance=0.10),)
A budget is a ceiling, not a metric: exceeding it fails the run.
The suite also names your prompt as an artifact:
The prompt is the thing under test, so every run records it and the report shows the diff above the scores it moved. It sits at the top level here rather than inside either service, because both package it from there — one file, so the two cannot drift, and the suite names it without naming a framework. In your own repository it goes wherever your service reads it from.
The baseline — .digline/northwind/baselines/support.json, written by
promote and committed. This is the file that makes the whole thing work: the
approved answers, in your repository, moving with it, reviewed in your pull
requests. Runs go to .digline/northwind/runs/ and are gitignored.
3. Run it, read it, approve it¶
$ uv run digline run --suite suite.py
2026-09-01T13-12-52-981903-00-00-bc060bda8aefb7f4
$ uv run digline report --suite suite.py --run latest --locale en --out report.html
$ uv run digline promote --suite suite.py --run latest
Read the report before you promote. promote means these answers are the ones
we stand behind — it is a decision, not a build step, which is why nothing
does it for you and why the result is a file you commit.
report.html in this directory is the one this example produced.
--locale it renders the same run in Italian; the dates and the numbers do not
move, so two reports of one run stay comparable line by line.
4. Compare on every change¶
Not only when you edit the prompt. The three changes this catches that a diff cannot:
- the prompt moved — the report shows the diff of the file, above the scores;
- the model moved — someone bumps
support.modelinapplication.properties, and the comparison names it:model gpt-4o-mini → gpt-4o, beside any score that dropped with it; - nothing you did moved — the provider updated the model under a name that did not change. This is the one nobody notices, and it is why the workflow below also runs on a schedule.
compare reports a configuration change; it never fails on one. What fails the
run is a score that got worse.
5. The gate¶
.github/workflows/check.yml. Two jobs: your Maven build, and the
comparison.
- name: Compare with the committed baseline
run: |
KEY=$(uv run digline run --suite suite.py)
uv run digline compare --suite suite.py --run "$KEY"
compare exits 0 when nothing got worse, 1 when something did, 2
when a case could not be judged at all. That is the gate — no parsing of output,
no threshold in the workflow.
Note what is not there: promote. A job that promotes and then compares is
comparing a run with itself, and passes whatever happened.
Set SUPPORT_URL to a deployed instance of your service, and keep the provider
key on that side. Unset, the suite runs against stub.py.
6. What it costs you¶
Honestly:
uvand Python 3.12+ on the CI runner. Oneuv sync, cached.- One Python file in review.
suite.pyis about forty lines and someone on the team has to be able to read it. That is the real cost, and it is not zero. It is Python because a judge is an object and a check is a function; there is no configuration file today. - Reporting three fields from one endpoint, and keeping the price list in
SupportService.javacurrent.
And what it does not require:
- No port, no server, no account. digline is a command that reads and writes files in your repository.
- No data leaves. The only network call is the one your suite makes, to your own service. Baselines, runs and reports stay in your repository.
- No rewrite. The endpoint calls a service you already have.
Running a real service¶
Either one. They answer the same thing, and the suite does not change between them — which is the easiest way to see what this example is claiming.
Spring Boot:
$ cd app-spring && OPENAI_API_KEY=sk-... mvn spring-boot:run
$ cd .. && SUPPORT_URL=http://localhost:8080/evaluate uv run digline run --suite suite.py
Quarkus:
$ cd app-quarkus && OPENAI_API_KEY=sk-... mvn quarkus:dev
$ cd .. && SUPPORT_URL=http://localhost:8080/evaluate uv run digline run --suite suite.py
Both need a JDK 21 and an OpenAI key. The answers then stop being deterministic,
which is what tolerance on the budgets and Repeated on a judge are for — see
the guide (docs/guide.md), the chapter on judge noise.
The two differ inside, and none of it reaches the endpoint: Spring builds the
model in a constructor, Quarkus has the quarkus-langchain4j extension build it
from application.properties; and the extension currently brings langchain4j
1.0.0-beta2, where the interface is still ChatLanguageModel, against
1.0.1's ChatModel on the Spring side. Run the suite against either and the
run file is the same document.
Needs digline 0.3.0 (config_path on HttpTarget).