openapi: 3.1.0
info:
  title: cite8 API
  version: 0.1.0
  summary: Citation-grounded clinical RAG. Hybrid retrieval, cited generation, claim verification.
  description: |
    `cite8` is a citation-grounded clinical RAG service. Every answer and
    every verdict points back to a peer-reviewed source by stable id and
    PubMed URL; abstention is a first-class verdict rather than a fallback.

    All `/api/v1/*` endpoints require a Bearer token (see `securitySchemes`).
    Provision keys with `npm run keys -- create --label "..."` — plaintext is
    shown exactly once.

    Every response carries:

      - `X-Cite8-Cost-Cents` — estimated cost of the request, USD-cents (rounded up).
      - `X-Cite8-Quota-Remaining` — requests left in the current 60-second sliding window.
      - `X-Cite8-Quota-Limit` — the per-key rate limit (default 60/min).

    On `429 Too Many Requests`, `Retry-After` is set (seconds).

    The corpus today is a single PubMed slice (`pubmed-msk`, ~474 docs on
    spinal manipulation and related MSK topics). Additional corpora
    (`amaguides`, `optima`, `nhtsa`, etc.) will appear as future `corpus`
    values without breaking this contract — filter via the `corpora` /
    `domains` request fields, which already exist on every retrieval-backed
    route.
  contact:
    name: cite8 maintainers
    email: contact@cite8.dev
    url: https://cite8.dev/about
  license:
    name: TBD

servers:
  - url: https://cite8.dev
    description: Production
  - url: https://cite8.attl.app
    description: Production (legacy hostname, 301s to cite8.dev)
  - url: http://localhost:3000
    description: Local dev

security:
  - bearerAuth: []

tags:
  - name: health
    description: Liveness / DB readiness probes.
  - name: retrieval
    description: Pure retrieval — no LLM generation.
  - name: ask
    description: Full RAG. Returns a cited, verified answer with confidence.
  - name: verify
    description: Claim-driven verification. Pass in claims, get verdicts and sources.
  - name: ingest
    description: Trigger corpus expansion. Async — returns a job id immediately, poll for completion.
  - name: article
    description: Whole-article audit. Markdown in, per-claim verdict table out. The editorial CI gate.
  - name: media
    description: Video / audio URL audit. URL in, transcript + per-claim verdict table out. Async — returns a job id immediately, poll for completion.

