# AI Questions — Developer Integration Guide

This guide is for developers integrating the AI Questions platform into a third-party application (LMS, mobile app, internal tool, Moodle plugin, etc.). It covers all three services exposed by the platform:

1. **AI Question Generation** — turn documents into exam questions.
2. **Auto-Grading (Paper Exams)** — auto-correct scanned student answer sheets.
3. **Essay Marking** — score long-form student writing against a rubric.

> **Base URL:** `https://aiquestions.intrazero.com/api/v1`
> **Auth:** `Authorization: Bearer <entity_api_key>` on every request.
> **Rate limit:** 30 req/min per entity.

---

## 0. Glossary

| Term | Meaning |
|---|---|
| **Entity** | A tenant (school, publisher, LMS instance). Owns API keys, subscriptions, and all generated/graded data. |
| **Package** | A pricing tier. Defines included quotas for question generation, auto-grading, and essay marking — each tracked separately. |
| **Subscription** | An entity's active package with `starts_at`/`expires_at` and per-dimension `used_*` counters. |
| **Extra pools** | Per-entity standalone fallback quotas (`extra_questions`, `extra_graded_questions`, `extra_essays`) that never expire and are consumed after the subscription is exhausted. |
| **Quota source** | On every billable result, the system records `subscription` / `mixed` / `extra_*` so you can audit who paid for what. |

---

## 1. Authentication

Every request must include an entity API key as a Bearer token:

```http
GET /api/v1/quota HTTP/1.1
Host: aiquestions.intrazero.com
Authorization: Bearer 8c4f0e7a-1234-4abc-9def-0123456789ab
Accept: application/json
```

Get your API key from the admin panel → **Entities** → your row → "API Key" column. Keys are UUIDs and never rotate automatically.

Errors:
- `401` — missing or invalid API key
- `403` — entity inactive, URL not allowlisted, or feature not enabled on the package
- `402` — quota exhausted
- `422` — validation error
- `429` — rate limited

---

## 2. Service A — AI Question Generation

Turn an uploaded document into a list of exam questions.

### Endpoint
`POST /api/v1/generate` *(multipart/form-data)*

### Fields
| Field | Type | Required | Notes |
|---|---|---|---|
| `file` | file | yes | `pdf`, `docx`, `pptx`, `ppt`, `txt`, `md`. Max 50 MB. |
| `num_questions` | int | yes | 1–50 (or your package limit). |
| `question_type` | string | no | `multichoice` (default), `truefalse`, `shortanswer`, `essay`, `fillinblanks`, `matching`, `numerical`, `ordering`, `hotspot`, `labeling`, `matrix`, `calculated`, `recording`, `bowtie`, `interactive`. |
| `difficulty` | string | no | `easy`, `medium`, `hard`, `mixed` (default). |
| `topic` | string | no | Bias generation toward a topic. |
| `language` | string | no | `en`, `ar`, `fr`, ... (auto-detect by default). |

### Quota
Charged from the subscription's question pool: 1 quota unit per question generated. Falls back to `extra_questions` if exhausted.

### Example
```bash
curl -X POST https://aiquestions.intrazero.com/api/v1/generate \
  -H "Authorization: Bearer $API_KEY" \
  -F "file=@chapter5.pdf" \
  -F "num_questions=10" \
  -F "question_type=multichoice" \
  -F "difficulty=mixed"
```

### Response (200)
```json
{
  "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
  }
}
```

### Cross-request deduplication
For small batches (≤3 questions), the platform automatically deduplicates against the entity's last 5 completed requests on the same filename within the past 10 minutes. This prevents Moodle plugins that fetch one question at a time from receiving repeats.

### Quota check
`GET /api/v1/quota` → `{ "remaining": 487, "subscription": {...}, "extra_questions": 50 }`.

---

## 3. Service B — Auto-Grading (Paper Exams)

OCR scanned answer sheets, compare against an answer key, and return per-question results. Supports **multiple students per upload**.

