openapi: 3.1.0
info:
  title: AI Questions API
  version: "1.3.0"
  description: |
    REST API for the AI Questions platform. Three services in one API:

    1. **AI Question Generation** — turn documents into exam questions.
    2. **Auto-Grading** — OCR and grade scanned student answer sheets.
    3. **Essay Marking** — score student essays against a rubric.

    All endpoints require Bearer token auth using an entity API key. Quotas are enforced per package
    and tracked in three independent dimensions: question generation, auto-grading, and essay marking.

    To try a request inline, click **Authorize** in the top-right and paste your API key.

    ## What's new in 1.3.0

    Grading responses now include a structured `analysis` object on each
    graded question (auto-grading) and on the essay detail (essay marking):

    - Universal keys: `correctness`, `key_concepts_covered`,
      `key_concepts_missing`, `errors[]`, `strengths`,
      `improvement_suggestions`, `bloom_level`, `confidence`
    - Paper exams also include `handwriting` (legibility, neatness, script,
      crossed_out, corrections_visible, writing_quality_notes)
    - Essays also include `language_quality`, `argument_structure`,
      `evidence_use`, `originality`

    Free-text values are localized to the exam language (e.g. an Arabic exam
    returns Arabic analysis text). The change is **additive** — all existing
    fields are unchanged, and `analysis` may be `null` if the AI did not return
    a valid object. Bubble-sheet responses are unchanged.

    See the `Analysis` and `Handwriting` schemas below.
  contact:
    name: AI Questions Support
    url: https://aiquestions.intrazero.com/#contact
  license:
    name: Proprietary

