rtm-class-ai / README.md
nbintang
fix: logging
383b8a3
|
Raw
History Blame Contribute Delete
16.7 kB
metadata
title: Rtm Class AI
emoji: 🌍
colorFrom: blue
colorTo: gray
sdk: docker
app_port: 7860
pinned: false

RTM-Class-AI

Standalone FastAPI microservice for async material generation and LKPD generation from uploaded files.

What this service does

  • Accepts file uploads for:
    • POST /api/mcq (generate MCQ only)
    • POST /api/essay (generate Essay only)
    • POST /api/summary (generate Summary only)
    • POST /api/material (generate mcq, essay, summary)
    • POST /api/lkpd (generate LKPD + downloadable PDF)
  • Immediately returns 202 Accepted with job_id
  • Processes jobs in background worker (Redis queue)
  • Delivers final result to callback_url via HTTP POST

Current architecture flow

  1. Client submits multipart form request.
  2. API validates request and stores job in Redis.
  3. Worker pulls queue, extracts text (.pdf, .pptx, .txt), indexes RAG, calls model.
  4. Worker sends callback payload (retry with backoff if callback fails).
  5. LKPD flow also renders a PDF and exposes a temporary download URL.

API module structure

  • src/main.py initializes app lifespan/middleware and registers routers.
  • src/api/material_routes.py handles /api/material, /api/mcq, /api/essay, /api/summary.
  • src/api/lkpd_routes.py handles /api/lkpd and /api/lkpd/files/{file_id}.
  • src/api/oauth_routes.py handles /api/oauth/token.
  • src/api/job_submission.py centralizes shared request validation and enqueue flow.
  • src/api/schemas.py centralizes API response DTOs.

Tech stack

  • Python 3.11
  • FastAPI + Uvicorn
  • Redis (async queue + job metadata)
  • LangChain + LangGraph
  • Groq (langchain-groq)
  • Chroma (langchain-chroma)
  • ReportLab (LKPD PDF rendering)

Requirements

  • Python >=3.11
  • Redis server
  • GROQ_API_KEY set in .env

Local setup

python -m venv .venv
# Windows
.venv\Scripts\activate
# Linux/macOS
source .venv/bin/activate

pip install -e .
cp .env.example .env

Run locally

uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload

Alternative launcher:

python cmd/run.py

Run with Docker Compose

  1. Fill .env (minimum: GROQ_API_KEY).
  2. Start services:
docker compose up --build

Service endpoints:

  • API: http://localhost:7860
  • Redis: localhost:6379

Stop:

docker compose down

Task shortcuts (taskipy):

uv run task up
uv run task upd
uv run task down
uv run task logs
uv run task ps

HTTP API

All API endpoints in this section are intended for service-to-service usage. Clients do not generate JWT locally. Use OAuth client credentials to get an access token first.

POST /api/oauth/token

Request body must be application/x-www-form-urlencoded:

  • grant_type=client_credentials (required)
  • client_id (required)
  • client_secret (required)
  • scope (optional, space-separated; defaults to OAUTH_DEFAULT_SCOPES)

This endpoint is public and rate-limited per IP and per client_id.

Success response (200):

{
  "success": true,
  "data": {
    "access_token": "<JWT>",
    "token_type": "Bearer",
    "expires_in": 300,
    "scope": "material:write lkpd:write lkpd:read"
  },
  "message": "Access token issued.",
  "meta": {
    "request_id": "req-..."
  }
}

The token is signed by server-side JWT_SECRET and includes:

  • iss, aud, sub=client:<client_id>, iat, exp, scope, jti

Error response format follows the same global API envelope (success=false, error, meta), for example:

{
  "success": false,
  "error": {
    "code": "invalid_request",
    "message": "grant_type, client_id, and client_secret are required.",
    "details": {
      "error": "invalid_request",
      "error_description": "grant_type, client_id, and client_secret are required."
    }
  },
  "meta": {
    "request_id": "req-..."
  }
}

POST /api/mcq

Multipart form fields:

  • user_id (required, non-empty)
  • file (required, one file: .pdf, .pptx, .txt)
  • callback_url (required, valid http/https)
  • mcq_count (optional, default 10, range 1..20)
  • mcp_enabled (optional, default true)

Response (202):

{
  "success": true,
  "data": {
    "job_id": "job-...",
    "status": "accepted"
  },
  "message": "MCQ queued for async processing.",
  "meta": {
    "request_id": "req-..."
  }
}

POST /api/essay

Multipart form fields:

  • user_id (required, non-empty)
  • file (required, one file: .pdf, .pptx, .txt)
  • callback_url (required, valid http/https)
  • essay_count (optional, default 3, range 1..10)
  • mcp_enabled (optional, default true)