### Endpoint
`POST /api/v1/grading/jobs` *(multipart/form-data)*

### Fields
| Field | Type | Required | Notes |
|---|---|---|---|
| `student_papers[]` | file[] | yes | Image (jpg/png/webp) or PDF. Multiple files allowed. |
| `answer_key` | file | one of | Image/PDF of the answer key. |
| `answer_key_json` | string (JSON) | one of | Pre-structured key: `[{"number":"1","answer":"...","max_score":1}]`. |
| `split_mode` | string | no | `one_per_file` (default), `fixed_pages`, or `auto` (vision-based segmentation when one PDF stacks many students). |
| `pages_per_student` | int | when `split_mode=fixed_pages` | |
| `subject` | string | no | Metadata. |
| `language` | string | no | `en`, `ar`, ... |
| `rubric` | string | no | `exact_match`, `semantic` (default), `partial_credit`. |

### Pre-flight rejections
- `403` — package does not have `grading_enabled` and no `extra_graded_questions` available.
- `402` — grading quota exhausted.
- `422` — `student_papers` count exceeds `package.max_students_per_job`.

### Quota
1 unit of **grading quota** per question that was successfully read & graded. Questions that come back as `unreadable` (low OCR confidence or blank) are **not charged**.

### Response (201)
```json
{
  "job_id": "uuid",
  "status": "needs_review",
  "submissions_count": 2,
  "graded_submissions": 2,
  "submissions": [
    { "id": "sub-uuid", "student_index": 1, "student_name": "Alice",
      "student_id": "S-1001", "score": 18, "max_score": 20,
      "total_questions": 20, "graded_questions": 19, "status": "needs_review" }
  ]
}
```

`status` is `completed` when every question on every student was readable, otherwise `needs_review`.

### Reading results
```bash
# Job summary
curl -H "Authorization: Bearer $API_KEY" \
  https://aiquestions.intrazero.com/api/v1/grading/jobs/{job_id}

# One student's full per-question breakdown
curl -H "Authorization: Bearer $API_KEY" \
  https://.../grading/jobs/{job_id}/submissions/{submission_id}
```

Each question carries:
```json
{
  "number": "7", "expected_answer": "H2O", "student_answer": null,
  "score": 0, "max_score": 1, "confidence": 0.21,
  "status": "unreadable", "reason_code": "low_confidence_ocr",
  "feedback": null,
  "analysis": null,
  "crop_url": "https://.../questions/7/crop"
}
```

`status` ∈ `graded`, `unreadable`, `manual_override`.
`reason_code` for unreadable: `low_confidence_ocr`, `blank`, `missing`.

#### `analysis` — structured per-question insight (since v1.3.0)

When `status = graded`, each question also includes an `analysis` JSON object
describing **why** the student got the score they did. Free-text values are
returned in the exam language.

```json
{
  "number": "3",
  "expected_answer": "9",
  "student_answer": "3",
  "is_correct": false,
  "score": 0,
  "max_score": 20,
  "feedback": "Student wrote 3 instead of 9.",
  "analysis": {
    "correctness": "incorrect",
    "key_concepts_covered": [],
    "key_concepts_missing": ["correct numerical answer"],
    "errors": [
      { "type": "factual", "severity": "high", "description": "Student answered 3; expected 9." }
    ],
    "strengths": [],
    "improvement_suggestions": ["Review the source material before answering."],
    "bloom_level": "recall",
    "confidence": 0.95,
    "handwriting": {
      "legibility": "clear",
      "neatness_score": 7,
      "language": "english",
      "script": "latin",
      "crossed_out": false,
      "corrections_visible": false,
      "writing_quality_notes": ["clear, single-digit answer"]
    }
  }
}
```

**Universal keys** (also returned for essay grading):

