Spaces:
Sleeping
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(generatemcq,essay,summary)POST /api/lkpd(generate LKPD + downloadable PDF)
- Immediately returns
202 Acceptedwithjob_id - Processes jobs in background worker (Redis queue)
- Delivers final result to
callback_urlvia HTTP POST
Current architecture flow
- Client submits multipart form request.
- API validates request and stores job in Redis.
- Worker pulls queue, extracts text (
.pdf,.pptx,.txt), indexes RAG, calls model. - Worker sends callback payload (retry with backoff if callback fails).
- LKPD flow also renders a PDF and exposes a temporary download URL.
API module structure
src/main.pyinitializes app lifespan/middleware and registers routers.src/api/material_routes.pyhandles/api/material,/api/mcq,/api/essay,/api/summary.src/api/lkpd_routes.pyhandles/api/lkpdand/api/lkpd/files/{file_id}.src/api/oauth_routes.pyhandles/api/oauth/token.src/api/job_submission.pycentralizes shared request validation and enqueue flow.src/api/schemas.pycentralizes 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_KEYset 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
- Fill
.env(minimum:GROQ_API_KEY). - 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 toOAUTH_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, validhttp/https)mcq_count(optional, default10, range1..20)mcp_enabled(optional, defaulttrue)
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, validhttp/https)essay_count(optional, default3, range1..10)mcp_enabled(optional, defaulttrue)
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, validhttp/https)summary_max_words(optional, default200, range80..400)mcp_enabled(optional, defaulttrue)
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, validhttp/https)generate_types(required, repeatable:mcq,essay,summary; unique; min 1)mcq_count(optional, default10, range1..20)essay_count(optional, default3, range1..10)summary_max_words(optional, default200, range80..400)mcp_enabled(optional, defaulttrue)
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, validhttp/https)activity_count(optional, default5, 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
404if 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:
POSTJSON - Attempts:
1 + WEBHOOK_CALLBACK_MAX_RETRIES- Default =
4total attempts (1 initial + 3 retries)
- Default =
- Backoff:
WEBHOOK_CALLBACK_BACKOFF_SECONDS(default5,15,45) with small jitter
Material event: material.generated
status values in callback payload:
succeededfailed_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:
succeededfailed_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)
acceptedprocessingsucceededfailed_processingfailed_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_DIRand expired usingLKPD_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=.chromaGROQ_API_KEY=GROQ_MODEL=llama-3.1-8b-instantGROQ_TEMPERATURE=0.2GROQ_TIMEOUT_SECONDS=30MCP_SERVERS_JSON={}AGENT_MAX_ITERATIONS=5AGENT_MEMORY_COLLECTION=agent_memoryRAG_COLLECTION_NAME=material_chunksRAG_CHUNK_SIZE=1000RAG_CHUNK_OVERLAP=150RAG_TOP_K=8RAG_FETCH_K=24RAG_MMR_LAMBDA=0.5MATERIAL_MAX_FILE_MB=15DEFAULT_MCQ_COUNT=10DEFAULT_ESSAY_COUNT=3DEFAULT_SUMMARY_MAX_WORDS=200REDIS_URL=redis://localhost:6379/0WEBHOOK_CALLBACK_TIMEOUT_SECONDS=10WEBHOOK_CALLBACK_MAX_RETRIES=3WEBHOOK_CALLBACK_BACKOFF_SECONDS=5,15,45JOB_TTL_SECONDS=86400JOB_QUEUE_KEY=material_jobs:queueLKPD_DEFAULT_ACTIVITY_COUNT=5LKPD_MIN_ACTIVITY_COUNT=1LKPD_MAX_ACTIVITY_COUNT=15LKPD_JOB_QUEUE_KEY=lkpd_jobs:queueLKPD_PDF_DIR=.generated/lkpdLKPD_PDF_TTL_SECONDS=86400LKPD_HEADER_LOGO_PATH=.assets/lkpd/logo.pngLKPD_HEADER_ACCENT_HEX=#1F4E79LKPD_HEADER_TITLE_LINE1=LEMBAR KERJA PESERTA DIDIK (LKPD)LKPD_HEADER_TITLE_LINE2=SMARTER AILKPD_HEADER_TITLE_LINE3=APP_PUBLIC_BASE_URL=http://localhost:7860APP_LOG_LEVEL=INFO(fallback:LOG_LEVEL)APP_LOG_STREAM=stderr(stderrrecommended for hosted runtime logs)JWT_ENABLED=true|false(default: true inAPP_ENV=production, otherwise false)JWT_SECRET=(required whenJWT_ENABLED=trueorOAUTH_ENABLED=true, minimum 32 chars)JWT_ISSUER=my-backendJWT_AUDIENCE=rtm-class-aiJWT_CLOCK_SKEW_SECONDS=30JWT_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 inAPP_ENV=production, otherwise false)OAUTH_CLIENT_ID=rtm-clientOAUTH_CLIENT_SECRET=...(required whenOAUTH_ENABLED=true)OAUTH_ALLOWED_SCOPES=material:write lkpd:write lkpd:readOAUTH_DEFAULT_SCOPES=material:write lkpd:write lkpd:readOAUTH_TOKEN_TTL_SECONDS=300OAUTH_TOKEN_RATE_LIMIT_WINDOW_SECONDS=60OAUTH_TOKEN_RATE_LIMIT_PER_IP=30OAUTH_TOKEN_RATE_LIMIT_PER_CLIENT=30
Authentication (JWT)
- Clients authenticate to
POST /api/oauth/tokenusingclient_id+client_secret. JWT_SECRETis 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:issmust matchJWT_ISSUERaudmust matchJWT_AUDIENCEsubmust start withclient:iatandexp
scopeclaim remains space-separated and enforced per endpoint.jtiis issued on each token and checked against Redis denylist when enabled.- Endpoint scopes:
/api/mcqrequiresmaterial:write/api/essayrequiresmaterial:write/api/summaryrequiresmaterial:write/api/materialrequiresmaterial:write/api/lkpdrequireslkpd:write/api/lkpd/files/{file_id}requireslkpd: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