qfa.api.composition#

Composition helpers for constructing the application services.

This module is the domain-graph half of the composition root. The FastAPI lifespan in qfa.api.app still owns infrastructure wiring (database engine, usage repository, TrackingLLMAdapter, app.state attachment, logging setup) but delegates the construction of the application services themselves — together with their driven adapters that don’t require the database — to this module.

build_services() returns every application service as a ServiceGraph; build_analyze_service() is the narrower entry point for callers (scripts, notebooks) that only want the analyze use case.

Why it lives here rather than at package root:

  • qfa.api already has import-linter permission to import both qfa.services and qfa.adapters; placing the factory here keeps the existing contracts untouched.

  • AGENTS.md designates qfa.api.app as the composition root, and this module is a sibling extraction — the architectural role hasn’t moved, just the construction code.

The factories are intentionally pure with respect to the API server’s runtime concerns. They do not construct a database engine, do not wrap the LLM in TrackingLLMAdapter, and do not read API keys. Callers that need those concerns (notably the FastAPI lifespan) build them and pass the wrapped LLM in via the llm keyword argument. Callers that don’t (scripts, notebooks, ad-hoc evaluation harnesses) call build_services (or one of its single-service wrappers) with no overrides and get services over a plain LiteLLM client.

Besides the driven adapters, this module also builds the LLMCallExecutor the services delegate their LLM-call scaffolding to. Per ADR-017 that collaborator is injected, not self-constructed, so the composition root stays the one place where the object graph is assembled — and there is exactly one executor instance, shared by every service.

The services may hold two LLM connections: the primary one used for generation, and an optional second one used only for judge calls, configured via JUDGE_LLM_*. resolve_judge_llm_settings() applies the judge/primary inheritance rule here, once, before either client is built.

Functions

build_analyze_service(settings, *[, llm, ...])

Build only the AnalyzeService half of build_services().

build_embedder(settings)

Build the self-hosted embedding adapter, or return None when unconfigured.

build_services(settings, *[, llm, ...])

Construct every application service from application settings.

register_custom_model_prices()

Load custom model pricing from the bundled YAML resource.

resolve_judge_llm_settings(primary, judge)

Resolve the judge connection settings against the primary ones.

Classes

ServiceGraph(sensitivity, coding, analyze, ...)

The application services the API publishes on app.state.

class qfa.api.composition.ServiceGraph(sensitivity: SensitivityService, coding: CodingService, analyze: AnalyzeService, summarize: SummarizeService)[source]#

Bases: object

The application services the API publishes on app.state.

Epic #112 split the one-time Orchestrator god class into one service per use case (#267 removed the emptied-out class itself), so the composition root returns more than one object. Grouping them keeps the shared parts of the graph — notably the single LLMCallExecutor — built once, and gives the lifespan one thing to construct and unpack.

Variables:
  • sensitivity (SensitivityService) – The detect-sensitive use case, extracted in #263.

  • coding (CodingService) – The assign-codes use case, backing POST /v1/assign-codes.

  • analyze (AnalyzeService) – The analyze use case (analyze_bulk, analyze_hierarchical), extracted in #266.

  • summarize (SummarizeService) – The summarize / summarize_bulk use cases, extracted in #264.

sensitivity: SensitivityService#
coding: CodingService#
analyze: AnalyzeService#
summarize: SummarizeService#
qfa.api.composition.resolve_judge_llm_settings(primary: LLMSettings, judge: JudgeLLMSettings) LLMSettings | None[source]#

Resolve the judge connection settings against the primary ones.

This is the single place the judge/primary inheritance rule is applied, so no judge.x or primary.x fallback has to be repeated at any call site. It runs before either client is built.

The rule is per field: an explicitly set JUDGE_LLM_* field overrides only itself, every unset field (None) keeps the primary’s value — including api_key, which is why enabling a judge model needs no new secret. timeout_seconds, max_total_tokens and chars_per_token have no judge-side override and always come from primary.

Parameters:
  • primary (LLMSettings) – The primary (generation) LLM connection settings, i.e. LLM_*.

  • judge (JudgeLLMSettings) – The judge overrides, i.e. JUDGE_LLM_*.

Returns:

None when judge.model is unset or empty — meaning no separate judge connection is configured and judge calls should keep using the primary client. Otherwise a complete LLMSettings describing the judge connection, ready to hand to build_llm_client.

Return type:

LLMSettings | None

qfa.api.composition.build_embedder(settings: EmbeddingSettings) EmbeddingPort | None[source]#

Build the self-hosted embedding adapter, or return None when unconfigured.

The embedder is optional: when EMBEDDING_MODEL_PATH is not set this returns None, and a mode=hierarchical request then fails with 502 analysis_unavailable (AnalyzeService raises AnalysisError when its embedder is None); single_pass is unaffected. Production deployments set the path variables; local / CI runs omit them so the normal test suite never downloads a multi-GB model.

Parameters:

settings (EmbeddingSettings) – Embedding configuration loaded from environment variables.

Returns:

A fully-constructed OnnxEmbedder (for the configured EMBEDDING_MODEL_KIND), or None when model_path is empty.

Return type:

EmbeddingPort | None

qfa.api.composition.register_custom_model_prices() None[source]#

Load custom model pricing from the bundled YAML resource.

Registers models with LiteLLM so that completion_cost() works for models not in the built-in cost map. Idempotent: LiteLLM’s register_model overwrites existing entries with the same key, so repeated calls (e.g. once per build_services in a notebook) are safe.

qfa.api.composition.build_services(settings: AppSettings, *, llm: LLMPort | None = None, judge_llm: LLMPort | None = None, embedder: EmbeddingPort | None = None) ServiceGraph[source]#

Construct every application service from application settings.

This is the shared composition point used by both the FastAPI lifespan and out-of-process callers (scripts, notebooks). It owns the construction of the services’ driven dependencies that do not require a database connection: the anonymiser, the LLM client (when not overridden), and the optional embedder — plus the one LLMCallExecutor every service shares.

Every service is built over the same LLMCallExecutor, so the per-call timeout, the token ceiling and the anonymiser are configured once for the whole graph rather than per use case.

Parameters:
  • settings (AppSettings) – Loaded application settings. Sub-settings consulted: llm (for the default LLM client), embedding (for the default embedder), orchestrator, and analyze.

  • llm (LLMPort | None, optional) – Pre-built LLM port to use instead of constructing one from settings.llm. The FastAPI lifespan passes a TrackingLLMAdapter here so usage is recorded; scripts can pass a logging wrapper or a fake for offline runs. None (the default) builds a plain LiteLLMClient — suitable for one-shot scripts that don’t need DB-backed tracking.

  • judge_llm (LLMPort | None, optional) – Pre-built LLM port for judge calls, mirroring llm. The FastAPI lifespan passes a second TrackingLLMAdapter here so judge usage and cost are recorded too. None (the default) builds one from settings.judge_llm resolved against settings.llm — and stays None when JUDGE_LLM_MODEL is unset, in which case the services run judge calls on the primary client, exactly as they did before the judge connection existed. Note this is resolved independently of llm: a caller that injects a fake primary and has JUDGE_LLM_MODEL set in the environment should inject a judge fake too, or it will get a real judge client alongside the fake.

  • embedder (EmbeddingPort | None, optional) – Pre-built embedder to use instead of constructing one from settings.embedding. Pass an explicit value when the caller has already constructed one (e.g. the lifespan, which logs its construction before delegating). None (the default) builds one via build_embedder() and may legitimately remain None when the embedding model path is unset — in that case hierarchical analysis will fail at runtime with AnalysisError (single-pass remains usable). Only AnalyzeService takes it; no other use case needs an embedder.

Returns:

The fully wired services, sharing one executor and one anonymiser, ready to be published on app.state.

Return type:

ServiceGraph

qfa.api.composition.build_analyze_service(settings: AppSettings, *, llm: LLMPort | None = None, judge_llm: LLMPort | None = None, embedder: EmbeddingPort | None = None) AnalyzeService[source]#

Build only the AnalyzeService half of build_services().

Convenience wrapper for scripts and notebooks that drive analyze_bulk / analyze_hierarchical in-process. Pass embedder (or configure EMBEDDING_MODEL_PATH) for the hierarchical mode; without one it raises AnalysisError at request time and single_pass still works.