Syntagma Documentation

Syntagma

An Agentic Curriculum Lifecycle Management System for PES University

Syntagma is a FastAPI + static-frontend application that collects course submissions from faculty, refines them into curriculum-ready records with an LLM, renders the entire syllabus as HTML/PDF, lets an assistant agent propose and apply reviewable edits, and preserves named curriculum snapshots (versions).

It is built for a real, fast-changing syllabus: PESU revamps course content nearly every academic year, so nothing about the produced document is hardcoded. Course data, elective categorization, and specialization brackets all live in the database and are rendered dynamically.

Live Demo: syntagma.lonelyguy.tech (preferred) | Backup: pesucurriculum.vercel.app

Sign In Dashboard

Features

  • Course submission with auto-parsed course codes (semester, department, credits extracted automatically)
  • AI refinement that preserves all syllabus topics, only cleans and structures content
  • Full curriculum PDFs in PES University's official A4 format with letterhead
  • Agentic Editor with AI assistant (SSE streaming, 35 tools, draft review, attachments)
  • Reviewable drafts (agent never auto-applies changes)
  • Agent retry with fallback model (Fibonacci backoff on 502/503, automatic model switch)
  • Chat persistence (messages, tool calls, and results saved to database)
  • Dynamic specialization management (DB-driven tracks, not hardcoded)
  • Version snapshots with restore, revision history, and version-vs-version comparison
  • Course visibility toggle and credit-based sorting
  • Dual cache layer (Redis + in-memory, lazy invalidation)
  • Authentication via Supabase Auth

Tech Stack

ComponentTechnologyPurpose
Backend frameworkFastAPI 0.138 + UvicornASGI server, routing, validation
LanguagePython 3.12Runtime
FrontendVanilla HTML/CSS/JSNo build step, served as static files
DatabaseSupabase (PostgreSQL)Persistent storage for all data
CacheUpstash Redis (optional)Serverless Redis; falls back to in-memory dict
LLM providerOpenRouterStreaming + tool calling, fallback model retry
PDF engineWeasyPrintA4 curriculum PDFs from Jinja2 HTML
TemplatingJinja2Curriculum layout, course pages, diffs
HTTP clienthttpxAsync requests to OpenRouter and external URLs
Spreadsheet parsingopenpyxlReading uploaded .xlsx files for text extraction
Markdown renderingmarked.js (CDN)Agent chat message rendering in browser
HTML sanitizationDOMPurify (CDN)Sanitizing agent-generated HTML
PDF text extractionpoppler-utils (pdftotext)Extracting text from uploaded PDF attachments
AuthSupabase Auth (JWT)Browser-based authentication
Error trackingSentry SDK (optional)Production error monitoring
DeploymentDocker on HF SpacesBackend at port 7860
Frontend deployVercelStatic hosting with /api rewrite to backend

Quick Start

python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cd backend && fastapi dev app/main.py

Server at http://127.0.0.1:8000. API under /api. Frontend served from frontend/.

source .venv/bin/activate
pytest                              # 229 tests
python -m compileall backend/app    # also runs in CI

Architecture

