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.apialready has import-linter permission to import bothqfa.servicesandqfa.adapters; placing the factory here keeps the existing contracts untouched.AGENTS.mddesignatesqfa.api.appas 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 only the |
|
Build the self-hosted embedding adapter, or return None when unconfigured. |
|
Construct every application service from application settings. |
Load custom model pricing from the bundled YAML resource. |
|
|
Resolve the judge connection settings against the primary ones. |
Classes
|
The application services the API publishes on |
- class qfa.api.composition.ServiceGraph(sensitivity: SensitivityService, coding: CodingService, analyze: AnalyzeService, summarize: SummarizeService)[source]#
Bases:
objectThe application services the API publishes on
app.state.Epic #112 split the one-time
Orchestratorgod 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 singleLLMCallExecutor— 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.xfallback 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 — includingapi_key, which is why enabling a judge model needs no new secret.timeout_seconds,max_total_tokensandchars_per_tokenhave no judge-side override and always come fromprimary.- Parameters:
primary (LLMSettings) – The primary (generation) LLM connection settings, i.e.
LLM_*.judge (JudgeLLMSettings) – The judge overrides, i.e.
JUDGE_LLM_*.
- Returns:
Nonewhenjudge.modelis unset or empty — meaning no separate judge connection is configured and judge calls should keep using the primary client. Otherwise a completeLLMSettingsdescribing the judge connection, ready to hand tobuild_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_PATHis not set this returnsNone, and amode=hierarchicalrequest then fails with 502analysis_unavailable(AnalyzeServiceraisesAnalysisErrorwhen its embedder isNone);single_passis 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 configuredEMBEDDING_MODEL_KIND), orNonewhenmodel_pathis 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’sregister_modeloverwrites existing entries with the same key, so repeated calls (e.g. once perbuild_servicesin 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
LLMCallExecutorevery 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, andanalyze.llm (LLMPort | None, optional) – Pre-built LLM port to use instead of constructing one from
settings.llm. The FastAPI lifespan passes aTrackingLLMAdapterhere so usage is recorded; scripts can pass a logging wrapper or a fake for offline runs.None(the default) builds a plainLiteLLMClient— 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 secondTrackingLLMAdapterhere so judge usage and cost are recorded too.None(the default) builds one fromsettings.judge_llmresolved againstsettings.llm— and staysNonewhenJUDGE_LLM_MODELis 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 ofllm: a caller that injects a fake primary and hasJUDGE_LLM_MODELset 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 viabuild_embedder()and may legitimately remainNonewhen the embedding model path is unset — in that case hierarchical analysis will fail at runtime withAnalysisError(single-pass remains usable). OnlyAnalyzeServicetakes 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:
- 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
AnalyzeServicehalf ofbuild_services().Convenience wrapper for scripts and notebooks that drive
analyze_bulk/analyze_hierarchicalin-process. Passembedder(or configureEMBEDDING_MODEL_PATH) for the hierarchical mode; without one it raisesAnalysisErrorat request time andsingle_passstill works.