paths:
  /api/v1/health:
    get:
      tags: [health]
      summary: DB ping (authed).
      operationId: getHealth
      responses:
        '200':
          description: OK
          headers:
            X-Cite8-Cost-Cents: { $ref: '#/components/headers/CostCents' }
            X-Cite8-Quota-Remaining: { $ref: '#/components/headers/QuotaRemaining' }
            X-Cite8-Quota-Limit: { $ref: '#/components/headers/QuotaLimit' }
          content:
            application/json:
              schema:
                type: object
                required: [status, db]
                properties:
                  status: { type: string, example: ok }
                  db: { type: string, example: ok }
                  api_key: { type: string, example: claude-code-local }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503':
          description: DB unreachable.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: degraded }
                  db: { type: string, example: unreachable }
                  error: { type: string }

  /api/v1/search:
    post:
      tags: [retrieval]
      summary: Hybrid retrieval, no LLM.
      description: |
        Vector top-50 + lexical top-50 → RRF fusion → Voyage rerank-2 →
        top-`k` chunks. The cheap endpoint agents call when they want to
        reason over evidence themselves rather than have cite8 summarize.
      operationId: search
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SearchRequest' }
      responses:
        '200':
          description: Ranked sources.
          headers:
            X-Cite8-Cost-Cents: { $ref: '#/components/headers/CostCents' }
            X-Cite8-Quota-Remaining: { $ref: '#/components/headers/QuotaRemaining' }
            X-Cite8-Quota-Limit: { $ref: '#/components/headers/QuotaLimit' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SearchResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/ask:
    post:
      tags: [ask]
      summary: Full RAG with cited, verified answer.
      description: |
        Retrieves, generates a grounded answer with `[N]` markers, runs
        per-citation verification via Haiku, computes a confidence
        verdict, and applies the score-based abstention floor.
      operationId: ask
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AskRequest' }
      responses:
        '200':
          description: Cited answer.
          headers:
            X-Cite8-Cost-Cents: { $ref: '#/components/headers/CostCents' }
            X-Cite8-Quota-Remaining: { $ref: '#/components/headers/QuotaRemaining' }
            X-Cite8-Quota-Limit: { $ref: '#/components/headers/QuotaLimit' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AskResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/verify-claim:
    post:
      tags: [verify]
      summary: One claim → ranked sources with verdicts.
      description: |
        Retrieves top-k sources for the claim and verifies each. Returns
        the flat per-source shape with `verdict` ∈ `{supports, partial,
        contradicts, unrelated, unknown}` + `reasoning` + optional
        verbatim `quote`.

        Designed for per-reference audit while drafting: pass one
        sentence-shaped claim, get evidence for and against it.
      operationId: verifyClaim
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VerifyClaimRequest' }
      responses:
        '200':
          description: Per-source verdicts.
          headers:
            X-Cite8-Cost-Cents: { $ref: '#/components/headers/CostCents' }
            X-Cite8-Quota-Remaining: { $ref: '#/components/headers/QuotaRemaining' }
            X-Cite8-Quota-Limit: { $ref: '#/components/headers/QuotaLimit' }
            X-Cite8-Corpus-As-Of: { $ref: '#/components/headers/CorpusAsOf' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VerifyClaimResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/verify:
    post:
      tags: [verify]
      summary: Batch of claims → batch of verifications.
      description: |
        One round trip per article-worth of claims. Per-claim verifications
        run in parallel server-side; claims run sequentially.
      operationId: verifyBatch
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VerifyBatchRequest' }
      responses:
        '200':
          description: One result per input claim.
          headers:
            X-Cite8-Cost-Cents: { $ref: '#/components/headers/CostCents' }
            X-Cite8-Quota-Remaining: { $ref: '#/components/headers/QuotaRemaining' }
            X-Cite8-Quota-Limit: { $ref: '#/components/headers/QuotaLimit' }
            X-Cite8-Corpus-As-Of: { $ref: '#/components/headers/CorpusAsOf' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VerifyBatchResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/verify-article:
    post:
      tags: [article]
      summary: Audit a markdown article's cited claims.
      description: |
        Extracts cited claims (sentences containing a PMID, PubMed URL, or
        DOI URL), resolves each citation to a source (local corpus first,
        live PubMed E-utilities fallback), runs the claim+source through
        the per-source verifier, and returns a per-claim audit plus a
        summary with a `passing` flag.

        "Passing" = (zero contradicts) AND (zero unrelated) AND (zero
        unresolved). The `unrelated` clause catches fabricated or
        transcription-error PMIDs where the cited paper is off-topic
        from the claim — an explicit author-chosen citation that ends
        up `unrelated` is the smoking gun for "wrong paper cited."
        CI gates can apply stricter rules on top by inspecting the
        per-claim breakdown.

        Live-fetched sources are not inserted into the corpus; they live
        only for the duration of the request. To make a citation available
        for retrieval-backed endpoints afterward, queue an ingest.

        Citation formats supported in V1:
          - `PMID:12345678` (also `PMID 12345678`, `PubMed ID: 12345678`)
          - `https://pubmed.ncbi.nlm.nih.gov/12345678/`
          - `https://doi.org/10.xxxx/xxxxx`

        Markdown link syntax `[text](https://pubmed.ncbi.nlm.nih.gov/...)` is
        unwrapped before extraction so inline-link citations work the same way.
      operationId: verifyArticle
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VerifyArticleRequest' }
      responses:
        '200':
          description: Audit result.
          headers:
            X-Cite8-Cost-Cents: { $ref: '#/components/headers/CostCents' }
            X-Cite8-Quota-Remaining: { $ref: '#/components/headers/QuotaRemaining' }
            X-Cite8-Quota-Limit: { $ref: '#/components/headers/QuotaLimit' }
            X-Cite8-Corpus-As-Of: { $ref: '#/components/headers/CorpusAsOf' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ArticleAudit' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/verify-media:
    post:
      tags: [media]
      summary: Fact-check a video URL against the corpus. Async.
      description: |
        Accepts a video / audio URL and returns a job id immediately
        (HTTP 202). The server downloads the audio (yt-dlp), transcribes
        it locally (whisper.cpp), extracts verifiable clinical claims
        (Claude Sonnet), and runs each claim through the per-claim
        verifier against the corpus. Poll `GET /api/v1/verify-media/{id}`
        for status and final audit.

        Supported sources: any URL yt-dlp handles (YouTube, TikTok,
        Instagram, Twitter/X, Vimeo, Facebook, etc. — ~1700 extractors).

        Transcripts are cached server-side by URL. Re-running the same
        URL skips the slow download + transcribe phases and runs only
        the corpus-dependent verification — so consumers get fresh
        verdicts against the current corpus on every call without
        re-paying the transcription cost. Pass `force_refresh: true` to
        bypass the cache and re-transcribe.

        Jobs run serially server-side. Whisper transcription is the
        dominant cost (~1-2x realtime on CPU).
      operationId: verifyMediaEnqueue
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/VerifyMediaRequest' }
      responses:
        '202':
          description: Job queued.
          headers:
            X-Cite8-Cost-Cents: { $ref: '#/components/headers/CostCents' }
            X-Cite8-Quota-Remaining: { $ref: '#/components/headers/QuotaRemaining' }
            X-Cite8-Quota-Limit: { $ref: '#/components/headers/QuotaLimit' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MediaJob' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/verify-media/{id}:
    get:
      tags: [media]
      summary: Poll a media-verification job.
      operationId: verifyMediaStatus
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Current state of the job.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MediaJob' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: Job not found (process restart, or stale id).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/verify-media/{id}/cancel:
    post:
      tags: [media]
      summary: Cancel a queued media-verification job.
      description: |
        Cancels a job that is still in the queue. Returns 409 if the job
        is already running, done, failed, or cancelled — the pipeline
        (yt-dlp → whisper-cli → Anthropic) is not abort-plumbed, so a
        running job runs to completion.
      operationId: verifyMediaCancel
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Job was queued and is now cancelled.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MediaJob' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: Job not found.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: Job is not in a cancellable state (already running, done, failed, or cancelled).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/ingest:
    post:
      tags: [ingest]
      summary: Queue a PubMed ingest. Async.
      description: |
        Accepts a PubMed search query and returns a job id immediately
        (HTTP 202). Server runs the ingest in the background. Poll
        `GET /api/v1/ingest/{id}` for progress and final stats.

        Ingest is idempotent on PMID — re-issuing the same query is free.
        Jobs run serially to avoid racing Voyage's provider-side rate limit.
      operationId: ingestEnqueue
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/IngestRequest' }
      responses:
        '202':
          description: Job queued.
          headers:
            X-Cite8-Cost-Cents: { $ref: '#/components/headers/CostCents' }
            X-Cite8-Quota-Remaining: { $ref: '#/components/headers/QuotaRemaining' }
            X-Cite8-Quota-Limit: { $ref: '#/components/headers/QuotaLimit' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IngestJobQueued' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

    get:
      tags: [ingest]
      summary: List recent ingest jobs.
      operationId: ingestList
      responses:
        '200':
          description: Recent jobs (capped at 50).
          content:
            application/json:
              schema:
                type: object
                required: [jobs]
                properties:
                  jobs:
                    type: array
                    items: { $ref: '#/components/schemas/IngestJob' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/ingest/batch:
    post:
      tags: [ingest]
      summary: Queue up to 100 PubMed ingests in one call.
      description: |
        Batch form of `/api/v1/ingest`. One HTTP call enqueues N jobs,
        returns all job IDs immediately (HTTP 202). Solves the per-key
        rate-limit footgun for backfill scripts: 600 ingest topics would
        otherwise need 10 minutes of 60/min throttling; batched, it's
        one call.

        Jobs still run serially server-side via the same in-memory queue
        (one ingest at a time to respect Voyage's provider-side rate
        limit), so total wall-clock time is unchanged vs firing them
        individually — only the client-side HTTP rate-limit pressure is.
        Poll each job's status via `GET /api/v1/ingest/{id}`.

        Cap: 100 queries per batch to keep one request from monopolizing
        the in-memory job map.
      operationId: ingestBatchEnqueue
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/IngestBatchRequest' }
      responses:
        '202':
          description: All jobs queued.
          headers:
            X-Cite8-Cost-Cents: { $ref: '#/components/headers/CostCents' }
            X-Cite8-Quota-Remaining: { $ref: '#/components/headers/QuotaRemaining' }
            X-Cite8-Quota-Limit: { $ref: '#/components/headers/QuotaLimit' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IngestBatchResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/ingest/{id}:
    get:
      tags: [ingest]
      summary: Poll an ingest job.
      operationId: ingestStatus
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Current state of the job.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IngestJob' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: Job not found (process restart, or stale id).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429': { $ref: '#/components/responses/RateLimited' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: cite8_sk_*
      description: |
        `Authorization: Bearer cite8_sk_...`
        Provision via `npm run keys -- create --label "..."`.
        Keys are hashed at rest; plaintext shown exactly once at creation.

  headers:
    CostCents:
      description: Estimated request cost in USD-cents (rounded up).
      schema: { type: integer, minimum: 0, example: 1 }
    QuotaRemaining:
      description: Requests remaining in the current 60s window.
      schema: { type: integer, minimum: 0, example: 58 }
    QuotaLimit:
      description: Per-key rate limit (requests per minute).
      schema: { type: integer, minimum: 1, example: 60 }
    CorpusAsOf:
      description: |
        ISO 8601 timestamp of the most recently ingested document in the
        corpus. Lets consumers distinguish "citation likely-stale" (cited
        paper is newer than the corpus) from "citation likely-fabricated"
        (cited paper doesn't exist anywhere). Set on `verify-*` endpoints.
      schema: { type: string, format: date-time, example: '2026-05-27T03:00:00Z' }

  responses:
    BadRequest:
      description: Malformed request body.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ValidationError' }
    Unauthorized:
      description: Missing, malformed, or revoked bearer token.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Per-key rate limit exceeded.
      headers:
        Retry-After:
          description: Seconds until the next request will be accepted.
          schema: { type: integer, minimum: 1 }
      content:
        application/json:
          schema:
            type: object
            required: [error, limit, retry_after_seconds]
            properties:
              error: { type: string, example: rate limit exceeded }
              limit: { type: integer, example: 60 }
              retry_after_seconds: { type: integer, example: 12 }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string }
    ValidationError:
      type: object
      required: [error, issues]
      properties:
        error: { type: string, example: invalid request body }
        issues:
          type: array
          items:
            type: object
            required: [path, message]
            properties:
              path: { type: string, example: query }
              message: { type: string }

    Filters:
      type: object
      description: Optional corpus / domain filters for retrieval-backed routes.
      properties:
        corpora:
          type: array
          items: { type: string }
          maxItems: 20
          description: Corpus tags to restrict to (e.g. `pubmed-msk`).
        domains:
          type: array
          items: { type: string }
          maxItems: 20
          description: Editorial-domain tags to overlap on (e.g. `msk`, `chiropractic`).

    SearchRequest:
      allOf:
        - $ref: '#/components/schemas/Filters'
        - type: object
          required: [query]
          properties:
            query: { type: string, minLength: 1, maxLength: 2000 }
            k: { type: integer, minimum: 1, maximum: 50, default: 20 }

    Source:
      type: object
      description: A retrieved source with rerank score and stable id.
      required: [cite8_id, pmid, title, pubmed_url, rerank_score]
      properties:
        cite8_id: { type: string, example: cite8:pubmed:40978305 }
        pmid: { type: string, example: '40978305' }
        pmc_id: { type: [string, 'null'] }
        doi: { type: [string, 'null'] }
        corpus: { type: string, example: pubmed-msk }
        domains: { type: array, items: { type: string } }
        title: { type: string }
        journal: { type: [string, 'null'] }
        year: { type: [integer, 'null'] }
        authors: { type: array, items: { type: string } }
        abstract: { type: [string, 'null'] }
        mesh_terms: { type: array, items: { type: string } }
        pubmed_url: { type: string, format: uri }
        doi_url: { type: [string, 'null'], format: uri }
        rerank_score: { type: number, minimum: 0, maximum: 1 }

    SearchResponse:
      type: object
      required: [sources, latency_ms]
      properties:
        sources:
          type: array
          items: { $ref: '#/components/schemas/Source' }
        top_rerank_score: { type: [number, 'null'], minimum: 0, maximum: 1 }
        latency_ms: { type: integer, minimum: 0 }

    AskRequest:
      allOf:
        - $ref: '#/components/schemas/Filters'
        - type: object
          required: [query]
          properties:
            query: { type: string, minLength: 1, maxLength: 2000 }
            k: { type: integer, minimum: 1, maximum: 50, default: 20 }

    Citation:
      type: object
      required: [marker, cite8_id, pmid, title, pubmed_url]
      properties:
        marker: { type: integer, description: 'The [N] marker number used in `answer`.' }
        cite8_id: { type: string, example: cite8:pubmed:40978305 }
        pmid: { type: string }
        pmc_id: { type: [string, 'null'] }
        document_id: { type: string }
        title: { type: string }
        journal: { type: [string, 'null'] }
        year: { type: [integer, 'null'] }
        pubmed_url: { type: string, format: uri }
        doi_url: { type: [string, 'null'], format: uri }

    Verdict:
      type: string
      enum: [supports, partial, contradicts, unrelated, unknown]
      description: |
        - `supports`    — source directly states or strongly implies the claim, including specifics.
        - `partial`     — source addresses the topic and supports the direction but not the magnitude / population / timeframe.
        - `contradicts` — source is on-topic and conflicts with the claim.
        - `unrelated`   — source is off-topic; cannot support or refute the claim.
        - `unknown`     — verifier did not return a parseable verdict.

    Verification:
      type: object
      required: [claim, marker, verdict, reasoning]
      properties:
        claim:
          type: string
          description: 'The sentence (with [N] marker) from the answer that this verification targets.'
        marker: { type: integer }
        verdict: { $ref: '#/components/schemas/Verdict' }
        reasoning: { type: string }
        quote: { type: [string, 'null'] }

    AskResponse:
      type: object
      required: [answer, citations, verifications, abstained, confidence, retrieved_count, latency_ms]
      properties:
        answer: { type: string }
        citations:
          type: array
          items: { $ref: '#/components/schemas/Citation' }
        verifications:
          type: array
          items: { $ref: '#/components/schemas/Verification' }
        abstained: { type: boolean }
        confidence:
          type: string
          enum: [high, low, abstained]
        retrieved_count: { type: integer, minimum: 0 }
        top_rerank_score: { type: [number, 'null'], minimum: 0, maximum: 1 }
        latency_ms: { type: integer, minimum: 0 }

    VerifyClaimRequest:
      allOf:
        - $ref: '#/components/schemas/Filters'
        - type: object
          required: [claim]
          properties:
            claim: { type: string, minLength: 1, maxLength: 2000 }
            k: { type: integer, minimum: 1, maximum: 50, default: 10 }
            include_quotes:
              type: boolean
              default: false
              description: When true, each verification carries a verbatim source quote.

    ClaimVerificationSource:
      type: object
      required: [cite8_id, pmid, title, pubmed_url, verdict, reasoning, score]
      properties:
        cite8_id: { type: string }
        pmid: { type: string }
        pmc_id: { type: [string, 'null'] }
        doi: { type: [string, 'null'] }
        title: { type: string }
        journal: { type: [string, 'null'] }
        year: { type: [integer, 'null'] }
        authors: { type: array, items: { type: string } }
        abstract: { type: [string, 'null'] }
        corpus: { type: string }
        domains: { type: array, items: { type: string } }
        pubmed_url: { type: string, format: uri }
        doi_url: { type: [string, 'null'], format: uri }
        score: { type: number, minimum: 0, maximum: 1 }
        verdict: { $ref: '#/components/schemas/Verdict' }
        reasoning: { type: string }
        quote: { type: [string, 'null'] }

    VerifyClaimResponse:
      type: object
      required: [claim, verifications, latency_ms]
      properties:
        claim: { type: string }
        verifications:
          type: array
          items: { $ref: '#/components/schemas/ClaimVerificationSource' }
        latency_ms: { type: integer, minimum: 0 }

    VerifyBatchRequest:
      allOf:
        - $ref: '#/components/schemas/Filters'
        - type: object
          required: [claims]
          properties:
            claims:
              type: array
              items: { type: string, minLength: 1, maxLength: 2000 }
              minItems: 1
              maxItems: 100
            k: { type: integer, minimum: 1, maximum: 50, default: 10 }
            include_quotes: { type: boolean, default: false }

    VerifyBatchResult:
      type: object
      required: [claim, verifications]
      properties:
        claim: { type: string }
        verifications:
          type: array
          items: { $ref: '#/components/schemas/ClaimVerificationSource' }

    VerifyBatchResponse:
      type: object
      required: [results, latency_ms]
      properties:
        results:
          type: array
          items: { $ref: '#/components/schemas/VerifyBatchResult' }
        latency_ms: { type: integer, minimum: 0 }

    VerifyArticleRequest:
      type: object
      required: [markdown]
      properties:
        markdown:
          type: string
          minLength: 1
          maxLength: 500000
          description: The article body. Markdown formatting is stripped before claim extraction.
        include_quotes:
          type: boolean
          default: false
          description: When true, each verification carries a verbatim source quote.

    ExtractedCitation:
      type: object
      required: [kind, raw]
      properties:
        kind: { type: string, enum: [pmid, pmid-url, doi-url] }
        raw: { type: string, description: 'The literal text matched in the markdown.' }
        pmid: { type: string }
        doi: { type: string }

    ArticleVerification:
      type: object
      required: [citation, resolve_status, in_corpus]
      properties:
        citation: { $ref: '#/components/schemas/ExtractedCitation' }
        resolve_status:
          type: string
          enum: [local, live, unresolved]
          description: |
            - `local`      — citation resolved against a doc already in the corpus.
            - `live`       — citation resolved by live PubMed fetch (not in corpus).
            - `unresolved` — citation could not be resolved (no such PMID, etc.).
        cite8_id: { type: [string, 'null'] }
        pmid: { type: [string, 'null'] }
        pmc_id: { type: [string, 'null'] }
        doi: { type: [string, 'null'] }
        title: { type: [string, 'null'] }
        journal: { type: [string, 'null'] }
        year: { type: [integer, 'null'] }
        authors: { type: array, items: { type: string } }
        pubmed_url: { type: [string, 'null'] }
        doi_url: { type: [string, 'null'] }
        in_corpus: { type: boolean }
        verdict:
          oneOf:
            - $ref: '#/components/schemas/Verdict'
            - { type: 'null' }
        reasoning: { type: [string, 'null'] }
        quote: { type: [string, 'null'] }
        error: { type: string }

    ClaimAudit:
      type: object
      required: [text, verifications]
      properties:
        text: { type: string }
        verifications:
          type: array
          items: { $ref: '#/components/schemas/ArticleVerification' }

    ArticleSummary:
      type: object
      required: [total_claims, total_citations, resolved_local, resolved_live, unresolved, verdict_counts, passing, passing_rationale]
      properties:
        total_claims: { type: integer, minimum: 0 }
        total_citations: { type: integer, minimum: 0 }
        resolved_local: { type: integer, minimum: 0 }
        resolved_live: { type: integer, minimum: 0 }
        unresolved: { type: integer, minimum: 0 }
        verdict_counts:
          type: object
          properties:
            supports:    { type: integer, minimum: 0 }
            partial:     { type: integer, minimum: 0 }
            contradicts: { type: integer, minimum: 0 }
            unrelated:   { type: integer, minimum: 0 }
            unknown:     { type: integer, minimum: 0 }
        passing: { type: boolean }
        passing_rationale: { type: string }

    ArticleAudit:
      type: object
      required: [claims, summary, latency_ms]
      properties:
        claims:
          type: array
          items: { $ref: '#/components/schemas/ClaimAudit' }
        summary: { $ref: '#/components/schemas/ArticleSummary' }
        latency_ms: { type: integer, minimum: 0 }

    IngestRequest:
      type: object
      required: [query]
      properties:
        query:
          type: string
          minLength: 1
          maxLength: 500
          description: PubMed search query. Passed straight to NCBI esearch.
        max:
          type: integer
          minimum: 1
          maximum: 2000
          default: 200
          description: Maximum PMIDs to pull. Existing PMIDs are skipped (idempotent).
        corpus:
          type: string
          maxLength: 100
          default: pubmed-msk
          description: Corpus tag stamped on newly inserted documents.
        domains:
          type: array
          items: { type: string, minLength: 1 }
          maxItems: 20
          description: Editorial-domain tags stamped on newly inserted documents.

    IngestBatchRequest:
      type: object
      required: [queries]
      properties:
        queries:
          type: array
          minItems: 1
          maxItems: 100
          items: { $ref: '#/components/schemas/IngestRequest' }
          description: Up to 100 ingest specs to enqueue at once.

    IngestBatchResponse:
      type: object
      required: [jobs]
      properties:
        jobs:
          type: array
          items: { $ref: '#/components/schemas/IngestJobQueued' }
          description: One queued job per input query, in the order submitted.

    IngestJobQueued:
      type: object
      required: [job_id, status, query, max, created_at]
      properties:
        job_id: { type: string, format: uuid }
        status: { type: string, enum: [queued] }
        query: { type: string }
        max: { type: integer }
        created_at: { type: string, format: date-time }

    IngestJobResult:
      type: object
      required: [query, pmidsFound, documentsInserted, chunksInserted]
      properties:
        query: { type: string }
        pmidsFound: { type: integer }
        pmidsAlreadyInDb: { type: integer }
        pmidsNewlyFetched: { type: integer }
        parsed: { type: integer }
        skippedNoAbstract: { type: integer }
        skippedParseError: { type: integer }
        documentsInserted: { type: integer }
        chunksInserted: { type: integer }
        durationMs: { type: integer }

    IngestJob:
      type: object
      required: [job_id, status, query, created_at]
      properties:
        job_id: { type: string, format: uuid }
        status:
          type: string
          enum: [queued, running, done, failed]
        phase:
          type: [string, 'null']
          enum: [esearch, efetch, embed, insert, null]
        query: { type: string }
        max: { type: integer }
        corpus: { type: [string, 'null'] }
        domains: { type: [array, 'null'], items: { type: string } }
        result:
          oneOf:
            - $ref: '#/components/schemas/IngestJobResult'
            - { type: 'null' }
        error: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
        started_at: { type: [string, 'null'], format: date-time }
        finished_at: { type: [string, 'null'], format: date-time }
        duration_ms: { type: [integer, 'null'] }

    VerifyMediaRequest:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          maxLength: 2000
          description: Video / audio URL. Anything yt-dlp recognizes (YouTube, TikTok, Instagram, Twitter/X, Vimeo, Facebook, etc).
        max_claims:
          type: integer
          minimum: 1
          maximum: 100
          default: 30
          description: Cap claims extracted from the transcript.
        k:
          type: integer
          minimum: 1
          maximum: 50
          default: 10
          description: Sources retrieved per claim during verification.
        include_quotes:
          type: boolean
          default: false
          description: When true, each verification carries a verbatim source quote (doubles per-claim verifier cost).
        force_refresh:
          type: boolean
          default: false
          description: Skip the transcript cache and re-download + re-transcribe even if this URL was seen before.
        language:
          type: string
          minLength: 2
          maxLength: 10
          default: en
          description: Transcript language hint (ISO 639-1).

    MediaAuditSummary:
      type: object
      required: [totalClaims, totalVerifications, counts]
      properties:
        totalClaims: { type: integer, minimum: 0 }
        totalVerifications: { type: integer, minimum: 0 }
        counts:
          type: object
          properties:
            supports:    { type: integer, minimum: 0 }
            partial:     { type: integer, minimum: 0 }
            contradicts: { type: integer, minimum: 0 }
            unrelated:   { type: integer, minimum: 0 }
            unknown:     { type: integer, minimum: 0 }

    MediaAudit:
      type: object
      required: [url, cacheHit, download, transcribe, extraction, claims, results, summary, timings]
      properties:
        url: { type: string, format: uri }
        cacheHit:
          type: boolean
          description: True if the transcript came from cache rather than a fresh download + transcribe.
        download:
          type: object
          properties:
            audioPath: { type: string, description: 'Empty string when cacheHit=true; transient path otherwise.' }
            bytes: { type: integer, minimum: 0 }
            title: { type: [string, 'null'] }
            durationSec: { type: [integer, 'null'] }
            uploader: { type: [string, 'null'] }
            videoId: { type: [string, 'null'] }
        transcribe:
          type: object
          properties:
            transcript: { type: string }
            wordCount: { type: integer, minimum: 0 }
            durationMs: { type: integer, minimum: 0 }
            modelPath: { type: string }
        extraction:
          type: object
          properties:
            claimCount: { type: integer, minimum: 0 }
            inputTokens: { type: integer, minimum: 0 }
            outputTokens: { type: integer, minimum: 0 }
        claims:
          type: array
          items: { type: string }
        results:
          type: array
          items: { $ref: '#/components/schemas/VerifyBatchResult' }
        summary: { $ref: '#/components/schemas/MediaAuditSummary' }
        timings:
          type: object
          properties:
            downloadMs: { type: integer, minimum: 0 }
            transcribeMs: { type: integer, minimum: 0 }
            extractMs: { type: integer, minimum: 0 }
            verifyMs: { type: integer, minimum: 0 }
            totalMs: { type: integer, minimum: 0 }

    MediaJob:
      type: object
      required: [job_id, status, url, created_at]
      properties:
        job_id: { type: string, format: uuid }
        status:
          type: string
          enum: [queued, running, done, failed, cancelled]
        phase:
          type: [string, 'null']
          enum: [download, transcribe, extract, verify, null]
        url: { type: string, format: uri }
        queued_by_api_key_id: { type: [integer, 'null'] }
        queued_by_api_key_label: { type: [string, 'null'] }
        result:
          oneOf:
            - $ref: '#/components/schemas/MediaAudit'
            - { type: 'null' }
        error: { type: [string, 'null'] }
        created_at: { type: string, format: date-time }
        started_at: { type: [string, 'null'], format: date-time }
        finished_at: { type: [string, 'null'], format: date-time }
        duration_ms: { type: [integer, 'null'] }