flowchart TB subgraph Frontend["Frontend (Vanilla HTML/CSS/JS)"] Auth["/auth/ Sign In"] Dashboard["/ Dashboard"] Form["/form/ Course Submission"] Courses["/courses/ Course Management"] Preview["/preview/ PDF Preview"] Editor["/live-editor/ Agentic Editor"] Versions["/versions/ Version History"] end subgraph Backend["FastAPI Backend (/api)"] direction TB SubAPI["Submissions"] CoursesAPI["Courses"] PreviewAPI["Preview (8 endpoints)"] AgentAPI["Agent (13 endpoints)"] ChatAPI["Chat (SSE streaming)"] VersionsAPI["Versions (10 endpoints)"] end subgraph Services["Services Layer"] Refinement["Refinement - LLM content extraction"] AgentTools["Agent Tools - 35 tools"] Diffing["Diffing - protected field validation"] Curriculum["Curriculum - sorting, snapshots"] PreviewSvc["Preview - build_course_preview"] Rendering["Rendering - Jinja2 + WeasyPrint"] Cache["Cache - Redis + in-memory"] end subgraph External["External Services"] LLM["OpenRouter - Primary + Fallback"] Redis[("Upstash Redis")] Supa[(Supabase Postgres)] WeasyPrint["WeasyPrint - A4 PDF"] end Auth -->|"JWT"| Dashboard Dashboard --> Form & Courses & Preview & Editor & Versions Form -->|"POST /submissions"| SubAPI SubAPI --> Refinement Refinement -->|"extract content"| LLM Refinement -->|"store"| Supa Courses --> CoursesAPI CoursesAPI --> Cache Cache -.->|"miss"| Supa Preview --> PreviewAPI PreviewAPI --> PreviewSvc PreviewSvc --> Rendering Rendering --> WeasyPrint Editor -->|"SSE"| ChatAPI ChatAPI --> LLM LLM -->|"tool calls"| AgentTools AgentTools --> Supa ChatAPI -->|"save"| Supa VersionsAPI --> Curriculum Curriculum -->|"snapshot"| Supa Editor -->|"review/apply"| AgentAPI AgentAPI --> Diffing Diffing -->|"validate"| Supa Cache -.-> Redis

Layer Responsibilities

LayerLocationResponsibility
Static frontendfrontend/Course entry, course management, PDF preview, agentic editor, version history
API backendbackend/app/FastAPI routes, validation, refinement, previews, drafts, chat, snapshots
Servicesbackend/app/services/Business logic, LLM integration, caching, diffing, tool dispatch
PersistenceSupabase PostgresRaw submissions, refined courses, agent drafts, chat history, attachments, curriculum versions
CacheRedis + in-memoryCourse lists, version lists, PDFs, with lazy invalidation
RenderingJinja2 + WeasyPrintCurriculum summary pages, course detail pages, PDF exports
Model providerOpenRouterSubmission refinement and live-editor chat with tool calls + fallback model retry
AuthSupabase Auth (JWT)Browser-based authentication with token verification
MonitoringSentry SDKOptional production error tracking

Backend Project Structure

PathResponsibility
main.pyFastAPI app, CORS, Supabase/.env loading, mounts /api routers and the static frontend
api.pyAggregates all route routers under a single /api router
supabase.pySupabase client + first_row() helper
cache.pyDual cache (Redis + in-memory), lazy invalidation, prefix-based deletion
models/submission.pyCourseSubmission (request contract) and parse_course_code()
services/deterministic.pycompute_hours, compute_program, compute_course_type from credit category
services/refinement.pyThe LLM refinement pipeline (refine)
services/curriculum.pySorting, ordering, version snapshots, draft records, field updates
services/diffing.pyJSON diff, protected-field validation, patch apply/merge
services/preview.pybuild_course_preview, build_specialization_context
services/rendering.pyJinja2 environment, filters, SEMESTER_NAMES global
services/agent_tools.pyAgent tool definitions + TOOLS registry (35 tools) + call_tool
services/openrouter.pycall() (one-shot), stream_chat() (tool-calling loop), fallback model retry
services/schema.pyREQUIRED_TABLES and schema_status()
services/errors.pydatabase_http_exception()
services/attachments.pyText extraction from PDF/DOCX/XLSX/TXT
services/books.pyparse_books() textbook parser
services/elective_categorization.pyAI-powered elective-to-track matching
routes/health.pyGET /api/health/schema
routes/submissions.pyPOST /api/submissions, POST /api/submissions/{id}/refine
routes/preview.pyCourse/HTML/PDF preview endpoints (8 endpoints)
routes/refined.pyGET/PATCH a single refined course
routes/courses.pyList + toggle visibility + soft-delete refined courses
routes/agent.pyDraft + document-draft + tool endpoints (13 endpoints)
routes/chat.pyChat sessions, SSE streaming, attachments, system prompt
routes/versions.pyVersion CRUD, restore, previews, diffs (10 endpoints)
routes/auth.pyToken verification, logout
templates/jinja_sample.htmlSingle course + full document renderer + title page
templates/jinja_program.htmlProgram-level title page (large seal) + PEOs/POs
templates/jinja_1_to_8.htmlSemester summary tables (1-4, 7-8)
templates/jinja_sem_5_6.htmlSemester 5/6 electives + specialization tables
templates/jinja_diff.htmlStructured diff renderer for drafts

