All Academic Operations
app/— FastAPI application (API, services, models)scripts/— optional local utilities (not required at runtime)storage/— generated PDFs, slide cache, and uploads (created on startup; gitignored). Override withCOURSE_AI_STORAGE_ROOT.
- Copy env template:
copy .env.dev.example .env.dev
- Fill secrets in
.env.dev(ANTHROPIC_API_KEY, etc.) - Start stack:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
API runs on http://localhost:8000, Postgres runs on localhost:5432.
- Copy env template:
copy .env.prod.example .env.prod
- Set managed Postgres URL in
.env.prod - Start API:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up --build -d
Use a managed Postgres service in production (RDS/Azure/Supabase/Neon), not a DB inside the same app container.
- In Railway, create a new project from this GitHub repo.
- Add a PostgreSQL service in the same project.
- Keep app service using the repo
Dockerfile(already configured).
Set these variables in Railway:
DATABASE_URL= connection string from Railway Postgres (ensure it usespostgresql://; app auto-converts topostgresql+asyncpg://)API_SECRET_KEY= strong random secretBASE_URL= your Railway public backend URLLOG_LEVEL=INFOANTHROPIC_API_KEY= your Claude API keyANTHROPIC_MODEL= e.g.claude-3-5-sonnet-latestANTHROPIC_BASE_URL=https://api.anthropic.comZOHO_CALLBACK_URL= optional callback endpoint
Push to main. Railway auto-deploys.
Health check endpoint: /api/v1/health
- Railway provides dynamic
PORT; container is already configured for it. - Runtime files live under
storage/(storage/pdfs,storage/ppts,storage/uploads). On Railway this volume is still ephemeral unless you attach persistent storage or use object storage (S3/R2/GCS). - Optional: set
COURSE_AI_STORAGE_ROOTto point uploads/PDFs elsewhere.
- Default
POST /api/v1/courses→ 202 withjob_id,zoho_record_id,status,message, andpolling.by_zoho_record_id→GET /api/v1/courses/{zoho_record_id}/outline-job(slim JSON: no Gamma/slides fields). - Poll
GET /api/v1/courses/{zoho_record_id}/outline-job(slim JSON for outline jobs). - Synchronous
POST /api/v1/courses?sync=true→ 200 when AI + PDF finish (same slim shape as outline job). May time out from Zoho; prefer async + poll.
Slides (separate): POST /api/v1/slides/ then GET /api/v1/slides/{zoho_record_id} for module_gamma_links, etc.
- Module flow: Planner -> Generator -> Validator -> (retry max N) -> Gamma
- Planner/Generator/Validator models are configurable via env vars:
SLIDES_PLANNER_MODELSLIDES_GENERATOR_MODELSLIDES_VALIDATOR_MODEL
- Validation loop:
SLIDES_VALIDATION_MAX_LOOPS(default2) - Per-module slide bounds:
SLIDES_MIN_PER_MODULE(default10)SLIDES_MAX_PER_MODULE(default20)
- Final Gamma safety remains enforced with batching at
MAX_SLIDES_PER_BATCH=60. - Optional Gamma template/sharing config for slides:
GAMMA_USE_TEMPLATE=true+GAMMA_TEMPLATE_ID=<id>to use/v1.0/generations/from-templateGAMMA_WORKSPACE_ACCESS/GAMMA_EXTERNAL_ACCESS(view,comment,edit, etc.)GAMMA_EMAIL_EDIT_LIST(comma-separated recipients granted edit access)
See OAuth overview and Upload attachment (file or link).
- Create a Zoho Self Client / Server-based app, generate refresh token with scopes including CRM modules + attachments (e.g.
ZohoCRM.modules.ALLand attachment scope as required by Zoho). - Set environment variables (e.g. Railway):
| Variable | Purpose |
|---|---|
ZOHO_CLIENT_ID |
OAuth client id |
ZOHO_CLIENT_SECRET |
OAuth client secret |
ZOHO_REFRESH_TOKEN |
Long-lived refresh token |
ZOHO_ACCOUNTS_BASE_URL |
e.g. https://accounts.zoho.com (use .eu / .in etc. for your DC) |
ZOHO_CRM_API_BASE |
Usually https://www.zohoapis.com |
ZOHO_CRM_MODULE_API_NAME |
Legacy fallback module API name (used when specific outline/slides vars are not set). |
ZOHO_CRM_OUTLINE_MODULE_API_NAME |
CRM module API name for course-outline attach (recommended). |
ZOHO_CRM_SLIDES_MODULE_API_NAME |
CRM module API name for slides input fetch from file-upload field outline. |
ZOHO_CRM_SLIDES_LINKS_FIELD_API_NAME |
Slides module field API name to write module-wise Gamma links text (default: Link_for_Courseware). |
ZOHO_ATTACH_PDF_LINK_TO_CRM |
true to attach the generated public PDF URL to the record after the job completes |
-
zoho_record_idinPOST /coursesmust be the outline-module record ID Zoho sends (the long numeric Record Id from CRM, same id used in the URL when you open the record). The backend attaches the generated PDF public link to that record viaPOST .../crm/v8/{outline_module}/{record_id}/Attachments(link attachment). SetZOHO_CRM_OUTLINE_MODULE_API_NAMEfor this flow. -
Access tokens are refreshed automatically (cached; refresh uses your refresh token before the ~1 hour expiry).
| Where | What |
|---|---|
| Railway | Project → your API service → Variables → add each key/value → Redeploy. |
| Local | Copy into .env next to API_SECRET_KEY, DATABASE_URL, etc. (never commit .env). |
Required for attach-after-job:
ZOHO_CLIENT_ID— Zoho API Console → your Self Client / Server-based application → Client ID.ZOHO_CLIENT_SECRET— same app → Client Secret.ZOHO_REFRESH_TOKEN— generated once via OAuth grant with required CRM scopes (see Zoho OAuth docs); store securely.ZOHO_ACCOUNTS_BASE_URL—https://accounts.zoho.com(US); usehttps://accounts.zoho.eu,https://accounts.zoho.in, etc. if your org is in that DC.ZOHO_CRM_API_BASE— usuallyhttps://www.zohoapis.com.ZOHO_CRM_OUTLINE_MODULE_API_NAME— CRM module API name for course-outline attach flow.ZOHO_CRM_SLIDES_MODULE_API_NAME— CRM module API name for slides source fetch flow (fieldoutline).ZOHO_CRM_MODULE_API_NAME— optional legacy fallback when the two specific vars above are not set.ZOHO_ATTACH_PDF_LINK_TO_CRM—trueto attach the generated public PDF URL to the record after the job completes.
- The callback does not upload PDF bytes; it POSTs JSON (or form) with a
pdf_urlstring. Your Zoho Function / Flow must download that URL or use CRM attach via OAuth (ZOHO_ATTACH_PDF_LINK_TO_CRM). - If the callback returns 400, Zoho often expects
application/x-www-form-urlencodedinstead of JSON. Set:ZOHO_CALLBACK_BODY_FORMAT=form
- After deploy, check logs:
Zoho callback rejected | ... body=...shows Zoho’s error message.
If logs show HTML / “Zoho Accounts” / a login page: ZOHO_CALLBACK_URL is wrong — you pasted a browser URL (login or CRM UI), not a machine URL. The backend POSTs without cookies, so Zoho returns the Accounts HTML page → 400. Fix: use a real webhook target, for example:
- CRM → Functions → create a function → copy its Invoke URL (public URL that accepts POST), or
- Zoho Flow → incoming webhook URL, or
- Your own Railway/ngrok endpoint that receives the callback.
CRM PDF on the record without a callback URL: set ZOHO_ATTACH_PDF_LINK_TO_CRM=true and OAuth env vars so this API attaches the pdf_url via Upload attachment (link).
Logs will now show Zoho’s error and error_description. Common fixes:
| Symptom | Fix |
|---|---|
invalid_client |
Wrong ZOHO_CLIENT_ID / ZOHO_CLIENT_SECRET or extra spaces/newlines in Railway variables. |
invalid_grant |
Refresh token revoked, expired, or generated for a different client id. Regenerate refresh token in API Console. |
invalid_code (often with empty error_description) |
Refresh token and client don’t match, or wrong DC: regenerate refresh token using the same API Console app and same accounts host (e.g. EU app → https://accounts.zoho.eu). Do not store a one-time authorization code in ZOHO_REFRESH_TOKEN — only the refresh_token field from the token exchange JSON. |
| Wrong data center | If your org is EU/IN/AU, use ZOHO_ACCOUNTS_BASE_URL=https://accounts.zoho.eu (or .in, .com.au) — must match where the app was created. |
Token URL must be POST to /oauth/v2/token |
Already handled; do not paste a browser URL into ZOHO_ACCOUNTS_BASE_URL (only the origin, e.g. https://accounts.zoho.com). |
Callback HTML / 400: ZOHO_CALLBACK_URL must not be a Zoho login page. Leave ZOHO_CALLBACK_URL empty until you have a real Function/Flow webhook URL, or attach will still work via OAuth above.
Verify OAuth outside the app (replace placeholders; use your DC host):
curl -sS -X POST "https://accounts.zoho.com/oauth/v2/token" \
-d "grant_type=refresh_token" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "refresh_token=YOUR_REFRESH_TOKEN"You must see JSON with "access_token". If you see "error":"invalid_code", fix credentials in Zoho API Console before Railway will work.