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 UIGET /docs on a running instance

  • OpenAPI JSONGET /openapi.json on 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

POST

/v1/analyze-bulk

Bulk free-text analysis over submitted feedback records

POST

/v1/summarize

Per-record summaries with quality scores

POST

/v1/summarize-bulk

Single bulk summary with judge score

POST

/v1/assign-codes

Hierarchical code assignment

GET

/v1/usage

Aggregate stats for the caller’s tenant

GET

/v1/usage/all/by-tenant

Cross-tenant stats, tenants top-level with per-operation nested (requires is_superuser=true)

GET

/v1/usage/all/by-operation

Cross-tenant stats, operations top-level with per-tenant nested (requires is_superuser=true)

GET

/v1/health

Liveness probe; no auth

POST /v1/analyze-bulk — field reference#

Request#

Field

Type

Default

Description

feedback_records

list

Non-empty list of {id, content, metadata?, url_id?} records. Individual records may have empty content (e.g. a blank EspoCRM description) — those are dropped before analysis rather than failing the request.

prompt

string

Analyst question (1–4000 chars).

output_language

string or null

null

Free-text target language for the analysis output (e.g. "Dutch", "Brazilian Portuguese") — any language the model can produce. Prefer an ISO 639-1 code ("nl") or English language name ("Dutch") for the most predictable results. The value is sanitized and never rejected. Omit (or null) to let the model answer in the language of the input records.

anonymize

bool

true

Anonymize record text before the LLM call.

mode

"single_pass" | "hierarchical"

"single_pass"

single_pass runs one LLM call under the token cap (input over the cap → 413). hierarchical runs embed → cluster → map → reduce over large corpora and additionally returns confidence.

period

"day" | "week" | "month" | null

null → server default (week)

Granularity for the deterministic coding_trends table. day for short-window deep-dives, week for the typical 1-3 month operational corpus, month for multi-year corpora. Omit to use the server-side default (ANALYZE_DEFAULT_CODING_TREND_PERIOD).

espo_feedback_base_url

string or null

null

Base URL for the EspoCRM feedback record detail view. See Hyperlinking feedback records below.

Response (200 OK)#

Field

Type

Notes

analysis

string

Model output.

quality_score

float or null

Judge score in [0, 1]. null when the judge call failed (not an error — see uncertainty_explanation).

uncertainty_explanation

string

Natural-language judge reasoning, or a constant unavailable message when the judge failed.

feedback_record_count

int

Number of records actually analyzed (records with empty content are dropped).

request_id

string

Canonical UUID matching the X-Request-ID response header.

used_anonymization

bool

Whether anonymization was applied.

confidence

float or null

Coverage-weighted mean of per-chunk faithfulness scores. Populated only for mode=hierarchical; null for single_pass.

coding_trends

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). null when no record carries a parseable date. Bucket-label shape depends on period: YYYY-MM-DD for day, YYYY-Www (ISO week) for week, YYYY-MM for month.

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/summarize that 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

error.code

401

authentication_required

403

forbidden

404

not_found

409

conflict

413

payload_too_large

422

validation_error, json_invalid, prompt_injection_detected, content_policy_violation

429

llm_rate_limited (see below)

502

analysis_unavailable, llm_error

503

usage_backend_unavailable

504

analysis_timeout, llm_timeout

500

internal_error

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 U+0000U+001F (\n, \r, \t, \f, \v, …), " and \ must be escaped inside a JSON string. Serialise with a JSON library; never assemble a body by concatenating raw field values.

Parse failure

A body that does not parse returns 422 json_invalid, with the cause and the byte offset in fields[0].issue. This happens before any route handler runs, so no server-side sanitiser can rescue it.

Rejected after parsing

Three content classes return 422 prompt_injection_detected: a NUL byte, 200 or more consecutive identical characters (e.g. a pasted ------ divider), and content beginning SYSTEM:, ASSISTANT: or USER:.

Breaking changes#

API field names changed in 0.14.0 (the ubiquitous-language migration). See the migration guide for 0.14.0.