Frontend Project Structure

PathPurpose
index.htmlDashboard hub linking to all surfaces
form/Raw course submission form with course code parsing
courses/Refined course list with filtering, visibility toggle, soft delete
preview/Overall or per-semester PDF preview/download
live-editor/Agentic Editor: course preview, chat assistant, JSON editor, draft review, version restore
versions/Snapshot list, preview, comparison, editor handoff
auth/Sign in (Supabase Auth)
shared/auth-guard.js, supabase-client.js, shared.css, dialog.js

How It Works

Syntagma has six surfaces. Each serves a purpose in the curriculum lifecycle.

Authentication

Sign In

Supabase Auth with email/password. JWT verified on every API call. The shared/auth-guard.js script redirects unauthenticated users to /auth/.

Dashboard

Dashboard

Quick links to all surfaces: Submission Form, Courses, Preview, Live Editor, Versions, and Documentation.

Submission Form

Course Submission Form

Faculty enter raw course data. Course codes are auto-parsed: semester, department, credits, and course type extracted from the code. Background refinement kicks in immediately after submission. Alternatively, course details can be sent as attachments in the agent chat, where the agent creates a refined course directly using create_refined_course.

Courses

Courses Page Filtered Courses Delete Modal

Lists all refined courses. Filter by semester or department. Toggle visibility (hidden courses are excluded from previews and PDFs). Delete with confirmation modal.

Preview

Single Course Preview Full Document Preview Semester Preview

Three view modes: single course, full document, or per-semester. PDFs generated via WeasyPrint in PES University's A4 format with letterhead.

Agentic Editor

Agentic Editor Editor with Chat Editor Annotated

Two-column workspace. Left: preview iframe (single course or full document). Right: tabbed panel.

TabWhat it does
AgentAI assistant via SSE streaming. 35 tools. Creates reviewable drafts, never auto-applies. Thread management with rename/delete. File attachments supported.
FieldsRaw JSON editor. "Propose Changes" creates a reviewable draft. "Save" directly updates (non-protected fields only).
ReviewLoads pending drafts (new courses, single-course, multi-course). Shows diff. Apply or reject.

Versions

Versions Page Current Document Version Comparison Rename Category

Named snapshots of the entire curriculum. "Current" at top of sidebar represents live state. Preview any snapshot, compare two versions, or restore a previous state. Restore archives absent courses and resets applied drafts.

Backend Internals

Submission Pipeline

StepWhat happens
1POST /api/submissions validates payload against CourseSubmission
2parse_course_code() derives offering_department, target_department, semester, credit_category
3Inserts into submissions, queues background refinement
4refine(submission_id) builds deterministic fields, calls OpenRouter, upserts refined_submissions
5Course visible in /api/courses, previews, and editor

Alternative path: course details sent as chat attachments can be used by the agent to create a refined course directly via create_refined_course, bypassing the submission/refinement pipeline.

Course Code Encoding

UE + YY (year) + DEPT (2-letter) + NUMBER (3-digit) + SUFFIX.

DigitMeaning
TensCredits (0/2/4/5)
HundredsSemester group
Suffix A/BOdd/even semester parity

parse_course_code() in models/submission.py is the single source of truth.

Deterministic Fields

Computed from credit_category by services/deterministic.py. Protected from casual edits.

CategoryLTPSCCourse type
540255Core Course-Lab Integrated
440044Core Course
220022Core Theory
000000Foundation Course

