openapi: 3.1.0
info:
  title: Movie Finder API
  description: >
    AI-powered movie discovery and Q&A via LangGraph.

    ## Authentication
    All `/chat` endpoints require a Bearer token obtained from `/auth/login` or
    `/auth/register`. Access tokens expire in **30 minutes**; use
    `/auth/refresh` to obtain a new pair before expiry. Refresh tokens expire
    in **7 days**. Call `POST /auth/logout` with the refresh token to revoke it
    server-side before it expires.

    ## Rate limiting
    Login, token, and chat endpoints are rate-limited. Exceeded limits return
    **429** with a `Retry-After` header (seconds until the limit resets).

    ## Streaming
    `POST /chat` returns a `text/event-stream` response. Use `fetch` with a
    `ReadableStream` decoder — **not** `EventSource`, which is GET-only and
    cannot carry a request body.

  version: 1.0.0

servers:
  - url: http://localhost:8000
    description: Local development server

security: []   # default: no auth; overridden per-operation where required

# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
paths:

  # ── Auth ─────────────────────────────────────────────────────────────────

  /auth/register:
    post:
      tags: [auth]
      summary: Register
      description: Create a new account and return a JWT pair.
      operationId: register
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UserCreate"
            example:
              email: alice@example.com
              password: "s3cr3tPwd"
      responses:
        "201":
          description: Account created — JWT pair returned.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Token"
        "409":
          description: Email already registered.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorDetail"
              example:
                detail: "Email already registered"
        "422":
          $ref: "#/components/responses/ValidationError"

  /auth/login:
    post:
      tags: [auth]
      summary: Login
      description: Authenticate with email + password and return a JWT pair.
      operationId: login
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UserLogin"
            example:
              email: alice@example.com
              password: "s3cr3tPwd"
      responses:
        "200":
          description: Authenticated — JWT pair returned.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Token"
        "401":
          description: Invalid credentials.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorDetail"
              example:
                detail: "Invalid credentials"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimitExceeded"

  /auth/refresh:
    post:
      tags: [auth]
      summary: Refresh tokens
      description: >
        Exchange a valid refresh token for a new JWT pair.
        Call this before the access token (30 min TTL) expires.
        On 401 redirect the user to `/login`.
      operationId: refresh
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RefreshRequest"
      responses:
        "200":
          description: New JWT pair returned.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Token"
        "401":
          description: Refresh token invalid, expired, or already revoked.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorDetail"
        "422":
          $ref: "#/components/responses/ValidationError"

  /auth/logout:
    post:
      tags: [auth]
      summary: Logout
      description: >
        Revoke a refresh token by blocklisting its JTI until expiry.
        Call this when the user logs out to invalidate the refresh token
        server-side. Access tokens are short-lived (30 min) and expire
        on their own — they cannot be individually revoked. On success
        the client should discard both tokens from storage.
      operationId: logout
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RefreshRequest"
      responses:
        "204":
          description: Refresh token revoked — no content returned.
        "401":
          description: Token is not a refresh token or is missing required claims.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorDetail"
        "422":
          $ref: "#/components/responses/ValidationError"

  # ── Chat ─────────────────────────────────────────────────────────────────

  /chat:
    post:
      tags: [chat]
      summary: Chat (SSE stream)
      description: |
        Send a message and receive a **Server-Sent Events** stream of AI reply
        tokens followed by a final result event.

        ### Session lifecycle
        - `session_id` is a client-generated **UUID v4**.
        - If the session doesn't exist it is created automatically.
        - If it belongs to another user the server returns **403**.

        ### Stream format
        Each SSE line has the form `data: <json>\n\n`.

        **Token event** (one per streamed chunk):
        ```json
        { "type": "token", "content": "<chunk>" }
        ```

        **Done event** (exactly one, always last):
        ```json
        {
          "type": "done",
          "session_id": "uuid",
          "reply": "full assistant reply",
          "phase": "discovery | confirmation | qa",
          "candidates": [...],        // only when phase == "confirmation"
          "confirmed_movie": {...}    // only when phase == "qa"
        }
        ```

        ### Conversation phases
        | Phase | Description |
        |-------|-------------|
        | `discovery` | AI gathers preferences (default) |
        | `confirmation` | AI found candidates; `candidates` array present |
        | `qa` | User confirmed a movie; `confirmed_movie` object present |

        The phase is driven entirely by the AI chain — the client is read-only.

        ### Client implementation notes
        Use `fetch()` with a `ReadableStream` decoder. Do **not** use
        `EventSource` (GET-only, cannot carry a body). Parse the `data: `
        prefix, JSON-decode each line, and skip blank lines. Accumulate
        `token` events into a live text buffer; on `done` finalise and handle
        phase data.
      operationId: chat
      security:
        - HTTPBearer: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ChatRequest"
            example:
              session_id: "550e8400-e29b-41d4-a716-446655440000"
              message: "I'm in the mood for a 90s sci-fi thriller"
      responses:
        "200":
          description: 'SSE stream — `Content-Type: text/event-stream`.'
          content:
            text/event-stream:
              schema:
                $ref: "#/components/schemas/SseStream"
        "401":
          description: Missing or expired access token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorDetail"
        "403":
          description: Session belongs to a different user.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorDetail"
              example:
                detail: "Session not found"
        "422":
          $ref: "#/components/responses/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimitExceeded"

  /chat/{session_id}:
    delete:
      tags: [chat]
      summary: Delete session
      description: >
        Permanently delete a session and all its messages. Only the owning
        user may delete their own sessions. Returns 204 on success.
      operationId: deleteSession
      security:
        - HTTPBearer: []
      parameters:
        - name: session_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: Session deleted.
        "401":
          description: Missing or expired access token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorDetail"
        "404":
          description: Session not found or belongs to another user.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorDetail"

  /chat/{session_id}/history:
    get:
      tags: [chat]
      summary: Get session history
      description: >
        Return the full message history for a session owned by the current
        user. Use this to resume a session after page refresh or navigation.
      operationId: getHistory
      security:
        - HTTPBearer: []
      parameters:
        - name: session_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: UUID of the chat session.
      responses:
        "200":
          description: Session history returned.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SessionHistory"
        "401":
          description: Missing or expired access token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorDetail"
        "404":
          description: Session not found or belongs to another user.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorDetail"
              example:
                detail: "Session not found"
        "422":
          $ref: "#/components/responses/ValidationError"

  /chat/sessions:
    get:
      tags: [chat]
      summary: List sessions
      description: >
        Return a paginated list of chat sessions belonging to the authenticated
        user, ordered by creation time (newest first). Each item includes
        `first_message` so the frontend can display a title without loading full
        history. Call this on app load / after login to restore the session
        sidebar. Use `limit` and `offset` to page through results.
      operationId: listSessions
      security:
        - HTTPBearer: []
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
          description: Maximum number of sessions to return.
        - name: offset
          in: query
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
          description: Number of sessions to skip before returning results.
      responses:
        "200":
          description: Paginated session list returned.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SessionPage"
        "401":
          description: Missing or expired access token.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorDetail"

  # ── Ops ──────────────────────────────────────────────────────────────────

  /health/live:
    get:
      tags: [ops]
      summary: Liveness probe
      description: >
        Returns 200 when the process is running. Used by Docker HEALTHCHECK
        and load balancers to detect crashed replicas.
      operationId: healthLive
      responses:
        "200":
          description: Process is alive.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok

  /health/ready:
    get:
      tags: [ops]
      summary: Readiness probe
      description: >
        Returns 200 when the application is ready to serve traffic (database
        pool connected, graph compiled). Returns 503 during startup or when a
        required dependency is unavailable.
      operationId: healthReady
      responses:
        "200":
          description: Application is ready to serve requests.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok
        "503":
          description: Application is not yet ready.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorDetail"