Response (202):

{
  "success": true,
  "data": {
    "job_id": "job-...",
    "status": "accepted"
  },
  "message": "Essay queued for async processing.",
  "meta": {
    "request_id": "req-..."
  }
}

POST /api/summary

Multipart form fields:

  • user_id (required, non-empty)
  • file (required, one file: .pdf, .pptx, .txt)
  • callback_url (required, valid http/https)
  • summary_max_words (optional, default 200, range 80..400)
  • mcp_enabled (optional, default true)

Response (202):

{
  "success": true,
  "data": {
    "job_id": "job-...",
    "status": "accepted"
  },
  "message": "Summary queued for async processing.",
  "meta": {
    "request_id": "req-..."
  }
}

POST /api/material (legacy, still supported)

Multipart form fields:

  • user_id (required, non-empty)
  • file (required, one file: .pdf, .pptx, .txt)
  • callback_url (required, valid http/https)
  • generate_types (required, repeatable: mcq, essay, summary; unique; min 1)
  • mcq_count (optional, default 10, range 1..20)
  • essay_count (optional, default 3, range 1..10)
  • summary_max_words (optional, default 200, range 80..400)
  • mcp_enabled (optional, default true)

Response (202):

{
  "success": true,
  "data": {
    "job_id": "job-...",
    "status": "accepted"
  },
  "message": "Material queued for async processing.",
  "meta": {
    "request_id": "req-..."
  }
}

POST /api/lkpd

Multipart form fields:

  • user_id (required, non-empty)
  • file (required, one file: .pdf, .pptx, .txt)
  • callback_url (required, valid http/https)
  • activity_count (optional, default 5, constrained by env min/max)

Response (202):

{
  "success": true,
  "data": {
    "job_id": "job-...",
    "status": "accepted"
  },
  "message": "LKPD queued for async processing.",
  "meta": {
    "request_id": "req-..."
  }
}

GET /api/lkpd/files/{file_id}

  • Returns generated LKPD PDF (application/pdf)
  • Returns 404 if file is missing or expired

API Response Format

All non-callback HTTP JSON responses use this envelope.

Success format:

{
  "success": true,
  "data": {},
  "message": "Optional message",
  "meta": {
    "request_id": "req-..."
  }
}

Error format:

{
  "success": false,
  "error": {
    "code": "machine_readable_code",
    "message": "Human readable message",
    "details": null
  },
  "meta": {
    "request_id": "req-..."
  }
}

Common error codes:

  • invalid_request (400)
  • unauthorized (401)
  • forbidden (403)
  • not_found (404)
  • payload_too_large (413)
  • too_many_requests (429)
  • validation_error (422)
  • internal_error (500)
  • service_unavailable (503)

Callback contract

Delivery behavior

  • Callback target: your submitted callback_url
  • Method: POST JSON
  • Attempts: 1 + WEBHOOK_CALLBACK_MAX_RETRIES
    • Default = 4 total attempts (1 initial + 3 retries)
  • Backoff: WEBHOOK_CALLBACK_BACKOFF_SECONDS (default 5,15,45) with small jitter

Material event: material.generated

status values in callback payload:

  • succeeded
  • failed_processing

Success example:

{
  "event": "material.generated",
  "job_id": "job-...",
  "status": "succeeded",
  "user_id": "user-1",
  "result": {
    "user_id": "user-1",
    "document_id": "doc-...",
    "material": {
      "filename": "materi.pdf",
      "file_type": "pdf",
      "extracted_chars": 12345
    },
    "mcq_quiz": { "questions": [] },
    "essay_quiz": { "questions": [] },
    "summary": {
      "title": "...",
      "overview": "...",
      "key_points": []
    },
    "sources": [],
    "tool_calls": [],
    "warnings": []
  },
  "attempt": 1,
  "finished_at": "2026-03-02T00:00:00Z"
}

Failed processing example:

{
  "event": "material.generated",
  "job_id": "job-...",
  "status": "failed_processing",
  "user_id": "user-1",
  "error": {
    "code": "material_validation_error",
    "message": "Unsupported file type. Allowed extensions: .pdf, .pptx, .txt"
  },
  "attempt": 1,
  "finished_at": "2026-03-02T00:00:00Z"
}

LKPD event: lkpd.generated

status values in callback payload:

  • succeeded
  • failed_processing

Success example:

{
  "event": "lkpd.generated",
  "job_id": "job-...",
  "status": "succeeded",
  "user_id": "user-1",
  "result": {
    "document_id": "doc-...",
    "material": {
      "filename": "materi.pdf",
      "file_type": "pdf",
      "extracted_chars": 12345
    },
    "lkpd": {
      "title": "...",
      "learning_objectives": ["..."],
      "instructions": ["..."],
      "activities": [
        {
          "activity_no": 1,
          "task": "...",
          "expected_output": "...",
          "assessment_hint": "..."
        }
      ],
      "worksheet_template": "...",
      "assessment_rubric": [
        {
          "aspect": "...",
          "criteria": "...",
          "score_range": "1-4"
        }
      ]
    },
    "pdf_url": "http://localhost:7860/api/lkpd/files/lkpd-...",
    "pdf_expires_at": "2026-03-03T00:00:00Z",
    "sources": [],
    "warnings": []
  },
  "attempt": 1,
  "finished_at": "2026-03-02T00:00:00Z"
}

Failed processing example:

{
  "event": "lkpd.generated",
  "job_id": "job-...",
  "status": "failed_processing",
  "user_id": "user-1",
  "error": {
    "code": "lkpd_validation_error",
    "message": "Model failed to produce valid LKPD JSON output after one retry."
  },
  "attempt": 1,
  "finished_at": "2026-03-02T00:00:00Z"
}

Internal job statuses (Redis record)

  • accepted
  • processing
  • succeeded
  • failed_processing
  • failed_delivery

Example curl

Get access token:

TOKEN=$(curl -s -X POST http://localhost:7860/api/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=$OAUTH_CLIENT_ID" \
  -d "client_secret=$OAUTH_CLIENT_SECRET" \
  -d "scope=material:write lkpd:write lkpd:read" | python -c "import sys,json; print(json.load(sys.stdin)['data']['access_token'])")

Submit MCQ job:

curl -X POST http://localhost:7860/api/mcq \
  -H "Authorization: Bearer $TOKEN" \
  -F "user_id=user-1" \
  -F "callback_url=https://example.com/hooks/mcq" \
  -F "mcq_count=10" \
  -F "mcp_enabled=true" \
  -F "file=@./materi.pdf"

Submit Essay job:

curl -X POST http://localhost:7860/api/essay \
  -H "Authorization: Bearer $TOKEN" \
  -F "user_id=user-1" \
  -F "callback_url=https://example.com/hooks/essay" \
  -F "essay_count=3" \
  -F "mcp_enabled=true" \
  -F "file=@./materi.pdf"

Submit Summary job:

curl -X POST http://localhost:7860/api/summary \
  -H "Authorization: Bearer $TOKEN" \
  -F "user_id=user-1" \
  -F "callback_url=https://example.com/hooks/summary" \
  -F "summary_max_words=200" \
  -F "mcp_enabled=true" \
  -F "file=@./materi.pdf"

Submit legacy material job (multiple generate types in one request):

curl -X POST http://localhost:7860/api/material \
  -H "Authorization: Bearer $TOKEN" \
  -F "user_id=user-1" \
  -F "callback_url=https://example.com/hooks/material" \
  -F "generate_types=mcq" \
  -F "generate_types=essay" \
  -F "generate_types=summary" \
  -F "mcq_count=10" \
  -F "essay_count=3" \
  -F "summary_max_words=200" \
  -F "mcp_enabled=true" \
  -F "file=@./materi.pdf"

Submit LKPD job:

curl -X POST http://localhost:7860/api/lkpd \
  -H "Authorization: Bearer $TOKEN" \
  -F "user_id=user-1" \
  -F "callback_url=https://example.com/hooks/lkpd" \
  -F "activity_count=5" \
  -F "file=@./materi.pdf"

Download LKPD PDF:

curl -L "http://localhost:7860/api/lkpd/files/lkpd-xxxxxxxx" \
  -H "Authorization: Bearer $TOKEN" \
  -o lkpd.pdf

LKPD PDF output behavior

  • PDF generated with branded header on every page
  • Optional logo from LKPD_HEADER_LOGO_PATH
  • Header title lines configurable (LINE1, LINE2, LINE3)
  • First page includes:
    • Document ID
    • Source file info
    • Student identity block (Nama, NIS, Kelas, Tanggal)
  • Stored in LKPD_PDF_DIR and expired using LKPD_PDF_TTL_SECONDS

RAG isolation behavior

  • Each upload gets a new document_id
  • Retrieval filter is strict by user_id + document_id
  • New uploads are not mixed with previous upload contexts by default

MCP behavior

  • Controlled by request field mcp_enabled
  • Server config from MCP_SERVERS_JSON
  • Only transport="streamable_http" configs are accepted
  • If configured but tools unavailable, processing still continues with warnings