Protected fields (diffing.py): program, lecture_hours, tutorial_hours, practical_hours, self_study, credits, course_type. Drafts that change them are blocked.

Preview & PDF Generation

build_course_preview(row) converts a refined_submissions row into a flat dict for templates. services/rendering.py builds the Jinja2 environment.

TemplateRenders
jinja_program.htmlTitle page (PES seal, program name, year) and PEOs/POs
jinja_sample.htmlIndividual course pages and summary tables
jinja_1_to_8.htmlSemester summary tables (1-4, 7-8)
jinja_sem_5_6.htmlElectives and specialization tables (5/6)
jinja_diff.htmlStructured diffs for agent drafts

WeasyPrint converts rendered HTML to PDF. PDFs cached 60s. Course lists and version lists cached 180s.

Specialization System

Fully data-driven. No hardcoded specialization brackets.

TableSchema
specialization_definitionsOne row per track: id, semester, letter, name, key, academic_year
course_specialization_assignmentsOne row per (course, track): id, refined_id, specialization_id
refined_submissions.is_electiveBoolean flag

build_specialization_context() loads tracks and assignments. Template excludes electives from regular tables, splits by code suffix (AA/BA -> group A), renders specialization assignment tables.

Agent tools: define_specialization, list_specializations, assign_elective_to_tracks, remove_elective_from_tracks, categorize_elective.

Agent System

Tool-calling LLM loop via openrouter.stream_chat. System prompt in chat.py:chat_system_prompt.

ComponentPurpose
Curriculum structure contextSemester ranges, course code encoding, credit categories
Draft vs. direct creation guidelinesWhen to draft vs. create directly
Desirable knowledge guidanceHow to handle prerequisite knowledge fields
Agent acknowledgement instructionBrief text before tool calls
update_agent_draft tool mentionModifying existing drafts

Agent Tools (35 total)

ToolCategoryDescription
get_current_course_jsonReadFull template-ready course JSON
get_course_codesReadLightweight IDs (refined_id, code, title, semester)
get_course_syllabusReadUnits, objectives, course_outcomes
get_course_textbooksReadtext_books, reference_books
get_course_deterministicReadProtected fields (read-only context)
get_course_labReadLab experiments, tools/languages
get_course_fieldsReadArbitrary field subset for one course
batch_read_coursesReadRead specific fields from multiple courses in one call
get_curriculum_jsonReadFull curriculum, optionally by semester
list_coursesReadCourse IDs/titles, optionally by semester
get_curriculum_statsReadAggregate statistics (total courses, credits per semester, course type distribution)
diff_course_jsonReadCompare two course JSONs
get_course_draftReadRead a staged course draft
get_document_draftReadRead a staged document draft
get_versionReadLoad a curriculum version snapshot with its course list
diff_versionsReadCompare two version snapshots (added/removed/changed courses)
get_course_assignmentsReadWhich specialization tracks a course belongs to
list_specializationsReadList track definitions
get_attachment_textReadRead uploaded chat attachments
fetch_urlReadFetch external URL content
web_searchReadSearch the web for external context
create_course_draftWritePropose changes for a single course (reviewable draft)
update_agent_draftWriteMerge fields into an existing draft (avoids duplicates)
create_document_draftWritePropose changes for multiple courses (reviewable draft)
assign_elective_to_tracksWriteCategorize an elective into specialization tracks
remove_elective_from_tracksWriteRemove a course from specialization tracks
define_specializationWriteCreate a new specialization track
categorize_electiveWriteAI-powered elective categorization into existing tracks
update_deterministic_fieldsWriteChange protected fields; produces a blocked draft requiring explicit user approval
create_refined_courseWriteCreate new courses directly (for brand-new courses only)
create_spreadsheetGenerateGenerate CSV or Excel (.xlsx) files from structured row data
create_reportGenerateGenerate markdown or PDF reports
create_curriculum_versionGenerateSnapshot the current curriculum state
signal_doneControlSignal task completion with a summary

SSE Event Types