| Key | Type | Description |
|-----|------|-------------|
| `correctness` | enum | `fully_correct` / `partially_correct` / `incorrect` / `blank` |
| `key_concepts_covered` | string[] | Concepts the student got right |
| `key_concepts_missing` | string[] | Important concepts they missed |
| `errors[]` | object[] | Each: `{type, description, severity}` |
| `strengths` | string[] | What they did well |
| `improvement_suggestions` | string[] | Concrete, question-specific tips |
| `bloom_level` | enum | `recall` / `understanding` / `application` / `analysis` / `evaluation` / `creation` |
| `confidence` | float 0..1 | AI's certainty in the analysis |

**Paper-exam-only**: `handwriting` sub-object with `legibility`, `neatness_score`, `language`, `script`, `crossed_out`, `corrections_visible`, `writing_quality_notes[]`. When the student selected a printed option (circled MCQ / checked True/False box) instead of writing, `legibility` is `not_handwritten` — don't show "messy handwriting" warnings in this case.

**Essay-only extras** (in essay-grading responses): `language_quality{grammar,clarity,structure,vocabulary}` (each 0..10), `argument_structure`, `evidence_use`, `originality`.

**Notes for integrators:**
- `analysis` is **additive** — all existing fields remain unchanged.
- `analysis` may be `null` (rare; AI returned malformed JSON). Always null-check.
- Empty arrays (`[]`) are normal — they mean "nothing to report", not an error.
- Don't hard-code the list of error `type` values; treat unknown values as generic.
- Bubble-sheet responses do **not** include `analysis` (nothing to analyze for letter selection).

### Handling unreadable questions
Fetch the cropped image for manual review:
```http
GET /api/v1/grading/jobs/{id}/submissions/{sid}/questions/{n}/crop
→ image/png  (just the student's answer region, padded 5%)
```
Display it to the teacher, get the correct reading, then submit a manual override:
```http
PATCH /api/v1/grading/jobs/{id}/submissions/{sid}/questions/{n}
{
  "student_answer": "H2O",
  "is_correct": true,
  "score": 1
}
```
The submission's total score is recomputed automatically. The question's status becomes `manual_override`. Quota is **not** refunded (the AI work was done).

### Listing jobs
`GET /api/v1/grading/jobs?page=1` — paginated, entity-scoped.

### Deletion
`DELETE /api/v1/grading/jobs/{id}` — removes the job and all associated artifacts.

---

## 4. Service C — Essay Marking

Score long-form student writing against a rubric. Supports typed text or scanned/handwritten essays.

> ⚠ Essay marking is a **separately-priced feature**. Your package must have `essay_marking_enabled=true` and `essays_included>0` (or the entity must have `extra_essays>0`).

### Endpoint
`POST /api/v1/grading/essays` *(multipart/form-data)*

### Fields
| Field | Type | Required | Notes |
|---|---|---|---|
| `essays[]` | file[] | one of | Image/PDF (handwritten or scanned), or `.txt`/`.docx` for typed essays. One file = one essay. |
| `essay_text[]` | string[] | one of | Inline typed essays. Index-aligned with `student_names[]`. |
| `student_names[]` | string[] | no | Display names. |
| `rubric_json` | string (JSON) | one of | Canonical rubric (see schema below). |
| `rubric_file` | file | one of | Free-form rubric document; the AI normalizes it. |
| `model_answer` | string | one of | Exemplar essay; the AI derives criteria from it. |
| `essay_prompt` | string | no | The writing prompt the students were responding to. |
| `language` | string | no | `en`, `ar`, ... |
| `feedback_depth` | string | no | `brief` (default), `detailed`, `revision_suggestions`. |

### Canonical rubric schema
```json
{
  "name": "Argumentative essay rubric",
  "criteria": [
    { "key": "thesis", "label": "Thesis", "max_score": 5,
      "description": "A clear, arguable thesis appears in the introduction." },
    { "key": "evidence", "label": "Evidence", "max_score": 10 },
    { "key": "organization", "label": "Organization", "max_score": 5 },
    { "key": "mechanics", "label": "Mechanics", "max_score": 5 }
  ]
}
```

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