Environment variables

  • CHROMA_PERSIST_DIR=.chroma
  • GROQ_API_KEY=
  • GROQ_MODEL=llama-3.1-8b-instant
  • GROQ_TEMPERATURE=0.2
  • GROQ_TIMEOUT_SECONDS=30
  • MCP_SERVERS_JSON={}
  • AGENT_MAX_ITERATIONS=5
  • AGENT_MEMORY_COLLECTION=agent_memory
  • RAG_COLLECTION_NAME=material_chunks
  • RAG_CHUNK_SIZE=1000
  • RAG_CHUNK_OVERLAP=150
  • RAG_TOP_K=8
  • RAG_FETCH_K=24
  • RAG_MMR_LAMBDA=0.5
  • MATERIAL_MAX_FILE_MB=15
  • DEFAULT_MCQ_COUNT=10
  • DEFAULT_ESSAY_COUNT=3
  • DEFAULT_SUMMARY_MAX_WORDS=200
  • REDIS_URL=redis://localhost:6379/0
  • WEBHOOK_CALLBACK_TIMEOUT_SECONDS=10
  • WEBHOOK_CALLBACK_MAX_RETRIES=3
  • WEBHOOK_CALLBACK_BACKOFF_SECONDS=5,15,45
  • JOB_TTL_SECONDS=86400
  • JOB_QUEUE_KEY=material_jobs:queue
  • LKPD_DEFAULT_ACTIVITY_COUNT=5
  • LKPD_MIN_ACTIVITY_COUNT=1
  • LKPD_MAX_ACTIVITY_COUNT=15
  • LKPD_JOB_QUEUE_KEY=lkpd_jobs:queue
  • LKPD_PDF_DIR=.generated/lkpd
  • LKPD_PDF_TTL_SECONDS=86400
  • LKPD_HEADER_LOGO_PATH=.assets/lkpd/logo.png
  • LKPD_HEADER_ACCENT_HEX=#1F4E79
  • LKPD_HEADER_TITLE_LINE1=LEMBAR KERJA PESERTA DIDIK (LKPD)
  • LKPD_HEADER_TITLE_LINE2=SMARTER AI
  • LKPD_HEADER_TITLE_LINE3=
  • APP_PUBLIC_BASE_URL=http://localhost:7860
  • APP_LOG_LEVEL=INFO (fallback: LOG_LEVEL)
  • APP_LOG_STREAM=stderr (stderr recommended for hosted runtime logs)
  • JWT_ENABLED=true|false (default: true in APP_ENV=production, otherwise false)
  • JWT_SECRET= (required when JWT_ENABLED=true or OAUTH_ENABLED=true, minimum 32 chars)
  • JWT_ISSUER=my-backend
  • JWT_AUDIENCE=rtm-class-ai
  • JWT_CLOCK_SKEW_SECONDS=30
  • JWT_REQUIRED_SCOPES={"/api/material":"material:write","/api/mcq":"material:write","/api/essay":"material:write","/api/summary":"material:write","/api/lkpd":"lkpd:write","/api/lkpd/files/{file_id}":"lkpd:read"}
  • JWT_DENYLIST_ENABLED=true|false (default: true)
  • JWT_DENYLIST_PREFIX=auth:denylist:jti:
  • OAUTH_ENABLED=true|false (default: true in APP_ENV=production, otherwise false)
  • OAUTH_CLIENT_ID=rtm-client
  • OAUTH_CLIENT_SECRET=... (required when OAUTH_ENABLED=true)
  • OAUTH_ALLOWED_SCOPES=material:write lkpd:write lkpd:read
  • OAUTH_DEFAULT_SCOPES=material:write lkpd:write lkpd:read
  • OAUTH_TOKEN_TTL_SECONDS=300
  • OAUTH_TOKEN_RATE_LIMIT_WINDOW_SECONDS=60
  • OAUTH_TOKEN_RATE_LIMIT_PER_IP=30
  • OAUTH_TOKEN_RATE_LIMIT_PER_CLIENT=30

Authentication (JWT)

  • Clients authenticate to POST /api/oauth/token using client_id + client_secret.
  • JWT_SECRET is server-only and must never be distributed to clients.
  • API header format remains Authorization: Bearer <access_token>.
  • JWT algorithm: HS256.
  • Required JWT claims validated on /api/* routes:
    • iss must match JWT_ISSUER
    • aud must match JWT_AUDIENCE
    • sub must start with client:
    • iat and exp
  • scope claim remains space-separated and enforced per endpoint.
  • jti is issued on each token and checked against Redis denylist when enabled.
  • Endpoint scopes:
    • /api/mcq requires material:write
    • /api/essay requires material:write
    • /api/summary requires material:write
    • /api/material requires material:write
    • /api/lkpd requires lkpd:write
    • /api/lkpd/files/{file_id} requires lkpd:read

Notes

  • No public endpoint for polling job status yet
  • Final output delivery is callback-only
  • Callback signature/auth is not implemented yet
  • Callback payload contract is unchanged by API response envelopes