Streamed from POST /chat/sessions/{id}/messages:

EventDescription
statusStatus updates (model name, tool execution)
tokenStreamed text tokens from the LLM
tool_callAgent invoking a tool (name + arguments)
tool_resultTool execution result
draftA course draft was created
document_draftA document draft was created
refined_courseA new course was created directly
context_usageToken usage statistics
errorError occurred
doneStream complete

Chat Persistence

All messages (user, assistant, tool_call, tool_result) saved to chat_messages with role and metadata. Tool calls include function name and arguments; tool results include output. Conversation history persists across page refreshes.

Agent Output Validation

ValidationBehavior
desirable_knowledgePlaceholder values (e.g., "None specified") stripped by _create_refined_course
Draft-status courses_create_course_draft rejects drafts, directs agent to create_refined_course

Versioning

create_version_snapshot copies every active refined_submissions into finalized_submissions pinned to a curriculum_versions row. restore overwrites current refined data, archives absent courses, writes revision history.

Caching

Dual cache layer (services/cache.py):

BackendConfigBehavior
Redis (Upstash, optional)REDIS_URLSurvives server restarts
In-memory dictFallbackSurvives within a process
PrefixTTLPurpose
courses_list180sCourse list for /api/courses
course_pdf:60sIndividual course PDF bytes
version_list180sVersion list for /api/versions

Invalidation: lazy (delete-on-write). Prewarm on startup. Debug: set is_cache_disabled = True in cache.py.

Error Handling & Retry

OpenRouter client (services/openrouter.py):

ParameterValue
Retryable statuses502, 503 (server errors)
Retryable exceptionshttpx.TimeoutException, httpx.TransportError
NOT retried429 (rate limit) - shows error and stops immediately
BackoffFibonacci sequence [1, 1, 2, 3, 5, 8, 13] seconds with +/-25% jitter
Max retries3 per request
Retry-After headerRespected when present

Fallback model:

ParameterBehavior
OPENROUTER_FALLBACK_MODELSet env var to enable (e.g. google/gemma-3-27b-it:free)
TriggerAfter 3 failed retries on the primary model
ScopeCommits to fallback for the rest of the chat thread
NotificationAnnounced via SSE status events so the UI can inform the user
PersistenceOnce committed, active_model persists as the fallback for all subsequent calls

429 (rate limit): No retry, no fallback. Error shown in chat and status bar. User must wait and retry manually.

Chat streaming errors: Surfaced as SSE error events. UI creates error bubble even when no tokens received. Done handler reloads messages from database.

API Reference

All paths are prefixed with /api at runtime.

Health

EndpointMethodPurpose
/api/health/schemaGETVerify required Supabase tables exist. Returns 503 if tables are missing.

Submissions

EndpointMethodPurpose
/api/submissionsPOSTStore a raw submission, queue background refinement. Body: CourseSubmission.
/api/submissions/{id}/refinePOSTManually trigger refinement for an existing submission.

Refined Courses

EndpointMethodPurpose
/api/refined/{refined_id}GETRead template-ready course fields.
/api/refined/{refined_id}PATCHUpdate editable refined fields. Promotes draft-status courses to refined. Body: { fields: dict }.

Course Management

EndpointMethodPurpose
/api/coursesGETList active refined courses (cached 180s).
/api/courses/{refined_id}/visiblePATCHToggle course visibility. Body: { visible: bool }.
/api/courses/{refined_id}DELETESoft-delete (archive) a course.

Preview & PDF

EndpointMethodPurpose
/api/preview/course/{refined_id}GETRender one course as HTML.
/api/preview/course/{refined_id}/pdfGETGenerate PDF for one course. ?download=true for attachment.
/api/preview/htmlGETRender full curriculum as HTML.
/api/preview/pdfGETGenerate full curriculum PDF. ?download=true for attachment.
/api/preview/semester/{sem}/pdfGETGenerate one semester as PDF.
/api/preview/semester/{sem}/coursesGETList course IDs for a semester.
/api/preview/pending-coursesGETList draft-status (pending) courses.
/api/preview/coursesGETList all visible refined course IDs.

