openapi: 3.1.0

info:
  title: Parlavi API
  version: 1.0.0
  description: |
    Parlavi is a writing-voice transfer API. The two-step workflow is:

    1. **Analyse** — submit a prose sample, get back a `profileId` (a stored
       voice fingerprint).
    2. **Restyle** — pass the `profileId` and any text; get back that text
       rewritten in the analysed voice.

    You can also skip step 1 by passing a `voice_id` from the platform voice
    catalog (`GET /api/v1/voices`) directly to the restyle endpoint.

    All 4xx/5xx responses use the [RFC 7807](https://www.rfc-editor.org/rfc/rfc7807)
    `application/problem+json` format.

    Rate-limiting headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`,
    `X-RateLimit-Reset`) are present on every response.

servers:
  - url: /
    description: Current origin

tags:
  - name: restyle
    description: Rewrite text in a stored or platform voice
  - name: profiles
    description: Style-profile analysis and retrieval
  - name: voices
    description: Platform voice catalog and personal adapter status
  - name: keys
    description: API key management
  - name: usage
    description: Usage statistics and quota
  - name: sources
    description: Connected content sources for writing sample extraction
  - name: teams
    description: Team management (Team plan)
  - name: health
    description: Service health
  - name: adapters
    description: LoRA fine-tuned style adapter management

# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------

paths:

  /api/v1/analyze:
    post:
      operationId: analyzeText
      summary: Analyze text and create a style profile
      description: |
        Accepts a prose sample via `application/json`, `multipart/form-data`
        (`.txt`, `.md`, `.docx`), or `text/plain`. Normalises the text, runs
        the hybrid extraction pipeline (computational + LLM layers), persists
        the result, and returns the profile alongside extracted features.

        Minimum: 1 word after normalization. Maximum: 200 KB. Short samples
        (under 300 words) still return a profile but with lower
        `featureCoveragePct` and a `qualityHint` field in the response.
      tags: [profiles]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AnalyzeJsonRequest'
            example:
              text: "The quick brown fox jumps over the lazy dog. This is a sample text for analysis."
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/AnalyzeFileRequest'
          text/plain:
            schema:
              type: string
              description: Raw prose body (UTF-8). Max 200 KB.
      responses:
        '200':
          description: Profile created successfully
          headers:
            x-request-id:
              $ref: '#/components/headers/XRequestId'
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnalyzeResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/v1/restyle:
    post:
      operationId: restyleText
      summary: Rewrite text in a stored voice
      description: |
        Rewrites the supplied text so it sounds like the author captured in the
        given style profile or platform voice.

        Supply **either** `profile_id` (a UUID from `POST /api/v1/analyze`) or
        `voice_id` (a UUID from `GET /api/v1/voices`). At least one is required.

        **Async path** — set `webhook_url` to receive a `POST` callback once the
        pipeline finishes. The response is `202 Accepted` with a `job_id`; the
        callback body is `{ job_id, status: "complete"|"failed", result?, error? }`.
      tags: [restyle]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RestyleRequest'
            examples:
              withProfileId:
                summary: Using a profileId from /analyze
                value:
                  profile_id: "123e4567-e89b-12d3-a456-426614174000"
                  text: "Leverage synergies to maximize stakeholder value."
              withVoiceId:
                summary: Using a platform voice
                value:
                  voice_id: "550e8400-e29b-41d4-a716-446655440000"
                  text: "Leverage synergies to maximize stakeholder value."
              async:
                summary: Async with webhook
                value:
                  profile_id: "123e4567-e89b-12d3-a456-426614174000"
                  text: "Leverage synergies to maximize stakeholder value."
                  webhook_url: "https://your-server.com/webhook"
      responses:
        '200':
          description: Text restyled successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RestyleResponse'
        '202':
          description: Async job accepted (only when `webhook_url` is supplied)
          content:
            application/json:
              schema:
                type: object
                required: [job_id, status]
                properties:
                  job_id:
                    type: string
                    format: uuid
                  status:
                    type: string
                    enum: [processing]
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '503':
          description: Restyle service temporarily unavailable
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/v1/voices:
    get:
      operationId: listVoices
      summary: List platform voices available for restyle
      description: |
        Returns all platform voices that can be passed as `voice_id` to
        `POST /api/v1/restyle`. Each voice has a pre-analysed style profile.

        Authenticated users also receive a `sampleCount` field indicating how
        many of their ingested writing samples match that voice's style cluster.
      tags: [voices]
      responses:
        '200':
          description: List of available voices
          content:
            application/json:
              schema:
                type: object
                required: [voices]
                properties:
                  voices:
                    type: array
                    items:
                      $ref: '#/components/schemas/PlatformVoice'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/v1/profiles/public:
    get:
      operationId: listPublicProfiles
      summary: List public community profiles
      description: |
        Paginated list of style profiles that users have opted in to the public
        gallery. No authentication required. Useful for browsing example voices.
      tags: [profiles]
      parameters:
        - name: page
          in: query
          description: 0-indexed page number
          schema:
            type: integer
            default: 0
            minimum: 0
      responses:
        '200':
          description: Paginated public profiles
          content:
            application/json:
              schema:
                type: object
                required: [profiles, total, page, hasMore]
                properties:
                  profiles:
                    type: array
                    items:
                      $ref: '#/components/schemas/StyleProfile'
                  total:
                    type: integer
                  page:
                    type: integer
                  hasMore:
                    type: boolean
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/v1/profiles/{id}:
    get:
      operationId: getProfile
      summary: Retrieve a stored style profile by UUID
      tags: [profiles]
      parameters:
        - name: id
          in: path
          required: true
          description: UUID of the style profile
          schema:
            type: string
            format: uuid
            example: "123e4567-e89b-12d3-a456-426614174000"
      responses:
        '200':
          description: Profile found
          headers:
            x-request-id:
              $ref: '#/components/headers/XRequestId'
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Remaining:
              $ref: '#/components/headers/XRateLimitRemaining'
            X-RateLimit-Reset:
              $ref: '#/components/headers/XRateLimitReset'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StyleProfile'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/v1/lora/train:
    post:
      operationId: startLoraTraining
      summary: Start LoRA fine-tuning
      description: |
        Uploads writing samples to Together AI and kicks off a fine-tuning job.
        Returns immediately with `adapter_id`; poll `GET /api/v1/lora/{id}` for
        status updates.
      tags: [adapters]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [texts]
              properties:
                texts:
                  type: array
                  items:
                    type: string
                  minItems: 1
                  maxItems: 500
                  description: Writing samples used for fine-tuning
                profile_id:
                  type: string
                  format: uuid
                  description: Optional style profile to associate with this adapter
      responses:
        '201':
          description: Fine-tuning job accepted
          content:
            application/json:
              schema:
                type: object
                required: [adapter_id, status]
                properties:
                  adapter_id:
                    type: string
                    format: uuid
                    description: UUID of the new LoRA adapter record
                  status:
                    type: string
                    enum: [pending, training]
                    description: Initial job status
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          description: Training service unavailable
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
              example:
                type: "about:blank"
                title: "Service Unavailable"
                status: 503
                detail: "Together AI training service is currently unavailable"
                instance: "/api/v1/lora/train"

  /api/v1/lora:
    get:
      operationId: listLoraAdapters
      summary: List LoRA adapters
      description: Returns all LoRA adapters owned by the authenticated user.
      tags: [adapters]
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Adapter list
          content:
            application/json:
              schema:
                type: object
                required: [adapters]
                properties:
                  adapters:
                    type: array
                    items:
                      $ref: '#/components/schemas/LoraAdapter'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v1/lora/{id}:
    get:
      operationId: getLoraAdapter
      summary: Get LoRA adapter by ID
      description: |
        Retrieves a LoRA adapter record. If the adapter is still in a
        non-terminal state, this endpoint performs a lazy status refresh
        against the Together AI API before returning.
      tags: [adapters]
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: UUID of the LoRA adapter
          schema:
            type: string
            format: uuid
            example: "123e4567-e89b-12d3-a456-426614174000"
      responses:
        '200':
          description: Adapter found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoraAdapter'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'


  # ---------------------------------------------------------------------------
  # API Keys
  # ---------------------------------------------------------------------------

  /api/v1/keys:
    get:
      operationId: listApiKeys
      summary: List API keys
      description: Returns all active (non-revoked) API keys for the authenticated user. Raw key values are never returned after creation.
      tags: [keys]
      security:
        - bearerAuth: []
      responses:
        '200':
          description: List of API keys
          content:
            application/json:
              schema:
                type: object
                required: [keys]
                properties:
                  keys:
                    type: array
                    items:
                      $ref: '#/components/schemas/ApiKey'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'
    post:
      operationId: createApiKey
      summary: Create an API key
      description: |
        Generates a new API key. The raw key value is returned **once only** in the
        response and never stored. Store it securely — it cannot be retrieved again.
      tags: [keys]
      security:
        - bearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 100
                  description: Optional human-readable label for this key.
                  example: "My integration"
      responses:
        '201':
          description: Key created — raw value shown once
          content:
            application/json:
              schema:
                type: object
                required: [id, name, key, createdAt]
                properties:
                  id:
                    type: string
                    format: uuid
                  name:
                    type: string
                    nullable: true
                  key:
                    type: string
                    description: Raw API key. Shown once — store it securely.
                    example: "pk_live_abc123..."
                  createdAt:
                    type: string
                    format: date-time
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/v1/keys/{id}:
    delete:
      operationId: deleteApiKey
      summary: Revoke an API key
      description: Permanently revokes the key. Requests using the revoked key will receive 401 immediately.
      tags: [keys]
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Key revoked
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

  # ---------------------------------------------------------------------------
  # Usage
  # ---------------------------------------------------------------------------

  /api/v1/usage:
    get:
      operationId: getUsage
      summary: Get API usage statistics
      description: |
        Returns the authenticated user's call counts for the past 7 days and
        current-month usage against their tier limit.
      tags: [usage]
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Usage statistics
          content:
            application/json:
              schema:
                type: object
                required: [dailyCounts, monthly]
                properties:
                  dailyCounts:
                    type: array
                    items:
                      type: object
                      required: [date, count]
                      properties:
                        date:
                          type: string
                          format: date
                          example: "2025-06-01"
                        count:
                          type: integer
                  monthly:
                    type: object
                    required: [used, limit, tier, resetAt]
                    properties:
                      used:
                        type: integer
                        description: Restyle calls used this calendar month.
                      limit:
                        type: integer
                        description: Monthly call limit for the user's tier. -1 means unlimited.
                      tier:
                        type: string
                        example: "pro"
                      resetAt:
                        type: string
                        format: date-time
                        description: When the monthly counter resets (first of next month, UTC).
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'

  # ---------------------------------------------------------------------------
  # Profiles (user-owned)
  # ---------------------------------------------------------------------------

  /api/v1/profiles:
    get:
      operationId: listProfiles
      summary: List user's style profiles
      description: Returns all style profiles belonging to the authenticated user, newest first.
      tags: [profiles]
      security:
        - bearerAuth: []
      responses:
        '200':
          description: List of profiles
          content:
            application/json:
              schema:
                type: object
                required: [profiles]
                properties:
                  profiles:
                    type: array
                    items:
                      $ref: '#/components/schemas/StyleProfile'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/v1/profiles/demo:
    get:
      operationId: listDemoProfiles
      summary: List demo profiles
      description: Returns all built-in demo style profiles. No authentication required. Useful for testing restyle without creating a profile first.
      tags: [profiles]
      responses:
        '200':
          description: Demo profiles
          content:
            application/json:
              schema:
                type: object
                required: [profiles]
                properties:
                  profiles:
                    type: array
                    items:
                      $ref: '#/components/schemas/StyleProfile'
        '500':
          $ref: '#/components/responses/InternalServerError'

  # ---------------------------------------------------------------------------
  # Restyle jobs (async polling)
  # ---------------------------------------------------------------------------

  /api/v1/restyle/jobs/{id}:
    get:
      operationId: getRestyleJob
      summary: Poll an async restyle job
      description: |
        Returns the current status of an async restyle job created when
        `webhook_url` was supplied to `POST /api/v1/restyle`.

        Jobs expire after 1 hour. Poll until `status` is `complete` or `failed`.
      tags: [restyle]
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Job UUID from the 202 response
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Job status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RestyleJob'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

  # ---------------------------------------------------------------------------
  # Data Sources
  # ---------------------------------------------------------------------------

  /api/v1/sources:
    get:
      operationId: listSources
      summary: List connected content sources
      description: Returns all content sources (Medium, Substack, etc.) connected by the authenticated user.
      tags: [sources]
      security:
        - bearerAuth: []
      responses:
        '200':
          description: List of sources
          content:
            application/json:
              schema:
                type: object
                required: [sources]
                properties:
                  sources:
                    type: array
                    items:
                      $ref: '#/components/schemas/DataSource'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'
    post:
      operationId: createSource
      summary: Connect a content source
      description: |
        Connects a URL (Medium profile, Substack newsletter, etc.) and triggers
        background extraction of writing samples. Returns immediately — scraping
        runs asynchronously.
      tags: [sources]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  example: "https://medium.com/@yourhandle"
      responses:
        '201':
          description: Source connected
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DataSource'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: Source already connected
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/v1/sources/{id}:
    delete:
      operationId: deleteSource
      summary: Remove a content source
      description: Disconnects the source. Associated writing samples are retained.
      tags: [sources]
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Source removed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

  # ---------------------------------------------------------------------------
  # Voices — personal adapter status
  # ---------------------------------------------------------------------------

  /api/v1/voices/status:
    get:
      operationId: getVoiceStatus
      summary: Get personal voice adapter status
      description: |
        Returns whether the authenticated user's personal LoRA adapter is loaded
        into VRAM and ready for low-latency restyle calls.

        `voice_status` reflects training state in the DB; `ready` indicates the
        adapter is actively loaded in the inference server.

        | voice_status | ready | Meaning |
        |---|---|---|
        | null | false | No personal voice trained yet |
        | queued / training | false | Training in progress |
        | ready | false | Trained but not loaded in VRAM |
        | ready | true | Trained and serving requests |
        | failed | false | Training failed |
      tags: [voices]
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Voice adapter status
          content:
            application/json:
              schema:
                type: object
                required: [ready, adapter_name, voice_status]
                properties:
                  ready:
                    type: boolean
                    description: True if adapter is loaded in VRAM.
                  adapter_name:
                    type: string
                    nullable: true
                  voice_status:
                    type: string
                    nullable: true
                    enum: [queued, training, ready, failed, syncing]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '503':
          description: Inference server unreachable
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'

  # ---------------------------------------------------------------------------
  # Teams
  # ---------------------------------------------------------------------------

  /api/v1/teams:
    get:
      operationId: listTeams
      summary: List teams
      description: Returns all teams the authenticated user belongs to.
      tags: [teams]
      security:
        - bearerAuth: []
      responses:
        '200':
          description: List of teams
          content:
            application/json:
              schema:
                type: object
                required: [teams]
                properties:
                  teams:
                    type: array
                    items:
                      $ref: '#/components/schemas/Team'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'
    post:
      operationId: createTeam
      summary: Create a team
      description: Creates a new team. The creator is automatically assigned the `owner` role.
      tags: [teams]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  maxLength: 100
                  example: "Acme Corp"
                slug:
                  type: string
                  maxLength: 50
                  description: URL-safe identifier. Auto-generated from name if omitted.
                  example: "acme-corp"
      responses:
        '201':
          description: Team created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Team'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: Slug already taken
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'
        '500':
          $ref: '#/components/responses/InternalServerError'

  /api/v1/teams/{id}:
    get:
      operationId: getTeam
      summary: Get team details
      tags: [teams]
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TeamId'
      responses:
        '200':
          description: Team details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Team'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      operationId: updateTeam
      summary: Update team name or slug
      description: Admin role required.
      tags: [teams]
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TeamId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                slug:
                  type: string
      responses:
        '200':
          description: Team updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Team'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      operationId: deleteTeam
      summary: Delete a team
      description: Owner role required. Cascades — removes all members and invitations.
      tags: [teams]
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TeamId'
      responses:
        '204':
          description: Team deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/teams/{id}/members:
    get:
      operationId: listTeamMembers
      summary: List team members
      description: Member role required.
      tags: [teams]
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TeamId'
      responses:
        '200':
          description: List of members
          content:
            application/json:
              schema:
                type: object
                required: [members]
                properties:
                  members:
                    type: array
                    items:
                      $ref: '#/components/schemas/TeamMember'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /api/v1/teams/{id}/members/{memberId}:
    delete:
      operationId: removeTeamMember
      summary: Remove a team member
      description: Admin role required. An owner cannot remove themselves.
      tags: [teams]
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TeamId'
        - name: memberId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Member removed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/teams/{id}/invitations:
    get:
      operationId: listTeamInvitations
      summary: List pending invitations
      description: Admin role required.
      tags: [teams]
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TeamId'
      responses:
        '200':
          description: Pending invitations
          content:
            application/json:
              schema:
                type: object
                required: [invitations]
                properties:
                  invitations:
                    type: array
                    items:
                      $ref: '#/components/schemas/TeamInvitation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      operationId: createTeamInvitation
      summary: Invite a member to a team
      description: Admin role required. Expiry is 7 days.
      tags: [teams]
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TeamId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, role]
              properties:
                email:
                  type: string
                  format: email
                  example: "colleague@company.com"
                role:
                  type: string
                  enum: [member, admin]
      responses:
        '201':
          description: Invitation created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TeamInvitation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: User already a member or invitation pending
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetail'

  /api/healthz:
    get:
      operationId: healthCheck
      summary: Service health check
      description: |
        Returns `200 ok` when the database is reachable, `503 error` otherwise.
        Response is never cached (`cache-control: no-store`).
      tags: [health]
      responses:
        '200':
          description: Service healthy
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'
        '503':
          description: Service degraded — database unreachable
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthErrorResponse'

# ---------------------------------------------------------------------------
# Components
# ---------------------------------------------------------------------------

components:

  # ---- Security Schemes ----

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: API key or session JWT passed as a Bearer token

  # ---- Parameters ----

  parameters:
    TeamId:
      name: id
      in: path
      required: true
      description: Team UUID
      schema:
        type: string
        format: uuid

  # ---- Headers ----

  headers:
    XRequestId:
      description: Opaque request identifier for log correlation
      schema:
        type: string
        format: uuid
    XRateLimitLimit:
      description: Maximum number of requests allowed in the current window
      schema:
        type: integer
        example: 10
    XRateLimitRemaining:
      description: Number of requests remaining in the current window
      schema:
        type: integer
        example: 9
    XRateLimitReset:
      description: Unix timestamp (seconds) when the current window resets
      schema:
        type: integer
        format: int64
        example: 1714500000

  # ---- Request Schemas ----

  schemas:
    RestyleRequest:
      type: object
      required: [text]
      properties:
        profile_id:
          type: string
          format: uuid
          description: UUID of a style profile from `POST /api/v1/analyze`. Required if `voice_id` is absent.
          example: "123e4567-e89b-12d3-a456-426614174000"
        voice_id:
          type: string
          format: uuid
          description: UUID of a platform voice from `GET /api/v1/voices`. Required if `profile_id` is absent.
          example: "550e8400-e29b-41d4-a716-446655440000"
        text:
          type: string
          description: The text to rewrite. Minimum 10 words.
          example: "Leverage synergies to maximize stakeholder value."
        inference_mode:
          type: string
          enum: [standard, natural]
          default: standard
          description: |
            `standard` — temperature 0.7, deterministic output.
            `natural` — temperature 0.85 + frequency penalty, more varied output
            that reduces AI-detection signals.
        webhook_url:
          type: string
          format: uri
          description: |
            If supplied, the response is `202 Accepted` immediately and the
            pipeline runs asynchronously. The result is POSTed to this URL when done.

    RestyleResponse:
      type: object
      required: [restyledText, profileId, durationMs, modelUsed, inputTokens, outputTokens]
      properties:
        restyledText:
          type: string
          description: The input text rewritten in the target voice.
        profileId:
          type: string
          format: uuid
          description: The style profile that was applied.
        durationMs:
          type: integer
          description: End-to-end pipeline duration in milliseconds.
        modelUsed:
          type: string
          description: Model identifier used for restyling.
          example: "qwen3-4b-lora/professional-en"
        inputTokens:
          type: integer
        outputTokens:
          type: integer
        fidelityScore:
          type: number
          format: float
          nullable: true
          description: Cosine similarity between input and output style vectors (0–1). Higher is closer.
        detectedLanguage:
          type: string
          nullable: true
          description: ISO 639-1 language code detected in the input text.
          example: "en"

    PlatformVoice:
      type: object
      required: [id, name, clusterId]
      properties:
        id:
          type: string
          format: uuid
          description: Voice UUID — pass as `voice_id` to `POST /api/v1/restyle`.
        name:
          type: string
          description: Human-readable voice name.
          example: "Professional EN"
        clusterId:
          type: string
          description: Style cluster identifier.
          example: "professional-en"
        profileId:
          type: string
          format: uuid
          nullable: true
          description: Backing style profile UUID.
        sampleCount:
          type: integer
          nullable: true
          description: Number of the authenticated user's writing samples matching this voice cluster. Only present for authenticated requests.

    AnalyzeJsonRequest:
      type: object
      required: [text]
      properties:
        text:
          type: string
          minLength: 1
          description: Prose sample to analyse. UTF-8. Max 200 KB.
          example: "Here is a passage of writing to analyse for style features..."

    AnalyzeFileRequest:
      type: object
      required: [file]
      properties:
        file:
          type: string
          format: binary
          description: |
            File to extract text from. Supported formats:
            `.txt`, `.md` / `.markdown`, `.docx`. Max 200 KB.

    # ---- Response Schemas ----

    AnalyzeResponse:
      type: object
      required: [profileId, features, shareUrl, featureCoveragePct, durationMs]
      properties:
        profileId:
          type: string
          format: uuid
          description: UUID of the newly created style profile
          example: "123e4567-e89b-12d3-a456-426614174000"
        features:
          $ref: '#/components/schemas/ProfileFeaturesV1'
        shareUrl:
          type: string
          description: Relative URL to the public profile page
          example: "/profile/123e4567-e89b-12d3-a456-426614174000"
        featureCoveragePct:
          type: integer
          minimum: 0
          maximum: 100
          description: Percentage of feature fields successfully populated (0–100)
          example: 95
        durationMs:
          type: integer
          description: End-to-end pipeline duration in milliseconds
          example: 1240
        qualityHint:
          type: string
          nullable: true
          description: >
            Present when the sample is under 300 words. Informs the caller
            that feature coverage may be lower than usual and suggests
            providing more text for better results.
          example: "Sample is short (54 words). Providing 300+ words improves feature coverage."

    StyleProfile:
      type: object
      required: [profileId, version, features, sampleWordCount, featureCoveragePct, shareUrl, createdAt, updatedAt]
      properties:
        profileId:
          type: string
          format: uuid
          description: Unique identifier for this style profile
          example: "123e4567-e89b-12d3-a456-426614174000"
        version:
          type: string
          description: Schema version of the stored profile
          example: "v1"
        features:
          $ref: '#/components/schemas/ProfileFeaturesV1'
        sampleWordCount:
          type: integer
          description: Word count of the input sample used to create this profile
          example: 847
        featureCoveragePct:
          type: integer
          minimum: 0
          maximum: 100
          description: Percentage of feature fields populated
          example: 95
        shareUrl:
          type: string
          description: Relative URL to the public profile page
          example: "/profile/123e4567-e89b-12d3-a456-426614174000"
        language:
          type: string
          nullable: true
          description: ISO 639-1 language code detected from the sample
          example: "en"
        createdAt:
          type: string
          format: date-time
          example: "2025-05-05T18:00:00.000Z"
        updatedAt:
          type: string
          format: date-time
          example: "2025-05-05T18:00:00.000Z"

    ProfileFeaturesV1:
      type: object
      description: |
        Composite writing-style feature vector derived from three analysis layers:
        - **Layer 1 – Lexical**: vocabulary and word-level patterns
        - **Layer 2 – Syntactic**: sentence structure and punctuation habits
        - **Layer 3 – Rhetorical**: persuasion and argumentation patterns
      required:
        - vocabulary_richness
        - formality_level
        - avg_word_length
        - contraction_rate
        - filler_word_patterns
        - jargon_density
        - avg_sentence_length
        - sentence_length_variance
        - passive_voice_ratio
        - punctuation_personality
        - fragment_usage
        - list_tendency
        - hedging_frequency
        - directness_score
        - question_usage
        - emphasis_techniques
        - transition_style
        - opening_patterns
        - closing_patterns
      properties:
        # Layer 1 — Lexical
        vocabulary_richness:
          type: number
          format: float
          description: Type-token ratio (unique words / total words), 0–1
          example: 0.72
        formality_level:
          type: string
          enum: [formal, neutral, casual]
          description: Overall register of the writing sample
          example: neutral
        avg_word_length:
          type: number
          format: float
          description: Mean number of characters per word
          example: 4.8
        contraction_rate:
          type: number
          format: float
          description: Fraction of words that are contractions, 0–1
          example: 0.03
        filler_word_patterns:
          type: array
          items:
            type: string
          description: Frequently recurring filler or hedging words
          example: ["basically", "actually", "you know"]
        jargon_density:
          type: number
          format: float
          description: Fraction of words classified as domain jargon, 0–1
          example: 0.08
        # Layer 2 — Syntactic
        avg_sentence_length:
          type: number
          format: float
          description: Mean number of words per sentence
          example: 18.4
        sentence_length_variance:
          type: number
          format: float
          description: Variance in sentence lengths (higher = more varied rhythm)
          example: 42.1
        passive_voice_ratio:
          type: number
          format: float
          description: Fraction of sentences using passive construction, 0–1
          example: 0.11
        punctuation_personality:
          $ref: '#/components/schemas/PunctuationPersonality'
        fragment_usage:
          type: boolean
          description: Whether the author regularly uses sentence fragments
          example: false
        list_tendency:
          type: number
          format: float
          description: Fraction of paragraphs containing lists or enumerations, 0–1
          example: 0.14
        # Layer 3 — Rhetorical
        hedging_frequency:
          type: number
          format: float
          description: Rate of hedging language per 100 words
          example: 2.3
        directness_score:
          type: number
          format: float
          description: 0–1 score of directness/assertiveness; higher = more direct
          example: 0.68
        question_usage:
          type: string
          enum: [none, low, moderate, high]
          description: Frequency of rhetorical or direct questions
          example: low
        emphasis_techniques:
          type: array
          items:
            type: string
          description: Techniques used for emphasis (e.g. italics, repetition, ALL CAPS)
          example: ["italics", "sentence repetition"]
        transition_style:
          type: string
          description: Dominant transition/connector style
          example: "additive (also, furthermore)"
        opening_patterns:
          type: array
          items:
            type: string
          description: Recurring patterns used to open paragraphs or sections
          example: ["topic statement first", "anecdote hook"]
        closing_patterns:
          type: array
          items:
            type: string
          description: Recurring patterns used to close paragraphs or sections
          example: ["rhetorical question", "summary restatement"]

    PunctuationPersonality:
      type: object
      required: [em_dash_freq, semicolon_freq, paren_freq, exclamation_freq]
      description: Per-1000-words frequency of distinctive punctuation marks
      properties:
        em_dash_freq:
          type: number
          format: float
          description: Em-dash frequency per 1 000 words
          example: 3.2
        semicolon_freq:
          type: number
          format: float
          description: Semicolon frequency per 1 000 words
          example: 1.0
        paren_freq:
          type: number
          format: float
          description: Parenthesis-pair frequency per 1 000 words
          example: 5.4
        exclamation_freq:
          type: number
          format: float
          description: Exclamation-mark frequency per 1 000 words
          example: 0.5

    LoraAdapter:
      type: object
      required: [id, status, createdAt]
      properties:
        id:
          type: string
          format: uuid
        status:
          type: string
          enum: [pending, training, succeeded, failed]
        jobId:
          type: string
          nullable: true
          description: Together AI fine-tuning job ID
        modelVersionId:
          type: string
          nullable: true
          description: Trained model identifier (available after succeeded)
        profileId:
          type: string
          format: uuid
          nullable: true
        createdAt:
          type: string
          format: date-time
        errorMessage:
          type: string
          nullable: true
          description: Error details (available after failed)

    ProblemDetail:
      type: object
      description: RFC 7807 problem detail object
      required: [type, title, status, detail, instance]
      properties:
        type:
          type: string
          description: A URI reference that identifies the problem type
          example: "about:blank"
        title:
          type: string
          description: Short, human-readable summary of the problem type
          example: "Unprocessable Entity"
        status:
          type: integer
          description: HTTP status code
          example: 422
        detail:
          type: string
          description: Human-readable explanation specific to this occurrence
          example: "Provide at least 10 words. Got 8."
        instance:
          type: string
          description: URI reference that identifies the specific occurrence
          example: "/api/v1/analyze"

    HealthResponse:
      type: object
      required: [status, db, version, requestId]
      properties:
        status:
          type: string
          enum: [ok]
          example: ok
        db:
          type: string
          enum: [connected]
          example: connected
        version:
          type: string
          example: "0.1.0"
        requestId:
          type: string
          example: "unknown"

    HealthErrorResponse:
      type: object
      required: [status, db, version, requestId]
      properties:
        status:
          type: string
          enum: [error]
          example: error
        db:
          type: string
          enum: [disconnected]
          example: disconnected
        version:
          type: string
          example: "0.1.0"
        requestId:
          type: string
          example: "unknown"
        error:
          type: string
          description: Human-readable description of the database error
          example: "DATABASE_URL is not set"

    ApiKey:
      type: object
      required: [id, name, createdAt, lastUsedAt]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          nullable: true
          example: "My integration"
        createdAt:
          type: string
          format: date-time
        lastUsedAt:
          type: string
          format: date-time
          nullable: true

    DataSource:
      type: object
      required: [id, url, platform, status, createdAt]
      properties:
        id:
          type: string
          format: uuid
        url:
          type: string
          format: uri
          example: "https://medium.com/@yourhandle"
        platform:
          type: string
          example: "medium"
        status:
          type: string
          enum: [syncing, synced, failed]
        sampleCount:
          type: integer
          nullable: true
          description: Number of writing samples extracted from this source.
        createdAt:
          type: string
          format: date-time

    RestyleJob:
      type: object
      required: [job_id, status, created_at]
      properties:
        job_id:
          type: string
          format: uuid
        status:
          type: string
          enum: [processing, complete, failed]
        result:
          $ref: '#/components/schemas/RestyleResponse'
          nullable: true
          description: Present when status is complete.
        error:
          type: string
          nullable: true
          description: Present when status is failed.
        created_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
          nullable: true

    Team:
      type: object
      required: [id, name, slug, createdAt]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          example: "Acme Corp"
        slug:
          type: string
          example: "acme-corp"
        createdAt:
          type: string
          format: date-time

    TeamMember:
      type: object
      required: [id, userId, role, createdAt]
      properties:
        id:
          type: string
          format: uuid
        userId:
          type: string
          format: uuid
        email:
          type: string
          format: email
          nullable: true
        name:
          type: string
          nullable: true
        role:
          type: string
          enum: [owner, admin, member]
        createdAt:
          type: string
          format: date-time

    TeamInvitation:
      type: object
      required: [id, teamId, email, role, expiresAt, createdAt]
      properties:
        id:
          type: string
          format: uuid
        teamId:
          type: string
          format: uuid
        email:
          type: string
          format: email
        role:
          type: string
          enum: [member, admin]
        expiresAt:
          type: string
          format: date-time
        createdAt:
          type: string
          format: date-time

  # ---- Reusable Responses ----

  responses:
    Unauthorized:
      description: Missing or invalid authentication credentials
      headers:
        x-request-id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          example:
            type: "about:blank"
            title: "Unauthorized"
            status: 401
            detail: "A valid Bearer token is required"
            instance: "/api/v1/lora"

    Forbidden:
      description: Authenticated user does not own the requested resource
      headers:
        x-request-id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          example:
            type: "about:blank"
            title: "Forbidden"
            status: 403
            detail: "You do not have access to this adapter"
            instance: "/api/v1/lora/123e4567-e89b-12d3-a456-426614174000"

    BadRequest:
      description: Malformed request — missing required fields or invalid format
      headers:
        x-request-id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          example:
            type: "about:blank"
            title: "Bad Request"
            status: 400
            detail: "Field \"text\" must be a non-empty string"
            instance: "/api/v1/analyze"

    PayloadTooLarge:
      description: Input exceeds the 200 KB size limit
      headers:
        x-request-id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          example:
            type: "about:blank"
            title: "Payload Too Large"
            status: 413
            detail: "Text must not exceed 200 KB"
            instance: "/api/v1/analyze"

    UnsupportedMediaType:
      description: Content-Type not accepted by this endpoint
      headers:
        x-request-id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          example:
            type: "about:blank"
            title: "Unsupported Media Type"
            status: 415
            detail: "Use application/json with { text } or multipart/form-data with a file field"
            instance: "/api/v1/analyze"

    UnprocessableEntity:
      description: Input is syntactically valid but semantically unprocessable (e.g. too short)
      headers:
        x-request-id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          example:
            type: "about:blank"
            title: "Text Too Short"
            status: 422
            detail: "Provide at least 10 words. Got 8."
            instance: "/api/v1/analyze"

    TooManyRequests:
      description: Rate limit exceeded — sliding window of 10 requests / 10 s per IP
      headers:
        x-request-id:
          $ref: '#/components/headers/XRequestId'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/XRateLimitReset'
        Retry-After:
          description: Seconds until the rate-limit window resets
          schema:
            type: integer
            example: 7
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          example:
            type: "about:blank"
            title: "Too Many Requests"
            status: 429
            detail: "Rate limit exceeded. Retry after 7 seconds."
            instance: "/api/v1/analyze"

    NotFound:
      description: Requested resource does not exist
      headers:
        x-request-id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          example:
            type: "about:blank"
            title: "Not Found"
            status: 404
            detail: "Profile \"123e4567-e89b-12d3-a456-426614174000\" does not exist"
            instance: "/api/v1/profiles/123e4567-e89b-12d3-a456-426614174000"

    InternalServerError:
      description: Unexpected server-side failure
      headers:
        x-request-id:
          $ref: '#/components/headers/XRequestId'
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
          example:
            type: "about:blank"
            title: "Internal Server Error"
            status: 500
            detail: "Extraction pipeline failed"
            instance: "/api/v1/analyze"