### Pre-flight rejections
- `403` — essay marking not enabled on this package.
- `402` — essay quota exhausted.
- `422` — too many essays in one job, or essay too long.

### Example
```bash
curl -X POST https://aiquestions.intrazero.com/api/v1/grading/essays \
  -H "Authorization: Bearer $API_KEY" \
  -F "essays[]=@alice.pdf" \
  -F "essays[]=@bob.pdf" \
  -F 'rubric_json={"criteria":[{"key":"thesis","label":"Thesis","max_score":5},{"key":"evidence","label":"Evidence","max_score":10}]}' \
  -F "essay_prompt=Should social media be regulated?"
```

### Response (201)
```json
{
  "job_id": "uuid",
  "mode": "essay",
  "status": "completed",
  "submissions_count": 2,
  "essays_charged": 2,
  "submissions": [
    {
      "id": "sub-uuid",
      "student_name": "Alice",
      "essay": {
        "word_count": 487,
        "overall_score": 13,
        "max_overall_score": 15,
        "criteria": [
          { "key": "thesis", "label": "Thesis", "score": 4, "max_score": 5,
            "feedback": "Clear thesis but lacks specificity." },
          { "key": "evidence", "label": "Evidence", "score": 9, "max_score": 10,
            "feedback": "Strong examples in paragraphs 2 and 4." }
        ],
        "overall_feedback": "Solid argument with minor gaps...",
        "highlights": [
          { "start": 412, "end": 458, "kind": "unsupported_claim",
            "note": "This claim needs a citation." }
        ],
        "status": "graded"
      }
    }
  ]
}
```

### Manual override
```http
PATCH /api/v1/grading/essays/{id}/submissions/{submissionId}
{
  "criteria": [{ "key": "thesis", "score": 5, "feedback": "Adjusted up." }],
  "overall_feedback": "Final teacher comment."
}
```
Recomputes the overall score. Sets essay status to `manual_override`. Quota is not refunded.

### Reading the OCR'd text
`GET /api/v1/grading/essays/{id}/submissions/{submissionId}/text` → plaintext.

---

---

## 4b. Service D — Bubble Sheet (OMR) Marking

OCR-grade scanned multichoice answer sheets. Highly accurate, deterministic, and **billed per sheet** (not per question).

> ⚠ Bubble sheet marking is a **separately-priced add-on**. Your package must have `bubble_sheet_marking_enabled=true` and `bubble_sheets_included>0` (or the entity must have `extra_bubble_sheets>0`).

### Endpoint
`POST /api/v1/grading/bubble-sheets` *(multipart/form-data)*

### Fields
| Field | Type | Required | Notes |
|---|---|---|---|
| `student_sheets[]` | file[] | yes | Image or PDF. A single multi-page PDF stacking many students (one per page) is supported and recommended. |
| `answer_key` | file | one of | Image/PDF — the same template with the correct bubbles filled. |
| `answer_key_json` | string | one of | `[{"number":"1","answer":"B"},...]` |
| `subject` | string | no | Metadata |
| `language` | string | no | `en`, `ar`, ... |

### Quota
**1 unit per sheet processed**, regardless of question count. Failed sheets are not charged.

### Pre-flight rejections
- `403` — bubble sheet marking not enabled on this package.
- `402` — bubble sheet quota exhausted.
- `422` — too many sheets in one job (`max_students_per_job`).

### Example
```bash
curl -X POST https://aiquestions.intrazero.com/api/v1/grading/bubble-sheets \
  -H "Authorization: Bearer $API_KEY" \
  -H "Accept: application/json" \
  -F "student_sheets[]=@CS201_midterm_stack.pdf" \
  -F 'answer_key_json=[{"number":"1","answer":"B"},{"number":"2","answer":"A"}]' \
  -F "subject=CS201 Midterm"
```

