openapi: 3.1.0
info:
  title: Apograph Voice Rewrite API
  version: '1'
  description: |
    Rewrite text in the caller's voice seat, then score LUAR in the same
    response. Score-only LUAR is a second route for an in-chat polish check.
    Human still copies and sends. Nothing auto-sends.

    Auth: Authorization Bearer APOGRAPH_TENANT_API_KEY (or a portal session
    cookie). Never put real keys in this file or in tickets.

    Machine-readable twin of apps/apograph/docs/VOICE_REWRITE_API.md.
    Live portal routes: POST /api/v1/voice/rewrite and POST /api/v1/voice/luar.
servers:
  - url: http://localhost:3000
    description: Local portal (npm run dev)
  - url: https://apograph.ai
    description: Hosted Apograph
tags:
  - name: voice
    description: Voice rewrite and LUAR for external writing apps
paths:
  /api/v1/voice/rewrite:
    post:
      tags: [voice]
      operationId: voiceRewrite
      summary: Rewrite text in the caller voice seat
      description: |
        Returns voiced_text and a sync numeric luar. Never null/async luar.
        polish false (default) is voice only. polish "light" also returns
        polished_text and luar_polished. Fail closed on auth, empty text,
        seat/GPU failure, or luar_unavailable. Does not send.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VoiceRewriteRequest'
            examples:
              email:
                summary: Email rewrite, voice only
                value:
                  text: Thanks for the intro. I can walk through how we measure whether a draft still sounds like the author, then we can pick a time next week.
                  channel: email
                  polish: false
      responses:
        '200':
          description: Voiced text plus sync LUAR. Copy/send is still human.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VoiceRewriteOk'
              examples:
                ok:
                  value:
                    ok: true
                    voiced_text: Thanks for the intro. I can walk you through how we score whether a draft still sounds like you, then we can find a time next week.
                    luar: 0.72
                    luar_below_floor: false
                    voice_seat_id: c1rw
                    model: qwen3-8b-voice-c1rw
                    usage:
                      prompt_tokens: 180
                      completion_tokens: 42
                      total_tokens: 222
                below_floor:
                  summary: Still 200; caller decides whether to copy
                  value:
                    ok: true
                    voiced_text: Thanks for the intro. I can walk you through how we score whether a draft still sounds like you, then we can find a time next week.
                    luar: 0.51
                    luar_below_floor: true
                    voice_seat_id: c1rw
                    model: qwen3-8b-voice-c1rw
                    usage:
                      prompt_tokens: 180
                      completion_tokens: 42
                      total_tokens: 222
                non_production:
                  summary: Local desk seat URL
                  value:
                    ok: true
                    voiced_text: Thanks for the intro. I can walk you through how we score whether a draft still sounds like you, then we can find a time next week.
                    luar: 0.72
                    luar_below_floor: false
                    voice_seat_id: c1rw
                    model: qwen3-8b-voice-c1rw
                    usage:
                      prompt_tokens: 180
                      completion_tokens: 42
                      total_tokens: 222
                    non_production: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '503':
          description: Seat/GPU down (voice_unavailable) or LUAR could not be scored.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VoiceFail'
              examples:
                voice_unavailable:
                  value:
                    error: voice_unavailable
                    message: c1rw voice is not reachable. Retry after GPU On. Desk URL is non-production.
                luar_unavailable:
                  value:
                    error: luar_unavailable
                    message: LUAR could not be scored
  /api/v1/voice/luar:
    post:
      tags: [voice]
      operationId: voiceLuar
      summary: Score-only LUAR after an in-chat polish
      description: |
        Same auth as rewrite. Does not rewrite. Fail closed with
        luar_unavailable — never return null/async luar. Does not send.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/RequestId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VoiceLuarRequest'
            examples:
              score:
                value:
                  text: Thanks for the intro. I can walk you through how we score whether a draft still sounds like you, then we can find a time next week.
      responses:
        '200':
          description: Numeric LUAR for the submitted text.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VoiceLuarOk'
              examples:
                ok:
                  value:
                    ok: true
                    luar: 0.71
                    voice_seat_id: c1rw
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '503':
          description: LUAR could not be scored.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LuarUnavailable'
              examples:
                luar_unavailable:
                  value:
                    error: luar_unavailable
                    message: LUAR could not be scored
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        Tenant API key. Create/rotate APOGRAPH_TENANT_API_KEY on the portal
        host; send the same secret here. Never paste a real key.
  parameters:
    RequestId:
      name: x-request-id
      in: header
      required: false
      schema:
        type: string
        format: uuid
      description: Correlation id. Portal mints one if omitted.
  responses:
    Unauthorized:
      description: Missing session and missing/wrong Bearer. Do not retry with the same secret.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Unauthorized'
          example:
            error: unauthorized
    Forbidden:
      description: |
        Wrong tenant. These two routes currently emit 401 for missing/invalid
        Bearer (single APOGRAPH_TENANT_API_KEY). Handle 403 the same as 401
        if a gateway or later tenant check returns it. Do not retry.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Forbidden'
          example:
            error: unauthorized
    BadRequest:
      description: Invalid JSON or field. See message.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/BadRequest'
          examples:
            empty_text:
              value:
                error: bad_request
                message: text required
            bad_json:
              value:
                error: bad_request
                message: JSON body with text required
            polish:
              value:
                error: bad_request
                message: polish must be false or "light"
            too_long:
              value:
                error: bad_request
                message: text exceeds 150000 chars — split it
  schemas:
    VoiceRewriteRequest:
      type: object
      additionalProperties: false
      required: [text]
      properties:
        text:
          type: string
          minLength: 1
          maxLength: 150000
          description: Draft to rewrite. Non-empty after trim.
        voice_seat_id:
          type: string
          default: c1rw
          description: Voice seat. Unknown ids fail closed as voice_unavailable.
        channel:
          type: string
          enum: [email, linkedin_message, note, generic]
          default: generic
        polish:
          description: false = voice only (default). "light" adds polished_text.
          default: false
          oneOf:
            - type: boolean
              enum: [false]
            - type: string
              enum: [light]
        max_words:
          type: number
          exclusiveMinimum: 0
          description: Positive JSON number. Cap on the voiced draft.
    VoiceUsage:
      type: object
      additionalProperties: false
      required: [prompt_tokens, completion_tokens, total_tokens]
      properties:
        prompt_tokens:
          type: number
        completion_tokens:
          type: number
        total_tokens:
          type: number
    VoiceRewriteOk:
      type: object
      additionalProperties: false
      required:
        - ok
        - voiced_text
        - luar
        - luar_below_floor
        - voice_seat_id
        - model
        - usage
      properties:
        ok:
          type: boolean
          enum: [true]
        voiced_text:
          type: string
        polished_text:
          type: string
          description: Present only when polish is light.
        luar:
          type: number
          description: Sync LUAR of voiced_text. Never null.
        luar_polished:
          type: number
          description: Present only when polish is light.
        luar_below_floor:
          type: boolean
          description: true when luar is under the product floor 0.60.
        voice_seat_id:
          type: string
        model:
          type: string
          examples: [qwen3-8b-voice-c1rw]
        usage:
          $ref: '#/components/schemas/VoiceUsage'
        non_production:
          type: boolean
          enum: [true]
          description: Present only when the voice URL host is local.
    VoiceLuarRequest:
      type: object
      additionalProperties: false
      required: [text]
      properties:
        text:
          type: string
          minLength: 1
          description: Current draft to score (after in-chat polish).
        voice_seat_id:
          type: string
          default: c1rw
    VoiceLuarOk:
      type: object
      additionalProperties: false
      required: [ok, luar, voice_seat_id]
      properties:
        ok:
          type: boolean
          enum: [true]
        luar:
          type: number
        voice_seat_id:
          type: string
    Unauthorized:
      type: object
      additionalProperties: false
      required: [error]
      properties:
        error:
          type: string
          enum: [unauthorized]
    Forbidden:
      type: object
      additionalProperties: false
      required: [error]
      properties:
        error:
          type: string
          description: Live routes fold this into unauthorized (401).
    BadRequest:
      type: object
      additionalProperties: false
      required: [error, message]
      properties:
        error:
          type: string
          enum: [bad_request]
        message:
          type: string
    VoiceFail:
      type: object
      additionalProperties: false
      required: [error, message]
      properties:
        error:
          type: string
          enum: [voice_unavailable, luar_unavailable]
        message:
          type: string
    LuarUnavailable:
      type: object
      additionalProperties: false
      required: [error, message]
      properties:
        error:
          type: string
          enum: [luar_unavailable]
        message:
          type: string
          examples:
            - LUAR could not be scored