Agent Drafts

EndpointMethodPurpose
/api/agent/draftsGETList 100 most recent agent drafts.
/api/agent/draftsPOSTCreate a single-course draft. Body: { refined_id, fields, reason? }.
/api/agent/drafts/{draft_id}GETFetch a draft with base/proposed JSON and diff summary.
/api/agent/drafts/{draft_id}/applyPOSTApply a proposed draft. Rejects blocked drafts or protected-field changes.
/api/agent/drafts/{draft_id}/previewGETRender draft as HTML. ?diff=true for diff view.

Document Drafts (multi-course)

EndpointMethodPurpose
/api/agent/document-draftsGETList 100 most recent document drafts.
/api/agent/document-draftsPOSTCreate a multi-course draft. Body: { courses: [{ refined_id, fields }], reason? }.
/api/agent/document-drafts/{id}GETFetch document draft with all linked course drafts.
/api/agent/document-drafts/{id}/applyPOSTApply all proposed sub-drafts.
/api/agent/document-drafts/{id}/previewGETRender all proposed courses. ?diff=true for diff view.

Agent Tools & Diff

EndpointMethodPurpose
/api/agent/toolsGETList all agent tool schemas (OpenAI function-calling format).
/api/agent/tools/{tool_name}POSTExecute a tool directly. Body: { arguments: dict }.
/api/agent/context-lengthGETReturn current model's context window size.
/api/agent/diffPOSTCompute structured diff between two course JSONs.

Chat

EndpointMethodPurpose
/api/chat/sessionsGETList active sessions. ?refined_id= or ?document_draft_id= to filter.
/api/chat/sessionsPOSTCreate a session. Body: { refined_id?, document_draft_id?, title? }.
/api/chat/sessions/{id}PATCHRename a session. Body: { title }.
/api/chat/sessions/{id}DELETESoft-archive a session and its messages.
/api/chat/sessions/{id}/messagesGETFetch all messages in a session.
/api/chat/sessions/{id}/messagesPOSTSend a message and stream response via SSE. Events: status, token, tool_call, tool_result, draft, document_draft, refined_course, context_usage, error, done.
/api/chat/sessions/{id}/attachmentsPOSTUpload files (multipart/form-data). 10MB/file, 25MB total.
/api/chat/sessions/{id}/attachments/{att_id}/downloadGETDownload an attachment.
/api/chat/sessions/{id}/attachments/{att_id}/previewGETPreview an attachment inline.

Versions

EndpointMethodPurpose
/api/versionsGETList all curriculum snapshots. Includes course_count and has_changes.
/api/versionsPOSTCreate a snapshot. Returns 409 if no changes since last version. Body: { name, program?, academic_year?, status? }.
/api/versions/{version_id}GETFetch version with its course list.
/api/versions/{version_id}PATCHUpdate version metadata. Body: { name?, academic_year?, status? }.
/api/versions/{version_id}DELETEDelete a version and its finalized_submissions.
/api/versions/{version_id}/restorePOSTRestore a snapshot. Archives absent courses. Resets applied drafts.
/api/versions/{version_id}/courses/{refined_id}GETFetch a single course snapshot from a version.
/api/versions/{version_id}/courses/{refined_id}/previewGETRender a version course as HTML.
/api/versions/{version_id}/previewGETRender all version courses. ?diff=true for diff vs current.
/api/versions/{id1}/diff/{id2}GETRender side-by-side diff between two versions.

Auth

EndpointMethodPurpose
/api/auth/checkGETVerify the bearer token is valid. Returns user info or 401.
/api/auth/logoutPOSTSign out the current user.

Query Parameters

ParameterApplies toDescription
curriculum_yearAll preview/PDF endpointsPin the batch year (e.g. 2025-2026). Falls back to CURRICULUM_YEAR env var.
downloadPDF endpointsSet true to trigger Content-Disposition attachment.
diffDraft/version previewSet true to render diff view instead of proposed content.

