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 and API_DOCS_ENABLED=true/docs, /redoc and /openapi.json are off by default since OGUR-76, so production does not publish its own route map.

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).
  • No endpoint triggers long-running work any more. The 202 Accepted + BackgroundTask generation routes were removed in OGUR-76 (see below); the only remaining POSTs are auth, feedback, and the two metered LLM endpoints.
  • Read endpoints return 404 when the requested record doesn't exist — and the same 404 when the caller is not granted the landscape, deliberately indistinguishable (see "Per-user landscape grants").
  • POST /api/ask returns 422 on empty question.

GET /health

Liveness probe. Tagged meta.

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

Auth (OGUR-64)

Session mechanism: per-user password + opaque server-side session token in an HttpOnly cookie (ogur_session, SameSite=Lax, Secure via SESSION_COOKIE_SECURE, TTL via SESSION_TTL_HOURS, default 14 days). Full contract: product/specs/ogur-64-minimal-auth-session.md. Every route requires the cookie except GET /health, POST /api/auth/login and POST /api/auth/logout — 401 otherwise. That covers /api/auth/me, the feedback endpoints (OGUR-69), the report surface (/api/explore/landscapes*, /api/explore/classes, /api/signals), the paid-LLM endpoints (/api/ask, /api/explore/decompose), and — since OGUR-76 — the briefing routes (/api/briefing/*) and comparative evidence (/api/landscapes/{id}/evidence/comparative).

Those last two were the exception this sentence used to record as a deferred follow-up. They were reachable unauthenticated in production and answered 404 for content reasons rather than authorization, which read as safe only because the deployed database happened to hold no briefings. See product/security-review-2026-08-12.md.

Per-user landscape grants (OGUR-65)

Each identity is mapped to the landscapes/reports it may read (UserLandscapeAccess rows; granted via scripts/seed/seed_users.py --grant EMAIL LANDSCAPE_ID). A landscape the caller is not granted answers exactly like one this deployment does not serve — 404 {"detail":"no report for landscape '<id>'"} — so a cross-client probe leaks nothing about what exists. List surfaces scope instead of erroring: the roster (GET /api/explore/landscapes) lists only granted landscapes, and GET /api/signals without landscape_id returns only signals in the caller's granted set (no grants → empty list).

Daily LLM spend ceiling (OGUR-65)

/api/ask and /api/explore/decompose meter each request's token usage (CostMeter estimate) into a per-identity daily rollup (UTC day). Once the day's spend reaches DAILY_LLM_SPEND_CEILING_USD (default $5.00), both endpoints answer 429 {"detail":"daily LLM spend ceiling ($5.00) reached; resets at 00:00 UTC"} before calling the LLM. Admission is an atomic reserve-then-settle: each admitted request reserves a fixed amount against the day's rollup via a DB-side increment before the ceiling is read, so a concurrent burst cannot each see an under-ceiling total and all pass; settlement replaces the reservation with the request's actual cost on every path, including engine failures. The effective model must be in the CostMeter price table (an unpriced model would meter at $0 and never count toward the ceiling): a caller-supplied model outside it is a 422, an unpriced configured default (LLM_CLASSIFIER_MODEL / LLM_SYNTHESIS_MODEL) is a 503.

POST /api/auth/login

Body {"email": str, "password": str}200 with the user and a Set-Cookie header, or 401 {"detail":"Invalid email or password"} (identical body for unknown email / wrong password / deactivated user).

curl -c cookies.txt -X POST http://localhost:8000/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"genfit.provisional@ogur.local","password":"…"}'
# → {"id":"…","email":"genfit.provisional@ogur.local","display_name":"Genfit Analyst (provisional)","organization":"Genfit"}

POST /api/auth/logout

Deletes the session row and clears the cookie. 204 always (idempotent).

GET /api/auth/me

Returns the authenticated user (id, email, display_name, organization) or 401 {"detail":"Not authenticated"} on a missing, invalid, expired, or deactivated session.


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


Generation endpoints — removed in OGUR-76

POST /api/briefing/{landscape_id}, POST .../drug/{drug_name}/generate and POST .../drug/{drug_name}/{tab}/generate no longer exist. Each returned 202 and ran the Synthesizer or an analyzer against the Anthropic API from a BackgroundTask with no authentication and no spend reservation, outside OGUR-65's daily ceiling. They were deleted rather than gated: on-demand generation was not an MVP-1 surface and remains unserved (mvp-scope.md §6). They now answer 404/405, asserted by tests/unit/api/test_authorization.py::test_anonymous_generation_routes_no_longer_exist.

Briefings and per-tab analyses are produced offline, by scripts/briefing/generate_briefing.py, generate_drug_briefing.py and analyze_drug.py. Anything that reintroduces a generation route must take a reservation through ogur.api.deps.reserve_llm_spend first.


GET /api/briefing/{landscape_id}/drug/{drug_name}

Return the latest drug-focused briefing (generated by scripts/briefing/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.


Per-tab analyses

Three read endpoints, one per drug tab (Overview / Trials / Competitive):

GET /api/briefing/{landscape_id}/drug/{drug_name}/{tab}

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
Competitive no row, OR executive_summary is empty / whitespace empty-body check

A grant-miss also answers 404, and does so before any of the above — see "Authentication" below.

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.


GET /api/explore/landscapes

Which frozen Explore runs this deployment can serve a report for, in configured order. A client discovers the roster rather than hardcoding an id — a UI that hardcodes one shows a dead link the moment the deployment is repointed, and shows nothing at all for a second run.

{
  "landscapes": [
    {
      "landscape_id": "gns561-cca-autophagy-001",
      "name": "GNS561 — autophagy-directed oncology (cholangiocarcinoma)",
      "lead_asset": "GNS561 (ezurpimtrostat)",
      "sponsor": "GENFIT / GenoScience Pharma",
      "targets": ["PPT1", "ULK1", "ULK2", "…"]
    }
  ]
}

Every field is passed through from the answering pack's own meta / landscape block, never derived. lead_asset and sponsor may be null for a pack that omits them; a pack that supplies them as the wrong type is rejected at load rather than raising inside this response model.

503, never a partial roster, if any configured pack fails to load or if nothing is configured at all. Listing only the packs that happen to parse would present an incomplete catalog as a complete one — an analyst cannot tell a landscape this deployment does not hold from one whose file is broken.


GET /api/explore/landscapes/{landscape_id}/report

The frozen competitive brief for an Explore landscape. Deterministic — no LLM in the path; this serves the output of a completed /landscape-loop-explore run, it does not run one.

Query params

Param Values Default
variant full | tldr | compact full

Only full (19 sections) and tldr (14) were ever authored. compact (15) is a projection of full with the methodology, appendix, companion-files and full-assignee sections dropped — it comes back derived: true, derived_from: "full" with an explanatory note, and a UI must label it as a rendering rather than a third document.

Sections are returned in the brief's own order and must not be re-sorted. The order is the argument: headline → clinical programmes → wider mechanism class → pre-clinical → same-disease competitors → patent estate → literature. section.order is the pack's own value; for compact it is inherited from full and therefore has gaps, so treat the list order as authoritative and order as a stable cross-variant identifier.

body_md carries <!--table:N--> placeholders marking where tables[N] was lifted out, so prose and tables re-interleave in the author's order.

Provenance. Every row of a CI table carries how we came to know it — searched (our run surfaced it independently), supplied (the customer named it and our search did not), or program context (the customer's own asset). The compound form supplied · patent-corroborated arrives split into tag + qualifier with raw preserved. table.provenance_column is the index into columns, so a renderer can show a badge and drop the redundant text column without string-matching the header. Blending these tags would present a design partner's own inputs back as our findings — carry the field through to the surface.

ResponseLandscapeReport (schemas.py):

{
  "landscape_id": "gns561-cca-autophagy-001",
  "variant": "full",
  "derived": false,
  "derived_from": null,
  "note": null,
  "title": "GNS561 (ezurpimtrostat) — competitive landscape",
  "provenance_vocabulary": ["program context", "searched", "supplied"],
  "evidence_depth": {
    "note": "Evidence depth describes the source records this report was assembled from. They are titles, abstracts and structured registry and patent metadata: no full-text document — manuscript, patent specification or trial protocol — was retrieved into them. Where the brief discusses a specific document's contents, that passage was read at its source and is cited inline.",
    "classes": [
      {
        "source_class": "paper",
        "state": "metadata_only",
        "count": 582,
        "coverage": {
          "metadata_only": 29,
          "title_abstract": 553
        },
        "detail": "PubMed/OpenAlex bibliographic records. Full manuscripts were not retrieved into these records."
      },
      {
        "source_class": "patent",
        "state": "metadata_only",
        "count": 141,
        "coverage": {
          "metadata_only": 141
        },
        "detail": "patent bibliographic metadata (title, assignees, dates). Patent descriptions and claims were not retrieved into these records."
      },
      {
        "source_class": "trial",
        "state": "metadata_only",
        "count": 34,
        "coverage": {
          "metadata_only": 34
        },
        "detail": "structured registry fields (phase, status, sponsor, why-stopped). Trial protocols and results publications were not retrieved into these records. 19/34 carry the cached full CT.gov registry record."
      }
    ]
  },
  "sources_used": {
    "note": "Every record behind this report comes from the public databases below. Sources that were searched and returned nothing in scope are listed with a zero count: publishing the zeros is what makes the non-zeros credible. Records assembled: 472 companies, 582 papers, 141 patents, 34 trials.",
    "sources": [
      {"id": "clinicaltrials", "label": "ClinicalTrials.gov", "layer": "trials", "record_count": 34, "url": "https://clinicaltrials.gov/"},
      {"id": "gpatents", "label": "Google Patents", "layer": "patents", "record_count": 141, "url": "https://patents.google.com/"},
      {"id": "pubmed", "label": "PubMed", "layer": "papers", "record_count": 300, "url": "https://pubmed.ncbi.nlm.nih.gov/"},
      {"id": "openalex", "label": "OpenAlex", "layer": "papers", "record_count": 282, "url": "https://openalex.org/"},
      {"id": "cde", "label": "NMPA CDE (China)", "layer": "companies", "record_count": 0, "url": "https://www.chinadrugtrials.org.cn/"},
      {"id": "sec", "label": "SEC EDGAR", "layer": "companies", "record_count": 1, "url": "https://www.sec.gov/edgar/search/"},
      {"id": "hkex", "label": "HKEX / CNInfo", "layer": "companies", "record_count": 0, "url": "https://www.hkexnews.hk/"}
    ]
  },
  "sections": [
    {
      "id": "2-detailed-ci-summary-clinical-programmes-…",
      "level": 2,
      "order": 5,
      "title": "2 · Detailed CI summary — clinical programmes …",
      "title_plain": "2 · Detailed CI summary — clinical programmes …",
      "body_md": "…markdown with <!--table:0--> placeholders…",
      "tables": [
        {
          "columns": ["Company", "Asset", "…", "Provenance"],
          "provenance_column": 8,
          "rows": [
            {
              "cells": [
                {"column": "Company", "md": "**GENFIT**", "text": "GENFIT", "links": []}
              ],
              "links": [
                {"label": "NCT05874414", "url": "https://clinicaltrials.gov/study/NCT05874414"}
              ],
              "provenance": {"tag": "program context", "qualifier": "", "raw": "program context"}
            }
          ]
        }
      ]
    }
  ]
}
curl "http://localhost:8000/api/explore/landscapes/gns561-cca-autophagy-001/report?variant=compact"

evidence_depth (OGUR-78) discloses how much of each cited document class the pack's records actually hold, so an abstract-level analysis cannot read as full-document review. It is derived from pack["records"] when the pack is loaded (ogur/api/evidence_depth.py), not stored in the pack — a frozen copy could only ever disagree with the rows beside it, and deriving makes the invariants true by construction rather than by assertion.

Every declared document class is served whether or not the pack holds any of it — filing is currently always zero, and publishing that zero is the point: the brief's own stated main limit is registry blindness to regulatory and corporate events, so a class list that simply omitted filings would let a reader assume they were covered. A class with no records carries state not_retrieved, count 0 and an empty coverage.

Per class: state is the corpus summary (the weakest document in the class), coverage is the per-state document count behind it and sums to count — documents, not rows, since a class can span several record layers (Polygon's trials and trials_sweep share 12 NCTs) — and detail names what was not retrieved. holds and omits are the short phrases detail is built from, served separately so a compact renderer does not re-author the same vocabulary and drift from it. Depth is disclosed once, at report level; source chips carry no depth marker.

The detail sentence also reports how many cited sources have no record in the pack — that count is computed at build time from the same identifier resolution, which is why the normalisation above still matters even though the map itself is no longer served (unpadded, GNS561 would claim nine uncovered patents where the true figure is five).

sources_used names the public databases the records came from, and is derived the same way, from the same records (ogur/api/sources_used.py). The consulted roster lives in code, so a source that returned nothing is still served with record_count 0 — publishing the zeros is what makes the non-zeros credible, and a renderer that filters them out inverts the disclosure. layer names the unit the count is in: trials / patents / papers are documents, companies is derived entities for a source known only from company attribution, every entry carries the URL of the database itself, and anything the roster does not recognise buckets under an other-documents or other-companies entry rather than being dropped. The two are kept apart because they are counted in different units — a pack carrying both an unrecognised document layer and a company attributed to an unknown registry publishes both, and neither swallows the other.

It is served on every variant, compact included. compact drops the appendix, which is the only place the brief itself names its sources — computing the roster rather than scraping the appendix is what lets the short read keep the disclosure.

422 on an unknown variant — never a silent fall back to full, which would hand back a different document under the requested name. 404 if this deployment's pack does not cover landscape_id (the detail names what it does serve). 503 if the pack is missing or malformed; the endpoint never degrades to an empty report, which would read as "we searched and found nothing".


GET /api/explore/landscapes/{landscape_id}/figures

The brief's two figures as data, never images. The run's matplotlib PNGs are not the delivery path — the UI re-renders from these payloads, so the figures inherit the page theme and their rows stay linkable.

ResponseLandscapeFigures:

{
  "landscape_id": "gns561-cca-autophagy-001",
  "figure1": {
    "kind": "swimlane",
    "stages": ["Pre-clinical / patent-stage", "Phase 1", "Phase 1/2", "Phase 2", "Phase 3", "Approved"],
    "cards": [
      {
        "band": "autophagy", "stage": "Phase 1/2", "asset": "GNS561 + trametinib",
        "company": "GENFIT", "target": "PPT1 + MEK1/2", "indication": "KRAS-mut iCCA (lead)",
        "closed": false, "is_focus": true, "nct": "NCT05874414",
        "status": "RECRUITING", "phase": "PHASE1/PHASE2",
        "source_url": null, "source_label": null
      }
    ]
  },
  "figure2": {
    "kind": "patent_whitespace",
    "x_label": "Patent filings retrieved (Google Patents full-text, A61/C07, 2011-2026)",
    "in_thesis_label": "oncology",
    "other_label": "other indications",
    "rows": [{"target": "PPT1", "in_thesis": 0, "other": 43, "total": 43, "highlight": true}]
  }
}

band splits the mechanism class from same-disease competitors; closed marks a terminated or withdrawn trial; is_focus marks the customer's own asset. source_url / source_label are the grounding document for a card with no registry record — in practice the newest patent filing behind a patent-stage aggregation, or the founder patent of an unregistered focus asset. Every card (figure1 and figure3) must carry a non-empty nct or an absolute HTTP(S) source_url + non-empty source_label pair; a pack violating that is rejected at load with a 503, because the renderers' fallback for a card with neither is to omit the source chip, while a relative URL navigates inside Ogur instead of opening raw evidence. Either result breaks the one-click provenance contract (ux-spec §6.3). figure2.highlight marks the brief's strongest finding: every autophagy target in the neighbourhood carries oncology filings except GNS561's own target, whose 43 filings are all Batten-disease gene therapy.

Both figures carry their own labels, because the ids are shared across landscapes and their meanings are not. figure1.bands gives each band id a label — other is GNS561's cholangiocarcinoma field and Polygon's cytoprotection graveyard. figure2.in_thesis counts evidence inside this landscape's thesis, with in_thesis_label / other_label naming the split: GNS561 asks how many of a target's filings are oncology, Polygon how many of a mechanism class's trials are in acute cardiac ischemia. A renderer that keys labels off the id captions one landscape's data with another's meaning.

in_thesis was called oncology while GNS561 was the only landscape. The API reads the old spelling through a validation alias so the frozen pack loads unrebuilt, but serves in_thesis — and defaults the two labels to GNS561's original wording, so a pack that predates the fields renders exactly as authored.

Those defaults are for gns561-cca-autophagy-001 and nothing else. Every later pack must supply figure1.bands and both figure2 labels, or it is rejected at load with a 503. bands must cover every band its cards use, and each declaration needs a non-empty id and label — declaring a band is not the same as naming one, and a blank label renders as an unnamed heading just as surely as a missing declaration renders as a raw slug. id must be unique within the list: it is the cards' foreign key, and the renderer draws one lane per declaration while selecting each lane's cards by id, so a repeated id draws the same programmes in every duplicate lane. Ungated, the defaults could not tell the one pack that is allowed to omit labels from a new pack that simply forgot them — the second would serve a 200 with its own counts under GNS561's wording, which is exactly the borrowed-meaning failure the label fields were added to prevent.

Assert the invariants, not rendered output — 23 swimlane cards, 10 patent rows, PPT1.in_thesis == 0 / other == 43. matplotlib is pinned nowhere in this repo.

404 on an unknown landscape, 503 on a missing pack, as above.

Where the report data comes from

Both endpoints read one committed JSON: archived_data/gns561_explore/gns561_seed_pack.json (shapes documented in the sibling SEED_PACK_CONTRACT.md). They do not re-assemble the brief — the generators reproduce byte-identically offline at $0, so re-deriving the document inside a request handler could only introduce a second parse that drifts from the first.

The path is the explore_report_pack_path setting (env: EXPLORE_REPORT_PACK_PATH), absolute by default so uvicorn finds it from any working directory. The pack is parsed once and cached per path in ogur/api/report_pack.py.

One deployment can serve several frozen runs. EXPLORE_REPORT_PACK_PATH accepts an os.pathsep-separated list; a single-path setting keeps working verbatim. Requests resolve through an exact landscape_id → pack index — never a fallback to whichever pack is first, because serving one design partner's competitive read under another's id is the worst failure this layer has. The index is cached per configuration, so serving N packs does not make every request re-read all N. A landscape this deployment does not hold is a 404; a configured pack that will not load, or no pack configured at all, is a 503 naming the path and the setting.

EXPLORE_REPORT_PACK_PATH="/path/gns561_seed_pack.json:/path/polygon-cd8-ami-001_seed_pack.json"

What "malformed" means. Key presence is not enough — a pack carrying all four top-level keys can still hold briefs.full.sections = [] or figures = {}, which would serve a 200 with no content or raise inside a handler for a 500. So the load-time check covers everything a handler dereferences plus the content these endpoints promise is non-empty: meta.landscape_id and meta.provenance_vocabulary, landscape.name (the report title's only fallback), each brief's variant and non-empty sections, figure1 / figure2 against their response models with non-empty cards / rows, and every figure1 / figure3 card's NCT-or-source provenance pair, including an absolute HTTP(S) URL when the card has no NCT. A stale pack still carrying the retired identifiers map serves fine; the field is ignored, not rejected. Anything else is a 503 whose detail names the specific field.

Types, not just presence. meta.landscape_id and landscape.name must be non-empty strings, not merely values that stringify. An integer id passes a str() check and then breaks both routes in different directions — it becomes an integer key in the pack index, so the report route (whose path param is always a string) 404s for the only landscape configured, while the roster raises inside its response model for a 500. The same applies to meta.lead_asset / meta.sponsor, which are optional but must be strings when present. Validation runs before the parsed pack is cached, so repairing the file on disk restores service on the next request — no restart.


Companion CSV exports

The brief's appendix names companion files. Until this endpoint existed nothing was downloadable anywhere — no export route, no download affordance, and two of the five filenames the GNS561 appendix listed existed nowhere at all. These two routes make the claim true, and make it true from the pack itself: rows are serialized in the request from pack["records"], nothing is written to disk, and no CSV is baked into the container image. A shipped file is a second copy of the evidence that can drift from the brief citing it; a derived one cannot.

Both routes carry require_report_access, so an ungranted landscape is indistinguishable from an unserved one (404, one shared detail) and an anonymous request is 401.

GET /api/explore/landscapes/{landscape_id}/export

Which companion CSVs this landscape's pack can produce, with counts read off the records rather than authored:

[
  {"layer": "trials",  "filename": "gns561-cca-autophagy-001-trials.csv",  "row_count": 34,
   "href": "/api/explore/landscapes/gns561-cca-autophagy-001/export/trials.csv"},
  {"layer": "patents", "filename": "gns561-cca-autophagy-001-patents.csv", "row_count": 141,
   "href": "/api/explore/landscapes/gns561-cca-autophagy-001/export/patents.csv"}
]

GET /api/explore/landscapes/{landscape_id}/export/{layer}.csv

text/csv with Content-Disposition: attachment; filename="{landscape_id}-{layer}.csv".

layer is allowlisted to trials, patents, papers, companies404 otherwise, before the pack is touched. The allowlist, not the pack's key set, decides what is offered: Polygon's pack also carries trials_sweep, 458 raw query rows the brief never cites, and offering a build intermediate as a companion file presents scaffolding as a finding.

A record layer that is not a list of objects is a 503 naming the layer, never a 500: the loader validates briefs and figures but deliberately not records, so these two routes are the first place a malformed layer is dereferenced, and they translate it into the contract the rest of the pack layer promises.

Column order is declared, not discovered (_LAYER_COLUMNS in ogur/api/routes/export.py), so the same pack exports byte-identically on every request — the reproducibility property the pack itself has. Each layer leads with the identifier the brief cites the row by (nct, publication_number, pmid, canonical_name) followed by the link or attribution that resolves it, so a downloaded row is still one click from its raw source. A column the declaration does not know about is appended, sorted, rather than dropped — an unexported column is evidence the customer paid for and never received.

In the UI these render as the appendix's Downloads block: the companion-files section's authored file list is replaced at render time by this manifest (frontend/src/components/explore/report/CompanionDownloads.tsx). The pack stays frozen; a pack without that section is unaffected.


Feedback (OGUR-69, section anchoring OGUR-72)

Persistent, authenticated analyst feedback on a served report. Both endpoints require a valid session cookie (401 otherwise) and a landscape this deployment serves a report for and the caller is granted (404 otherwise, one shared detail for both misses) — the check is the require_report_access dependency in ogur/api/deps.py, tightened to the per-user landscape mapping by OGUR-65.

Document identity is (document_source, document_source_id) — the source-record layer (NCT number, PMID, publication number). Feedback never references Signal.id: that column is a fresh uuid on every reseed, so rows anchored to it would orphan. Report-level feedback (OGUR-71's single feedback box) omits both document fields.

Section identity is the pair (variant, section_id) (OGUR-72). A section slug is unique only within a rendered variant, and compact is derived from full with its own section roster — so section_id alone names nothing, and a body carrying one without a variant is a 422. The reverse is allowed: a variant with no section_id is feedback about a whole rendering. Both fields are null on report-level feedback, which is what every row written before OGUR-72 already is.

POST /api/explore/landscapes/{landscape_id}/feedback

Body — FeedbackCreate:

{
  "text": "the deal table is missing Ionis",
  "document_source": "clinicaltrials",
  "document_source_id": "NCT05874414",
  "variant": "full",
  "section_id": "clinical-landscape",
  "quoted_text": "GNS561 is the only autophagy inhibitor in CCA"
}

text is required (1–10,000 chars, whitespace-stripped). The two document fields are optional but must appear together (422 otherwise), as are section_id and variant (422 if section_id arrives alone). quoted_text is an optional excerpt, capped at 500 chars — it is context for the reader, never an anchor: nothing resolves against it. The author is always the session identity — the schema forbids unknown fields, so a body attempting to name an author is a 422, never a silent drop.

201FeedbackOut:

{
  "id": "…",
  "landscape_id": "gns561-cca-autophagy-001",
  "document_source": "clinicaltrials",
  "document_source_id": "NCT05874414",
  "variant": "full",
  "section_id": "clinical-landscape",
  "quoted_text": "GNS561 is the only autophagy inhibitor in CCA",
  "text": "the deal table is missing Ionis",
  "created_at": "2026-08-11T10:00:00.000Z",
  "author": {"id": "…", "display_name": "Genfit Analyst (provisional)", "organization": "Genfit"}
}

GET /api/explore/landscapes/{landscape_id}/feedback

200list[FeedbackOut], oldest first, every row for the landscape — report-level, document-scoped and section-anchored alike. The client splits them; the API does not filter, so a comment whose anchor no longer matches any rendered section is still served and can be surfaced rather than lost. Author attribution is resolved at read time from the user table; a deleted author degrades to id-only (display_name / organization null) rather than dropping the row — feedback is an audit trail.


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)
401 No valid session cookie ({"detail":"Not authenticated"})
404 Landscape/briefing/analysis/report not found — or not granted to this identity (indistinguishable by design)
422 Validation error (e.g. blank question, unknown report variant, unpriced model)
429 Daily LLM spend ceiling reached for this identity (DAILY_LLM_SPEND_CEILING_USD)
500 Unhandled exception — check server logs
503 Configured Explore report pack missing or malformed — the detail names the path

What's not exposed yet

  • No WebSocket or SSE streaming — briefings are batch-generated and polled.
  • Authn/authz cover the report surface and paid-LLM endpoints (OGUR-64/65); the legacy briefings/evidence routes still assume 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 / candidate retrieval is MVP-2 (Explore backend) territory.