API Reference¶
FastAPI app defined in ogur/api/app.py. Interactive OpenAPI at http://localhost:8000/docs when the server is running.
Base URL for all non-meta endpoints: /api.
Conventions¶
- All responses are
application/json. - Timestamps in responses are ISO 8601 with millisecond precision and a
Zsuffix — Safari/JSDaterequires ≤ 3 decimal places (Pythonisoformat()emits 6, which crashesdate-fns; see schemas.py:10). - POST endpoints that trigger long-running work return 202 Accepted and run the job in a FastAPI
BackgroundTask. - Read endpoints return 404 when the requested record doesn't exist.
- POST
/api/askreturns 422 on empty question.
GET /health¶
Liveness probe. Tagged meta.
GET /api/signals¶
List and filter raw signals.
Query parameters (routes/signals.py:15):
| Param | Type | Default | Notes |
|---|---|---|---|
landscape_id |
str |
null |
Filter to one landscape |
drug_name |
str |
null |
Normalized generic — case lowered by the API |
source |
str |
null |
clinicaltrials / pubmed / openfda / etc. |
signal_type |
list[str] |
[] |
Repeatable; OR-matches any type |
severity |
str |
null |
high / medium / low |
limit |
int |
50 |
Clamp [1, 500] |
offset |
int |
0 |
Clamp ≥ 0 |
Results are always ordered detected_at DESC.
Response — list[SignalOut] (schemas.py:149):
[
{
"id": "…uuid…",
"source": "clinicaltrials",
"signal_type": "phase_transition",
"severity": "high",
"drug_name": "pembrolizumab",
"company": "Merck",
"phase": "Phase 3",
"target": "PD-1",
"indication": "Non-Small Cell Lung Cancer",
"title": "…",
"summary": "…",
"detected_at": "2026-04-03T17:25:05.462Z",
"landscape_id": "nsclc-001"
}
]
curl 'http://localhost:8000/api/signals?landscape_id=nsclc-001&signal_type=fda_approval&signal_type=phase_transition&limit=10'
GET /api/briefing/{landscape_id}¶
Return the most recent landscape-level briefing.
404 if no briefing exists for that landscape. Trigger one with the POST below.
Response — BriefingOut (schemas.py:20):
{
"id": "…",
"landscape_id": "nsclc-001",
"generated_at": "2026-04-03T17:25:05.462Z",
"period_start": "…",
"period_end": "…",
"executive_summary": "…",
"signal_analyses": [
{
"signal_id": "…",
"drug": "pembrolizumab",
"headline": "…",
"what_happened": "…",
"why_it_matters": "…",
"cross_source_connections": "…",
"confidence": "high",
"severity": "high"
}
],
"strategic_implications": "…",
"watchlist": ["LY3537982 (Eli Lilly, Phase 3) …"],
"predictions": [],
"kiq_answers": [
{
"kiq_id": "kiq-imm-001",
"finding": "Dupilumab maintains its lead in atopic dermatitis…",
"evidence": "NCT0… and Q4 IR call confirm…",
"uncertainty": "Limited Phase 3 head-to-head vs. lebrikizumab",
"implication": "First-mover window narrows by ~6 months",
"confidence": "high"
}
],
"schema_valid": true,
"schema_errors": [],
"signals_count": 20,
"model_used": "claude-sonnet-4-6"
}
Harness fields:
kiq_answers[]— one structured answer per active KIQ for this landscape. Synthesized against the KIQs loaded byscripts/seed_kiqs.py. See architecture.md §4.8.schema_valid— tri-state (null/true/false).nullmeans no validator ran (legacy briefing).falsemeans the verification gate rejected the synthesizer output even afterMAX_SCHEMA_RETRIESretries — operator should investigate.schema_errors[]— error strings emitted byvalidate_kiq_answers. Empty whenschema_validistrue. Decoded defensively in schemas.py:54: malformed JSON in the DB column degrades to[]rather than 500-ing.
The serializer in schemas.py:39 handles legacy rows where executive_summary was mistakenly stored as raw synthesizer JSON (it unwraps to .executive_summary).
POST /api/briefing/{landscape_id}¶
Trigger a new landscape briefing. Returns 202 Accepted immediately; the pipeline runs in a background task.
Body (optional):
Without since, the detector uses briefing_window_days (default 7) from config.py.
Response:
404 if the landscape is not registered. See routes/briefings.py:79.
GET /api/briefing/{landscape_id}/drug/{drug_name}¶
Return the latest drug-focused briefing (generated by scripts/generate_drug_briefing.py). Internally keyed by landscape_id = f"{landscape_id}-{drug_name}".
Same BriefingOut shape as the landscape briefing — including kiq_answers. Drug briefings load drug-specific KIQs (those keyed on the drug-composite ID); class-level KIQs keyed on the parent landscape are not loaded here. See architecture.md §2 — Path C.
POST /api/briefing/{landscape_id}/drug/{drug_name}/generate¶
Enqueue a drug briefing. 202 Accepted.
Per-tab analyses¶
Three endpoints per drug tab (Overview / Trials / Competitive). All share the same shape:
GET /api/briefing/{landscape_id}/drug/{drug_name}/{tab}
POST /api/briefing/{landscape_id}/drug/{drug_name}/{tab}/generate
Where {tab} ∈ overview | trials | competitive.
Results are cached as Briefing rows with composite landscape_id (e.g. nsclc-001-pembrolizumab-overview). See architecture.md §2.
OverviewOut (schemas.py:62)¶
drug_name, brand_name, company, target, moa, drug_class, route,
approved_indications[], phase,
key_differentiators[], safety_signals[], data_gaps[],
confidence, signals_used, generated_at
TrialsOut (schemas.py:93)¶
drug_name,
trials[] { nct_id, title, phase, status, indication, primary_endpoint,
estimated_completion, key_results, sponsor, source_signal_id },
active_count, completed_count, summary, confidence,
signals_used, generated_at
CompetitiveOut (schemas.py:125)¶
focus_drug, landscape_indication,
competitors[] { drug_name, company, tier, target, phase, primary_indication,
differentiation, threat_level, evidence_basis },
route_matrix[target → drug_name[]],
threat_register[] { drug, company, threat_level, timeline, rationale,
trigger_to_watch },
white_space, confidence, evidence_notes,
signals_used, generated_at
Error states (404 logic varies by tab):
| Tab | 404 when | Notes |
|---|---|---|
| Overview | no row exists | simple presence check |
| Trials | no row, OR cached executive_summary == "Failed to parse structured response." |
uses the _FAILED sentinel, routes/briefings.py:219 |
| Competitive | no row, OR executive_summary is empty / whitespace |
empty-body check, routes/briefings.py:258 |
POST always returns 202 — the background task handles its own errors and writes what it can.
Note on the confidence field. OverviewOut, TrialsOut, and CompetitiveOut all expose a confidence string field. The analyzers don't populate it today — it defaults to "low" per the Pydantic schema. Treat confidence on per-tab responses as not-yet-meaningful; the field exists for future model output. (The synthesizer's per-signal confidence in BriefingOut.signal_analyses[] IS populated and meaningful — that's a separate field.)
GET /api/landscapes/{landscape_id}/evidence/comparative¶
Head-to-head competitor evidence cards. Deterministic — reads EvidenceRecord rows, picks one row per (drug, headline endpoint), no LLM in the path.
404 if landscape_id is unknown OR is not in the v1 hardcoded landscape config (currently only immunology-001). Per-landscape headline endpoints + included drugs live in a dict at the top of routes/evidence.py; moving this to DB is on the backlog when more than two landscapes need cards.
Headline-row picker (per drug, per endpoint):
- Fuzzy-match endpoint names against
_ENDPOINT_ALIASES(e.g."EASI-75 at week 16"→"EASI-75"). - Drop rows where
comparator_valueis null — single-arm rows would render misleading H2H cards. - Sort remaining by
(confidence_rank desc, extracted_at desc). Take the top.
If no row survives for a (drug, endpoint), the endpoint is omitted from that card. If no endpoint survives, the drug is dropped entirely (no empty cards).
Response — LandscapeEvidenceComparative:
{
"landscape_id": "immunology-001",
"headline_endpoints": ["EASI-75", "IGA 0/1", "Pruritus NRS"],
"competitors": [
{
"drug_name": "dupilumab",
"company": "Sanofi/Regeneron",
"phase": "Approved",
"endpoints": [
{
"endpoint": "EASI-75",
"endpoint_type": "efficacy",
"arm": "dupilumab 300 mg q2w",
"value": 47.0,
"unit": "%",
"comparator_value": 9.0,
"p_value": 0.0001,
"ci_lower": null,
"ci_upper": null,
"trial_id": "NCT02277743",
"source_url": "https://clinicaltrials.gov/…",
"confidence": "high"
}
]
}
],
"generated_at": "2026-04-25T17:25:05.462Z"
}
POST /api/ask¶
Ad-hoc Q&A against the signal database. Haiku-backed.
Body — AskRequest (schemas.py:138):
{
"question": "What are the implications of recent KRAS inhibitor trial terminations?",
"landscape_id": "nsclc-001"
}
422 if question is blank.
Response — AskResponse:
{
"answer": "BMS's co-termination of MRTX0902 (SOS1) and CC-90003 (ERK1/2) confirms …",
"key_signals": ["signal-uuid-1", "signal-uuid-2"],
"sources_used": 12
}
key_signals are Signal IDs the LLM cited — the frontend uses these to render EntityChips linked to source records.
Error responses¶
All errors return an RFC 7807-style payload:
| Code | When |
|---|---|
| 400 | (none currently emitted — FastAPI validates upstream) |
| 404 | Landscape/briefing/analysis not found |
| 422 | Validation error (e.g. blank question) |
| 500 | Unhandled exception — check server logs |
What's not exposed yet¶
- No WebSocket or SSE streaming — briefings are batch-generated and polled.
- No authn/authz — the API is single-tenant and assumes trusted callers.
- No pagination cursors on
/api/signals— onlylimit/offset. Adequate for current signal volumes (~1,500 per landscape). - No search endpoint across signals (full-text).
GET /api/signalswithdrug_name+signal_typefilters covers most retrieval needs; semantic search is Phase 3 full.