servers:
  - url: https://aiquestions.intrazero.com
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Question Generation
    description: Generate exam questions from uploaded documents.
  - name: Auto-Grading
    description: Auto-grade scanned student answer sheets.
  - name: Essay Marking
    description: Score student essays against a rubric.
  - name: Bubble Sheet Marking
    description: OMR — auto-grade scanned multichoice bubble sheets.
  - name: Account
    description: Quota and request status.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: UUID
      description: Entity API key. Get yours from the admin panel → Entities.

  responses:
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            missing:
              summary: No Authorization header
              value: { success: false, message: "API key is required. Use Bearer token authentication." }
            invalid:
              summary: Wrong key
              value: { success: false, message: "Invalid API key." }

    Forbidden:
      description: Entity inactive, URL not allowlisted, or feature not enabled on the package.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            inactive:
              value: { success: false, message: "Entity is inactive. Contact administrator." }
            featureGate:
              summary: Feature not enabled on package
              value: { success: false, message: "Auto-grading is not enabled for this account. Upgrade your package to access this feature." }
            essayGate:
              value: { success: false, message: "Essay marking is not enabled for this account. Upgrade your package to access this feature." }

    QuotaExhausted:
      description: Quota for this dimension is exhausted.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            grading:
              value: { success: false, message: "Grading quota exhausted. Remaining: 0" }
            essays:
              value: { success: false, message: "Essay quota exhausted. Remaining: 0" }
            generation:
              value: { success: false, message: "Insufficient quota. Remaining: 0" }

    ValidationError:
      description: Validation error.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            missingKey:
              value: { success: false, message: "answer_key or answer_key_json required" }
            tooManyStudents:
              value: { success: false, message: "This package allows up to 30 students per job; received 50." }
            badRubric:
              value: { success: false, message: "rubric_json must contain a criteria array" }

    RateLimited:
      description: Too many requests. The limit is 30 req/min per entity.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds until you can retry.

  schemas:
    Error:
      type: object
      required: [success, message]
      properties:
        success: { type: boolean, example: false }
        message: { type: string, example: "Human-readable error description" }

    QuotaResponse:
      type: object
      properties:
        questions_remaining: { type: integer, example: 487 }
        grading_remaining: { type: integer, example: 192 }
        essays_remaining: { type: integer, example: 18 }
        bubble_sheets_remaining: { type: integer, example: 79 }
        subscription:
          type: object
          properties:
            id: { type: integer, example: 10 }
            package: { type: string, example: "Pro" }
            expires_at: { type: string, format: date-time }

    GenerateRequest:
      type: object
      required: [file, num_questions]
      properties:
        file:
          type: string
          format: binary
          description: PDF, DOCX, PPTX, PPT, TXT, or MD. Max 50 MB.
        num_questions:
          type: integer
          minimum: 1
          maximum: 50
          example: 10
        question_type:
          type: string
          enum: [multichoice, truefalse, shortanswer, essay, fillinblanks, matching, numerical, ordering, hotspot, labeling, matrix, calculated, recording, bowtie, interactive]
          default: multichoice
        difficulty:
          type: string
          enum: [easy, medium, hard, mixed]
          default: mixed
        topic: { type: string, example: "Photosynthesis" }
        language: { type: string, example: en }

    GeneratedQuestion:
      type: object
      properties:
        name: { type: string, example: "Cell Membrane Function" }
        stem: { type: string, example: "Which structure controls what enters and leaves a cell?" }
        choices:
          type: array
          items: { type: string }
          example: ["Nucleus", "Cell membrane", "Cytoplasm", "Mitochondria"]
        answer: { type: string, example: "Cell membrane" }
        explanation: { type: string, example: "The cell membrane is selectively permeable..." }

    GenerateResponse:
      type: object
      properties:
        success: { type: boolean, example: true }
        request_id: { type: integer, example: 1234 }
        questions:
          type: array
          items: { $ref: '#/components/schemas/GeneratedQuestion' }
        meta:
          type: object
          properties:
            requested: { type: integer, example: 10 }
            generated: { type: integer, example: 10 }
            remaining_quota: { type: integer, example: 487 }
            processing_time_ms: { type: integer, example: 8421 }

    GradingJobCreateRequest:
      type: object
      required: [student_papers]
      properties:
        student_papers:
          type: array
          items: { type: string, format: binary }
          description: One or more image/PDF files. PDFs may stack multiple students (see split_mode).
        answer_key:
          type: string
          format: binary
          description: Image/PDF of the answer key. Required if answer_key_json is omitted.
        answer_key_json:
          type: string
          description: |
            Pre-structured key as JSON string:
            `[{"number":"1","answer":"Paris","max_score":1}]`
        split_mode:
          type: string
          enum: [one_per_file, fixed_pages, auto]
          default: one_per_file
        pages_per_student: { type: integer, minimum: 1 }
        subject: { type: string }
        language: { type: string, example: en }
        rubric:
          type: string
          enum: [exact_match, semantic, partial_credit]
          default: semantic

    GradingJob:
      type: object
      properties:
        job_id: { type: string, format: uuid }
        status:
          type: string
          enum: [processing, completed, needs_review, failed]
        submissions_count: { type: integer, example: 2 }
        graded_submissions: { type: integer, example: 2 }
        submissions:
          type: array
          items: { $ref: '#/components/schemas/GradingSubmissionSummary' }
        error: { type: string, nullable: true }

    GradingSubmissionSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        student_index: { type: integer }
        student_name: { type: string, nullable: true }
        student_id: { type: string, nullable: true }
        score: { type: number, format: float }
        max_score: { type: number, format: float }
        total_questions: { type: integer }
        graded_questions: { type: integer }
        status:
          type: string
          enum: [processing, completed, needs_review, failed]

    GradingSubmissionDetail:
      allOf:
        - $ref: '#/components/schemas/GradingSubmissionSummary'
        - type: object
          properties:
            questions:
              type: array
              items: { $ref: '#/components/schemas/GradingQuestion' }

    GradingQuestion:
      type: object
      properties:
        number: { type: string, example: "7" }
        expected_answer: { type: string }
        student_answer: { type: string, nullable: true }
        is_correct: { type: boolean, nullable: true }
        score: { type: number, format: float }
        max_score: { type: number, format: float }
        confidence: { type: number, format: float, nullable: true }
        status:
          type: string
          enum: [graded, unreadable, manual_override]
        reason_code:
          type: string
          nullable: true
          enum: [low_confidence_ocr, blank, missing, null]
        feedback: { type: string, nullable: true }
        analysis:
          allOf:
            - $ref: '#/components/schemas/Analysis'
          nullable: true
          description: |
            Structured per-question analysis. May be `null` if the AI did not
            return a valid analysis object. All free-text values inside are
            written in the exam language passed to the grading job.
        crop_url:
          type: string
          format: uri
          nullable: true
          description: Set when status=unreadable. PNG of the student's answer region.

    Analysis:
      type: object
      description: |
        Structured analysis of a graded answer. Always additive — existing
        fields like `feedback`, `is_correct`, `score` remain unchanged.
        Free-text values are returned in the exam language (Arabic exam → Arabic text).
      properties:
        correctness:
          type: string
          enum: [fully_correct, partially_correct, incorrect, blank]
        key_concepts_covered:
          type: array
          items: { type: string }
          description: Concepts the student got right.
        key_concepts_missing:
          type: array
          items: { type: string }
          description: Important concepts the student didn't address.
        errors:
          type: array
          description: |
            Issues found in the student's answer. Treat unknown `type` values
            as generic errors — we may add new types over time.
          items:
            type: object
            properties:
              type:
                type: string
                description: |
                  Common values: `factual`, `conceptual`, `computational`,
                  `terminology`, `incomplete`. Essays may also include
                  `argumentation`, `grammar`, `structure`.
              description: { type: string }
              severity: { type: string, enum: [low, medium, high] }
        strengths:
          type: array
          items: { type: string }
          description: What the student did well.
        improvement_suggestions:
          type: array
          items: { type: string }
          description: Concrete, question-specific tips.
        bloom_level:
          type: string
          enum: [recall, understanding, application, analysis, evaluation, creation]
          description: Cognitive level the answer demonstrates.
        confidence:
          type: number
          format: float
          minimum: 0
          maximum: 1
          description: AI's certainty in the analysis. Use to flag low-confidence cases for human review.
        handwriting:
          $ref: '#/components/schemas/Handwriting'
        # Essay-only extra keys (only present in essay grading responses):
        language_quality:
          type: object
          description: (Essay only) Each scored 0–10.
          properties:
            grammar: { type: integer, minimum: 0, maximum: 10 }
            clarity: { type: integer, minimum: 0, maximum: 10 }
            structure: { type: integer, minimum: 0, maximum: 10 }
            vocabulary: { type: integer, minimum: 0, maximum: 10 }
        argument_structure:
          type: string
          enum: [well_organized, somewhat_organized, disorganized]
          description: (Essay only)
        evidence_use:
          type: string
          enum: [strong, moderate, weak, missing]
          description: (Essay only)
        originality:
          type: string
          enum: [original, common, generic]
          description: (Essay only)

    Handwriting:
      type: object
      description: |
        Handwriting analysis for paper-exam answers. When the student selected
        a printed option (circled MCQ / checked True/False box) instead of
        writing, `legibility = "not_handwritten"` — show no handwriting warnings
        in this case.
      properties:
        legibility:
          type: string
          enum: [clear, readable, hard_to_read, illegible, not_handwritten]
        neatness_score:
          type: integer
          minimum: 0
          maximum: 10
          description: 0 if not handwritten.
        language: { type: string, description: "Detected language of the writing (e.g. \"arabic\", \"english\")." }
        script: { type: string, enum: [latin, arabic, mixed, other] }
        crossed_out: { type: boolean }
        corrections_visible: { type: boolean, description: Eraser marks, scribbles, edits. }
        writing_quality_notes:
          type: array
          items: { type: string }
          description: Free-form observations (e.g. "circled multiple-choice option", "checked True box").

    GradingOverrideRequest:
      type: object
      properties:
        student_answer: { type: string, example: "H2O" }
        is_correct: { type: boolean, example: true }
        score: { type: number, example: 1 }

    EssayJobCreateRequest:
      type: object
      properties:
        essays:
          type: array
          items: { type: string, format: binary }
          description: |
            File uploads — image, PDF, DOCX, or TXT. One file = one essay.
            Provide either `essays[]` or `essay_text[]` (or both).
        essay_text:
          type: array
          items: { type: string }
          description: Inline typed essays. Index-aligned with `student_names[]`.
        student_names:
          type: array
          items: { type: string }
        rubric_json:
          type: string
          description: |
            Canonical rubric as JSON string. **One of** rubric_json / rubric_file / model_answer required.
            Schema: `{ "name":"...", "criteria":[{"key","label","max_score","description?"}] }`
        rubric_file:
          type: string
          format: binary
          description: Free-form rubric document. The AI normalizes it to canonical JSON.
        model_answer:
          type: string
          description: Exemplar essay; the AI derives criteria from it.
        essay_prompt:
          type: string
          example: "Should social media be regulated?"
        language:
          type: string
          example: en
        feedback_depth:
          type: string
          enum: [brief, detailed, revision_suggestions]
          default: brief
        subject: { type: string }

    EssayCriterionScore:
      type: object
      properties:
        key: { type: string, example: thesis }
        label: { type: string, example: "Thesis statement" }
        score: { type: number, example: 4 }
        max_score: { type: number, example: 5 }
        feedback: { type: string, example: "Clear thesis but lacks specificity." }
        evidence_quotes:
          type: array
          items: { type: string }

    EssayHighlight:
      type: object
      properties:
        start: { type: integer, example: 412 }
        end: { type: integer, example: 458 }
        kind: { type: string, example: unsupported_claim }
        note: { type: string, example: "This claim needs a citation." }

    EssayDetail:
      type: object
      properties:
        word_count: { type: integer, example: 487 }
        overall_score: { type: number, example: 13 }
        max_overall_score: { type: number, example: 15 }
        overall_feedback: { type: string }
        analysis:
          allOf:
            - $ref: '#/components/schemas/Analysis'
          nullable: true
          description: |
            Structured analysis of the essay. Includes the universal keys plus
            essay-only extras (`language_quality`, `argument_structure`,
            `evidence_use`, `originality`). May be `null` if the AI did not
            return a valid analysis object.
        highlights:
          type: array
          items: { $ref: '#/components/schemas/EssayHighlight' }
        extraction_confidence: { type: number, format: float, nullable: true }
        status:
          type: string
          enum: [graded, unreadable, manual_override, failed]
        reason_code:
          type: string
          nullable: true
          enum: [too_long, extraction_failed, null]
        criteria:
          type: array
          items: { $ref: '#/components/schemas/EssayCriterionScore' }
        extracted_text_url:
          type: string
          format: uri
          description: URL returning the OCR'd plain-text essay.

    EssaySubmission:
      type: object
      properties:
        id: { type: string, format: uuid }
        student_index: { type: integer }
        student_name: { type: string, nullable: true }
        status:
          type: string
          enum: [processing, completed, needs_review, failed]
        score: { type: number }
        max_score: { type: number }
        essay: { $ref: '#/components/schemas/EssayDetail' }

    EssayJob:
      type: object
      properties:
        job_id: { type: string, format: uuid }
        mode: { type: string, enum: [essay] }
        status:
          type: string
          enum: [processing, completed, needs_review, failed]
        submissions_count: { type: integer }
        graded_submissions: { type: integer }
        essays_charged: { type: integer }
        essay_quota_source:
          type: string
          enum: [subscription, mixed, extra_essays, null]
          nullable: true
        rubric: { type: object }
        submissions:
          type: array
          items: { $ref: '#/components/schemas/EssaySubmission' }
        error: { type: string, nullable: true }

    EssayOverrideRequest:
      type: object
      properties:
        criteria:
          type: array
          items:
            type: object
            properties:
              key: { type: string, example: thesis }
              score: { type: number, example: 5 }
              feedback: { type: string, example: "Adjusted up after re-read." }
        overall_feedback:
          type: string
          example: "Final teacher comment."