# ---------------------------------------------------------------------------
# Components
# ---------------------------------------------------------------------------
components:

  # ── Security ─────────────────────────────────────────────────────────────
  securitySchemes:
    HTTPBearer:
      type: http
      scheme: bearer
      bearerFormat: JWT

  # ── Reusable responses ────────────────────────────────────────────────────
  responses:
    ValidationError:
      description: Request body failed schema validation.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/HTTPValidationError"
    RateLimitExceeded:
      description: Rate limit exceeded — back off and retry after the indicated delay.
      headers:
        Retry-After:
          description: Seconds until the rate limit resets.
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorDetail"
          example:
            detail: "Rate limit exceeded"

  # ── Schemas ───────────────────────────────────────────────────────────────
  schemas:

    # Auth input
    UserCreate:
      type: object
      required: [email, password]
      properties:
        email:
          type: string
          format: email
          description: Must be a valid e-mail address.
        password:
          type: string
          minLength: 8
          description: Minimum 8 characters.

    UserLogin:
      type: object
      required: [email, password]
      properties:
        email:
          type: string
          format: email
        password:
          type: string

    RefreshRequest:
      type: object
      required: [refresh_token]
      properties:
        refresh_token:
          type: string
          description: The refresh token obtained from a previous auth call.

    # Auth output
    Token:
      type: object
      required: [access_token, refresh_token]
      properties:
        access_token:
          type: string
          description: >
            Short-lived JWT (30 min). Send as `Authorization: Bearer <token>`
            on every authenticated request.
        refresh_token:
          type: string
          description: Long-lived token (7 days). Use with `/auth/refresh`.
        token_type:
          type: string
          default: bearer

    # Chat input
    ChatRequest:
      type: object
      required: [session_id, message]
      properties:
        session_id:
          type: string
          format: uuid
          description: >
            Client-generated UUID v4. Creates the session on first use; reuses
            it on subsequent messages.
        message:
          type: string
          minLength: 1
          description: >
            The user's chat message. Must be at least 1 character. The server
            enforces a configurable maximum length (default 2000 characters);
            messages exceeding this limit return 422.

    # SSE stream (logical schema — not a JSON body)
    SseStream:
      description: >
        A sequence of `data: <json>\n\n` lines. Two event shapes are emitted:
        `SseTokenEvent` (many) and `SseDoneEvent` (one, final).
      oneOf:
        - $ref: "#/components/schemas/SseTokenEvent"
        - $ref: "#/components/schemas/SseDoneEvent"

    SseTokenEvent:
      type: object
      required: [type, content]
      properties:
        type:
          type: string
          enum: [token]
        content:
          type: string
          description: A single streamed text chunk.

    SseDoneEvent:
      type: object
      required: [type, session_id, reply, phase]
      properties:
        type:
          type: string
          enum: [done]
        session_id:
          type: string
          format: uuid
        reply:
          type: string
          description: >
            The complete assistant reply text for this turn.
            **Always render this as a chat message regardless of phase.**
            On the first `qa` turn (movie just confirmed) this contains the movie
            overview generated by the Q&A agent. Note: `qa` turns do NOT emit
            `token` events — the full reply arrives only in this `done` event.
        phase:
          type: string
          enum: [discovery, confirmation, qa]
          description: Current conversation phase after this turn.
        candidates:
          type: array
          description: Present only when `phase == "confirmation"`.
          items:
            $ref: "#/components/schemas/MovieCandidate"
        confirmed_movie:
          description: Present only when `phase == "qa"`.
          $ref: "#/components/schemas/ConfirmedMovie"

    MovieCandidate:
      type: object
      required: [rag_title, confidence]
      description: >
        A RAG candidate cross-referenced and enriched with live IMDb data.
        All `imdb_*` fields are null when no confident IMDb match was found.
      properties:
        rag_title:
          type: string
          description: Title as indexed in the RAG corpus.
        rag_year:
          type: integer
          description: Release year from the RAG corpus.
        rag_director:
          type: string
          description: Director from the RAG corpus.
        rag_genre:
          type: array
          items:
            type: string
          description: Genres from the RAG corpus.
        rag_cast:
          type: array
          items:
            type: string
          description: Cast members from the RAG corpus.
        rag_plot:
          type: string
          description: Plot summary from the RAG corpus.
        imdb_id:
          type: string
          nullable: true
          description: IMDb title identifier (e.g. `tt0133093`). Null when no confident match.
        imdb_title:
          type: string
          nullable: true
          description: Title as returned by the IMDb API (may differ from rag_title).
        imdb_year:
          type: integer
          nullable: true
          description: Official IMDb release year.
        imdb_rating:
          type: number
          nullable: true
          description: IMDb user rating (0–10).
        imdb_plot:
          type: string
          nullable: true
          description: Official IMDb plot summary.
        imdb_genres:
          type: array
          items:
            type: string
          description: Official IMDb genres.
        imdb_directors:
          type: array
          items:
            type: string
          description: Official IMDb directors.
        imdb_stars:
          type: array
          items:
            type: string
          description: Official IMDb stars.
        imdb_poster_url:
          type: string
          format: uri
          nullable: true
          description: >
            Poster image URL fetched from IMDb during enrichment.
            Null when no confident IMDb match was found for this candidate.
        confidence:
          type: number
          format: float
          minimum: 0
          maximum: 1
          description: >
            Blended confidence score (0–1):
            40% Qdrant cosine similarity (semantic relevance to user's query)
            + 60% IMDb title/year match (correctness of the IMDb record lookup).
            Values are meaningfully differentiated across candidates — display
            as a percentage (e.g. 91%) rather than treating 1.00 as an upper bound.

    ConfirmedMovie:
      type: object
      description: >
        Full enriched record for the confirmed movie. Field names use the
        `imdb_` prefix (matching the backend's enrichment pipeline).
        This object is present on **every** qa-phase `done` event, not just
        the first — use it to keep the movie detail panel populated.
      properties:
        imdb_id:
          type: string
          nullable: true
        imdb_title:
          type: string
          nullable: true
        imdb_year:
          type: integer
          nullable: true
        imdb_rating:
          type: number
          nullable: true
        imdb_plot:
          type: string
          nullable: true
        imdb_genres:
          type: array
          items:
            type: string
        imdb_directors:
          type: array
          items:
            type: string
        imdb_stars:
          type: array
          items:
            type: string
        imdb_poster_url:
          type: string
          format: uri
          nullable: true
        rag_title:
          type: string
          description: Original title from the RAG corpus.
        rag_year:
          type: integer
        confidence:
          type: number
      additionalProperties: true

    SessionPage:
      type: object
      description: Paginated list of session summaries.
      required: [total, limit, offset, items]
      properties:
        total:
          type: integer
          description: Total number of sessions owned by the user (across all pages).
        limit:
          type: integer
          description: Page size used for this response.
        offset:
          type: integer
          description: Number of sessions skipped before this page.
        items:
          type: array
          items:
            $ref: "#/components/schemas/SessionSummary"

    SessionSummary:
      type: object
      required: [session_id, phase, updated_at]
      properties:
        session_id:
          type: string
          format: uuid
        phase:
          type: string
          enum: [discovery, confirmation, qa]
        updated_at:
          type: string
          format: date-time
        first_message:
          type: string
          nullable: true
          description: >
            Content of the first user message in this session. Used by the
            frontend to derive a display title. Null when no messages exist yet.
        confirmed_movie:
          nullable: true
          description: >
            Present (non-null) when `phase == "qa"`. The `EnrichedMovie`-shaped
            dict stored at confirmation time. Null for discovery/confirmation
            sessions. Same shape as `ConfirmedMovie`.
          $ref: "#/components/schemas/ConfirmedMovie"

    # Chat history
    SessionHistory:
      type: object
      required: [session_id, phase, messages]
      properties:
        session_id:
          type: string
          format: uuid
        phase:
          type: string
          enum: [discovery, confirmation, qa]
        messages:
          type: array
          items:
            $ref: "#/components/schemas/Message"
        confirmed_movie:
          nullable: true
          description: >
            Present (non-null) when `phase == "qa"`. Same shape as in
            `SessionSummary`. Used to restore the movie detail panel and
            session title after page reload.
          $ref: "#/components/schemas/ConfirmedMovie"

    Message:
      type: object
      required: [id, session_id, role, content, created_at]
      properties:
        id:
          type: string
          format: uuid
        session_id:
          type: string
          format: uuid
        role:
          type: string
          enum: [user, assistant]
        content:
          type: string
        created_at:
          type: string
          format: date-time

    # Errors
    ErrorDetail:
      type: object
      required: [detail]
      properties:
        detail:
          type: string

    HTTPValidationError:
      type: object
      properties:
        detail:
          type: array
          items:
            $ref: "#/components/schemas/ValidationError"

    ValidationError:
      type: object
      required: [loc, msg, type]
      properties:
        loc:
          type: array
          items:
            anyOf:
              - type: string
              - type: integer
          description: Path to the invalid field.
        msg:
          type: string
        type:
          type: string
        input:
          description: The value that failed validation.
        ctx:
          type: object
          additionalProperties: true