### Response (201)
```json
{
  "job_id": "uuid",
  "mode": "bubble_sheet",
  "status": "needs_review",
  "submissions_count": 21,
  "bubble_sheets_charged": 21,
  "bubble_quota_source": "subscription",
  "template": { "questions": 30, "options": 4, "exam_label": "E10" },
  "submissions": [
    {
      "student_index": 1,
      "student_name": "Test Student 1",
      "student_id": "22",
      "qr_payload": "E10-S22",
      "score": 18, "max_score": 30,
      "status": "completed"
    }
  ]
}
```

### Per-question reason codes
- `blank` — no bubble filled (graded as 0, no review needed)
- `multi_mark` — more than one bubble filled (graded as 0, no review needed)
- `ambiguous` — confidence below threshold; **cropped PNG returned** at `crop_url` for manual review
- `missing` — question expected by the key but not detected

### Manual override
Same shape as auto-grading:
```http
PATCH /api/v1/grading/bubble-sheets/{id}/submissions/{sid}/questions/{n}
{ "student_answer": "B", "is_correct": true, "score": 1 }
```

### Crop for ambiguous bubble
`GET /api/v1/grading/bubble-sheets/{id}/submissions/{sid}/questions/{n}/crop` → PNG

### QR code identity
If the bubble sheet template includes a QR code encoding `E{exam_id}-S{student_id}` (e.g. `E10-S42`), the platform decodes it and populates `qr_payload`, `student_id`. The printed name field is the fallback.

---

## 5. Quotas & Packages

The platform tracks **three independent quota dimensions** per entity:

| Dimension | Subscription column | Extra pool column | Charged by |
|---|---|---|---|
| Question generation | `subscriptions.used_questions` | `entities.extra_questions` | each generated question |
| Auto-grading | `subscriptions.used_graded_questions` | `entities.extra_graded_questions` | each graded question (not unreadable) |
| Essay marking | `subscriptions.used_essays` | `entities.extra_essays` | each successfully graded essay |
| Bubble sheet marking | `subscriptions.used_bubble_sheets` | `entities.extra_bubble_sheets` | each processed sheet (per student) |

The package controls availability:
- `total_questions` — generation quota per period
- `grading_enabled` + `graded_questions_included` — auto-grading
- `essay_marking_enabled` + `essays_included` — essay marking
- `bubble_sheet_marking_enabled` + `bubble_sheets_included` — bubble sheet OMR
- `max_students_per_job`, `max_pages_per_job`, `max_words_per_essay`, `max_questions_per_sheet` — per-job ceilings

When a result is recorded, the platform stores **which subscription paid for it** and **what kind of pool** (`subscription`, `mixed`, `extra_*`) so finance can audit usage.

### Quota check endpoint
`GET /api/v1/quota`
```json
{
  "questions_remaining": 487,
  "grading_remaining": 192,
  "essays_remaining": 18,
  "subscription": { "id": 10, "package": "Pro", "expires_at": "2027-04-09" }
}
```

---

## 6. Webhooks (planned, phase 2)

For long-running jobs (large auto-grading uploads, essay batches), the platform will optionally POST a signed payload to `webhook_url` when the job completes. Until webhooks ship, **poll** the `GET /jobs/{id}` endpoint at 2–5 second intervals.

---

## 7. Error Handling

All error responses share a stable shape with a machine-readable `error_code` you can switch on without parsing free-text messages:

```json
{
  "success": false,
  "error_code": "quota_exhausted",
  "message": "Auto-grading quota is exhausted.",
  "details": {
    "dimension": "grading",
    "remaining": 0,
    "requested": 5
  }
}
```

### Error code reference