paths:
  ##############################################################
  # Account
  ##############################################################
  /api/v1/quota:
    get:
      tags: [Account]
      summary: Get remaining quota across all dimensions
      responses:
        '200':
          description: Quota snapshot
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QuotaResponse' }
              example:
                questions_remaining: 487
                grading_remaining: 192
                essays_remaining: 18
                subscription:
                  id: 10
                  package: "Pro"
                  expires_at: "2027-04-09T00:00:00Z"
        '401': { $ref: '#/components/responses/Unauthorized' }

  /api/v1/status/{id}:
    get:
      tags: [Account]
      summary: Get a question-generation request by id
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: integer }
      responses:
        '200':
          description: Request details
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: Not found

  ##############################################################
  # 1. Question Generation
  ##############################################################
  /api/v1/generate:
    post:
      tags: [Question Generation]
      summary: Generate questions from a document
      description: |
        Upload a document (PDF / DOCX / PPTX / TXT) and receive AI-generated exam questions.
        Charged 1 quota unit per question generated.

        **Cross-request deduplication**: when `num_questions ≤ 3`, the platform automatically
        deduplicates against the entity's last 5 completed requests on the same filename within
        the past 10 minutes — useful for Moodle plugins that fetch one question at a time.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: '#/components/schemas/GenerateRequest' }
            encoding:
              file:
                contentType: application/pdf, application/vnd.openxmlformats-officedocument.wordprocessingml.document, application/vnd.openxmlformats-officedocument.presentationml.presentation, text/plain, text/markdown
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GenerateResponse' }
              examples:
                multichoice:
                  summary: 10 multichoice questions
                  value:
                    success: true
                    request_id: 1234
                    questions:
                      - name: "Cell Membrane Function"
                        stem: "Which structure controls what enters and leaves a cell?"
                        choices: ["Nucleus", "Cell membrane", "Cytoplasm", "Mitochondria"]
                        answer: "Cell membrane"
                        explanation: "The cell membrane is selectively permeable..."
                    meta: { requested: 10, generated: 10, remaining_quota: 487, processing_time_ms: 8421 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/QuotaExhausted' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500':
          description: Generation failure
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { success: false, request_id: 1234, message: "OpenAI API returned HTTP 500: ..." }

  ##############################################################
  # 2. Auto-Grading
  ##############################################################
  /api/v1/grading/jobs:
    get:
      tags: [Auto-Grading]
      summary: List auto-grading jobs (paginated)
      parameters:
        - in: query
          name: page
          schema: { type: integer, default: 1 }
      responses:
        '200':
          description: Paginated list
        '401': { $ref: '#/components/responses/Unauthorized' }

    post:
      tags: [Auto-Grading]
      summary: Submit a new grading job
      description: |
        Upload one or more student answer sheets plus an answer key. The platform OCRs every
        answer, matches it against the key, and scores each question. Multi-student uploads are
        supported via `split_mode`.

        **Quota**: charged 1 unit per question successfully read & graded. `unreadable` questions are not charged.

        **Pre-flight rejections**:
        - `403` if package does not have `grading_enabled` and no `extra_graded_questions`
        - `402` if grading quota = 0
        - `422` if `student_papers` count exceeds `package.max_students_per_job`
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: '#/components/schemas/GradingJobCreateRequest' }
      responses:
        '201':
          description: Job processed (synchronous in v1)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GradingJob' }
              examples:
                singleStudentNeedsReview:
                  summary: Single student, one question unreadable
                  value:
                    job_id: "019d71f5-9ea8-7287-b7cc-f701de315d16"
                    status: needs_review
                    submissions_count: 1
                    graded_submissions: 1
                    submissions:
                      - id: "019d71f5-9edd-73f0-b3da-cc54344786a2"
                        student_index: 1
                        student_name: "Alice Demo"
                        student_id: "S-1001"
                        score: 2
                        max_score: 3
                        total_questions: 3
                        graded_questions: 2
                        status: needs_review
                    error: null
                multiStudentCompleted:
                  summary: Two students, fully graded
                  value:
                    job_id: "019d71f5-da2c-7254-8180-8c0b7a5c6fd1"
                    status: completed
                    submissions_count: 2
                    graded_submissions: 2
                    submissions:
                      - { id: "sub-uuid-1", student_index: 1, student_name: "Alice Demo", student_id: "S-1001", score: 18, max_score: 20, total_questions: 20, graded_questions: 20, status: completed }
                      - { id: "sub-uuid-2", student_index: 2, student_name: "Bob Demo",   student_id: "S-1002", score: 15, max_score: 20, total_questions: 20, graded_questions: 20, status: completed }
                    error: null
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/QuotaExhausted' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/grading/jobs/{id}:
    get:
      tags: [Auto-Grading]
      summary: Get an auto-grading job by id
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Job details
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GradingJob' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Not your entity's job }
        '404': { description: Not found }

    delete:
      tags: [Auto-Grading]
      summary: Delete a grading job
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: Deleted }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Forbidden }

  /api/v1/grading/jobs/{id}/submissions/{submissionId}:
    get:
      tags: [Auto-Grading]
      summary: Per-student detail with full per-question breakdown
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: path
          name: submissionId
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Submission detail
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GradingSubmissionDetail' }
              example:
                id: "019d71f5-da4b-73e2-ba95-0e41d564fd3f"
                student_index: 1
                student_name: "Alice Demo"
                student_id: "S-1001"
                score: 2
                max_score: 3
                total_questions: 3
                graded_questions: 2
                status: needs_review
                questions:
                  - number: "1"
                    expected_answer: "Paris"
                    student_answer: "Paris"
                    is_correct: true
                    score: 1
                    max_score: 1
                    confidence: 0.97
                    status: graded
                    reason_code: null
                    feedback: "Correct"
                    analysis:
                      correctness: fully_correct
                      key_concepts_covered: ["capital of France"]
                      key_concepts_missing: []
                      errors: []
                      strengths: ["accurate, concise answer"]
                      improvement_suggestions: []
                      bloom_level: recall
                      confidence: 0.95
                      handwriting:
                        legibility: clear
                        neatness_score: 8
                        language: english
                        script: latin
                        crossed_out: false
                        corrections_visible: false
                        writing_quality_notes: ["clear, well-formed letters"]
                    crop_url: null
                  - number: "2"
                    expected_answer: "H2O"
                    student_answer: "H2O"
                    is_correct: true
                    score: 1
                    max_score: 1
                    confidence: 0.94
                    status: graded
                    reason_code: null
                    feedback: "Correct"
                    analysis:
                      correctness: fully_correct
                      key_concepts_covered: ["chemical formula for water"]
                      errors: []
                      strengths: ["correct formula"]
                      improvement_suggestions: []
                      bloom_level: recall
                      confidence: 0.95
                      handwriting: { legibility: not_handwritten, neatness_score: 0, language: english, script: latin, crossed_out: false, corrections_visible: false, writing_quality_notes: ["typed answer"] }
                    crop_url: null
                  - number: "3"
                    expected_answer: "42"
                    student_answer: "scribble"
                    is_correct: false
                    score: 0
                    max_score: 1
                    confidence: 0.18
                    status: unreadable
                    reason_code: low_confidence_ocr
                    feedback: null
                    analysis: null
                    crop_url: "https://aiquestions.intrazero.com/api/v1/grading/jobs/.../questions/3/crop"

  /api/v1/grading/jobs/{id}/submissions/{submissionId}/questions/{questionNumber}/crop:
    get:
      tags: [Auto-Grading]
      summary: Get the cropped PNG of an unreadable question
      description: Returns the student's answer region as PNG, ready to display in a manual review UI.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: path
          name: submissionId
          required: true
          schema: { type: string, format: uuid }
        - in: path
          name: questionNumber
          required: true
          schema: { type: string }
      responses:
        '200':
          description: PNG image
          content:
            image/png:
              schema: { type: string, format: binary }
        '404':
          description: No crop available

  /api/v1/grading/jobs/{id}/submissions/{submissionId}/questions/{questionNumber}/{patch}:
    parameters:
      - in: path
        name: id
        required: true
        schema: { type: string, format: uuid }
      - in: path
        name: submissionId
        required: true
        schema: { type: string, format: uuid }
      - in: path
        name: questionNumber
        required: true
        schema: { type: string }
      - in: path
        name: patch
        required: true
        schema: { type: string, enum: [override] }
        description: Use the literal "override" — placeholder so the spec doesn't collide with the GET /crop path.

  /api/v1/grading/jobs/{id}/submissions/{submissionId}/questions/{questionNumber}:
    patch:
      tags: [Auto-Grading]
      summary: Manually override a graded question
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: path
          name: submissionId
          required: true
          schema: { type: string, format: uuid }
        - in: path
          name: questionNumber
          required: true
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/GradingOverrideRequest' }
            example: { student_answer: "42", is_correct: true, score: 1 }
      responses:
        '200':
          description: Updated submission with recomputed score
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GradingSubmissionDetail' }

  ##############################################################
  # 3. Essay Marking
  ##############################################################
  /api/v1/grading/essays:
    get:
      tags: [Essay Marking]
      summary: List essay marking jobs (paginated)
      responses:
        '200': { description: Paginated list }
        '401': { $ref: '#/components/responses/Unauthorized' }

    post:
      tags: [Essay Marking]
      summary: Submit essays for AI grading
      description: |
        Send one or more student essays plus a rubric. Essays may be uploaded as files
        (image, PDF, DOCX, TXT) **or** sent inline as text strings (`essay_text[]`). Both can
        be mixed in the same job.

        **Quota**: 1 unit of essay quota per essay successfully graded. Essays that fail
        extraction or exceed `max_words_per_essay` are not charged.

        **Pre-flight rejections**:
        - `403` if `essay_marking_enabled=false` on the package and `extra_essays=0`
        - `402` if essay quota exhausted
        - `422` if too many essays in one job
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: '#/components/schemas/EssayJobCreateRequest' }
      responses:
        '201':
          description: Job processed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EssayJob' }
              examples:
                inlineTypedEssay:
                  summary: One typed essay (essay_text[])
                  value:
                    job_id: "019d7205-cbe9-71d5-b0c9-c974a62d7090"
                    mode: essay
                    status: completed
                    submissions_count: 1
                    graded_submissions: 1
                    essays_charged: 1
                    essay_quota_source: subscription
                    rubric:
                      name: "Argumentative essay rubric"
                      criteria:
                        - { key: thesis,   label: "Thesis",   max_score: 5 }
                        - { key: evidence, label: "Evidence", max_score: 10 }
                    submissions:
                      - id: "019d7205-cc93-72ff-a5a5-bd79aa4ac892"
                        student_index: 1
                        student_name: "Typed Demo"
                        status: completed
                        score: 12
                        max_score: 15
                        essay:
                          word_count: 16
                          overall_score: 12
                          max_overall_score: 15
                          overall_feedback: "Solid argument with minor gaps..."
                          highlights: []
                          extraction_confidence: 1
                          status: graded
                          reason_code: null
                          criteria:
                            - { key: thesis,   label: "Thesis",   score: 4, max_score: 5,  feedback: "Clear thesis but lacks specificity.", evidence_quotes: [] }
                            - { key: evidence, label: "Evidence", score: 8, max_score: 10, feedback: "Strong examples in paragraphs 2 and 4.", evidence_quotes: [] }
                          extracted_text_url: "https://aiquestions.intrazero.com/api/v1/grading/essays/.../text"
                    error: null
                multiEssayUpload:
                  summary: Two essays via essays[] file uploads
                  value:
                    job_id: "019d7205-d37c-72b9-9571-5c441dc5a12e"
                    mode: essay
                    status: completed
                    submissions_count: 2
                    graded_submissions: 2
                    essays_charged: 2
                    essay_quota_source: subscription
                    rubric: { name: "R", criteria: [{ key: thesis, label: "Thesis", max_score: 5 }, { key: evidence, label: "Evidence", max_score: 5 }] }
                    submissions:
                      - { id: "sub-1", student_index: 1, student_name: "Alice", status: completed, score: 8,  max_score: 10, essay: { word_count: 412, overall_score: 8,  max_overall_score: 10, overall_feedback: "...", criteria: [], status: graded } }
                      - { id: "sub-2", student_index: 2, student_name: "Bob",   status: completed, score: 9,  max_score: 10, essay: { word_count: 388, overall_score: 9,  max_overall_score: 10, overall_feedback: "...", criteria: [], status: graded } }
                tooLong:
                  summary: Essay exceeds max_words_per_essay (returns success but essay marked failed, not charged)
                  value:
                    job_id: "019d7206-..."
                    mode: essay
                    status: needs_review
                    submissions_count: 1
                    graded_submissions: 0
                    essays_charged: 0
                    essay_quota_source: null
                    submissions:
                      - { id: "sub-x", student_index: 1, student_name: "Long", status: failed, score: 0, max_score: 15, essay: { word_count: 9001, overall_score: 0, max_overall_score: 15, status: failed, reason_code: too_long, criteria: [] } }
                    error: null
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/QuotaExhausted' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/grading/essays/{id}:
    get:
      tags: [Essay Marking]
      summary: Get an essay job
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Job
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EssayJob' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Forbidden }
        '404': { description: Not found }

    delete:
      tags: [Essay Marking]
      summary: Delete an essay job
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: Deleted }

  /api/v1/grading/essays/{id}/submissions/{submissionId}:
    get:
      tags: [Essay Marking]
      summary: Per-essay detail with criteria scores
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: path
          name: submissionId
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Submission with full essay payload
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EssaySubmission' }

    patch:
      tags: [Essay Marking]
      summary: Manually override an essay (per-criterion scores or overall feedback)
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: path
          name: submissionId
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EssayOverrideRequest' }
            example:
              criteria:
                - { key: thesis, score: 5, feedback: "Adjusted up." }
              overall_feedback: "Final teacher comment."
      responses:
        '200':
          description: Updated submission
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EssaySubmission' }

  ##############################################################
  # 4. Bubble Sheet Marking
  ##############################################################
  /api/v1/grading/bubble-sheets:
    get:
      tags: [Bubble Sheet Marking]
      summary: List bubble sheet jobs (paginated)
      responses:
        '200': { description: Paginated list }
        '401': { $ref: '#/components/responses/Unauthorized' }

    post:
      tags: [Bubble Sheet Marking]
      summary: Submit bubble sheets for OMR grading
      description: |
        Upload one or more multichoice answer sheets (image or multi-page PDF stacking many students)
        plus an answer key. The platform detects the template, decodes the QR code for student identity,
        and grades each bubble against the key.

        **Quota**: 1 unit of bubble sheet quota per **sheet** processed (regardless of question count).

        **Pre-flight rejections**:
        - `403` if `bubble_sheet_marking_enabled=false` and no `extra_bubble_sheets`
        - `402` if quota exhausted
        - `422` if more sheets than `package.max_students_per_job`
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [student_sheets]
              properties:
                student_sheets:
                  type: array
                  items: { type: string, format: binary }
                  description: One or more image/PDF files. PDFs may stack many students (one per page).
                answer_key:
                  type: string
                  format: binary
                  description: Image/PDF — same template with correct bubbles filled. Required if `answer_key_json` is omitted.
                answer_key_json:
                  type: string
                  description: |
                    Pre-structured key as JSON string:
                    `[{"number":"1","answer":"B"},{"number":"2","answer":"A"}]`
                subject: { type: string }
                language: { type: string, example: en }
      responses:
        '201':
          description: Job processed
          content:
            application/json:
              example:
                job_id: "uuid"
                mode: bubble_sheet
                status: needs_review
                submissions_count: 21
                graded_submissions: 21
                bubble_sheets_charged: 21
                bubble_quota_source: subscription
                template:
                  questions: 30
                  options: 4
                  exam_label: "E10"
                  qr_payload: null
                submissions:
                  - id: "sub-uuid"
                    student_index: 1
                    student_name: "Test Student 1"
                    student_id: "22"
                    qr_payload: "E10-S22"
                    score: 18
                    max_score: 30
                    total_questions: 30
                    graded_questions: 30
                    status: completed
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/QuotaExhausted' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /api/v1/grading/bubble-sheets/{id}:
    get:
      tags: [Bubble Sheet Marking]
      summary: Get a bubble sheet job
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: Job }
    delete:
      tags: [Bubble Sheet Marking]
      summary: Delete a bubble sheet job
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200': { description: Deleted }

  /api/v1/grading/bubble-sheets/{id}/submissions/{submissionId}:
    get:
      tags: [Bubble Sheet Marking]
      summary: Per-student detail with full per-question breakdown
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: path
          name: submissionId
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Submission detail
          content:
            application/json:
              example:
                id: "sub-uuid"
                student_index: 1
                student_name: "Test Student 1"
                student_id: "22"
                qr_payload: "E10-S22"
                score: 18
                max_score: 30
                status: needs_review
                questions:
                  - { number: "1", expected_answer: "B", student_answer: "B", is_correct: true,  score: 1, max_score: 1, confidence: 0.99, status: graded, reason_code: null, crop_url: null }
                  - { number: "12", expected_answer: "C", student_answer: "multi", is_correct: false, score: 0, max_score: 1, confidence: 0.98, status: graded, reason_code: multi_mark, crop_url: null }
                  - { number: "28", expected_answer: "D", student_answer: null, is_correct: false, score: 0, max_score: 1, confidence: 0.95, status: graded, reason_code: blank, crop_url: null }
                  - { number: "29", expected_answer: "B", student_answer: "A", is_correct: false, score: 0, max_score: 1, confidence: 0.30, status: unreadable, reason_code: ambiguous, crop_url: "https://.../questions/29/crop" }

  /api/v1/grading/bubble-sheets/{id}/submissions/{submissionId}/questions/{questionNumber}/crop:
    get:
      tags: [Bubble Sheet Marking]
      summary: Crop PNG of an ambiguous bubble row
      parameters:
        - { in: path, name: id, required: true, schema: { type: string, format: uuid } }
        - { in: path, name: submissionId, required: true, schema: { type: string, format: uuid } }
        - { in: path, name: questionNumber, required: true, schema: { type: string } }
      responses:
        '200':
          description: PNG image
          content:
            image/png:
              schema: { type: string, format: binary }

  /api/v1/grading/bubble-sheets/{id}/submissions/{submissionId}/questions/{questionNumber}:
    patch:
      tags: [Bubble Sheet Marking]
      summary: Manually override an ambiguous question
      parameters:
        - { in: path, name: id, required: true, schema: { type: string, format: uuid } }
        - { in: path, name: submissionId, required: true, schema: { type: string, format: uuid } }
        - { in: path, name: questionNumber, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            example: { student_answer: "B", is_correct: true, score: 1 }
      responses:
        '200':
          description: Updated submission with recomputed score

  /api/v1/grading/essays/{id}/submissions/{submissionId}/text:
    get:
      tags: [Essay Marking]
      summary: Plain text of the OCR'd essay
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
        - in: path
          name: submissionId
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Plain text
          content:
            text/plain:
              schema: { type: string }
              example: |
                Social media should be regulated because misinformation spreads quickly.
                The thesis is supported by multiple studies showing...
