openapi: 3.1.0
info:
  title: Vitagri Pulse Brain — Public Evidence API
  version: 1.0.0
  summary: Read-only catalogue of nutrition, soil, and food-system research curated by Vitagri.
  description: |
    The Pulse Brain is Vitagri's open research catalogue: ~4,000 records spanning
    peer-reviewed studies, preprints, industry reports, government policy, and
    grey literature on food, soil health, nutrition, and sustainable agriculture.

    This API exposes the **public, read-only** layer used by
    `https://vitagri.org/evidence/`. Three access tiers:

    - **Free** — anonymous, 100 requests/minute per IP, no key needed.
    - **Researcher** — free, self-serve API key, 1,000 requests/minute,
      bulk export. Sign up at `/evidence/api/sign-up/`.
    - **Commercial** — admin-issued, 5,000 requests/minute, commercial use, SLA.
      Contact `info@vitagri.org`.

    Pass the key in the `X-API-Key` header (or `?apiKey=` query). When you
    exceed the rate limit you'll get a `429` with a `Retry-After` header
    indicating seconds to wait.

    Responses are filterable by theme, geography, source type, study type,
    publication status, year, and tier, with full-text search and (for queries
    of two or more characters) hybrid lexical-plus-semantic ranking.

    For change requests or bulk-export needs, contact `info@vitagri.org`.

  contact:
    name: Vitagri
    email: info@vitagri.org
    url: https://www.vitagri.org/contact/
  license:
    name: Catalogue licence — research metadata only; original sources remain under their own copyright
    url: https://www.vitagri.org/evidence/

servers:
  - url: https://www.vitagri.org/api/v1
    description: Production (versioned alias)
  - url: https://www.vitagri.org/.netlify/functions
    description: Production (direct function path — unversioned, current-latest)

tags:
  - name: Catalogue
    description: List, get, count, and facet operations against the live catalogue.
  - name: Keys
    description: Self-serve API key issuance for the Researcher tier.

security:
  - {}                     # Anonymous (Free tier) is allowed
  - ApiKeyAuth: []         # Or pass an X-API-Key for Researcher/Commercial tier

