REST API#
The HTTP API the backend exposes. For the auto-generated reference of the qfa Python package, see Python API reference instead.
The live, always-current OpenAPI reference is served by FastAPI itself:
Swagger UI —
GET /docson a running instanceOpenAPI JSON —
GET /openapi.jsonon a running instance
For local dev, that’s http://localhost:8000/docs.
Quick reference#
All endpoints except GET /v1/health require Authorization: Bearer <key>.
Method |
Path |
Purpose |
|---|---|---|
|
|
Bulk free-text analysis over submitted feedback records |
|
|
Per-record summaries with quality scores |
|
|
Single bulk summary with judge score |
|
|
Hierarchical code assignment |
|
|
Aggregate stats for the caller’s tenant |
|
|
Cross-tenant stats, tenants top-level with per-operation nested (requires |
|
|
Cross-tenant stats, operations top-level with per-tenant nested (requires |
|
|
Liveness probe; no auth |
POST /v1/analyze-bulk — field reference#
Request#
Field |
Type |
Default |
Description |
|---|---|---|---|
|
list |
— |
Non-empty list of |
|
string |
— |
Analyst question (1–4000 chars). |
|
string or null |
|
Free-text target language for the analysis output (e.g. |
|
bool |
|
Anonymize record text before the LLM call. |
|
|
|
|
|
|
|
Granularity for the deterministic |
|
string or null |
|
Base URL for the EspoCRM feedback record detail view. See Hyperlinking feedback records below. |
Response (200 OK)#
Field |
Type |
Notes |
|---|---|---|
|
string |
Model output. |
|
float or null |
Judge score in [0, 1]. |
|
string |
Natural-language judge reasoning, or a constant unavailable message when the judge failed. |
|
int |
Number of records actually analyzed (records with empty |
|
string |
Canonical UUID matching the |
|
bool |
Whether anonymization was applied. |
|
float or null |
Coverage-weighted mean of per-chunk faithfulness scores. Populated only for |
|
object or null |
Deterministic code-by-period frequency table. Populated for both modes whenever the configured date + code metadata fields are present (it depends only on metadata, not on the analysis pipeline). |
For mode: "hierarchical", the response additionally populates confidence
(a coverage-weighted mean of per-chunk faithfulness). coding_trends is
populated for both modes, so existing single-pass integrations that ignored
the field are unaffected; clients that want trends can now read them from
the single-pass response too.
Per-record inference endpoints (/v1/summarize, /v1/assign-codes, /v1/detect-sensitive) accept a single feedback_record and return one result object, unlike bulk endpoints that accept multiple records and return aggregated output.
POST /v1/summarize takes no language parameter: the generated title and summary follow the record’s own language, detected server-side from its content. Records too short to detect fall back to instructing the model to mirror the input language.
Empty content is accepted on every endpoint and never causes a 422. A record with empty content carries no information, so it is dropped (bulk) or short-circuited to a 200 with no LLM call (per-record) — see each endpoint’s reference for the exact result shape. This keeps a single blank EspoCRM description from silently failing a whole request (issue #138).
POST /v1/assign-codes picks codes in a single LLM call: the classifier is shown the entire coding framework flattened into one option per node (every depth, not just leaves) and picks the best-fitting path(s) directly — no recursive per-level picking. Each selected path is then scored by a separate judge call per level, root to leaf, stopping at the first level that falls below confidence_threshold. It accepts an optional confidence_threshold; candidates whose judged confidence falls below it are filtered out. Its assigned_codes list is never empty: whenever no code is applied — because the content was empty, because no candidate reached the threshold, or because nothing in the framework was judged relevant — the response is a 200 with exactly one entry whose coding_level_*/confidence_* fields are null and whose explanation begins with the line NO CODING APPLIED. followed by the reason. A client therefore always has something to show the user instead of an unexplained empty list.
When the threshold is what blocked coding, the explanation names the threshold as a percentage and lists the closest near misses (at most three, highest-scoring first, each with its decisive level’s reasoning), then counts any remainder:
NO CODING APPLIED.
No code reached the 10% confidence threshold, so this record needs human review.
Shelter > Repairs > Roofing — 4%
No mention of roof damage; the feedback concerns rent costs.
Water — 3%
No reference to water access.
5 further codes scored below 3%.
These explanations are English only, regardless of the language of the feedback.
Hyperlinking feedback records#
/v1/analyze-bulk and /v1/summarize-bulk accept an optional espo_feedback_base_url alongside feedback_records. When it’s set, any mention of a feedback record’s id in the output text (e.g. an analysis citing Form-07762 as supporting evidence) is rewritten as a markdown hyperlink:
[Form-07762](espo_feedback_base_url/url_id)
url_id is a separate, optional field on each feedback record — the EspoCRM URL path segment for that record (distinct from id, which is the citation-friendly identifier the model sees and may repeat in prose). A record is only hyperlinked if both espo_feedback_base_url is set on the request and that record’s url_id is non-empty; otherwise its id is left as plain text. Matching is exact and word-boundary-safe (Form-1 never matches inside Form-10).
This is presentational only — it rewrites the already-generated analysis/summary text before it’s returned, including the pretty_output block, so no extra rendering step is needed on the EspoCRM side beyond the flowchart’s existing markdown-aware field display.
Usage endpoint response shape#
All usage endpoints return aggregated stats in two parallel views:
Per REST API call (top-level fields): each distinct call to one of the analysis endpoints (
/v1/analyze-bulk,/v1/summarize,/v1/summarize-bulk,/v1/assign-codes) counts as one. An endpoint like/v1/summarizethat fans out to several LLM calls internally (a summary call plus a judge call) still shows up as a single entry here.Per LLM call (
llm_call_stats): each individual LLM provider call counts as one. Use this view when you want to see raw provider traffic — for example to compute the LLM-calls-per-API-call ratio (llm_call_stats.total_calls / total_calls).
GET /v1/usage and GET /v1/usage/all/by-tenant carry an operations breakdown under each tenant block, sorted by total_cost_usd desc (ties: operation asc), with empty operations omitted. GET /v1/usage/all/by-operation flips the hierarchy: operations are top-level, each carrying a nested tenants breakdown (sorted by total_cost_usd desc, ties broken by tenant_id asc). Every block — at any level — carries its own llm_call_stats.
total_cost_usd sums every row in the window — including failed attempts that incurred a real cost — so the figure reflects what was actually spent. Distributions (avg/min/max/p5/p95) and token totals are computed over successful rows only so failures cannot skew them.
Full per-field semantics (including how failed_calls is counted for multi-LLM-call invocations and the asyncio.gather fan-out caveat on call_duration) live in the OpenAPI docs at GET /docs.
curl examples#
A minimal analyze-bulk call:
curl -X POST http://localhost:8000/v1/analyze-bulk \
-H "Authorization: Bearer $LOCAL_KEY" \
-H "Content-Type: application/json" \
-d '{
"feedback_records": [
{"id": "r-1", "content": "The coordination was good but shelter access was difficult."}
],
"prompt": "Identify the top themes.",
"mode": "single_pass"
}'
Example 200 response:
{
"analysis": "The feedback highlights ...",
"quality_score": 0.82,
"uncertainty_explanation": "Coverage is high; all themes supported by at least two records.",
"feedback_record_count": 1,
"request_id": "550e8400-e29b-41d4-a716-446655440000"
}
Error envelope#
Every error response shares this shape:
{
"error": {
"code": "validation_error",
"message": "Request validation failed.",
"request_id": "550e8400-e29b-41d4-a716-446655440000",
"fields": [
{"field": "feedback_records[0].content", "issue": "..."}
]
}
}
fields only appears on 422. request_id is always present and matches the X-Request-ID response header. It is a canonical UUID string and is also the value persisted in the llm_calls.call_id column for every LLM call the request makes — quote the request_id when reporting an issue and ops can join logs and DB rows on it directly. See Cross-cutting concerns § Error → HTTP mapping for the full mapping.
Status |
|
|---|---|
401 |
|
403 |
|
404 |
|
409 |
|
413 |
|
422 |
|
429 |
|
502 |
|
503 |
|
504 |
|
500 |
|
A 429 carries a Retry-After header (integer seconds) — honour it before retrying. 5xx and 429 messages are constant strings, not exception detail; request_id is the handle to give support, not the message text.
Request body encoding#
content — and every other string field — may hold any character, including line breaks, tabs and quotes, provided the body is valid JSON per RFC 8259. Do not strip whitespace characters client-side; escape them.
Rule |
Detail |
|---|---|
Escaping |
Control characters |
Parse failure |
A body that does not parse returns 422 |
Rejected after parsing |
Three content classes return 422 |
Breaking changes#
API field names changed in 0.14.0 (the ubiquitous-language migration). See the migration guide for 0.14.0.