openapi: 3.1.0
info:
  title: Plio Live Earnings API
  version: "1.0.0"
  description: |
    REST endpoints for live, attributed earnings-call transcripts.

    For streaming endpoints (SSE), see `asyncapi.yaml`.

    All endpoints require a subscriber API key — either via `Authorization:
    Bearer <token>` header, or `?token=<token>` query parameter (the latter
    exists for browser `EventSource` integrations that can't set headers).

    A token grants access to exactly one `call_slug`. Cross-call use returns
    401.
  contact:
    name: Plio
    email: inder@plio.live
    url: https://pliioai.com

servers:
  - url: https://pliioai.com
    description: Production

security:
  - bearerAuth: []
  - queryToken: []

tags:
  - name: meta
    description: Call metadata + roster.
  - name: events
    description: Event log backfill.
  - name: sections
    description: Phase boundaries (section accordion data).

paths:
  /api/live/{slug}/meta:
    get:
      tags: [meta]
      operationId: getCallMeta
      summary: Call metadata, roster, and current status
      description: |
        Returns ticker, fiscal period, current call status
        (`scheduled` / `live` / `ended` / `unknown`), the executive roster
        with avatars + titles, and the covering-analyst list with firm
        affiliations.

        Lightweight — call once on page load + optionally re-fetch when
        you observe the call has ended (so the UI can pivot to a
        post-call state). No need to poll.
      parameters:
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: Call metadata
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CallMeta'
              examples:
                ndva-ended:
                  summary: NVDA Q4 FY26, after the call has ended
                  value:
                    callSlug: nvda-q4-fy26
                    tenantId: plio-live
                    ticker: NVDA
                    fiscalPeriod: Q4 FY26
                    status: ended
                    elapsedSec: null
                    roster:
                      ceo: Jensen Huang
                      cfo: Colette Kress
                      ir: Toshiya Hari
                      extras: [Stewart Stecker]
                    participants:
                      - name: Jensen Huang
                        title: CEO
                        avatarUrl: null
                        role: ceo
                        brainSlug: jensen-huang
                      - name: Colette Kress
                        title: CFO
                        avatarUrl: null
                        role: cfo
                        brainSlug: colette-kress
                    coveringAnalysts:
                      - name: Joe Moore
                        firm: Morgan Stanley
                        firmDomain: morganstanley.com
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/live/{slug}/events:
    get:
      tags: [events]
      operationId: getEventsRange
      summary: Backfill event log for a range of seqs
      description: |
        Returns up to `limit` event envelopes with `seq` in `[from, to]`.

        Use this to:
          - Hydrate a fresh subscriber with historical events on page load
          - Recover after an SSE reconnect (server sets the `Last-Event-ID`
            header on the stream — use that as `from`)

        If the response contains exactly `limit` events and there are more
        in range, `nextFrom` is set to `(last_seq + 1)`. Re-fetch with
        `from=nextFrom` to continue.
      parameters:
        - $ref: '#/components/parameters/Slug'
        - name: from
          in: query
          required: true
          schema:
            type: integer
            minimum: 0
          description: First seq (inclusive)
        - name: to
          in: query
          required: true
          schema:
            type: integer
            minimum: 0
          description: Last seq (inclusive). Must satisfy `to >= from`.
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 5000
            default: 1000
          description: Max events per response
      responses:
        '200':
          description: Event range
          content:
            application/json:
              schema:
                type: object
                required: [events, nextFrom]
                properties:
                  events:
                    type: array
                    items:
                      $ref: '#/components/schemas/EventEnvelope'
                  nextFrom:
                    type: [integer, "null"]
                    description: |
                      When non-null, pagination is required — request again
                      with `from=nextFrom`. Null means you've received
                      everything up to `to`.
        '400':
          description: Invalid range (`from > to`)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/live/{slug}/sections:
    get:
      tags: [sections]
      operationId: getSections
      summary: Phase-transition boundaries for section navigation
      description: |
        Returns the list of `phase_transition` events for the call, in
        chronological order. Each carries a `triggerTurnId` you can use
        to scroll the transcript to the exact bubble that opened the
        section.
      parameters:
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: Section list
          content:
            application/json:
              schema:
                type: object
                required: [sections]
                properties:
                  sections:
                    type: array
                    items:
                      type: object
                      required: [fromPhase, toPhase, triggerTurnId, seq]
                      properties:
                        fromPhase:
                          $ref: '#/components/schemas/Phase'
                        toPhase:
                          $ref: '#/components/schemas/Phase'
                        triggerTurnId:
                          type: string
                        seq:
                          type: integer
                        publishedAt:
                          type: string
                          format: date-time
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/live/{slug}/report:
    get:
      tags: [report]
      operationId: getReport
      summary: Post-call quality + content report
      description: |
        Aggregated post-call report combining the full attributed transcript
        (segmented by phase), AI-surfaced insights with judge scoring,
        runtime + latency stats, and any learnings extracted from this call.

        Pure read — no LLM calls, no writes. The judge scoring and
        learnings rows are populated by an operator-triggered
        `POST /report/generate` step (operator-only — not in this spec).
        If `generate` hasn't run yet, `insights[*].judge` is `null`,
        `learnings_extracted` is `[]`, and the `transcripts_by_phase` +
        `stats` + `latency` sections are still fully populated. Subscribers
        polling this endpoint pre-judge get the transcript + stats; calling
        it after the operator triggers `generate` gets the scored version.

        Tenant scoping enforced by the auth resolver — a token for tenant
        A cannot fetch tenant B's report even if call slugs collide.
      parameters:
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: Report shape (see schema)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Report'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Unknown call slug
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /api/live/{slug}/predictions:
    get:
      tags: [predictions]
      operationId: getPredictions
      summary: Latest analyst-question predictions for the call
      description: |
        Returns the latest `prediction_runs` row for `drop_number=1` and its
        ordered `prediction_items`. Prefers `status='complete'` runs;
        falls back to the latest `pending` / `error` run if no complete
        run exists yet (so the consumer can render pending / error states
        instead of an empty 404).

        Three prediction drops are scoped per the design:

          - Drop 1 (pre-call): generated ~25-30 min before broadcast,
            anchored to the press release + analyst Lens corpus. **LIVE.**
          - Drop 2 (end of prepared remarks): re-ranked with delta callouts
            based on what mgmt actually said. **NOT YET SHIPPED — V1.1.**
          - Drop 3 (per-Q&A): re-ranked remaining-analyst predictions after
            each answered turn. **NOT YET SHIPPED — V1.1.**

        V1 ships Drop 1 only. Drops 2 + 3 layer onto the same response
        shape (the `dropNumber` field discriminates) once they ship.

        Caching:
          - `complete` runs: `Cache-Control: private, max-age=10` (the
            content is immutable; brief private caching is safe).
          - `pending` / `error` runs: `no-cache` (the panel polls every
            10s and a cached 200 would mask the flip to complete).
      parameters:
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: Latest prediction run + items for the call
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PredictionsResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Cookie-authed user without a subscription row for this call
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: No prediction_runs row exists for this call yet
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: ek_*
    queryToken:
      type: apiKey
      in: query
      name: token

  parameters:
    Slug:
      name: slug
      in: path
      required: true
      schema:
        type: string
        pattern: '^[a-z0-9-]+$'
      description: Call slug — e.g., `nvda-q4-fy26`.

  responses:
    Unauthorized:
      description: Missing, invalid, or wrong-scope token
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: Unknown call slug
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          enum: [unauthorized, forbidden, invalid_range, not_found, no_predictions, call_not_found, internal_error]

    PredictionsResponse:
      type: object
      required: [run, items]
      properties:
        run:
          $ref: '#/components/schemas/PredictionRun'
        items:
          type: array
          description: |
            Predicted analyst questions for this run, ordered by `rank` ascending.
            Empty when `run.status` is `pending` or `error`.
          items:
            $ref: '#/components/schemas/PredictionItem'

    PredictionRun:
      type: object
      required: [id, tenantId, callSlug, dropNumber, runSeq, status, createdAt]
      properties:
        id:
          type: string
          format: uuid
        tenantId:
          type: string
          example: plio-live
        callSlug:
          type: string
          example: mrvl-q1-fy27
        dropNumber:
          type: integer
          enum: [1, 2, 3]
          description: |
            1 = pre-call (LIVE). 2 = end-of-prepared-remarks (NOT YET SHIPPED).
            3 = per-Q&A recalibration (NOT YET SHIPPED).
        runSeq:
          type: integer
          minimum: 1
          description: |
            Monotonic per (tenantId, callSlug, dropNumber). Re-runs of the
            same drop bump this. Consumers reading "latest" sort by this DESC.
        status:
          type: string
          enum: [pending, complete, error]
        createdAt:
          type: string
          format: date-time
        errorMessage:
          type:
            - string
            - 'null'
          description: One-line diagnostic when status='error'; null otherwise.

    PredictionItem:
      type: object
      required:
        - id
        - rank
        - analystName
        - analystFirm
        - predictedQuestion
        - whyReasoning
        - peerEvidence
        - confidence
      properties:
        id:
          type: string
          format: uuid
        rank:
          type: integer
          minimum: 1
          description: Unique within a run. 1 = highest joint likelihood × strategic importance.
        analystName:
          type: string
          example: Vivek Arya
        analystFirm:
          type: string
          example: Bank of America
        predictedQuestion:
          type: string
          description: The literal question, phrased in the analyst's voice.
        whyReasoning:
          type: string
          description: Why this analyst, why this question, why now.
        peerEvidence:
          type: array
          description: |
            Cross-company precedents where the same analyst (or a peer) has
            asked an analogous question on another company's call.
          items:
            $ref: '#/components/schemas/PeerEvidence'
        triggerLanguage:
          type:
            - string
            - 'null'
          description: Verbatim release/remarks sentence that motivates this question.
        bestAnswerFrame:
          type:
            - string
            - 'null'
          description: How management should frame the answer.
        likelyFollowUp:
          type:
            - string
            - 'null'
          description: Most likely follow-up if mgmt's answer is evasive.
        confidence:
          type:
            - number
            - 'null'
          minimum: 0
          maximum: 1
          description: Joint-probability estimate (0.00-1.00) that this question will be asked.

    PeerEvidence:
      type: object
      required: [company, quarter, question]
      properties:
        company:
          type: string
          example: Marvell
        quarter:
          type: string
          example: q4-fy26
        question:
          type: string
          description: Verbatim or paraphrased prior question.

    Report:
      type: object
      description: |
        Aggregated post-call report. Sections are independent — `call` +
        `stats` + `latency` + `transcripts_by_phase` are always populated
        for an ended call; `insights[*].judge`, `insights[*].operator_override`,
        and `learnings_extracted` are populated by the operator-triggered
        post-call grade pass (`POST /report/generate`).
      required:
        - call
        - stats
        - latency
        - transcripts_by_phase
        - insights
        - learnings_extracted
      properties:
        call:
          type: object
          required: [slug, ticker, fiscal_period, status]
          properties:
            slug:
              type: string
            ticker:
              type: string
              example: NVDA
            fiscal_period:
              type: string
              example: Q1 FY27
            press_release_url:
              type:
                - string
                - 'null'
            scheduled_start:
              type:
                - string
                - 'null'
              format: date-time
            status:
              type: string
              enum: [scheduled, live, ended, cancelled]
        stats:
          type: object
          required:
            - total_turns
            - total_insights
            - insights_scored
            - scored_breakdown
            - duplicates_detected
          properties:
            total_runtime_sec:
              type:
                - number
                - 'null'
            total_turns:
              type: integer
            total_insights:
              type: integer
            insights_scored:
              type: integer
              description: Subset of `total_insights` that have a judge or operator score.
            scored_breakdown:
              type: object
              description: Count of insights by effective score bucket.
              required: [good, meh, bad, wrong]
              properties:
                good:
                  type: integer
                meh:
                  type: integer
                bad:
                  type: integer
                wrong:
                  type: integer
            duplicates_detected:
              type: integer
              description: Insights the judge marked as semantic duplicates of an earlier one (`judge_dedup_of_seq`).
        latency:
          type: object
          description: |
            Wall-clock metrics across the event stream. `null` fields
            mean too few events to compute (< 2 events for span; <
            samples threshold for percentiles).
          required: [samples]
          properties:
            span_sec:
              type:
                - number
                - 'null'
              description: Time from earliest event to latest event.
            median_inter_event_sec:
              type:
                - number
                - 'null'
            p95_inter_event_sec:
              type:
                - number
                - 'null'
            worst_inter_event_sec:
              type:
                - number
                - 'null'
            samples:
              type: integer
        transcripts_by_phase:
          type: array
          description: |
            Attributed turns segmented by call phase, in chronological
            order. Each phase block contains the turns that fell within
            it. Use `seq` to anchor back to the live event log; use
            `elapsed_sec` for relative timing within the call.
          items:
            type: object
            required: [phase, turns]
            properties:
              phase:
                $ref: '#/components/schemas/Phase'
              turns:
                type: array
                items:
                  type: object
                  required: [seq, speaker, text, elapsed_sec]
                  properties:
                    seq:
                      type: integer
                    speaker:
                      type: string
                    text:
                      type: string
                    elapsed_sec:
                      type: number
        insights:
          type: array
          description: |
            AI-surfaced moments from the call (guidance moves, analyst
            pressure, CFO numbers, deviation from prior guidance, etc.).
            `judge` is null pre-grade; `effective_score` resolves to the
            operator override when set, otherwise the judge score.
          items:
            type: object
            required: [seq, category, frame, insight, watch_for]
            properties:
              seq:
                type: integer
              elapsed_sec:
                type:
                  - number
                  - 'null'
              category:
                type: string
                description: Coarse bucket — `guidance`, `surprise`, `pressure`, `numbers`, etc. Treat unknown values as opaque tags.
              frame:
                type: string
              insight:
                type: string
              watch_for:
                type: string
              judge:
                description: Opus-as-judge scoring. Null pre-grade.
                oneOf:
                  - type: 'null'
                  - type: object
                    required: [score, reasoning, model, at]
                    properties:
                      score:
                        type: string
                        enum: [good, meh, bad, wrong]
                      reasoning:
                        type: string
                      dedup_of_seq:
                        type:
                          - integer
                          - 'null'
                      model:
                        type: string
                      at:
                        type: string
                        format: date-time
              operator_override:
                description: Operator manual override of the judge score. Null when not overridden.
                oneOf:
                  - type: 'null'
                  - type: object
                    required: [score, at]
                    properties:
                      score:
                        type: string
                        enum: [good, meh, bad, wrong]
                      note:
                        type:
                          - string
                          - 'null'
                      at:
                        type: string
                        format: date-time
              effective_score:
                type:
                  - string
                  - 'null'
                enum: [good, meh, bad, wrong, null]
                description: Operator override if set, otherwise judge score, otherwise null.
        learnings_extracted:
          type: array
          description: |
            Patterns extracted from this call's insights for future
            prompt-time injection. `scope=global` learnings apply across
            all calls; `scope=company` are pinned to a specific ticker
            via `scope_value`.
          items:
            type: object
            required: [pattern, evidence_seqs, scope, extracted_at]
            properties:
              pattern:
                type: string
              applies_when:
                type:
                  - string
                  - 'null'
              evidence_seqs:
                type: array
                items:
                  type: integer
              scope:
                type: string
                enum: [global, company]
              scope_value:
                type:
                  - string
                  - 'null'
              extracted_at:
                type: string
                format: date-time

    Phase:
      type: string
      enum:
        - unknown
        - operator_opening
        - ir_intro
        - cfo_remarks
        - ceo_remarks
        - qa
        - closing

    Participant:
      type: object
      required: [name]
      properties:
        name:
          type: string
        title:
          type: [string, "null"]
        avatarUrl:
          type: [string, "null"]
          format: uri
        role:
          type: [string, "null"]
          enum: [ceo, cfo, ir, null]
        brainSlug:
          type: string
          description: |
            If present, links to a Plio AI lens at `/voice/{brainSlug}`
            that can answer follow-ups grounded in this person's prior
            statements (including this call's transcript).

    CoveringAnalyst:
      type: object
      required: [name]
      properties:
        name:
          type: string
        firm:
          type: [string, "null"]
        firmDomain:
          type: string
          description: |
            Used to render the firm's logo via logo.dev.

    CallMeta:
      type: object
      required: [callSlug, tenantId, ticker, status, roster, participants]
      properties:
        callSlug:
          type: string
        tenantId:
          type: string
          example: plio-live
        ticker:
          type: [string, "null"]
        fiscalPeriod:
          type: [string, "null"]
        status:
          type: string
          enum: [scheduled, live, ended, unknown]
        elapsedSec:
          type: [integer, "null"]
        roster:
          type: object
          required: [ceo, cfo, ir, extras]
          properties:
            ceo:
              type: [string, "null"]
            cfo:
              type: [string, "null"]
            ir:
              type: [string, "null"]
            extras:
              type: array
              items:
                type: string
        participants:
          type: array
          items:
            $ref: '#/components/schemas/Participant'
        coveringAnalysts:
          type: array
          items:
            $ref: '#/components/schemas/CoveringAnalyst'

    EventEnvelope:
      type: object
      required: [tenantId, callSlug, seq, publishedAt, event]
      description: |
        The wire-format envelope shared by REST (`/events`) and SSE
        (`/stream`). `event.source` is always `"plio-live"` on
        attribution events — internal provenance labels are stripped.
      properties:
        tenantId:
          type: string
          example: plio-live
        callSlug:
          type: string
        seq:
          type: integer
          description: Monotonic per (tenant, call). Use to detect gaps.
        publishedAt:
          type: string
          format: date-time
        event:
          oneOf:
            - $ref: '#/components/schemas/AttributionEvent'
            - $ref: '#/components/schemas/UnknownEvent'
            - $ref: '#/components/schemas/PhaseTransitionEvent'
            - $ref: '#/components/schemas/DoneEvent'
          discriminator:
            propertyName: type

    AttributionEvent:
      type: object
      required: [type, turnId, name, source]
      properties:
        type:
          const: attribution
        turnId:
          type: string
        name:
          type: string
        source:
          const: plio-live
        text:
          type: string
        phase:
          $ref: '#/components/schemas/Phase'

    UnknownEvent:
      type: object
      required: [type, turnId]
      properties:
        type:
          const: unknown
        turnId:
          type: string
        text:
          type: string
        phase:
          $ref: '#/components/schemas/Phase'

    PhaseTransitionEvent:
      type: object
      required: [type, fromPhase, toPhase, triggerTurnId]
      properties:
        type:
          const: phase_transition
        fromPhase:
          $ref: '#/components/schemas/Phase'
        toPhase:
          $ref: '#/components/schemas/Phase'
        triggerTurnId:
          type: string

    DoneEvent:
      type: object
      required: [type]
      properties:
        type:
          const: done