| `error_code` | HTTP | Meaning | Recommended client behavior |
|---|---|---|---|
| `unauthenticated` | 401 | Missing or invalid API key | Surface "check your API key" |
| `entity_inactive` | 403 | The entity has been deactivated by an admin | Show "contact administrator" |
| `no_subscription` | 403 | The entity has no active subscription | Show "purchase a plan" |
| `subscription_expired` | 403 | The subscription's `expires_at` has passed | Show "renew subscription" |
| `subscription_not_started` | 403 | The subscription's `starts_at` is in the future | Show "available on {date}" |
| `package_inactive` | 403 | The subscription's package was deactivated | Contact support |
| `feature_not_enabled` | 403 | Package doesn't include this feature; no extras pool | Show "upgrade plan to unlock {feature}" |
| `forbidden` | 403 | Tried to access another entity's resource | Drop the request |
| `quota_exhausted` | 402 | Quota for this dimension is 0 | Show "buy more {dimension}" |
| `quota_insufficient_for_batch` | 402 | Has some quota but not enough for this batch | Tell user how many items fit |
| `job_limit_exceeded` | 422 | Per-job ceiling exceeded (e.g. `max_students_per_job`) | Reduce batch size |
| `validation_error` | 422 | Generic validation failure (file too large, missing field, malformed JSON) | Surface `message` |
| `processing_failed` | 422 | The job couldn't be processed (e.g. no answer key after parsing) | Surface `message` |
| `not_found` | 404 | The job or submission doesn't exist | Drop / refresh list |
| `rate_limited` | 429 | Hit the 30 req/min ceiling | Backoff respecting `Retry-After` |
| `internal_error` | 500 | Unexpected server-side failure | Retry with backoff; alert user |

### `details` field by code

| `error_code` | `details` keys |
|---|---|
| `no_subscription`, `subscription_expired`, `subscription_not_started`, `package_inactive` | `subscription_state` |
| `feature_not_enabled` | `feature` (one of `grading`, `essays`, `bubble_sheets`) |
| `quota_exhausted`, `quota_insufficient_for_batch` | `dimension`, `remaining`, `requested` |
| `job_limit_exceeded` | `limit_field`, `limit`, `received` |

### Backwards compatibility

The legacy `{ "success": false, "message": "..." }` shape is still present — `error_code` and `details` are **additive**. Existing clients that only read `message` continue to work.

Common patterns:
- Always check `response.status` before parsing.
- On `429`, back off and retry; respect `Retry-After` if present.
- On `402`, surface a "purchase more quota" CTA — do not silently retry.
- On `403` for a feature gate, surface "feature not enabled on your plan" — do not retry.
- On `422`, the `message` is safe to display to end users.

---

## 8. Best Practices

- **Cache the API key** in your backend, never expose it to a browser.
- **Server-side proxy**: front-end → your backend → AI Questions API. This keeps the key safe and lets you enforce per-user quotas downstream.
- **Idempotency**: question generation and grading are not idempotent — submitting the same payload twice charges twice. If your client may retry on network errors, store a local request id and verify with `GET /status/{id}` (generation) or `GET /jobs/{id}` (grading) before retrying.
- **File hygiene**: strip EXIF and downscale very large scans to ≤2000px on the long edge before upload. This cuts latency and OCR cost.
- **Multi-student uploads**: prefer `split_mode=auto` only when the PDF really has variable students per paper; `one_per_file` is faster and cheaper.
- **Essay prompts**: always pass `essay_prompt` when you have it — grading quality drops noticeably without context.
- **Manual override UX**: render the `crop_url` image directly next to the text input so a teacher can correct unreadable answers in <5 seconds.
- **Show quota to admins**: hit `GET /api/v1/quota` when an admin opens settings so they see all three dimensions and can avoid surprises.

---

## 9. Changelog
| Version | Date | Notes |
|---|---|---|
| 1.0 | 2026-04 | Initial release: question generation. |
| 1.1 | 2026-04 | Auto-grading (paper exams) + per-package grading quota. |
| 1.2 | 2026-04 | Essay marking + per-package essay quota. |
| 1.3 | 2026-04 | Bubble sheet (OMR) marking + per-package bubble quota. |

---

## 10. Support

For integration questions, contact the AI Questions team via the contact form on the homepage or email `support@aiquestions.intrazero.com`.
