Skip to content

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 Z suffix — Safari/JS Date requires ≤ 3 decimal places (Python isoformat() emits 6, which crashes date-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/ask returns 422 on empty question.

GET /health

Liveness probe. Tagged meta.

curl http://localhost:8000/health
# → {"status":"ok"}

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.

Responselist[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.

ResponseBriefingOut (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 by scripts/seed_kiqs.py. See architecture.md §4.8.
  • schema_valid — tri-state (null / true / false). null means no validator ran (legacy briefing). false means the verification gate rejected the synthesizer output even after MAX_SCHEMA_RETRIES retries — operator should investigate.
  • schema_errors[] — error strings emitted by validate_kiq_answers. Empty when schema_valid is true. 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):

{ "since": "2026-03-01T00:00:00Z" }

Without since, the detector uses briefing_window_days (default 7) from config.py.

Response:

{ "status": "triggered", "landscape_id": "nsclc-001" }

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.

{ "status": "generating", "drug": "pembrolizumab", "landscape_id": "nsclc-001" }

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):

  1. Fuzzy-match endpoint names against _ENDPOINT_ALIASES (e.g. "EASI-75 at week 16""EASI-75").
  2. Drop rows where comparator_value is null — single-arm rows would render misleading H2H cards.
  3. 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).

ResponseLandscapeEvidenceComparative:

{
  "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"
}
curl http://localhost:8000/api/landscapes/immunology-001/evidence/comparative

POST /api/ask

Ad-hoc Q&A against the signal database. Haiku-backed.

BodyAskRequest (schemas.py:138):

{
  "question": "What are the implications of recent KRAS inhibitor trial terminations?",
  "landscape_id": "nsclc-001"
}

422 if question is blank.

ResponseAskResponse:

{
  "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:

{ "detail": "No briefing found for landscape 'foo'" }
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 — only limit/offset. Adequate for current signal volumes (~1,500 per landscape).
  • No search endpoint across signals (full-text). GET /api/signals with drug_name + signal_type filters covers most retrieval needs; semantic search is Phase 3 full.