Total: 49 endpoints across 9 route files.

Database Schema

Run docs/schema.sql in the Supabase SQL editor. Required tables:

submissions, refined_submissions, curriculum_versions, finalized_submissions, agent_drafts, agent_document_drafts, course_revision_history, chat_sessions, chat_messages, chat_attachments, specialization_definitions, course_specialization_assignments.

Course Status Lifecycle

submissions.status:    pending  ->  refined
refined_submissions:   draft    ->  refined  ->  archived
agent_drafts:          proposed ->  applied
                       blocked  ->  proposed (on user edit)
chat_sessions:         active   ->  archived

Key Columns on refined_submissions

course_code, course_title, semester, credit_category, program, lecture_hours, tutorial_hours, practical_hours, self_study, credits, course_type, is_elective, visible, units (jsonb), objectives/text_books/... (arrays), status.

visible (default true) controls whether a course renders in preview/PDF output. Toggle it from the course management page. Hidden courses stay in the database and remain editable but are excluded from every rendered document.

Verify with GET /api/health/schema.

Table Relationships

refined_submissions (1) ----< (N) agent_drafts
refined_submissions (1) ----< (N) course_revision_history
curriculum_versions (1) ----< (N) finalized_submissions
agent_document_drafts (1) ----< (N) agent_drafts
chat_sessions (1) ----< (N) chat_messages
chat_sessions (1) ----< (N) chat_attachments
specialization_definitions (1) ----< (N) course_specialization_assignments
refined_submissions (1) ----< (N) course_specialization_assignments

Specialization Tables

TableSchema
specialization_definitionsOne row per track: id, semester, letter (A/B/C...), name, key (SCC/MIDS/CSCS), academic_year
course_specialization_assignmentsOne row per (course, track) membership: id, refined_id, specialization_id
refined_submissions.is_electiveBoolean flag marking a course as an elective

Environment Variables

Required backend (/api server, loaded from repo-root .env)

Variable
SUPABASE_URL
SUPABASE_KEY
OPENROUTER_URL
OPENROUTER_API_KEY
OPENROUTER_MODEL

Optional

VariableDescription
CURRICULUM_YEARThe active batch label (e.g. 2025-2026)
OPENROUTER_FALLBACK_MODELBackup model used when the primary model fails after retries (e.g. google/gemma-3-27b-it:free)
REDIS_URLUpstash Redis URL for persistent caching (falls back to in-memory)
SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASEError tracking configuration

The frontend uses the public Supabase anon key directly in shared/supabase-client.js.

Deployment

Backend (HF Spaces)

Docker image runs uvicorn app.main:app --host 0.0.0.0 --port 7860 (HF Space).

.github/workflows/sync-to-hub.yml syncs main to the HF Space.

Required HF Space secrets:

SecretDescription
SUPABASE_URL, SUPABASE_KEYDatabase connection
OPENROUTER_URL, OPENROUTER_API_KEY, OPENROUTER_MODELLLM provider
OPENROUTER_FALLBACK_MODELRecommended fallback model
REDIS_URLOptional persistent caching
SENTRY_DSN, SENTRY_ENVIRONMENT, SENTRY_RELEASEOptional error tracking

Frontend (Vercel)

Static hosting with frontend/vercel.json rewriting /api/* to the backend.

Required Vercel environment variables:

VariableDescription
NEXT_PUBLIC_SUPABASE_URLPublic Supabase URL
NEXT_PUBLIC_SUPABASE_ANON_KEYPublic Supabase anon key

CI (.github/workflows/ci.yml)

Runs on push/PR: checkout, Python 3.12, pip install (cached), apt-get install poppler-utils, pytest, python -m compileall backend/app.

Local Development

python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cd backend && fastapi dev app/main.py

Server at http://127.0.0.1:8000. API under /api. Frontend served from frontend/.

source .venv/bin/activate
pytest                              # all tests
python -m compileall backend/app    # also runs in CI