med-agent-hub is the shared profile and model-execution service used by
ChartSearchAI, Catalyst, the validation harness, and direct OpenAI-compatible
clients. All configured workflows use the same Profile schema. Hosted
clinical profiles compose the Hub's clinical stage engine; caller-orchestrated
profiles configure named roles that an application such as Catalyst invokes
while retaining its own domain workflow.
The hosted clinical workflow remains the client-facing clinical answer service; the caller-orchestrated workflow does not move Catalyst's SQL business logic into the Hub.
client
-> POST /v1/chat/completions (profile id, messages, optional patient)
med-agent-hub
-> context source(s) and complete evidence ledger
-> optional deterministic context selection for oversized inputs
-> optional team gather
-> answer -> deterministic substance/temporal gate
-> immediate reference resolution and answer_done
-> optional review (labels the answer) -> re-gate -> final reference resolution
-> optional Answer citation grounding or second-writer agreement check
-> In-Depth -> deterministic temporal and citation-resolution checks, plus optional review and citation grounding checks that mark claims -> done
OpenAI-compatible model router at LLM_BASE_URL
Streaming and blocking requests execute the same asynchronous engine in server/engine.py. Blocking requests drain the engine's events into the response envelope. Product profiles stream answer_done, optional answer_validation, indepth_pending, indepth_done or indepth_error, and done.
Product terminal events carry the complete assistant envelope. In-Depth state is emitted only in the nested inDepth object so its answer cannot overwrite the direct clinical Answer. Checks mark In-Depth claims and never remove them: a marked claim is followed by a short note naming what to check, inDepth.validation.marked lists the marked claims, and confidence.in_depth counts them. When a deterministic date patch changes a claim, or the In-Depth fails after synthesis, inDepth.reviewDraft renders the pre-check claims and inDepth.reviewReferences resolves only that draft's citations. These review fields are explicitly not the shipped answer or final evidence set. Java and ESM clients reject the retired flattened In-Depth shape.
When a deterministic Answer check changes a model draft, answerValidation.originalAnswer preserves the earliest pre-check Answer and answerValidation.originalReferences resolves only that draft's citations. The final references field remains authoritative for the checked Answer and accepted In-Depth claims.
Configured profiles live in server/levels.yaml and declare a workflow,
human label, topology, ordered stages, role models, prompts, knobs, and policies.
workflow: clinical_answer profiles use the hosted clinical stage engine. The
workflow: catalyst_query profile uses topology: caller: Hub owns its models,
prompts, and knobs while Catalyst owns catalog context, SQL policy/lint,
writer/reviewer orchestration, execution, and lineage. The preferred clinical
product profile is single-12b-bare (Gemma 4 12B with deterministic chart,
temporal, and drug-safety checks only, omitting model review and citation
grounding); clinical discovery marks it as the effective default only when
available. single-12b-checked adds model review and citation grounding as a
checked research and comparison arm, while single-12b-fast runs the same
Answer checks without In-Depth, as an explicit faster choice; a product profile
may leave out In-Depth but never its gate. Product envelopes always enforce
deterministic temporal validation, regardless of discovery visibility, require
exact tokenizer-backed context counting, and apply the hub-owned chart_answer
JSON schema. A product request cannot replace that contract; low-level legs
retain their existing caller-controlled response_format behavior.
A profile that reviews or grounds its Answer declares a review policy:
action: label (the reviewer's verdict labels the Answer and never replaces it;
apply and redo are reserved and refused) and grounding, how cited records
are checked against their sentences. separate runs attribution checks
in context-bounded sequential batches of up to four pairs with the grounding model; folded
asks the reviewer to judge each cited record in the same call and writes the
citation statuses from its reply; off checks no citation support and leaves
citations unchecked without a label.
The choice covers the Answer and the In-Depth claims alike.
The engine implements these choices and never branches on a profile id.
Low-level experiment legs use these ids:
answer:<model>@<prompt>~<gate>~temp<n>answer-review:<model>@<prompt>indepth-only:<model>@<prompt>
Low-level legs are callable but are not advertised by GET /v1/models. Unknown ids return a structured model_not_found response; they are never forwarded to the model backend.
server/context_sources.py defines provider-neutral source, evidence-ledger, selector, and token-counter contracts. Available adapters are:
- inline numbered chart context;
- optional Querystore patient records;
- optional static clinical knowledge-base results.
Querystore is not a startup dependency. Inline requests work without it, and alternate sources can implement the same ContextSource contract. A caller can request a source list with context.sources; otherwise the hub selects one patient source or the inline chart. Team gather profiles add static knowledge as a supplemental source in the same evidence ledger, so KB facts retain stable provenance and citation mappings instead of traveling through a parallel context path.
Small charts retain their original chart text. For larger charts, the model input limit is a safety ceiling, not a target to fill. The deterministic selector admits mandatory safety records, exact matches, QueryStore's question-scoped slice (or, when that slice is unavailable, a 32-record clinical core that prioritizes active conditions and then recency), and records with meaningful normalized query overlap. It stops when eligible evidence is exhausted, even when capacity remains; other records are traced as zero_relevance. When eligible evidence exceeds the ceiling, one batched model-tokenizer request computes the rendered record costs. The selector ranks once, can skip an individually oversized record, and performs only bounded exact checks of assembled candidates. The final admitted prompt count is reused by dispatch, so a normal stage does not tokenize the same prompt twice. Router capability detection and the older /apply-template plus /tokenize fallback are cached for the process, while exact prompt counts stay in a small request-scoped cache. The selected view preserves canonical citation indices, includes whole records only, and discloses every included or excluded source id and reason in trace metadata. Answer, In-Depth synthesis, In-Depth review, and the bounded retry each fit a stage-local prompt view with the exact model tokenizer; all views derive from the same complete ledger. Temporal facts and deterministic checks always use that complete ledger. Mandatory-context overflow returns structured insufficient_context metadata rather than silently truncating evidence.
For QueryStore sources, shared selection and date semantics belong to the QueryStore API and its ADR Decisions 16–18. Send the raw question with opt-in interpretation; consume the protected selection tiers and trace effective types, temporal policy, included/excluded IDs and budget decisions. The local selector described above continues to serve non-QueryStore sources. Mandatory or typed-complete evidence that cannot fit fails explicitly rather than being silently omitted.
The bounded, memory-only, single-flight patient ledger cache is keyed by source,
authorization scope and patient. Revalidate every turn using the first-page ETag;
reuse only after 304. Changed charts require all pages from one snapshot. Retry
an inconsistent acquisition once, then report source failure. Never serve stale
context after failed revalidation or persist patient ledgers to disk. Context
slices must match the ledger snapshot; ranked hits must resolve by stable identity
and content digest, with one refresh/retry before explicit failure. Recompute
reference-date-dependent temporal facts on every turn.
OpenMRS owns durable conversation/audit state; the Hub receives prior turns and retains only disposable execution state. It does not cache final clinical answers or silently select a different provider, mode or model. Inline and alternate sources remain usable without QueryStore.
Cache scope reflects the configured source service principal; it is not a claim of per-user authorization. The OpenMRS caller authorizes patient access before relay. Generalizing this cache to other callers/sources requires a trusted scope, never a client-selected cache namespace, and reauthorization/invalidation when permissions or source configuration change. Hits retain the same provenance as fresh reads. Operating cache metrics contain identities/hashes, not chart text.
Any generic ContextSource cache extension (research checkpoint C2) must retain
immutable ledger provenance, single-flight requests, bounded entries/bytes and
explicit purge, keyed by source/configuration, patient, trusted scope and ledger
format revision. Sources without usable validators bypass reuse unless an explicit
product freshness policy is approved. Do not cache conversation history,
question-conditioned views, temporal classification, gate results or grounding.
This is extension direction, not a claim that the existing QueryStore cache
already implements every generic-source feature.
Every stage of one answer (answer, review, In-Depth, In-Depth review) sends the
same context message first and its own instructions after it. That message holds
the gathered evidence, led by the chart-wide temporal facts, then the selected
patient records, so later answers on the same chart can reuse its start too. A later
stage keeps the answer's selected records whenever its prompt fits the window,
and selects its own only when it does not. The router's exact-token prompt cache
then reads that message once per answer; Gemma 4 needs --swa-full for that. The
Hub keeps no prompt state of its own, so a cache miss costs time, never
correctness. Reusing a full chart across answers (gate G13) is tracked in
OpenClinAI's provider parity
thread.
- Every product Answer receives deterministic substance, date, temporal, date-value, and trend checks before
answer_done. - The answer reviewer is another model pass, so it labels the answer and never replaces it. An error it reports leaves the answer as written, marked
needs_reviewwith a yellow note. The reviewer is told which earliest and latest statements the dated chart series confirm, and an error it reports on one of them is set aside. - The In-Depth draws on knowledge-base and guideline context as well as the chart. It is written once and, when a reviewer is configured, reviewed once; the review flags claims and never asks for a new draft.
- Every product In-Depth claim receives deterministic temporal and citation-resolution results, plus citation-grounding results when grounding is enabled, before display. The temporal check reads each claim on its own words, never the question's, so a question asking for a trend cannot fail a claim that states one value; its trend rules apply only to a claim that cites a record of the measured series and does not say the trend is unsupported, a value is checked against the date stated in its own clause, and a chart date the sampler wrote with an underscore (
2026-01_26) is repaired before the check. Every citation check (Answer and In-Depth) has the checker list the patient facts a statement gives (values, dates, drugs, results, diagnoses, statuses, stages) and mark eachfound,differs(the record states another value; what it states is copied) ornot_found; the verdict is derived from that list in code, in batches of at most four pairs, at the profile's grounding temperature (0.7 for Qwen 3 checkers, or the configured default) with the repetition penalty off. Each reference and eachgroundingChecks[]entry carriessupport:supported,contradicted(a fact differs; a red mark naming what the record states),not_addressed(the record does not hold a stated fact; aninfonote, never a mark) orunchecked;groundingStatusis derived from it for older clients. A reviewer flag counts only when itschartquote points at the chart (a[N]index or text the chart holds); otherwise it is aninfodoubt (review_doubt) and the answer stays checked.inDepth.validation.review_statusisclean,flaggedorunavailable;citation_checks[]carriespass,not_addressed,info(uncited guidance),warn(unchecked) andfail(contradicted). A claim a check questions is shown with a note naming the check and what the chart says: red marks for a date-check or citation contradiction or a record outside the chart, yellow marks for a review flag or an unchecked citation.confidence.in_depthis red only when every claim is marked, yellow when some are. An uncited claim is tagged as general guidance, not marked for checking. policies.confidence(also accepted insidepolicies.review) islevels(the green, yellow or red grade) orfindings: the shippedconfidence.answer.levelandconfidence.in_depth.levelarenulland each section carriesfindings[](check: date, review, citation or record;where:answeror the In-Depth point number;status;says). A grade returns only once it is calibrated against judged runs (confidence-labels.md).- Checked arms for the next judged run (confidence-labels.md, pruning of 28 October 2026):
single-12b-bare(the default product profile) runs the deterministic checks alone;single-12b-verifyadds a cross-family reviewer (Qwen3.5-9B) that asks and answers verification questions (validation-verifyprompts; the verdict JSON also lists what itchecked) with the citation verdicts folded into that review (with separate citation checks on fallback when the review omits citation verdicts);single-12b-agreeadds theagreestage: a second writer (models.second, another family) answers on its own, the shown answer stays the first writer's, and each sentence of it is checked against the second answer with the same facts-first check. The envelope carriesagreement(model,answer,status: agreed, partly, differs, unchecked;claims[]withstatusagreed, differs, unverified or unchecked,otherandnot_stated); a claim the second writer states differently is afailissue (agreement) and a finding with checkagreement, one it does not state aninfonote. A product profile without a review policy carries noground_verdictsstage and checks no citations; its answer status still comes from the deterministic checks (a gate patch isedited), and its In-Depth carries noreview_status. The writer's DRY multiplier defaults to 0. - Citation grounding checks every claim group in stable, context-bounded sequential batches; a failed batch cannot erase verdicts from successful batches.
- References resolve against the complete current evidence ledger and carry source id, resource metadata, source text, usage locations, resolution state, and final grounding state.
- Citation count is metadata, not a confidence score.
Trace packages are appended to $TEAM_TRACE_DIR/trace.jsonl (default /app/trace) and include request correlation metadata, the final answer, original draft when applicable, context selection, temporal facts summary, Answer and In-Depth gate results, final references, model roles, sampling settings, and ordered stage steps.
The deterministic drug-safety layer accepts either a package-shaped JSON source or an operator-provided ATC classification export through DRUG_SAFETY_SOURCE_FORMAT and DRUG_SAFETY_DATASET_PATH. A source package declares its identity, version, provenance, and review state. Only clinically_approved packages can emit deterministic product warnings; proposed, evidence_curated, or retired data is reported honestly and cannot be presented as reviewed clinical decision support. The bundled JSON and cross-reactivity data are unreviewed research seeds. ATC supplies classification, not interaction, contraindication, duplicate-therapy, or cross-reactivity rules.
Every product response carries a canonical drug_safety.v1 result with checked, limited, or unavailable status; package metadata; medication-mapping and patient-exposure coverage; identity confidence; issues; and any approved findings. The legacy safetyStatus and safetyWarnings fields remain for client compatibility. A missing source, incomplete patient exposure, unresolved active medication, or failed execution can therefore never look like a clean check.
Weight-aware dose checks read the newest fresh numeric Querystore obs matching DRUG_SAFETY_WEIGHT_CONCEPT_UUID (CIEL weight 5089... by default). DRUG_SAFETY_WEIGHT_MAX_AGE_DAYS defaults to 90. Set the concept value to none to disable only the weight-aware arm. Missing, stale, malformed, or unavailable optional safety data degrades to no additional warning and never interrupts an answer.
Interaction rules preserve their source-assigned severity. DRUG_SAFETY_MIN_INTERACTION_SEVERITY defaults to minor, matching the bundled provider: rated Unknown rules are omitted, while Minor, Moderate, Major, and unrated curated rules remain eligible. An unrecognized setting falls back to minor rather than silently disabling rated warnings.
POST /v1/chat/completions: blocking or staged streaming profile execution.GET /v1/models: configured profile metadata, availability, validation capability, exact context requirements, at most one hub-selected available default marker, and a versioned top-levelbackendinventory (catalog_reachableplus exact router-advertised model IDs) for generic clients that own their own profiles. Router model IDs are public identifiers and must not embed credentials; endpoint and model metadata are credential-sanitized separately.GET /v1/hub/query-profiles: caller-orchestrated Catalyst query profiles, exact role/model/prompt/knob evidence, and live router availability.POST /v1/hub/query-profiles/{profile}/roles/{role}/generate: execute one configured query role. Callers provide non-system messages and an optional response format; callers cannot override the role model, prompt, or knobs. Every request that reaches the model returns versioned request evidence with the exact system and caller messages, selected profile/role/model, response format and model settings, and an RFC 8785 canonical SHA-256 digest. When the router supports it, the same evidence includes the exact rendered prompt and token count against the model's advertised context window and the role's configured output allowance. Missing rendering, counting, or window information is reported with a stable reason and is never estimated. For an otherwise valid request, only a known overflow blocks the model call; it returns422with the same evidence. The existingtoken_accountingfield keeps its compatible successful shape. Named Catalyst role generation has no automatic total deadline. It waits for completion unless the caller disconnects or explicitly stops the request; either interruption cancels the outstanding model HTTP request and releases any slot held by that request. An interrupted waiter never releases another caller's slot. Transport interruption cannot supply a completed model response.POST /v1/hub/query-profiles/{profile}/roles/{role}/warm: internal deployment-lifecycle warmup for Catalyst's reusable writer prefix. It accepts the same caller context and applies the same fixed profile. Disconnecting the lifecycle caller cancels the model work. It returns no model content or request evidence and must not be used for a person’s query.POST /v1/hub/generate: raw single-model compatibility endpoint for generic consumers that own their own profile configuration. Catalyst does not use it.GET /health: service health, uptime, and process memory.GET /: concise service status.
The hub is the application entrypoint; LLM_BASE_URL identifies its OpenAI-compatible model-serving backend, normally the local llama.cpp router.
poetry install --with dev
cp env.recommended .env
poetry run uvicorn server.main:app --host 0.0.0.0 --port 8080 --reload --env-file .envInline smoke request:
curl -fsS http://localhost:8080/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "answer:gemma-e4b@synthesis-chartsearchai~enforce~temp0",
"messages": [
{"role": "system", "content": "You are a clinical assistant."},
{"role": "user", "content": "[1] (2026-01-01) Medication: lisinopril 10 mg"},
{"role": "user", "content": "What medication is documented?"}
]
}'For a product profile, supply patient with a configured patient source or provide an inline chart. Use the OpenClinAI umbrella for the assembled OpenMRS setup; the harness only runs configured experiments.
server/
main.py FastAPI application
openai_compat.py OpenAI-compatible API and profile discovery
engine.py single stream-and-drain stage engine
levels.yaml configured profiles
levels_loader.py profile compiler and dynamic leg parser
context_sources.py sources, ledger, exact budgets, deterministic selector
temporal.py temporal facts and deterministic gates
team.py reusable answer/review/gather/grounding stage helpers
drug_safety.py deterministic dosing, interaction, and contraindication checks
kb.py provenance-bearing static clinical knowledge search
prompts/ file-backed stage prompts
The retired A2A/MCP agent servers and their runtime dependencies are not part of this service.