paths:
  /evidence:
    get:
      tags: [Catalogue]
      summary: List, search, or facet records (also accepts ?action=get|count|facets)
      security:
        - {}                 # Anonymous Free tier
        - ApiKeyAuth: []     # Or keyed Researcher/Commercial
      description: |
        The single multi-action endpoint that powers the public catalogue.
        The `action` query parameter selects the response shape:

        - `list` *(default)* — paginated, filterable list of records.
        - `get` — full single record by `id` (also exposes related items).
        - `count` — total visible record count.
        - `facets` — facet values + counts across the whole catalogue.

        For full-text search, pass a `q` query of two or more characters. To
        opt into hybrid lexical-plus-semantic ranking, also pass
        `scoring=hybrid`. Without `q`, `scoring` is ignored.
      parameters:
        - $ref: '#/components/parameters/Action'
        - name: id
          in: query
          description: Record id, required when `action=get`.
          schema: { type: string }
          example: PB12345
        - name: q
          in: query
          description: Free-text search (two or more characters).
          schema: { type: string }
          example: phytonutrient density
        - name: scoring
          in: query
          description: |
            Optional ranking mode. Only applies when `q` is set.
            - `hybrid` — fuses lexical and semantic similarity (recommended).
            - `semantic` — pure embedding cosine similarity.
            - `lexical` — substring/keyword scoring only.
          schema:
            type: string
            enum: [hybrid, semantic, lexical]
        - name: theme
          in: query
          description: Filter by canonical high-level theme.
          schema: { type: string }
          example: Soil
        - name: subjectTheme
          in: query
          description: Filter by canonical subject theme (one of ~33 enums).
          schema: { type: string }
          example: Nutrition
        - name: geography
          in: query
          description: |
            Filter by region scope. Accepts one of three preset values
            (`uk`, `uk-eu`, `usa`) which each match a country cluster, or
            a literal canonical country/region string for exact matching.
          schema: { type: string }
          example: uk-eu
        - name: sourceType
          in: query
          description: Filter by source type (Peer-reviewed research, Preprint, Industry/policy report, Grey literature, etc.).
          schema: { type: string }
          example: Peer-reviewed research
        - name: studyType
          in: query
          description: Filter by study type (RCT, Systematic review, Cohort study, etc.).
          schema: { type: string }
        - name: publicationStatus
          in: query
          description: Filter by publication status.
          schema:
            type: string
            enum: [Published, Preprint, Under review]
        - name: tier
          in: query
          description: Vitagri evidence tier (1=highest).
          schema: { type: integer, minimum: 1, maximum: 4 }
        - name: year
          in: query
          description: Filter by publication year.
          schema: { type: integer }
        - name: sort
          in: query
          description: Sort key. `relevance` requires `q`.
          schema:
            type: string
            enum: [relevance, recent, year, tier, title]
        - name: page
          in: query
          description: 1-indexed page number.
          schema: { type: integer, minimum: 1, default: 1 }
        - name: pageSize
          in: query
          description: Records per page (1-100).
          schema: { type: integer, minimum: 1, maximum: 100, default: 24 }
      responses:
        '200':
          description: Successful response (shape varies by `action`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ListResponse'
                  - $ref: '#/components/schemas/GetResponse'
                  - $ref: '#/components/schemas/CountResponse'
                  - $ref: '#/components/schemas/FacetsResponse'
              examples:
                list:
                  summary: action=list (default)
                  value:
                    total: 3981
                    filtered: 14
                    page: 1
                    pageSize: 24
                    pageCount: 1
                    records:
                      - id: PB12345
                        title: Cover crops increase soil organic carbon...
                        year: 2024
                        publisher: Nature Sustainability
                        sourceType: Peer-reviewed research
                        publicationStatus: Published
                facets:
                  summary: action=facets
                  value:
                    total: 3981
                    facets:
                      publicationStatus:
                        Published: 2757
                        Preprint: 10
                        Under review: 1
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

  /keys:
    post:
      tags: [Keys]
      summary: Issue a self-serve Researcher tier API key
      description: |
        Captures email + organisation + use case and issues a key on the
        spot. The plaintext key is returned **once** in the response and
        cannot be recovered later — store it immediately.

        Commercial keys are not self-serve — `POST` with `tier: "commercial"`
        returns 403 with a contact link.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/IssueRequest' }
      responses:
        '201':
          description: Key issued. The plaintext is in the response body.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IssueResponse' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          description: Tier requires admin issue (Commercial).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Researcher or Commercial tier key (vgapi_live_…). Free tier requires no key.

  parameters:
    Action:
      name: action
      in: query
      description: Operation selector. Defaults to `list`.
      required: false
      schema:
        type: string
        enum: [list, get, count, facets]
        default: list

  responses:
    BadRequest:
      description: Malformed request (e.g. `action=get` without `id`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: API key missing, malformed, unknown, or revoked.
      headers:
        WWW-Authenticate:
          schema: { type: string, example: 'Bearer realm="vitagri-evidence"' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Record not found or not publishable.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Per-tier rate limit exceeded.
      headers:
        Retry-After:
          schema: { type: integer, example: 42 }
          description: Seconds to wait before retrying.
        X-RateLimit-Limit:
          schema: { type: integer, example: 1000 }
        X-RateLimit-Remaining:
          schema: { type: integer, example: 0 }
        X-RateLimit-Tier:
          schema: { type: string, example: researcher }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    InternalError:
      description: Server-side failure.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: { type: string, example: id query parameter required for action=get }
        detail: { type: string }

    Record:
      type: object
      required: [id, title]
      description: Public projection of a Pulse Brain record. Internal scoring fields, completeness signals, and admin metadata are stripped.
      properties:
        id: { type: string, example: PB12345 }
        title: { type: string }
        authors: { type: string, nullable: true }
        year: { type: integer, nullable: true }
        publisher: { type: string, nullable: true }
        publisherCategory: { type: string, nullable: true }
        sourceType: { type: string, nullable: true, example: Peer-reviewed research }
        publicationStatus: { type: string, nullable: true, example: Published }
        studyType: { type: string, nullable: true }
        studyDesign: { type: string, nullable: true }
        theme: { type: string, nullable: true }
        subjectTheme: { type: string, nullable: true }
        topic: { type: string, nullable: true }
        geography: { type: string, nullable: true }
        tier: { type: integer, nullable: true, minimum: 1, maximum: 4 }
        doi: { type: string, nullable: true }
        url: { type: string, format: uri, nullable: true }
        pdfUrl: { type: string, format: uri, nullable: true }
        license: { type: string, nullable: true }
        oaStatus: { type: string, nullable: true }
        addedAt: { type: string, format: date-time, nullable: true }
        score:
          type: number
          nullable: true
          description: Fused relevance score, only present when `scoring` is active.

    RecordDetail:
      allOf:
        - $ref: '#/components/schemas/Record'
        - type: object
          properties:
            description: { type: string, nullable: true }
            keyFindings: { type: array, items: { type: string }, nullable: true }
            citations: { type: integer, nullable: true }

    ListResponse:
      type: object
      required: [total, filtered, page, pageSize, pageCount, records]
      properties:
        total: { type: integer, description: Total visible records in the catalogue. }
        filtered: { type: integer, description: Records matching the current filters. }
        page: { type: integer }
        pageSize: { type: integer }
        pageCount: { type: integer }
        records:
          type: array
          items: { $ref: '#/components/schemas/Record' }
        facets:
          $ref: '#/components/schemas/Facets'
        scoring:
          type: object
          nullable: true
          description: Present only when `scoring=hybrid|semantic|lexical` was requested.
          properties:
            mode: { type: string }
            queryEmbedded: { type: boolean }
            lexicalHits: { type: integer }
            semanticHits: { type: integer }

    GetResponse:
      type: object
      required: [record]
      properties:
        record: { $ref: '#/components/schemas/RecordDetail' }
        related:
          type: array
          description: Up to 6 related records sharing the subject theme.
          items: { $ref: '#/components/schemas/Record' }

    CountResponse:
      type: object
      required: [total, updatedAt]
      properties:
        total: { type: integer }
        updatedAt: { type: string, format: date-time }

    FacetsResponse:
      type: object
      required: [total, facets]
      properties:
        total: { type: integer }
        facets: { $ref: '#/components/schemas/Facets' }

    Facets:
      type: object
      description: Map of facet name → { value: count } pairs.
      additionalProperties:
        type: object
        additionalProperties: { type: integer }
      example:
        publicationStatus:
          Published: 2757
          Preprint: 10
          Under review: 1
        sourceType:
          Peer-reviewed research: 2420
          Industry/policy report: 891
          Grey literature: 412

    IssueRequest:
      type: object
      required: [email, agreedToTerms]
      properties:
        email:
          type: string
          format: email
          example: jane@university.edu
        organisation:
          type: string
          nullable: true
          example: University of Oxford
        useCase:
          type: string
          nullable: true
          description: One- or two-sentence description of intended use.
          example: Dashboard tracking soil-health research trends.
        tier:
          type: string
          enum: [researcher]
          default: researcher
          description: Only `researcher` is self-serve. Commercial keys are admin-issued.
        agreedToTerms:
          type: boolean
          example: true
          description: Must be true. Confirms acceptance of fair-use terms.

    IssueResponse:
      type: object
      required: [ok, key, keyId, tier, limits]
      properties:
        ok: { type: boolean, example: true }
        key:
          type: string
          example: vgapi_live_a1b2c3d4e5f6...
          description: Plaintext key. Shown once. Cannot be recovered later.
        keyId: { type: string, example: AKlmoxe6y9-a5cf30 }
        prefix: { type: string, example: vgapi_live_a1b2 }
        tier: { type: string, example: researcher }
        tierLabel: { type: string, example: Researcher }
        limits:
          type: object
          properties:
            perMinute: { type: integer, example: 1000 }
            perDay:    { type: integer, nullable: true, example: 50000 }
        usageHint: { type: string }
        message: { type: string }
