Lonelyguyse1 commited on
Commit
2ce19b1
·
0 Parent(s):

deploy: restore with README

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +23 -0
  2. README.md +205 -0
  3. backend/app/__init__.py +0 -0
  4. backend/app/_logo_data.py +1 -0
  5. backend/app/api.py +8 -0
  6. backend/app/cache.py +134 -0
  7. backend/app/main.py +87 -0
  8. backend/app/models/__init__.py +1 -0
  9. backend/app/models/agent.py +23 -0
  10. backend/app/models/chat.py +26 -0
  11. backend/app/models/submission.py +101 -0
  12. backend/app/preview.py +108 -0
  13. backend/app/rendering.py +102 -0
  14. backend/app/routes/__init__.py +1 -0
  15. backend/app/routes/agent.py +339 -0
  16. backend/app/routes/auth.py +17 -0
  17. backend/app/routes/chat.py +577 -0
  18. backend/app/routes/courses.py +63 -0
  19. backend/app/routes/health.py +18 -0
  20. backend/app/routes/preview.py +235 -0
  21. backend/app/routes/refined.py +31 -0
  22. backend/app/routes/submissions.py +60 -0
  23. backend/app/routes/versions.py +425 -0
  24. backend/app/services/agent_tools.py +1695 -0
  25. backend/app/services/attachments.py +91 -0
  26. backend/app/services/books.py +105 -0
  27. backend/app/services/curriculum.py +225 -0
  28. backend/app/services/deterministic.py +43 -0
  29. backend/app/services/diffing.py +241 -0
  30. backend/app/services/elective_categorization.py +202 -0
  31. backend/app/services/errors.py +16 -0
  32. backend/app/services/openrouter.py +475 -0
  33. backend/app/services/refinement.py +630 -0
  34. backend/app/services/schema.py +30 -0
  35. backend/app/supabase.py +24 -0
  36. backend/app/templates/jinja_1_to_8.html +236 -0
  37. backend/app/templates/jinja_diff.html +266 -0
  38. backend/app/templates/jinja_program.html +42 -0
  39. backend/app/templates/jinja_sample.html +516 -0
  40. backend/app/templates/jinja_sem_5_6.html +263 -0
  41. docs/schema.sql +157 -0
  42. frontend/auth/auth.css +129 -0
  43. frontend/auth/auth.js +44 -0
  44. frontend/auth/index.html +41 -0
  45. frontend/courses.html +11 -0
  46. frontend/courses/courses.css +236 -0
  47. frontend/courses/courses.js +165 -0
  48. frontend/courses/index.html +85 -0
  49. frontend/docs/docs.css +287 -0
  50. frontend/docs/index.html +897 -0
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y \
6
+ libpango-1.0-0 \
7
+ libpangocairo-1.0-0 \
8
+ libgdk-pixbuf-2.0-0 \
9
+ libffi-dev \
10
+ poppler-utils \
11
+ shared-mime-info \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ COPY requirements.txt .
15
+
16
+ RUN pip install --no-cache-dir -r requirements.txt
17
+
18
+ COPY backend/ .
19
+ COPY frontend/ frontend/
20
+
21
+ EXPOSE 7860
22
+
23
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Curriculum Backend
3
+ colorFrom: blue
4
+ colorTo: gray
5
+ sdk: docker
6
+ app_port: 7860
7
+ pinned: false
8
+ ---
9
+
10
+ # Syntagma
11
+
12
+ <div align="center">
13
+
14
+ ![Python](https://img.shields.io/badge/python-3.12-3776AB?style=flat-square&logo=python&logoColor=white)
15
+ ![FastAPI](https://img.shields.io/badge/fastapi-0.138-009688?style=flat-square&logo=fastapi&logoColor=white)
16
+ ![Supabase](https://img.shields.io/badge/supabase-postgres-3FCF8E?style=flat-square&logo=supabase&logoColor=white)
17
+ ![Upstash Redis](https://img.shields.io/badge/upstash--redis-cache-DD0000?style=flat-square&logo=redis&logoColor=white)
18
+ ![OpenRouter](https://img.shields.io/badge/openrouter--llm-6366F1?style=flat-square&logo=openai&logoColor=white)
19
+ ![WeasyPrint](https://img.shields.io/badge/weasyprint-pdf-E64B1A?style=flat-square&logo=markdown&logoColor=white)
20
+ ![CI](https://img.shields.io/badge/CI-passing-brightgreen?style=flat-square&logo=githubactions&logoColor=white)
21
+ ![Tests](https://img.shields.io/badge/tests-229-green?style=flat-square&logo=pytest&logoColor=white)
22
+ ![Sentry](https://img.shields.io/badge/sentry--sdk-2.63-362D59?style=flat-square&logo=sentry&logoColor=white)
23
+ ![HF Space](https://img.shields.io/badge/deploy-HF%20Spaces-yellow?style=flat-square&logo=huggingface&logoColor=white)
24
+ ![Vercel](https://img.shields.io/badge/frontend-Vercel-black?style=flat-square&logo=vercel&logoColor=white)
25
+
26
+ </div>
27
+
28
+ ## The Problem
29
+
30
+ PES University revamps its B.Tech curriculum nearly every academic year. Faculty submit course content in raw, inconsistent formats. Someone manually compiles it into the official syllabus document. There is no version history. There is no way to compare what changed between years. The entire process is manual, error-prone, and slow.
31
+
32
+ ## How Syntagma Solves It
33
+
34
+ Faculty submit raw course content through a form. The system uses AI to clean and structure it into curriculum-ready records. Admins review, edit, and approve changes through an agentic AI assistant that proposes edits but never applies them without human approval. The full curriculum renders as official A4 PDFs with PES University's letterhead. Every change is tracked with named version snapshots.
35
+
36
+ **Live Demo:** **[syntagma.lonelyguy.tech](https://syntagma.lonelyguy.tech/)** (preferred)
37
+
38
+ Backup: [pesucurriculum.vercel.app](https://pesucurriculum.vercel.app/)
39
+
40
+ ## Architecture
41
+
42
+ ```mermaid
43
+ flowchart TB
44
+ subgraph Frontend
45
+ Auth["/auth/ Sign In"]
46
+ Dashboard["/ Dashboard"]
47
+ Form["/form/ Course Submission"]
48
+ Courses["/courses/ Course Management"]
49
+ Preview["/preview/ PDF Preview"]
50
+ Editor["/live-editor/ Agentic Editor"]
51
+ Versions["/versions/ Version History"]
52
+ Docs["/docs/ Documentation"]
53
+ end
54
+
55
+ subgraph Backend["FastAPI Backend /api"]
56
+ direction TB
57
+ SubAPI["Submissions"]
58
+ CoursesAPI["Courses"]
59
+ PreviewAPI["Preview 8 endpoints"]
60
+ AgentAPI["Agent 13 endpoints"]
61
+ ChatAPI["Chat SSE streaming"]
62
+ VersionsAPI["Versions 10 endpoints"]
63
+ end
64
+
65
+ subgraph External
66
+ LLM["OpenRouter Primary + Fallback"]
67
+ Redis[("Upstash Redis")]
68
+ Supa[(Supabase Postgres)]
69
+ WeasyPrint["WeasyPrint A4 PDF"]
70
+ end
71
+
72
+ Auth -->|"JWT"| Dashboard
73
+ Dashboard --> Form & Courses & Preview & Editor & Versions & Docs
74
+
75
+ Form -->|"POST /submissions"| SubAPI
76
+ SubAPI -->|"refine"| LLM
77
+ SubAPI --> Supa
78
+
79
+ Courses --> CoursesAPI
80
+ CoursesAPI --> Redis
81
+ Redis -.->|"miss"| Supa
82
+
83
+ Preview --> PreviewAPI
84
+ PreviewAPI --> WeasyPrint
85
+
86
+ Editor -->|"SSE"| ChatAPI
87
+ ChatAPI --> LLM
88
+ LLM -->|"tool calls"| AgentAPI
89
+ AgentAPI --> Supa
90
+
91
+ VersionsAPI --> Supa
92
+ ```
93
+
94
+ | Layer | Stack |
95
+ |---|---|
96
+ | Backend | Python 3.12, FastAPI 0.138, Uvicorn |
97
+ | Frontend | Vanilla HTML/CSS/JS (no build step) |
98
+ | Database | Supabase (PostgreSQL) |
99
+ | Cache | Upstash Redis (optional, falls back to in-memory) |
100
+ | AI/LLM | OpenRouter (streaming, tool calling, fallback model retry) |
101
+ | PDF | Jinja2 + WeasyPrint (A4 layout with PES University letterhead) |
102
+ | Auth | Supabase Auth (JWT) |
103
+ | Deploy | Docker on HF Spaces, Vercel frontend proxy |
104
+ | Monitoring | Sentry SDK (optional) |
105
+
106
+ ## Features
107
+
108
+ - **Course submission** with auto-parsed course codes (semester, department, credits extracted from the code itself)
109
+ - **AI refinement** that preserves all syllabus topics, only cleans and structures content
110
+ - **Full curriculum PDFs** in PES University's official A4 format with letterhead, summary tables, and course details
111
+ - **Agentic Editor** with AI assistant (SSE streaming, 35 tools, draft review, file attachments)
112
+ - **Reviewable drafts** - the agent never auto-applies changes; every edit goes through human review
113
+ - **Agent retry with fallback model** (Fibonacci backoff on 502/503, automatic model switch)
114
+ - **Chat persistence** (messages, tool calls, and tool results saved to database across sessions)
115
+ - **Dynamic specialization management** (DB-driven tracks, not hardcoded)
116
+ - **Version snapshots** with restore, revision history, and version-vs-version comparison
117
+ - **Course visibility toggle** and credit-based sorting
118
+ - **Dual cache layer** (Redis + in-memory, lazy invalidation)
119
+ - **Authentication** via Supabase Auth (JWT)
120
+ - **35 agent tools** for reading, writing, and managing curriculum data
121
+ - **49 API endpoints** across 9 route files
122
+
123
+ ## Quick Start
124
+
125
+ ```bash
126
+ python3 -m venv .venv
127
+ source .venv/bin/activate
128
+ pip install -r requirements.txt
129
+ cd backend && fastapi dev app/main.py
130
+ ```
131
+
132
+ Server at `http://127.0.0.1:8000`. API under `/api`. Frontend served from `frontend/`.
133
+
134
+ ```bash
135
+ source .venv/bin/activate
136
+ pytest # 229 tests
137
+ python -m compileall backend/app # also runs in CI
138
+ ```
139
+
140
+ ## Documentation
141
+
142
+ Full documentation is available on the **[Docs page](https://syntagma.lonelyguy.tech/docs/)** within the app, covering:
143
+
144
+ - System architecture and data flow
145
+ - Submission pipeline, refinement, preview, specializations, agent system, versioning
146
+ - All 49 API endpoints with request/response schemas
147
+ - Database schema, 12 tables, status lifecycles
148
+ - Environment variables (required and optional)
149
+ - Deployment (Docker, Vercel, HF Spaces, CI/CD)
150
+ - All 35 agent tools with descriptions
151
+
152
+ ## Project Structure
153
+
154
+ ```
155
+ backend/ FastAPI (Python) ASGI entrypoint at app/main.py
156
+ frontend/ Vanilla HTML/CSS/JS, no build step
157
+ tests/ 29 pytest files (229 tests)
158
+ docs/ Markdown docs source (rendered as frontend surface)
159
+ ```
160
+
161
+ ## Screenshots
162
+
163
+ ### Sign In
164
+
165
+ ![Sign In - email and password authentication via Supabase Auth](assets/images/auth_page.png)
166
+
167
+ ### Dashboard
168
+
169
+ ![Dashboard - navigation hub linking to all surfaces](assets/images/home_page.png)
170
+
171
+ ### Course Submission
172
+
173
+ ![Course Submission - faculty enter course code, title, content, and references](assets/images/submit_course_page.png)
174
+
175
+ ### Courses Management
176
+
177
+ ![Courses Default - filterable table with semester, code, title, credits, and visibility toggle](assets/images/courses_default.png)
178
+
179
+ ![Courses with Filter and Visibility Toggle - semester and visibility filters applied](assets/images/courses_filtered_visible_toggle.png)
180
+
181
+ ![Delete Confirmation - confirmation dialog before archiving a course](assets/images/courses_delete_modal.png)
182
+
183
+ ### PDF Preview
184
+
185
+ ![Full Document Preview - complete curriculum rendered as a multi-page PDF in the browser](assets/images/preview_full_doc.png)
186
+
187
+ ![Semester Preview - single semester PDF with summary tables and course details](assets/images/preview_full_sem.png)
188
+
189
+ ![Single Course Preview - individual course page with syllabus, textbooks, and outcomes](assets/images/preview_single_course.png)
190
+
191
+ ### Agentic Editor
192
+
193
+ ![Agentic Editor - AI assistant chat, JSON fields editor, and draft review in a three-tab side panel](assets/images/editor_sample_annotated.png)
194
+
195
+ ![Agentic Editor Single Course - course preview with agent chat and version controls](assets/images/editor_single_course.png)
196
+
197
+ ### Version History
198
+
199
+ ![Versions Default - sidebar list of curriculum snapshots with expand/collapse groups](assets/images/versions_default.png)
200
+
201
+ ![Version Comparison - side-by-side diff between two version snapshots](assets/images/versions_annotated_comparision.png)
202
+
203
+ ![Version Rename - inline rename form for version names and categories](assets/images/versions_rename_category.png)
204
+
205
+ ![Current Document Version - live curriculum state shown as the "Current" entry](assets/images/versions_current_document.png)
backend/app/__init__.py ADDED
File without changes
backend/app/_logo_data.py ADDED
@@ -0,0 +1 @@
 
 
1
+ PES_LOGO_DATA_URI = "data:image/png;base64,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"
backend/app/api.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+
3
+ from app.routes import agent, chat, courses, health, preview, refined, submissions, versions
4
+
5
+ router = APIRouter()
6
+
7
+ for route in (health, submissions, preview, refined, agent, chat, versions, courses):
8
+ router.include_router(route.router)
backend/app/cache.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ import pickle
4
+ import time
5
+ import threading
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ DEFAULT_TTL = 60
10
+ _MAX_MEMORY_ENTRIES = 500
11
+ _REDIS_RETRY_COOLDOWN = 60
12
+
13
+ _redis_client = None
14
+ _redis_initAttempted = False
15
+ _redis_lastFail = 0.0
16
+ _lock = threading.Lock()
17
+ _memory: dict[str, tuple[float, int, object]] = {}
18
+
19
+
20
+ def _get_redis():
21
+ global _redis_client, _redis_initAttempted, _redis_lastFail
22
+ if _redis_client is not None:
23
+ return _redis_client
24
+ if _redis_initAttempted:
25
+ if time.monotonic() - _redis_lastFail < _REDIS_RETRY_COOLDOWN:
26
+ return None
27
+ _redis_initAttempted = False
28
+ url = os.getenv("REDIS_URL", "").strip()
29
+ if not url:
30
+ _redis_initAttempted = True
31
+ return None
32
+ try:
33
+ import redis as _redis
34
+ pool = _redis.ConnectionPool.from_url(
35
+ url,
36
+ decode_responses=False,
37
+ max_connections=10,
38
+ socket_connect_timeout=5,
39
+ socket_timeout=10,
40
+ )
41
+ client = _redis.Redis(connection_pool=pool)
42
+ client.ping()
43
+ _redis_client = client
44
+ _redis_initAttempted = True
45
+ logger.info("Redis connected to %s", url.split("@")[-1] if "@" in url else url)
46
+ return _redis_client
47
+ except Exception:
48
+ logger.exception("Redis connection failed, falling back to in-memory cache")
49
+ _redis_client = None
50
+ _redis_initAttempted = True
51
+ _redis_lastFail = time.monotonic()
52
+ return None
53
+
54
+
55
+ def _prune_memory():
56
+ now = time.monotonic()
57
+ expired = [k for k, (ts, ttl, _) in _memory.items() if now - ts > ttl]
58
+ for k in expired:
59
+ del _memory[k]
60
+ if len(_memory) > _MAX_MEMORY_ENTRIES:
61
+ oldest = sorted(_memory, key=lambda k: _memory[k][0])[:len(_memory) - _MAX_MEMORY_ENTRIES]
62
+ for k in oldest:
63
+ del _memory[k]
64
+
65
+
66
+ def get(key: str) -> object | None:
67
+ r = _get_redis()
68
+ if r is not None:
69
+ try:
70
+ raw = r.get(key)
71
+ if raw is None:
72
+ return None
73
+ return pickle.loads(raw)
74
+ except Exception:
75
+ logger.debug("Redis GET failed for key=%s", key)
76
+ with _lock:
77
+ entry = _memory.get(key)
78
+ if entry is None:
79
+ return None
80
+ ts, ttl, value = entry
81
+ if time.monotonic() - ts > ttl:
82
+ del _memory[key]
83
+ return None
84
+ return value
85
+
86
+
87
+ def put(key: str, value: object, ttl: int = DEFAULT_TTL) -> None:
88
+ r = _get_redis()
89
+ if r is not None:
90
+ try:
91
+ r.setex(key, ttl, pickle.dumps(value))
92
+ return
93
+ except Exception:
94
+ logger.debug("Redis SET failed for key=%s", key)
95
+ with _lock:
96
+ _memory[key] = (time.monotonic(), ttl, value)
97
+ if len(_memory) > _MAX_MEMORY_ENTRIES + 50:
98
+ _prune_memory()
99
+
100
+
101
+ def invalidate(prefix: str = "") -> None:
102
+ r = _get_redis()
103
+ if r is not None:
104
+ try:
105
+ if not prefix:
106
+ r.flushdb()
107
+ return
108
+ cursor = 0
109
+ while True:
110
+ cursor, keys = r.scan(cursor, match=f"{prefix}*", count=100)
111
+ if keys:
112
+ r.delete(*keys)
113
+ if cursor == 0:
114
+ break
115
+ return
116
+ except Exception:
117
+ logger.debug("Redis INVALIDATE failed for prefix=%s", prefix)
118
+ with _lock:
119
+ if not prefix:
120
+ _memory.clear()
121
+ return
122
+ keys = [k for k in _memory if k.startswith(prefix)]
123
+ for k in keys:
124
+ del _memory[k]
125
+
126
+
127
+ def close() -> None:
128
+ global _redis_client
129
+ if _redis_client is not None:
130
+ try:
131
+ _redis_client.close()
132
+ except Exception:
133
+ pass
134
+ _redis_client = None
backend/app/main.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from collections.abc import AsyncIterator
3
+ from contextlib import asynccontextmanager
4
+ from pathlib import Path
5
+
6
+ import sentry_sdk
7
+ from fastapi import FastAPI
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from fastapi.staticfiles import StaticFiles
10
+ from dotenv import load_dotenv
11
+
12
+ from app.api import router
13
+ from app.routes.auth import router as auth_router
14
+
15
+ load_dotenv(Path(__file__).resolve().parents[2] / ".env")
16
+
17
+ sentry_dsn = os.getenv("SENTRY_DSN", "").strip()
18
+ if sentry_dsn:
19
+ sentry_config = {
20
+ "dsn": sentry_dsn,
21
+ "environment": os.getenv("SENTRY_ENVIRONMENT", "").strip() or "production",
22
+ }
23
+ sentry_release = os.getenv("SENTRY_RELEASE", "").strip()
24
+ if sentry_release:
25
+ sentry_config["release"] = sentry_release
26
+ sentry_sdk.init(**sentry_config)
27
+
28
+
29
+ def frontend_directory():
30
+ app_root = Path(__file__).resolve().parents[1]
31
+ candidates = (
32
+ app_root / "frontend",
33
+ app_root.parent / "frontend",
34
+ app_root.parent.parent / "frontend",
35
+ Path("/frontend"),
36
+ )
37
+ return next((path for path in candidates if path.exists()), None)
38
+
39
+
40
+ @asynccontextmanager
41
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
42
+ from app.routes.preview import preload_pdfs
43
+ preload_pdfs()
44
+ _prewarm_cache()
45
+ yield
46
+ from app import cache
47
+ cache.close()
48
+
49
+
50
+ def _prewarm_cache():
51
+ """Warm frequently-read caches at startup to avoid cold-start latency."""
52
+ import logging
53
+ import threading
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+ def _worker():
58
+ try:
59
+ from app import cache
60
+ from app.supabase import supabase
61
+
62
+ rows = supabase.table("refined_submissions").select("id,status").in_("status", ["refined"]).eq("visible", True).order("id").execute().data
63
+ ids = [row["id"] for row in rows]
64
+ cache.put("all_course_ids", ids, ttl=300)
65
+ logger.info("Cache prewarm complete: %d courses", len(ids))
66
+ except Exception:
67
+ logger.debug("Cache prewarm failed", exc_info=True)
68
+
69
+ threading.Thread(target=_worker, daemon=True, name="cache-prewarm").start()
70
+
71
+
72
+ app = FastAPI(title="Syntagma", lifespan=lifespan)
73
+
74
+ app.add_middleware(
75
+ CORSMiddleware,
76
+ allow_origins=["*"],
77
+ allow_methods=["*"],
78
+ allow_headers=["*"],
79
+ )
80
+
81
+ app.include_router(router, prefix="/api")
82
+ app.include_router(auth_router, prefix="/api")
83
+
84
+ frontend_dir = frontend_directory()
85
+ if not frontend_dir:
86
+ raise RuntimeError("Frontend directory not found")
87
+ app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend")
backend/app/models/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
backend/app/models/agent.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class AgentDraftPayload(BaseModel):
5
+ refined_id: int
6
+ fields: dict
7
+ reason: str = ""
8
+
9
+
10
+ class AgentDocumentCoursePayload(BaseModel):
11
+ refined_id: int
12
+ fields: dict
13
+
14
+
15
+ class AgentDocumentDraftPayload(BaseModel):
16
+ courses: list[AgentDocumentCoursePayload] = Field(min_length=1)
17
+ reason: str = ""
18
+ curriculum_version_id: int | None = None
19
+ uploaded_document_id: str = ""
20
+
21
+
22
+ class AgentToolPayload(BaseModel):
23
+ arguments: dict = Field(default_factory=dict)
backend/app/models/chat.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field, field_validator
2
+
3
+
4
+ class ChatSessionPayload(BaseModel):
5
+ refined_id: int | None = None
6
+ document_draft_id: int | None = None
7
+ title: str = ""
8
+
9
+
10
+ class ChatSessionTitlePayload(BaseModel):
11
+ title: str = Field(min_length=1, max_length=120)
12
+
13
+ @field_validator("title", mode="before")
14
+ @classmethod
15
+ def strip_title(cls, value):
16
+ return value.strip() if isinstance(value, str) else value
17
+
18
+
19
+ class ChatMessagePayload(BaseModel):
20
+ content: str = ""
21
+ metadata: dict = Field(default_factory=dict)
22
+
23
+ @field_validator("content", mode="before")
24
+ @classmethod
25
+ def strip_content(cls, value):
26
+ return value.strip() if isinstance(value, str) else value
backend/app/models/submission.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from pydantic import BaseModel, Field, field_validator
3
+
4
+
5
+ class ParsedCourseCode(BaseModel):
6
+ year: str
7
+ dept: str
8
+ semester: str
9
+ offering_dept: str
10
+ target_dept: str
11
+ credit_category: str
12
+ is_lateral: bool
13
+ base_code: str
14
+
15
+
16
+ def parse_course_code(code: str) -> ParsedCourseCode:
17
+ """Parse PESU course code like UE25CS242B into components."""
18
+ code = code.strip().upper().replace(" ", "")
19
+
20
+ if not code.startswith("UE"):
21
+ raise ValueError("Course code must start with 'UE'")
22
+
23
+ # UE + year (2 digits) + dept (2 chars) + number (3 digits) + suffix
24
+ # UE25CS242B -> year=25, dept=CS, num=242, suffix=B
25
+ import re
26
+ match = re.match(r"^UE(\d{2})([A-Z]{2})(\d{3})([A-Z\*]+)$", code)
27
+ if not match:
28
+ raise ValueError(f"Invalid course code format: {code}")
29
+
30
+ year, dept, num_str, suffix = match.groups()
31
+ num = int(num_str)
32
+
33
+ # Number format: [semester_group][credits][sequence]
34
+ # e.g. 151 -> group=1, credits=5, seq=1
35
+ # e.g. 242 -> group=2, credits=4, seq=2
36
+ semester_group = num // 100
37
+ credits_digit = (num // 10) % 10
38
+ credit_category = str(credits_digit) if credits_digit in (0, 2, 4, 5) else "4"
39
+
40
+ # Semester: each group covers 2 semesters.
41
+ # A/B suffix determines odd/even within the group.
42
+ is_even = suffix.startswith("B") or suffix.endswith("B") or suffix in ("XX", "XB")
43
+ semester = str(semester_group * 2 - 1 + (1 if is_even else 0))
44
+
45
+ # Map department to offering_department
46
+ dept_map = {
47
+ "CS": "CS",
48
+ "EC": "CS",
49
+ "EE": "CS",
50
+ "ME": "CS",
51
+ "BT": "CS",
52
+ "AI": "CS",
53
+ "ML": "CS",
54
+ "MA": "MA",
55
+ "PH": "MA",
56
+ "CH": "MA",
57
+ "HU": "UZ",
58
+ "UZ": "UZ",
59
+ }
60
+ offering_dept = dept_map.get(dept, "CS")
61
+
62
+ # Target department is based on program (all map to CSE for CS dept courses)
63
+ target_map = {
64
+ "CS": "CSE",
65
+ "EC": "ECE",
66
+ "EE": "EEE",
67
+ "ME": "ME",
68
+ "BT": "BT",
69
+ "AI": "AIML",
70
+ "ML": "AIML",
71
+ }
72
+ target_dept = target_map.get(dept, "CSE")
73
+
74
+ is_lateral = "*" in suffix
75
+
76
+ return ParsedCourseCode(
77
+ year=year,
78
+ dept=dept,
79
+ semester=semester,
80
+ offering_dept=offering_dept,
81
+ target_dept=target_dept,
82
+ credit_category=credit_category,
83
+ is_lateral=is_lateral,
84
+ base_code=code,
85
+ )
86
+
87
+
88
+ class CourseSubmission(BaseModel):
89
+ faculty_email: str = Field(default="", max_length=254)
90
+ course_title: str = Field(min_length=3, max_length=150)
91
+ course_code: str = Field(min_length=8, max_length=12) # e.g., UE25CS242B
92
+ raw_course_content: str = Field(min_length=50)
93
+ text_books: str = Field(min_length=5)
94
+ reference_books: str = ""
95
+ preferred_tools: str = ""
96
+ credit_category: str = ""
97
+
98
+ @field_validator("faculty_email", "course_title", "course_code", "raw_course_content", "text_books", "reference_books", "preferred_tools", "credit_category", mode="before")
99
+ @classmethod
100
+ def strip(cls, value):
101
+ return value.strip() if isinstance(value, str) else value
backend/app/preview.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+ from app.services.books import parse_books
4
+ from app.services.deterministic import compute_course_type, compute_hours, compute_program
5
+ from app.supabase import supabase
6
+
7
+
8
+ def _lines(value) -> list[str]:
9
+ if not value:
10
+ return []
11
+ if isinstance(value, list):
12
+ return [str(item).strip() for item in value if str(item).strip()]
13
+ return [line.strip() for line in str(value).splitlines() if line.strip()]
14
+
15
+
16
+ def _content(row: dict) -> dict:
17
+ raw = row.get("refined_content") or {}
18
+ if isinstance(raw, dict):
19
+ return raw
20
+ if isinstance(raw, str) and raw.strip():
21
+ return json.loads(raw)
22
+ return {}
23
+
24
+
25
+ def _text(*values) -> str:
26
+ for value in values:
27
+ if isinstance(value, str):
28
+ text = value.strip()
29
+ if text and text != "-":
30
+ return text
31
+ return ""
32
+
33
+
34
+ def build_course_preview(row: dict) -> dict:
35
+ content = _content(row)
36
+ submission = row.get("_submission") or {}
37
+ credit_category = str(submission.get("credit_category") or row.get("credit_category") or content.get("credits") or "")
38
+ target_department = str(submission.get("target_department") or "")
39
+
40
+ hours = {
41
+ "lecture_hours": row.get("lecture_hours", content.get("lecture_hours", 0)),
42
+ "tutorial_hours": row.get("tutorial_hours", content.get("tutorial_hours", 0)),
43
+ "practical_hours": row.get("practical_hours", content.get("practical_hours", 0)),
44
+ "self_study": row.get("self_study", content.get("self_study", 0)),
45
+ "credits": row.get("credits", content.get("credits", 0)),
46
+ }
47
+ if credit_category and not any(hours.values()):
48
+ hours = compute_hours(credit_category)
49
+
50
+ program = row.get("program") or content.get("program") or ""
51
+ if target_department and not program:
52
+ program = compute_program(target_department)
53
+
54
+ course_type = row.get("course_type") or content.get("course_type") or ""
55
+ if credit_category and not course_type:
56
+ course_type = compute_course_type(credit_category)
57
+
58
+ practical_hours = int(hours["practical_hours"] or 0)
59
+ status = str(row.get("status") or content.get("status") or "")
60
+
61
+ objectives = _lines(row.get("objectives") or content.get("objectives"))[:4]
62
+ course_outcomes = _lines(row.get("course_outcomes") or content.get("course_outcomes"))[:4]
63
+ units = row.get("units") or content.get("units") or []
64
+ lab_experiments = _lines(row.get("lab_experiments") or content.get("lab_experiments"))[:10] if practical_hours else []
65
+ text_books = parse_books(row.get("text_books") or content.get("text_books")) or parse_books(submission.get("text_books"))
66
+ reference_books = parse_books(row.get("reference_books") or content.get("reference_books")) or parse_books(submission.get("reference_books"))
67
+ has_content = any([objectives, course_outcomes, units, lab_experiments, text_books, reference_books])
68
+ return {
69
+ "refined_id": row.get("id"),
70
+ "course_code": str(row.get("course_code") or content.get("course_code") or ""),
71
+ "course_title": str(row.get("course_title") or content.get("course_title") or ""),
72
+ "program": str(program),
73
+ "lecture_hours": str(hours["lecture_hours"]),
74
+ "tutorial_hours": str(hours["tutorial_hours"]),
75
+ "practical_hours": str(practical_hours),
76
+ "self_study": str(hours["self_study"]),
77
+ "credits": str(hours["credits"]),
78
+ "semester": str(row.get("semester") or content.get("semester") or ""),
79
+ "course_type": str(course_type),
80
+ "is_elective": bool(row.get("is_elective") or content.get("is_elective") or False),
81
+ "status": status,
82
+ "render_detail": status != "summary_only" and has_content,
83
+ "tools_languages": _text(row.get("tools_languages"), content.get("tools_languages"), submission.get("preferred_tools")),
84
+ "desirable_knowledge": _text(row.get("desirable_knowledge"), content.get("desirable_knowledge")),
85
+ "prelude": row.get("prelude") or content.get("prelude") or "",
86
+ "objectives": objectives,
87
+ "course_outcomes": course_outcomes,
88
+ "units": units,
89
+ "lab_experiments": lab_experiments,
90
+ "text_books": text_books,
91
+ "reference_books": reference_books,
92
+ }
93
+
94
+
95
+ def build_specialization_context(academic_year: str = "") -> dict:
96
+ """Load specialization tracks and their elective assignments for template rendering.
97
+
98
+ academic_year is accepted for forward compatibility (multiple batches) but the
99
+ current single-batch deployment returns every track. Filtering by an empty or
100
+ mismatched year would hide all tracks, so we load them all and let the caller
101
+ scope by semester inside the template.
102
+ """
103
+ specs = supabase.table("specialization_definitions").select("*").order("semester").order("letter").execute().data
104
+ assignments = supabase.table("course_specialization_assignments").select("*").execute().data
105
+ return {
106
+ "specializations": specs,
107
+ "specialization_assignments": assignments,
108
+ }
backend/app/rendering.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import re
3
+ from pathlib import Path
4
+
5
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
6
+ from jinja2.ext import do
7
+ from markupsafe import Markup, escape
8
+
9
+ APP_DIR = Path(__file__).resolve().parent
10
+
11
+
12
+ def _find_frontend_dir() -> Path:
13
+ app_root = APP_DIR.parent
14
+ candidates = (
15
+ app_root / "frontend",
16
+ app_root.parent / "frontend",
17
+ app_root.parent.parent / "frontend",
18
+ Path("/frontend"),
19
+ )
20
+ result = next((p for p in candidates if p.exists()), None)
21
+ if result is None:
22
+ raise RuntimeError("Frontend directory not found; cannot resolve image assets for PDF rendering")
23
+ return result
24
+
25
+
26
+ def _load_pes_logo() -> str:
27
+ frontend = _find_frontend_dir()
28
+ logo_path = frontend / "images" / "image2.png"
29
+ if logo_path.exists():
30
+ b64 = base64.b64encode(logo_path.read_bytes()).decode()
31
+ return f"data:image/png;base64,{b64}"
32
+ try:
33
+ from app._logo_data import PES_LOGO_DATA_URI
34
+ return PES_LOGO_DATA_URI
35
+ except ImportError:
36
+ return ""
37
+
38
+
39
+ FRONTEND_DIR = _find_frontend_dir()
40
+ templates = Environment(loader=FileSystemLoader(APP_DIR / "templates"), autoescape=select_autoescape(["html", "xml"]), extensions=[do])
41
+ URL_RE = re.compile(r"https?://[^\s<>()]+")
42
+ YEAR_RE = re.compile(r"\d{4}")
43
+
44
+
45
+ def linkify(value: str) -> Markup:
46
+ text = str(value or "")
47
+ parts = []
48
+ last = 0
49
+ for match in URL_RE.finditer(text):
50
+ raw_url = match.group(0)
51
+ url = raw_url.rstrip(".,;:)]}")
52
+ trailing = raw_url[len(url) :]
53
+ safe_url = escape(url)
54
+ parts.append(escape(text[last : match.start()]))
55
+ parts.append(Markup(f'<a class="resource-link" href="{safe_url}" target="_blank" rel="noopener noreferrer">{safe_url}</a>'))
56
+ parts.append(escape(trailing))
57
+ last = match.end()
58
+ parts.append(escape(text[last:]))
59
+ return Markup("".join(str(part) for part in parts))
60
+
61
+
62
+ def batch_start_year(semester, curriculum_year: str) -> int | None:
63
+ match = YEAR_RE.search(str(curriculum_year or ""))
64
+ if not match:
65
+ return None
66
+ try:
67
+ sem = int(semester)
68
+ except (TypeError, ValueError):
69
+ return None
70
+ return int(match.group()) - ((sem - 1) // 2)
71
+
72
+
73
+ def batch_label(semester, curriculum_year: str) -> str:
74
+ start = batch_start_year(semester, curriculum_year)
75
+ return f"({start}-{(start + 4) % 100:02d} BATCH)" if start else ""
76
+
77
+
78
+ def course_code_for_year(value: str, semester, curriculum_year: str) -> str:
79
+ code = str(value or "")
80
+ start = batch_start_year(semester, curriculum_year)
81
+ if not start or len(code) < 5 or code[:2] not in {"UE", "UZ"} or not code[2:4].isdigit():
82
+ return code
83
+ return f"{code[:2]}{start % 100:02d}{code[4:]}"
84
+
85
+
86
+ templates.filters["linkify"] = linkify
87
+ templates.filters["course_code_for_year"] = course_code_for_year
88
+ templates.globals["batch_label"] = batch_label
89
+
90
+ SEMESTER_NAMES = {
91
+ "1": "I",
92
+ "2": "II",
93
+ "3": "III",
94
+ "4": "IV",
95
+ "5": "V",
96
+ "6": "VI",
97
+ "7": "VII",
98
+ "8": "VIII",
99
+ }
100
+ templates.globals["SEMESTER_NAMES"] = SEMESTER_NAMES
101
+
102
+ templates.globals["pes_logo"] = _load_pes_logo()
backend/app/routes/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
backend/app/routes/agent.py ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+
3
+ from fastapi import APIRouter, HTTPException, Query
4
+ from fastapi.responses import HTMLResponse
5
+ from postgrest.exceptions import APIError
6
+
7
+ from app.models.agent import AgentDocumentDraftPayload, AgentDraftPayload, AgentToolPayload
8
+ from app.preview import build_course_preview
9
+ from app.rendering import templates
10
+ from app.services.agent_tools import call_tool, list_tool_schemas
11
+ from app.services.curriculum import create_version_snapshot, draft_record, load_agent_draft, load_document_draft, selected_curriculum_year, update_refined_fields
12
+ from app.services.diffing import build_course_diff, diff_course
13
+ from app.services.errors import database_http_exception
14
+ from app.supabase import supabase
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ router = APIRouter()
19
+
20
+
21
+ @router.post("/agent/diff")
22
+ def compare_course(payload: dict):
23
+ current = payload.get("current")
24
+ proposed = payload.get("proposed")
25
+ if not isinstance(current, dict) or not isinstance(proposed, dict):
26
+ raise HTTPException(status_code=400, detail="current and proposed are required")
27
+ return diff_course(current, proposed)
28
+
29
+
30
+ @router.post("/agent/drafts")
31
+ def create_agent_draft(payload: AgentDraftPayload):
32
+ try:
33
+ record = draft_record(payload.refined_id, payload.fields, payload.reason)
34
+ result = supabase.table("agent_drafts").insert(record).execute()
35
+ except LookupError as exc:
36
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
37
+ except APIError as exc:
38
+ raise database_http_exception(exc) from exc
39
+ return {"message": "Draft created", "draft": result.data[0]}
40
+
41
+
42
+ @router.get("/agent/drafts")
43
+ def list_agent_drafts():
44
+ try:
45
+ rows = supabase.table("agent_drafts").select("*").order("id", desc=True).limit(100).execute().data
46
+ except APIError as exc:
47
+ raise database_http_exception(exc) from exc
48
+ return {
49
+ "drafts": [
50
+ {
51
+ "id": row["id"],
52
+ "refined_id": row["refined_id"],
53
+ "status": row.get("status") or "",
54
+ "course_title": (row.get("proposed_json") or {}).get("course_title") or "",
55
+ "course_code": (row.get("proposed_json") or {}).get("course_code") or "",
56
+ "change_reason": row.get("change_reason") or "",
57
+ "created_at": row.get("created_at") or "",
58
+ }
59
+ for row in rows
60
+ ]
61
+ }
62
+
63
+
64
+ @router.get("/agent/drafts/{draft_id}")
65
+ def get_agent_draft(draft_id: int):
66
+ try:
67
+ return {"draft": load_agent_draft(draft_id)}
68
+ except LookupError as exc:
69
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
70
+ except APIError as exc:
71
+ raise database_http_exception(exc) from exc
72
+
73
+
74
+ @router.get("/agent/drafts/{draft_id}/preview")
75
+ def preview_agent_draft(draft_id: int, diff: bool = False, curriculum_year: str | None = Query(None)):
76
+ try:
77
+ draft = load_agent_draft(draft_id)
78
+ except LookupError as exc:
79
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
80
+ except APIError as exc:
81
+ raise database_http_exception(exc) from exc
82
+
83
+ if diff:
84
+ base = dict(draft.get("base_refined_json") or {})
85
+ proposed = dict(draft.get("proposed_json") or {})
86
+ course_diff = build_course_diff(base, proposed)
87
+ html = templates.get_template("jinja_diff.html").render(
88
+ base=base,
89
+ proposed=proposed,
90
+ course_diff=course_diff,
91
+ curriculum_year=selected_curriculum_year(curriculum_year),
92
+ asset_root="/",
93
+ )
94
+ else:
95
+ proposed = build_course_preview(draft["proposed_json"])
96
+ html = templates.get_template("jinja_sample.html").render(course=proposed, curriculum_year=selected_curriculum_year(curriculum_year), asset_root="/", show_thank_you=False)
97
+ return HTMLResponse(html, headers={"Cache-Control": "no-store"})
98
+
99
+
100
+ def _generate_version_name(summary: dict, action: str) -> str:
101
+ """Generate a conventional-commit-style version name from diff summary."""
102
+ parts = []
103
+ if summary.get("syllabus_change_percent", 0) > 0:
104
+ parts.append(f"syllabus:{summary['syllabus_change_percent']}%")
105
+ if summary.get("topics_added"):
106
+ parts.append(f"+{len(summary['topics_added'])} topics")
107
+ if summary.get("topics_removed"):
108
+ parts.append(f"-{len(summary['topics_removed'])} topics")
109
+ if summary.get("protected_changes"):
110
+ parts.append("protected-fields")
111
+ if not parts:
112
+ parts.append("updates")
113
+ change_reason = summary.get("change_reason") or ""
114
+ prefix = action.capitalize()
115
+ if change_reason:
116
+ short = change_reason[:60].strip()
117
+ return f"{prefix}: {short} ({', '.join(parts)})"
118
+ return f"{prefix}: {', '.join(parts)}"
119
+
120
+
121
+ @router.post("/agent/drafts/{draft_id}/apply")
122
+ def apply_agent_draft(draft_id: int):
123
+ try:
124
+ draft = load_agent_draft(draft_id)
125
+ except LookupError as exc:
126
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
127
+ except APIError as exc:
128
+ raise database_http_exception(exc) from exc
129
+
130
+ summary = draft.get("diff_summary") or {}
131
+ if draft.get("status") != "proposed":
132
+ raise HTTPException(status_code=400, detail="Only proposed drafts can be applied")
133
+ if summary.get("protected_changes"):
134
+ raise HTTPException(status_code=400, detail="Draft changes deterministic fields")
135
+
136
+ try:
137
+ supabase.table("course_revision_history").insert(
138
+ {
139
+ "refined_id": draft["refined_id"],
140
+ "agent_draft_id": draft_id,
141
+ "previous_json": draft["base_refined_json"],
142
+ "next_json": draft["proposed_json"],
143
+ "json_patch": draft["json_patch"],
144
+ "diff_summary": summary,
145
+ "change_reason": draft.get("change_reason") or "",
146
+ }
147
+ ).execute()
148
+ data = update_refined_fields(int(draft["refined_id"]), draft["proposed_json"])
149
+ supabase.table("refined_submissions").update({"status": "refined"}).eq("id", int(draft["refined_id"])).execute()
150
+ supabase.table("agent_drafts").update({"status": "applied"}).eq("id", draft_id).execute()
151
+ version_name = _generate_version_name(summary, "apply")
152
+ try:
153
+ version = create_version_snapshot(version_name)
154
+ except Exception:
155
+ logger.exception("Version snapshot failed after draft apply")
156
+ version = None
157
+ except APIError as exc:
158
+ raise database_http_exception(exc) from exc
159
+ return {"message": "Draft applied", "data": data, "version": version}
160
+
161
+
162
+ @router.post("/agent/document-drafts")
163
+ def create_agent_document_draft(payload: AgentDocumentDraftPayload):
164
+ try:
165
+ records = [draft_record(course.refined_id, course.fields, payload.reason) for course in payload.courses]
166
+ except LookupError as exc:
167
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
168
+ except APIError as exc:
169
+ raise database_http_exception(exc) from exc
170
+
171
+ summaries = [record["diff_summary"] for record in records]
172
+ document_summary = {
173
+ "courses_changed": len(records),
174
+ "courses_with_removed_topics": sum(1 for summary in summaries if summary.get("topics_removed")),
175
+ "courses_with_protected_changes": sum(1 for summary in summaries if summary.get("protected_changes")),
176
+ "max_syllabus_change_percent": max((summary.get("syllabus_change_percent") or 0 for summary in summaries), default=0),
177
+ }
178
+ status = "blocked" if document_summary["courses_with_protected_changes"] else "proposed"
179
+
180
+ try:
181
+ document = (
182
+ supabase.table("agent_document_drafts")
183
+ .insert(
184
+ {
185
+ "curriculum_version_id": payload.curriculum_version_id,
186
+ "uploaded_document_id": payload.uploaded_document_id.strip(),
187
+ "diff_summary": document_summary,
188
+ "change_reason": payload.reason.strip(),
189
+ "status": status,
190
+ }
191
+ )
192
+ .execute()
193
+ .data[0]
194
+ )
195
+ for record in records:
196
+ record["document_draft_id"] = document["id"]
197
+ drafts = supabase.table("agent_drafts").insert(records).execute().data
198
+ except APIError as exc:
199
+ raise database_http_exception(exc) from exc
200
+ return {"message": "Document draft created", "document_draft": document, "drafts": drafts}
201
+
202
+
203
+ @router.get("/agent/document-drafts")
204
+ def list_agent_document_drafts():
205
+ try:
206
+ rows = supabase.table("agent_document_drafts").select("*").order("id", desc=True).limit(100).execute().data
207
+ except APIError as exc:
208
+ raise database_http_exception(exc) from exc
209
+ return {
210
+ "document_drafts": [
211
+ {
212
+ "id": row["id"],
213
+ "status": row.get("status") or "",
214
+ "uploaded_document_id": row.get("uploaded_document_id") or "",
215
+ "change_reason": row.get("change_reason") or "",
216
+ "created_at": row.get("created_at") or "",
217
+ }
218
+ for row in rows
219
+ ]
220
+ }
221
+
222
+
223
+ @router.post("/agent/document-drafts/{document_draft_id}/apply")
224
+ def apply_agent_document_draft(document_draft_id: int):
225
+ try:
226
+ result = load_document_draft(document_draft_id)
227
+ except LookupError as exc:
228
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
229
+ except APIError as exc:
230
+ raise database_http_exception(exc) from exc
231
+
232
+ document = result["document_draft"]
233
+ drafts = result["drafts"]
234
+
235
+ if document.get("status") != "proposed":
236
+ raise HTTPException(status_code=400, detail="Only proposed document drafts can be applied")
237
+
238
+ summary = document.get("diff_summary") or {}
239
+ if summary.get("courses_with_protected_changes"):
240
+ raise HTTPException(status_code=400, detail="Document draft contains protected field changes")
241
+
242
+ applied = []
243
+ for draft in drafts:
244
+ if draft.get("status") != "proposed":
245
+ continue
246
+ try:
247
+ supabase.table("course_revision_history").insert(
248
+ {
249
+ "refined_id": draft["refined_id"],
250
+ "agent_draft_id": draft["id"],
251
+ "previous_json": draft["base_refined_json"],
252
+ "next_json": draft["proposed_json"],
253
+ "json_patch": draft["json_patch"],
254
+ "diff_summary": draft["diff_summary"],
255
+ "change_reason": draft.get("change_reason") or "",
256
+ }
257
+ ).execute()
258
+ update_refined_fields(int(draft["refined_id"]), draft["proposed_json"])
259
+ supabase.table("refined_submissions").update({"status": "refined"}).eq("id", int(draft["refined_id"])).execute()
260
+ supabase.table("agent_drafts").update({"status": "applied"}).eq("id", draft["id"]).execute()
261
+ applied.append(draft["id"])
262
+ except APIError as exc:
263
+ raise database_http_exception(exc) from exc
264
+
265
+ supabase.table("agent_document_drafts").update({"status": "applied"}).eq("id", document_draft_id).execute()
266
+ version_name = _generate_version_name(summary, "apply-multi")
267
+ try:
268
+ version = create_version_snapshot(version_name)
269
+ except Exception:
270
+ logger.exception("Version snapshot failed after document draft apply")
271
+ version = None
272
+
273
+ return {"message": f"Applied {len(applied)} drafts", "applied_draft_ids": applied, "version": version}
274
+
275
+
276
+ @router.get("/agent/document-drafts/{document_draft_id}")
277
+ def get_agent_document_draft(document_draft_id: int):
278
+ try:
279
+ return load_document_draft(document_draft_id)
280
+ except LookupError as exc:
281
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
282
+ except APIError as exc:
283
+ raise database_http_exception(exc) from exc
284
+
285
+
286
+ @router.get("/agent/document-drafts/{document_draft_id}/preview")
287
+ def preview_agent_document_draft(document_draft_id: int, diff: bool = False, curriculum_year: str | None = Query(None)):
288
+ try:
289
+ result = load_document_draft(document_draft_id)
290
+ except LookupError as exc:
291
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
292
+ except APIError as exc:
293
+ raise database_http_exception(exc) from exc
294
+ drafts = result["drafts"]
295
+ if not drafts:
296
+ raise HTTPException(status_code=404, detail="Document draft not found")
297
+
298
+ if diff:
299
+ course_diffs = []
300
+ for child in drafts:
301
+ base = dict(child.get("base_refined_json") or {})
302
+ proposed = dict(child.get("proposed_json") or {})
303
+ course_diff = build_course_diff(base, proposed)
304
+ course_diffs.append({"base": base, "proposed": proposed, "course_diff": course_diff})
305
+ html = templates.get_template("jinja_diff.html").render(
306
+ course_diffs=course_diffs,
307
+ curriculum_year=selected_curriculum_year(curriculum_year),
308
+ asset_root="/",
309
+ )
310
+ else:
311
+ courses = sorted(
312
+ (build_course_preview(draft["proposed_json"]) for draft in drafts),
313
+ key=lambda course: (int(course.get("semester") or 0), str(course.get("course_code") or ""), str(course.get("course_title") or "")),
314
+ )
315
+ html = templates.get_template("jinja_sample.html").render(courses=courses, semester="", curriculum_year=selected_curriculum_year(curriculum_year), asset_root="/", show_summaries=True)
316
+ return HTMLResponse(html, headers={"Cache-Control": "no-store"})
317
+
318
+
319
+ @router.get("/agent/tools")
320
+ def get_agent_tools():
321
+ return {"tools": list_tool_schemas()}
322
+
323
+
324
+ @router.get("/agent/context-length")
325
+ def get_context_length():
326
+ from app.services.openrouter import context_length, MODEL
327
+ return {"context_length": context_length(), "model": MODEL}
328
+
329
+
330
+ @router.post("/agent/tools/{tool_name}")
331
+ def run_agent_tool(tool_name: str, payload: AgentToolPayload):
332
+ try:
333
+ return {"name": tool_name, "result": call_tool(tool_name, payload.arguments)}
334
+ except LookupError as exc:
335
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
336
+ except ValueError as exc:
337
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
338
+ except APIError as exc:
339
+ raise database_http_exception(exc) from exc
backend/app/routes/auth.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Request
2
+ from fastapi.responses import JSONResponse
3
+
4
+ router = APIRouter()
5
+
6
+
7
+ @router.post("/auth/logout")
8
+ async def logout():
9
+ return {"message": "Logged out"}
10
+
11
+
12
+ @router.get("/auth/check")
13
+ async def check_auth(request: Request):
14
+ auth_header = request.headers.get("authorization", "")
15
+ if not auth_header.startswith("Bearer "):
16
+ return JSONResponse({"authenticated": False}, status_code=401)
17
+ return {"authenticated": True}
backend/app/routes/chat.py ADDED
@@ -0,0 +1,577 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import json
3
+ import logging
4
+
5
+ import sentry_sdk
6
+ from fastapi import APIRouter, File, HTTPException, UploadFile
7
+ from fastapi.responses import Response, StreamingResponse
8
+ from postgrest.exceptions import APIError
9
+
10
+ from app.models.chat import ChatMessagePayload, ChatSessionPayload, ChatSessionTitlePayload
11
+ from app.services.agent_tools import call_tool, list_tool_schemas
12
+ from app.services.attachments import extract_text
13
+ from app.services.curriculum import load_document_draft, refined_course
14
+ from app.services.errors import database_http_exception
15
+ from app.services.openrouter import OpenRouterError, context_length, stream_chat
16
+ from app.supabase import first_row, supabase
17
+
18
+ router = APIRouter()
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def load_chat_session(session_id: int) -> dict:
23
+ try:
24
+ row = first_row(supabase.table("chat_sessions").select("*").eq("id", session_id))
25
+ except APIError as exc:
26
+ raise database_http_exception(exc) from exc
27
+ if not row:
28
+ raise HTTPException(status_code=404, detail="Chat session not found")
29
+ return row
30
+
31
+
32
+ def chat_messages(session_id: int) -> list[dict]:
33
+ rows = supabase.table("chat_messages").select("*").eq("session_id", session_id).order("id").execute().data
34
+
35
+ if not rows:
36
+ return rows
37
+
38
+ all_ids: list[int] = []
39
+ for row in rows:
40
+ for att in (row.get("metadata") or {}).get("attachments") or []:
41
+ if isinstance(att, dict) and att.get("id"):
42
+ all_ids.append(int(att["id"]))
43
+
44
+ known_ids = set(all_ids)
45
+
46
+ if not known_ids:
47
+ orphan_rows = (
48
+ supabase.table("chat_attachments")
49
+ .select("id,filename,content_type,size_bytes,status,error")
50
+ .eq("session_id", session_id)
51
+ .is_("message_id", "null")
52
+ .execute().data
53
+ )
54
+ orphans = [r for r in orphan_rows if r["id"] not in known_ids]
55
+
56
+ if orphans:
57
+ last_assistant = None
58
+ for row in reversed(rows):
59
+ if row.get("role") == "assistant":
60
+ last_assistant = row
61
+ break
62
+ if last_assistant:
63
+ meta = last_assistant.get("metadata") or {}
64
+ existing = meta.get("attachments") or []
65
+ for o in orphans:
66
+ existing.append({"id": o["id"]})
67
+ all_ids.append(o["id"])
68
+ known_ids.add(o["id"])
69
+ meta["attachments"] = existing
70
+ last_assistant["metadata"] = meta
71
+
72
+ if not all_ids:
73
+ return rows
74
+
75
+ att_rows = (
76
+ supabase.table("chat_attachments")
77
+ .select("id,filename,content_type,size_bytes,status,error")
78
+ .eq("session_id", session_id)
79
+ .in_("id", all_ids)
80
+ .execute()
81
+ .data
82
+ )
83
+ att_map = {str(a["id"]): a for a in att_rows}
84
+
85
+ for row in rows:
86
+ meta = row.get("metadata") or {}
87
+ enriched = []
88
+ for att in meta.get("attachments") or []:
89
+ if isinstance(att, dict) and att.get("id"):
90
+ full = att_map.get(str(att["id"]), {})
91
+ enriched.append({
92
+ "id": att["id"],
93
+ "name": full.get("filename") or "attachment",
94
+ "type": full.get("content_type") or "",
95
+ "size": full.get("size_bytes") or 0,
96
+ "status": full.get("status") or "",
97
+ "error": full.get("error") or "",
98
+ })
99
+ else:
100
+ enriched.append(att)
101
+ meta["attachments"] = enriched
102
+ row["metadata"] = meta
103
+
104
+ return rows
105
+
106
+
107
+ def insert_chat_message(session_id: int, role: str, content: str, metadata: dict | None = None) -> dict:
108
+ return (
109
+ supabase.table("chat_messages")
110
+ .insert({"session_id": session_id, "role": role, "content": content, "metadata": metadata or {}})
111
+ .execute()
112
+ .data[0]
113
+ )
114
+
115
+
116
+ def update_attachment_message(session_id: int, attachment_ids: list[int], message_id: int) -> None:
117
+ if attachment_ids:
118
+ supabase.table("chat_attachments").update({"message_id": message_id}).eq("session_id", session_id).in_("id", attachment_ids).execute()
119
+
120
+
121
+ def attachment_ids(metadata: dict | None) -> list[int]:
122
+ ids = []
123
+ for item in (metadata or {}).get("attachments") or []:
124
+ if isinstance(item, dict) and item.get("id"):
125
+ ids.append(int(item["id"]))
126
+ return ids
127
+
128
+
129
+ def attachment_context(session_id: int, metadata: dict | None) -> str:
130
+ ids = attachment_ids(metadata)
131
+ if not ids:
132
+ return ""
133
+ rows = supabase.table("chat_attachments").select("filename,status,error,extracted_text").eq("session_id", session_id).in_("id", ids).execute().data
134
+ blocks = []
135
+ for row in rows:
136
+ name = row.get("filename") or "attachment"
137
+ status = row.get("status") or ""
138
+ text = str(row.get("extracted_text") or "").strip()
139
+ if text:
140
+ blocks.append(f"Attachment: {name}\n{text}")
141
+ else:
142
+ error = row.get("error") or "No extracted text"
143
+ blocks.append(f"Attachment: {name}\nStatus: {status}. {error}")
144
+ return "\n\n".join(blocks)
145
+
146
+
147
+ def stable_context(value: dict) -> str:
148
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2)
149
+
150
+
151
+ def chat_system_prompt(session: dict) -> str:
152
+ session_id = session.get("id")
153
+ context = ""
154
+ if session.get("refined_id"):
155
+ course = refined_course(int(session["refined_id"]))
156
+ context = stable_context({"active_session_id": session_id, "active_refined_id": session["refined_id"], "active_course": course})
157
+ elif session.get("document_draft_id"):
158
+ context = stable_context({"active_session_id": session_id, **load_document_draft(int(session["document_draft_id"]))})
159
+ else:
160
+ context = stable_context({"active_session_id": session_id})
161
+ return f"""You are the Syntagma live editor assistant.
162
+ Be concise, practical, and specific to the active curriculum data.
163
+ Keep conversations professional and friendly. Do not use em dashes in any output. Use standard hyphens or commas instead.
164
+ Never modify a course title, course code, or other identifying fields unless explicitly asked by the user. Use the exact name and code the user provides. Do not embellish or add words the user did not say.
165
+ When the user asks you to do something (create a course, generate a report, etc.), first send a brief text message acknowledging the request (1-2 sentences like "I'll create that course for you." or "Working on the report now.") before calling any tools. This lets the user know you received their request and are working on it.
166
+ Always respond by calling a tool -- never state limitations or guess. The available tools handle course data, fetching URLs, searching the web, generating spreadsheets, generating reports, and creating drafts.
167
+ Never silently fail. If a tool returns an error, report it to the user. If you finish a chain of tools, always call signal_done with a summary of what was accomplished.
168
+ When you need clarification before proceeding (credit category, semester, course code, elective vs core, specialization track, etc.), call ask_user with your question. The stream ends and the user will respond in their next message. Do NOT guess or assume when the user's intent is ambiguous.
169
+
170
+ Curriculum structure (B.Tech CSE):
171
+ - 8 semesters. Semesters 1-4: foundation and core courses (programming, math, basic sciences). Semesters 5-6: core courses plus elective specialization tracks. Semesters 7-8: advanced electives and capstone project.
172
+ - Credit categories: "5" = Core Course-Lab Integrated (4L 0T 2P 5C), "4" = Core Course (4L 0T 0P 4C), "2" = Core Theory (2L 0T 0P 2C), "0" = Foundation Course (0L 0T 0P 0C).
173
+ - Only credit_category "5" courses have a lab component (lab_experiments). All other courses (categories 4, 2, 0) must have empty lab_experiments.
174
+ - All target departments map to "B. TECH" program.
175
+ - Course code convention: prefix UE (university elective) or UZ (open elective), 2-digit batch year, 2-letter department (CS/MA/AM/etc), 3-digit level, letter suffix for semester parity (A=odd, B=even). Specialization electives have extended suffixes like AAX, BBX. Every course code must be unique across the entire curriculum.
176
+ - Semesters 5-6 have elective specialization tracks (e.g. Machine Intelligence and Data Science, Cybersecurity, etc.). Tracks are labeled with letters (A, B, C...). Each track has a set of elective courses assigned to it.
177
+ - Specialization tables (when listing courses under a specialization track) must only include elective courses (is_elective=true). Core courses and foundation courses must not appear in specialization track tables.
178
+ - Unit hours: every unit must be assigned exactly 14 hours. Do not vary hours across units.
179
+ - Deterministic/protected fields (require update_deterministic_fields to change): program, lecture_hours, tutorial_hours, practical_hours, self_study, credits, course_type. These are auto-computed from credit_category and target_department.
180
+ - Agent-editable fields: course_code, course_title, semester, is_elective, tools_languages, desirable_knowledge, prelude, objectives, course_outcomes, units, lab_experiments, text_books, reference_books.
181
+ - Fields that are lists (objectives, course_outcomes, text_books, reference_books, lab_experiments) are JSON arrays. Send them as ["item1", "item2"] in the tool call.
182
+ - desirable_knowledge should only reference courses that actually exist in the curriculum (use list_courses or get_course_codes to check). For brand-new courses with no prior dependencies, set it to an empty string. Never invent course names for desirable_knowledge.
183
+ - Courses have a status: "draft" (newly created, not yet finalized), "refined" (approved and visible in curriculum), "archived" (hidden).
184
+ - When a course is in "draft" status, you can update it directly with create_refined_course (pass the refined_id). Do not create a separate draft for draft-status courses.
185
+
186
+ Course types and electives:
187
+ - is_elective is a boolean flag separate from credit_category/course_type. A course with credit_category "4" can be either a core course (is_elective=false) or an elective course (is_elective=true).
188
+ - credit_category determines the hours and credits. is_elective determines whether the course appears in specialization track tables.
189
+ - To create an elective: set is_elective=true in create_refined_course. Use a course code with extended suffix (e.g. UE25CS342BA7 for semester 6).
190
+ - To assign an elective to specialization tracks: use assign_elective_to_tracks with the refined_id and specialization_ids.
191
+ - To create a new specialization track: use define_specialization.
192
+ - Electives can exist without being assigned to any track. Assignment to tracks is optional.
193
+
194
+ Syllabus design rules (enforce strictly when creating or modifying courses):
195
+ - Every course must have 3-5 units. Each unit has a title, content (syllabus text), and exactly 14 hours.
196
+ - Every course must have 3-4 course_outcomes that are measurable (use verbs like "Analyze", "Implement", "Design", "Evaluate").
197
+ - Every course must have 3-4 objectives describing what students will learn.
198
+ - text_books: at least 1 textbook reference with author, title, edition, publisher, year.
199
+ - reference_books: optional but recommended. Follow the same format as text_books.
200
+ - Course titles should be concise (under 80 chars) and descriptive.
201
+ - course_outcomes should NOT duplicate objectives. Outcomes are measurable assessments; objectives are learning goals.
202
+ - For credit_category "5": include 4-6 lab_experiments describing hands-on tasks.
203
+ - For credit_category "4" or "2": lab_experiments must be empty.
204
+ - prelude: 2-3 sentences describing the course scope and relevance.
205
+ - desirable_knowledge: list prerequisite courses or skills. Use only course codes that exist in the curriculum.
206
+
207
+ Decision guidelines:
208
+ - Before creating a new course, ALWAYS call list_courses to see all existing course codes. Generate a unique course code that does not appear in the list. The server also validates uniqueness and will reject duplicates.
209
+ - If the user asks to create something new, first check if it already exists using list_courses or get_course_codes. If it does NOT exist, proceed directly to create_refined_course with all required fields. Do NOT stop after just checking -- always complete the full workflow.
210
+ - If modifying an existing course that has status "refined", use create_course_draft (creates a reviewable draft).
211
+ - If modifying a course that has status "draft", use create_refined_course with the refined_id (direct update, no draft needed).
212
+ - When assigning electives to specializations, first list existing specializations with list_specializations, then use assign_elective_to_tracks.
213
+ - When unsure about any parameter (which semester, credit category, course code, elective vs core, etc.), call ask_user to ask the user. Do NOT guess or assume.
214
+ - When generating course content (units, objectives, etc.), follow the syllabus design rules above.
215
+
216
+ Chaining tools: When a user request clearly requires multiple steps (e.g. "export semester 3 to CSV" needs list_courses then batch_read_courses then create_spreadsheet), chain the tools in a single turn. Do not stop after one tool if the task is not yet complete. Stop chaining and respond only when the task is done or you need user input.
217
+ Always call signal_done at the end of a completed task with a concise summary.
218
+
219
+ Read source documents with get_attachment_text, then call create_report to save generated content as a chat attachment.
220
+
221
+ Tool selection for course changes (critical):
222
+ - create_course_draft: For modifying an existing course that has status "refined". Creates a reviewable draft the user must approve. Requires refined_id and a "fields" object containing ONLY the fields to change.
223
+ - update_agent_draft: For modifying a draft you already created (instead of creating a duplicate). Requires the draft_id from a previous create_course_draft call and a "fields" object with the changes. Use this when you need to adjust a draft after reviewing it.
224
+ - create_refined_course: For creating a brand-new course OR updating a course that still has status "draft". Pass all course details as flat arguments. For updates, include the refined_id.
225
+ - create_document_draft: For changes across multiple existing courses.
226
+ - update_deterministic_fields: ONLY for changing protected fields (program, hours, credits, course_type). Creates a blocked draft. Always confirm with the user first.
227
+
228
+ Before creating a course, always call list_courses or batch_read_courses to check if it already exists. If the course_code is not in the list, use create_refined_course. If it exists, use create_course_draft with the refined_id from the list.
229
+ When the user asks what changed, call diff_course_json or read the relevant draft before answering.
230
+ For broad document requests, use get_curriculum_json to inspect the whole syllabus before proposing edits. Prefer filtering by semester to avoid oversized responses.
231
+ For version comparison, call get_version to load a snapshot, then diff_versions to compare two versions.
232
+ For statistics and summaries, call get_curriculum_stats for aggregate data or batch_read_courses to read fields from many courses at once.
233
+ For spreadsheet exports, call batch_read_courses to gather data, then create_spreadsheet to generate CSV or Excel files.
234
+ For specialization management, call list_specializations to discover tracks, define_specialization to create one, and assign_elective_to_tracks / get_course_assignments to categorize electives.
235
+ To fetch a public URL, call fetch_url and use the returned text.
236
+ To search the web for current information, to ground a response in verified facts, or to compare with external documents, call web_search with a query. Always cite the source when using web search results.
237
+ Never apply a draft, never claim a draft was applied, and never claim the refined database was changed.
238
+ After creating a draft, tell the user to review the diff in the Review panel before applying it.
239
+ To change deterministic fields (program, hours, credits, course_type), call update_deterministic_fields. This creates a draft that is blocked until the user explicitly approves it in the Review panel. Confirm with the user before changing these fields.
240
+
241
+ Course data access -- prefer granular tools over full JSON:
242
+ - get_course_codes: lightweight IDs (refined_id, course_code, title, semester) -- use for lists/lookups
243
+ - get_course_syllabus: units, objectives, course_outcomes
244
+ - get_course_textbooks: text_books, reference_books
245
+ - get_course_deterministic: program, hours, credits, course_type (read-only, agent-protected)
246
+ - get_course_lab: lab_experiments, tools_languages
247
+ - get_course_fields: arbitrary specific fields (provide field name list)
248
+ - batch_read_courses: read specific fields from multiple courses in one call (preferred over looping get_course_fields)
249
+ - get_current_course_json: full course JSON -- only use when you truly need everything
250
+ - get_curriculum_json: full curriculum or filtered by semester (ALWAYS pass semester filter to avoid oversized responses)
251
+
252
+ When the user's request is fully addressed (draft created, question answered, report/spreadsheet generated), call signal_done with a concise summary of what was accomplished.
253
+
254
+ Active context:
255
+ {context or "No active course or document draft is selected."}"""
256
+
257
+
258
+ def model_messages(session_id: int, rows: list[dict]) -> tuple[list[dict], dict]:
259
+ messages = []
260
+ for row in rows:
261
+ if row.get("role") not in {"user", "assistant"}:
262
+ continue
263
+ content = str(row.get("content") or "").strip()
264
+ context = attachment_context(session_id, row.get("metadata")) if row.get("role") == "user" else ""
265
+ if context:
266
+ content = f"{content}\n\n{context}".strip()
267
+ if content:
268
+ messages.append({"role": row["role"], "content": content})
269
+
270
+ budget = context_length() * 3
271
+ total = sum(len(m["content"]) for m in messages)
272
+ if total > budget and len(messages) >= 2:
273
+ while total > budget and len(messages) >= 2:
274
+ removed_user = messages.pop(0)
275
+ total -= len(removed_user["content"])
276
+ if messages:
277
+ removed_assistant = messages.pop(0)
278
+ total -= len(removed_assistant["content"])
279
+ messages.insert(0, {
280
+ "role": "user",
281
+ "content": "[Earlier conversation context has been truncated to manage token usage]"
282
+ })
283
+ usage = {"total_chars": total, "budget_chars": budget, "message_count": len(messages), "context_length": context_length()}
284
+ return messages, usage
285
+
286
+
287
+ def sse(event: str, data: dict) -> str:
288
+ return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
289
+
290
+
291
+ @router.post("/chat/sessions")
292
+ def create_chat_session(payload: ChatSessionPayload):
293
+ record = {"refined_id": payload.refined_id, "document_draft_id": payload.document_draft_id, "title": payload.title.strip()}
294
+ result = supabase.table("chat_sessions").insert(record).execute()
295
+ session = result.data[0]
296
+ session_id = session["id"]
297
+
298
+ if payload.refined_id:
299
+ starter = f"👋 Hello! I'm ready to help with course **{payload.title}** (refined_id: {payload.refined_id}). You can ask me to:\n\n- Create a draft with specific changes\n- Explain the current syllabus structure\n- Compare with other courses\n- Fetch textbook details or lab experiments\n\nWhat would you like to do?"
300
+ elif payload.document_draft_id:
301
+ starter = f"👋 Hello! I'm ready to help with document draft **{payload.document_draft_id}**. You can ask me to:\n\n- Review the proposed changes across multiple courses\n- Compare draft versions\n- Generate a summary report\n\nWhat would you like to do?"
302
+ else:
303
+ starter = "👋 Hello! I'm your Syntagma assistant. You can ask me to:\n\n- Browse the curriculum with `get_curriculum_json`\n- Inspect specific courses with granular tools\n- Create drafts for course changes\n- Generate reports and comparisons\n\nWhat would you like to explore?"
304
+
305
+ supabase.table("chat_messages").insert({
306
+ "session_id": session_id,
307
+ "role": "assistant",
308
+ "content": starter,
309
+ "metadata": {"starter": True}
310
+ }).execute()
311
+
312
+ return {"session": session}
313
+
314
+
315
+ @router.get("/chat/sessions")
316
+ def list_chat_sessions(refined_id: int | None = None, document_draft_id: int | None = None):
317
+ query = supabase.table("chat_sessions").select("*").eq("status", "active")
318
+ if refined_id is not None:
319
+ query = query.eq("refined_id", refined_id)
320
+ if document_draft_id is not None:
321
+ query = query.eq("document_draft_id", document_draft_id)
322
+ rows = query.order("id", desc=True).limit(50).execute().data
323
+ return {"sessions": rows}
324
+
325
+
326
+ @router.get("/chat/sessions/{session_id}/messages")
327
+ def get_chat_messages(session_id: int):
328
+ load_chat_session(session_id)
329
+ return {"messages": chat_messages(session_id)}
330
+
331
+
332
+ @router.delete("/chat/sessions/{session_id}")
333
+ def clear_chat_session(session_id: int):
334
+ load_chat_session(session_id)
335
+ supabase.table("chat_sessions").delete().eq("id", session_id).execute()
336
+ return {"message": "Chat deleted"}
337
+
338
+
339
+ @router.patch("/chat/sessions/{session_id}")
340
+ def rename_chat_session(session_id: int, payload: ChatSessionTitlePayload):
341
+ load_chat_session(session_id)
342
+ row = supabase.table("chat_sessions").update({"title": payload.title}).eq("id", session_id).execute().data[0]
343
+ return {"message": "Chat renamed", "session": row}
344
+
345
+
346
+ @router.post("/chat/sessions/{session_id}/messages")
347
+ def create_chat_message(session_id: int, payload: ChatMessagePayload):
348
+ if not payload.content and not payload.metadata:
349
+ raise HTTPException(status_code=400, detail="Message content is required")
350
+ session = load_chat_session(session_id)
351
+ user_message = insert_chat_message(session_id, "user", payload.content, payload.metadata)
352
+ update_attachment_message(session_id, attachment_ids(payload.metadata), user_message["id"])
353
+
354
+ def stream():
355
+ answer = []
356
+ tool_results = []
357
+ report_attachment_ids = []
358
+
359
+ def remember_tool_result(name: str, result: dict) -> None:
360
+ tool_results.append({"name": name, "result": result})
361
+ attachment = (result or {}).get("attachment")
362
+ if attachment and attachment.get("id"):
363
+ report_attachment_ids.append(attachment["id"])
364
+
365
+ def flush_tool_results():
366
+ while tool_results:
367
+ item = tool_results.pop(0)
368
+ result = item["result"] or {}
369
+ draft = result.get("draft")
370
+ document_draft = result.get("document_draft")
371
+ if item["name"] == "create_course_draft" and draft:
372
+ yield sse("draft", {"draft": draft})
373
+ if item["name"] == "create_document_draft" and document_draft:
374
+ yield sse("document_draft", {"document_draft": document_draft})
375
+ if item["name"] == "update_deterministic_fields" and draft:
376
+ yield sse("draft", {"draft": draft})
377
+ if item["name"] == "create_refined_course" and result.get("refined_id"):
378
+ yield sse("refined_course", {"refined_id": result["refined_id"], "updated": result.get("updated", False)})
379
+
380
+ def save_assistant_message():
381
+ content = "".join(answer).strip()
382
+ if not content:
383
+ return None
384
+ metadata = {}
385
+ if report_attachment_ids:
386
+ metadata["attachments"] = [{"id": aid} for aid in report_attachment_ids]
387
+ message = insert_chat_message(session_id, "assistant", content, metadata)
388
+ if report_attachment_ids:
389
+ update_attachment_message(session_id, report_attachment_ids, message["id"])
390
+ return message
391
+
392
+ try:
393
+ yield sse("status", {"message": "Analyzing your request..."})
394
+ rows = chat_messages(session_id)
395
+ system = chat_system_prompt(session)
396
+ msgs, _ = model_messages(session_id, rows)
397
+ for item in stream_chat(system, msgs, list_tool_schemas(), call_tool, remember_tool_result):
398
+ if isinstance(item, dict) and "$status" in item:
399
+ yield sse("status", {"message": item["$status"]})
400
+ continue
401
+ if isinstance(item, dict) and "$usage" in item:
402
+ yield sse("context_usage", item["$usage"])
403
+ continue
404
+ if isinstance(item, dict) and "$event" in item:
405
+ event = item["$event"]
406
+ data = {k: v for k, v in item.items() if k != "$event"}
407
+ if event == "tool_call":
408
+ insert_chat_message(session_id, "tool", f"\u2699 {data.get('name', '')}({json.dumps(data.get('arguments', {}), ensure_ascii=False)})", {"name": data.get("name"), "arguments": data.get("arguments"), "tool_call_type": "call"})
409
+ elif event == "tool_result":
410
+ status_char = "\u2713" if data.get("status") == "ok" else "\u2717"
411
+ insert_chat_message(session_id, "tool", f"{status_char} {data.get('name', '')} completed", {"name": data.get("name"), "status": data.get("status"), "tool_call_type": "result"})
412
+ yield sse(event, data)
413
+ continue
414
+ yield from flush_tool_results()
415
+ answer.append(item)
416
+ yield sse("token", {"text": item})
417
+ yield from flush_tool_results()
418
+ message = save_assistant_message()
419
+ yield sse("done", {"message_id": message["id"] if message else 0})
420
+ except OpenRouterError as exc:
421
+ yield from flush_tool_results()
422
+ save_assistant_message()
423
+ logger.warning(
424
+ "Chat model request failed for session %s: status=%s detail=%s",
425
+ session_id,
426
+ exc.status_code,
427
+ exc.provider_message[:300],
428
+ )
429
+ err = exc.message
430
+ if exc.provider_message:
431
+ err = f"{err} ({exc.provider_message[:200]})"
432
+ yield sse("error", {"message": err})
433
+ except Exception as exc:
434
+ yield from flush_tool_results()
435
+ save_assistant_message()
436
+ logger.exception("Chat stream failed for session %s", session_id)
437
+ sentry_sdk.capture_exception(exc)
438
+ yield sse("error", {"message": "An internal error occurred. Please try again later."})
439
+
440
+ return StreamingResponse(stream(), media_type="text/event-stream", headers={"Cache-Control": "no-store"})
441
+
442
+
443
+ @router.post("/chat/sessions/{session_id}/attachments")
444
+ async def upload_chat_attachments(session_id: int, files: list[UploadFile] = File(...)):
445
+ load_chat_session(session_id)
446
+ attachments = []
447
+ for file in files:
448
+ data = await file.read()
449
+ text, status, error = extract_text(file.filename or "attachment", file.content_type or "", data)
450
+
451
+ # Store binary content as base64 for non-text files
452
+ content_base64 = ""
453
+ if file.content_type and not file.content_type.startswith("text/") and file.content_type != "application/json":
454
+ content_base64 = base64.b64encode(data).decode()
455
+
456
+ row = (
457
+ supabase.table("chat_attachments")
458
+ .insert(
459
+ {
460
+ "session_id": session_id,
461
+ "filename": file.filename or "attachment",
462
+ "content_type": file.content_type or "",
463
+ "size_bytes": len(data),
464
+ "extracted_text": text,
465
+ "content_base64": content_base64,
466
+ "status": status,
467
+ "error": error,
468
+ }
469
+ )
470
+ .execute()
471
+ .data[0]
472
+ )
473
+ attachments.append(
474
+ {
475
+ "id": row["id"],
476
+ "name": row["filename"],
477
+ "type": row["content_type"],
478
+ "size": row["size_bytes"],
479
+ "status": row["status"],
480
+ "error": row["error"],
481
+ "extracted_chars": len(row.get("extracted_text") or ""),
482
+ }
483
+ )
484
+ return {"attachments": attachments}
485
+
486
+
487
+ @router.get("/chat/sessions/{session_id}/attachments/{attachment_id}/download")
488
+ def download_chat_attachment(session_id: int, attachment_id: int):
489
+ load_chat_session(session_id)
490
+ row = supabase.table("chat_attachments").select("*").eq("id", attachment_id).eq("session_id", session_id).execute().data
491
+ if not row:
492
+ raise HTTPException(status_code=404, detail="Attachment not found")
493
+ row = row[0]
494
+
495
+ content = row.get("content_base64")
496
+ if content:
497
+ data = base64.b64decode(content)
498
+ else:
499
+ data = (row.get("extracted_text") or "").encode()
500
+
501
+ return Response(
502
+ content=data,
503
+ media_type=row.get("content_type") or "application/octet-stream",
504
+ headers={"Content-Disposition": f'attachment; filename="{row["filename"]}"', "Cache-Control": "no-store"},
505
+ )
506
+
507
+
508
+ @router.get("/chat/sessions/{session_id}/attachments/{attachment_id}/preview")
509
+ def preview_chat_attachment(session_id: int, attachment_id: int):
510
+ load_chat_session(session_id)
511
+ row = supabase.table("chat_attachments").select("*").eq("id", attachment_id).eq("session_id", session_id).execute().data
512
+ if not row:
513
+ raise HTTPException(status_code=404, detail="Attachment not found")
514
+ row = row[0]
515
+
516
+ content_type = row.get("content_type") or ""
517
+ text = row.get("extracted_text") or ""
518
+ b64 = row.get("content_base64") or ""
519
+ filename = row.get("filename") or ""
520
+
521
+ if content_type in ("text/markdown", "text/csv", "text/plain", "text/html") or content_type.startswith("text/"):
522
+ return Response(
523
+ content=text.encode(),
524
+ media_type=content_type,
525
+ headers={"Content-Disposition": "inline", "Cache-Control": "no-store"},
526
+ )
527
+
528
+ if b64:
529
+ data = base64.b64decode(b64)
530
+ if filename.lower().endswith(".xlsx") and data[:2] == b"PK":
531
+ html = _xlsx_to_html(data)
532
+ return Response(
533
+ content=html.encode(),
534
+ media_type="text/html",
535
+ headers={"Content-Disposition": "inline", "Cache-Control": "no-store"},
536
+ )
537
+ return Response(
538
+ content=data,
539
+ media_type=content_type,
540
+ headers={"Content-Disposition": "inline", "Cache-Control": "no-store"},
541
+ )
542
+
543
+ return Response(
544
+ content=text.encode(),
545
+ media_type="text/plain",
546
+ headers={"Content-Disposition": "inline", "Cache-Control": "no-store"},
547
+ )
548
+
549
+
550
+ def _xlsx_to_html(data: bytes) -> str:
551
+ import io
552
+ from openpyxl import load_workbook
553
+
554
+ wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
555
+ parts = ["<style>table.xlsx{border-collapse:collapse;font-family:system-ui;font-size:13px;width:100%}table.xlsx th{background:#00377b;color:#fff;padding:6px 10px;text-align:left;font-weight:600;border:1px solid #00377b}table.xlsx td{padding:5px 10px;border:1px solid #d1d5db}table.xlsx tr:nth-child(even){background:#f4f4f5}</style>"]
556
+ for ws in wb.worksheets:
557
+ rows = list(ws.iter_rows(values_only=True))
558
+ if not rows:
559
+ continue
560
+ if len(wb.sheetnames) > 1:
561
+ parts.append(f"<h4 style='margin:16px 0 8px;font-family:system-ui'>{ws.title}</h4>")
562
+ parts.append("<table class='xlsx'><thead><tr>")
563
+ for cell in rows[0]:
564
+ parts.append(f"<th>{_esc(str(cell) if cell is not None else '')}</th>")
565
+ parts.append("</tr></thead><tbody>")
566
+ for row in rows[1:]:
567
+ parts.append("<tr>")
568
+ for cell in row:
569
+ parts.append(f"<td>{_esc(str(cell) if cell is not None else '')}</td>")
570
+ parts.append("</tr>")
571
+ parts.append("</tbody></table>")
572
+ wb.close()
573
+ return "\n".join(parts)
574
+
575
+
576
+ def _esc(s: str) -> str:
577
+ return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
backend/app/routes/courses.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+ from postgrest.exceptions import APIError
3
+
4
+ from app import cache
5
+ from app.preview import build_course_preview
6
+ from app.services.curriculum import attach_submissions, invalidate_curriculum_cache
7
+ from app.services.errors import database_http_exception
8
+ from app.supabase import first_row, supabase
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.get("/courses")
14
+ def list_courses():
15
+ cached = cache.get("courses_list")
16
+ if cached is not None:
17
+ return {"courses": cached}
18
+ try:
19
+ rows = supabase.table("refined_submissions").select("*").in_("status", ["refined"]).execute().data
20
+ rows = attach_submissions(rows)
21
+ except APIError as exc:
22
+ raise database_http_exception(exc) from exc
23
+ courses = [build_course_preview(row) | {"id": row["id"], "visible": row.get("visible", True)} for row in rows]
24
+ courses.sort(key=lambda item: (int(item.get("semester") or 0), item.get("course_code") or "", item.get("course_title") or ""))
25
+ cache.put("courses_list", courses, ttl=300)
26
+ return {"courses": courses}
27
+
28
+
29
+ @router.patch("/courses/{refined_id}/visible")
30
+ def set_visibility(refined_id: int, body: dict):
31
+ visible = body.get("visible")
32
+ if not isinstance(visible, bool):
33
+ raise HTTPException(status_code=400, detail="visible must be a boolean")
34
+ try:
35
+ row = first_row(supabase.table("refined_submissions").select("id").eq("id", refined_id).neq("status", "archived"))
36
+ if not row:
37
+ raise HTTPException(status_code=404, detail="Course not found")
38
+ result = supabase.table("refined_submissions").update({"visible": visible}).eq("id", refined_id).execute()
39
+ except APIError as exc:
40
+ raise database_http_exception(exc) from exc
41
+ cache.invalidate("courses_list")
42
+ cache.invalidate("sem_courses:")
43
+ cache.invalidate("pending_courses")
44
+ cache.invalidate("all_course_ids")
45
+ invalidate_curriculum_cache()
46
+ return {"message": "Visibility updated", "course": result.data[0] if result.data else None}
47
+
48
+
49
+ @router.delete("/courses/{refined_id}")
50
+ def delete_course(refined_id: int):
51
+ try:
52
+ row = first_row(supabase.table("refined_submissions").select("id").eq("id", refined_id).neq("status", "archived"))
53
+ if not row:
54
+ raise HTTPException(status_code=404, detail="Course not found")
55
+ result = supabase.table("refined_submissions").update({"status": "archived"}).eq("id", refined_id).execute()
56
+ except APIError as exc:
57
+ raise database_http_exception(exc) from exc
58
+ cache.invalidate("courses_list")
59
+ cache.invalidate("sem_courses:")
60
+ cache.invalidate("pending_courses")
61
+ cache.invalidate("all_course_ids")
62
+ invalidate_curriculum_cache()
63
+ return {"message": "Course removed", "course": result.data[0] if result.data else None}
backend/app/routes/health.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+ from postgrest.exceptions import APIError
3
+
4
+ from app.services.errors import database_http_exception
5
+ from app.services.schema import schema_status
6
+
7
+ router = APIRouter()
8
+
9
+
10
+ @router.get("/health/schema")
11
+ def check_schema():
12
+ try:
13
+ status = schema_status()
14
+ except APIError as exc:
15
+ raise database_http_exception(exc) from exc
16
+ if not status["ok"]:
17
+ raise HTTPException(status_code=503, detail=status)
18
+ return status
backend/app/routes/preview.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ import threading
4
+ import time as _time
5
+
6
+ from fastapi import APIRouter, HTTPException, Query
7
+ from fastapi.responses import HTMLResponse, Response
8
+ from weasyprint import HTML
9
+
10
+ from app import cache
11
+ from app.preview import build_course_preview, build_specialization_context
12
+ from app.rendering import FRONTEND_DIR, course_code_for_year, templates
13
+ from app.services.curriculum import attach_submissions, ordered_courses, selected_curriculum_year
14
+ from app.supabase import first_row, supabase
15
+
16
+ logger = logging.getLogger(__name__)
17
+ router = APIRouter()
18
+
19
+
20
+ @router.get("/preview/semester/{sem}/courses")
21
+ def list_courses(sem: int, curriculum_year: str | None = Query(None)):
22
+ cy = selected_curriculum_year(curriculum_year)
23
+ cache_key = f"sem_courses:{sem}:{cy}"
24
+ cached = cache.get(cache_key)
25
+ if cached is not None:
26
+ return cached
27
+ result = supabase.table("refined_submissions").select("id,status,course_code,course_title,semester").in_("status", ["refined"]).eq("visible", True).eq("semester", sem).order("id").execute()
28
+ ids = [row["id"] for row in result.data]
29
+ courses = [{"id": row["id"], "course_code": course_code_for_year(row.get("course_code") or "", row.get("semester"), cy), "course_title": row.get("course_title") or ""} for row in result.data]
30
+ out = {"course_ids": ids, "courses": courses}
31
+ cache.put(cache_key, out, ttl=300)
32
+ return out
33
+
34
+
35
+ @router.get("/preview/pending-courses")
36
+ def list_pending_courses():
37
+ cache_key = "pending_courses"
38
+ cached = cache.get(cache_key)
39
+ if cached is not None:
40
+ return {"courses": cached}
41
+ result = supabase.table("refined_submissions").select("id,course_code,course_title").eq("status", "draft").eq("visible", True).order("id").execute()
42
+ courses = [{"id": row["id"], "course_code": row.get("course_code") or "", "course_title": row.get("course_title") or ""} for row in result.data]
43
+ cache.put(cache_key, courses, ttl=300)
44
+ return {"courses": courses}
45
+
46
+
47
+ @router.get("/preview/courses")
48
+ def list_all_courses():
49
+ cache_key = "all_course_ids"
50
+ cached = cache.get(cache_key)
51
+ if cached is not None:
52
+ return {"course_ids": cached}
53
+ result = supabase.table("refined_submissions").select("id,semester,course_code").in_("status", ["refined"]).eq("visible", True).execute()
54
+ rows = sorted(result.data, key=lambda row: (int(row.get("semester") or 0), str(row.get("course_code") or ""), int(row.get("id") or 0)))
55
+ ids = [row["id"] for row in rows]
56
+ cache.put(cache_key, ids, ttl=300)
57
+ return {"course_ids": ids}
58
+
59
+
60
+ @router.get("/preview/course/{refined_id}")
61
+ def preview_course(refined_id: int, curriculum_year: str | None = Query(None)):
62
+ cy = selected_curriculum_year(curriculum_year)
63
+ cache_key = f"course_html:{refined_id}:{cy}"
64
+ cached = cache.get(cache_key)
65
+ if cached is not None:
66
+ return HTMLResponse(cached, headers={"Cache-Control": "public, max-age=30, s-maxage=300, stale-while-revalidate=600"})
67
+ try:
68
+ row = first_row(supabase.table("refined_submissions").select("*").eq("id", refined_id))
69
+ if not row:
70
+ raise HTTPException(status_code=404, detail="Refined submission not found")
71
+ row = attach_submissions([row])[0]
72
+ html = templates.get_template("jinja_sample.html").render(
73
+ course=build_course_preview(row),
74
+ curriculum_year=cy,
75
+ asset_root="/",
76
+ show_thank_you=False,
77
+ )
78
+ except HTTPException:
79
+ raise
80
+ except Exception:
81
+ logger.exception("Preview render failed for refined_id=%s", refined_id)
82
+ raise HTTPException(status_code=500, detail="Failed to render course preview.")
83
+ cache.put(cache_key, html, ttl=600)
84
+ return HTMLResponse(html, headers={"Cache-Control": "public, max-age=30, s-maxage=300, stale-while-revalidate=600"})
85
+
86
+
87
+ @router.get("/preview/course/{refined_id}/pdf")
88
+ def download_course_pdf(refined_id: int, download: bool = Query(False), curriculum_year: str | None = Query(None)):
89
+ cy = selected_curriculum_year(curriculum_year)
90
+ cache_key = f"course_pdf:{refined_id}:{cy}"
91
+ cached = cache.get(cache_key)
92
+ if cached is not None:
93
+ return pdf_response(cached, f"course-{refined_id}.pdf", download)
94
+ try:
95
+ row = first_row(supabase.table("refined_submissions").select("*").eq("id", refined_id))
96
+ if not row:
97
+ raise HTTPException(status_code=404, detail="Refined submission not found")
98
+ row = attach_submissions([row])[0]
99
+ html = templates.get_template("jinja_sample.html").render(
100
+ courses=[build_course_preview(row)],
101
+ semester="",
102
+ curriculum_year=cy,
103
+ asset_root="",
104
+ show_summaries=True,
105
+ show_thank_you=False,
106
+ **build_specialization_context(cy),
107
+ )
108
+ pdf = HTML(string=html, base_url=str(FRONTEND_DIR)).write_pdf()
109
+ except HTTPException:
110
+ raise
111
+ except Exception:
112
+ logger.exception("Course PDF render failed for refined_id=%s", refined_id)
113
+ raise HTTPException(status_code=500, detail="Failed to render course PDF.")
114
+ cache.put(cache_key, pdf, ttl=600)
115
+ return pdf_response(pdf, f"course-{refined_id}.pdf", download)
116
+
117
+
118
+ @router.get("/preview/html")
119
+ def preview_all_html(curriculum_year: str | None = Query(None)):
120
+ cy = selected_curriculum_year(curriculum_year)
121
+ cache_key = f"full_html:{cy}"
122
+ cached = cache.get(cache_key)
123
+ if cached is not None:
124
+ return HTMLResponse(cached, headers={"Cache-Control": "public, max-age=30, s-maxage=300, stale-while-revalidate=600"})
125
+ result = supabase.table("refined_submissions").select("*").in_("status", ["refined"]).eq("visible", True).execute()
126
+ courses = ordered_courses(result.data)
127
+ html = templates.get_template("jinja_sample.html").render(
128
+ courses=courses,
129
+ semester="",
130
+ curriculum_year=cy,
131
+ asset_root="/",
132
+ show_summaries=True,
133
+ **build_specialization_context(cy),
134
+ )
135
+ cache.put(cache_key, html, ttl=600)
136
+ return HTMLResponse(html, headers={"Cache-Control": "public, max-age=30, s-maxage=300, stale-while-revalidate=600"})
137
+
138
+
139
+ @router.get("/preview/pdf")
140
+ def download_all_pdf(download: bool = Query(False), curriculum_year: str | None = Query(None)):
141
+ cy = selected_curriculum_year(curriculum_year)
142
+ cache_key = f"full_pdf:{cy}"
143
+ cached = cache.get(cache_key)
144
+ if cached is not None:
145
+ return pdf_response(cached, "curriculum-preview.pdf", download, s_maxage=180)
146
+ result = supabase.table("refined_submissions").select("*").in_("status", ["refined"]).eq("visible", True).execute()
147
+ courses = ordered_courses(result.data)
148
+ html = templates.get_template("jinja_sample.html").render(
149
+ courses=courses,
150
+ semester="",
151
+ curriculum_year=cy,
152
+ asset_root="",
153
+ show_summaries=True,
154
+ **build_specialization_context(cy),
155
+ )
156
+ pdf = HTML(string=html, base_url=str(FRONTEND_DIR)).write_pdf()
157
+ cache.put(cache_key, pdf, ttl=600)
158
+ return pdf_response(pdf, "curriculum-preview.pdf", download, s_maxage=180)
159
+
160
+
161
+ @router.get("/preview/semester/{sem}/pdf")
162
+ def download_pdf(sem: int, download: bool = Query(False), curriculum_year: str | None = Query(None)):
163
+ cy = selected_curriculum_year(curriculum_year)
164
+ cache_key = f"sem_pdf:{sem}:{cy}"
165
+ cached = cache.get(cache_key)
166
+ if cached is not None:
167
+ return pdf_response(cached, f"semester-{sem}.pdf", download)
168
+ result = supabase.table("refined_submissions").select("*").in_("status", ["refined"]).eq("visible", True).eq("semester", sem).order("id").execute()
169
+ courses = ordered_courses(result.data)
170
+ html = templates.get_template("jinja_sample.html").render(
171
+ courses=courses,
172
+ semester=sem,
173
+ curriculum_year=cy,
174
+ asset_root="",
175
+ show_summaries=True,
176
+ **build_specialization_context(cy),
177
+ )
178
+ pdf = HTML(string=html, base_url=str(FRONTEND_DIR)).write_pdf()
179
+ cache.put(cache_key, pdf, ttl=600)
180
+ return pdf_response(pdf, f"semester-{sem}.pdf", download)
181
+
182
+
183
+ def pdf_response(pdf: bytes, filename: str, download: bool, s_maxage: int = 300) -> Response:
184
+ disposition = "attachment" if download else "inline"
185
+ return Response(
186
+ content=pdf,
187
+ media_type="application/pdf",
188
+ headers={
189
+ "Content-Disposition": f'{disposition}; filename="{filename}"',
190
+ "Cache-Control": f"public, max-age=30, s-maxage={s_maxage}, stale-while-revalidate=600",
191
+ },
192
+ )
193
+
194
+
195
+ def _generate_pdf(cy: str) -> bytes | None:
196
+ try:
197
+ result = supabase.table("refined_submissions").select("*").in_("status", ["refined"]).eq("visible", True).execute()
198
+ courses = ordered_courses(result.data)
199
+ html = templates.get_template("jinja_sample.html").render(
200
+ courses=courses,
201
+ semester="",
202
+ curriculum_year=cy,
203
+ asset_root="",
204
+ show_summaries=True,
205
+ **build_specialization_context(cy),
206
+ )
207
+ return HTML(string=html, base_url=str(FRONTEND_DIR)).write_pdf()
208
+ except Exception:
209
+ logger.exception("PDF preload failed for year=%s", cy)
210
+ return None
211
+
212
+
213
+ def preload_pdfs() -> None:
214
+ """Generate and cache PDFs for all configured curriculum years on startup."""
215
+ years_raw = os.getenv("CURRICULUM_YEAR", "").strip()
216
+ years = [y.strip() for y in years_raw.split(",") if y.strip()]
217
+ if not years:
218
+ logger.info("No CURRICULUM_YEAR set, skipping PDF preload (user sets year from dashboard)")
219
+ return
220
+
221
+ def _worker():
222
+ for cy in years:
223
+ t0 = _time.monotonic()
224
+ pdf = _generate_pdf(cy)
225
+ if pdf:
226
+ existing = cache.get(f"full_pdf:{cy}")
227
+ if existing is None:
228
+ cache.put(f"full_pdf:{cy}", pdf, ttl=600)
229
+ elapsed = (_time.monotonic() - t0) * 1000
230
+ logger.info("PDF preload complete for year=%s in %dms (%d bytes)", cy, elapsed, len(pdf))
231
+ else:
232
+ logger.warning("PDF preload skipped for year=%s", cy)
233
+
234
+ thread = threading.Thread(target=_worker, daemon=True, name="pdf-preload")
235
+ thread.start()
backend/app/routes/refined.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException
2
+
3
+ from app.preview import build_course_preview
4
+ from app.services.curriculum import attach_submissions, update_refined_fields
5
+ from app.supabase import first_row, supabase
6
+
7
+ router = APIRouter()
8
+
9
+
10
+ @router.get("/refined/{refined_id}")
11
+ def get_refined(refined_id: int):
12
+ row = first_row(supabase.table("refined_submissions").select("*").eq("id", refined_id))
13
+ if not row:
14
+ raise HTTPException(status_code=404, detail="Refined submission not found")
15
+ row = attach_submissions([row])[0]
16
+ return {"id": refined_id, "fields": build_course_preview(row)}
17
+
18
+
19
+ @router.patch("/refined/{refined_id}")
20
+ def update_refined(refined_id: int, payload: dict):
21
+ fields = payload.get("fields")
22
+ if not isinstance(fields, dict):
23
+ raise HTTPException(status_code=400, detail="fields is required")
24
+ try:
25
+ data = update_refined_fields(refined_id, fields)
26
+ row = first_row(supabase.table("refined_submissions").select("status").eq("id", refined_id))
27
+ if row and row.get("status") == "draft":
28
+ supabase.table("refined_submissions").update({"status": "refined"}).eq("id", refined_id).execute()
29
+ return {"message": "Updated", "data": data}
30
+ except ValueError as exc:
31
+ raise HTTPException(status_code=400, detail=str(exc))
backend/app/routes/submissions.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sentry_sdk
2
+ from fastapi import APIRouter, BackgroundTasks, HTTPException
3
+ from postgrest.exceptions import APIError
4
+
5
+ from app.models.submission import CourseSubmission, parse_course_code
6
+ from app.services.errors import database_http_exception
7
+ from app.services.refinement import refine
8
+ from app.supabase import supabase
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ def refine_later(submission_id: int) -> None:
14
+ try:
15
+ refine(submission_id)
16
+ except Exception as exc:
17
+ sentry_sdk.capture_exception(exc)
18
+ supabase.table("submissions").update({"status": "refine_failed"}).eq("id", submission_id).execute()
19
+
20
+
21
+ @router.post("/submissions")
22
+ def receive(data: CourseSubmission, background_tasks: BackgroundTasks):
23
+ parsed = parse_course_code(data.course_code)
24
+ payload = data.model_dump()
25
+ user_credit = (payload.get("credit_category") or "").strip()
26
+ if user_credit in ("0", "2", "4", "5"):
27
+ credit_cat = user_credit
28
+ else:
29
+ credit_cat = parsed.credit_category
30
+ raw = data.raw_course_content or ""
31
+ if "Course Code" not in raw:
32
+ raw = f"Course Code: {data.course_code}\n{raw}"
33
+ payload.pop("course_code", None)
34
+ payload["raw_course_content"] = raw
35
+ payload.update({
36
+ "offering_department": parsed.offering_dept,
37
+ "target_department": parsed.target_dept,
38
+ "semester": int(parsed.semester),
39
+ "credit_category": credit_cat,
40
+ "status": "pending",
41
+ })
42
+ try:
43
+ result = supabase.table("submissions").insert(payload).execute()
44
+ except APIError as exc:
45
+ raise database_http_exception(exc) from exc
46
+ submission = result.data[0]
47
+ background_tasks.add_task(refine_later, submission["id"])
48
+ return {"message": "Submission Received!", "submission": submission}
49
+
50
+
51
+ @router.post("/submissions/{id}/refine")
52
+ def refine_submission(id: int):
53
+ try:
54
+ return {"message": "Refined", "data": refine(id)}
55
+ except LookupError as exc:
56
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
57
+ except APIError as exc:
58
+ raise database_http_exception(exc) from exc
59
+ except (ValueError, KeyError) as exc:
60
+ raise HTTPException(status_code=422, detail=f"Refinement failed: {exc}") from exc
backend/app/routes/versions.py ADDED
@@ -0,0 +1,425 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Query
2
+ from fastapi.responses import HTMLResponse
3
+ from postgrest.exceptions import APIError
4
+
5
+ from app import cache
6
+ from app.preview import build_course_preview, build_specialization_context
7
+ from app.rendering import templates
8
+ from app.services.curriculum import attach_submissions, DEFAULT_CURRICULUM_YEAR, invalidate_curriculum_cache, selected_curriculum_year, update_refined_fields
9
+ from app.services.diffing import build_course_diff, diff_course
10
+ from app.services.errors import database_http_exception
11
+ from app.supabase import first_row, supabase
12
+
13
+ router = APIRouter()
14
+
15
+
16
+ def _version(version_id: int) -> dict:
17
+ row = first_row(supabase.table("curriculum_versions").select("*").eq("id", version_id))
18
+ if not row:
19
+ raise HTTPException(status_code=404, detail="Version not found")
20
+ return row
21
+
22
+
23
+ def _snapshot(version_id: int, refined_id: int) -> dict:
24
+ row = first_row(
25
+ supabase.table("finalized_submissions")
26
+ .select("*")
27
+ .eq("curriculum_version_id", version_id)
28
+ .eq("refined_id", refined_id)
29
+ )
30
+ if not row:
31
+ raise HTTPException(status_code=404, detail="Version course not found")
32
+ return row
33
+
34
+
35
+ def _course_summary(row: dict) -> dict:
36
+ course = row.get("course_json") or {}
37
+ return {
38
+ "id": row["id"],
39
+ "refined_id": row["refined_id"],
40
+ "semester": course.get("semester") or "",
41
+ "course_code": course.get("course_code") or "",
42
+ "course_title": course.get("course_title") or "",
43
+ }
44
+
45
+
46
+ @router.get("/versions")
47
+ def list_versions():
48
+ cached = cache.get("versions_list")
49
+ if cached is not None:
50
+ return {"versions": cached}
51
+ try:
52
+ rows = supabase.table("curriculum_versions").select("*").order("id", desc=True).execute().data
53
+ version_ids = [row["id"] for row in rows]
54
+ if version_ids:
55
+ fs_rows = (
56
+ supabase.table("finalized_submissions")
57
+ .select("curriculum_version_id,refined_id,course_json")
58
+ .in_("curriculum_version_id", version_ids)
59
+ .execute()
60
+ .data
61
+ )
62
+ count_map: dict[int, int] = {}
63
+ version_courses: dict[int, dict[int, dict]] = {}
64
+ for c in fs_rows:
65
+ vid = c["curriculum_version_id"]
66
+ count_map[vid] = count_map.get(vid, 0) + 1
67
+ version_courses.setdefault(vid, {})[c["refined_id"]] = c.get("course_json") or {}
68
+ for row in rows:
69
+ row["course_count"] = count_map.get(row["id"], 0)
70
+ current_rows = supabase.table("refined_submissions").select("*").in_("status", ["refined"]).execute().data
71
+ current_rows = attach_submissions(current_rows)
72
+ current_map = {row["id"]: build_course_preview(row) for row in current_rows}
73
+ current_rids = set(current_map.keys())
74
+ for row in rows:
75
+ vid = row["id"]
76
+ v_courses = version_courses.get(vid, {})
77
+ has_changes = False
78
+ version_rids = set(v_courses.keys())
79
+ if current_rids - version_rids:
80
+ has_changes = True
81
+ if not has_changes:
82
+ for rid, v_json in v_courses.items():
83
+ c_json = current_map.get(rid, {})
84
+ if v_json != c_json:
85
+ has_changes = True
86
+ break
87
+ row["has_changes"] = has_changes
88
+ else:
89
+ for row in rows:
90
+ row["course_count"] = 0
91
+ row["has_changes"] = False
92
+ except APIError as exc:
93
+ raise database_http_exception(exc) from exc
94
+ cache.put("versions_list", rows, ttl=300)
95
+ return {"versions": rows}
96
+
97
+
98
+ @router.patch("/versions/{version_id}")
99
+ def update_version(version_id: int, payload: dict):
100
+ _version(version_id)
101
+ updates = {}
102
+ if "name" in payload:
103
+ new_name = str(payload["name"]).strip()
104
+ if new_name:
105
+ existing = (
106
+ supabase.table("curriculum_versions")
107
+ .select("id")
108
+ .eq("name", new_name)
109
+ .neq("id", version_id)
110
+ .limit(1)
111
+ .execute()
112
+ .data
113
+ )
114
+ if existing:
115
+ raise HTTPException(status_code=400, detail="A version with this name already exists")
116
+ updates["name"] = new_name
117
+ if "academic_year" in payload:
118
+ updates["academic_year"] = str(payload["academic_year"]).strip()
119
+ if "status" in payload:
120
+ updates["status"] = str(payload["status"]).strip()
121
+ if not updates:
122
+ raise HTTPException(status_code=400, detail="No fields to update")
123
+ try:
124
+ row = supabase.table("curriculum_versions").update(updates).eq("id", version_id).execute().data[0]
125
+ except APIError as exc:
126
+ raise database_http_exception(exc) from exc
127
+ cache.invalidate("versions_list")
128
+ return {"version": row}
129
+
130
+
131
+ @router.post("/versions")
132
+ def create_version(payload: dict):
133
+ name = str(payload.get("name") or "").strip()
134
+ if not name:
135
+ raise HTTPException(status_code=400, detail="Version name is required")
136
+
137
+ try:
138
+ existing = (
139
+ supabase.table("curriculum_versions")
140
+ .select("id")
141
+ .eq("name", name)
142
+ .limit(1)
143
+ .execute()
144
+ .data
145
+ )
146
+ if existing:
147
+ raise HTTPException(status_code=400, detail="A version with this name already exists")
148
+
149
+ rows = supabase.table("refined_submissions").select("*").in_("status", ["refined"]).execute().data
150
+ rows = attach_submissions(rows)
151
+ courses = [{"refined_id": row["id"], "course_json": build_course_preview(row)} for row in rows]
152
+
153
+ latest = (
154
+ supabase.table("curriculum_versions")
155
+ .select("id")
156
+ .order("id", desc=True)
157
+ .limit(1)
158
+ .execute()
159
+ .data
160
+ )
161
+ if latest:
162
+ prev_fs = (
163
+ supabase.table("finalized_submissions")
164
+ .select("refined_id,course_json")
165
+ .eq("curriculum_version_id", latest[0]["id"])
166
+ .execute()
167
+ .data
168
+ )
169
+ prev_map = {c["refined_id"]: c.get("course_json") or {} for c in prev_fs}
170
+ if prev_map and all(
171
+ prev_map.get(c["refined_id"]) == c["course_json"] for c in courses
172
+ ):
173
+ raise HTTPException(status_code=409, detail="No changes since last version")
174
+
175
+ version = (
176
+ supabase.table("curriculum_versions")
177
+ .insert(
178
+ {
179
+ "name": name,
180
+ "program": str(payload.get("program") or (courses[0]["course_json"].get("program") if courses else "") or "").strip(),
181
+ "academic_year": str(payload.get("academic_year") or DEFAULT_CURRICULUM_YEAR).strip(),
182
+ "status": str(payload.get("status") or "draft").strip(),
183
+ }
184
+ )
185
+ .execute()
186
+ .data[0]
187
+ )
188
+ if courses:
189
+ records = [{**course, "curriculum_version_id": version["id"]} for course in courses]
190
+ supabase.table("finalized_submissions").insert(records).execute()
191
+ except APIError as exc:
192
+ raise database_http_exception(exc) from exc
193
+ cache.invalidate("versions_list")
194
+ return {"version": version, "courses": len(courses)}
195
+
196
+
197
+ @router.post("/versions/{version_id}/restore")
198
+ def restore_version(version_id: int):
199
+ try:
200
+ version = _version(version_id)
201
+ rows = (
202
+ supabase.table("finalized_submissions")
203
+ .select("*")
204
+ .eq("curriculum_version_id", version_id)
205
+ .execute()
206
+ .data
207
+ )
208
+ if not rows:
209
+ raise HTTPException(status_code=400, detail="This version has no saved courses. Restore aborted.")
210
+ version_refined_ids = [row["refined_id"] for row in rows]
211
+ current_rows = supabase.table("refined_submissions").select("*").execute().data
212
+ current = {row["id"]: build_course_preview(row) for row in attach_submissions(current_rows)}
213
+ current_status = {row["id"]: row.get("status") for row in current_rows}
214
+ extra_ids = [row["id"] for row in current_rows if row["id"] not in version_refined_ids]
215
+ history = []
216
+ restored = 0
217
+ for row in rows:
218
+ refined_id = int(row["refined_id"])
219
+ previous = current.get(refined_id)
220
+ restored_course = row["course_json"]
221
+ if not previous:
222
+ continue
223
+ if previous != restored_course:
224
+ summary = diff_course(previous, restored_course)
225
+ history.append(
226
+ {
227
+ "refined_id": refined_id,
228
+ "source": "version_restore",
229
+ "previous_json": previous,
230
+ "next_json": restored_course,
231
+ "json_patch": summary.pop("json_patch"),
232
+ "diff_summary": summary,
233
+ "change_reason": f"Restore version: {version['name']}",
234
+ }
235
+ )
236
+ if previous != restored_course or current_status.get(refined_id) != "refined":
237
+ update_refined_fields(refined_id, {**restored_course, "status": "refined"})
238
+ restored += 1
239
+ if history:
240
+ supabase.table("course_revision_history").insert(history).execute()
241
+ if extra_ids:
242
+ supabase.table("refined_submissions").update({"status": "archived"}).in_("id", extra_ids).execute()
243
+ supabase.table("agent_drafts").update({"status": "proposed"}).eq("status", "applied").execute()
244
+ supabase.table("agent_document_drafts").update({"status": "proposed"}).eq("status", "applied").execute()
245
+ invalidate_curriculum_cache()
246
+ except APIError as exc:
247
+ raise database_http_exception(exc) from exc
248
+ cache.invalidate("ver_preview:")
249
+ cache.invalidate("versions_list")
250
+ return {"message": "Version restored", "version": version, "courses_restored": restored, "courses_archived": len(extra_ids)}
251
+
252
+
253
+ @router.get("/versions/{version_id}")
254
+ def get_version(version_id: int):
255
+ try:
256
+ version = _version(version_id)
257
+ rows = (
258
+ supabase.table("finalized_submissions")
259
+ .select("*")
260
+ .eq("curriculum_version_id", version_id)
261
+ .execute()
262
+ .data
263
+ )
264
+ except APIError as exc:
265
+ raise database_http_exception(exc) from exc
266
+ courses = [_course_summary(row) for row in rows]
267
+ courses.sort(
268
+ key=lambda course: (
269
+ int(course.get("semester") or 0),
270
+ str(course.get("course_code") or ""),
271
+ str(course.get("course_title") or ""),
272
+ )
273
+ )
274
+ return {"version": version, "courses": courses}
275
+
276
+
277
+ @router.get("/versions/{version_id}/courses/{refined_id}")
278
+ def get_version_course(version_id: int, refined_id: int):
279
+ try:
280
+ version = _version(version_id)
281
+ snapshot = _snapshot(version_id, refined_id)
282
+ except APIError as exc:
283
+ raise database_http_exception(exc) from exc
284
+ return {"version": version, "refined_id": refined_id, "fields": snapshot["course_json"]}
285
+
286
+
287
+ @router.get("/versions/{version_id}/courses/{refined_id}/preview")
288
+ def preview_version_course(version_id: int, refined_id: int, curriculum_year: str | None = Query(None)):
289
+ try:
290
+ snapshot = _snapshot(version_id, refined_id)
291
+ except APIError as exc:
292
+ raise database_http_exception(exc) from exc
293
+ _version(version_id)
294
+ html = templates.get_template("jinja_sample.html").render(course=snapshot["course_json"], curriculum_year=selected_curriculum_year(curriculum_year), asset_root="/", show_thank_you=False)
295
+ return HTMLResponse(html, headers={"Cache-Control": "no-store"})
296
+
297
+
298
+ @router.get("/versions/{version_id}/preview")
299
+ def preview_version(version_id: int, diff: bool = Query(False), curriculum_year: str | None = Query(None)):
300
+ try:
301
+ _version(version_id)
302
+ rows = (
303
+ supabase.table("finalized_submissions")
304
+ .select("*")
305
+ .eq("curriculum_version_id", version_id)
306
+ .order("refined_id")
307
+ .execute()
308
+ .data
309
+ )
310
+ except APIError as exc:
311
+ raise database_http_exception(exc) from exc
312
+
313
+ version_courses = sorted(
314
+ ({"refined_id": row["refined_id"], **row["course_json"]} for row in rows),
315
+ key=lambda course: (int(course.get("semester") or 0), str(course.get("course_code") or ""), str(course.get("course_title") or "")),
316
+ )
317
+
318
+ if not diff:
319
+ cache_key = f"ver_preview:{version_id}:clean:{selected_curriculum_year(curriculum_year)}"
320
+ cached = cache.get(cache_key)
321
+ if cached:
322
+ return HTMLResponse(cached, headers={"Cache-Control": "public, max-age=30"})
323
+ html = templates.get_template("jinja_sample.html").render(
324
+ courses=version_courses,
325
+ semester="",
326
+ curriculum_year=selected_curriculum_year(curriculum_year),
327
+ asset_root="/",
328
+ show_summaries=True,
329
+ **build_specialization_context(selected_curriculum_year(curriculum_year)),
330
+ )
331
+ cache.put(cache_key, html, ttl=120)
332
+ return HTMLResponse(html, headers={"Cache-Control": "public, max-age=30"})
333
+
334
+ # Diff mode: compare version against current refined submissions
335
+ cache_key = f"ver_preview:{version_id}:diff:{selected_curriculum_year(curriculum_year)}"
336
+ cached = cache.get(cache_key)
337
+ if cached:
338
+ return HTMLResponse(cached, headers={"Cache-Control": "public, max-age=30"})
339
+
340
+ refined_ids = [v["refined_id"] for v in version_courses if "refined_id" in v]
341
+ current_rows = supabase.table("refined_submissions").select("*").in_("id", refined_ids).execute().data if refined_ids else []
342
+ current_rows = attach_submissions(current_rows)
343
+ current = {row["id"]: build_course_preview(row) for row in current_rows}
344
+
345
+ course_diffs = []
346
+ for v_course in version_courses:
347
+ refined_id = v_course.get("refined_id")
348
+ current_course = current.get(refined_id)
349
+ if current_course:
350
+ base = dict(current_course)
351
+ proposed = dict(v_course)
352
+ if base == proposed:
353
+ continue
354
+ course_diff = build_course_diff(base, proposed)
355
+ if any(v is not None for v in course_diff.values()):
356
+ course_diffs.append({"base": base, "proposed": proposed, "course_diff": course_diff})
357
+
358
+ html = templates.get_template("jinja_diff.html").render(
359
+ course_diffs=course_diffs,
360
+ curriculum_year=selected_curriculum_year(curriculum_year),
361
+ asset_root="/",
362
+ )
363
+ cache.put(cache_key, html, ttl=120)
364
+ return HTMLResponse(html, headers={"Cache-Control": "public, max-age=30"})
365
+
366
+
367
+ @router.delete("/versions/{version_id}")
368
+ def delete_version(version_id: int):
369
+ _version(version_id)
370
+ try:
371
+ supabase.table("finalized_submissions").delete().eq("curriculum_version_id", version_id).execute()
372
+ supabase.table("curriculum_versions").delete().eq("id", version_id).execute()
373
+ except APIError as exc:
374
+ raise database_http_exception(exc) from exc
375
+ cache.invalidate("versions_list")
376
+ cache.invalidate("ver_preview:")
377
+ return {"message": "Version deleted"}
378
+
379
+
380
+ @router.get("/versions/{version_id1}/diff/{version_id2}")
381
+ def diff_two_versions(version_id1: int, version_id2: int, curriculum_year: str | None = Query(None)):
382
+ cy = selected_curriculum_year(curriculum_year)
383
+ cache_key = f"ver_diff:{version_id1}:{version_id2}:{cy}"
384
+ cached = cache.get(cache_key)
385
+ if cached:
386
+ return HTMLResponse(cached, headers={"Cache-Control": "public, max-age=30"})
387
+ try:
388
+ fs1 = (
389
+ supabase.table("finalized_submissions")
390
+ .select("refined_id,course_json")
391
+ .eq("curriculum_version_id", version_id1)
392
+ .execute()
393
+ .data
394
+ )
395
+ fs2 = (
396
+ supabase.table("finalized_submissions")
397
+ .select("refined_id,course_json")
398
+ .eq("curriculum_version_id", version_id2)
399
+ .execute()
400
+ .data
401
+ )
402
+ except APIError as exc:
403
+ raise database_http_exception(exc) from exc
404
+
405
+ map1 = {row["refined_id"]: row["course_json"] for row in fs1}
406
+ map2 = {row["refined_id"]: row["course_json"] for row in fs2}
407
+ all_ids = sorted(set(map1.keys()) | set(map2.keys()))
408
+
409
+ course_diffs = []
410
+ for rid in all_ids:
411
+ base = map1.get(rid) or {}
412
+ proposed = map2.get(rid) or {}
413
+ if base == proposed:
414
+ continue
415
+ course_diff = build_course_diff(base, proposed)
416
+ if any(v is not None for v in course_diff.values()):
417
+ course_diffs.append({"base": base, "proposed": proposed, "course_diff": course_diff})
418
+
419
+ html = templates.get_template("jinja_diff.html").render(
420
+ course_diffs=course_diffs,
421
+ curriculum_year=cy,
422
+ asset_root="/",
423
+ )
424
+ cache.put(cache_key, html, ttl=120)
425
+ return HTMLResponse(html, headers={"Cache-Control": "public, max-age=30"})
backend/app/services/agent_tools.py ADDED
@@ -0,0 +1,1695 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass
3
+ import base64
4
+ import json
5
+ import re
6
+ from urllib.parse import quote_plus
7
+
8
+ import httpx
9
+
10
+ from app.services.curriculum import (
11
+ REFINED_FIELDS,
12
+ create_version_snapshot,
13
+ draft_record,
14
+ invalidate_curriculum_cache,
15
+ load_agent_draft,
16
+ load_document_draft,
17
+ ordered_courses,
18
+ refined_course,
19
+ )
20
+ from app.services.diffing import diff_course
21
+ from app.supabase import first_row, supabase
22
+
23
+
24
+ def _markdown_to_html(md: str) -> str:
25
+ """Convert basic markdown to HTML for PDF generation."""
26
+ html = md
27
+ # Headers
28
+ html = re.sub(r"^### (.+)$", r"<h3>\1</h3>", html, flags=re.MULTILINE)
29
+ html = re.sub(r"^## (.+)$", r"<h2>\1</h2>", html, flags=re.MULTILINE)
30
+ html = re.sub(r"^# (.+)$", r"<h1>\1</h1>", html, flags=re.MULTILINE)
31
+ # Bold
32
+ html = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", html)
33
+ # Italic
34
+ html = re.sub(r"\*(.+?)\*", r"<em>\1</em>", html)
35
+ # Code inline
36
+ html = re.sub(r"`(.+?)`", r"<code>\1</code>", html)
37
+ # Code blocks
38
+ html = re.sub(
39
+ r"```(\w+)?\n(.+?)```",
40
+ r'<pre><code class="language-\1">\2</code></pre>',
41
+ html,
42
+ flags=re.DOTALL,
43
+ )
44
+ # Links
45
+ html = re.sub(r"\[(.+?)\]\((.+?)\)", r'<a href="\2" target="_blank">\1</a>', html)
46
+ # Unordered lists
47
+ html = re.sub(r"^\- (.+)$", r"<li>\1</li>", html, flags=re.MULTILINE)
48
+ html = re.sub(r"(<li>.*?</li>\n)+", r"<ul>\n\g<0></ul>", html)
49
+ # Tables (basic)
50
+ # Split lines
51
+ lines = html.split("\n")
52
+ in_table = False
53
+ result = []
54
+ for line in lines:
55
+ if line.strip().startswith("|") and "|" in line[1:]:
56
+ if not in_table:
57
+ result.append("<table>")
58
+ in_table = True
59
+ cells = [c.strip() for c in line.strip("|").split("|")]
60
+ tag = "th" if not in_table or not result[-1].startswith("<table>") else "td"
61
+ if len(result) == 1 and result[-1] == "<table>":
62
+ tag = "th"
63
+ result.append(
64
+ "<tr>" + "".join(f"<{tag}>{c}</{tag}>" for c in cells) + "</tr>"
65
+ )
66
+ else:
67
+ if in_table:
68
+ result.append("</table>")
69
+ in_table = False
70
+ result.append(line)
71
+ if in_table:
72
+ result.append("</table>")
73
+ html = "\n".join(result)
74
+ # Paragraphs
75
+ lines = html.split("\n")
76
+ result = []
77
+ in_p = False
78
+ for line in lines:
79
+ if line.strip() and not line.startswith("<") and not line.startswith("</"):
80
+ if not in_p:
81
+ result.append("<p>")
82
+ in_p = True
83
+ result.append(line)
84
+ else:
85
+ if in_p:
86
+ result.append("</p>")
87
+ in_p = False
88
+ result.append(line)
89
+ if in_p:
90
+ result.append("</p>")
91
+ return "\n".join(result)
92
+
93
+
94
+ ToolHandler = Callable[[dict], dict]
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class AgentTool:
99
+ name: str
100
+ description: str
101
+ parameters: dict
102
+ handler: ToolHandler
103
+
104
+ def schema(self) -> dict:
105
+ return {
106
+ "type": "function",
107
+ "function": {
108
+ "name": self.name,
109
+ "description": self.description,
110
+ "parameters": self.parameters,
111
+ },
112
+ }
113
+
114
+
115
+ def list_tool_schemas() -> list[dict]:
116
+ return [tool.schema() for tool in TOOLS.values()]
117
+
118
+
119
+ def call_tool(name: str, arguments: dict | None = None) -> dict:
120
+ tool = TOOLS.get(name)
121
+ if not tool:
122
+ raise LookupError("Agent tool not found")
123
+ return tool.handler(arguments or {})
124
+
125
+
126
+ def _require_int(arguments: dict, key: str) -> int:
127
+ value = arguments.get(key)
128
+ if value is None:
129
+ raise ValueError(f"{key} is required")
130
+ return int(value)
131
+
132
+
133
+ def _require_dict(arguments: dict, key: str) -> dict:
134
+ value = arguments.get(key)
135
+ if not isinstance(value, dict):
136
+ raise ValueError(f"{key} must be an object")
137
+ return value
138
+
139
+
140
+ def _get_current_course(arguments: dict) -> dict:
141
+ return {"course": refined_course(_require_int(arguments, "refined_id"))}
142
+
143
+
144
+ def _get_course_fields(arguments: dict) -> dict:
145
+ """Get specific field groups from a course. More efficient than full course JSON."""
146
+ refined_id = _require_int(arguments, "refined_id")
147
+ fields = arguments.get("fields")
148
+ if not isinstance(fields, list) or not fields:
149
+ raise ValueError("fields must be a non-empty array of field names")
150
+
151
+ course = refined_course(refined_id)
152
+ result = {"refined_id": refined_id}
153
+ for field in fields:
154
+ if field in course:
155
+ result[field] = course[field]
156
+ else:
157
+ result[field] = None
158
+ return result
159
+
160
+
161
+ def _get_course_codes(arguments: dict) -> dict:
162
+ """Get lightweight course identifiers: refined_id, course_code, course_title, semester."""
163
+ refined_id = _require_int(arguments, "refined_id")
164
+ course = refined_course(refined_id)
165
+ return {
166
+ "refined_id": refined_id,
167
+ "course_code": course.get("course_code"),
168
+ "course_title": course.get("course_title"),
169
+ "semester": course.get("semester"),
170
+ "program": course.get("program"),
171
+ }
172
+
173
+
174
+ def _get_course_syllabus(arguments: dict) -> dict:
175
+ """Get syllabus content: units, objectives, course_outcomes."""
176
+ refined_id = _require_int(arguments, "refined_id")
177
+ course = refined_course(refined_id)
178
+ return {
179
+ "refined_id": refined_id,
180
+ "units": course.get("units"),
181
+ "objectives": course.get("objectives"),
182
+ "course_outcomes": course.get("course_outcomes"),
183
+ }
184
+
185
+
186
+ def _get_course_textbooks(arguments: dict) -> dict:
187
+ """Get textbook fields: text_books, reference_books."""
188
+ refined_id = _require_int(arguments, "refined_id")
189
+ course = refined_course(refined_id)
190
+ return {
191
+ "refined_id": refined_id,
192
+ "text_books": course.get("text_books"),
193
+ "reference_books": course.get("reference_books"),
194
+ }
195
+
196
+
197
+ def _get_course_deterministic(arguments: dict) -> dict:
198
+ """Get deterministic fields (program, hours, credits, course_type) — these are agent-protected."""
199
+ refined_id = _require_int(arguments, "refined_id")
200
+ course = refined_course(refined_id)
201
+ return {
202
+ "refined_id": refined_id,
203
+ "program": course.get("program"),
204
+ "lecture_hours": course.get("lecture_hours"),
205
+ "tutorial_hours": course.get("tutorial_hours"),
206
+ "practical_hours": course.get("practical_hours"),
207
+ "self_study": course.get("self_study"),
208
+ "credits": course.get("credits"),
209
+ "course_type": course.get("course_type"),
210
+ }
211
+
212
+
213
+ def _get_course_lab(arguments: dict) -> dict:
214
+ """Get lab experiments and tools/languages."""
215
+ refined_id = _require_int(arguments, "refined_id")
216
+ course = refined_course(refined_id)
217
+ return {
218
+ "refined_id": refined_id,
219
+ "lab_experiments": course.get("lab_experiments"),
220
+ "tools_languages": course.get("tools_languages"),
221
+ }
222
+
223
+
224
+ def _diff_course_json(arguments: dict) -> dict:
225
+ return diff_course(
226
+ _require_dict(arguments, "current"), _require_dict(arguments, "proposed")
227
+ )
228
+
229
+
230
+ def _create_course_draft(arguments: dict) -> dict:
231
+ refined_id = _require_int(arguments, "refined_id")
232
+ if refined_id <= 0:
233
+ raise ValueError(
234
+ "refined_id must be a valid existing course ID. To create a brand-new course, use create_refined_course instead."
235
+ )
236
+ fields = arguments.get("fields")
237
+ if not isinstance(fields, dict) or not fields:
238
+ raise ValueError(
239
+ 'fields must be a non-empty object containing only the fields to change, e.g. {"text_books": "new value"}. Do not pass all course data; only pass what should change.'
240
+ )
241
+ for key in _ARRAY_FIELDS:
242
+ if key in fields:
243
+ fields[key] = _coerce_array(fields[key])
244
+ row = first_row(supabase.table("refined_submissions").select("status").eq("id", refined_id))
245
+ if not row:
246
+ raise ValueError(f"Course with refined_id {refined_id} not found.")
247
+ if row.get("status") == "draft":
248
+ raise ValueError(f"Course {refined_id} has status 'draft'. Use create_refined_course with the refined_id to update draft-status courses directly.")
249
+ record = draft_record(refined_id, fields, str(arguments.get("reason") or ""))
250
+ draft = supabase.table("agent_drafts").insert(record).execute().data[0]
251
+ return {"draft": draft}
252
+
253
+
254
+ def _update_agent_draft(arguments: dict) -> dict:
255
+ draft_id = _require_int(arguments, "draft_id")
256
+ fields = arguments.get("fields")
257
+ if not isinstance(fields, dict) or not fields:
258
+ raise ValueError(
259
+ 'fields must be a non-empty object containing only the fields to change, e.g. {"text_books": "new value"}.'
260
+ )
261
+ for key in _ARRAY_FIELDS:
262
+ if key in fields:
263
+ fields[key] = _coerce_array(fields[key])
264
+ row = first_row(supabase.table("agent_drafts").select("refined_id,proposed_json,status").eq("id", draft_id))
265
+ if not row:
266
+ raise ValueError(f"Draft with id {draft_id} not found.")
267
+ if row.get("status") not in ("proposed", "blocked"):
268
+ raise ValueError(f"Draft {draft_id} has status '{row.get('status')}' and cannot be edited. Only proposed or blocked drafts can be updated.")
269
+ from app.services.curriculum import merge_fields, diff_course, validate_draft
270
+ proposed = merge_fields(row["proposed_json"], fields)
271
+ base = first_row(supabase.table("refined_submissions").select("*").eq("id", int(row["refined_id"])))
272
+ base = base or {}
273
+ summary = diff_course(base, proposed)
274
+ issues = validate_draft(base, proposed)
275
+ summary["validation_issues"] = issues
276
+ update = {
277
+ "proposed_json": proposed,
278
+ "json_patch": summary.pop("json_patch"),
279
+ "diff_summary": summary,
280
+ "status": "blocked" if issues else "proposed",
281
+ }
282
+ result = supabase.table("agent_drafts").update(update).eq("id", draft_id).execute()
283
+ return {"draft": result.data[0] if result.data else row}
284
+
285
+
286
+ _ARRAY_FIELDS = {
287
+ "course_outcomes",
288
+ "lab_experiments",
289
+ "objectives",
290
+ "text_books",
291
+ "reference_books",
292
+ "units",
293
+ }
294
+
295
+
296
+ def _coerce_array(value):
297
+ if isinstance(value, list):
298
+ return value
299
+ if isinstance(value, str):
300
+ stripped = value.strip()
301
+ if stripped.startswith("["):
302
+ try:
303
+ parsed = json.loads(stripped)
304
+ if isinstance(parsed, list):
305
+ return parsed
306
+ except json.JSONDecodeError:
307
+ pass
308
+ lines = [line.strip() for line in stripped.splitlines()]
309
+ cleaned = []
310
+ for line in lines:
311
+ for prefix in ("- ", "• ", "* "):
312
+ if line.startswith(prefix):
313
+ line = line[len(prefix):]
314
+ break
315
+ line = line.strip()
316
+ if line:
317
+ cleaned.append(line)
318
+ if len(cleaned) > 1:
319
+ return cleaned
320
+ return [stripped] if stripped else []
321
+ return [value] if value is not None else []
322
+
323
+
324
+ def _create_refined_course(arguments: dict) -> dict:
325
+ from app.services.deterministic import (
326
+ compute_course_type,
327
+ compute_hours,
328
+ compute_program,
329
+ )
330
+
331
+ credit_category = str(arguments.get("credit_category") or "4")
332
+ target_dept = str(arguments.get("target_department") or "CSE")
333
+ det = compute_hours(credit_category)
334
+
335
+ is_elective = bool(arguments.get("is_elective"))
336
+ computed = {
337
+ "program": compute_program(target_dept),
338
+ "course_type": "Elective" if is_elective else compute_course_type(credit_category),
339
+ "status": "draft",
340
+ }
341
+ for key in (
342
+ "lecture_hours",
343
+ "tutorial_hours",
344
+ "practical_hours",
345
+ "self_study",
346
+ "credits",
347
+ ):
348
+ if key in arguments:
349
+ computed[key] = int(arguments[key] or 0)
350
+ else:
351
+ computed[key] = det[key]
352
+ if "semester" in arguments:
353
+ computed["semester"] = int(arguments["semester"] or 0)
354
+
355
+ fields = {
356
+ k: v for k, v in arguments.items() if v is not None and k in REFINED_FIELDS
357
+ }
358
+ for key in _ARRAY_FIELDS:
359
+ if key in fields:
360
+ fields[key] = _coerce_array(fields[key])
361
+ dk = str(fields.get("desirable_knowledge") or "").strip()
362
+ if dk and dk.lower() in ("none", "n/a", "na", "-"):
363
+ fields["desirable_knowledge"] = ""
364
+ if "is_elective" in arguments:
365
+ fields["is_elective"] = bool(arguments["is_elective"])
366
+ fields.update(computed)
367
+
368
+ refined_id = arguments.get("refined_id")
369
+ if not refined_id and "course_code" in fields:
370
+ dup = (
371
+ supabase.table("refined_submissions")
372
+ .select("id")
373
+ .eq("course_code", fields["course_code"])
374
+ .limit(1)
375
+ .execute()
376
+ .data
377
+ )
378
+ if dup:
379
+ return {"error": f"Course code {fields['course_code']} already exists (refined_id: {dup[0]['id']}). Pick a unique code."}
380
+
381
+ if refined_id:
382
+ result = (
383
+ supabase.table("refined_submissions")
384
+ .update(fields)
385
+ .eq("id", int(refined_id))
386
+ .execute()
387
+ )
388
+ row = result.data[0] if result.data else None
389
+ invalidate_curriculum_cache()
390
+ return {"refined_id": int(refined_id), "updated": True, "course": row}
391
+
392
+ if "submission_id" not in fields or not fields.get("submission_id"):
393
+ placeholder = (
394
+ supabase.table("submissions")
395
+ .insert(
396
+ {
397
+ "faculty_email": "ai-generated@pes.edu",
398
+ "course_title": fields.get("course_title") or "",
399
+ "offering_department": "CS",
400
+ "target_department": arguments.get("target_department") or "CSE",
401
+ "semester": int(arguments.get("semester") or 1),
402
+ "credit_category": arguments.get("credit_category") or "4",
403
+ "raw_course_content": fields.get("prelude") or "AI-created course",
404
+ "text_books": "",
405
+ "reference_books": "",
406
+ "preferred_tools": "",
407
+ "status": "refined",
408
+ }
409
+ )
410
+ .execute()
411
+ .data[0]
412
+ )
413
+ fields["submission_id"] = placeholder["id"]
414
+
415
+ result = supabase.table("refined_submissions").insert(fields).execute()
416
+ row = result.data[0]
417
+ invalidate_curriculum_cache()
418
+ return {"refined_id": row["id"], "updated": False, "course": row}
419
+
420
+
421
+ def _get_curriculum_json(arguments: dict) -> dict:
422
+ query = supabase.table("refined_submissions").select("*").in_("status", ["refined"])
423
+ if arguments.get("semester") is not None:
424
+ query = query.eq("semester", int(arguments["semester"]))
425
+ return {"courses": ordered_courses(query.execute().data)}
426
+
427
+
428
+ def _create_document_draft(arguments: dict) -> dict:
429
+ courses = arguments.get("courses")
430
+ if not isinstance(courses, list) or not courses:
431
+ raise ValueError("courses must be a non-empty array")
432
+
433
+ records = []
434
+ for course in courses:
435
+ if not isinstance(course, dict):
436
+ raise ValueError("each course must be an object")
437
+ fields = _require_dict(course, "fields")
438
+ for key in _ARRAY_FIELDS:
439
+ if key in fields:
440
+ fields[key] = _coerce_array(fields[key])
441
+ records.append(
442
+ draft_record(
443
+ int(course.get("refined_id")),
444
+ fields,
445
+ str(arguments.get("reason") or ""),
446
+ )
447
+ )
448
+
449
+ summaries = [record["diff_summary"] for record in records]
450
+ document_summary = {
451
+ "courses_changed": len(records),
452
+ "courses_with_removed_topics": sum(
453
+ 1 for summary in summaries if summary.get("topics_removed")
454
+ ),
455
+ "courses_with_protected_changes": sum(
456
+ 1 for summary in summaries if summary.get("protected_changes")
457
+ ),
458
+ "max_syllabus_change_percent": max(
459
+ (summary.get("syllabus_change_percent") or 0 for summary in summaries),
460
+ default=0,
461
+ ),
462
+ }
463
+ document = (
464
+ supabase.table("agent_document_drafts")
465
+ .insert(
466
+ {
467
+ "curriculum_version_id": arguments.get("curriculum_version_id"),
468
+ "uploaded_document_id": str(
469
+ arguments.get("uploaded_document_id") or ""
470
+ ).strip(),
471
+ "diff_summary": document_summary,
472
+ "change_reason": str(arguments.get("reason") or "").strip(),
473
+ "status": "blocked"
474
+ if document_summary["courses_with_protected_changes"]
475
+ else "proposed",
476
+ }
477
+ )
478
+ .execute()
479
+ .data[0]
480
+ )
481
+ for record in records:
482
+ record["document_draft_id"] = document["id"]
483
+ drafts = supabase.table("agent_drafts").insert(records).execute().data
484
+ return {"document_draft": document, "drafts": drafts}
485
+
486
+
487
+ def _get_course_draft(arguments: dict) -> dict:
488
+ return {"draft": load_agent_draft(_require_int(arguments, "draft_id"))}
489
+
490
+
491
+ def _get_document_draft(arguments: dict) -> dict:
492
+ return load_document_draft(_require_int(arguments, "document_draft_id"))
493
+
494
+
495
+ def _get_preview_url(arguments: dict) -> dict:
496
+ kind = str(arguments.get("kind") or "")
497
+ item_id = _require_int(arguments, "id")
498
+ paths = {
499
+ "course": f"/api/preview/course/{item_id}",
500
+ "draft": f"/api/agent/drafts/{item_id}/preview",
501
+ "document_draft": f"/api/agent/document-drafts/{item_id}/preview",
502
+ }
503
+ if kind not in paths:
504
+ raise ValueError("kind must be course, draft, or document_draft")
505
+ return {"url": paths[kind]}
506
+
507
+
508
+ def _list_courses(arguments: dict) -> dict:
509
+ query = (
510
+ supabase.table("refined_submissions")
511
+ .select("id,semester,course_code,course_title")
512
+ .neq("status", "archived")
513
+ )
514
+ if arguments.get("semester") is not None:
515
+ query = query.eq("semester", int(arguments["semester"]))
516
+ rows = query.execute().data
517
+ rows.sort(
518
+ key=lambda row: (
519
+ int(row.get("semester") or 0),
520
+ str(row.get("course_code") or ""),
521
+ int(row.get("id") or 0),
522
+ )
523
+ )
524
+ return {"courses": rows}
525
+
526
+
527
+ def _fetch_url(arguments: dict) -> dict:
528
+ url = str(arguments.get("url") or "").strip()
529
+ if not url:
530
+ raise ValueError("url is required")
531
+ resp = httpx.get(url, timeout=30, follow_redirects=True)
532
+ resp.raise_for_status()
533
+ text = resp.text[:15000]
534
+ return {"url": url, "text": text, "chars": len(text)}
535
+
536
+
537
+ def _web_search(arguments: dict) -> dict:
538
+ """Search the web using DuckDuckGo's HTML endpoint and return top results."""
539
+ query = str(arguments.get("query") or "").strip()
540
+ if not query:
541
+ raise ValueError("query is required")
542
+ num_results = int(arguments.get("num_results") or 5)
543
+ num_results = min(max(num_results, 1), 10)
544
+
545
+ url = f"https://html.duckduckgo.com/html/?q={quote_plus(query)}"
546
+ resp = httpx.get(
547
+ url, timeout=15, follow_redirects=True, headers={"User-Agent": "Mozilla/5.0"}
548
+ )
549
+ resp.raise_for_status()
550
+
551
+ # Parse results from DuckDuckGo HTML
552
+ html = resp.text
553
+ results = []
554
+ # Pattern for result snippets
555
+ result_pattern = re.compile(r'class="result__snippet">(.*?)</a>', re.DOTALL)
556
+ title_pattern = re.compile(r'class="result__title">.*?>(.*?)</a>', re.DOTALL)
557
+ url_pattern = re.compile(r'class="result__url">.*?>(.*?)</a>', re.DOTALL)
558
+
559
+ snippets = result_pattern.findall(html)
560
+ titles = title_pattern.findall(html)
561
+ urls = url_pattern.findall(html)
562
+
563
+ for i in range(min(num_results, len(snippets))):
564
+ title = re.sub(r"<[^>]+>", "", titles[i] if i < len(titles) else "").strip()
565
+ snippet = re.sub(r"<[^>]+>", "", snippets[i]).strip()
566
+ link = re.sub(r"<[^>]+>", "", urls[i] if i < len(urls) else "").strip()
567
+ if title or snippet:
568
+ results.append({"title": title, "snippet": snippet[:300], "url": link})
569
+
570
+ return {"query": query, "results": results}
571
+
572
+
573
+ def _create_report(arguments: dict) -> dict:
574
+ session_id = _require_int(arguments, "session_id")
575
+ content = str(arguments.get("content") or "").strip()
576
+ if not content:
577
+ raise ValueError("content is required")
578
+ filename = str(arguments.get("filename") or "report.md").strip()
579
+ fmt = str(arguments.get("format") or "markdown").strip().lower()
580
+ if fmt not in ("markdown", "pdf"):
581
+ raise ValueError("format must be 'markdown' or 'pdf'")
582
+
583
+ if fmt == "pdf":
584
+ # Convert markdown to HTML then to PDF
585
+ # Simple markdown to HTML conversion
586
+ html_content = _markdown_to_html(content)
587
+ html_full = f"""<!DOCTYPE html>
588
+ <html>
589
+ <head>
590
+ <meta charset="utf-8">
591
+ <style>
592
+ body {{ font-family: Arial, sans-serif; line-height: 1.6; max-width: 800px; margin: 0 auto; padding: 20px; }}
593
+ h1, h2, h3 {{ color: #00377b; }}
594
+ table {{ border-collapse: collapse; width: 100%; margin: 10px 0; }}
595
+ th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
596
+ th {{ background: #f0f2f5; }}
597
+ code {{ background: #f3f4f6; padding: 2px 4px; border-radius: 3px; }}
598
+ pre {{ background: #f3f4f6; padding: 10px; overflow-x: auto; }}
599
+ </style>
600
+ </head>
601
+ <body>
602
+ {html_content}
603
+ </body>
604
+ </html>"""
605
+
606
+ # Use weasyprint to generate PDF
607
+ from weasyprint import HTML
608
+
609
+ pdf_bytes = HTML(string=html_full, base_url=".").write_pdf()
610
+
611
+ if filename.endswith(".md"):
612
+ filename = filename[:-3] + ".pdf"
613
+ elif not filename.endswith(".pdf"):
614
+ filename = filename + ".pdf"
615
+
616
+ content_type = "application/pdf"
617
+ size_bytes = len(pdf_bytes)
618
+ extracted_text = f"[PDF file - {size_bytes} bytes]"
619
+ content_base64 = base64.b64encode(pdf_bytes).decode()
620
+ else:
621
+ pdf_bytes = None
622
+ content_type = "text/markdown"
623
+ size_bytes = len(content.encode())
624
+ extracted_text = content
625
+ content_base64 = ""
626
+
627
+ row = (
628
+ supabase.table("chat_attachments")
629
+ .insert(
630
+ {
631
+ "session_id": session_id,
632
+ "filename": filename,
633
+ "content_type": content_type,
634
+ "size_bytes": size_bytes,
635
+ "extracted_text": extracted_text,
636
+ "content_base64": content_base64,
637
+ "status": "ready",
638
+ }
639
+ )
640
+ .execute()
641
+ .data[0]
642
+ )
643
+
644
+ return {
645
+ "attachment": {
646
+ "id": row["id"],
647
+ "filename": row["filename"],
648
+ "chars": size_bytes,
649
+ "format": fmt,
650
+ }
651
+ }
652
+
653
+
654
+ def _attachment_text(arguments: dict) -> dict:
655
+ session_id = _require_int(arguments, "session_id")
656
+ ids = [int(value) for value in arguments.get("attachment_ids") or []]
657
+ if not ids:
658
+ raise ValueError("attachment_ids is required")
659
+ rows = (
660
+ supabase.table("chat_attachments")
661
+ .select("id,filename,status,error,extracted_text")
662
+ .eq("session_id", session_id)
663
+ .in_("id", ids)
664
+ .execute()
665
+ .data
666
+ )
667
+ return {"attachments": rows}
668
+
669
+
670
+ def _create_curriculum_version(arguments: dict) -> dict:
671
+ name = str(arguments.get("name") or "").strip()
672
+ if not name:
673
+ raise ValueError("name is required")
674
+ version = create_version_snapshot(name)
675
+ return {"version": version}
676
+
677
+
678
+ def _define_specialization(arguments: dict) -> dict:
679
+ semester = _require_int(arguments, "semester")
680
+ name = str(arguments.get("name") or "").strip()
681
+ if not name:
682
+ raise ValueError("name is required")
683
+ letter = str(arguments.get("letter") or "").strip().upper()
684
+ if not letter:
685
+ existing = (
686
+ supabase.table("specialization_definitions")
687
+ .select("letter")
688
+ .eq("semester", semester)
689
+ .execute()
690
+ .data
691
+ )
692
+ used = {row["letter"] for row in existing}
693
+ for candidate in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
694
+ if candidate not in used:
695
+ letter = candidate
696
+ break
697
+ if not letter:
698
+ raise ValueError(
699
+ "No free specialization letter available for this semester"
700
+ )
701
+ academic_year = str(arguments.get("academic_year") or "").strip()
702
+ key = str(arguments.get("key") or "").strip().upper()
703
+ if not key:
704
+ key = name.split("(")[-1].rstrip(")") if "(" in name else name[:3].upper()
705
+ row = (
706
+ supabase.table("specialization_definitions")
707
+ .insert(
708
+ {
709
+ "semester": semester,
710
+ "letter": letter,
711
+ "name": name,
712
+ "key": key,
713
+ "academic_year": academic_year,
714
+ }
715
+ )
716
+ .execute()
717
+ .data[0]
718
+ )
719
+ return {"specialization": row}
720
+
721
+
722
+ def _list_specializations(arguments: dict) -> dict:
723
+ query = supabase.table("specialization_definitions").select("*")
724
+ if arguments.get("semester") is not None:
725
+ query = query.eq("semester", int(arguments["semester"]))
726
+ return {"specializations": query.order("semester").order("letter").execute().data}
727
+
728
+
729
+ def _assign_elective_to_tracks(arguments: dict) -> dict:
730
+ refined_id = _require_int(arguments, "refined_id")
731
+ spec_ids = arguments.get("specialization_ids")
732
+ if not isinstance(spec_ids, list) or not spec_ids:
733
+ raise ValueError("specialization_ids must be a non-empty array")
734
+ created = 0
735
+ for spec_id in spec_ids:
736
+ existing = (
737
+ supabase.table("course_specialization_assignments")
738
+ .select("id")
739
+ .eq("refined_id", refined_id)
740
+ .eq("specialization_id", int(spec_id))
741
+ .execute()
742
+ .data
743
+ )
744
+ if not existing:
745
+ supabase.table("course_specialization_assignments").insert(
746
+ {"refined_id": refined_id, "specialization_id": int(spec_id)}
747
+ ).execute()
748
+ created += 1
749
+ supabase.table("refined_submissions").update({"is_elective": True}).eq(
750
+ "id", refined_id
751
+ ).execute()
752
+ return {"assignments_created": created}
753
+
754
+
755
+ def _remove_elective_from_tracks(arguments: dict) -> dict:
756
+ refined_id = _require_int(arguments, "refined_id")
757
+ spec_ids = arguments.get("specialization_ids")
758
+ if not isinstance(spec_ids, list) or not spec_ids:
759
+ raise ValueError("specialization_ids must be a non-empty array")
760
+ removed = 0
761
+ for spec_id in spec_ids:
762
+ result = (
763
+ supabase.table("course_specialization_assignments")
764
+ .delete()
765
+ .eq("refined_id", refined_id)
766
+ .eq("specialization_id", int(spec_id))
767
+ .execute()
768
+ )
769
+ removed += len(result.data or [])
770
+ return {"assignments_removed": removed}
771
+
772
+
773
+ def _get_course_assignments(arguments: dict) -> dict:
774
+ refined_id = _require_int(arguments, "refined_id")
775
+ assignments = (
776
+ supabase.table("course_specialization_assignments")
777
+ .select("*, specialization_definitions(*)")
778
+ .eq("refined_id", refined_id)
779
+ .execute()
780
+ .data
781
+ )
782
+ return {"refined_id": refined_id, "assignments": assignments}
783
+
784
+
785
+ def _categorize_elective(arguments: dict) -> dict:
786
+ """Re-run guarded AI categorization for an elective needing human review."""
787
+ from app.services.elective_categorization import categorize_refined_elective
788
+
789
+ return categorize_refined_elective(_require_int(arguments, "refined_id"))
790
+
791
+
792
+ def _update_deterministic_fields(arguments: dict) -> dict:
793
+ from app.services.diffing import PROTECTED_FIELDS
794
+
795
+ refined_id = _require_int(arguments, "refined_id")
796
+ fields = _require_dict(arguments, "fields")
797
+ protected = {key: value for key, value in fields.items() if key in PROTECTED_FIELDS}
798
+ if not protected:
799
+ raise ValueError(
800
+ "No deterministic fields provided. Use create_course_draft for other fields."
801
+ )
802
+ record = draft_record(
803
+ refined_id,
804
+ protected,
805
+ str(
806
+ arguments.get("reason")
807
+ or "Explicit user request to change deterministic fields"
808
+ ),
809
+ )
810
+ record["status"] = "blocked"
811
+ draft = supabase.table("agent_drafts").insert(record).execute().data[0]
812
+ return {
813
+ "draft": draft,
814
+ "warning": "This draft modifies deterministic fields (program, hours, credits, course_type). The user must explicitly approve it in the Review panel before it is applied.",
815
+ }
816
+
817
+
818
+ def _signal_done(arguments: dict) -> dict:
819
+ summary = str(arguments.get("summary") or "").strip()
820
+ if not summary:
821
+ raise ValueError("summary is required")
822
+ return {"done": True, "summary": summary}
823
+
824
+
825
+ def _ask_user(arguments: dict) -> dict:
826
+ question = str(arguments.get("question") or "").strip()
827
+ if not question:
828
+ raise ValueError("question is required")
829
+ return {"asked": True, "question": question, "done": True, "summary": question}
830
+
831
+
832
+ def _create_spreadsheet(arguments: dict) -> dict:
833
+ """Generate CSV or Excel file from rows data and save as chat attachment."""
834
+ session_id = _require_int(arguments, "session_id")
835
+ rows = arguments.get("rows")
836
+ if not isinstance(rows, list) or not rows:
837
+ raise ValueError("rows must be a non-empty array of objects")
838
+ columns = arguments.get("columns")
839
+ if not isinstance(columns, list) or not columns:
840
+ raise ValueError("columns must be a non-empty array of column names")
841
+ filename = str(arguments.get("filename") or "spreadsheet.csv").strip()
842
+ fmt = str(arguments.get("format") or "csv").strip().lower()
843
+ if fmt not in ("csv", "xlsx"):
844
+ raise ValueError("format must be 'csv' or 'xlsx'")
845
+
846
+ if fmt == "xlsx":
847
+ import io
848
+ from openpyxl import Workbook
849
+ from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
850
+
851
+ wb = Workbook()
852
+ ws = wb.active
853
+ ws.title = "Data"
854
+
855
+ header_font = Font(bold=True, color="FFFFFF", size=11)
856
+ header_fill = PatternFill(
857
+ start_color="00377B", end_color="00377B", fill_type="solid"
858
+ )
859
+ thin_border = Border(
860
+ left=Side(style="thin"),
861
+ right=Side(style="thin"),
862
+ top=Side(style="thin"),
863
+ bottom=Side(style="thin"),
864
+ )
865
+
866
+ for col_idx, col_name in enumerate(columns, 1):
867
+ cell = ws.cell(row=1, column=col_idx, value=col_name)
868
+ cell.font = header_font
869
+ cell.fill = header_fill
870
+ cell.alignment = Alignment(horizontal="center")
871
+ cell.border = thin_border
872
+
873
+ for row_idx, row_data in enumerate(rows, 2):
874
+ for col_idx, col_name in enumerate(columns, 1):
875
+ value = row_data.get(col_name, "")
876
+ cell = ws.cell(row=row_idx, column=col_idx, value=value)
877
+ cell.border = thin_border
878
+ cell.alignment = Alignment(wrap_text=True)
879
+
880
+ for col_idx, col_name in enumerate(columns, 1):
881
+ max_len = len(str(col_name))
882
+ for row_idx in range(2, len(rows) + 2):
883
+ val = str(ws.cell(row=row_idx, column=col_idx).value or "")
884
+ max_len = max(max_len, min(len(val), 60))
885
+ ws.column_dimensions[ws.cell(row=1, column=col_idx).column_letter].width = (
886
+ max_len + 2
887
+ )
888
+
889
+ buf = io.BytesIO()
890
+ wb.save(buf)
891
+ buf.seek(0)
892
+ file_bytes = buf.read()
893
+
894
+ if not filename.endswith(".xlsx"):
895
+ filename = filename.rsplit(".", 1)[0] + ".xlsx"
896
+ content_type = (
897
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
898
+ )
899
+ extracted_text = f"[Excel file - {len(file_bytes)} bytes, {len(rows)} rows]"
900
+ content_base64 = base64.b64encode(file_bytes).decode()
901
+ size_bytes = len(file_bytes)
902
+ else:
903
+ import io
904
+ import csv
905
+
906
+ buf = io.StringIO()
907
+ writer = csv.DictWriter(buf, fieldnames=columns, extrasaction="ignore")
908
+ writer.writeheader()
909
+ for row_data in rows:
910
+ writer.writerow({k: v for k, v in row_data.items() if k in columns})
911
+ csv_text = buf.getvalue()
912
+
913
+ if not filename.endswith(".csv"):
914
+ filename = filename.rsplit(".", 1)[0] + ".csv"
915
+ content_type = "text/csv"
916
+ extracted_text = csv_text
917
+ content_base64 = ""
918
+ size_bytes = len(csv_text.encode())
919
+
920
+ row = (
921
+ supabase.table("chat_attachments")
922
+ .insert(
923
+ {
924
+ "session_id": session_id,
925
+ "filename": filename,
926
+ "content_type": content_type,
927
+ "size_bytes": size_bytes,
928
+ "extracted_text": extracted_text,
929
+ "content_base64": content_base64,
930
+ "status": "ready",
931
+ }
932
+ )
933
+ .execute()
934
+ .data[0]
935
+ )
936
+ return {
937
+ "attachment": {
938
+ "id": row["id"],
939
+ "filename": row["filename"],
940
+ "rows": len(rows),
941
+ "columns": columns,
942
+ "format": fmt,
943
+ }
944
+ }
945
+
946
+
947
+ def _get_version(arguments: dict) -> dict:
948
+ """Load a curriculum version snapshot with its course list."""
949
+ version_id = _require_int(arguments, "version_id")
950
+ version_row = first_row(
951
+ supabase.table("curriculum_versions").select("*").eq("id", version_id)
952
+ )
953
+ if not version_row:
954
+ raise ValueError(f"Version {version_id} not found")
955
+ snapshot_rows = (
956
+ supabase.table("finalized_submissions")
957
+ .select("refined_id,course_json")
958
+ .eq("curriculum_version_id", version_id)
959
+ .order("refined_id")
960
+ .execute()
961
+ .data
962
+ )
963
+ courses = []
964
+ for snap in snapshot_rows:
965
+ cj = snap.get("course_json") or {}
966
+ courses.append(
967
+ {
968
+ "refined_id": snap.get("refined_id"),
969
+ "course_code": cj.get("course_code", ""),
970
+ "course_title": cj.get("course_title", ""),
971
+ "semester": cj.get("semester", ""),
972
+ "credits": cj.get("credits", ""),
973
+ }
974
+ )
975
+ return {"version": version_row, "courses": courses, "course_count": len(courses)}
976
+
977
+
978
+ def _diff_versions(arguments: dict) -> dict:
979
+ """Compare two curriculum version snapshots. Shows added, removed, and changed courses."""
980
+ version_a_id = _require_int(arguments, "version_a")
981
+ version_b_id = _require_int(arguments, "version_b")
982
+
983
+ def _load_snapshot(vid: int) -> dict:
984
+ rows = (
985
+ supabase.table("finalized_submissions")
986
+ .select("refined_id,course_json")
987
+ .eq("curriculum_version_id", vid)
988
+ .execute()
989
+ .data
990
+ )
991
+ return {row["refined_id"]: row.get("course_json") or {} for row in rows}
992
+
993
+ snap_a = _load_snapshot(version_a_id)
994
+ snap_b = _load_snapshot(version_b_id)
995
+
996
+ all_ids = set(snap_a.keys()) | set(snap_b.keys())
997
+ added = []
998
+ removed = []
999
+ changed = []
1000
+ unchanged = []
1001
+
1002
+ for rid in sorted(all_ids):
1003
+ a = snap_a.get(rid)
1004
+ b = snap_b.get(rid)
1005
+ if a and not b:
1006
+ removed.append(
1007
+ {
1008
+ "refined_id": rid,
1009
+ "course_code": a.get("course_code", ""),
1010
+ "course_title": a.get("course_title", ""),
1011
+ }
1012
+ )
1013
+ elif b and not a:
1014
+ added.append(
1015
+ {
1016
+ "refined_id": rid,
1017
+ "course_code": b.get("course_code", ""),
1018
+ "course_title": b.get("course_title", ""),
1019
+ }
1020
+ )
1021
+ elif a != b:
1022
+ d = diff_course(a, b)
1023
+ changed.append(
1024
+ {
1025
+ "refined_id": rid,
1026
+ "course_code": b.get("course_code", ""),
1027
+ "course_title": b.get("course_title", ""),
1028
+ "change_percent": d.get("change_percent", 0),
1029
+ "syllabus_change_percent": d.get("syllabus_change_percent", 0),
1030
+ "topics_added": d.get("topics_added", []),
1031
+ "topics_removed": d.get("topics_removed", []),
1032
+ }
1033
+ )
1034
+ else:
1035
+ unchanged.append(rid)
1036
+
1037
+ return {
1038
+ "version_a": version_a_id,
1039
+ "version_b": version_b_id,
1040
+ "added": added,
1041
+ "removed": removed,
1042
+ "changed": changed,
1043
+ "unchanged_count": len(unchanged),
1044
+ "summary": {
1045
+ "added": len(added),
1046
+ "removed": len(removed),
1047
+ "changed": len(changed),
1048
+ "unchanged": len(unchanged),
1049
+ },
1050
+ }
1051
+
1052
+
1053
+ def _get_curriculum_stats(arguments: dict) -> dict:
1054
+ """Compute aggregate statistics for the curriculum or a specific semester."""
1055
+ query = (
1056
+ supabase.table("refined_submissions")
1057
+ .select(
1058
+ "semester,credits,course_type,credit_category,lecture_hours,practical_hours,visible,is_elective"
1059
+ )
1060
+ .in_("status", ["refined"])
1061
+ )
1062
+ if arguments.get("semester") is not None:
1063
+ query = query.eq("semester", int(arguments["semester"]))
1064
+ rows = query.execute().data
1065
+
1066
+ if not rows:
1067
+ return {"stats": {}, "message": "No courses found"}
1068
+
1069
+ by_semester: dict[int, dict] = {}
1070
+ for row in rows:
1071
+ sem = int(row.get("semester") or 0)
1072
+ if sem not in by_semester:
1073
+ by_semester[sem] = {
1074
+ "total": 0,
1075
+ "visible": 0,
1076
+ "total_credits": 0,
1077
+ "electives": 0,
1078
+ "course_types": {},
1079
+ "credit_categories": {},
1080
+ }
1081
+ s = by_semester[sem]
1082
+ s["total"] += 1
1083
+ if row.get("visible", True):
1084
+ s["visible"] += 1
1085
+ s["total_credits"] += int(row.get("credits") or 0)
1086
+ if row.get("is_elective"):
1087
+ s["electives"] += 1
1088
+ ct = str(row.get("course_type") or "Unknown")
1089
+ s["course_types"][ct] = s["course_types"].get(ct, 0) + 1
1090
+ cc = str(row.get("credit_category") or "?")
1091
+ s["credit_categories"][cc] = s["credit_categories"].get(cc, 0) + 1
1092
+
1093
+ total_credits = sum(s["total_credits"] for s in by_semester.values())
1094
+ total_courses = sum(s["total"] for s in by_semester.values())
1095
+
1096
+ return {
1097
+ "total_courses": total_courses,
1098
+ "total_credits": total_credits,
1099
+ "by_semester": {str(k): v for k, v in sorted(by_semester.items())},
1100
+ }
1101
+
1102
+
1103
+ def _batch_read_courses(arguments: dict) -> dict:
1104
+ """Read specific fields from multiple courses in one call. More efficient than multiple get_course_fields calls."""
1105
+ refined_ids = arguments.get("refined_ids")
1106
+ if not isinstance(refined_ids, list) or not refined_ids:
1107
+ raise ValueError("refined_ids must be a non-empty array of integers")
1108
+ fields = arguments.get("fields")
1109
+ if not isinstance(fields, list) or not fields:
1110
+ raise ValueError("fields must be a non-empty array of field names")
1111
+
1112
+ results = []
1113
+ for rid in refined_ids:
1114
+ rid = int(rid)
1115
+ course = refined_course(rid)
1116
+ if course is None:
1117
+ results.append({"refined_id": rid, "error": "Course not found"})
1118
+ continue
1119
+ entry = {"refined_id": rid}
1120
+ for field in fields:
1121
+ entry[field] = course.get(field)
1122
+ results.append(entry)
1123
+
1124
+ return {"courses": results, "count": len(results)}
1125
+
1126
+
1127
+ OBJECT = {"type": "object", "additionalProperties": False}
1128
+
1129
+ TOOLS: dict[str, AgentTool] = {
1130
+ "get_current_course_json": AgentTool(
1131
+ "get_current_course_json",
1132
+ "Read the current template-ready JSON for one refined course.",
1133
+ {
1134
+ **OBJECT,
1135
+ "properties": {"refined_id": {"type": "integer"}},
1136
+ "required": ["refined_id"],
1137
+ },
1138
+ _get_current_course,
1139
+ ),
1140
+ "get_course_codes": AgentTool(
1141
+ "get_course_codes",
1142
+ "Read lightweight course identifiers (refined_id, course_code, course_title, semester, program). Use for listing or quick lookups.",
1143
+ {
1144
+ **OBJECT,
1145
+ "properties": {"refined_id": {"type": "integer"}},
1146
+ "required": ["refined_id"],
1147
+ },
1148
+ _get_course_codes,
1149
+ ),
1150
+ "get_course_syllabus": AgentTool(
1151
+ "get_course_syllabus",
1152
+ "Read syllabus content: units, objectives, course_outcomes.",
1153
+ {
1154
+ **OBJECT,
1155
+ "properties": {"refined_id": {"type": "integer"}},
1156
+ "required": ["refined_id"],
1157
+ },
1158
+ _get_course_syllabus,
1159
+ ),
1160
+ "get_course_textbooks": AgentTool(
1161
+ "get_course_textbooks",
1162
+ "Read textbook fields: text_books, reference_books.",
1163
+ {
1164
+ **OBJECT,
1165
+ "properties": {"refined_id": {"type": "integer"}},
1166
+ "required": ["refined_id"],
1167
+ },
1168
+ _get_course_textbooks,
1169
+ ),
1170
+ "get_course_deterministic": AgentTool(
1171
+ "get_course_deterministic",
1172
+ "Read deterministic/protected fields: program, lecture_hours, tutorial_hours, practical_hours, self_study, credits, course_type. These cannot be changed by the agent.",
1173
+ {
1174
+ **OBJECT,
1175
+ "properties": {"refined_id": {"type": "integer"}},
1176
+ "required": ["refined_id"],
1177
+ },
1178
+ _get_course_deterministic,
1179
+ ),
1180
+ "get_course_lab": AgentTool(
1181
+ "get_course_lab",
1182
+ "Read lab experiments and tools/languages.",
1183
+ {
1184
+ **OBJECT,
1185
+ "properties": {"refined_id": {"type": "integer"}},
1186
+ "required": ["refined_id"],
1187
+ },
1188
+ _get_course_lab,
1189
+ ),
1190
+ "get_course_fields": AgentTool(
1191
+ "get_course_fields",
1192
+ "Read arbitrary specific fields from a course. Provide a list of field names. More efficient than fetching full JSON when you only need a subset.",
1193
+ {
1194
+ **OBJECT,
1195
+ "properties": {
1196
+ "refined_id": {"type": "integer"},
1197
+ "fields": {"type": "array", "items": {"type": "string"}, "minItems": 1},
1198
+ },
1199
+ "required": ["refined_id", "fields"],
1200
+ },
1201
+ _get_course_fields,
1202
+ ),
1203
+ "diff_course_json": AgentTool(
1204
+ "diff_course_json",
1205
+ "Compare two course JSON objects and return patch operations, changed percent, and syllabus topic changes.",
1206
+ {
1207
+ **OBJECT,
1208
+ "properties": {
1209
+ "current": {"type": "object"},
1210
+ "proposed": {"type": "object"},
1211
+ },
1212
+ "required": ["current", "proposed"],
1213
+ },
1214
+ _diff_course_json,
1215
+ ),
1216
+ "create_course_draft": AgentTool(
1217
+ "create_course_draft",
1218
+ "Create a human-reviewable draft for one course. This never applies changes to refined_submissions.",
1219
+ {
1220
+ **OBJECT,
1221
+ "properties": {
1222
+ "refined_id": {"type": "integer"},
1223
+ "fields": {"type": "object"},
1224
+ "reason": {"type": "string"},
1225
+ },
1226
+ "required": ["refined_id", "fields"],
1227
+ },
1228
+ _create_course_draft,
1229
+ ),
1230
+ "update_agent_draft": AgentTool(
1231
+ "update_agent_draft",
1232
+ "Update an existing proposed or blocked draft with new field values. Use this to modify a draft you already created instead of creating a duplicate. Merges the new fields into the existing proposed_json.",
1233
+ {
1234
+ **OBJECT,
1235
+ "properties": {
1236
+ "draft_id": {"type": "integer", "description": "The ID of the existing draft to update"},
1237
+ "fields": {"type": "object", "description": "Fields to update in the draft's proposed content"},
1238
+ "reason": {"type": "string", "description": "Why the draft is being updated"},
1239
+ },
1240
+ "required": ["draft_id", "fields"],
1241
+ },
1242
+ _update_agent_draft,
1243
+ ),
1244
+ "create_refined_course": AgentTool(
1245
+ "create_refined_course",
1246
+ "Create a new course directly in refined_submissions, or update an existing one by refined_id. Use this ONLY for brand-new courses that do not exist in the curriculum yet. For modifications to existing courses, use create_course_draft instead. Call signal_done immediately after this tool. Do not retry if this tool returns an error; report the error to the user instead.",
1247
+ {
1248
+ **OBJECT,
1249
+ "properties": {
1250
+ "refined_id": {
1251
+ "type": "integer",
1252
+ "description": "If provided, update this existing course. If omitted, create a new one.",
1253
+ },
1254
+ "course_code": {"type": "string", "description": "e.g. UE25CS353A"},
1255
+ "course_title": {"type": "string"},
1256
+ "semester": {"type": "integer", "minimum": 1, "maximum": 8},
1257
+ "target_department": {
1258
+ "type": "string",
1259
+ "description": "CSE, ECE, ME, BT, EEE, AIML",
1260
+ },
1261
+ "credit_category": {"type": "string", "description": "0, 2, 4, or 5"},
1262
+ "is_elective": {
1263
+ "type": "boolean",
1264
+ "description": "Set true for elective courses (specialization electives in semesters 5-6). Default false (core course).",
1265
+ },
1266
+ "units": {
1267
+ "type": "array",
1268
+ "description": "Course units. Each unit must have unit_number (int), title (str), content (str, the syllabus text), and hours (int).",
1269
+ "items": {
1270
+ "type": "object",
1271
+ "properties": {
1272
+ "unit_number": {"type": "integer"},
1273
+ "title": {"type": "string"},
1274
+ "content": {"type": "string"},
1275
+ "hours": {"type": "integer"},
1276
+ },
1277
+ "required": ["unit_number", "title", "content", "hours"],
1278
+ },
1279
+ },
1280
+ "objectives": {"type": "array", "items": {"type": "string"}, "description": "Learning objectives. Send as JSON array of strings."},
1281
+ "course_outcomes": {"type": "array", "items": {"type": "string"}, "description": "Measurable course outcomes. Send as JSON array of strings."},
1282
+ "text_books": {"type": "array", "items": {"type": "string"}, "description": "Textbook references with author, title, edition, publisher, year. Send as JSON array."},
1283
+ "reference_books": {"type": "array", "items": {"type": "string"}, "description": "Reference books. Send as JSON array."},
1284
+ "lab_experiments": {"type": "array", "items": {"type": "string"}, "description": "Lab experiments (only for credit_category 5). Send as JSON array."},
1285
+ "tools_languages": {"type": "string"},
1286
+ "prelude": {"type": "string"},
1287
+ "desirable_knowledge": {"type": "string"},
1288
+ },
1289
+ "required": [
1290
+ "course_code",
1291
+ "course_title",
1292
+ "semester",
1293
+ "target_department",
1294
+ "credit_category",
1295
+ ],
1296
+ },
1297
+ _create_refined_course,
1298
+ ),
1299
+ "get_curriculum_json": AgentTool(
1300
+ "get_curriculum_json",
1301
+ "Read template-ready JSON for the full curriculum, optionally filtered by semester.",
1302
+ {
1303
+ **OBJECT,
1304
+ "properties": {"semester": {"type": "integer", "minimum": 1, "maximum": 8}},
1305
+ },
1306
+ _get_curriculum_json,
1307
+ ),
1308
+ "create_document_draft": AgentTool(
1309
+ "create_document_draft",
1310
+ "Create one human-reviewable document draft containing proposed changes for multiple courses. This never applies changes.",
1311
+ {
1312
+ **OBJECT,
1313
+ "properties": {
1314
+ "courses": {
1315
+ "type": "array",
1316
+ "items": {
1317
+ "type": "object",
1318
+ "properties": {
1319
+ "refined_id": {"type": "integer"},
1320
+ "fields": {"type": "object"},
1321
+ },
1322
+ "required": ["refined_id", "fields"],
1323
+ "additionalProperties": False,
1324
+ },
1325
+ },
1326
+ "reason": {"type": "string"},
1327
+ "uploaded_document_id": {"type": "string"},
1328
+ "curriculum_version_id": {"type": "integer"},
1329
+ },
1330
+ "required": ["courses"],
1331
+ },
1332
+ _create_document_draft,
1333
+ ),
1334
+ "get_course_draft": AgentTool(
1335
+ "get_course_draft",
1336
+ "Read one staged course draft and its diff summary.",
1337
+ {
1338
+ **OBJECT,
1339
+ "properties": {"draft_id": {"type": "integer"}},
1340
+ "required": ["draft_id"],
1341
+ },
1342
+ _get_course_draft,
1343
+ ),
1344
+ "get_document_draft": AgentTool(
1345
+ "get_document_draft",
1346
+ "Read one staged document draft and all linked course drafts.",
1347
+ {
1348
+ **OBJECT,
1349
+ "properties": {"document_draft_id": {"type": "integer"}},
1350
+ "required": ["document_draft_id"],
1351
+ },
1352
+ _get_document_draft,
1353
+ ),
1354
+ "get_preview_url": AgentTool(
1355
+ "get_preview_url",
1356
+ "Return the preview URL for a course, course draft, or document draft.",
1357
+ {
1358
+ **OBJECT,
1359
+ "properties": {
1360
+ "kind": {
1361
+ "type": "string",
1362
+ "enum": ["course", "draft", "document_draft"],
1363
+ },
1364
+ "id": {"type": "integer"},
1365
+ },
1366
+ "required": ["kind", "id"],
1367
+ },
1368
+ _get_preview_url,
1369
+ ),
1370
+ "list_courses": AgentTool(
1371
+ "list_courses",
1372
+ "List refined course IDs and titles, optionally filtered by semester.",
1373
+ {
1374
+ **OBJECT,
1375
+ "properties": {"semester": {"type": "integer", "minimum": 1, "maximum": 8}},
1376
+ },
1377
+ _list_courses,
1378
+ ),
1379
+ "get_attachment_text": AgentTool(
1380
+ "get_attachment_text",
1381
+ "Read extracted text for uploaded chat attachments within a chat session.",
1382
+ {
1383
+ **OBJECT,
1384
+ "properties": {
1385
+ "session_id": {"type": "integer"},
1386
+ "attachment_ids": {"type": "array", "items": {"type": "integer"}},
1387
+ },
1388
+ "required": ["session_id", "attachment_ids"],
1389
+ },
1390
+ _attachment_text,
1391
+ ),
1392
+ "fetch_url": AgentTool(
1393
+ "fetch_url",
1394
+ "Fetch a public URL and return its text content. Use to read web pages, public documents, and online resources.",
1395
+ {**OBJECT, "properties": {"url": {"type": "string"}}, "required": ["url"]},
1396
+ _fetch_url,
1397
+ ),
1398
+ "web_search": AgentTool(
1399
+ "web_search",
1400
+ "Search the web and return top results with titles, snippets, and URLs. Use for finding current information, documentation, or references.",
1401
+ {
1402
+ **OBJECT,
1403
+ "properties": {
1404
+ "query": {"type": "string", "description": "Search query"},
1405
+ "num_results": {
1406
+ "type": "integer",
1407
+ "minimum": 1,
1408
+ "maximum": 10,
1409
+ "default": 5,
1410
+ },
1411
+ },
1412
+ "required": ["query"],
1413
+ },
1414
+ _web_search,
1415
+ ),
1416
+ "create_report": AgentTool(
1417
+ "create_report",
1418
+ "Save a generated document (report, comparison, summary, etc.) as a chat attachment accessible to the user. Use after reading source documents and generating new content. Supports markdown (.md) or PDF (.pdf) output.",
1419
+ {
1420
+ **OBJECT,
1421
+ "properties": {
1422
+ "session_id": {"type": "integer"},
1423
+ "content": {
1424
+ "type": "string",
1425
+ "description": "Full report/document content in markdown format",
1426
+ },
1427
+ "filename": {
1428
+ "type": "string",
1429
+ "description": "Filename including extension, e.g. comparison-report.md or comparison-report.pdf",
1430
+ },
1431
+ "format": {
1432
+ "type": "string",
1433
+ "enum": ["markdown", "pdf"],
1434
+ "default": "markdown",
1435
+ "description": "Output format: markdown or pdf",
1436
+ },
1437
+ },
1438
+ "required": ["session_id", "content"],
1439
+ },
1440
+ _create_report,
1441
+ ),
1442
+ "create_curriculum_version": AgentTool(
1443
+ "create_curriculum_version",
1444
+ "Create a named curriculum version snapshot (like a git commit). Use to checkpoint the curriculum state after a set of changes. Provide a descriptive name like 'feat: add CS201 lab experiments' or 'fix: correct credit hours for ECE301'.",
1445
+ {
1446
+ **OBJECT,
1447
+ "properties": {
1448
+ "name": {
1449
+ "type": "string",
1450
+ "description": "Descriptive version name (conventional commit style encouraged)",
1451
+ },
1452
+ },
1453
+ "required": ["name"],
1454
+ },
1455
+ _create_curriculum_version,
1456
+ ),
1457
+ "define_specialization": AgentTool(
1458
+ "define_specialization",
1459
+ "Create a specialization track (e.g. Machine Intelligence and Data Science) for a given semester. The letter (A, B, C...) is auto-assigned if omitted. Used to set up the elective specialization brackets.",
1460
+ {
1461
+ **OBJECT,
1462
+ "properties": {
1463
+ "semester": {
1464
+ "type": "integer",
1465
+ "minimum": 1,
1466
+ "maximum": 8,
1467
+ "description": "Semester the specialization applies to (5 or 6 for electives)",
1468
+ },
1469
+ "name": {
1470
+ "type": "string",
1471
+ "description": "Full specialization name, e.g. 'Machine Intelligence and Data Science (MIDS)'",
1472
+ },
1473
+ "letter": {
1474
+ "type": "string",
1475
+ "description": "Optional letter label (A, B, C). Auto-assigned if omitted.",
1476
+ },
1477
+ "key": {
1478
+ "type": "string",
1479
+ "description": "Optional short key (SCC, MIDS, CSCS). Derived from name if omitted.",
1480
+ },
1481
+ "academic_year": {
1482
+ "type": "string",
1483
+ "description": "Optional academic year batch, e.g. 2025-26",
1484
+ },
1485
+ },
1486
+ "required": ["semester", "name"],
1487
+ },
1488
+ _define_specialization,
1489
+ ),
1490
+ "list_specializations": AgentTool(
1491
+ "list_specializations",
1492
+ "List specialization track definitions, optionally filtered by semester. Use to discover which tracks exist before assigning electives.",
1493
+ {
1494
+ **OBJECT,
1495
+ "properties": {"semester": {"type": "integer", "minimum": 1, "maximum": 8}},
1496
+ },
1497
+ _list_specializations,
1498
+ ),
1499
+ "assign_elective_to_tracks": AgentTool(
1500
+ "assign_elective_to_tracks",
1501
+ "Categorize an elective course into one or more specialization tracks by their specialization_id. Also marks the course as an elective. Use after determining (e.g. via AI analysis) which tracks the course belongs to.",
1502
+ {
1503
+ **OBJECT,
1504
+ "properties": {
1505
+ "refined_id": {"type": "integer"},
1506
+ "specialization_ids": {
1507
+ "type": "array",
1508
+ "items": {"type": "integer"},
1509
+ "description": "Specialization track IDs to assign the elective to",
1510
+ },
1511
+ },
1512
+ "required": ["refined_id", "specialization_ids"],
1513
+ },
1514
+ _assign_elective_to_tracks,
1515
+ ),
1516
+ "remove_elective_from_tracks": AgentTool(
1517
+ "remove_elective_from_tracks",
1518
+ "Remove an elective course from one or more specialization tracks.",
1519
+ {
1520
+ **OBJECT,
1521
+ "properties": {
1522
+ "refined_id": {"type": "integer"},
1523
+ "specialization_ids": {
1524
+ "type": "array",
1525
+ "items": {"type": "integer"},
1526
+ "description": "Specialization track IDs to remove the elective from",
1527
+ },
1528
+ },
1529
+ "required": ["refined_id", "specialization_ids"],
1530
+ },
1531
+ _remove_elective_from_tracks,
1532
+ ),
1533
+ "get_course_assignments": AgentTool(
1534
+ "get_course_assignments",
1535
+ "Return which specialization tracks a course currently belongs to, including the track definitions.",
1536
+ {
1537
+ **OBJECT,
1538
+ "properties": {"refined_id": {"type": "integer"}},
1539
+ "required": ["refined_id"],
1540
+ },
1541
+ _get_course_assignments,
1542
+ ),
1543
+ "categorize_elective": AgentTool(
1544
+ "categorize_elective",
1545
+ "Run guarded AI categorization for a refined elective. It assigns only existing semester tracks with high confidence; all other results require human confirmation.",
1546
+ {
1547
+ **OBJECT,
1548
+ "properties": {"refined_id": {"type": "integer"}},
1549
+ "required": ["refined_id"],
1550
+ },
1551
+ _categorize_elective,
1552
+ ),
1553
+ "update_deterministic_fields": AgentTool(
1554
+ "update_deterministic_fields",
1555
+ "Create a reviewable draft that changes deterministic/protected fields (program, lecture_hours, tutorial_hours, practical_hours, self_study, credits, course_type). This is the ONLY way to modify those fields. The resulting draft is blocked until the user explicitly approves it in the Review panel. Always confirm with the user before calling this.",
1556
+ {
1557
+ **OBJECT,
1558
+ "properties": {
1559
+ "refined_id": {"type": "integer"},
1560
+ "fields": {
1561
+ "type": "object",
1562
+ "description": 'Protected fields to change, e.g. {"credits": 5, "lecture_hours": 4}',
1563
+ },
1564
+ "reason": {"type": "string"},
1565
+ },
1566
+ "required": ["refined_id", "fields"],
1567
+ },
1568
+ _update_deterministic_fields,
1569
+ ),
1570
+ "signal_done": AgentTool(
1571
+ "signal_done",
1572
+ "Signal that the agent has completed the user's request. Provide a concise summary of what was accomplished. This ends the agent's turn.",
1573
+ {
1574
+ **OBJECT,
1575
+ "properties": {
1576
+ "summary": {
1577
+ "type": "string",
1578
+ "description": "Brief summary of what was done, e.g. 'Created draft for CS201 adding Unit 5 on Graph Algorithms'",
1579
+ },
1580
+ },
1581
+ "required": ["summary"],
1582
+ },
1583
+ _signal_done,
1584
+ ),
1585
+ "ask_user": AgentTool(
1586
+ "ask_user",
1587
+ "Ask the user a clarifying question before proceeding. Call this when you need more information (credit category, semester, course code, elective vs core, etc.). The stream ends after this tool, and the user will respond in their next message.",
1588
+ {
1589
+ **OBJECT,
1590
+ "properties": {
1591
+ "question": {
1592
+ "type": "string",
1593
+ "description": "The question to ask the user",
1594
+ },
1595
+ },
1596
+ "required": ["question"],
1597
+ },
1598
+ _ask_user,
1599
+ ),
1600
+ "create_spreadsheet": AgentTool(
1601
+ "create_spreadsheet",
1602
+ "Generate a CSV or Excel file from structured row data and save as a downloadable chat attachment. Provide column names and row objects.",
1603
+ {
1604
+ **OBJECT,
1605
+ "properties": {
1606
+ "session_id": {"type": "integer"},
1607
+ "columns": {
1608
+ "type": "array",
1609
+ "items": {"type": "string"},
1610
+ "description": "Column names in display order",
1611
+ },
1612
+ "rows": {
1613
+ "type": "array",
1614
+ "items": {"type": "object"},
1615
+ "description": "Array of row objects, keys matching column names",
1616
+ },
1617
+ "filename": {
1618
+ "type": "string",
1619
+ "description": "Filename including extension, e.g. semester-3-courses.xlsx",
1620
+ },
1621
+ "format": {"type": "string", "enum": ["csv", "xlsx"], "default": "csv"},
1622
+ },
1623
+ "required": ["session_id", "columns", "rows"],
1624
+ },
1625
+ _create_spreadsheet,
1626
+ ),
1627
+ "get_version": AgentTool(
1628
+ "get_version",
1629
+ "Load a curriculum version snapshot with its metadata and course list. Use to inspect what a version contains before comparing or restoring.",
1630
+ {
1631
+ **OBJECT,
1632
+ "properties": {
1633
+ "version_id": {"type": "integer", "description": "Version snapshot ID"},
1634
+ },
1635
+ "required": ["version_id"],
1636
+ },
1637
+ _get_version,
1638
+ ),
1639
+ "diff_versions": AgentTool(
1640
+ "diff_versions",
1641
+ "Compare two curriculum version snapshots. Returns added, removed, and changed courses with per-course change percentages.",
1642
+ {
1643
+ **OBJECT,
1644
+ "properties": {
1645
+ "version_a": {
1646
+ "type": "integer",
1647
+ "description": "First version ID (base)",
1648
+ },
1649
+ "version_b": {
1650
+ "type": "integer",
1651
+ "description": "Second version ID (target)",
1652
+ },
1653
+ },
1654
+ "required": ["version_a", "version_b"],
1655
+ },
1656
+ _diff_versions,
1657
+ ),
1658
+ "get_curriculum_stats": AgentTool(
1659
+ "get_curriculum_stats",
1660
+ "Compute aggregate curriculum statistics: total courses, credits per semester, course type distribution, visible vs hidden counts. Optionally filter to one semester.",
1661
+ {
1662
+ **OBJECT,
1663
+ "properties": {
1664
+ "semester": {
1665
+ "type": "integer",
1666
+ "minimum": 1,
1667
+ "maximum": 8,
1668
+ "description": "Optional: stats for one semester only",
1669
+ },
1670
+ },
1671
+ },
1672
+ _get_curriculum_stats,
1673
+ ),
1674
+ "batch_read_courses": AgentTool(
1675
+ "batch_read_courses",
1676
+ "Read specific fields from multiple courses in one call. More efficient than calling get_course_fields multiple times. Returns an array of course field subsets.",
1677
+ {
1678
+ **OBJECT,
1679
+ "properties": {
1680
+ "refined_ids": {
1681
+ "type": "array",
1682
+ "items": {"type": "integer"},
1683
+ "description": "List of course refined IDs to read",
1684
+ },
1685
+ "fields": {
1686
+ "type": "array",
1687
+ "items": {"type": "string"},
1688
+ "description": "Field names to read for each course",
1689
+ },
1690
+ },
1691
+ "required": ["refined_ids", "fields"],
1692
+ },
1693
+ _batch_read_courses,
1694
+ ),
1695
+ }
backend/app/services/attachments.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import subprocess
3
+ import tempfile
4
+ import zipfile
5
+ from pathlib import Path
6
+ from xml.etree import ElementTree
7
+
8
+ MAX_FILE_BYTES = 8 * 1024 * 1024
9
+ MAX_TEXT_CHARS = 200000
10
+ SPACE = re.compile(r"[ \t]+")
11
+ BLANKS = re.compile(r"\n{3,}")
12
+
13
+
14
+ def extract_text(filename: str, content_type: str, data: bytes) -> tuple[str, str, str]:
15
+ if len(data) > MAX_FILE_BYTES:
16
+ return "", "failed", "File is larger than 8 MB"
17
+ suffix = Path(filename or "").suffix.lower()
18
+ try:
19
+ if suffix == ".pdf" or content_type == "application/pdf":
20
+ return _clean(_pdf_text(data)), "ready", ""
21
+ if suffix == ".docx":
22
+ return _clean(_docx_text(data)), "ready", ""
23
+ if suffix == ".xlsx":
24
+ return _clean(_xlsx_text(data)), "ready", ""
25
+ if suffix in {".txt", ".md", ".csv"} or content_type.startswith("text/"):
26
+ return _clean(_decode(data)), "ready", ""
27
+ if content_type.startswith("image/"):
28
+ return "", "unsupported", "Image OCR is not enabled yet"
29
+ return "", "unsupported", "File type is not supported yet"
30
+ except Exception as exc:
31
+ return "", "failed", str(exc)
32
+
33
+
34
+ def _pdf_text(data: bytes) -> str:
35
+ with tempfile.TemporaryDirectory() as temp_dir:
36
+ path = Path(temp_dir) / "upload.pdf"
37
+ path.write_bytes(data)
38
+ result = subprocess.run(
39
+ ["pdftotext", "-layout", str(path), "-"],
40
+ check=True,
41
+ capture_output=True,
42
+ text=True,
43
+ timeout=30,
44
+ )
45
+ return result.stdout
46
+
47
+
48
+ def _docx_text(data: bytes) -> str:
49
+ with tempfile.TemporaryDirectory() as temp_dir:
50
+ path = Path(temp_dir) / "upload.docx"
51
+ path.write_bytes(data)
52
+ with zipfile.ZipFile(path) as docx:
53
+ xml = docx.read("word/document.xml")
54
+ root = ElementTree.fromstring(xml)
55
+ ns = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
56
+ lines = []
57
+ for paragraph in root.iter(f"{ns}p"):
58
+ text = "".join(node.text or "" for node in paragraph.iter(f"{ns}t")).strip()
59
+ if text:
60
+ lines.append(text)
61
+ return "\n".join(lines)
62
+
63
+
64
+ def _xlsx_text(data: bytes) -> str:
65
+ import openpyxl
66
+ with tempfile.TemporaryDirectory() as temp_dir:
67
+ path = Path(temp_dir) / "upload.xlsx"
68
+ path.write_bytes(data)
69
+ wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
70
+ rows = []
71
+ for sheet in wb:
72
+ for row in sheet.iter_rows(values_only=True):
73
+ line = "\t".join(str(c or "") for c in row)
74
+ if line.strip():
75
+ rows.append(line)
76
+ return "\n".join(rows)
77
+
78
+
79
+ def _decode(data: bytes) -> str:
80
+ for encoding in ("utf-8", "utf-16", "latin-1"):
81
+ try:
82
+ return data.decode(encoding)
83
+ except UnicodeDecodeError:
84
+ continue
85
+ return data.decode("utf-8", errors="ignore")
86
+
87
+
88
+ def _clean(text: str) -> str:
89
+ text = SPACE.sub(" ", text.replace("\r\n", "\n").replace("\r", "\n"))
90
+ text = BLANKS.sub("\n\n", text).strip()
91
+ return text[:MAX_TEXT_CHARS]
backend/app/services/books.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+ WORD = re.compile(r"\w+")
4
+ BOOK_STOP = re.compile(
5
+ r"\bCourse\b[\s\S]{0,160}\bOutcome\b|"
6
+ r"\b(Course\s+Objectives?|Assignment\s*/|Laboratory|Recommended\s+Materials)\b",
7
+ re.IGNORECASE,
8
+ )
9
+ BOOK_LABEL = re.compile(
10
+ r"(?im)^\s*(?:Text\s*)?Book\(s\):\s*|"
11
+ r"^\s*Text\s+Book\(s\):\s*|"
12
+ r"^\s*Reference\s*(?:Book\(s\):)?\s*"
13
+ )
14
+ BOOK_NUMBER = re.compile(r"(?:^|\n|\s{2,}|(?<=\.)\s+)\d+\s*[:.)]\s*")
15
+ ORDINAL_LINE = re.compile(r"(?im)^\s*(st|nd|rd|th)\s*$")
16
+ PAGE_NOISE = re.compile(
17
+ r"P\.?\s*E\.?\s*S\.?\s*University|"
18
+ r"Curriculum|"
19
+ r"\s*:-\s*[A-Za-z]*\s*\d{4}\s*-\s*\d{4}\b|"
20
+ r"\b\d+\s*\|\s*Page\b",
21
+ re.IGNORECASE,
22
+ )
23
+ TEXT_BOOK_LINE = re.compile(r"\bText\s+Book\(s\):", re.IGNORECASE)
24
+ REFERENCE_LINE = re.compile(r"^\s*Reference\b", re.IGNORECASE)
25
+ SECTION_END_LINE = re.compile(r"^\s*(Course\s+Outcome|Assignment\s*/|Course\s+Objectives?|Laboratory)\b", re.IGNORECASE)
26
+
27
+
28
+ def _words(text: str) -> int:
29
+ return len(WORD.findall(text))
30
+
31
+
32
+ def _flatten(values) -> str:
33
+ parts = []
34
+ for value in values:
35
+ if not value:
36
+ continue
37
+ if isinstance(value, list):
38
+ parts.extend(str(item) for item in value if str(item).strip())
39
+ else:
40
+ parts.append(str(value))
41
+ text = "\n".join(parts)
42
+ match = BOOK_STOP.search(text)
43
+ if match:
44
+ text = text[: match.start()]
45
+ text = ORDINAL_LINE.sub("", text)
46
+ text = PAGE_NOISE.sub("\n", text)
47
+ return BOOK_LABEL.sub("", text)
48
+
49
+
50
+ def _clean(value: str) -> str:
51
+ value = BOOK_LABEL.sub("", value)
52
+ value = re.sub(r"\bBook\(s\):?\s*", "", value, flags=re.IGNORECASE)
53
+ value = re.sub(r"-\s+", "-", value)
54
+ return re.sub(r"\s+", " ", value).strip(" :")
55
+
56
+
57
+ def parse_books(*values) -> list[str]:
58
+ text = _flatten(values)
59
+ if not text.strip():
60
+ return []
61
+
62
+ numbered = [part for part in BOOK_NUMBER.split(text) if part.strip()]
63
+ if numbered and (len(numbered) > 1 or BOOK_NUMBER.search(text)):
64
+ parts = numbered
65
+ else:
66
+ parts = [line for line in text.splitlines() if line.strip()]
67
+
68
+ books = []
69
+ seen = set()
70
+ for part in parts:
71
+ item = _clean(part)
72
+ if not item:
73
+ continue
74
+ if books and (item.startswith("(") or (_words(item) <= 3 and not re.search(r"\b\d{4}\b", item))):
75
+ books[-1] = f"{books[-1]} {item}".strip()
76
+ continue
77
+ key = re.sub(r"[^a-z0-9]+", " ", item.lower()).strip()
78
+ if key not in seen:
79
+ books.append(item)
80
+ seen.add(key)
81
+ return books
82
+
83
+
84
+ def raw_book_section(raw_content: str, kind: str) -> str:
85
+ lines = (raw_content or "").splitlines()
86
+ start = None
87
+ for index, line in enumerate(lines):
88
+ if kind == "text" and TEXT_BOOK_LINE.search(line):
89
+ start = index
90
+ break
91
+ if kind == "reference" and REFERENCE_LINE.search(line):
92
+ start = index
93
+ break
94
+ if start is None:
95
+ return ""
96
+
97
+ end = len(lines)
98
+ for index in range(start + 1, len(lines)):
99
+ if kind == "text" and REFERENCE_LINE.search(lines[index]):
100
+ end = index
101
+ break
102
+ if SECTION_END_LINE.search(lines[index]):
103
+ end = index
104
+ break
105
+ return "\n".join(lines[start:end])
backend/app/services/curriculum.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from os import environ
2
+
3
+ from app import cache
4
+ from app.preview import build_course_preview
5
+ from app.services.diffing import diff_course, merge_fields, validate_draft
6
+ from app.supabase import first_row, supabase
7
+
8
+ DEFAULT_CURRICULUM_YEAR = environ.get("CURRICULUM_YEAR", "").strip()
9
+
10
+
11
+ def invalidate_curriculum_cache():
12
+ """Invalidate all cached curriculum PDF/HTML after data changes."""
13
+ cache.invalidate("full_pdf:")
14
+ cache.invalidate("full_html:")
15
+ cache.invalidate("sem_pdf:")
16
+ cache.invalidate("course:")
17
+ cache.invalidate("course_html:")
18
+ cache.invalidate("course_pdf:")
19
+ cache.invalidate("sem_courses:")
20
+ cache.invalidate("courses_list")
21
+ cache.invalidate("pending_courses")
22
+ cache.invalidate("all_course_ids")
23
+ cache.invalidate("ver_preview:")
24
+ SOURCE_ORDER = {
25
+ "UE25CS151A": 1,
26
+ "UE25CS151B": 1,
27
+ "UE24CS251A": 1,
28
+ "UE24CS252A": 2,
29
+ "UE24MA242A": 3,
30
+ "UE24CS241A": 4,
31
+ "UE24CS243A": 5,
32
+ "UZ24UZ221A": 6,
33
+ "UE25MA201A*": 7,
34
+ "UE24CS251B": 1,
35
+ "UE24CS252B": 2,
36
+ "UE24CS241B": 3,
37
+ "UE24CS242B": 4,
38
+ "UE24MA241B": 5,
39
+ "UZ24UZ221B": 6,
40
+ "UE25MA201B*": 7,
41
+ "UE23CS351A": 1,
42
+ "UE23CS352A": 2,
43
+ "UE23CS341A": 3,
44
+ "UE23CS342AAX": 4,
45
+ "UE23CS343ABX": 5,
46
+ "UE23CS320A": 6,
47
+ "UE23CS351B": 1,
48
+ "UE23CS352B": 2,
49
+ "UE23CS341B": 3,
50
+ "UE23CS342BAX": 4,
51
+ "UE23CS343BBX": 5,
52
+ "UE23CS320B": 6,
53
+ "UE22CS441A": 1,
54
+ "UZ22UZ422A": 2,
55
+ "UE22AM421AXX": 3,
56
+ "UE22CS421B": 1,
57
+ "UE22CS461XB": 2,
58
+ }
59
+
60
+ REFINED_FIELDS = {
61
+ "semester",
62
+ "course_code",
63
+ "course_title",
64
+ "program",
65
+ "lecture_hours",
66
+ "tutorial_hours",
67
+ "practical_hours",
68
+ "self_study",
69
+ "credits",
70
+ "course_type",
71
+ "is_elective",
72
+ "tools_languages",
73
+ "desirable_knowledge",
74
+ "prelude",
75
+ "objectives",
76
+ "course_outcomes",
77
+ "units",
78
+ "lab_experiments",
79
+ "text_books",
80
+ "reference_books",
81
+ "status",
82
+ }
83
+
84
+
85
+ def attach_submissions(rows: list[dict]) -> list[dict]:
86
+ ids = [row["submission_id"] for row in rows if row.get("submission_id")]
87
+ if not ids:
88
+ return rows
89
+ cache_key = f"attach:{','.join(str(i) for i in sorted(ids))}"
90
+ cached = cache.get(cache_key)
91
+ if cached is not None:
92
+ for row in rows:
93
+ row["_submission"] = cached.get(row.get("submission_id"), {})
94
+ return rows
95
+ submissions = supabase.table("submissions").select("*").in_("id", ids).execute().data
96
+ by_id = {row["id"]: row for row in submissions}
97
+ cache.put(cache_key, by_id, ttl=300)
98
+ for row in rows:
99
+ row["_submission"] = by_id.get(row.get("submission_id"), {})
100
+ return rows
101
+
102
+
103
+ def ordered_courses(rows: list[dict]) -> list[dict]:
104
+ rows = attach_submissions(rows)
105
+ rows.sort(key=course_sort_key)
106
+ return [build_course_preview(row) for row in rows]
107
+
108
+
109
+ def course_credits(row: dict) -> int:
110
+ value = row.get("credits")
111
+ if value not in (None, ""):
112
+ return int(value)
113
+ category = str((row.get("_submission") or {}).get("credit_category") or "").strip()
114
+ if category.isdigit():
115
+ return int(category)
116
+ return 0
117
+
118
+
119
+ def course_sort_key(row: dict) -> tuple[int, int, int, int]:
120
+ semester = int(row.get("semester") or 0)
121
+ code = str(row.get("course_code") or "").replace(" ", "").upper()
122
+ order = SOURCE_ORDER.get(code)
123
+ if order is None and semester == 5:
124
+ order = elective_order(code, "AA", "AB")
125
+ if order is None and semester == 6:
126
+ order = elective_order(code, "BA", "BB")
127
+ return semester, -course_credits(row), order or 900, int(row.get("id") or 0)
128
+
129
+
130
+ def elective_order(code: str, first_group: str, second_group: str) -> int | None:
131
+ for offset, group in ((100, first_group), (200, second_group)):
132
+ if group in code:
133
+ suffix = code.rsplit(group, 1)[-1].rstrip("X")
134
+ return offset + int(suffix) if suffix.isdigit() else offset
135
+ return None
136
+
137
+
138
+ def create_version_snapshot(name: str) -> dict | None:
139
+ rows = supabase.table("refined_submissions").select("*").in_("status", ["refined"]).execute().data
140
+ rows = attach_submissions(rows)
141
+ courses = [{"refined_id": row["id"], "course_json": build_course_preview(row)} for row in rows]
142
+ program = courses[0]["course_json"].get("program") if courses else ""
143
+ result = (
144
+ supabase.table("curriculum_versions")
145
+ .insert({"name": name, "program": program, "academic_year": selected_curriculum_year(), "status": "draft"})
146
+ .execute()
147
+ )
148
+ version = (result.data or [None])[0]
149
+ if not version:
150
+ return None
151
+ if courses:
152
+ records = [{**course, "curriculum_version_id": version["id"]} for course in courses]
153
+ supabase.table("finalized_submissions").insert(records).execute()
154
+ return version
155
+
156
+
157
+ def selected_curriculum_year(override: str | None = None) -> str:
158
+ if override and override.strip():
159
+ return override.strip()
160
+ return DEFAULT_CURRICULUM_YEAR
161
+
162
+
163
+ def refined_course(refined_id: int) -> dict:
164
+ cache_key = f"course:{refined_id}"
165
+ cached = cache.get(cache_key)
166
+ if cached is not None:
167
+ return cached
168
+ row = first_row(supabase.table("refined_submissions").select("*").eq("id", refined_id))
169
+ if not row:
170
+ raise LookupError("Refined submission not found")
171
+ result = build_course_preview(attach_submissions([row])[0])
172
+ cache.put(cache_key, result, ttl=300)
173
+ return result
174
+
175
+
176
+ def update_refined_fields(refined_id: int, fields: dict) -> dict | None:
177
+ update = {key: fields[key] for key in REFINED_FIELDS if key in fields}
178
+ for key in ("semester", "lecture_hours", "tutorial_hours", "practical_hours", "self_study", "credits"):
179
+ if key in update:
180
+ try:
181
+ update[key] = int(update[key] or 0)
182
+ except (TypeError, ValueError) as exc:
183
+ raise ValueError(f"Invalid value for {key}: {update[key]!r}. Must be a number.") from exc
184
+ is_elective = update.get("is_elective")
185
+ if is_elective is None:
186
+ existing = first_row(supabase.table("refined_submissions").select("is_elective").eq("id", refined_id))
187
+ is_elective = existing.get("is_elective") if existing else False
188
+ if is_elective:
189
+ update["course_type"] = "Elective"
190
+ result = supabase.table("refined_submissions").update(update).eq("id", refined_id).execute()
191
+ invalidate_curriculum_cache()
192
+ return result.data[0] if result.data else None
193
+
194
+
195
+ def draft_record(refined_id: int, fields: dict, reason: str = "", document_draft_id: int | None = None) -> dict:
196
+ base = refined_course(refined_id)
197
+ proposed = merge_fields(base, fields)
198
+ summary = diff_course(base, proposed)
199
+ issues = validate_draft(base, proposed)
200
+ summary["validation_issues"] = issues
201
+ return {
202
+ "refined_id": refined_id,
203
+ "document_draft_id": document_draft_id,
204
+ "base_refined_json": base,
205
+ "proposed_json": proposed,
206
+ "json_patch": summary.pop("json_patch"),
207
+ "diff_summary": summary,
208
+ "change_reason": reason.strip(),
209
+ "status": "blocked" if issues else "proposed",
210
+ }
211
+
212
+
213
+ def load_agent_draft(draft_id: int) -> dict:
214
+ draft = first_row(supabase.table("agent_drafts").select("*").eq("id", draft_id))
215
+ if not draft:
216
+ raise LookupError("Agent draft not found")
217
+ return draft
218
+
219
+
220
+ def load_document_draft(document_draft_id: int) -> dict:
221
+ document = first_row(supabase.table("agent_document_drafts").select("*").eq("id", document_draft_id))
222
+ if not document:
223
+ raise LookupError("Document draft not found")
224
+ drafts = supabase.table("agent_drafts").select("*").eq("document_draft_id", document_draft_id).order("id").execute().data
225
+ return {"document_draft": document, "drafts": drafts}
backend/app/services/deterministic.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ _HOURS_MAP = {
2
+ "5": {"lecture_hours": 4, "tutorial_hours": 0, "practical_hours": 2, "self_study": 5, "credits": 5},
3
+ "4": {"lecture_hours": 4, "tutorial_hours": 0, "practical_hours": 0, "self_study": 4, "credits": 4},
4
+ "2": {"lecture_hours": 2, "tutorial_hours": 0, "practical_hours": 0, "self_study": 2, "credits": 2},
5
+ "0": {"lecture_hours": 0, "tutorial_hours": 0, "practical_hours": 0, "self_study": 0, "credits": 0},
6
+ }
7
+
8
+ _PROGRAM_MAP = {
9
+ "CSE": "B. TECH",
10
+ "AIML": "B. TECH",
11
+ "ECE": "B. TECH",
12
+ "ME": "B. TECH",
13
+ "EEE": "B. TECH",
14
+ "BT": "B. TECH",
15
+ }
16
+
17
+ _COURSE_TYPE_MAP = {
18
+ "0": "Foundation Course",
19
+ "5": "Core Course-Lab Integrated",
20
+ "4": "Core Course",
21
+ "2": "Core Theory",
22
+ }
23
+
24
+
25
+ def compute_hours(cat: str) -> dict:
26
+ result = _HOURS_MAP.get(cat)
27
+ if result is None:
28
+ raise ValueError(f"Unknown credit_category: {cat!r}. Expected one of: {sorted(_HOURS_MAP)}")
29
+ return result
30
+
31
+
32
+ def compute_program(dept: str) -> str:
33
+ result = _PROGRAM_MAP.get(dept)
34
+ if result is None:
35
+ raise ValueError(f"Unknown department: {dept!r}. Expected one of: {sorted(_PROGRAM_MAP)}")
36
+ return result
37
+
38
+
39
+ def compute_course_type(cat: str) -> str:
40
+ result = _COURSE_TYPE_MAP.get(cat)
41
+ if result is None:
42
+ raise ValueError(f"Unknown credit_category: {cat!r}. Expected one of: {sorted(_COURSE_TYPE_MAP)}")
43
+ return result
backend/app/services/diffing.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import difflib
2
+ import json
3
+ import re
4
+ from copy import deepcopy
5
+ from typing import Any
6
+
7
+ PROTECTED_FIELDS = {
8
+ "program",
9
+ "lecture_hours",
10
+ "tutorial_hours",
11
+ "practical_hours",
12
+ "self_study",
13
+ "credits",
14
+ "course_type",
15
+ }
16
+
17
+ TOPIC_SPLIT = re.compile(r"[,;\n]+")
18
+ SPACE = re.compile(r"\s+")
19
+
20
+
21
+ def stable_json(value: Any) -> str:
22
+ return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False)
23
+
24
+
25
+ def build_patch(old: Any, new: Any, path: str = "") -> list[dict]:
26
+ if isinstance(old, dict) and isinstance(new, dict):
27
+ patch = []
28
+ old_keys = set(old)
29
+ new_keys = set(new)
30
+ for key in sorted(old_keys - new_keys):
31
+ patch.append({"op": "remove", "path": f"{path}/{_escape(key)}"})
32
+ for key in sorted(new_keys - old_keys):
33
+ patch.append({"op": "add", "path": f"{path}/{_escape(key)}", "value": new[key]})
34
+ for key in sorted(old_keys & new_keys):
35
+ patch.extend(build_patch(old[key], new[key], f"{path}/{_escape(key)}"))
36
+ return patch
37
+
38
+ if old != new:
39
+ return [{"op": "replace", "path": path or "/", "value": new}]
40
+ return []
41
+
42
+
43
+ def apply_patch(value: Any, patch: list[dict]) -> Any:
44
+ result = deepcopy(value)
45
+ for op in patch:
46
+ name = op.get("op")
47
+ path = op.get("path")
48
+ if name not in {"add", "remove", "replace"} or not isinstance(path, str):
49
+ raise ValueError("Unsupported patch operation")
50
+ parent, key = _resolve_parent(result, path)
51
+ if isinstance(parent, list):
52
+ index = int(key)
53
+ if name == "remove":
54
+ parent.pop(index)
55
+ elif name == "replace":
56
+ parent[index] = op.get("value")
57
+ else:
58
+ parent.insert(index, op.get("value"))
59
+ continue
60
+ if name == "remove":
61
+ parent.pop(key, None)
62
+ else:
63
+ parent[key] = op.get("value")
64
+ return result
65
+
66
+
67
+ def diff_course(old: dict, new: dict) -> dict:
68
+ old_text = stable_json(old)
69
+ new_text = stable_json(new)
70
+ patch = build_patch(old, new)
71
+ syllabus_old = _syllabus_text(old)
72
+ syllabus_new = _syllabus_text(new)
73
+ topics_old = _topics(syllabus_old)
74
+ topics_new = _topics(syllabus_new)
75
+
76
+ def _is_protected(field: str) -> bool:
77
+ if field == "course_type" and new.get("is_elective"):
78
+ return False
79
+ return field in PROTECTED_FIELDS
80
+
81
+ return {
82
+ "json_patch": patch,
83
+ "patch_operations": len(patch),
84
+ "change_percent": _change_percent(old_text, new_text),
85
+ "syllabus_change_percent": _change_percent(syllabus_old, syllabus_new),
86
+ "topics_added": sorted(topics_new - topics_old),
87
+ "topics_removed": sorted(topics_old - topics_new),
88
+ "protected_changes": sorted(field for field in PROTECTED_FIELDS if _is_protected(field) and _field_value(old, field) != _field_value(new, field)),
89
+ "unified_diff": "\n".join(
90
+ difflib.unified_diff(
91
+ old_text.splitlines(),
92
+ new_text.splitlines(),
93
+ fromfile="current",
94
+ tofile="proposed",
95
+ lineterm="",
96
+ )
97
+ ),
98
+ }
99
+
100
+
101
+ def validate_draft(old: dict, new: dict) -> list[str]:
102
+ issues = []
103
+ for field in PROTECTED_FIELDS:
104
+ if field == "course_type" and new.get("is_elective"):
105
+ continue
106
+ if _field_value(old, field) != _field_value(new, field):
107
+ issues.append(f"{field} is deterministic and cannot be changed by an agent draft")
108
+ return issues
109
+
110
+
111
+ def merge_fields(base: dict, fields: dict) -> dict:
112
+ merged = deepcopy(base)
113
+ for key, value in fields.items():
114
+ if key in PROTECTED_FIELDS and str(base.get(key) or "") == str(value or ""):
115
+ value = base.get(key)
116
+ merged[key] = value
117
+ if merged.get("is_elective"):
118
+ merged["course_type"] = "Elective"
119
+ return merged
120
+
121
+
122
+ def _field_value(value: dict, field: str) -> str:
123
+ return str(value.get(field) or "")
124
+
125
+
126
+ def _escape(value: str) -> str:
127
+ return value.replace("~", "~0").replace("/", "~1")
128
+
129
+
130
+ def _unescape(value: str) -> str:
131
+ return value.replace("~1", "/").replace("~0", "~")
132
+
133
+
134
+ def _resolve_parent(value: Any, path: str) -> tuple[Any, str]:
135
+ if path in {"", "/"}:
136
+ raise ValueError("Root patch operations are not supported")
137
+ parts = [_unescape(part) for part in path.strip("/").split("/")]
138
+ current = value
139
+ for part in parts[:-1]:
140
+ current = current[int(part)] if isinstance(current, list) else current[part]
141
+ return current, parts[-1]
142
+
143
+
144
+ def _change_percent(old: str, new: str) -> float:
145
+ if not old and not new:
146
+ return 0.0
147
+ ratio = difflib.SequenceMatcher(None, old, new).ratio()
148
+ return round((1 - ratio) * 100, 2)
149
+
150
+
151
+ def _syllabus_text(value: dict) -> str:
152
+ units = value.get("units") or []
153
+ if not isinstance(units, list):
154
+ return ""
155
+ parts = []
156
+ for unit in units:
157
+ if isinstance(unit, dict):
158
+ parts.append(str(unit.get("title") or ""))
159
+ parts.append(str(unit.get("content") or ""))
160
+ return SPACE.sub(" ", " ".join(parts)).strip()
161
+
162
+
163
+ def _topics(text: str) -> set[str]:
164
+ topics = set()
165
+ for item in TOPIC_SPLIT.split(text):
166
+ topic = SPACE.sub(" ", item).strip(" .:-").lower()
167
+ if len(topic) >= 4:
168
+ topics.add(topic)
169
+ return topics
170
+
171
+
172
+ def diff_text_field(old: str, new: str) -> dict | None:
173
+ if old == new:
174
+ return None
175
+ return {"kind": "text", "old": old or "", "new": new or ""}
176
+
177
+
178
+ def diff_list_field(old: list, new: list) -> dict | None:
179
+ if old == new:
180
+ return None
181
+ old_set = set(old)
182
+ new_set = set(new)
183
+ return {
184
+ "kind": "list",
185
+ "removed": sorted(old_set - new_set),
186
+ "added": sorted(new_set - old_set),
187
+ "unchanged": sorted(old_set & new_set),
188
+ }
189
+
190
+
191
+ def diff_units_field(old_units: list, new_units: list) -> dict | None:
192
+ if old_units == new_units:
193
+ return None
194
+ old_by_title = {u.get("title", ""): u for u in old_units if isinstance(u, dict)}
195
+ new_by_title = {u.get("title", ""): u for u in new_units if isinstance(u, dict)}
196
+ all_titles = sorted(set(old_by_title.keys()) | set(new_by_title.keys()))
197
+ units_diff = []
198
+ for title in all_titles:
199
+ old_u = old_by_title.get(title)
200
+ new_u = new_by_title.get(title)
201
+ if old_u and not new_u:
202
+ units_diff.append({"kind": "removed", "unit": old_u})
203
+ elif new_u and not old_u:
204
+ units_diff.append({"kind": "added", "unit": new_u})
205
+ else:
206
+ unit_changes = {}
207
+ for field in ("title", "content", "hours"):
208
+ ov = old_u.get(field, "")
209
+ nv = new_u.get(field, "")
210
+ if ov != nv:
211
+ unit_changes[field] = {"old": ov, "new": nv}
212
+ if unit_changes:
213
+ units_diff.append({"kind": "changed", "unit": new_u, "changes": unit_changes})
214
+ else:
215
+ units_diff.append({"kind": "unchanged", "unit": new_u})
216
+ return {"kind": "units", "units": units_diff}
217
+
218
+
219
+ def build_course_diff(base: dict, proposed: dict) -> dict:
220
+ diff = {
221
+ "course_title": diff_text_field(base.get("course_title", ""), proposed.get("course_title", "")),
222
+ "course_code": diff_text_field(base.get("course_code", ""), proposed.get("course_code", "")),
223
+ "program": diff_text_field(base.get("program", ""), proposed.get("program", "")),
224
+ "lecture_hours": diff_text_field(str(base.get("lecture_hours", "")), str(proposed.get("lecture_hours", ""))),
225
+ "tutorial_hours": diff_text_field(str(base.get("tutorial_hours", "")), str(proposed.get("tutorial_hours", ""))),
226
+ "practical_hours": diff_text_field(str(base.get("practical_hours", "")), str(proposed.get("practical_hours", ""))),
227
+ "self_study": diff_text_field(str(base.get("self_study", "")), str(proposed.get("self_study", ""))),
228
+ "credits": diff_text_field(str(base.get("credits", "")), str(proposed.get("credits", ""))),
229
+ "course_type": diff_text_field(base.get("course_type", ""), proposed.get("course_type", "")),
230
+ "semester": diff_text_field(str(base.get("semester", "")), str(proposed.get("semester", ""))),
231
+ "tools_languages": diff_text_field(base.get("tools_languages", ""), proposed.get("tools_languages", "")),
232
+ "desirable_knowledge": diff_text_field(base.get("desirable_knowledge", ""), proposed.get("desirable_knowledge", "")),
233
+ "prelude": diff_text_field(base.get("prelude", ""), proposed.get("prelude", "")),
234
+ "objectives": diff_list_field(base.get("objectives") or [], proposed.get("objectives") or []),
235
+ "course_outcomes": diff_list_field(base.get("course_outcomes") or [], proposed.get("course_outcomes") or []),
236
+ "units": diff_units_field(base.get("units") or [], proposed.get("units") or []),
237
+ "lab_experiments": diff_list_field(base.get("lab_experiments") or [], proposed.get("lab_experiments") or []),
238
+ "text_books": diff_list_field(base.get("text_books") or [], proposed.get("text_books") or []),
239
+ "reference_books": diff_list_field(base.get("reference_books") or [], proposed.get("reference_books") or []),
240
+ }
241
+ return diff
backend/app/services/elective_categorization.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Guarded AI categorization for newly refined elective courses."""
2
+
3
+ import logging
4
+
5
+ from app.services.openrouter import call as llm
6
+ from app.supabase import first_row, supabase
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ MIN_CONFIDENCE = 0.80
11
+ ELECTIVE_MARKERS = ("AA", "AB", "BA", "BB")
12
+ SYSTEM_PROMPT = """You categorize PES University elective courses into existing specialization tracks.
13
+ Return only valid JSON in this exact shape:
14
+ {"confidence": 0.0, "assignments": [{"specialization_id": 1, "confidence": 0.0, "reasoning": "Short evidence-based reason."}]}
15
+ Use only IDs supplied in the prompt. Never invent a track or ID. Assign only clear matches.
16
+ For no clear match, return an empty assignments list and confidence below 0.80.
17
+ """
18
+
19
+
20
+ def is_elective_course(course: dict) -> bool:
21
+ """Mirror the legacy elective-code grouping rule for semesters 5 and 6."""
22
+ code = str(course.get("course_code") or "").replace(" ", "").upper()
23
+ return int(course.get("semester") or 0) in (5, 6) and any(
24
+ marker in code for marker in ELECTIVE_MARKERS
25
+ )
26
+
27
+
28
+ def _course_content(course: dict) -> str:
29
+ """Extract a flat text representation of course content for the LLM prompt."""
30
+ units = course.get("units") or []
31
+ unit_text = []
32
+ if isinstance(units, list):
33
+ for unit in units:
34
+ if isinstance(unit, dict):
35
+ unit_text.append(
36
+ f"{unit.get('title', '')}: {unit.get('content', '')}".strip()
37
+ )
38
+ else:
39
+ unit_text.append(str(unit))
40
+ parts = [
41
+ course.get("course_title"),
42
+ course.get("prelude"),
43
+ course.get("objectives"),
44
+ course.get("course_outcomes"),
45
+ *unit_text,
46
+ course.get("tools_languages"),
47
+ ]
48
+ return "\n".join(str(p) for p in parts if p).strip()[:18000]
49
+
50
+
51
+ def _confirmation(reason: str, **extra) -> dict:
52
+ return {"assigned": False, "needs_human_confirmation": True, "reason": reason, **extra}
53
+
54
+
55
+ def _fetch_tracks(semester: int) -> list[dict]:
56
+ """Return specialization definitions for the given semester."""
57
+ return (
58
+ supabase.table("specialization_definitions")
59
+ .select("id,semester,letter,name,key,academic_year")
60
+ .eq("semester", semester)
61
+ .order("letter")
62
+ .execute()
63
+ .data
64
+ )
65
+
66
+
67
+ def _validate_assignments(assignments: list, valid_ids: set[int]) -> dict | None:
68
+ """Validate each assignment entry; returns an error confirmation or None on success."""
69
+ seen: set[int] = set()
70
+ for assignment in assignments:
71
+ try:
72
+ spec_id = int(assignment["specialization_id"])
73
+ conf = float(assignment["confidence"])
74
+ reasoning = str(assignment["reasoning"]).strip()
75
+ except (KeyError, TypeError, ValueError):
76
+ return _confirmation("invalid_model_assignment")
77
+ if spec_id not in valid_ids:
78
+ return _confirmation(
79
+ "unknown_specialization_id", specialization_id=spec_id
80
+ )
81
+ if conf < MIN_CONFIDENCE or not reasoning:
82
+ return _confirmation(
83
+ "low_assignment_confidence", specialization_id=spec_id
84
+ )
85
+ if spec_id not in seen:
86
+ seen.add(spec_id)
87
+ return None
88
+
89
+
90
+ def _persist_assignments(refined_id: int, accepted: list[dict]) -> int:
91
+ """Insert accepted assignments into the DB; returns the number created."""
92
+ created = 0
93
+ for assignment in accepted:
94
+ spec_id = assignment["specialization_id"]
95
+ exists = (
96
+ supabase.table("course_specialization_assignments")
97
+ .select("id")
98
+ .eq("refined_id", refined_id)
99
+ .eq("specialization_id", spec_id)
100
+ .execute()
101
+ .data
102
+ )
103
+ if not exists:
104
+ supabase.table("course_specialization_assignments").insert(
105
+ {"refined_id": refined_id, "specialization_id": spec_id}
106
+ ).execute()
107
+ created += 1
108
+ return created
109
+
110
+
111
+ def _parse_llm_response(result) -> tuple[float, list[dict]] | None:
112
+ """Parse and validate the LLM response; returns (confidence, assignments) or None."""
113
+ if not isinstance(result, dict):
114
+ return None
115
+ try:
116
+ confidence = float(result.get("confidence"))
117
+ except (TypeError, ValueError):
118
+ return None
119
+ assignments = result.get("assignments")
120
+ if not isinstance(assignments, list) or not assignments:
121
+ return None
122
+ return confidence, assignments
123
+
124
+
125
+ def categorize_refined_elective(refined_id: int) -> dict:
126
+ """Assign only existing specialization tracks with validated high-confidence output."""
127
+ course = first_row(
128
+ supabase.table("refined_submissions").select("*").eq("id", refined_id)
129
+ )
130
+ if not course:
131
+ raise LookupError("Refined submission not found")
132
+ if not course.get("is_elective") and not is_elective_course(course):
133
+ return {
134
+ "assigned": False,
135
+ "needs_human_confirmation": False,
136
+ "reason": "not_an_elective",
137
+ }
138
+ if not course.get("is_elective"):
139
+ supabase.table("refined_submissions").update({"is_elective": True}).eq(
140
+ "id", refined_id
141
+ ).execute()
142
+
143
+ tracks = _fetch_tracks(int(course["semester"]))
144
+ if not tracks:
145
+ return _confirmation("no_specializations_for_semester", refined_id=refined_id)
146
+
147
+ try:
148
+ result = llm(
149
+ SYSTEM_PROMPT,
150
+ f"""Course ID: {refined_id}
151
+ Course code: {course.get("course_code") or ""}
152
+ Course content:
153
+ {_course_content(course)}
154
+ Available specialization tracks: {tracks}""",
155
+ )
156
+ except Exception:
157
+ logger.exception(
158
+ "Elective categorization model call failed for refined_id=%s", refined_id
159
+ )
160
+ return _confirmation("model_error", refined_id=refined_id)
161
+
162
+ parsed = _parse_llm_response(result)
163
+ if parsed is None:
164
+ return _confirmation("invalid_model_response", refined_id=refined_id)
165
+
166
+ confidence, assignments = parsed
167
+ if confidence < MIN_CONFIDENCE:
168
+ return _confirmation(
169
+ "low_confidence_or_no_match", refined_id=refined_id, confidence=confidence
170
+ )
171
+
172
+ valid_ids = {int(track["id"]) for track in tracks}
173
+ validation_error = _validate_assignments(assignments, valid_ids)
174
+ if validation_error:
175
+ validation_error["refined_id"] = refined_id
176
+ return validation_error
177
+
178
+ seen: set[int] = set()
179
+ accepted: list[dict] = []
180
+ for assignment in assignments:
181
+ spec_id = int(assignment["specialization_id"])
182
+ if spec_id not in seen:
183
+ seen.add(spec_id)
184
+ accepted.append(
185
+ {
186
+ "specialization_id": spec_id,
187
+ "confidence": float(assignment["confidence"]),
188
+ "reasoning": str(assignment["reasoning"]).strip(),
189
+ }
190
+ )
191
+
192
+ created = _persist_assignments(refined_id, accepted)
193
+ logger.info(
194
+ "Elective categorization refined_id=%s assignments=%s", refined_id, accepted
195
+ )
196
+ return {
197
+ "assigned": True,
198
+ "needs_human_confirmation": False,
199
+ "refined_id": refined_id,
200
+ "assignments_created": created,
201
+ "assignments": accepted,
202
+ }
backend/app/services/errors.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+
3
+ import sentry_sdk
4
+ from fastapi import HTTPException
5
+ from postgrest.exceptions import APIError
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ def database_http_exception(exc: APIError) -> HTTPException:
11
+ message = str(exc)
12
+ if "schema cache" in message and "Could not find the table" in message:
13
+ return HTTPException(status_code=503, detail="Required database tables are missing. Run docs/schema.sql in Supabase.")
14
+ logger.error("Supabase APIError: %s", message)
15
+ sentry_sdk.capture_exception(exc)
16
+ return HTTPException(status_code=500, detail=f"Database request failed: {message[:200]}")
backend/app/services/openrouter.py ADDED
@@ -0,0 +1,475 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ import os
4
+ import random
5
+ import time
6
+
7
+ import httpx
8
+ from dotenv import load_dotenv
9
+ from httpx import Client, HTTPError, HTTPStatusError
10
+
11
+ load_dotenv("../.env")
12
+
13
+ URL = os.environ["OPENROUTER_URL"].strip()
14
+ KEY = os.environ["OPENROUTER_API_KEY"].strip()
15
+ MODEL = os.environ["OPENROUTER_MODEL"].strip()
16
+ FALLBACK_MODEL = os.environ.get("OPENROUTER_FALLBACK_MODEL", "").strip() or None
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _context_length: int | None = None
20
+ _last_fetch_attempt: float = 0.0
21
+
22
+
23
+ def fetch_context_length() -> int:
24
+ global _context_length, _last_fetch_attempt
25
+ if _context_length is not None:
26
+ return _context_length
27
+ now = time.time()
28
+ if now - _last_fetch_attempt < 60:
29
+ return _context_length or 128000
30
+ _last_fetch_attempt = now
31
+ api_base = URL.rsplit("/chat/completions", 1)[0]
32
+ try:
33
+ with Client(timeout=15) as client:
34
+ resp = client.get(f"{api_base}/model/{MODEL}", headers=_headers())
35
+ resp.raise_for_status()
36
+ data = resp.json().get("data") or {}
37
+ _context_length = int(data.get("context_length") or 128000)
38
+ logger.info("Model %s context_length=%d", MODEL, _context_length)
39
+ return _context_length
40
+ except HTTPStatusError as exc:
41
+ if exc.response.status_code == 404:
42
+ logger.debug("Model info endpoint not available for %s (provider does not support it)", MODEL)
43
+ else:
44
+ logger.warning("Failed to fetch context_length for %s: %s", MODEL, exc)
45
+ return 128000
46
+ except Exception as exc:
47
+ logger.warning("Failed to fetch context_length for %s: %s", MODEL, exc)
48
+ return 128000
49
+
50
+
51
+ def context_length() -> int:
52
+ return _context_length or 128000
53
+
54
+
55
+ _TOOL_LABELS = {
56
+ "get_course_codes": "Looking up courses",
57
+ "get_current_course_json": "Reading course data",
58
+ "get_course_syllabus": "Reading syllabus",
59
+ "get_course_textbooks": "Reading textbooks",
60
+ "get_course_deterministic": "Reading course properties",
61
+ "get_course_lab": "Reading lab details",
62
+ "get_course_fields": "Reading course fields",
63
+ "batch_read_courses": "Reading courses",
64
+ "get_curriculum_json": "Loading curriculum",
65
+ "get_curriculum_stats": "Computing statistics",
66
+ "create_course_draft": "Creating draft",
67
+ "create_refined_course": "Creating course",
68
+ "create_document_draft": "Creating document draft",
69
+ "create_report": "Generating report",
70
+ "create_spreadsheet": "Generating spreadsheet",
71
+ "diff_course_json": "Comparing courses",
72
+ "diff_versions": "Comparing versions",
73
+ "get_version": "Loading snapshot",
74
+ "update_deterministic_fields": "Updating course",
75
+ "get_attachment_text": "Reading attachment",
76
+ "list_specializations": "Loading specializations",
77
+ "define_specialization": "Creating specialization",
78
+ "assign_elective_to_tracks": "Categorizing elective",
79
+ "get_course_assignments": "Reading elective assignments",
80
+ "categorize_elective": "Categorizing elective",
81
+ "fetch_url": "Fetching URL",
82
+ "web_search": "Searching the web",
83
+ "signal_done": "Finalizing",
84
+ "get_document_draft": "Reading document draft",
85
+ "create_curriculum_version": "Creating snapshot",
86
+ "get_course_draft": "Reading draft",
87
+ "remove_elective_from_tracks": "Removing elective",
88
+ "ask_user": "Asking user",
89
+ "get_preview_url": "Getting preview URL",
90
+ "list_courses": "Looking up courses",
91
+ "update_agent_draft": "Updating draft",
92
+ }
93
+
94
+
95
+ def tool_status_label(name: str) -> str:
96
+ return _TOOL_LABELS.get(name, name.replace("_", " ").title())
97
+
98
+
99
+ class OpenRouterError(RuntimeError):
100
+ def __init__(self, status_code: int, retry_after: str | None = None, message: str | None = None, provider_message: str = ""):
101
+ self.status_code = status_code
102
+ self.retry_after = retry_after
103
+ self._message = message
104
+ self.provider_message = provider_message
105
+ super().__init__(self.message)
106
+
107
+ @property
108
+ def message(self) -> str:
109
+ if self._message:
110
+ return self._message
111
+ if self.status_code in {429, 503}:
112
+ if self.retry_after:
113
+ return f"Model provider is rate limited. Try again in {self.retry_after} seconds."
114
+ return "Model provider is rate limited. Please try again shortly."
115
+ return "Model provider request failed. Please try again later."
116
+
117
+
118
+ def _messages(system: str, messages: list[dict]) -> list[dict]:
119
+ return [{"role": "system", "content": system}, *messages]
120
+
121
+
122
+ def _headers() -> dict:
123
+ return {"Authorization": f"Bearer {KEY}"}
124
+
125
+
126
+ def _message_content(data: dict) -> str:
127
+ try:
128
+ return _assistant_message(data)["content"].strip()
129
+ except (KeyError, TypeError, AttributeError) as exc:
130
+ raise OpenRouterError(502, message="Model provider returned an invalid response. Please try again later.", provider_message=str(data.get("error") or data)[:500]) from exc
131
+
132
+
133
+ def _raise_for_status(response) -> None:
134
+ try:
135
+ response.raise_for_status()
136
+ except HTTPStatusError as exc:
137
+ raise OpenRouterError(
138
+ exc.response.status_code,
139
+ exc.response.headers.get("retry-after"),
140
+ provider_message=exc.response.text[:500],
141
+ ) from exc
142
+
143
+
144
+ _RETRYABLE = (httpx.ReadError, httpx.ConnectError, httpx.RemoteProtocolError, httpx.TimeoutException)
145
+ _RETRYABLE_STATUS = {502, 503}
146
+ _MAX_RETRIES = 3
147
+ _FIB = [1, 1, 2, 3, 5, 8, 13]
148
+
149
+
150
+ def _retry_sleep(attempt: int, retry_after: str | None = None) -> None:
151
+ if retry_after:
152
+ try:
153
+ time.sleep(min(float(retry_after), 30))
154
+ return
155
+ except (ValueError, TypeError):
156
+ pass
157
+ base = _FIB[min(attempt, len(_FIB) - 1)]
158
+ jitter = base * 0.25 * (2 * random.random() - 1)
159
+ time.sleep(max(0.5, base + jitter))
160
+
161
+
162
+ def _provider_error(data: dict) -> str:
163
+ error = data.get("error") if isinstance(data, dict) else None
164
+ if isinstance(error, dict):
165
+ return str(error.get("message") or error)
166
+ return str(error or data)[:500]
167
+
168
+
169
+ def _public_shape_error(provider_message: str) -> str:
170
+ lowered = provider_message.lower()
171
+ if "tool" in lowered and ("support" in lowered or "unsupported" in lowered):
172
+ return "The selected model does not support editor tools. Switch to a tool-calling model and try again."
173
+ if "rate" in lowered or "quota" in lowered:
174
+ return "Model provider is rate limited. Please try again shortly."
175
+ return "Model provider returned an invalid response. Please try again later."
176
+
177
+
178
+ def _assistant_message(data: dict) -> dict:
179
+ try:
180
+ message = data["choices"][0]["message"]
181
+ except (KeyError, IndexError, TypeError) as exc:
182
+ provider_message = _provider_error(data)
183
+ raise OpenRouterError(502, message=_public_shape_error(provider_message), provider_message=provider_message) from exc
184
+ if not isinstance(message, dict):
185
+ raise OpenRouterError(502, message="Model provider returned an invalid response. Please try again later.", provider_message=str(message)[:500])
186
+ return message
187
+
188
+
189
+ def _chat_message(messages: list[dict], tools: list[dict], model: str = MODEL) -> tuple[dict, int]:
190
+ with Client(timeout=120) as client:
191
+ response = client.post(URL, headers=_headers(), json={"model": model, "messages": messages, "tools": tools})
192
+ _raise_for_status(response)
193
+ data = response.json()
194
+ prompt_tokens = (data.get("usage") or {}).get("prompt_tokens") or 0
195
+ return _assistant_message(data), prompt_tokens
196
+
197
+
198
+ def _chat_with_tools(messages: list[dict], tools: list[dict], tool_runner, on_tool_result=None):
199
+ last_prompt_tokens = 0
200
+ active_model = MODEL
201
+ switched_to_fallback = False
202
+
203
+ for i in range(50):
204
+ yield {"$status": f"Thinking (step {i + 1})..."}
205
+ message = None
206
+ prompt_tokens = 0
207
+
208
+ models_to_try = [active_model]
209
+
210
+ for model in models_to_try:
211
+ for attempt in range(_MAX_RETRIES):
212
+ try:
213
+ message, prompt_tokens = _chat_message(messages, tools, model=model)
214
+ break
215
+ except OpenRouterError as exc:
216
+ if exc.status_code == 429:
217
+ yield {"$status": f"Rate limited by {model}. {exc.message}"}
218
+ logger.warning("Rate limited by %s (attempt %d/%d): %s", model, attempt + 1, _MAX_RETRIES, exc.message)
219
+ raise
220
+ if exc.status_code in _RETRYABLE_STATUS:
221
+ logger.warning("Provider %s error %d (attempt %d/%d): %s", model, exc.status_code, attempt + 1, _MAX_RETRIES, exc.message)
222
+ if attempt < _MAX_RETRIES - 1:
223
+ yield {"$status": f"Server error ({exc.status_code}). Retrying {model} (attempt {attempt + 2}/{_MAX_RETRIES})..."}
224
+ _retry_sleep(attempt, exc.retry_after)
225
+ continue
226
+ raise
227
+ except Exception as exc:
228
+ logger.warning("Network error with %s (attempt %d/%d): %s", model, attempt + 1, _MAX_RETRIES, exc)
229
+ if attempt < _MAX_RETRIES - 1:
230
+ yield {"$status": f"Connection error. Retrying {model} (attempt {attempt + 2}/{_MAX_RETRIES})..."}
231
+ _retry_sleep(attempt)
232
+ continue
233
+
234
+ if message is not None:
235
+ break
236
+
237
+ if model == MODEL and FALLBACK_MODEL and not switched_to_fallback:
238
+ switched_to_fallback = True
239
+ active_model = FALLBACK_MODEL
240
+ yield {"$status": "Primary model unavailable. Switching to backup model..."}
241
+ logger.info("Primary model %s failed after %d attempts, switching to %s", MODEL, _MAX_RETRIES, FALLBACK_MODEL)
242
+ models_to_try.append(FALLBACK_MODEL)
243
+
244
+ if message is None:
245
+ raise OpenRouterError(502, message="All model providers failed after retries. Please try again later.")
246
+
247
+ if prompt_tokens:
248
+ last_prompt_tokens = prompt_tokens
249
+
250
+ tool_calls = message.get("tool_calls") or []
251
+ if not tool_calls:
252
+ text = str(message.get("content") or "").strip()
253
+ if text:
254
+ yield text
255
+ elif i > 0:
256
+ yield "The agent finished without a response. Try rephrasing your request or breaking it into smaller steps."
257
+ if last_prompt_tokens:
258
+ yield {"$usage": {"prompt_tokens": last_prompt_tokens, "context_length": context_length()}}
259
+ return
260
+ messages.append({"role": "assistant", "content": message.get("content") or "", "tool_calls": tool_calls})
261
+
262
+ ack_text = str(message.get("content") or "").strip()
263
+ if ack_text:
264
+ yield ack_text
265
+
266
+ done = False
267
+ result = {}
268
+ for tool_call in tool_calls:
269
+ name = (tool_call.get("function") or {}).get("name") or ""
270
+ try:
271
+ arguments = _tool_arguments(tool_call)
272
+ except (ValueError, json.JSONDecodeError) as exc:
273
+ result = {"error": f"Invalid tool arguments: {exc}"}
274
+ yield {"$status": f"{tool_status_label(name)}..."}
275
+ yield {"$event": "tool_result", "name": name, "status": "error"}
276
+ messages.append({"role": "tool", "tool_call_id": tool_call.get("id") or name, "name": name, "content": json.dumps(result, ensure_ascii=False)})
277
+ continue
278
+ yield {"$status": f"{tool_status_label(name)}..."}
279
+ yield {"$event": "tool_call", "name": name, "arguments": arguments}
280
+ try:
281
+ result = _run_tool(tool_runner, name, arguments)
282
+ except ValueError as exc:
283
+ result = {"error": str(exc)}
284
+ yield {"$event": "tool_result", "name": name, "status": "ok" if "error" not in result else "error"}
285
+ if on_tool_result:
286
+ on_tool_result(name, result)
287
+ content = json.dumps(result, ensure_ascii=False)
288
+ if len(content) > 50000:
289
+ content = content[:50000] + '... [truncated; use semester filter or batch_read_courses for smaller results]'
290
+ messages.append(
291
+ {
292
+ "role": "tool",
293
+ "tool_call_id": tool_call.get("id") or name,
294
+ "name": name,
295
+ "content": content,
296
+ }
297
+ )
298
+ if (result or {}).get("done"):
299
+ done = True
300
+ if done:
301
+ summary = str((result or {}).get("summary") or "").strip()
302
+ if summary:
303
+ yield summary
304
+ if last_prompt_tokens:
305
+ yield {"$usage": {"prompt_tokens": last_prompt_tokens, "context_length": context_length()}}
306
+ return
307
+ yield "I created tool results, but could not finish the response. Please check the Review panel."
308
+ if last_prompt_tokens:
309
+ yield {"$usage": {"prompt_tokens": last_prompt_tokens, "context_length": context_length()}}
310
+
311
+
312
+ def _tool_arguments(tool_call: dict) -> dict:
313
+ raw = (tool_call.get("function") or {}).get("arguments") or "{}"
314
+ arguments = json.loads(raw) if isinstance(raw, str) else raw
315
+ if not isinstance(arguments, dict):
316
+ raise ValueError("Tool arguments must be an object")
317
+ return arguments
318
+
319
+
320
+ def _run_tool(tool_runner, name: str, arguments: dict) -> dict:
321
+ try:
322
+ return tool_runner(name, arguments)
323
+ except Exception as exc:
324
+ return {"error": str(exc)}
325
+
326
+
327
+ def _log_stream_fallback(exc: Exception) -> None:
328
+ if isinstance(exc, HTTPStatusError):
329
+ response = exc.response
330
+ logger.warning(
331
+ "OpenRouter streaming failed; retrying without stream. status=%s model=%s body=%s",
332
+ response.status_code,
333
+ MODEL,
334
+ response.text[:500],
335
+ )
336
+ return
337
+ logger.warning("OpenRouter streaming failed; retrying without stream. error=%s model=%s", exc.__class__.__name__, MODEL)
338
+
339
+
340
+ def _stream_chat(messages: list[dict], model: str = MODEL):
341
+ payload = {"model": model, "messages": messages, "stream": True}
342
+ last_exc = None
343
+ for attempt in range(_MAX_RETRIES):
344
+ try:
345
+ with Client(timeout=120) as client:
346
+ with client.stream("POST", URL, headers=_headers(), json=payload) as response:
347
+ if response.is_error:
348
+ response.read()
349
+ response.raise_for_status()
350
+ for line in response.iter_lines():
351
+ token = _stream_token(line)
352
+ if token:
353
+ yield token
354
+ return
355
+ except _RETRYABLE as exc:
356
+ last_exc = exc
357
+ logger.warning("Network error in stream with %s (attempt %d/%d): %s", model, attempt + 1, _MAX_RETRIES, exc)
358
+ if attempt < _MAX_RETRIES - 1:
359
+ _retry_sleep(attempt)
360
+ except OpenRouterError as exc:
361
+ last_exc = exc
362
+ if exc.status_code in _RETRYABLE_STATUS:
363
+ logger.warning("Provider %s error %d in stream (attempt %d/%d): %s", model, exc.status_code, attempt + 1, _MAX_RETRIES, exc.message)
364
+ if attempt < _MAX_RETRIES - 1:
365
+ _retry_sleep(attempt, exc.retry_after)
366
+ continue
367
+ raise
368
+ if model == MODEL and FALLBACK_MODEL:
369
+ logger.info("Primary model %s failed in stream, falling back to %s", MODEL, FALLBACK_MODEL)
370
+ yield from _stream_chat(messages, model=FALLBACK_MODEL)
371
+ return
372
+ raise OpenRouterError(502, message="Network error connecting to model provider. Please try again.") from last_exc
373
+
374
+
375
+ def _chat_text(messages: list[dict], model: str = MODEL) -> str:
376
+ last_exc = None
377
+ for attempt in range(_MAX_RETRIES):
378
+ try:
379
+ with Client(timeout=120) as client:
380
+ response = client.post(URL, headers=_headers(), json={"model": model, "messages": messages})
381
+ _raise_for_status(response)
382
+ return _message_content(response.json())
383
+ except _RETRYABLE as exc:
384
+ last_exc = exc
385
+ logger.warning("Network error in chat_text with %s (attempt %d/%d): %s", model, attempt + 1, _MAX_RETRIES, exc)
386
+ if attempt < _MAX_RETRIES - 1:
387
+ _retry_sleep(attempt)
388
+ except OpenRouterError as exc:
389
+ last_exc = exc
390
+ if exc.status_code in _RETRYABLE_STATUS:
391
+ logger.warning("Provider %s error %d in chat_text (attempt %d/%d): %s", model, exc.status_code, attempt + 1, _MAX_RETRIES, exc.message)
392
+ if attempt < _MAX_RETRIES - 1:
393
+ _retry_sleep(attempt, exc.retry_after)
394
+ continue
395
+ raise
396
+ if model == MODEL and FALLBACK_MODEL:
397
+ logger.info("Primary model %s failed in chat_text, falling back to %s", MODEL, FALLBACK_MODEL)
398
+ return _chat_text(messages, model=FALLBACK_MODEL)
399
+ raise OpenRouterError(502, message="Network error connecting to model provider. Please try again.") from last_exc
400
+
401
+
402
+ def call(system: str, user: str, model: str = MODEL) -> dict:
403
+ last_exc = None
404
+ for attempt in range(_MAX_RETRIES):
405
+ try:
406
+ with Client(timeout=120) as c:
407
+ r = c.post(
408
+ URL,
409
+ headers=_headers(),
410
+ json={"model": model, "messages": _messages(system, [{"role": "user", "content": user}])},
411
+ )
412
+ _raise_for_status(r)
413
+ data = r.json()
414
+ text = _message_content(data)
415
+ text = text.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
416
+ return json.loads(text)
417
+ except _RETRYABLE as exc:
418
+ last_exc = exc
419
+ logger.warning("Network error in call with %s (attempt %d/%d): %s", model, attempt + 1, _MAX_RETRIES, exc)
420
+ if attempt < _MAX_RETRIES - 1:
421
+ _retry_sleep(attempt)
422
+ except OpenRouterError as exc:
423
+ last_exc = exc
424
+ if exc.status_code in _RETRYABLE_STATUS:
425
+ logger.warning("Provider %s error %d in call (attempt %d/%d): %s", model, exc.status_code, attempt + 1, _MAX_RETRIES, exc.message)
426
+ if attempt < _MAX_RETRIES - 1:
427
+ _retry_sleep(attempt, exc.retry_after)
428
+ continue
429
+ raise
430
+ if model == MODEL and FALLBACK_MODEL:
431
+ logger.info("Primary model %s failed in call, falling back to %s", MODEL, FALLBACK_MODEL)
432
+ return call(system, user, model=FALLBACK_MODEL)
433
+ raise OpenRouterError(502, message="Network error connecting to model provider. Please try again.") from last_exc
434
+
435
+
436
+ def stream_chat(system: str, messages: list[dict], tools: list[dict] | None = None, tool_runner=None, on_tool_result=None):
437
+ chat_messages = _messages(system, messages)
438
+ if tools and tool_runner:
439
+ yield from _chat_with_tools(chat_messages, tools, tool_runner, on_tool_result)
440
+ return
441
+
442
+ emitted = False
443
+ try:
444
+ for token in _stream_chat(chat_messages):
445
+ emitted = True
446
+ yield token
447
+ except (HTTPError, json.JSONDecodeError) as exc:
448
+ _log_stream_fallback(exc)
449
+
450
+ if emitted:
451
+ return
452
+
453
+ text = _chat_text(chat_messages)
454
+ if text:
455
+ yield text
456
+
457
+
458
+ def _stream_token(line: str) -> str:
459
+ if not line or line.startswith(":"):
460
+ return ""
461
+ if not line.startswith("data:"):
462
+ return ""
463
+ data = line.removeprefix("data:").strip()
464
+ if data == "[DONE]":
465
+ return ""
466
+ chunk = json.loads(data)
467
+ choice = (chunk.get("choices") or [{}])[0]
468
+ delta = choice.get("delta") or {}
469
+ return delta.get("content") or ""
470
+
471
+
472
+ try:
473
+ fetch_context_length()
474
+ except Exception:
475
+ pass
backend/app/services/refinement.py ADDED
@@ -0,0 +1,630 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import re
3
+
4
+ from app.supabase import first_row, supabase
5
+ from app.services.books import parse_books, raw_book_section
6
+ from app.services.curriculum import invalidate_curriculum_cache
7
+ from app.services.deterministic import compute_hours, compute_program, compute_course_type
8
+ from app.services.openrouter import call as llm
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ WORD = re.compile(r"\w+")
13
+ UNIT_LINE = re.compile(r"\b(Unit\s+\d+|Module\s+(?:\d+|[IVX]+))\s*[:.-]?\s*(.*)$", re.IGNORECASE)
14
+ HOURS_SUFFIX = re.compile(r"\s*\b\d+\s+Hours?\b\s*$", re.IGNORECASE)
15
+ DESIRABLE_LINE = re.compile(r"\bDesirable\s+([^\n]+)", re.IGNORECASE)
16
+ COURSE_CODE = re.compile(r"\bCourse\s+Code\s*:?\s*([A-Z0-9]+)", re.IGNORECASE)
17
+ PAGE_NOISE = re.compile(
18
+ r"P\.?\s*E\.?\s*S\.?\s*University|"
19
+ r"Curriculum|"
20
+ r"\s*:-\s*[A-Za-z]*\s*\d{4}\s*-\s*\d{4}\b|"
21
+ r"\b\d+\s*\|\s*Page\b",
22
+ re.IGNORECASE,
23
+ )
24
+
25
+ SYS = """You refine PES University course submissions for the UG curriculum template.
26
+ Return only valid JSON. No markdown. No commentary.
27
+
28
+ Your job: take a raw faculty submission and produce a clean, template-ready course record.
29
+
30
+ CRITICAL -- you MUST fix all of the following in every field:
31
+ - Spelling errors (e.g. "lerning" -> "learning", "neual" -> "neural", "introducton" -> "introduction")
32
+ - Title case for the course title (e.g. "machine lerning" -> "Machine Learning")
33
+ - Title case for unit titles (e.g. "Unit 1: Introducton to ML" -> "Unit 1: Introduction to ML")
34
+ - Grammar and punctuation in all text fields
35
+ - Capitalize the first letter of every sentence in content fields
36
+ - Fix malformed abbreviations (e.g. "ML" is fine, but "ml" should be "ML")
37
+
38
+ Preserve the academic content, topics, and structure. Do not invent new topics, remove submitted topics, or change the course scope. But every string you output must be free of spelling errors and properly formatted.
39
+
40
+ Editing rules:
41
+ - If prelude, objectives, outcomes, units, tools, or books are already present, clean them instead of replacing them.
42
+ - Generate a field only when that field is missing, incomplete, or too rough for the template.
43
+ - Preserve every syllabus topic and subtopic from Raw Course Content.
44
+ - Do not summarize away, omit, replace, merge away, or simplify syllabus topics.
45
+ - Do not add advanced, fashionable, or unrelated topics.
46
+ - Do not invent course codes, books, references, departments, credits, or external prerequisites.
47
+ - Keep objectives and outcomes direct, concise, and aligned with the submitted scope.
48
+ - Return exactly 4 units. If the input already has 4 units, keep those boundaries. If it has a different structure, redistribute topics in order without dropping content.
49
+ - Unit hours must sum to the supplied total unit hours.
50
+ - For tools/languages, use Preferred Tools / Languages when provided; otherwise identify course-specific tools, languages, platforms, or AI tools from Raw Course Content.
51
+ - Do not use canned defaults for tools/languages.
52
+ - For desirable knowledge, use only relevant knowledge from Previously Completed Courses. Return an empty string when none apply.
53
+ - Generate laboratory experiments only when practical hours are non-zero. Keep them tied to submitted topics.
54
+ - Copy and clean books from the submitted book fields only.
55
+ - Use empty strings or empty arrays for truly unavailable optional content. Do not output "-".
56
+ """
57
+
58
+ SCHEMA = """{
59
+ "course_title": "corrected course title",
60
+ "prelude": "one short paragraph",
61
+ "objectives": ["3 to 4 objectives"],
62
+ "course_outcomes": ["3 to 4 measurable outcomes"],
63
+ "units": [{"title": "Unit 1: Title", "content": "compact topic list", "hours": 14}],
64
+ "lab_experiments": ["concise lab item"],
65
+ "tools_languages": "course-specific tools, languages, platforms, or AI tools",
66
+ "desirable_knowledge": "short text based only on previously completed courses, or empty string",
67
+ "text_books": ["submitted text books only"],
68
+ "reference_books": ["submitted reference books only"]
69
+ }"""
70
+
71
+ EXAMPLES = """Behavior examples calibrated to PESU curriculum style.
72
+ Use them only to learn fidelity, field shape, and level of detail. Do not copy example facts into the real answer.
73
+
74
+ Example 1: already refined input
75
+ Input:
76
+ Course Title: Web Technologies
77
+ Weekly Hours: L 4, T 0, P 0, S 4, C 4
78
+ Total Unit Hours: 56
79
+ Raw Course Content:
80
+ Unit 1: HTML, CSS and Client-Side Scripting - web architecture, HTTP request and response formats, URLs, HTML elements and attributes, web forms, HTML5 tags and controls, CSS selectors, style properties, box model, JavaScript objects, DOM manipulation, events and event handling. 14 Hours
81
+ Unit 2: HTML5 and ReactJS - HTML5 APIs, audio, video, progress, geolocation, callbacks, promises, single page applications, XML vs JSON, async/await, JSX, rendering elements, React setup, components, styling, props, state and context. 14 Hours
82
+ Unit 3: ReactJS and NodeJS - complex state management, keys, event handling, forms, hooks including useState, useRef, useEffect, useContext and useReducer, React Router, introduction to NextJS, NodeJS architecture, callbacks, modules, buffers, streams, file system and Axios API. 14 Hours
83
+ Unit 4: MongoDB and ExpressJS - documents, collections, reading and writing to MongoDB, MongoDB NodeJS driver, running a React application on NodeJS, React Router, web services, REST APIs, Express routing, URL building, error handling, middleware, form data and file upload. 14 Hours
84
+ Preferred Tools / Languages: HTML, CSS, JavaScript, MERN Technologies, GitHub, AI tools: Copilot and Tabnine
85
+ Previously Completed Courses: None
86
+ Text Books:
87
+ 1. Robin Nixon, Learning PHP, MySQL and JavaScript, 5th Edition, O'Reilly Media, 2018.
88
+ 2. Vasan Subramanian, Pro MERN Stack: Full Stack Web App Development with Mongo, Express, React, and Node, Apress, 2017.
89
+ Output:
90
+ {
91
+ "course_title": "Web Technologies",
92
+ "prelude": "This course develops an in-depth understanding of technologies required to design and develop rich web applications using client-side scripting, ReactJS, NodeJS, ExpressJS, and MongoDB.",
93
+ "objectives": ["Build web pages using HTML, CSS, JavaScript, and advanced JavaScript concepts.", "Use HTML5, AJAX, JSON, and ReactJS to develop interactive user interfaces.", "Build multi-tier applications by connecting ReactJS interfaces to NodeJS services.", "Integrate MongoDB through ExpressJS and RESTful web services."],
94
+ "course_outcomes": ["Develop web pages using HTML, CSS, JavaScript, DOM manipulation, and event handling.", "Create interactive user interfaces using HTML5 APIs and ReactJS components.", "Implement server-side functionality using NodeJS, modules, streams, file system APIs, and routing.", "Create RESTful services with ExpressJS and integrate MongoDB for persistent storage."],
95
+ "units": [
96
+ {"title": "Unit 1: HTML, CSS and Client-Side Scripting", "content": "Web architecture, HTTP request and response formats, URLs, HTML elements and attributes, web forms, HTML5 tags and controls, CSS selectors, style properties, box model, JavaScript objects, DOM manipulation, events and event handling.", "hours": 14},
97
+ {"title": "Unit 2: HTML5 and ReactJS", "content": "HTML5 APIs, audio, video, progress, geolocation, callbacks, promises, single page applications, XML vs JSON, async/await, JSX, rendering elements, React setup, components, styling, props, state and context.", "hours": 14},
98
+ {"title": "Unit 3: ReactJS and NodeJS", "content": "Complex state management, keys, event handling, forms, hooks including useState, useRef, useEffect, useContext and useReducer, React Router, introduction to NextJS, NodeJS architecture, callbacks, modules, buffers, streams, file system and Axios API.", "hours": 14},
99
+ {"title": "Unit 4: MongoDB and ExpressJS", "content": "Documents, collections, reading and writing to MongoDB, MongoDB NodeJS driver, running a React application on NodeJS, React Router, web services, REST APIs, Express routing, URL building, error handling, middleware, form data and file upload.", "hours": 14}
100
+ ],
101
+ "lab_experiments": [],
102
+ "tools_languages": "HTML, CSS, JavaScript, MERN Technologies, GitHub; AI tools: Copilot, Tabnine",
103
+ "desirable_knowledge": "",
104
+ "text_books": ["Robin Nixon, Learning PHP, MySQL and JavaScript, 5th Edition, O'Reilly Media, 2018.", "Vasan Subramanian, Pro MERN Stack: Full Stack Web App Development with Mongo, Express, React, and Node, Apress, 2017."],
105
+ "reference_books": []
106
+ }
107
+
108
+ Example 2: detailed lab-integrated input with title and formatting issues
109
+ Input:
110
+ Course Title: data structures & its aplications
111
+ Weekly Hours: L 4, T 0, P 2, S 5, C 5
112
+ Total Unit Hours: 56
113
+ Raw Course Content:
114
+ Unit 1: Linked List and Stacks - Review of C, static and dynamic memory allocation, doubly linked list, circular linked list, multilist and sparse matrix, skip list dictionary case study, stack using arrays and linked list, function execution, nested functions, recursion, Tower of Hanoi, infix to postfix, infix to prefix, expression evaluation, matching parenthesis. 14 Hours
115
+ Unit 2: Queues and Trees - simple queue, circular queue, priority queue, dequeue using arrays and linked list, Josephus problem, CPU scheduling using queues, N-ary trees, binary trees, binary search trees, forests, conversion to binary tree, preorder, inorder and postorder traversal. 14 Hours
116
+ Unit 3: Application of Trees and Introduction to Graphs - BST insertion and deletion using arrays and dynamic allocation, binary expression tree, threaded binary search tree, heaps, priority queue using min heap and max heap, dictionary and decision tree applications, AVL trees, rotations, splay tree, graph properties, adjacency matrix, adjacency list, DFS, BFS, network topology representation. 14 Hours
117
+ Unit 4: Applications of Graphs, B-Trees, Suffix Tree and Hashing - BFS and DFS applications, connectivity, path finding in a network, suffix trees, trie trees, insert, delete and search operations, hashing, hash functions, hash tables, separate chaining, open addressing, double hashing, rehashing, URL decoding and word prediction using trie trees and suffix trees. 14 Hours
118
+ Laboratory: linked list operations; stack applications; queue applications; binary tree and BST applications; graph data structure applications; hashing techniques.
119
+ Preferred Tools / Languages: C-Programming language; AI tools: VisuAlgo (Interactive Visualizations), Algorithm Visualizer (AI Explanations)
120
+ Previously Completed Courses: Problem Solving with C
121
+ Text Books: 1. Langsam Yedidyah, Moshe J. Augenstein, Aaron M. Tenenbaum, Data Structures using C / C++, Pearson Education Inc., 2nd Edition, 2015.
122
+ Output:
123
+ {
124
+ "course_title": "Data Structures and Applications",
125
+ "prelude": "This course introduces fundamental data structure concepts with emphasis on their theoretical foundations, implementation techniques, and practical applications using a programming language.",
126
+ "objectives": ["Analyze and design data structures for efficient storage, retrieval, and manipulation.", "Use linked lists, stacks, queues, trees, heaps, and graphs for suitable computational tasks.", "Implement insertion, deletion, searching, and modification operations across linear and non-linear data structures.", "Select and apply appropriate data structures to solve application-oriented problems."],
127
+ "course_outcomes": ["Select and apply appropriate data structures for solving problems across application domains.", "Implement fundamental data structures and their operations using suitable programming constructs.", "Use data structures effectively to design efficient solutions for computational problems.", "Develop software components by applying data structure principles and their applications."],
128
+ "units": [
129
+ {"title": "Unit 1: Linked List and Stacks", "content": "Review of C, static and dynamic memory allocation, doubly linked list, circular linked list, multilist and sparse matrix, skip list dictionary case study, stack using arrays and linked list, function execution, nested functions, recursion, Tower of Hanoi, infix to postfix, infix to prefix, expression evaluation, matching parenthesis.", "hours": 14},
130
+ {"title": "Unit 2: Queues and Trees", "content": "Simple queue, circular queue, priority queue, dequeue using arrays and linked list, Josephus problem, CPU scheduling using queues, N-ary trees, binary trees, binary search trees, forests, conversion to binary tree, preorder, inorder and postorder traversal.", "hours": 14},
131
+ {"title": "Unit 3: Application of Trees and Introduction to Graphs", "content": "BST insertion and deletion using arrays and dynamic allocation, binary expression tree, threaded binary search tree, heaps, priority queue using min heap and max heap, dictionary and decision tree applications, AVL trees, rotations, splay tree, graph properties, adjacency matrix, adjacency list, DFS, BFS, network topology representation.", "hours": 14},
132
+ {"title": "Unit 4: Applications of Graphs, B-Trees, Suffix Tree and Hashing", "content": "BFS and DFS applications, connectivity, path finding in a network, suffix trees, trie trees, insert, delete and search operations, hashing, hash functions, hash tables, separate chaining, open addressing, double hashing, rehashing, URL decoding and word prediction using trie trees and suffix trees.", "hours": 14}
133
+ ],
134
+ "lab_experiments": ["Linked list and advanced operations.", "Stack applications.", "Queue applications.", "Binary tree and binary search tree applications.", "Graph data structure applications.", "Hashing techniques."],
135
+ "tools_languages": "C-Programming language; AI tools: VisuAlgo (Interactive Visualizations), Algorithm Visualizer (AI Explanations)",
136
+ "desirable_knowledge": "Problem Solving with C",
137
+ "text_books": ["Langsam Yedidyah, Moshe J. Augenstein, Aaron M. Tenenbaum, Data Structures using C / C++, Pearson Education Inc., 2nd Edition, 2015."],
138
+ "reference_books": []
139
+ }
140
+ """
141
+
142
+
143
+ def _lines(value) -> list[str]:
144
+ if not value:
145
+ return []
146
+ if isinstance(value, list):
147
+ return [str(item).strip() for item in value if str(item).strip()]
148
+ return [line.strip() for line in str(value).splitlines() if line.strip()]
149
+
150
+
151
+ def _clean_noise(value: str) -> str:
152
+ return re.sub(r"\s+", " ", PAGE_NOISE.sub(" ", value)).strip()
153
+
154
+
155
+ def _books(*values) -> list[str]:
156
+ return parse_books(*values)
157
+
158
+
159
+ def _raw_book_section(raw_content: str, kind: str) -> str:
160
+ return raw_book_section(raw_content, kind)
161
+
162
+
163
+ def _words(text: str) -> int:
164
+ return len(WORD.findall(text))
165
+
166
+
167
+ def _clean_part(value: str) -> str:
168
+ return _clean_noise(value.strip(" \t-*\u2022"))
169
+
170
+
171
+ def _split_parts(text: str) -> list[str]:
172
+ lines = [_clean_part(line) for line in text.splitlines() if _clean_part(line)]
173
+ if len(lines) >= 4:
174
+ return lines
175
+ compact = _clean_part(text)
176
+ parts = [_clean_part(part) for part in re.split(r"(?<=[.!?])\s+", compact) if _clean_part(part)]
177
+ if len(parts) >= 4:
178
+ return parts
179
+ words = compact.split()
180
+ if not words:
181
+ return []
182
+ return [
183
+ " ".join(words[(index * len(words)) // 4 : ((index + 1) * len(words)) // 4])
184
+ for index in range(4)
185
+ ]
186
+
187
+
188
+ def _four_units_from_raw(raw_content: str) -> list[dict]:
189
+ parts = _split_parts(raw_content)
190
+ if not parts:
191
+ return []
192
+ buckets = [[] for _ in range(4)]
193
+ for index, part in enumerate(parts):
194
+ buckets[min(index * 4 // len(parts), 3)].append(part)
195
+ return [
196
+ {"title": f"Unit {index + 1}", "content": " ".join(bucket).strip(), "hours": 0}
197
+ for index, bucket in enumerate(buckets)
198
+ if bucket
199
+ ]
200
+
201
+
202
+ def _course_contents(raw_content: str) -> str:
203
+ lines = raw_content.splitlines()
204
+ start = None
205
+ for index, line in enumerate(lines):
206
+ if "Course" in line and "Contents" in line:
207
+ start = index
208
+ break
209
+ if "Course" in line and any("Contents" in item for item in lines[index + 1 : index + 3]):
210
+ start = index
211
+ break
212
+ if start is None:
213
+ return raw_content
214
+
215
+ end = len(lines)
216
+ for index in range(start + 1, len(lines)):
217
+ marker = lines[index].strip()
218
+ if not marker:
219
+ continue
220
+ if any(marker.startswith(label) for label in ("Laboratory", "Text Book", "Reference", "Course Outcome", "Assignment /")):
221
+ end = index
222
+ break
223
+ return "\n".join(lines[start:end]).strip()
224
+
225
+
226
+ def _syllabus_line(line: str) -> str:
227
+ clean = _clean_part(line)
228
+ clean = re.sub(r"^(Course\s+Contents|Contents)\s+", "", clean, flags=re.IGNORECASE)
229
+ clean = re.sub(r"^Course\s+(?=Unit\s+\d+|Module\s+(?:\d+|[IVX]+))", "", clean, flags=re.IGNORECASE)
230
+ return HOURS_SUFFIX.sub("", clean).strip()
231
+
232
+
233
+ def _units_from_course_contents(raw_content: str, fallback: bool = True) -> list[dict]:
234
+ content = _course_contents(raw_content)
235
+ lines = [line.strip() for line in content.splitlines() if line.strip()]
236
+ units = []
237
+ current = None
238
+
239
+ for line in lines:
240
+ clean = _syllabus_line(line)
241
+ if not clean:
242
+ continue
243
+ match = UNIT_LINE.search(clean)
244
+ if match:
245
+ if current:
246
+ units.append(current)
247
+ label = match.group(1).strip().title()
248
+ rest = match.group(2).strip(" :-")
249
+ if " - " in rest:
250
+ title, inline_content = rest.split(" - ", 1)
251
+ elif _words(rest) > 12:
252
+ title, inline_content = "", rest
253
+ else:
254
+ title, inline_content = rest, ""
255
+ current = {
256
+ "title": f"{label}: {title.strip()}" if title.strip() else label,
257
+ "content": inline_content.strip(),
258
+ "hours": 0,
259
+ }
260
+ continue
261
+ if current:
262
+ current["content"] = f"{current['content']} {clean}".strip()
263
+
264
+ if current:
265
+ units.append(current)
266
+ if len(units) == 4:
267
+ return units
268
+ return _four_units_from_raw(content) if fallback else []
269
+
270
+
271
+ def _unit_text(unit: dict) -> str:
272
+ title = str(unit.get("title", "")).strip()
273
+ content = str(unit.get("content", "")).strip()
274
+ if title and content:
275
+ return f"{title}: {content}"
276
+ return title or content
277
+
278
+
279
+ def _fit_four_units(units: list[dict], raw_content: str) -> list[dict]:
280
+ raw = _course_contents(raw_content).strip()
281
+ raw_units = _units_from_course_contents(raw_content, fallback=False)
282
+ raw_unit_words = _words(" ".join(_unit_text(unit) for unit in raw_units))
283
+ if raw_units and raw_unit_words >= _words(raw) * 0.7:
284
+ return raw_units
285
+ if raw and _words(" ".join(_unit_text(unit) for unit in units)) < _words(raw) * 0.8:
286
+ return raw_units or _units_from_course_contents(raw_content)
287
+ if len(units) > 4:
288
+ fourth = units[3].copy()
289
+ fourth["content"] = " ".join(_unit_text(unit) for unit in units[3:]).strip()
290
+ return units[:3] + [fourth]
291
+ if len(units) == 4:
292
+ return units
293
+ if raw:
294
+ return raw_units or _units_from_course_contents(raw_content)
295
+ return units
296
+
297
+
298
+ def _units(value) -> list[dict]:
299
+ units = []
300
+ for item in value if isinstance(value, list) else []:
301
+ if not isinstance(item, dict):
302
+ continue
303
+ title = str(item.get("title", "")).strip()
304
+ content = str(item.get("content", "")).strip()
305
+ hours_raw = str(item.get("hours", "0"))
306
+ hours = int("".join(ch for ch in hours_raw if ch.isdigit()) or 0)
307
+ if title or content:
308
+ units.append({"title": title, "content": content, "hours": hours})
309
+ return units
310
+
311
+
312
+ def _assign_hours(units: list[dict], total_hours: int) -> list[dict]:
313
+ if not units:
314
+ return units
315
+ for unit in units:
316
+ unit["hours"] = 14
317
+ return units
318
+
319
+
320
+ def _text(*values) -> str:
321
+ for value in values:
322
+ if isinstance(value, str):
323
+ text = value.strip()
324
+ if text and text != "-":
325
+ return text
326
+ return ""
327
+
328
+
329
+ def _course_code(raw_content: str) -> str:
330
+ match = COURSE_CODE.search(raw_content or "")
331
+ return match.group(1).upper() if match else ""
332
+
333
+
334
+ def _prior_course_titles(sub: dict) -> list[str]:
335
+ semester = int(sub["semester"])
336
+ if semester <= 1:
337
+ return []
338
+ rows = (
339
+ supabase.table("refined_submissions")
340
+ .select("submission_id,course_code,course_title,semester")
341
+ .lt("semester", semester)
342
+ .order("semester")
343
+ .execute()
344
+ .data
345
+ )
346
+ ids = [row["submission_id"] for row in rows if row.get("submission_id")]
347
+ submissions = supabase.table("submissions").select("id,target_department,raw_course_content").in_("id", ids).execute().data if ids else []
348
+ departments = {row["id"]: row.get("target_department") for row in submissions}
349
+ raw_codes = {row["id"]: _course_code(row.get("raw_course_content") or "") for row in submissions}
350
+ titles = []
351
+ for row in rows:
352
+ if departments.get(row.get("submission_id")) != sub.get("target_department"):
353
+ continue
354
+ title = str(row.get("course_title") or "").strip()
355
+ code = str(row.get("course_code") or raw_codes.get(row.get("submission_id")) or "").strip()
356
+ label = f"{code} - {title}" if code else title
357
+ if title and label not in titles:
358
+ titles.append(label)
359
+ return titles
360
+
361
+
362
+ def _normalized(text: str) -> str:
363
+ return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip()
364
+
365
+
366
+ def _singularized(text: str) -> str:
367
+ return " ".join(word[:-1] if len(word) > 3 and word.endswith("s") else word for word in _normalized(text).split())
368
+
369
+
370
+ def _core_key(text: str) -> str:
371
+ skip = {"a", "an", "and", "for", "in", "its", "of", "the", "to", "with"}
372
+ return " ".join(word for word in _singularized(text).split() if word not in skip)
373
+
374
+
375
+ def _prior_keys(course: str) -> list[str]:
376
+ match = re.match(r"\s*([A-Z]{2}\d{2}[A-Z0-9]+)\s*[-:]\s*(.+)", course)
377
+ code = match.group(1).lower() if match else ""
378
+ title = match.group(2) if match else course
379
+ return [key for key in (code, _normalized(title), _singularized(title), _core_key(title)) if key]
380
+
381
+
382
+ SPELLING_FIXES = {
383
+ "Introducton": "Introduction",
384
+ "introducton": "Introduction",
385
+ "Introdcution": "Introduction",
386
+ "Neual": "Neural",
387
+ "neual": "neural",
388
+ "Adavnced": "Advanced",
389
+ "adavnced": "advanced",
390
+ "Applicatons": "Applications",
391
+ "applicatons": "applications",
392
+ "preprocesing": "preprocessing",
393
+ "recurent": "recurrent",
394
+ "anomal": "anomaly",
395
+ "stuides": "studies",
396
+ "lerning": "learning",
397
+ "inteligence": "intelligence",
398
+ "artifical": "artificial",
399
+ "concurrncy": "concurrency",
400
+ "procesing": "processing",
401
+ "optimisation": "optimization",
402
+ "maximisation": "maximization",
403
+ "minimisation": "minimization",
404
+ "behaviour": "behavior",
405
+ "colour": "color",
406
+ " favour ": " favor ",
407
+ "analyse": "analyze",
408
+ "recognise": "recognize",
409
+ "summarise": "summarize",
410
+ "characterise": "characterize",
411
+ "utilise": "use",
412
+ "initialise": "initialize",
413
+ "licence": "license",
414
+ "defence": "defense",
415
+ "programme": "program",
416
+ "centre": "center",
417
+ "metre": "meter",
418
+ "litre": "liter",
419
+ "catalogue": "catalog",
420
+ "dialogue": "dialog",
421
+ "analysing": "analyzing",
422
+ "recognising": "recognizing",
423
+ "summarising": "summarizing",
424
+ "utilising": "using",
425
+ "initialising": "initializing",
426
+ "labelling": "labeling",
427
+ "modelling": "modeling",
428
+ "travelling": "traveling",
429
+ "cancelling": "canceling",
430
+ "fulfil ": "fulfill ",
431
+ "enrol ": "enroll ",
432
+ "skillset": "skill set",
433
+ "dataset": "data set",
434
+ "email ": "e-mail ",
435
+ "e mail": "e-mail",
436
+ }
437
+
438
+
439
+ def _fix_spelling(text: str) -> str:
440
+ if not text:
441
+ return text
442
+ for wrong, right in SPELLING_FIXES.items():
443
+ text = text.replace(wrong, right)
444
+ return text
445
+
446
+
447
+ def _title_case(text: str) -> str:
448
+ if not text:
449
+ return text
450
+ words = text.split()
451
+ minor = {"a", "an", "the", "and", "but", "or", "for", "nor", "in", "on", "at", "to", "by", "of", "with", "vs"}
452
+ result = []
453
+ for i, w in enumerate(words):
454
+ if i == 0 or w.lower() not in minor:
455
+ result.append(w[:1].upper() + w[1:] if w.islower() else w)
456
+ else:
457
+ result.append(w.lower())
458
+ return " ".join(result)
459
+
460
+
461
+ def _capitalize_sentences(text: str) -> str:
462
+ if not text:
463
+ return text
464
+ sentences = re.split(r"(?<=[.!?])\s+", text)
465
+ return " ".join(s[0].upper() + s[1:] if s else s for s in sentences)
466
+
467
+
468
+ def _post_process(out: dict) -> dict:
469
+ if not out:
470
+ return out
471
+
472
+ title = out.get("course_title") or ""
473
+ title = _fix_spelling(title)
474
+ title = _title_case(title)
475
+ out["course_title"] = title
476
+
477
+ for field in ("prelude", "tools_languages", "desirable_knowledge"):
478
+ val = out.get(field) or ""
479
+ if val:
480
+ out[field] = _capitalize_sentences(_fix_spelling(val))
481
+
482
+ for field in ("objectives", "course_outcomes", "lab_experiments"):
483
+ items = out.get(field) or []
484
+ if isinstance(items, list):
485
+ out[field] = [_capitalize_sentences(_fix_spelling(item)) for item in items]
486
+
487
+ for unit in out.get("units") or []:
488
+ if isinstance(unit, dict):
489
+ unit["title"] = _fix_spelling(unit.get("title") or "")
490
+ unit["content"] = _capitalize_sentences(_fix_spelling(unit.get("content") or ""))
491
+
492
+ for field in ("text_books", "reference_books"):
493
+ items = out.get(field) or []
494
+ if isinstance(items, list):
495
+ out[field] = [_fix_spelling(item) for item in items]
496
+
497
+ return out
498
+
499
+
500
+ def _prior_matches(text: str, prior_courses: list[str]) -> str:
501
+ normalized = _normalized(text)
502
+ singularized = _singularized(text)
503
+ core = _core_key(text)
504
+ matches = [course for course in prior_courses if any(key in normalized or key in singularized or key in core for key in _prior_keys(course))]
505
+ return ", ".join(matches)
506
+
507
+
508
+ def _submitted_desirable(raw_content: str, prior_courses: list[str]) -> str | None:
509
+ match = DESIRABLE_LINE.search(raw_content or "")
510
+ if not match:
511
+ return None
512
+ value = _text(match.group(1))
513
+ if not value:
514
+ return ""
515
+ return _prior_matches(value, prior_courses) or None
516
+
517
+
518
+ def _desirable(value, prior_courses: list[str], raw_content: str = "") -> str:
519
+ if not prior_courses:
520
+ return ""
521
+ submitted = _submitted_desirable(raw_content, prior_courses)
522
+ if submitted is not None:
523
+ return submitted
524
+ return _prior_matches(_text(value), prior_courses)
525
+
526
+
527
+ def _courses_text(courses: list[str]) -> str:
528
+ return "\n".join(f"- {course}" for course in courses) if courses else "None"
529
+
530
+
531
+ def build_refined_payload(sub: dict, out: dict, prior_courses: list[str] | None = None) -> dict:
532
+ out = out or {}
533
+ prior_courses = prior_courses or []
534
+ det = compute_hours(sub["credit_category"])
535
+ total_unit_hours = det["lecture_hours"] * 14
536
+ raw_content = sub.get("raw_course_content") or ""
537
+ text_book_source = _raw_book_section(raw_content, "text") or sub["text_books"]
538
+ reference_book_source = _raw_book_section(raw_content, "reference") or sub.get("reference_books")
539
+ units = _assign_hours(_fit_four_units(_units(out.get("units")), sub.get("raw_course_content") or ""), total_unit_hours)
540
+
541
+ objectives = _lines(out.get("objectives"))[:4]
542
+ course_outcomes = _lines(out.get("course_outcomes"))[:4] or objectives
543
+
544
+ from app.services.elective_categorization import is_elective_course
545
+
546
+ code = _course_code(raw_content) or sub.get("course_code", "")
547
+ is_elective = is_elective_course({"course_code": code, "semester": sub["semester"]})
548
+ return {
549
+ "submission_id": sub["id"],
550
+ "semester": int(sub["semester"]),
551
+ "course_code": code,
552
+ "course_title": sub["course_title"],
553
+ "program": compute_program(sub["target_department"]),
554
+ "course_type": "Elective" if is_elective else compute_course_type(sub["credit_category"]),
555
+ "is_elective": is_elective,
556
+ **det,
557
+ "prelude": _text(out.get("prelude"), f"This course covers {sub['course_title'].strip()}."),
558
+ "objectives": objectives,
559
+ "course_outcomes": course_outcomes,
560
+ "units": units,
561
+ "lab_experiments": _lines(out.get("lab_experiments"))[:10] if det["practical_hours"] else [],
562
+ "tools_languages": _text(out.get("tools_languages"), sub.get("preferred_tools")),
563
+ "desirable_knowledge": _desirable(out.get("desirable_knowledge"), prior_courses, raw_content),
564
+ "text_books": _books(text_book_source),
565
+ "reference_books": _books(reference_book_source),
566
+ "status": "refined",
567
+ }
568
+
569
+
570
+ def refine(submission_id: int):
571
+ sub = first_row(supabase.table("submissions").select("*").eq("id", submission_id))
572
+ if not sub:
573
+ raise LookupError("Submission not found")
574
+ det = compute_hours(sub["credit_category"])
575
+ ctype = compute_course_type(sub["credit_category"])
576
+ total_unit_hours = det["lecture_hours"] * 14
577
+ prior_courses = _prior_course_titles(sub)
578
+
579
+ prompt = f"""Return JSON matching this schema. Include every key:
580
+ {SCHEMA}
581
+
582
+ {EXAMPLES}
583
+
584
+ Now refine the real submission below.
585
+
586
+ Course Title: {sub["course_title"]}
587
+ Offering Department: {sub["offering_department"]}
588
+ Target Department: {sub["target_department"]}
589
+ Semester: {sub["semester"]}
590
+ Credit Category: {sub["credit_category"]}
591
+ Course Type: {ctype}
592
+ Weekly Hours: L {det["lecture_hours"]}, T {det["tutorial_hours"]}, P {det["practical_hours"]}, S {det["self_study"]}, C {det["credits"]}
593
+ Total Unit Hours: {total_unit_hours}
594
+ Raw Course Content:
595
+ {sub["raw_course_content"]}
596
+
597
+ Previously Completed Courses:
598
+ {_courses_text(prior_courses)}
599
+
600
+ Text Books:
601
+ {sub["text_books"]}
602
+
603
+ Reference Books:
604
+ {sub.get("reference_books") or "-"}
605
+
606
+ Preferred Tools / Languages:
607
+ {sub.get("preferred_tools") or "-"}"""
608
+
609
+ out = llm(SYS, prompt)
610
+ merged = build_refined_payload(sub, out, prior_courses)
611
+ _post_process(merged)
612
+
613
+ existing = supabase.table("refined_submissions").select("id").eq("submission_id", submission_id).execute().data
614
+ if existing:
615
+ supabase.table("refined_submissions").update(merged).eq("submission_id", submission_id).execute()
616
+ refined_id = existing[0]["id"]
617
+ else:
618
+ refined_id = supabase.table("refined_submissions").insert(merged).execute().data[0]["id"]
619
+
620
+ supabase.table("submissions").update({"status": "refined"}).eq("id", submission_id).execute()
621
+ invalidate_curriculum_cache()
622
+
623
+ if merged["is_elective"]:
624
+ try:
625
+ from app.services.elective_categorization import categorize_refined_elective
626
+ categorize_refined_elective(int(refined_id))
627
+ except Exception:
628
+ logger.exception("Elective categorization failed for refined_id=%s", refined_id)
629
+
630
+ return merged
backend/app/services/schema.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from postgrest.exceptions import APIError
2
+
3
+ from app.supabase import supabase
4
+
5
+ REQUIRED_TABLES = (
6
+ "submissions",
7
+ "refined_submissions",
8
+ "curriculum_versions",
9
+ "finalized_submissions",
10
+ "agent_document_drafts",
11
+ "agent_drafts",
12
+ "course_revision_history",
13
+ "chat_sessions",
14
+ "chat_messages",
15
+ "chat_attachments",
16
+ "specialization_definitions",
17
+ "course_specialization_assignments",
18
+ )
19
+
20
+
21
+ def schema_status() -> dict:
22
+ missing = []
23
+ for table in REQUIRED_TABLES:
24
+ try:
25
+ supabase.table(table).select("id").limit(1).execute()
26
+ except APIError as exc:
27
+ if "schema cache" not in str(exc):
28
+ raise
29
+ missing.append(table)
30
+ return {"ok": not missing, "missing_tables": missing, "required_tables": list(REQUIRED_TABLES)}
backend/app/supabase.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from os import environ
2
+ from supabase import create_client, ClientOptions
3
+ from dotenv import load_dotenv
4
+ import httpx
5
+
6
+ load_dotenv("../.env")
7
+
8
+ url = environ["SUPABASE_URL"].strip()
9
+ key = environ["SUPABASE_KEY"].strip()
10
+ supabase = create_client(
11
+ supabase_url=url,
12
+ supabase_key=key,
13
+ options=ClientOptions(
14
+ httpx_client=httpx.Client(
15
+ http2=False,
16
+ timeout=httpx.Timeout(connect=10, read=30, write=10, pool=10),
17
+ ),
18
+ ),
19
+ )
20
+
21
+
22
+ def first_row(query) -> dict | None:
23
+ rows = query.limit(1).execute().data
24
+ return rows[0] if rows else None
backend/app/templates/jinja_1_to_8.html ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% macro course_row(course, number) -%}
2
+ <tr>
3
+ <td class="center">{{ number }}</td>
4
+ <td>{{ course.course_code | course_code_for_year(course.semester, curriculum_year) }}</td>
5
+ <td>{{ course.course_title }}</td>
6
+ <td class="center">{{ course.lecture_hours }}</td>
7
+ <td class="center">{{ course.tutorial_hours }}</td>
8
+ <td class="center">{{ course.practical_hours }}</td>
9
+ <td class="center">{{ course.self_study }}</td>
10
+ <td class="center">{{ course.credits }}</td>
11
+ <td>{{ course.tools_languages }}</td>
12
+ <td>{{ course.course_type }}</td>
13
+ </tr>
14
+ {%- endmacro %}
15
+
16
+ {% macro final_year_row(course, number) -%}
17
+ <tr>
18
+ <td class="center">{{ number }}</td>
19
+ <td>{{ course.course_code | course_code_for_year(course.semester, curriculum_year) }}</td>
20
+ <td>{{ course.course_title }}</td>
21
+ <td class="center">{{ course.lecture_hours }}</td>
22
+ <td class="center">{{ course.tutorial_hours }}</td>
23
+ <td class="center">{{ course.practical_hours }}</td>
24
+ <td class="center">{{ course.self_study }}</td>
25
+ <td class="center">{{ course.credits }}</td>
26
+ <td class="center">{{ course.course_type }}</td>
27
+ </tr>
28
+ {%- endmacro %}
29
+
30
+ {% macro total_cell(total, credit_total) -%}
31
+ {% if total != credit_total %}{{ credit_total }}/{{ total }}{% else %}{{ total }}{% endif %}
32
+ {%- endmacro %}
33
+
34
+ {% macro regular_table(summary_semester, show_total) -%}
35
+ {% set totals = namespace(count=0, lecture=0, lecture_credit=0, tutorial=0, practical=0, self_study=0, credits=0) %}
36
+ {% for summary_course in course_list %}
37
+ {% if summary_course.semester|string == summary_semester|string %}
38
+ {% set credit_value = summary_course.credits|int %}
39
+ {% set totals.count = totals.count + 1 %}
40
+ {% set totals.lecture = totals.lecture + summary_course.lecture_hours|int %}
41
+ {% set totals.tutorial = totals.tutorial + summary_course.tutorial_hours|int %}
42
+ {% set totals.practical = totals.practical + summary_course.practical_hours|int %}
43
+ {% set totals.self_study = totals.self_study + summary_course.self_study|int %}
44
+ {% set totals.credits = totals.credits + credit_value %}
45
+ {% if credit_value > 0 %}
46
+ {% set totals.lecture_credit = totals.lecture_credit + summary_course.lecture_hours|int %}
47
+ {% endif %}
48
+ {% endif %}
49
+ {% endfor %}
50
+
51
+ {% if totals.count %}
52
+ <h2 class="semester-title">{{ SEMESTER_NAMES[summary_semester|string] }} SEMESTER {{ batch_label(summary_semester, curriculum_year) }}</h2>
53
+ <table class="meta summary-table">
54
+ <colgroup>
55
+ <col class="summary-number" />
56
+ <col class="summary-code" />
57
+ <col class="summary-title-column" />
58
+ <col class="summary-hour" />
59
+ <col class="summary-hour" />
60
+ <col class="summary-hour" />
61
+ <col class="summary-hour" />
62
+ <col class="summary-credit" />
63
+ <col class="summary-tools" />
64
+ <col class="summary-type" />
65
+ </colgroup>
66
+ <tbody>
67
+ <tr>
68
+ <th rowspan="2" class="center">Sl. No.</th>
69
+ <th rowspan="2">Course Code</th>
70
+ <th rowspan="2">Course Title</th>
71
+ <th colspan="4" class="center">Hours per week</th>
72
+ <th class="center nowrap">Credits</th>
73
+ <th rowspan="2">AI Tools / Tools /<br /> Languages</th>
74
+ <th rowspan="2">Course Type</th>
75
+ </tr>
76
+ <tr>
77
+ <th class="center">L</th>
78
+ <th class="center">T</th>
79
+ <th class="center">P</th>
80
+ <th class="center">S</th>
81
+ <th class="center">C</th>
82
+ </tr>
83
+
84
+ {% set row = namespace(number=1) %}
85
+ {% for summary_course in course_list %}
86
+ {% if summary_course.semester|string == summary_semester|string %}
87
+ {{ course_row(summary_course, row.number) }}
88
+ {% set row.number = row.number + 1 %}
89
+ {% endif %}
90
+ {% endfor %}
91
+
92
+ {% if show_total %}
93
+ <tr>
94
+ <th colspan="3" class="center">Total</th>
95
+ <td class="center">{{ total_cell(totals.lecture, totals.lecture_credit) }}</td>
96
+ <td class="center">{{ totals.tutorial }}</td>
97
+ <td class="center">{{ totals.practical }}</td>
98
+ <td class="center">{{ totals.self_study }}</td>
99
+ <td class="center">{{ totals.credits }}</td>
100
+ <td></td>
101
+ <td></td>
102
+ </tr>
103
+ {% endif %}
104
+ </tbody>
105
+ </table>
106
+ {% endif %}
107
+ {%- endmacro %}
108
+
109
+ {% set requested_semester = semester|string if semester is defined and semester else "" %}
110
+ {% if summary_range | default("") != "final" %}
111
+ {% set early = namespace(count=0) %}
112
+ {% for summary_course in course_list %}
113
+ {% if summary_course.semester|string in ["1", "2"] and (not requested_semester or requested_semester == summary_course.semester|string) %}
114
+ {% set early.count = early.count + 1 %}
115
+ {% endif %}
116
+ {% endfor %}
117
+
118
+ {% if early.count %}
119
+ <article class="summary-page">
120
+ <header class="letterhead">
121
+ <img class="seal" src="{{ pes_logo }}" alt="PES University logo" />
122
+ <span class="wordmark">Computer Science and Engineering</span>
123
+ </header>
124
+ {% for summary_semester in [1, 2] %}
125
+ {% if not requested_semester or requested_semester == summary_semester|string %}
126
+ {{ regular_table(summary_semester, false) }}
127
+ {% endif %}
128
+ {% endfor %}
129
+ </article>
130
+ {% endif %}
131
+
132
+ {% for summary_semester in [3, 4] %}
133
+ {% if not requested_semester or requested_semester == summary_semester|string %}
134
+ {% set current = namespace(count=0) %}
135
+ {% for summary_course in course_list %}
136
+ {% if summary_course.semester|string == summary_semester|string %}
137
+ {% set current.count = current.count + 1 %}
138
+ {% endif %}
139
+ {% endfor %}
140
+
141
+ {% if current.count %}
142
+ <article class="summary-page">
143
+ <header class="letterhead">
144
+ <img class="seal" src="{{ pes_logo }}" alt="PES University logo" />
145
+ <span class="wordmark">Computer Science and Engineering</span>
146
+ </header>
147
+ {{ regular_table(summary_semester, true) }}
148
+ </article>
149
+ {% endif %}
150
+ {% endif %}
151
+ {% endfor %}
152
+ {% endif %}
153
+
154
+ {% if summary_range | default("") != "early" and (not requested_semester or requested_semester in ["7", "8"]) %}
155
+ {% set final = namespace(count=0) %}
156
+ {% for summary_course in course_list %}
157
+ {% if summary_course.semester|string in ["7", "8"] and (not requested_semester or requested_semester == summary_course.semester|string) %}
158
+ {% set final.count = final.count + 1 %}
159
+ {% endif %}
160
+ {% endfor %}
161
+
162
+ {% if final.count %}
163
+ <article class="summary-page final-year-summary">
164
+ <header class="letterhead">
165
+ <img class="seal" src="{{ pes_logo }}" alt="PES University logo" />
166
+ <span class="wordmark">Computer Science and Engineering</span>
167
+ </header>
168
+
169
+ {% for summary_semester in [7, 8] %}
170
+ {% if not requested_semester or requested_semester == summary_semester|string %}
171
+ {% set totals = namespace(count=0, lecture=0, tutorial=0, practical=0, self_study=0, credits=0) %}
172
+ {% for summary_course in course_list %}
173
+ {% if summary_course.semester|string == summary_semester|string %}
174
+ {% set totals.count = totals.count + 1 %}
175
+ {% set totals.lecture = totals.lecture + summary_course.lecture_hours|int %}
176
+ {% set totals.tutorial = totals.tutorial + summary_course.tutorial_hours|int %}
177
+ {% set totals.practical = totals.practical + summary_course.practical_hours|int %}
178
+ {% set totals.self_study = totals.self_study + summary_course.self_study|int %}
179
+ {% set totals.credits = totals.credits + summary_course.credits|int %}
180
+ {% endif %}
181
+ {% endfor %}
182
+
183
+ {% if totals.count %}
184
+ <h2 class="semester-title">{{ SEMESTER_NAMES[summary_semester|string] }} SEMESTER {{ batch_label(summary_semester, curriculum_year) }}</h2>
185
+ <table class="meta summary-table final-year-table">
186
+ <colgroup>
187
+ <col class="final-number" />
188
+ <col class="final-code" />
189
+ <col class="final-title" />
190
+ <col class="final-hour" />
191
+ <col class="final-hour" />
192
+ <col class="final-hour" />
193
+ <col class="final-hour" />
194
+ <col class="final-credit" />
195
+ <col class="final-type" />
196
+ </colgroup>
197
+ <tbody>
198
+ <tr>
199
+ <th rowspan="2" class="center">Sl. No.</th>
200
+ <th rowspan="2">Course Code</th>
201
+ <th rowspan="2">Course Title</th>
202
+ <th colspan="4" class="center">Hours/week</th>
203
+ <th class="center nowrap">Credits</th>
204
+ <th rowspan="2" class="center">Course Type</th>
205
+ </tr>
206
+ <tr>
207
+ <th class="center">L</th>
208
+ <th class="center">T</th>
209
+ <th class="center">P</th>
210
+ <th class="center">S</th>
211
+ <th class="center">C</th>
212
+ </tr>
213
+ {% set row = namespace(number=1) %}
214
+ {% for summary_course in course_list %}
215
+ {% if summary_course.semester|string == summary_semester|string %}
216
+ {{ final_year_row(summary_course, row.number) }}
217
+ {% set row.number = row.number + 1 %}
218
+ {% endif %}
219
+ {% endfor %}
220
+ <tr>
221
+ <th colspan="3" class="center">Total</th>
222
+ <td class="center">{{ totals.lecture }}</td>
223
+ <td class="center">{{ totals.tutorial }}</td>
224
+ <td class="center">{{ totals.practical }}</td>
225
+ <td class="center">{{ totals.self_study }}</td>
226
+ <td class="center">{{ totals.credits }}</td>
227
+ <td></td>
228
+ </tr>
229
+ </tbody>
230
+ </table>
231
+ {% endif %}
232
+ {% endif %}
233
+ {% endfor %}
234
+ </article>
235
+ {% endif %}
236
+ {% endif %}
backend/app/templates/jinja_diff.html ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <style>
6
+ @page { size: A4; margin: 14mm 12mm 18mm; }
7
+ * { box-sizing: border-box; }
8
+ body { margin: 0; padding: 16px; color: #000; font-family: Calibri, Arial, sans-serif; font-size: 10pt; line-height: 1.2; }
9
+ .course + .course { break-before: page; page-break-before: always; }
10
+ .letterhead { display: flex; align-items: center; justify-content: center; gap: 12pt; margin-bottom: 10pt; }
11
+ .seal { width: 41px; height: auto; }
12
+ .wordmark { width: 265px; height: auto; }
13
+ table { width: 100%; border-collapse: collapse; table-layout: fixed; }
14
+ .meta { margin-bottom: -1pt; }
15
+ th, td { border: 1pt solid #000; padding: 4pt 5pt; vertical-align: top; overflow-wrap: anywhere; }
16
+ th { font-weight: 700; text-align: left; }
17
+ .center { text-align: center; }
18
+ .label { width: 18%; }
19
+ .hour { width: 7%; }
20
+ p { margin: 0 0 5pt; }
21
+ p:last-child { margin-bottom: 0; }
22
+ ul, ol { margin: 0; padding-left: 16pt; }
23
+ li { margin-bottom: 3pt; }
24
+ li:last-child { margin-bottom: 0; }
25
+ .unit-title, .hours { font-weight: 700; }
26
+ .unit { margin-bottom: 8pt; }
27
+ .unit:last-child { margin-bottom: 0; }
28
+ .hours { text-align: right; }
29
+ .resource-link { color: inherit; text-decoration: underline; }
30
+ .semester-title { margin: 0 0 8pt; text-align: center; font-size: 11pt; font-weight: 700; }
31
+ .diff-removed { background: #ffeef0; border-left: 3px solid #b91c1c; padding: 4pt 8pt; margin: 2pt 0; }
32
+ .diff-removed .diff-sign { color: #b91c1c; font-weight: 700; margin-right: 4pt; }
33
+ .diff-removed .diff-value { text-decoration: line-through; }
34
+ .diff-added { background: #f0fdf4; border-left: 3px solid #15803d; padding: 4pt 8pt; margin: 2pt 0; }
35
+ .diff-added .diff-sign { color: #15803d; font-weight: 700; margin-right: 4pt; }
36
+ .diff-unchanged { margin: 4pt 0; }
37
+ .diff-list-item { margin: 2pt 0; padding: 2pt 8pt; }
38
+ .diff-list-item.removed { background: #ffeef0; border-left: 3px solid #b91c1c; }
39
+ .diff-list-item.added { background: #f0fdf4; border-left: 3px solid #15803d; }
40
+ .diff-list-item.unchanged { background: transparent; border-left: 3px solid transparent; padding-left: 5pt; }
41
+ .diff-list-item .diff-sign { font-weight: 700; margin-right: 4pt; }
42
+ .diff-list-item.removed .diff-sign { color: #b91c1c; }
43
+ .diff-list-item.added .diff-sign { color: #15803d; }
44
+ .diff-list-item.removed .diff-value { text-decoration: line-through; }
45
+ .diff-unit { margin-bottom: 8pt; }
46
+ .diff-unit.removed { background: #ffeef0; padding: 8pt; border-left: 3px solid #b91c1c; }
47
+ .diff-unit.added { background: #f0fdf4; padding: 8pt; border-left: 3px solid #15803d; }
48
+ .diff-unit.changed { background: #fffbeb; padding: 8pt; border-left: 3px solid #b45309; }
49
+ .diff-unit.unchanged { margin-bottom: 8pt; }
50
+ .diff-unit-header { font-weight: 700; margin-bottom: 4pt; }
51
+ .diff-unit-changes { margin-left: 12pt; }
52
+ .diff-unit-change { margin: 2pt 0; padding: 2pt 8pt; }
53
+ .diff-unit-change .diff-sign { font-weight: 700; margin-right: 4pt; }
54
+ .diff-unit-change.removed { background: #ffeef0; color: #b91c1c; }
55
+ .diff-unit-change.added { background: #f0fdf4; color: #15803d; }
56
+ .diff-unit-change.changed { background: #fffbeb; color: #b45309; }
57
+ .footer-bar { position: fixed; left: 0; right: 0; bottom: -14mm; height: 9mm; background: #000; }
58
+ </style>
59
+ </head>
60
+ <body>
61
+ <div class="footer-bar"></div>
62
+
63
+ {% macro diff_inline(diff, fallback="") %}
64
+ {% if diff %}
65
+ {% if diff.old != "" %}
66
+ <div class="diff-removed"><span class="diff-sign">&minus;</span><span class="diff-value">{{ diff.old }}</span></div>
67
+ {% endif %}
68
+ {% if diff.new != "" %}
69
+ <div class="diff-added"><span class="diff-sign">+</span><span class="diff-value">{{ diff.new }}</span></div>
70
+ {% endif %}
71
+ {% else %}
72
+ {{ fallback }}
73
+ {% endif %}
74
+ {% endmacro %}
75
+
76
+ {% macro diff_cell(diff, proposed_value) %}
77
+ <td>
78
+ {% if diff %}
79
+ {% if diff.old != "" %}
80
+ <div class="diff-removed"><span class="diff-sign">&minus;</span><span class="diff-value">{{ diff.old }}</span></div>
81
+ {% endif %}
82
+ {% if diff.new != "" %}
83
+ <div class="diff-added"><span class="diff-sign">+</span><span class="diff-value">{{ diff.new }}</span></div>
84
+ {% endif %}
85
+ {% else %}
86
+ {{ proposed_value }}
87
+ {% endif %}
88
+ </td>
89
+ {% endmacro %}
90
+
91
+ {% macro render_list(course_diff, proposed_course, field_name, ordered=false) %}
92
+ {% set diff = course_diff[field_name] %}
93
+ {% if diff %}
94
+ {% if ordered %}<ol>{% else %}<ul>{% endif %}
95
+ {% for item in diff.unchanged %}
96
+ <li class="diff-list-item unchanged"><span class="diff-sign"> </span><span class="diff-value">{{ item }}</span></li>
97
+ {% endfor %}
98
+ {% for item in diff.removed %}
99
+ <li class="diff-list-item removed"><span class="diff-sign">&minus;</span><span class="diff-value">{{ item }}</span></li>
100
+ {% endfor %}
101
+ {% for item in diff.added %}
102
+ <li class="diff-list-item added"><span class="diff-sign">+</span><span class="diff-value">{{ item }}</span></li>
103
+ {% endfor %}
104
+ {% if ordered %}</ol>{% else %}</ul>{% endif %}
105
+ {% else %}
106
+ {% if ordered %}<ol>{% else %}<ul>{% endif %}
107
+ {% for item in proposed_course[field_name] %}
108
+ <li>{{ item }}</li>
109
+ {% endfor %}
110
+ {% if ordered %}</ol>{% else %}</ul>{% endif %}
111
+ {% endif %}
112
+ {% endmacro %}
113
+
114
+ {% macro render_units(course_diff, proposed_course) %}
115
+ {% set units_diff = course_diff.units %}
116
+ {% if units_diff %}
117
+ {% for unit_entry in units_diff.units %}
118
+ {% if unit_entry.kind == "removed" %}
119
+ <div class="diff-unit removed">
120
+ <div class="diff-unit-header">&minus; {{ unit_entry.unit.title }} ({{ unit_entry.unit.hours }} Hours)</div>
121
+ <p>{{ unit_entry.unit.content }}</p>
122
+ </div>
123
+ {% elif unit_entry.kind == "added" %}
124
+ <div class="diff-unit added">
125
+ <div class="diff-unit-header">+ {{ unit_entry.unit.title }} ({{ unit_entry.unit.hours }} Hours)</div>
126
+ <p>{{ unit_entry.unit.content }}</p>
127
+ </div>
128
+ {% elif unit_entry.kind == "changed" %}
129
+ <div class="diff-unit changed">
130
+ <div class="diff-unit-header">~ {{ unit_entry.unit.title }} ({{ unit_entry.unit.hours }} Hours)</div>
131
+ <p>{{ unit_entry.unit.content }}</p>
132
+ <div class="diff-unit-changes">
133
+ {% for field, change in unit_entry.changes.items() %}
134
+ <div class="diff-unit-change removed">&minus; {{ field }}: {{ change.old }}</div>
135
+ <div class="diff-unit-change added">+ {{ field }}: {{ change.new }}</div>
136
+ {% endfor %}
137
+ </div>
138
+ </div>
139
+ {% else %}
140
+ <div class="diff-unit unchanged">
141
+ <div class="unit-title">{{ unit_entry.unit.title }}</div>
142
+ <p>{{ unit_entry.unit.content }}</p>
143
+ <p class="hours">{{ unit_entry.unit.hours }} Hours</p>
144
+ </div>
145
+ {% endif %}
146
+ {% endfor %}
147
+ {% else %}
148
+ {% for unit in proposed_course.units %}
149
+ <div class="unit">
150
+ <p class="unit-title">{{ unit.title }}</p>
151
+ <p>{{ unit.content }}</p>
152
+ <p class="hours">{{ unit.hours }} Hours</p>
153
+ </div>
154
+ {% endfor %}
155
+ {% endif %}
156
+ {% endmacro %}
157
+
158
+ {% macro render_course(course_diff, proposed_course) %}
159
+ <article class="course">
160
+ <header class="letterhead">
161
+ <img class="seal" src="{{ pes_logo }}" alt="PES University logo" />
162
+ <span class="wordmark">Computer Science and Engineering</span>
163
+ </header>
164
+
165
+ <table class="meta">
166
+ <colgroup>
167
+ <col class="label" />
168
+ <col />
169
+ <col class="label" />
170
+ <col class="hour" />
171
+ <col class="hour" />
172
+ <col class="hour" />
173
+ <col class="hour" />
174
+ <col class="hour" />
175
+ </colgroup>
176
+ <tbody>
177
+ <tr>
178
+ <th>Course Code</th>
179
+ {{ diff_cell(course_diff.course_code, proposed_course.course_code | course_code_for_year(proposed_course.semester, curriculum_year)) }}
180
+ <th>Course Title</th>
181
+ <td colspan="5">{{ diff_inline(course_diff.course_title, proposed_course.course_title) }}</td>
182
+ </tr>
183
+ <tr>
184
+ <th rowspan="2">Program</th>
185
+ <td rowspan="2">{{ proposed_course.program }}</td>
186
+ <th rowspan="2">Hours per week / Credit Assigned</th>
187
+ <th class="center">L</th><th class="center">T</th><th class="center">P</th><th class="center">S</th><th class="center">C</th>
188
+ </tr>
189
+ <tr>
190
+ <td class="center">{{ proposed_course.lecture_hours }}</td>
191
+ <td class="center">{{ proposed_course.tutorial_hours }}</td>
192
+ <td class="center">{{ proposed_course.practical_hours }}</td>
193
+ <td class="center">{{ proposed_course.self_study }}</td>
194
+ <td class="center">{{ proposed_course.credits }}</td>
195
+ </tr>
196
+ <tr>
197
+ <th>Semester</th>
198
+ <td>{{ proposed_course.semester }}</td>
199
+ <th>Type of Course</th>
200
+ <td colspan="5">{{ proposed_course.course_type }}</td>
201
+ </tr>
202
+ <tr>
203
+ <th>AI Tools / Tools / Languages</th>
204
+ {{ diff_cell(course_diff.tools_languages, proposed_course.tools_languages) }}
205
+ <th>Desirable Knowledge</th>
206
+ <td colspan="5">{{ diff_inline(course_diff.desirable_knowledge, proposed_course.desirable_knowledge) }}</td>
207
+ </tr>
208
+ </tbody>
209
+ </table>
210
+
211
+ <table>
212
+ <colgroup>
213
+ <col class="label" />
214
+ <col />
215
+ </colgroup>
216
+ <tbody>
217
+ <tr>
218
+ <th>Prelude</th>
219
+ <td>{{ diff_inline(course_diff.prelude, proposed_course.prelude) }}</td>
220
+ </tr>
221
+ <tr>
222
+ <th>Course Objectives:</th>
223
+ <td>{{ render_list(course_diff, proposed_course, "objectives") }}</td>
224
+ </tr>
225
+ <tr>
226
+ <th>Course Contents</th>
227
+ <td>{{ render_units(course_diff, proposed_course) }}</td>
228
+ </tr>
229
+ {% if proposed_course.lab_experiments %}
230
+ <tr>
231
+ <th>Laboratory</th>
232
+ <td>{{ render_list(course_diff, proposed_course, "lab_experiments", ordered=true) }}</td>
233
+ </tr>
234
+ {% endif %}
235
+ <tr>
236
+ <th>Text Book(s):</th>
237
+ <td>{{ render_list(course_diff, proposed_course, "text_books", ordered=true) }}</td>
238
+ </tr>
239
+ {% if proposed_course.reference_books %}
240
+ <tr>
241
+ <th>Reference Book(s):</th>
242
+ <td>{{ render_list(course_diff, proposed_course, "reference_books", ordered=true) }}</td>
243
+ </tr>
244
+ {% endif %}
245
+ {% if proposed_course.course_outcomes %}
246
+ <tr>
247
+ <th>Course Outcome</th>
248
+ <td>{{ render_list(course_diff, proposed_course, "course_outcomes") }}</td>
249
+ </tr>
250
+ {% endif %}
251
+ </tbody>
252
+ </table>
253
+ </article>
254
+ {% endmacro %}
255
+
256
+ {% if course_diff is defined and course_diff %}
257
+ {{ render_course(course_diff, proposed) }}
258
+ {% elif course_diffs is defined and course_diffs %}
259
+ {% for cd in course_diffs %}
260
+ {{ render_course(cd.course_diff, cd.proposed) }}
261
+ {% endfor %}
262
+ {% else %}
263
+ <div style="padding: 20pt; text-align: center; font-weight: 700;">No diff data available</div>
264
+ {% endif %}
265
+ </body>
266
+ </html>
backend/app/templates/jinja_program.html ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <article class="summary-page">
2
+ <div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:90vh; text-align:center;">
3
+ <img src="{{ pes_logo }}" alt="PES University logo" style="width:140px; height:auto; margin-bottom:24pt;" />
4
+ <h1 style="font-size:22pt; font-weight:700; margin:0 0 24pt;">Computer Science and Engineering</h1>
5
+ <p style="font-size:12pt; margin:0;">P.E.S. University</p>
6
+ <p style="font-size:12pt; margin:4pt 0 0;">Curriculum :- {{ curriculum_year }}</p>
7
+ </div>
8
+ </article>
9
+
10
+ <article class="summary-page">
11
+ <header class="letterhead">
12
+ <img class="seal" src="{{ pes_logo }}" alt="PES University logo" />
13
+ </header>
14
+
15
+ <h1 style="font-size:16pt; font-weight:700; text-align:center; margin:0 0 4pt;">Computer Science and Engineering</h1>
16
+ <h2 style="font-size:12pt; font-weight:700; text-align:center; margin:0 0 14pt;">B. TECH IN COMPUTER SCIENCE AND ENGINEERING</h2>
17
+
18
+ <h3 style="font-size:11pt; font-weight:700; text-align:center; margin:0 0 8pt;">PROGRAM EDUCATIONAL OBJECTIVES</h3>
19
+ <ul style="padding-left:16pt; margin:0 0 14pt;">
20
+ <li style="margin-bottom:4pt;">Prepare and train students in theoretical foundations to work with cutting edge computing technologies and design solutions to complex engineering problems, making them ready to work in industrial environment.</li>
21
+ <li style="margin-bottom:4pt;">Develop all round skills such as team building, inter-personal skills, and leadership qualities in order to effectively communicate with engineering community and with society at large.</li>
22
+ <li style="margin-bottom:4pt;">Promote research culture through internships, research assistantships, research-oriented projects, sponsored and collaborative research and enable them to pursue higher studies in computer science and related fields.</li>
23
+ <li style="margin-bottom:4pt;">To inculcate social concern meeting the requirements of prospective employers and to develop an ability to innovate efficient computing solutions for a better society.</li>
24
+ <li>Create professionally superior and ethically strong globally competent employees and entrepreneurs.</li>
25
+ </ul>
26
+
27
+ <h3 style="font-size:11pt; font-weight:700; text-align:center; margin:0 0 8pt;">PROGRAM OUTCOMES</h3>
28
+ <ul style="padding-left:16pt; margin:0;">
29
+ <li style="margin-bottom:4pt;">Apply mathematical and theoretical principles in the modeling and design of high-quality computer-based systems using state-of-the-art computer technology.</li>
30
+ <li style="margin-bottom:4pt;">Conduct in-depth study of research literature in the area of Computer Science, analyse problems in order to arrive at substantiated conclusions using first principles of mathematics, and allied sciences.</li>
31
+ <li style="margin-bottom:4pt;">Design, implement and evaluate Computer Systems, programs and processes that meet partial/ complete specifications with concern for society, environment and culture.</li>
32
+ <li style="margin-bottom:4pt;">Design and conduct experiments, collect data, analyze and interpret the results to investigate complex engineering problems in the field of Computer Science.</li>
33
+ <li style="margin-bottom:4pt;">Apply state-of-the-art techniques and modern computer-based tools in prediction, comparison and modelling of complex engineering activities.</li>
34
+ <li style="margin-bottom:4pt;">Have a sound understanding of professional, legal, security and social issues and responsibilities in engineering activities involving Computer Science.</li>
35
+ <li style="margin-bottom:4pt;">Understand societal and environmental concerns and demonstrate responsibility in sustainable development of computer-based solutions.</li>
36
+ <li style="margin-bottom:4pt;">Be aware of ethical and professional responsibilities in engineering situations; make informed judgments regarding intellectual property and rights in relation to computer-based solutions in global, economic, environmental and societal contexts.</li>
37
+ <li style="margin-bottom:4pt;">Able to function effectively in teams to establish goals, plan tasks, meet deadlines, manage risk and produce high-quality technical solutions.</li>
38
+ <li style="margin-bottom:4pt;">Contribute and communicate effectively with the society, be able to write effective reports and design documents by adhering to appropriate standards, make effective presentations, give and receive clear instructions.</li>
39
+ <li style="margin-bottom:4pt;">Apply skills in clear communication, responsible teamwork and time management by, for example, managing a team or project and communicating with external stakeholders.</li>
40
+ <li>Recognize the need for and demonstrate an ability to engage in continuing professional development in its broadest sense.</li>
41
+ </ul>
42
+ </article>
backend/app/templates/jinja_sample.html ADDED
@@ -0,0 +1,516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <style>
6
+ @page {
7
+ size: A4;
8
+ margin: 14mm 12mm 18mm;
9
+
10
+ @bottom-left {
11
+ content: "P.E.S. University";
12
+ color: #fff;
13
+ font: 700 10pt Arial, sans-serif;
14
+ padding: 4pt 0 4pt 12mm;
15
+ }
16
+
17
+ @bottom-center {
18
+ content: "Curriculum :- {{ curriculum_year }}";
19
+ color: #fff;
20
+ font: 700 10pt Arial, sans-serif;
21
+ padding: 4pt 0;
22
+ }
23
+
24
+ @bottom-right {
25
+ content: counter(page) " | Page";
26
+ color: #fff;
27
+ font: 700 10pt Arial, sans-serif;
28
+ padding: 4pt 12mm 4pt 0;
29
+ text-align: right;
30
+ }
31
+ }
32
+
33
+ * {
34
+ box-sizing: border-box;
35
+ }
36
+
37
+ body {
38
+ margin: 0;
39
+ color: #000;
40
+ font-family: Calibri, Arial, sans-serif;
41
+ font-size: 10pt;
42
+ line-height: 1.2;
43
+ }
44
+
45
+ .course + .course,
46
+ .summary-page + .summary-page,
47
+ .summary-page + .course {
48
+ break-before: page;
49
+ page-break-before: always;
50
+ }
51
+
52
+ .letterhead {
53
+ display: flex;
54
+ align-items: center;
55
+ justify-content: center;
56
+ gap: 12pt;
57
+ margin-bottom: 10pt;
58
+ }
59
+
60
+ .seal {
61
+ width: 41px;
62
+ height: auto;
63
+ }
64
+
65
+ .wordmark {
66
+ font-family: 'Outfit', sans-serif;
67
+ font-size: 10pt;
68
+ font-weight: 600;
69
+ letter-spacing: 0.3pt;
70
+ }
71
+
72
+ table {
73
+ width: 100%;
74
+ border-collapse: collapse;
75
+ table-layout: fixed;
76
+ }
77
+
78
+ .meta {
79
+ margin-bottom: -1pt;
80
+ }
81
+
82
+ th,
83
+ td {
84
+ border: 1pt solid #000;
85
+ padding: 4pt 5pt;
86
+ vertical-align: top;
87
+ overflow-wrap: anywhere;
88
+ }
89
+
90
+ th {
91
+ font-weight: 700;
92
+ text-align: left;
93
+ }
94
+
95
+ .center {
96
+ text-align: center;
97
+ }
98
+
99
+ .label {
100
+ width: 18%;
101
+ }
102
+
103
+ .hour {
104
+ width: 7%;
105
+ }
106
+
107
+ p {
108
+ margin: 0 0 5pt;
109
+ }
110
+
111
+ p:last-child {
112
+ margin-bottom: 0;
113
+ }
114
+
115
+ ul,
116
+ ol {
117
+ margin: 0;
118
+ padding-left: 16pt;
119
+ }
120
+
121
+ li {
122
+ margin-bottom: 3pt;
123
+ }
124
+
125
+ li:last-child {
126
+ margin-bottom: 0;
127
+ }
128
+
129
+ .unit-title,
130
+ .hours {
131
+ font-weight: 700;
132
+ }
133
+
134
+ .unit {
135
+ margin-bottom: 8pt;
136
+ }
137
+
138
+ .unit:last-child {
139
+ margin-bottom: 0;
140
+ }
141
+
142
+ .hours {
143
+ text-align: right;
144
+ }
145
+
146
+ .resource-link {
147
+ color: inherit;
148
+ text-decoration: underline;
149
+ }
150
+
151
+ .semester-title {
152
+ margin: 0 0 8pt;
153
+ text-align: center;
154
+ font-size: 11pt;
155
+ font-weight: 700;
156
+ }
157
+
158
+ .summary-table {
159
+ margin-bottom: 8pt;
160
+ }
161
+
162
+ .specialization-table p {
163
+ margin: 0 0 2pt;
164
+ }
165
+
166
+ .summary-table th,
167
+ .summary-table td {
168
+ padding: 3pt 4pt;
169
+ font-size: 9pt;
170
+ }
171
+
172
+ .summary-table th {
173
+ font-size: 8.5pt;
174
+ }
175
+
176
+ .summary-number {
177
+ width: 6%;
178
+ }
179
+
180
+ .summary-code {
181
+ width: 16%;
182
+ }
183
+
184
+ .summary-title-column {
185
+ width: 18%;
186
+ }
187
+
188
+ .summary-hour {
189
+ width: 5.5%;
190
+ }
191
+
192
+ .summary-credit {
193
+ width: 8%;
194
+ }
195
+
196
+ .summary-tools {
197
+ width: 18%;
198
+ }
199
+
200
+ .summary-type {
201
+ width: 12%;
202
+ }
203
+
204
+ .nowrap {
205
+ white-space: nowrap;
206
+ }
207
+
208
+ .summary-table td:nth-child(8),
209
+ .summary-table th:nth-child(8) {
210
+ text-align: center;
211
+ }
212
+
213
+ .summary-table td:nth-child(n + 4):nth-child(-n + 8),
214
+ .summary-table th:nth-child(n + 4):nth-child(-n + 8) {
215
+ padding-left: 2pt;
216
+ padding-right: 2pt;
217
+ white-space: nowrap;
218
+ }
219
+
220
+ .summary-table td:nth-child(2),
221
+ .summary-table th:nth-child(2) {
222
+ white-space: nowrap;
223
+ }
224
+
225
+ .final-number {
226
+ width: 7%;
227
+ }
228
+
229
+ .final-code {
230
+ width: 16%;
231
+ }
232
+
233
+ .final-title {
234
+ width: 23%;
235
+ }
236
+
237
+ .final-hour {
238
+ width: 6%;
239
+ }
240
+
241
+ .final-credit {
242
+ width: 8%;
243
+ }
244
+
245
+ .final-type {
246
+ width: 22%;
247
+ }
248
+
249
+ .empty {
250
+ margin-top: 18pt;
251
+ text-align: center;
252
+ font-weight: 700;
253
+ }
254
+
255
+ .diff-label {
256
+ text-align: center;
257
+ font-size: 13pt;
258
+ font-weight: 700;
259
+ padding: 6pt 0;
260
+ margin-bottom: 8pt;
261
+ border: 2pt solid #6b7280;
262
+ border-radius: 4pt;
263
+ color: #374151;
264
+ background: #f9fafb;
265
+ }
266
+
267
+ .diff-changes {
268
+ margin: 8mm 0;
269
+ font-size: 10pt;
270
+ }
271
+ .diff-changes summary {
272
+ cursor: pointer;
273
+ font-weight: 700;
274
+ margin-bottom: 4pt;
275
+ }
276
+ .diff-changes table {
277
+ width: 100%;
278
+ border-collapse: collapse;
279
+ background: #fff;
280
+ }
281
+ .diff-changes th, .diff-changes td {
282
+ border: 1px solid #d1d5db;
283
+ padding: 4pt 6pt;
284
+ text-align: left;
285
+ vertical-align: top;
286
+ }
287
+ .diff-changes th {
288
+ background: #f3f4f6;
289
+ }
290
+ .diff-changes .old-val {
291
+ color: #b91c1c;
292
+ }
293
+ .diff-changes .new-val {
294
+ color: #15803d;
295
+ }
296
+ .diff-course-header {
297
+ font-weight: 700;
298
+ background: #f9fafb;
299
+ padding: 6pt 0;
300
+ margin-top: 6mm;
301
+ border-bottom: 1px solid #d1d5db;
302
+ }
303
+
304
+ .footer-bar {
305
+ position: fixed;
306
+ left: 0;
307
+ right: 0;
308
+ bottom: -14mm;
309
+ height: 9mm;
310
+ background: #000;
311
+ }
312
+ </style>
313
+ </head>
314
+ <body>
315
+ <div class="footer-bar"></div>
316
+ {% set course_list = courses if courses is defined else [course] %}
317
+ {% if course_list %}
318
+ {% if diff_changes is defined and diff_changes %}
319
+ <div class="diff-changes">
320
+ <details open>
321
+ <summary>Changed fields ({{ diff_changes|length }})</summary>
322
+ {% if diff_changes[0].course is defined %}
323
+ {% for group in diff_changes %}
324
+ <div class="diff-course-header">{{ group.course }}</div>
325
+ <table>
326
+ <tr><th>Field</th><th>Old Value</th><th>New Value</th></tr>
327
+ {% for c in group.changes %}
328
+ <tr><td>{{ c.field }}</td><td class="old-val">{{ c.old }}</td><td class="new-val">{{ c.new }}</td></tr>
329
+ {% endfor %}
330
+ </table>
331
+ {% endfor %}
332
+ {% else %}
333
+ <table>
334
+ <tr><th>Field</th><th>Old Value</th><th>New Value</th></tr>
335
+ {% for c in diff_changes %}
336
+ <tr><td>{{ c.field }}</td><td class="old-val">{{ c.old }}</td><td class="new-val">{{ c.new }}</td></tr>
337
+ {% endfor %}
338
+ </table>
339
+ {% endif %}
340
+ </details>
341
+ </div>
342
+ {% endif %}
343
+ {% if show_summaries | default(false) %}
344
+ {% include "jinja_program.html" %}
345
+ {% set summary_range = "early" %}
346
+ {% include "jinja_1_to_8.html" %}
347
+ {% include "jinja_sem_5_6.html" %}
348
+ {% set summary_range = "final" %}
349
+ {% include "jinja_1_to_8.html" %}
350
+ {% endif %}
351
+ {% for course in course_list %}
352
+ {% if not show_summaries | default(false) or course.render_detail | default(true) %}
353
+ <article class="course">
354
+ {% if course._version_label %}
355
+ <div class="diff-label">{{ course._version_label }}</div>
356
+ {% endif %}
357
+ <header class="letterhead">
358
+ <img class="seal" src="{{ pes_logo }}" alt="PES University logo" />
359
+ <span class="wordmark">Computer Science and Engineering</span>
360
+ </header>
361
+
362
+ <table class="meta">
363
+ <colgroup>
364
+ <col class="label" />
365
+ <col />
366
+ <col class="label" />
367
+ <col class="hour" />
368
+ <col class="hour" />
369
+ <col class="hour" />
370
+ <col class="hour" />
371
+ <col class="hour" />
372
+ </colgroup>
373
+ <tbody>
374
+ <tr>
375
+ <th>Course Code</th>
376
+ <td>{{ course.course_code | course_code_for_year(course.semester, curriculum_year) }}</td>
377
+ <th>Course Title</th>
378
+ <td colspan="5">{{ course.course_title }}</td>
379
+ </tr>
380
+ <tr>
381
+ <th rowspan="2">Program</th>
382
+ <td rowspan="2">{{ course.program }}</td>
383
+ <th rowspan="2">Hours per week/ Credit Assigned</th>
384
+ <th class="center">L</th>
385
+ <th class="center">T</th>
386
+ <th class="center">P</th>
387
+ <th class="center">S</th>
388
+ <th class="center">C</th>
389
+ </tr>
390
+ <tr>
391
+ <td class="center">{{ course.lecture_hours }}</td>
392
+ <td class="center">{{ course.tutorial_hours }}</td>
393
+ <td class="center">{{ course.practical_hours }}</td>
394
+ <td class="center">{{ course.self_study }}</td>
395
+ <td class="center">{{ course.credits }}</td>
396
+ </tr>
397
+ <tr>
398
+ <th>Semester</th>
399
+ <td>{{ course.semester }}</td>
400
+ <th>Type of Course</th>
401
+ <td colspan="5">{{ course.course_type }}</td>
402
+ </tr>
403
+ <tr>
404
+ <th>AI Tools / Tools / Languages</th>
405
+ <td>{{ course.tools_languages }}</td>
406
+ <th>Desirable Knowledge</th>
407
+ <td colspan="5">{{ course.desirable_knowledge }}</td>
408
+ </tr>
409
+ </tbody>
410
+ </table>
411
+
412
+ <table>
413
+ <colgroup>
414
+ <col class="label" />
415
+ <col />
416
+ </colgroup>
417
+ <tbody>
418
+ <tr>
419
+ <th>Prelude</th>
420
+ <td>{{ course.prelude }}</td>
421
+ </tr>
422
+ <tr>
423
+ <th>Course Objectives:</th>
424
+ <td>
425
+ <ul>
426
+ {% for objective in course.objectives %}
427
+ <li>{{ objective }}</li>
428
+ {% endfor %}
429
+ </ul>
430
+ </td>
431
+ </tr>
432
+ <tr>
433
+ <th>Course Contents</th>
434
+ <td>
435
+ {% if course.units %}
436
+ {% for unit in course.units %}
437
+ <div class="unit">
438
+ <p class="unit-title">{{ unit.title }}</p>
439
+ <p>{{ unit.content }}</p>
440
+ <p class="hours">{{ unit.hours }} Hours</p>
441
+ </div>
442
+ {% endfor %}
443
+ {% else %}
444
+ -
445
+ {% endif %}
446
+ </td>
447
+ </tr>
448
+ {% if course.lab_experiments %}
449
+ <tr>
450
+ <th>Laboratory</th>
451
+ <td>
452
+ <ol>
453
+ {% for experiment in course.lab_experiments %}
454
+ <li>{{ experiment }}</li>
455
+ {% endfor %}
456
+ </ol>
457
+ </td>
458
+ </tr>
459
+ {% endif %}
460
+ <tr>
461
+ <th>Text Book(s):</th>
462
+ <td>
463
+ {% if course.text_books %}
464
+ <ol>
465
+ {% for book in course.text_books %}
466
+ <li>{{ book | linkify }}</li>
467
+ {% endfor %}
468
+ </ol>
469
+ {% else %}
470
+ -
471
+ {% endif %}
472
+ </td>
473
+ </tr>
474
+ {% if course.reference_books %}
475
+ <tr>
476
+ <th>Reference Book(s):</th>
477
+ <td>
478
+ <ol>
479
+ {% for book in course.reference_books %}
480
+ <li>{{ book | linkify }}</li>
481
+ {% endfor %}
482
+ </ol>
483
+ </td>
484
+ </tr>
485
+ {% endif %}
486
+ {% if course.course_outcomes %}
487
+ <tr>
488
+ <th>Course Outcome</th>
489
+ <td>
490
+ <ul>
491
+ {% for outcome in course.course_outcomes %}
492
+ <li>{{ outcome }}</li>
493
+ {% endfor %}
494
+ </ul>
495
+ </td>
496
+ </tr>
497
+ {% endif %}
498
+ </tbody>
499
+ </table>
500
+ </article>
501
+ {% endif %}
502
+ {% endfor %}
503
+ {% else %}
504
+ <div class="empty">No refined courses found for Semester {{ semester }}.</div>
505
+ {% endif %}
506
+ {% if show_thank_you | default(true) %}
507
+ <article class="summary-page">
508
+ <div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:90vh; text-align:center;">
509
+ <img src="{{ pes_logo }}" alt="PES University logo" style="width:100px; height:auto; margin-bottom:24pt;" />
510
+ <h1 style="font-size:18pt; font-weight:700; margin:0 0 8pt;">Thank You</h1>
511
+ <p style="font-size:10pt; color:#555; margin:0;">P.E.S. University - Curriculum {{ curriculum_year }}</p>
512
+ </div>
513
+ </article>
514
+ {% endif %}
515
+ </body>
516
+ </html>
backend/app/templates/jinja_sem_5_6.html ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% macro course_row(course, number) -%}
2
+ {% set code = course.course_code|string|upper|replace(" ", "") %}
3
+ {% set title = course.course_title|string|lower %}
4
+ {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
5
+ {% set is_no_course = "no course offered" in title %}
6
+ {% set is_elective = course.is_elective %}
7
+ <tr>
8
+ <td class="center">{{ number }}</td>
9
+ <td>{{ course.course_code | course_code_for_year(course.semester, curriculum_year) }}</td>
10
+ <td>{{ course.course_title }}</td>
11
+ <td class="center">{% if is_no_course %}{% elif is_placeholder %}{% else %}{{ course.lecture_hours }}{% endif %}</td>
12
+ <td class="center">{% if is_no_course %}{% elif is_placeholder %}{% else %}{{ course.tutorial_hours }}{% endif %}</td>
13
+ <td class="center">{% if is_no_course %}{% elif is_placeholder %}{% else %}{{ course.practical_hours }}{% endif %}</td>
14
+ <td class="center">{% if is_no_course %}{% elif is_placeholder %}{% else %}{{ course.self_study }}{% endif %}</td>
15
+ <td class="center">{% if is_no_course %}{% elif is_placeholder %}{% else %}{{ course.credits }}{% endif %}</td>
16
+ <td>{{ course.tools_languages }}</td>
17
+ <td>{% if is_no_course %}{% elif is_elective or is_placeholder %}Elective Course{% else %}{{ course.course_type }}{% endif %}</td>
18
+ </tr>
19
+ {%- endmacro %}
20
+
21
+ {% macro total_cell(total, credit_total) -%}
22
+ {% if total != credit_total %}{{ credit_total }}/{{ total }}{% else %}{{ total }}{% endif %}
23
+ {%- endmacro %}
24
+
25
+ {% macro spec_codes_for(spec, summary_semester) -%}
26
+ {% set first = [] %}
27
+ {% set second = [] %}
28
+ {% for assign in specialization_assignments|default([]) %}
29
+ {% if assign.specialization_id == spec.id %}
30
+ {% for c in course_list %}
31
+ {% set code = c.course_code|string|upper|replace(" ", "") %}
32
+ {% set title = c.course_title|string|lower %}
33
+ {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
34
+ {% set is_no_course = "no course offered" in title %}
35
+ {% if c.refined_id|string == assign.refined_id|string
36
+ and c.semester|string == summary_semester|string
37
+ and c.is_elective
38
+ and not is_placeholder
39
+ and not is_no_course %}
40
+ {% set adjusted = c.course_code | course_code_for_year(c.semester, curriculum_year) %}
41
+ {% if (summary_semester == 5 and "AA" in code) or (summary_semester == 6 and "BA" in code) %}
42
+ {% do first.append(adjusted) %}
43
+ {% elif (summary_semester == 5 and "AB" in code) or (summary_semester == 6 and "BB" in code) %}
44
+ {% do second.append(adjusted) %}
45
+ {% else %}
46
+ {% do first.append(adjusted) %}
47
+ {% endif %}
48
+ {% endif %}
49
+ {% endfor %}
50
+ {% endif %}
51
+ {% endfor %}
52
+ <tr>
53
+ <td class="center">{{ spec.letter }}</td>
54
+ <td>{{ spec.name }}</td>
55
+ <td>{% for code in first %}<p>{{ code }}</p>{% endfor %}</td>
56
+ <td>{% for code in second %}<p>{{ code }}</p>{% endfor %}</td>
57
+ </tr>
58
+ {%- endmacro %}
59
+
60
+ {% set requested_semester = semester|string if semester is defined and semester else "" %}
61
+ {% for summary_semester in [5, 6] %}
62
+ {% if not requested_semester or requested_semester == summary_semester|string %}
63
+ {% set totals = namespace(count=0, lecture=0, lecture_credit=0, tutorial=0, practical=0, self_study=0, credits=0) %}
64
+ {% for summary_course in course_list %}
65
+ {% set code = summary_course.course_code|string|upper|replace(" ", "") %}
66
+ {% set title = summary_course.course_title|string|lower %}
67
+ {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
68
+ {% set is_no_course = "no course offered" in title %}
69
+ {% if summary_course.semester|string == summary_semester|string and not summary_course.is_elective and not is_placeholder and not is_no_course %}
70
+ {% set credit_value = summary_course.credits|int %}
71
+ {% set totals.count = totals.count + 1 %}
72
+ {% set totals.lecture = totals.lecture + summary_course.lecture_hours|int %}
73
+ {% set totals.tutorial = totals.tutorial + summary_course.tutorial_hours|int %}
74
+ {% set totals.practical = totals.practical + summary_course.practical_hours|int %}
75
+ {% set totals.self_study = totals.self_study + summary_course.self_study|int %}
76
+ {% set totals.credits = totals.credits + credit_value %}
77
+ {% if credit_value > 0 %}
78
+ {% set totals.lecture_credit = totals.lecture_credit + summary_course.lecture_hours|int %}
79
+ {% endif %}
80
+ {% endif %}
81
+ {% endfor %}
82
+ {% set placeholder_names = ["Elective I", "Elective II"] if summary_semester == 5 else ["Elective III", "Elective IV"] %}
83
+ {% set has_real_courses = totals.count > 0 %}
84
+ {% if has_real_courses %}
85
+ {% set totals.count = totals.count + placeholder_names|length %}
86
+ {% set totals.credits = totals.credits + placeholder_names|length * 4 %}
87
+ {% endif %}
88
+
89
+ {% set elective_a = namespace(count=0) %}
90
+ {% set elective_b = namespace(count=0) %}
91
+ {% for summary_course in course_list %}
92
+ {% set code = summary_course.course_code|string|upper|replace(" ", "") %}
93
+ {% set title = summary_course.course_title|string|lower %}
94
+ {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
95
+ {% set is_no_course = "no course offered" in title %}
96
+ {% if summary_course.semester|string == summary_semester|string and summary_course.is_elective and not is_placeholder and not is_no_course %}
97
+ {% if (summary_semester == 5 and "AA" in code) or (summary_semester == 6 and "BA" in code) %}
98
+ {% set elective_a.count = elective_a.count + 1 %}
99
+ {% elif (summary_semester == 5 and "AB" in code) or (summary_semester == 6 and "BB" in code) %}
100
+ {% set elective_b.count = elective_b.count + 1 %}
101
+ {% endif %}
102
+ {% endif %}
103
+ {% endfor %}
104
+
105
+ {% if totals.count or elective_a.count or elective_b.count %}
106
+ <article class="summary-page">
107
+ <header class="letterhead">
108
+ <img class="seal" src="{{ pes_logo }}" alt="PES University logo" />
109
+ <span class="wordmark">Computer Science and Engineering</span>
110
+ </header>
111
+ <h2 class="semester-title">{{ SEMESTER_NAMES[summary_semester|string] }} SEMESTER {{ batch_label(summary_semester, curriculum_year) }}</h2>
112
+
113
+ {% if totals.count %}
114
+ <table class="meta summary-table">
115
+ <colgroup>
116
+ <col class="summary-number" />
117
+ <col class="summary-code" />
118
+ <col class="summary-title-column" />
119
+ <col class="summary-hour" />
120
+ <col class="summary-hour" />
121
+ <col class="summary-hour" />
122
+ <col class="summary-hour" />
123
+ <col class="summary-credit" />
124
+ <col class="summary-tools" />
125
+ <col class="summary-type" />
126
+ </colgroup>
127
+ <tbody>
128
+ <tr>
129
+ <th rowspan="2" class="center">Sl. No.</th>
130
+ <th rowspan="2">Course Code</th>
131
+ <th rowspan="2">Course Title</th>
132
+ <th colspan="4" class="center">Hours per week</th>
133
+ <th class="center nowrap">Credits</th>
134
+ <th rowspan="2">AI Tools / Tools /<br /> Languages</th>
135
+ <th rowspan="2">Course Type</th>
136
+ </tr>
137
+ <tr>
138
+ <th class="center">L</th>
139
+ <th class="center">T</th>
140
+ <th class="center">P</th>
141
+ <th class="center">S</th>
142
+ <th class="center">C</th>
143
+ </tr>
144
+ {% set row = namespace(number=1) %}
145
+ {% for summary_course in course_list %}
146
+ {% set code = summary_course.course_code|string|upper|replace(" ", "") %}
147
+ {% set title = summary_course.course_title|string|lower %}
148
+ {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
149
+ {% set is_no_course = "no course offered" in title %}
150
+ {% if summary_course.semester|string == summary_semester|string and not summary_course.is_elective and not is_placeholder and not is_no_course %}
151
+ {{ course_row(summary_course, row.number) }}
152
+ {% set row.number = row.number + 1 %}
153
+ {% endif %}
154
+ {% endfor %}
155
+ {% if has_real_courses %}
156
+ {% for pname in placeholder_names %}
157
+ <tr>
158
+ <td class="center">{{ row.number }}</td>
159
+ <td></td>
160
+ <td>{{ pname }}</td>
161
+ <td class="center"></td>
162
+ <td class="center"></td>
163
+ <td class="center"></td>
164
+ <td class="center"></td>
165
+ <td class="center">4</td>
166
+ <td></td>
167
+ <td>Elective Course</td>
168
+ </tr>
169
+ {% set row.number = row.number + 1 %}
170
+ {% endfor %}
171
+ {% endif %}
172
+ <tr>
173
+ <th colspan="3" class="center">Total</th>
174
+ <td class="center">{{ total_cell(totals.lecture, totals.lecture_credit) }}</td>
175
+ <td class="center">{{ totals.tutorial }}</td>
176
+ <td class="center">{{ totals.practical }}</td>
177
+ <td class="center">{{ totals.self_study }}</td>
178
+ <td class="center">{{ totals.credits }}</td>
179
+ <td></td>
180
+ <td></td>
181
+ </tr>
182
+ </tbody>
183
+ </table>
184
+ {% endif %}
185
+
186
+ {% for group in ["a", "b"] %}
187
+ {% set group_count = elective_a.count if group == "a" else elective_b.count %}
188
+ {% if group_count %}
189
+ <table class="meta summary-table">
190
+ <colgroup>
191
+ <col class="summary-number" />
192
+ <col class="summary-code" />
193
+ <col class="summary-title-column" />
194
+ <col class="summary-hour" />
195
+ <col class="summary-hour" />
196
+ <col class="summary-hour" />
197
+ <col class="summary-hour" />
198
+ <col class="summary-credit" />
199
+ <col class="summary-tools" />
200
+ <col class="summary-type" />
201
+ </colgroup>
202
+ <tbody>
203
+ <tr>
204
+ <th colspan="10" class="center">
205
+ {% if summary_semester == 5 and group == "a" %}Elective-I{% endif %}
206
+ {% if summary_semester == 5 and group == "b" %}Elective-II{% endif %}
207
+ {% if summary_semester == 6 and group == "a" %}Elective-III{% endif %}
208
+ {% if summary_semester == 6 and group == "b" %}Elective-IV{% endif %}
209
+ </th>
210
+ </tr>
211
+ {% set start_number = totals.count + 1 if group == "a" else totals.count + elective_a.count + 1 %}
212
+ {% set row = namespace(number=start_number) %}
213
+ {% for summary_course in course_list %}
214
+ {% set code = summary_course.course_code|string|upper|replace(" ", "") %}
215
+ {% set title = summary_course.course_title|string|lower %}
216
+ {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
217
+ {% set is_no_course = "no course offered" in title %}
218
+ {% set is_group_a = (summary_semester == 5 and "AA" in code) or (summary_semester == 6 and "BA" in code) %}
219
+ {% set is_group_b = (summary_semester == 5 and "AB" in code) or (summary_semester == 6 and "BB" in code) %}
220
+ {% if summary_course.semester|string == summary_semester|string and summary_course.is_elective and not is_placeholder and not is_no_course and ((group == "a" and is_group_a) or (group == "b" and is_group_b)) %}
221
+ {{ course_row(summary_course, row.number) }}
222
+ {% set row.number = row.number + 1 %}
223
+ {% endif %}
224
+ {% endfor %}
225
+ </tbody>
226
+ </table>
227
+ {% endif %}
228
+ {% endfor %}
229
+
230
+ {% set semester_specs = [] %}
231
+ {% for spec in specializations|default([]) %}
232
+ {% if spec.semester|string == summary_semester|string %}
233
+ {% do semester_specs.append(spec) %}
234
+ {% endif %}
235
+ {% endfor %}
236
+ {% if semester_specs %}
237
+ <table class="meta summary-table specialization-table">
238
+ <colgroup>
239
+ <col class="summary-number" />
240
+ <col />
241
+ <col class="summary-title-column" />
242
+ <col class="summary-title-column" />
243
+ </colgroup>
244
+ <tbody>
245
+ <tr>
246
+ <th colspan="4" class="center">ELECTIVES TO BE OPTED FOR SPECIALIZATION</th>
247
+ </tr>
248
+ <tr>
249
+ <th class="center">Sl. No.</th>
250
+ <th>Specialization</th>
251
+ <th>{{ "Elective-I" if summary_semester == 5 else "Elective-III" }}</th>
252
+ <th>{{ "Elective-II" if summary_semester == 5 else "Elective-IV" }}</th>
253
+ </tr>
254
+ {% for spec in semester_specs %}
255
+ {{ spec_codes_for(spec, summary_semester) }}
256
+ {% endfor %}
257
+ </tbody>
258
+ </table>
259
+ {% endif %}
260
+ </article>
261
+ {% endif %}
262
+ {% endif %}
263
+ {% endfor %}
docs/schema.sql ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ create table if not exists submissions (
2
+ id bigint generated always as identity primary key,
3
+ faculty_email text not null,
4
+ course_title text not null,
5
+ course_code text not null,
6
+ offering_department text not null default 'CS',
7
+ target_department text not null default 'CSE',
8
+ semester int not null default 1,
9
+ credit_category text not null default '4',
10
+ raw_course_content text not null,
11
+ text_books text not null default '',
12
+ reference_books text not null default '',
13
+ preferred_tools text not null default '',
14
+ status text not null default 'pending',
15
+ created_at timestamptz default now()
16
+ );
17
+
18
+ -- Refined / template-ready courses
19
+ create table if not exists refined_submissions (
20
+ id bigint generated always as identity primary key,
21
+ submission_id bigint references submissions(id),
22
+ faculty_email text not null default '',
23
+ course_title text not null default '',
24
+ course_code text not null default '',
25
+ offering_department text not null default 'CS',
26
+ target_department text not null default 'CSE',
27
+ semester int not null default 1,
28
+ credit_category text not null default '4',
29
+ program text not null default 'B. TECH',
30
+ lecture_hours int not null default 0,
31
+ tutorial_hours int not null default 0,
32
+ practical_hours int not null default 0,
33
+ self_study int not null default 0,
34
+ credits int not null default 0,
35
+ course_type text not null default '',
36
+ is_elective boolean not null default false,
37
+ visible boolean not null default true,
38
+ units jsonb not null default '[]'::jsonb,
39
+ objectives text not null default '',
40
+ course_outcomes text not null default '',
41
+ text_books text not null default '',
42
+ reference_books text not null default '',
43
+ lab_experiments text not null default '',
44
+ tools_languages text not null default '',
45
+ raw_course_content text not null default '',
46
+ status text not null default 'refined',
47
+ created_at timestamptz default now()
48
+ );
49
+
50
+ -- Specialization track definitions (e.g. SCC, MIDS, CSCS for semesters 5 & 6)
51
+ create table if not exists specialization_definitions (
52
+ id bigint generated always as identity primary key,
53
+ semester int not null,
54
+ letter text not null,
55
+ name text not null,
56
+ key text not null,
57
+ academic_year text not null default '',
58
+ created_at timestamptz default now()
59
+ );
60
+
61
+ -- Which electives belong to which specialization tracks
62
+ create table if not exists course_specialization_assignments (
63
+ id bigint generated always as identity primary key,
64
+ refined_id bigint references refined_submissions(id) on delete cascade,
65
+ specialization_id bigint references specialization_definitions(id) on delete cascade,
66
+ created_at timestamptz default now(),
67
+ unique (refined_id, specialization_id)
68
+ );
69
+
70
+ -- Curriculum version snapshots
71
+ create table if not exists curriculum_versions (
72
+ id bigint generated always as identity primary key,
73
+ name text not null default '',
74
+ program text not null default '',
75
+ academic_year text not null default '',
76
+ status text not null default 'draft',
77
+ created_at timestamptz default now()
78
+ );
79
+
80
+ -- Course snapshots within a version
81
+ create table if not exists finalized_submissions (
82
+ id bigint generated always as identity primary key,
83
+ curriculum_version_id bigint references curriculum_versions(id) on delete cascade,
84
+ refined_id bigint references refined_submissions(id),
85
+ course_json jsonb not null default '{}'::jsonb,
86
+ created_at timestamptz default now()
87
+ );
88
+
89
+ -- Revision history for applied drafts
90
+ create table if not exists course_revision_history (
91
+ id bigint generated always as identity primary key,
92
+ refined_id bigint references refined_submissions(id),
93
+ draft_id bigint,
94
+ old_json jsonb not null default '{}'::jsonb,
95
+ new_json jsonb not null default '{}'::jsonb,
96
+ created_at timestamptz default now()
97
+ );
98
+
99
+ -- Agent-proposed course drafts
100
+ create table if not exists agent_drafts (
101
+ id bigint generated always as identity primary key,
102
+ refined_id bigint references refined_submissions(id),
103
+ document_draft_id bigint,
104
+ base_refined_json jsonb not null default '{}'::jsonb,
105
+ proposed_json jsonb not null default '{}'::jsonb,
106
+ json_patch jsonb not null default '[]'::jsonb,
107
+ diff_summary jsonb not null default '{}'::jsonb,
108
+ change_reason text not null default '',
109
+ status text not null default 'proposed',
110
+ created_at timestamptz default now()
111
+ );
112
+
113
+ -- Agent-proposed document drafts (multi-course)
114
+ create table if not exists agent_document_drafts (
115
+ id bigint generated always as identity primary key,
116
+ curriculum_version_id bigint,
117
+ uploaded_document_id text not null default '',
118
+ diff_summary jsonb not null default '{}'::jsonb,
119
+ change_reason text not null default '',
120
+ status text not null default 'proposed',
121
+ created_at timestamptz default now()
122
+ );
123
+
124
+ -- Chat sessions
125
+ create table if not exists chat_sessions (
126
+ id bigint generated always as identity primary key,
127
+ refined_id bigint,
128
+ document_draft_id bigint,
129
+ title text not null default '',
130
+ status text not null default 'active',
131
+ created_at timestamptz default now()
132
+ );
133
+
134
+ -- Chat messages
135
+ create table if not exists chat_messages (
136
+ id bigint generated always as identity primary key,
137
+ session_id bigint references chat_sessions(id) on delete cascade,
138
+ role text not null default 'user',
139
+ content text not null default '',
140
+ metadata jsonb not null default '{}'::jsonb,
141
+ created_at timestamptz default now()
142
+ );
143
+
144
+ -- Chat attachments (files, reports)
145
+ create table if not exists chat_attachments (
146
+ id bigint generated always as identity primary key,
147
+ session_id bigint references chat_sessions(id) on delete cascade,
148
+ message_id bigint,
149
+ filename text not null default '',
150
+ content_type text not null default '',
151
+ size_bytes bigint not null default 0,
152
+ extracted_text text not null default '',
153
+ content_base64 text not null default '',
154
+ status text not null default 'pending',
155
+ error text not null default '',
156
+ created_at timestamptz default now()
157
+ );
frontend/auth/auth.css ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "../shared.css";
2
+
3
+ .auth-shell {
4
+ min-height: 100dvh;
5
+ display: grid;
6
+ place-items: center;
7
+ background: var(--page-bg);
8
+ padding: 24px;
9
+ }
10
+
11
+ .auth-card {
12
+ width: 100%;
13
+ max-width: 400px;
14
+ background: var(--surface);
15
+ border: 1px solid var(--border);
16
+ border-radius: 20px;
17
+ padding: 2.5rem;
18
+ box-shadow: var(--shadow-md);
19
+ }
20
+
21
+ .auth-header {
22
+ text-align: center;
23
+ margin-bottom: 28px;
24
+ }
25
+
26
+ .auth-header h1 {
27
+ margin: 0 0 8px;
28
+ font-size: 24px;
29
+ font-weight: 700;
30
+ color: var(--pesu-blue);
31
+ }
32
+
33
+ .auth-subtitle {
34
+ margin: 0;
35
+ color: var(--muted);
36
+ font-size: 14px;
37
+ }
38
+
39
+ .auth-form {
40
+ display: grid;
41
+ gap: 18px;
42
+ }
43
+
44
+ .form-group {
45
+ display: grid;
46
+ gap: 6px;
47
+ }
48
+
49
+ .form-group label {
50
+ font-size: 13px;
51
+ font-weight: 600;
52
+ color: var(--text);
53
+ }
54
+
55
+ .form-group input {
56
+ width: 100%;
57
+ box-sizing: border-box;
58
+ }
59
+
60
+ .password-wrapper {
61
+ position: relative;
62
+ }
63
+
64
+ .password-wrapper input {
65
+ padding-right: 48px;
66
+ }
67
+
68
+ .toggle-password {
69
+ position: absolute;
70
+ right: 10px;
71
+ top: 50%;
72
+ transform: translateY(-50%);
73
+ background: transparent;
74
+ border: none;
75
+ padding: 4px;
76
+ color: var(--muted);
77
+ cursor: pointer;
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: center;
81
+ }
82
+
83
+ .toggle-password:hover {
84
+ color: var(--pesu-blue);
85
+ }
86
+
87
+ .toggle-password .eye-closed {
88
+ display: none;
89
+ }
90
+
91
+ .toggle-password.active .eye-open {
92
+ display: none;
93
+ }
94
+
95
+ .toggle-password.active .eye-closed {
96
+ display: block;
97
+ }
98
+
99
+ .auth-error {
100
+ display: flex;
101
+ align-items: center;
102
+ gap: 8px;
103
+ padding: 12px 14px;
104
+ border: 1px solid var(--error);
105
+ border-radius: 8px;
106
+ background: var(--error-bg);
107
+ color: var(--error);
108
+ font-size: 13px;
109
+ }
110
+
111
+ .auth-error:empty {
112
+ display: none;
113
+ }
114
+
115
+ .btn-primary {
116
+ border: none;
117
+ background: var(--pesu-orange);
118
+ color: #fff;
119
+ border-radius: 10px;
120
+ transition: background 0.15s, border-color 0.15s;
121
+ }
122
+
123
+ .btn-primary:hover {
124
+ border-color: color-mix(in srgb, var(--pesu-orange) 80%, black);
125
+ }
126
+
127
+ .btn-block {
128
+ width: 100%;
129
+ }
frontend/auth/auth.js ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { supabase } from '../shared/supabase-client.js';
2
+
3
+ const form = document.getElementById('login-form');
4
+ const emailInput = document.getElementById('email');
5
+ const passwordInput = document.getElementById('password');
6
+ const submitBtn = document.getElementById('submit-btn');
7
+ const errorEl = document.getElementById('auth-error');
8
+ const toggleBtn = document.querySelector('.toggle-password');
9
+
10
+ toggleBtn.addEventListener('click', () => {
11
+ const type = passwordInput.type === 'password' ? 'text' : 'password';
12
+ passwordInput.type = type;
13
+ toggleBtn.classList.toggle('active', type === 'text');
14
+ });
15
+
16
+ form.addEventListener('submit', async (e) => {
17
+ e.preventDefault();
18
+ errorEl.hidden = true;
19
+ errorEl.textContent = '';
20
+ submitBtn.disabled = true;
21
+ submitBtn.textContent = 'Signing in...';
22
+
23
+ const email = emailInput.value.trim();
24
+ const password = passwordInput.value;
25
+
26
+ const { error } = await supabase.auth.signInWithPassword({ email, password });
27
+
28
+ if (error) {
29
+ errorEl.textContent = error.message;
30
+ errorEl.hidden = false;
31
+ submitBtn.disabled = false;
32
+ submitBtn.textContent = 'Sign In';
33
+ return;
34
+ }
35
+
36
+ location.href = '/';
37
+ });
38
+
39
+ // Check if already signed in
40
+ supabase.auth.getSession().then(({ data: { session } }) => {
41
+ if (session) {
42
+ location.href = '/';
43
+ }
44
+ });
frontend/auth/index.html ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/png" href="../images/SE1128.png" />
7
+ <title>Sign In - Syntagma</title>
8
+ <link rel="stylesheet" href="../shared.css" />
9
+ <link rel="stylesheet" href="auth.css" />
10
+ </head>
11
+ <body>
12
+ <main class="auth-shell">
13
+ <div class="auth-card">
14
+ <div class="auth-header">
15
+ <h1>Syntagma</h1>
16
+ <div class="rule" style="margin: 0.5rem auto 0;"></div>
17
+ <p class="auth-subtitle">Sign in to continue</p>
18
+ </div>
19
+ <form id="login-form" class="auth-form" novalidate>
20
+ <div class="form-group">
21
+ <label for="email">Email</label>
22
+ <input type="email" id="email" name="email" autocomplete="email" required />
23
+ </div>
24
+ <div class="form-group">
25
+ <label for="password">Password</label>
26
+ <div class="password-wrapper">
27
+ <input type="password" id="password" name="password" autocomplete="current-password" required />
28
+ <button type="button" class="toggle-password" aria-label="Toggle password visibility">
29
+ <svg class="eye-open" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
30
+ <svg class="eye-closed" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
31
+ </button>
32
+ </div>
33
+ </div>
34
+ <div id="auth-error" class="auth-error" hidden></div>
35
+ <button type="submit" class="btn-primary btn-block" id="submit-btn">Sign In</button>
36
+ </form>
37
+ </div>
38
+ </main>
39
+ <script type="module" src="auth.js"></script>
40
+ </body>
41
+ </html>
frontend/courses.html ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta http-equiv="refresh" content="0;url=courses/" />
6
+ <title>Courses - PESU</title>
7
+ </head>
8
+ <body>
9
+ <a href="courses/">Courses</a>
10
+ </body>
11
+ </html>
frontend/courses/courses.css ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "../shared.css";
2
+
3
+ * {
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ body {
8
+ margin: 0;
9
+ min-height: 100dvh;
10
+ padding: 2rem;
11
+ background: var(--page-bg);
12
+ color: var(--text);
13
+ }
14
+
15
+ .shell {
16
+ max-width: 1200px;
17
+ margin: 0 auto;
18
+ display: grid;
19
+ gap: 1.25rem;
20
+ }
21
+
22
+ .topbar,
23
+ .panel {
24
+ background: var(--surface);
25
+ border: 1px solid var(--border);
26
+ border-radius: 16px;
27
+ box-shadow: var(--shadow-md);
28
+ }
29
+
30
+ .topbar {
31
+ display: flex;
32
+ align-items: center;
33
+ justify-content: space-between;
34
+ gap: 12px;
35
+ padding: 1.5rem 2rem;
36
+ }
37
+
38
+ h1 {
39
+ margin: 0;
40
+ color: var(--pesu-blue);
41
+ font-size: 1.5rem;
42
+ font-weight: 700;
43
+ }
44
+
45
+ p {
46
+ margin: 4px 0 0;
47
+ color: var(--muted-foreground);
48
+ font-size: 13px;
49
+ }
50
+
51
+ .actions,
52
+ .filters {
53
+ display: flex;
54
+ align-items: center;
55
+ gap: 8px;
56
+ min-width: 0;
57
+ }
58
+
59
+ .filters {
60
+ padding: 12px 16px;
61
+ border-bottom: 1px solid var(--border);
62
+ }
63
+
64
+ .filters input {
65
+ flex: 1;
66
+ }
67
+
68
+ #status {
69
+ margin-left: auto;
70
+ }
71
+
72
+ .table-wrap {
73
+ overflow: auto;
74
+ position: relative;
75
+ min-height: 320px;
76
+ }
77
+
78
+ table {
79
+ width: 100%;
80
+ border-collapse: collapse;
81
+ }
82
+
83
+ th,
84
+ td {
85
+ padding: 10px 12px;
86
+ border-bottom: 1px solid var(--border);
87
+ text-align: left;
88
+ vertical-align: top;
89
+ font-size: 14px;
90
+ }
91
+
92
+ th {
93
+ color: var(--pesu-blue);
94
+ background: #f8fafc;
95
+ font-size: 12px;
96
+ text-transform: uppercase;
97
+ font-weight: 700;
98
+ }
99
+
100
+ .course-row {
101
+ cursor: pointer;
102
+ transition: background 0.15s;
103
+ }
104
+
105
+ .course-row:hover {
106
+ background: #f8fafc;
107
+ }
108
+
109
+ td.center {
110
+ text-align: center;
111
+ vertical-align: middle;
112
+ }
113
+
114
+ .hidden-row {
115
+ opacity: 0.5;
116
+ }
117
+
118
+ .hidden-row td:not(.center) {
119
+ text-decoration: line-through;
120
+ }
121
+
122
+ td.center input[type="checkbox"] {
123
+ appearance: none;
124
+ -webkit-appearance: none;
125
+ width: 36px;
126
+ height: 20px;
127
+ min-height: 0;
128
+ padding: 0;
129
+ border: 1.5px solid var(--border);
130
+ border-radius: 10px;
131
+ background: var(--muted);
132
+ position: relative;
133
+ cursor: pointer;
134
+ transition: background 0.15s, border-color 0.15s;
135
+ vertical-align: middle;
136
+ outline: none;
137
+ }
138
+
139
+ td.center input[type="checkbox"]::before {
140
+ content: "";
141
+ position: absolute;
142
+ top: 2px;
143
+ left: 2px;
144
+ width: 14px;
145
+ height: 14px;
146
+ border-radius: 50%;
147
+ background: var(--muted-foreground);
148
+ transition: transform 0.15s, background 0.15s;
149
+ pointer-events: none;
150
+ }
151
+
152
+ td.center input[type="checkbox"]:checked {
153
+ background: var(--pesu-orange);
154
+ border-color: var(--pesu-orange);
155
+ }
156
+
157
+ td.center input[type="checkbox"]:checked::before {
158
+ transform: translateX(16px);
159
+ background: white;
160
+ }
161
+
162
+ td.center input[type="checkbox"]:focus {
163
+ outline: 2px solid var(--ring);
164
+ outline-offset: 2px;
165
+ }
166
+
167
+ .details {
168
+ display: grid;
169
+ gap: 8px;
170
+ color: #374151;
171
+ line-height: 1.45;
172
+ }
173
+
174
+ .details strong {
175
+ color: var(--pesu-blue);
176
+ }
177
+
178
+ .empty {
179
+ padding: 20px;
180
+ color: var(--muted-foreground);
181
+ text-align: center;
182
+ }
183
+
184
+ .viewer-loading {
185
+ position: absolute;
186
+ top: 50%;
187
+ left: 50%;
188
+ transform: translate(-50%, -50%);
189
+ display: flex;
190
+ flex-direction: column;
191
+ align-items: center;
192
+ justify-content: center;
193
+ gap: 12px;
194
+ padding: 24px 32px;
195
+ background: var(--surface);
196
+ border: 1px solid var(--border);
197
+ border-radius: 12px;
198
+ box-shadow: var(--shadow-md);
199
+ z-index: 5;
200
+ color: var(--muted-foreground);
201
+ font-size: 13px;
202
+ }
203
+
204
+ .viewer-loading[hidden] {
205
+ display: none;
206
+ }
207
+
208
+ .spinner {
209
+ width: 24px;
210
+ height: 24px;
211
+ border: 2.5px solid var(--border);
212
+ border-top-color: var(--pesu-blue);
213
+ border-radius: 50%;
214
+ animation: spin 0.7s linear infinite;
215
+ }
216
+
217
+ @keyframes spin {
218
+ to { transform: rotate(360deg); }
219
+ }
220
+
221
+ @media (max-width: 760px) {
222
+ body {
223
+ padding: 1rem;
224
+ }
225
+
226
+ .topbar,
227
+ .actions,
228
+ .filters {
229
+ align-items: stretch;
230
+ flex-direction: column;
231
+ }
232
+
233
+ #status {
234
+ margin-left: 0;
235
+ }
236
+ }
frontend/courses/courses.js ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const semester = document.getElementById("semester");
2
+ const visibility = document.getElementById("visibility");
3
+ const search = document.getElementById("search");
4
+ const statusText = document.getElementById("status");
5
+ const table = document.getElementById("course-table");
6
+ const tableWrap = table ? table.closest(".table-wrap") : null;
7
+ const loading = document.getElementById("loading");
8
+ let courses = [];
9
+ let openId = "";
10
+
11
+ function setStatus(text, kind = "") {
12
+ statusText.textContent = text;
13
+ statusText.className = `status-line ${kind}`.trim();
14
+ }
15
+
16
+ function cell(text) {
17
+ const td = document.createElement("td");
18
+ td.textContent = text || "";
19
+ return td;
20
+ }
21
+
22
+ function courseMatches(course) {
23
+ const query = search.value.trim().toLowerCase();
24
+ if (semester.value && String(course.semester) !== semester.value) return false;
25
+ if (visibility.value === "visible" && course.visible === false) return false;
26
+ if (visibility.value === "hidden" && course.visible !== false) return false;
27
+ if (!query) return true;
28
+ return `${course.course_code} ${course.course_title}`.toLowerCase().includes(query);
29
+ }
30
+
31
+ function detailsRow(course) {
32
+ const row = document.createElement("tr");
33
+ const td = document.createElement("td");
34
+ td.colSpan = 6;
35
+ const details = document.createElement("div");
36
+ details.className = "details";
37
+ details.append(
38
+ line("Type", course.course_type),
39
+ line("Tools", course.tools_languages || "Not specified"),
40
+ line("Desirable knowledge", course.desirable_knowledge || "None"),
41
+ line("Prelude", course.prelude || "Not specified"),
42
+ );
43
+ td.appendChild(details);
44
+ row.appendChild(td);
45
+ return row;
46
+ }
47
+
48
+ function line(label, value) {
49
+ const div = document.createElement("div");
50
+ const strong = document.createElement("strong");
51
+ strong.textContent = `${label}: `;
52
+ div.append(strong, document.createTextNode(value));
53
+ return div;
54
+ }
55
+
56
+ function render() {
57
+ table.replaceChildren();
58
+ const visible = courses.filter(courseMatches);
59
+ if (!visible.length) {
60
+ const row = document.createElement("tr");
61
+ const td = document.createElement("td");
62
+ td.colSpan = 6;
63
+ td.className = "empty";
64
+ td.textContent = "No courses found.";
65
+ row.appendChild(td);
66
+ table.appendChild(row);
67
+ setStatus("No courses found.");
68
+ return;
69
+ }
70
+
71
+ visible.forEach((course) => {
72
+ const row = document.createElement("tr");
73
+ row.className = course.visible === false ? "course-row hidden-row" : "course-row";
74
+ row.append(cell(course.semester), cell(course.course_code), cell(course.course_title), cell(course.credits));
75
+
76
+ const visibleCell = document.createElement("td");
77
+ visibleCell.className = "center";
78
+ const toggle = document.createElement("input");
79
+ toggle.type = "checkbox";
80
+ toggle.checked = course.visible !== false;
81
+ toggle.title = "Include in rendered documents";
82
+ toggle.addEventListener("click", (event) => event.stopPropagation());
83
+ toggle.addEventListener("change", () => setVisibility(course, toggle));
84
+ visibleCell.appendChild(toggle);
85
+ row.appendChild(visibleCell);
86
+
87
+ const action = document.createElement("td");
88
+ const remove = document.createElement("button");
89
+ remove.type = "button";
90
+ remove.className = "danger";
91
+ remove.textContent = "Delete";
92
+ remove.addEventListener("click", (event) => {
93
+ event.stopPropagation();
94
+ deleteCourse(course).catch(showError);
95
+ });
96
+ action.appendChild(remove);
97
+ row.appendChild(action);
98
+
99
+ row.addEventListener("click", () => {
100
+ openId = openId === String(course.id) ? "" : String(course.id);
101
+ render();
102
+ });
103
+ table.appendChild(row);
104
+ if (openId === String(course.id)) table.appendChild(detailsRow(course));
105
+ });
106
+ setStatus(`${visible.length} course${visible.length === 1 ? "" : "s"}.`, "ready");
107
+ }
108
+
109
+ async function setVisibility(course, toggle) {
110
+ const next = toggle.checked;
111
+ course.visible = next;
112
+ const row = toggle.closest("tr");
113
+ if (row) row.classList.toggle("hidden-row", !next);
114
+ try {
115
+ const response = await fetch(`/api/courses/${course.id}/visible`, {
116
+ method: "PATCH",
117
+ headers: { "Content-Type": "application/json" },
118
+ body: JSON.stringify({ visible: next }),
119
+ });
120
+ if (!response.ok) throw new Error();
121
+ } catch {
122
+ course.visible = !next;
123
+ toggle.checked = !next;
124
+ if (row) row.classList.toggle("hidden-row", !next);
125
+ setStatus("Unable to update visibility.", "error");
126
+ }
127
+ }
128
+
129
+ async function deleteCourse(course) {
130
+ if (!await showConfirm(`Delete ${course.course_code || course.course_title}?`)) return;
131
+ setStatus("Deleting course...");
132
+ const response = await fetch(`/api/courses/${course.id}`, { method: "DELETE" });
133
+ if (!response.ok) throw new Error("Delete failed");
134
+ courses = courses.filter((item) => item.id !== course.id);
135
+ if (openId === String(course.id)) openId = "";
136
+ render();
137
+ }
138
+
139
+ function yearParam(base) {
140
+ const y = localStorage.getItem("curriculumYear") || "";
141
+ if (!y) return base;
142
+ const sep = base.includes("?") ? "&" : "?";
143
+ return `${base}${sep}curriculum_year=${encodeURIComponent(y)}`;
144
+ }
145
+
146
+ async function loadCourses() {
147
+ if (loading) loading.hidden = false;
148
+ if (tableWrap) tableWrap.querySelector("table").hidden = true;
149
+ setStatus("Loading courses...");
150
+ const response = await fetch(yearParam("/api/courses"));
151
+ if (!response.ok) throw new Error("Unable to load courses");
152
+ const body = await response.json();
153
+ courses = body.courses || [];
154
+ if (loading) loading.hidden = true;
155
+ if (tableWrap) tableWrap.querySelector("table").hidden = false;
156
+ render();
157
+ }
158
+
159
+ semester.addEventListener("change", render);
160
+ visibility.addEventListener("change", render);
161
+ search.addEventListener("input", render);
162
+
163
+ loadCourses().catch((error) => {
164
+ setStatus(error instanceof Error ? error.message : "Unable to load courses.", "error");
165
+ });
frontend/courses/index.html ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/png" href="../images/SE1128.png" />
7
+ <title>Courses - PESU</title>
8
+ <link rel="stylesheet" href="../shared.css" />
9
+ <link rel="stylesheet" href="courses.css" />
10
+ <link rel="stylesheet" href="../shared/dialog.css" />
11
+ <script src="/shared/auth-guard.js"></script>
12
+ <script src="../shared/dialog.js"></script>
13
+ <script src="courses.js" defer></script>
14
+ </head>
15
+ <body>
16
+ <main class="shell">
17
+ <header class="topbar">
18
+ <div style="display:flex;align-items:center;gap:12px">
19
+ <a href="/" class="home-btn" title="Home">
20
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
21
+ </a>
22
+ <div>
23
+ <h1>Courses</h1>
24
+ <p>Manage refined courses used by preview, editor, and versions.</p>
25
+ </div>
26
+ </div>
27
+ <div class="actions">
28
+ <a class="button-link" href="../form/">Add Course</a>
29
+ <a class="button-link" href="../live-editor/">Agentic Editor</a>
30
+ </div>
31
+ </header>
32
+
33
+ <section class="panel">
34
+ <div class="filters">
35
+ <select id="semester" aria-label="Semester">
36
+ <option value="">All semesters</option>
37
+ <option value="1">Semester 1</option>
38
+ <option value="2">Semester 2</option>
39
+ <option value="3">Semester 3</option>
40
+ <option value="4">Semester 4</option>
41
+ <option value="5">Semester 5</option>
42
+ <option value="6">Semester 6</option>
43
+ <option value="7">Semester 7</option>
44
+ <option value="8">Semester 8</option>
45
+ </select>
46
+ <select id="visibility" aria-label="Visibility">
47
+ <option value="">All courses</option>
48
+ <option value="visible">Visible only</option>
49
+ <option value="hidden">Hidden only</option>
50
+ </select>
51
+ <input id="search" type="search" placeholder="Search courses..." aria-label="Search courses" />
52
+ <span class="status-line" id="status" role="status"></span>
53
+ </div>
54
+
55
+ <div class="table-wrap">
56
+ <div class="viewer-loading" id="loading">
57
+ <div class="spinner"></div>
58
+ <span>Loading courses...</span>
59
+ </div>
60
+ <table>
61
+ <colgroup>
62
+ <col style="width:80px" />
63
+ <col style="width:130px" />
64
+ <col />
65
+ <col style="width:70px" />
66
+ <col style="width:70px" />
67
+ <col style="width:90px" />
68
+ </colgroup>
69
+ <thead>
70
+ <tr>
71
+ <th>Semester</th>
72
+ <th>Code</th>
73
+ <th>Course</th>
74
+ <th>Credits</th>
75
+ <th>Visible</th>
76
+ <th></th>
77
+ </tr>
78
+ </thead>
79
+ <tbody id="course-table"></tbody>
80
+ </table>
81
+ </div>
82
+ </section>
83
+ </main>
84
+ </body>
85
+ </html>
frontend/docs/docs.css ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "../shared.css";
2
+
3
+ *, *::before, *::after {
4
+ box-sizing: border-box;
5
+ margin: 0;
6
+ padding: 0;
7
+ }
8
+
9
+ body {
10
+ min-height: 100dvh;
11
+ background: var(--page-bg);
12
+ color: var(--text);
13
+ }
14
+
15
+ .docs-toolbar {
16
+ display: flex;
17
+ align-items: center;
18
+ gap: 12px;
19
+ padding: 12px 24px;
20
+ background: var(--surface);
21
+ border: 1px solid var(--border);
22
+ border-radius: 12px;
23
+ margin: 16px 24px 0;
24
+ box-shadow: var(--shadow-sm);
25
+ position: sticky;
26
+ top: 16px;
27
+ z-index: 100;
28
+ }
29
+
30
+ .docs-toolbar-title {
31
+ font-weight: 700;
32
+ font-size: 1rem;
33
+ color: var(--pesu-blue);
34
+ }
35
+
36
+ .docs-toolbar-sep {
37
+ width: 1px;
38
+ height: 20px;
39
+ background: var(--border);
40
+ }
41
+
42
+ .docs-toolbar-subtitle {
43
+ color: var(--muted-foreground);
44
+ font-size: 0.85rem;
45
+ }
46
+
47
+ .docs-layout {
48
+ display: flex;
49
+ max-width: 1200px;
50
+ margin: 0 auto;
51
+ padding: 24px;
52
+ gap: 24px;
53
+ }
54
+
55
+ .docs-sidebar {
56
+ width: 220px;
57
+ flex-shrink: 0;
58
+ position: sticky;
59
+ top: 72px;
60
+ align-self: flex-start;
61
+ max-height: calc(100dvh - 88px);
62
+ overflow-y: auto;
63
+ }
64
+
65
+ .docs-sidebar-nav {
66
+ background: var(--surface);
67
+ border: 1px solid var(--border);
68
+ border-radius: 12px;
69
+ padding: 12px 0;
70
+ }
71
+
72
+ .docs-sidebar-heading {
73
+ padding: 8px 16px 4px;
74
+ font-size: 0.7rem;
75
+ font-weight: 700;
76
+ text-transform: uppercase;
77
+ letter-spacing: 0.05em;
78
+ color: var(--muted-foreground);
79
+ }
80
+
81
+ .docs-sidebar-link {
82
+ display: block;
83
+ padding: 6px 16px;
84
+ font-size: 0.85rem;
85
+ color: var(--text);
86
+ text-decoration: none;
87
+ border-left: 2px solid transparent;
88
+ transition: border-color 0.15s, color 0.15s;
89
+ }
90
+
91
+ .docs-sidebar-link:hover {
92
+ color: var(--pesu-orange);
93
+ }
94
+
95
+ .docs-sidebar-link.active {
96
+ border-left-color: var(--pesu-orange);
97
+ color: var(--pesu-orange);
98
+ font-weight: 600;
99
+ }
100
+
101
+ .docs-sidebar-link.sub {
102
+ padding-left: 28px;
103
+ font-size: 0.8rem;
104
+ color: var(--muted-foreground);
105
+ }
106
+
107
+ .docs-content {
108
+ flex: 1;
109
+ min-width: 0;
110
+ }
111
+
112
+ .docs-card {
113
+ background: var(--surface);
114
+ border: 1px solid var(--border);
115
+ border-radius: 12px;
116
+ padding: 32px 36px;
117
+ box-shadow: var(--shadow-sm);
118
+ }
119
+
120
+ .docs-card h1 {
121
+ font-size: 1.75rem;
122
+ font-weight: 700;
123
+ color: var(--pesu-blue);
124
+ margin-bottom: 8px;
125
+ }
126
+
127
+ .docs-card h2 {
128
+ font-size: 1.35rem;
129
+ font-weight: 700;
130
+ color: var(--pesu-blue);
131
+ margin-top: 40px;
132
+ margin-bottom: 12px;
133
+ padding-bottom: 8px;
134
+ border-bottom: 1px solid var(--border);
135
+ }
136
+
137
+ .docs-card h3 {
138
+ font-size: 1.1rem;
139
+ font-weight: 600;
140
+ color: var(--text);
141
+ margin-top: 28px;
142
+ margin-bottom: 8px;
143
+ }
144
+
145
+ .docs-card h4 {
146
+ font-size: 0.95rem;
147
+ font-weight: 600;
148
+ color: var(--muted-foreground);
149
+ margin-top: 20px;
150
+ margin-bottom: 6px;
151
+ }
152
+
153
+ .docs-card p {
154
+ margin-bottom: 12px;
155
+ line-height: 1.65;
156
+ }
157
+
158
+ .docs-card ul, .docs-card ol {
159
+ margin-bottom: 12px;
160
+ padding-left: 20px;
161
+ }
162
+
163
+ .docs-card li {
164
+ margin-bottom: 4px;
165
+ line-height: 1.6;
166
+ }
167
+
168
+ .docs-card a {
169
+ color: var(--pesu-blue);
170
+ text-decoration: none;
171
+ }
172
+
173
+ .docs-card a:hover {
174
+ text-decoration: underline;
175
+ }
176
+
177
+ .docs-card strong {
178
+ font-weight: 600;
179
+ }
180
+
181
+ .docs-card hr {
182
+ border: none;
183
+ border-top: 1px solid var(--border);
184
+ margin: 32px 0;
185
+ }
186
+
187
+ .docs-card code {
188
+ background: var(--muted);
189
+ padding: 1px 5px;
190
+ border-radius: 4px;
191
+ font-size: 0.85em;
192
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
193
+ color: var(--pesu-blue);
194
+ }
195
+
196
+ .docs-card pre {
197
+ background: #1e1e2e;
198
+ color: #cdd6f4;
199
+ padding: 16px 20px;
200
+ border-radius: 8px;
201
+ overflow-x: auto;
202
+ margin-bottom: 16px;
203
+ font-size: 0.85rem;
204
+ line-height: 1.6;
205
+ }
206
+
207
+ .docs-card pre code {
208
+ background: none;
209
+ padding: 0;
210
+ color: inherit;
211
+ font-size: inherit;
212
+ }
213
+
214
+ .docs-card table {
215
+ width: 100%;
216
+ border-collapse: collapse;
217
+ margin-bottom: 16px;
218
+ font-size: 0.85rem;
219
+ }
220
+
221
+ .docs-card th {
222
+ text-align: left;
223
+ padding: 8px 12px;
224
+ border: 1px solid var(--border);
225
+ background: var(--muted);
226
+ font-weight: 600;
227
+ }
228
+
229
+ .docs-card td {
230
+ padding: 8px 12px;
231
+ border: 1px solid var(--border);
232
+ }
233
+
234
+ .docs-card blockquote {
235
+ border-left: 3px solid var(--pesu-orange);
236
+ padding: 8px 16px;
237
+ margin-bottom: 12px;
238
+ background: var(--muted);
239
+ border-radius: 0 6px 6px 0;
240
+ color: var(--muted-foreground);
241
+ }
242
+
243
+ .docs-card img {
244
+ max-width: 100%;
245
+ border-radius: 8px;
246
+ border: 1px solid var(--border);
247
+ margin: 12px 0;
248
+ }
249
+
250
+ .docs-card .method-badge {
251
+ display: inline-block;
252
+ padding: 1px 6px;
253
+ border-radius: 4px;
254
+ font-size: 0.75rem;
255
+ font-weight: 700;
256
+ font-family: monospace;
257
+ }
258
+
259
+ .docs-card .method-get { background: #dbeafe; color: #1e40af; }
260
+ .docs-card .method-post { background: #dcfce7; color: #166534; }
261
+ .docs-card .method-patch { background: #fef3c7; color: #92400e; }
262
+ .docs-card .method-delete { background: #fee2e2; color: #991b1b; }
263
+
264
+ .docs-section {
265
+ display: none;
266
+ }
267
+
268
+ .docs-section.active {
269
+ display: block;
270
+ }
271
+
272
+ @media (max-width: 768px) {
273
+ .docs-layout {
274
+ flex-direction: column;
275
+ padding: 16px;
276
+ }
277
+
278
+ .docs-sidebar {
279
+ width: 100%;
280
+ position: static;
281
+ max-height: none;
282
+ }
283
+
284
+ .docs-card {
285
+ padding: 20px;
286
+ }
287
+ }
frontend/docs/index.html ADDED
@@ -0,0 +1,897 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <base href="/docs/" />
7
+ <link rel="icon" type="image/png" href="../images/SE1128.png" />
8
+ <title>Documentation - Syntagma</title>
9
+ <link rel="stylesheet" href="../shared.css" />
10
+ <link rel="stylesheet" href="docs.css" />
11
+ </head>
12
+ <body>
13
+ <header class="docs-toolbar">
14
+ <a href="/" class="home-btn" title="Home">
15
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
16
+ </a>
17
+ <span class="docs-toolbar-title">Syntagma</span>
18
+ <span class="docs-toolbar-sep"></span>
19
+ <span class="docs-toolbar-subtitle">Documentation</span>
20
+ </header>
21
+
22
+ <div class="docs-layout">
23
+ <aside class="docs-sidebar">
24
+ <nav class="docs-sidebar-nav">
25
+ <div class="docs-sidebar-heading">Contents</div>
26
+ <a class="docs-sidebar-link active" onclick="showSection('overview', this)" href="#overview">Overview</a>
27
+ <a class="docs-sidebar-link" onclick="showSection('arch', this)" href="#arch">Architecture</a>
28
+ <a class="docs-sidebar-link" onclick="showSection('how', this)" href="#how">How It Works</a>
29
+ <a class="docs-sidebar-link" onclick="showSection('backend', this)" href="#backend">Backend Internals</a>
30
+ <a class="docs-sidebar-link" onclick="showSection('api', this)" href="#api">API Reference</a>
31
+ <a class="docs-sidebar-link" onclick="showSection('db', this)" href="#db">Database Schema</a>
32
+ <a class="docs-sidebar-link" onclick="showSection('env', this)" href="#env">Environment</a>
33
+ <a class="docs-sidebar-link" onclick="showSection('deploy', this)" href="#deploy">Deployment</a>
34
+ </nav>
35
+ </aside>
36
+
37
+ <main class="docs-content">
38
+
39
+ <!-- ==================== OVERVIEW ==================== -->
40
+ <div class="docs-section active" id="overview">
41
+ <div class="docs-card">
42
+ <h1>Syntagma</h1>
43
+ <p style="color: var(--muted-foreground); margin-bottom: 24px;">An Agentic Curriculum Lifecycle Management System for PES University</p>
44
+
45
+ <p>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).</p>
46
+
47
+ <p>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.</p>
48
+
49
+ <p><strong>Live Demo:</strong> <a href="https://syntagma.lonelyguy.tech" target="_blank">syntagma.lonelyguy.tech</a> (preferred) | Backup: <a href="https://pesucurriculum.vercel.app" target="_blank">pesucurriculum.vercel.app</a></p>
50
+
51
+ <img src="images/auth_page.png" alt="Sign In" loading="lazy" />
52
+ <img src="images/home_page.png" alt="Dashboard" loading="lazy" />
53
+
54
+ <h2>Features</h2>
55
+ <ul>
56
+ <li>Course submission with auto-parsed course codes (semester, department, credits extracted automatically)</li>
57
+ <li>AI refinement that preserves all syllabus topics, only cleans and structures content</li>
58
+ <li>Full curriculum PDFs in PES University's official A4 format with letterhead</li>
59
+ <li>Agentic Editor with AI assistant (SSE streaming, 35 tools, draft review, attachments)</li>
60
+ <li>Reviewable drafts (agent never auto-applies changes)</li>
61
+ <li>Agent retry with fallback model (Fibonacci backoff on 502/503, automatic model switch)</li>
62
+ <li>Chat persistence (messages, tool calls, and results saved to database)</li>
63
+ <li>Dynamic specialization management (DB-driven tracks, not hardcoded)</li>
64
+ <li>Version snapshots with restore, revision history, and version-vs-version comparison</li>
65
+ <li>Course visibility toggle and credit-based sorting</li>
66
+ <li>Dual cache layer (Redis + in-memory, lazy invalidation)</li>
67
+ <li>Authentication via Supabase Auth</li>
68
+ </ul>
69
+
70
+ <h2>Tech Stack</h2>
71
+ <table>
72
+ <thead>
73
+ <tr><th>Component</th><th>Technology</th><th>Purpose</th></tr>
74
+ </thead>
75
+ <tbody>
76
+ <tr><td>Backend framework</td><td>FastAPI 0.138 + Uvicorn</td><td>ASGI server, routing, validation</td></tr>
77
+ <tr><td>Language</td><td>Python 3.12</td><td>Runtime</td></tr>
78
+ <tr><td>Frontend</td><td>Vanilla HTML/CSS/JS</td><td>No build step, served as static files</td></tr>
79
+ <tr><td>Database</td><td>Supabase (PostgreSQL)</td><td>Persistent storage for all data</td></tr>
80
+ <tr><td>Cache</td><td>Upstash Redis (optional)</td><td>Serverless Redis; falls back to in-memory dict</td></tr>
81
+ <tr><td>LLM provider</td><td>OpenRouter</td><td>Streaming + tool calling, fallback model retry</td></tr>
82
+ <tr><td>PDF engine</td><td>WeasyPrint</td><td>A4 curriculum PDFs from Jinja2 HTML</td></tr>
83
+ <tr><td>Templating</td><td>Jinja2</td><td>Curriculum layout, course pages, diffs</td></tr>
84
+ <tr><td>HTTP client</td><td>httpx</td><td>Async requests to OpenRouter and external URLs</td></tr>
85
+ <tr><td>Spreadsheet parsing</td><td>openpyxl</td><td>Reading uploaded .xlsx files for text extraction</td></tr>
86
+ <tr><td>Markdown rendering</td><td>marked.js (CDN)</td><td>Agent chat message rendering in browser</td></tr>
87
+ <tr><td>HTML sanitization</td><td>DOMPurify (CDN)</td><td>Sanitizing agent-generated HTML</td></tr>
88
+ <tr><td>PDF text extraction</td><td>poppler-utils (pdftotext)</td><td>Extracting text from uploaded PDF attachments</td></tr>
89
+ <tr><td>Auth</td><td>Supabase Auth (JWT)</td><td>Browser-based authentication</td></tr>
90
+ <tr><td>Error tracking</td><td>Sentry SDK (optional)</td><td>Production error monitoring</td></tr>
91
+ <tr><td>Deployment</td><td>Docker on HF Spaces</td><td>Backend at port 7860</td></tr>
92
+ <tr><td>Frontend deploy</td><td>Vercel</td><td>Static hosting with /api rewrite to backend</td></tr>
93
+ </tbody>
94
+ </table>
95
+
96
+ <h2>Quick Start</h2>
97
+ <pre><code>python3 -m venv .venv
98
+ source .venv/bin/activate
99
+ pip install -r requirements.txt
100
+ cd backend &amp;&amp; fastapi dev app/main.py</code></pre>
101
+ <p>Server at <code>http://127.0.0.1:8000</code>. API under <code>/api</code>. Frontend served from <code>frontend/</code>.</p>
102
+
103
+ <pre><code>source .venv/bin/activate
104
+ pytest # 229 tests
105
+ python -m compileall backend/app # also runs in CI</code></pre>
106
+ </div>
107
+ </div>
108
+
109
+ <!-- ==================== ARCHITECTURE ==================== -->
110
+ <div class="docs-section" id="arch">
111
+ <div class="docs-card">
112
+ <h1>Architecture</h1>
113
+
114
+ <div class="mermaid">
115
+ flowchart TB
116
+ subgraph Frontend["Frontend (Vanilla HTML/CSS/JS)"]
117
+ Auth["/auth/ Sign In"]
118
+ Dashboard["/ Dashboard"]
119
+ Form["/form/ Course Submission"]
120
+ Courses["/courses/ Course Management"]
121
+ Preview["/preview/ PDF Preview"]
122
+ Editor["/live-editor/ Agentic Editor"]
123
+ Versions["/versions/ Version History"]
124
+ end
125
+
126
+ subgraph Backend["FastAPI Backend (/api)"]
127
+ direction TB
128
+ SubAPI["Submissions"]
129
+ CoursesAPI["Courses"]
130
+ PreviewAPI["Preview (8 endpoints)"]
131
+ AgentAPI["Agent (13 endpoints)"]
132
+ ChatAPI["Chat (SSE streaming)"]
133
+ VersionsAPI["Versions (10 endpoints)"]
134
+ end
135
+
136
+ subgraph Services["Services Layer"]
137
+ Refinement["Refinement - LLM content extraction"]
138
+ AgentTools["Agent Tools - 35 tools"]
139
+ Diffing["Diffing - protected field validation"]
140
+ Curriculum["Curriculum - sorting, snapshots"]
141
+ PreviewSvc["Preview - build_course_preview"]
142
+ Rendering["Rendering - Jinja2 + WeasyPrint"]
143
+ Cache["Cache - Redis + in-memory"]
144
+ end
145
+
146
+ subgraph External["External Services"]
147
+ LLM["OpenRouter - Primary + Fallback"]
148
+ Redis[("Upstash Redis")]
149
+ Supa[(Supabase Postgres)]
150
+ WeasyPrint["WeasyPrint - A4 PDF"]
151
+ end
152
+
153
+ Auth -->|"JWT"| Dashboard
154
+ Dashboard --> Form &amp; Courses &amp; Preview &amp; Editor &amp; Versions
155
+
156
+ Form -->|"POST /submissions"| SubAPI
157
+ SubAPI --> Refinement
158
+ Refinement -->|"extract content"| LLM
159
+ Refinement -->|"store"| Supa
160
+
161
+ Courses --> CoursesAPI
162
+ CoursesAPI --> Cache
163
+ Cache -.->|"miss"| Supa
164
+
165
+ Preview --> PreviewAPI
166
+ PreviewAPI --> PreviewSvc
167
+ PreviewSvc --> Rendering
168
+ Rendering --> WeasyPrint
169
+
170
+ Editor -->|"SSE"| ChatAPI
171
+ ChatAPI --> LLM
172
+ LLM -->|"tool calls"| AgentTools
173
+ AgentTools --> Supa
174
+ ChatAPI -->|"save"| Supa
175
+
176
+ VersionsAPI --> Curriculum
177
+ Curriculum -->|"snapshot"| Supa
178
+
179
+ Editor -->|"review/apply"| AgentAPI
180
+ AgentAPI --> Diffing
181
+ Diffing -->|"validate"| Supa
182
+
183
+ Cache -.-> Redis
184
+ </div>
185
+
186
+ <h2>Layer Responsibilities</h2>
187
+ <table>
188
+ <thead>
189
+ <tr><th>Layer</th><th>Location</th><th>Responsibility</th></tr>
190
+ </thead>
191
+ <tbody>
192
+ <tr><td>Static frontend</td><td>frontend/</td><td>Course entry, course management, PDF preview, agentic editor, version history</td></tr>
193
+ <tr><td>API backend</td><td>backend/app/</td><td>FastAPI routes, validation, refinement, previews, drafts, chat, snapshots</td></tr>
194
+ <tr><td>Services</td><td>backend/app/services/</td><td>Business logic, LLM integration, caching, diffing, tool dispatch</td></tr>
195
+ <tr><td>Persistence</td><td>Supabase Postgres</td><td>Raw submissions, refined courses, agent drafts, chat history, attachments, curriculum versions</td></tr>
196
+ <tr><td>Cache</td><td>Redis + in-memory</td><td>Course lists, version lists, PDFs, with lazy invalidation</td></tr>
197
+ <tr><td>Rendering</td><td>Jinja2 + WeasyPrint</td><td>Curriculum summary pages, course detail pages, PDF exports</td></tr>
198
+ <tr><td>Model provider</td><td>OpenRouter</td><td>Submission refinement and live-editor chat with tool calls + fallback model retry</td></tr>
199
+ <tr><td>Auth</td><td>Supabase Auth (JWT)</td><td>Browser-based authentication with token verification</td></tr>
200
+ <tr><td>Monitoring</td><td>Sentry SDK</td><td>Optional production error tracking</td></tr>
201
+ </tbody>
202
+ </table>
203
+
204
+ <h2>Backend Project Structure</h2>
205
+ <table>
206
+ <thead>
207
+ <tr><th>Path</th><th>Responsibility</th></tr>
208
+ </thead>
209
+ <tbody>
210
+ <tr><td>main.py</td><td>FastAPI app, CORS, Supabase/.env loading, mounts /api routers and the static frontend</td></tr>
211
+ <tr><td>api.py</td><td>Aggregates all route routers under a single /api router</td></tr>
212
+ <tr><td>supabase.py</td><td>Supabase client + first_row() helper</td></tr>
213
+ <tr><td>cache.py</td><td>Dual cache (Redis + in-memory), lazy invalidation, prefix-based deletion</td></tr>
214
+ <tr><td>models/submission.py</td><td>CourseSubmission (request contract) and parse_course_code()</td></tr>
215
+ <tr><td>services/deterministic.py</td><td>compute_hours, compute_program, compute_course_type from credit category</td></tr>
216
+ <tr><td>services/refinement.py</td><td>The LLM refinement pipeline (refine)</td></tr>
217
+ <tr><td>services/curriculum.py</td><td>Sorting, ordering, version snapshots, draft records, field updates</td></tr>
218
+ <tr><td>services/diffing.py</td><td>JSON diff, protected-field validation, patch apply/merge</td></tr>
219
+ <tr><td>services/preview.py</td><td>build_course_preview, build_specialization_context</td></tr>
220
+ <tr><td>services/rendering.py</td><td>Jinja2 environment, filters, SEMESTER_NAMES global</td></tr>
221
+ <tr><td>services/agent_tools.py</td><td>Agent tool definitions + TOOLS registry (35 tools) + call_tool</td></tr>
222
+ <tr><td>services/openrouter.py</td><td>call() (one-shot), stream_chat() (tool-calling loop), fallback model retry</td></tr>
223
+ <tr><td>services/schema.py</td><td>REQUIRED_TABLES and schema_status()</td></tr>
224
+ <tr><td>services/errors.py</td><td>database_http_exception()</td></tr>
225
+ <tr><td>services/attachments.py</td><td>Text extraction from PDF/DOCX/XLSX/TXT</td></tr>
226
+ <tr><td>services/books.py</td><td>parse_books() textbook parser</td></tr>
227
+ <tr><td>services/elective_categorization.py</td><td>AI-powered elective-to-track matching</td></tr>
228
+ <tr><td>routes/health.py</td><td>GET /api/health/schema</td></tr>
229
+ <tr><td>routes/submissions.py</td><td>POST /api/submissions, POST /api/submissions/{id}/refine</td></tr>
230
+ <tr><td>routes/preview.py</td><td>Course/HTML/PDF preview endpoints (8 endpoints)</td></tr>
231
+ <tr><td>routes/refined.py</td><td>GET/PATCH a single refined course</td></tr>
232
+ <tr><td>routes/courses.py</td><td>List + toggle visibility + soft-delete refined courses</td></tr>
233
+ <tr><td>routes/agent.py</td><td>Draft + document-draft + tool endpoints (13 endpoints)</td></tr>
234
+ <tr><td>routes/chat.py</td><td>Chat sessions, SSE streaming, attachments, system prompt</td></tr>
235
+ <tr><td>routes/versions.py</td><td>Version CRUD, restore, previews, diffs (10 endpoints)</td></tr>
236
+ <tr><td>routes/auth.py</td><td>Token verification, logout</td></tr>
237
+ <tr><td>templates/jinja_sample.html</td><td>Single course + full document renderer + title page</td></tr>
238
+ <tr><td>templates/jinja_program.html</td><td>Program-level title page (large seal) + PEOs/POs</td></tr>
239
+ <tr><td>templates/jinja_1_to_8.html</td><td>Semester summary tables (1-4, 7-8)</td></tr>
240
+ <tr><td>templates/jinja_sem_5_6.html</td><td>Semester 5/6 electives + specialization tables</td></tr>
241
+ <tr><td>templates/jinja_diff.html</td><td>Structured diff renderer for drafts</td></tr>
242
+ </tbody>
243
+ </table>
244
+
245
+ <h2>Frontend Project Structure</h2>
246
+ <table>
247
+ <thead>
248
+ <tr><th>Path</th><th>Purpose</th></tr>
249
+ </thead>
250
+ <tbody>
251
+ <tr><td>index.html</td><td>Dashboard hub linking to all surfaces</td></tr>
252
+ <tr><td>form/</td><td>Raw course submission form with course code parsing</td></tr>
253
+ <tr><td>courses/</td><td>Refined course list with filtering, visibility toggle, soft delete</td></tr>
254
+ <tr><td>preview/</td><td>Overall or per-semester PDF preview/download</td></tr>
255
+ <tr><td>live-editor/</td><td>Agentic Editor: course preview, chat assistant, JSON editor, draft review, version restore</td></tr>
256
+ <tr><td>versions/</td><td>Snapshot list, preview, comparison, editor handoff</td></tr>
257
+ <tr><td>auth/</td><td>Sign in (Supabase Auth)</td></tr>
258
+ <tr><td>shared/</td><td>auth-guard.js, supabase-client.js, shared.css, dialog.js</td></tr>
259
+ </tbody>
260
+ </table>
261
+ </div>
262
+ </div>
263
+
264
+ <!-- ==================== HOW IT WORKS ==================== -->
265
+ <div class="docs-section" id="how">
266
+ <div class="docs-card">
267
+ <h1>How It Works</h1>
268
+ <p>Syntagma has six surfaces. Each serves a purpose in the curriculum lifecycle.</p>
269
+
270
+ <h2>Authentication</h2>
271
+ <img src="images/auth_page.png" alt="Sign In" loading="lazy" />
272
+ <p>Supabase Auth with email/password. JWT verified on every API call. The <code>shared/auth-guard.js</code> script redirects unauthenticated users to <code>/auth/</code>.</p>
273
+
274
+ <h2>Dashboard</h2>
275
+ <img src="images/home_page.png" alt="Dashboard" loading="lazy" />
276
+ <p>Quick links to all surfaces: Submission Form, Courses, Preview, Live Editor, Versions, and Documentation.</p>
277
+
278
+ <h2>Submission Form</h2>
279
+ <img src="images/submit_course_page.png" alt="Course Submission Form" loading="lazy" />
280
+ <p>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 <code>create_refined_course</code>.</p>
281
+
282
+ <h2>Courses</h2>
283
+ <img src="images/courses_default.png" alt="Courses Page" loading="lazy" />
284
+ <img src="images/courses_filtered_visible_toggle.png" alt="Filtered Courses" loading="lazy" />
285
+ <img src="images/courses_delete_modal.png" alt="Delete Modal" loading="lazy" />
286
+ <p>Lists all refined courses. Filter by semester or department. Toggle visibility (hidden courses are excluded from previews and PDFs). Delete with confirmation modal.</p>
287
+
288
+ <h2>Preview</h2>
289
+ <img src="images/preview_single_course.png" alt="Single Course Preview" loading="lazy" />
290
+ <img src="images/preview_full_doc.png" alt="Full Document Preview" loading="lazy" />
291
+ <img src="images/preview_full_sem.png" alt="Semester Preview" loading="lazy" />
292
+ <p>Three view modes: single course, full document, or per-semester. PDFs generated via WeasyPrint in PES University's A4 format with letterhead.</p>
293
+
294
+ <h2>Agentic Editor</h2>
295
+ <img src="images/editor_single_course.png" alt="Agentic Editor" loading="lazy" />
296
+ <img src="images/editor_sample_1.png" alt="Editor with Chat" loading="lazy" />
297
+ <img src="images/editor_sample_annotated.png" alt="Editor Annotated" loading="lazy" />
298
+ <p>Two-column workspace. Left: preview iframe (single course or full document). Right: tabbed panel.</p>
299
+ <table>
300
+ <thead>
301
+ <tr><th>Tab</th><th>What it does</th></tr>
302
+ </thead>
303
+ <tbody>
304
+ <tr><td>Agent</td><td>AI assistant via SSE streaming. 35 tools. Creates reviewable drafts, never auto-applies. Thread management with rename/delete. File attachments supported.</td></tr>
305
+ <tr><td>Fields</td><td>Raw JSON editor. "Propose Changes" creates a reviewable draft. "Save" directly updates (non-protected fields only).</td></tr>
306
+ <tr><td>Review</td><td>Loads pending drafts (new courses, single-course, multi-course). Shows diff. Apply or reject.</td></tr>
307
+ </tbody>
308
+ </table>
309
+
310
+ <h2>Versions</h2>
311
+ <img src="images/versions_default.png" alt="Versions Page" loading="lazy" />
312
+ <img src="images/versions_current_document.png" alt="Current Document" loading="lazy" />
313
+ <img src="images/versions_annotated_comparision.png" alt="Version Comparison" loading="lazy" />
314
+ <img src="images/versions_rename_category.png" alt="Rename Category" loading="lazy" />
315
+ <p>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.</p>
316
+ </div>
317
+ </div>
318
+
319
+ <!-- ==================== BACKEND INTERNALS ==================== -->
320
+ <div class="docs-section" id="backend">
321
+ <div class="docs-card">
322
+ <h1>Backend Internals</h1>
323
+
324
+ <h2>Submission Pipeline</h2>
325
+ <table>
326
+ <thead>
327
+ <tr><th>Step</th><th>What happens</th></tr>
328
+ </thead>
329
+ <tbody>
330
+ <tr><td>1</td><td><code>POST /api/submissions</code> validates payload against <code>CourseSubmission</code></td></tr>
331
+ <tr><td>2</td><td><code>parse_course_code()</code> derives <code>offering_department</code>, <code>target_department</code>, <code>semester</code>, <code>credit_category</code></td></tr>
332
+ <tr><td>3</td><td>Inserts into <code>submissions</code>, queues background refinement</td></tr>
333
+ <tr><td>4</td><td><code>refine(submission_id)</code> builds deterministic fields, calls OpenRouter, upserts <code>refined_submissions</code></td></tr>
334
+ <tr><td>5</td><td>Course visible in <code>/api/courses</code>, previews, and editor</td></tr>
335
+ </tbody>
336
+ </table>
337
+ <p>Alternative path: course details sent as chat attachments can be used by the agent to create a refined course directly via <code>create_refined_course</code>, bypassing the submission/refinement pipeline.</p>
338
+
339
+ <h2>Course Code Encoding</h2>
340
+ <p><code>UE</code> + <code>YY</code> (year) + <code>DEPT</code> (2-letter) + <code>NUMBER</code> (3-digit) + <code>SUFFIX</code>.</p>
341
+ <table>
342
+ <thead>
343
+ <tr><th>Digit</th><th>Meaning</th></tr>
344
+ </thead>
345
+ <tbody>
346
+ <tr><td>Tens</td><td>Credits (0/2/4/5)</td></tr>
347
+ <tr><td>Hundreds</td><td>Semester group</td></tr>
348
+ <tr><td>Suffix A/B</td><td>Odd/even semester parity</td></tr>
349
+ </tbody>
350
+ </table>
351
+ <p><code>parse_course_code()</code> in <code>models/submission.py</code> is the single source of truth.</p>
352
+
353
+ <h2>Deterministic Fields</h2>
354
+ <p>Computed from <code>credit_category</code> by <code>services/deterministic.py</code>. Protected from casual edits.</p>
355
+ <table>
356
+ <thead>
357
+ <tr><th>Category</th><th>L</th><th>T</th><th>P</th><th>S</th><th>C</th><th>Course type</th></tr>
358
+ </thead>
359
+ <tbody>
360
+ <tr><td>5</td><td>4</td><td>0</td><td>2</td><td>5</td><td>5</td><td>Core Course-Lab Integrated</td></tr>
361
+ <tr><td>4</td><td>4</td><td>0</td><td>0</td><td>4</td><td>4</td><td>Core Course</td></tr>
362
+ <tr><td>2</td><td>2</td><td>0</td><td>0</td><td>2</td><td>2</td><td>Core Theory</td></tr>
363
+ <tr><td>0</td><td>0</td><td>0</td><td>0</td><td>0</td><td>0</td><td>Foundation Course</td></tr>
364
+ </tbody>
365
+ </table>
366
+ <p><strong>Protected fields</strong> (<code>diffing.py</code>): <code>program</code>, <code>lecture_hours</code>, <code>tutorial_hours</code>, <code>practical_hours</code>, <code>self_study</code>, <code>credits</code>, <code>course_type</code>. Drafts that change them are blocked.</p>
367
+
368
+ <h2>Preview &amp; PDF Generation</h2>
369
+ <p><code>build_course_preview(row)</code> converts a <code>refined_submissions</code> row into a flat dict for templates. <code>services/rendering.py</code> builds the Jinja2 environment.</p>
370
+ <table>
371
+ <thead>
372
+ <tr><th>Template</th><th>Renders</th></tr>
373
+ </thead>
374
+ <tbody>
375
+ <tr><td><code>jinja_program.html</code></td><td>Title page (PES seal, program name, year) and PEOs/POs</td></tr>
376
+ <tr><td><code>jinja_sample.html</code></td><td>Individual course pages and summary tables</td></tr>
377
+ <tr><td><code>jinja_1_to_8.html</code></td><td>Semester summary tables (1-4, 7-8)</td></tr>
378
+ <tr><td><code>jinja_sem_5_6.html</code></td><td>Electives and specialization tables (5/6)</td></tr>
379
+ <tr><td><code>jinja_diff.html</code></td><td>Structured diffs for agent drafts</td></tr>
380
+ </tbody>
381
+ </table>
382
+ <p>WeasyPrint converts rendered HTML to PDF. PDFs cached 60s. Course lists and version lists cached 180s.</p>
383
+
384
+ <h2>Specialization System</h2>
385
+ <p>Fully data-driven. No hardcoded specialization brackets.</p>
386
+ <table>
387
+ <thead>
388
+ <tr><th>Table</th><th>Schema</th></tr>
389
+ </thead>
390
+ <tbody>
391
+ <tr><td><code>specialization_definitions</code></td><td>One row per track: id, semester, letter, name, key, academic_year</td></tr>
392
+ <tr><td><code>course_specialization_assignments</code></td><td>One row per (course, track): id, refined_id, specialization_id</td></tr>
393
+ <tr><td><code>refined_submissions.is_elective</code></td><td>Boolean flag</td></tr>
394
+ </tbody>
395
+ </table>
396
+ <p><code>build_specialization_context()</code> loads tracks and assignments. Template excludes electives from regular tables, splits by code suffix (AA/BA -> group A), renders specialization assignment tables.</p>
397
+ <p>Agent tools: <code>define_specialization</code>, <code>list_specializations</code>, <code>assign_elective_to_tracks</code>, <code>remove_elective_from_tracks</code>, <code>categorize_elective</code>.</p>
398
+
399
+ <h2>Agent System</h2>
400
+ <p>Tool-calling LLM loop via <code>openrouter.stream_chat</code>. System prompt in <code>chat.py:chat_system_prompt</code>.</p>
401
+ <table>
402
+ <thead>
403
+ <tr><th>Component</th><th>Purpose</th></tr>
404
+ </thead>
405
+ <tbody>
406
+ <tr><td>Curriculum structure context</td><td>Semester ranges, course code encoding, credit categories</td></tr>
407
+ <tr><td>Draft vs. direct creation guidelines</td><td>When to draft vs. create directly</td></tr>
408
+ <tr><td>Desirable knowledge guidance</td><td>How to handle prerequisite knowledge fields</td></tr>
409
+ <tr><td>Agent acknowledgement instruction</td><td>Brief text before tool calls</td></tr>
410
+ <tr><td><code>update_agent_draft</code> tool mention</td><td>Modifying existing drafts</td></tr>
411
+ </tbody>
412
+ </table>
413
+
414
+ <h3>Agent Tools (35 total)</h3>
415
+ <table>
416
+ <thead>
417
+ <tr><th>Tool</th><th>Category</th><th>Description</th></tr>
418
+ </thead>
419
+ <tbody>
420
+ <tr><td><code>get_current_course_json</code></td><td>Read</td><td>Full template-ready course JSON</td></tr>
421
+ <tr><td><code>get_course_codes</code></td><td>Read</td><td>Lightweight IDs (refined_id, code, title, semester)</td></tr>
422
+ <tr><td><code>get_course_syllabus</code></td><td>Read</td><td>Units, objectives, course_outcomes</td></tr>
423
+ <tr><td><code>get_course_textbooks</code></td><td>Read</td><td>text_books, reference_books</td></tr>
424
+ <tr><td><code>get_course_deterministic</code></td><td>Read</td><td>Protected fields (read-only context)</td></tr>
425
+ <tr><td><code>get_course_lab</code></td><td>Read</td><td>Lab experiments, tools/languages</td></tr>
426
+ <tr><td><code>get_course_fields</code></td><td>Read</td><td>Arbitrary field subset for one course</td></tr>
427
+ <tr><td><code>batch_read_courses</code></td><td>Read</td><td>Read specific fields from multiple courses in one call</td></tr>
428
+ <tr><td><code>get_curriculum_json</code></td><td>Read</td><td>Full curriculum, optionally by semester</td></tr>
429
+ <tr><td><code>list_courses</code></td><td>Read</td><td>Course IDs/titles, optionally by semester</td></tr>
430
+ <tr><td><code>get_curriculum_stats</code></td><td>Read</td><td>Aggregate statistics (total courses, credits per semester, course type distribution)</td></tr>
431
+ <tr><td><code>diff_course_json</code></td><td>Read</td><td>Compare two course JSONs</td></tr>
432
+ <tr><td><code>get_course_draft</code></td><td>Read</td><td>Read a staged course draft</td></tr>
433
+ <tr><td><code>get_document_draft</code></td><td>Read</td><td>Read a staged document draft</td></tr>
434
+ <tr><td><code>get_version</code></td><td>Read</td><td>Load a curriculum version snapshot with its course list</td></tr>
435
+ <tr><td><code>diff_versions</code></td><td>Read</td><td>Compare two version snapshots (added/removed/changed courses)</td></tr>
436
+ <tr><td><code>get_course_assignments</code></td><td>Read</td><td>Which specialization tracks a course belongs to</td></tr>
437
+ <tr><td><code>list_specializations</code></td><td>Read</td><td>List track definitions</td></tr>
438
+ <tr><td><code>get_attachment_text</code></td><td>Read</td><td>Read uploaded chat attachments</td></tr>
439
+ <tr><td><code>fetch_url</code></td><td>Read</td><td>Fetch external URL content</td></tr>
440
+ <tr><td><code>web_search</code></td><td>Read</td><td>Search the web for external context</td></tr>
441
+ <tr><td><code>create_course_draft</code></td><td>Write</td><td>Propose changes for a single course (reviewable draft)</td></tr>
442
+ <tr><td><code>update_agent_draft</code></td><td>Write</td><td>Merge fields into an existing draft (avoids duplicates)</td></tr>
443
+ <tr><td><code>create_document_draft</code></td><td>Write</td><td>Propose changes for multiple courses (reviewable draft)</td></tr>
444
+ <tr><td><code>assign_elective_to_tracks</code></td><td>Write</td><td>Categorize an elective into specialization tracks</td></tr>
445
+ <tr><td><code>remove_elective_from_tracks</code></td><td>Write</td><td>Remove a course from specialization tracks</td></tr>
446
+ <tr><td><code>define_specialization</code></td><td>Write</td><td>Create a new specialization track</td></tr>
447
+ <tr><td><code>categorize_elective</code></td><td>Write</td><td>AI-powered elective categorization into existing tracks</td></tr>
448
+ <tr><td><code>update_deterministic_fields</code></td><td>Write</td><td>Change protected fields; produces a blocked draft requiring explicit user approval</td></tr>
449
+ <tr><td><code>create_refined_course</code></td><td>Write</td><td>Create new courses directly (for brand-new courses only)</td></tr>
450
+ <tr><td><code>create_spreadsheet</code></td><td>Generate</td><td>Generate CSV or Excel (.xlsx) files from structured row data</td></tr>
451
+ <tr><td><code>create_report</code></td><td>Generate</td><td>Generate markdown or PDF reports</td></tr>
452
+ <tr><td><code>create_curriculum_version</code></td><td>Generate</td><td>Snapshot the current curriculum state</td></tr>
453
+ <tr><td><code>signal_done</code></td><td>Control</td><td>Signal task completion with a summary</td></tr>
454
+ </tbody>
455
+ </table>
456
+
457
+ <h3>SSE Event Types</h3>
458
+ <p>Streamed from <code>POST /chat/sessions/{id}/messages</code>:</p>
459
+ <table>
460
+ <thead>
461
+ <tr><th>Event</th><th>Description</th></tr>
462
+ </thead>
463
+ <tbody>
464
+ <tr><td><code>status</code></td><td>Status updates (model name, tool execution)</td></tr>
465
+ <tr><td><code>token</code></td><td>Streamed text tokens from the LLM</td></tr>
466
+ <tr><td><code>tool_call</code></td><td>Agent invoking a tool (name + arguments)</td></tr>
467
+ <tr><td><code>tool_result</code></td><td>Tool execution result</td></tr>
468
+ <tr><td><code>draft</code></td><td>A course draft was created</td></tr>
469
+ <tr><td><code>document_draft</code></td><td>A document draft was created</td></tr>
470
+ <tr><td><code>refined_course</code></td><td>A new course was created directly</td></tr>
471
+ <tr><td><code>context_usage</code></td><td>Token usage statistics</td></tr>
472
+ <tr><td><code>error</code></td><td>Error occurred</td></tr>
473
+ <tr><td><code>done</code></td><td>Stream complete</td></tr>
474
+ </tbody>
475
+ </table>
476
+
477
+ <h3>Chat Persistence</h3>
478
+ <p>All messages (user, assistant, tool_call, tool_result) saved to <code>chat_messages</code> with role and metadata. Tool calls include function name and arguments; tool results include output. Conversation history persists across page refreshes.</p>
479
+
480
+ <h3>Agent Output Validation</h3>
481
+ <table>
482
+ <thead>
483
+ <tr><th>Validation</th><th>Behavior</th></tr>
484
+ </thead>
485
+ <tbody>
486
+ <tr><td><code>desirable_knowledge</code></td><td>Placeholder values (e.g., "None specified") stripped by <code>_create_refined_course</code></td></tr>
487
+ <tr><td>Draft-status courses</td><td><code>_create_course_draft</code> rejects drafts, directs agent to <code>create_refined_course</code></td></tr>
488
+ </tbody>
489
+ </table>
490
+
491
+ <h2>Versioning</h2>
492
+ <p><code>create_version_snapshot</code> copies every active <code>refined_submissions</code> into <code>finalized_submissions</code> pinned to a <code>curriculum_versions</code> row. <code>restore</code> overwrites current refined data, archives absent courses, writes revision history.</p>
493
+
494
+ <h2>Caching</h2>
495
+ <p>Dual cache layer (<code>services/cache.py</code>):</p>
496
+ <table>
497
+ <thead>
498
+ <tr><th>Backend</th><th>Config</th><th>Behavior</th></tr>
499
+ </thead>
500
+ <tbody>
501
+ <tr><td>Redis (Upstash, optional)</td><td><code>REDIS_URL</code></td><td>Survives server restarts</td></tr>
502
+ <tr><td>In-memory dict</td><td>Fallback</td><td>Survives within a process</td></tr>
503
+ </tbody>
504
+ </table>
505
+ <table>
506
+ <thead>
507
+ <tr><th>Prefix</th><th>TTL</th><th>Purpose</th></tr>
508
+ </thead>
509
+ <tbody>
510
+ <tr><td><code>courses_list</code></td><td>180s</td><td>Course list for /api/courses</td></tr>
511
+ <tr><td><code>course_pdf:</code></td><td>60s</td><td>Individual course PDF bytes</td></tr>
512
+ <tr><td><code>version_list</code></td><td>180s</td><td>Version list for /api/versions</td></tr>
513
+ </tbody>
514
+ </table>
515
+ <p>Invalidation: lazy (delete-on-write). Prewarm on startup. Debug: set <code>is_cache_disabled = True</code> in <code>cache.py</code>.</p>
516
+
517
+ <h2>Error Handling &amp; Retry</h2>
518
+ <p>OpenRouter client (<code>services/openrouter.py</code>):</p>
519
+ <table>
520
+ <thead>
521
+ <tr><th>Parameter</th><th>Value</th></tr>
522
+ </thead>
523
+ <tbody>
524
+ <tr><td>Retryable statuses</td><td>502, 503 (server errors)</td></tr>
525
+ <tr><td>Retryable exceptions</td><td><code>httpx.TimeoutException</code>, <code>httpx.TransportError</code></td></tr>
526
+ <tr><td>NOT retried</td><td>429 (rate limit) - shows error and stops immediately</td></tr>
527
+ <tr><td>Backoff</td><td>Fibonacci sequence [1, 1, 2, 3, 5, 8, 13] seconds with +/-25% jitter</td></tr>
528
+ <tr><td>Max retries</td><td>3 per request</td></tr>
529
+ <tr><td>Retry-After header</td><td>Respected when present</td></tr>
530
+ </tbody>
531
+ </table>
532
+ <p><strong>Fallback model:</strong></p>
533
+ <table>
534
+ <thead>
535
+ <tr><th>Parameter</th><th>Behavior</th></tr>
536
+ </thead>
537
+ <tbody>
538
+ <tr><td><code>OPENROUTER_FALLBACK_MODEL</code></td><td>Set env var to enable (e.g. <code>google/gemma-3-27b-it:free</code>)</td></tr>
539
+ <tr><td>Trigger</td><td>After 3 failed retries on the primary model</td></tr>
540
+ <tr><td>Scope</td><td>Commits to fallback for the rest of the chat thread</td></tr>
541
+ <tr><td>Notification</td><td>Announced via SSE status events so the UI can inform the user</td></tr>
542
+ <tr><td>Persistence</td><td>Once committed, <code>active_model</code> persists as the fallback for all subsequent calls</td></tr>
543
+ </tbody>
544
+ </table>
545
+ <p><strong>429 (rate limit):</strong> No retry, no fallback. Error shown in chat and status bar. User must wait and retry manually.</p>
546
+ <p><strong>Chat streaming errors:</strong> Surfaced as SSE error events. UI creates error bubble even when no tokens received. Done handler reloads messages from database.</p>
547
+ </div>
548
+ </div>
549
+
550
+ <!-- ==================== API REFERENCE ==================== -->
551
+ <div class="docs-section" id="api">
552
+ <div class="docs-card">
553
+ <h1>API Reference</h1>
554
+ <p>All paths are prefixed with <code>/api</code> at runtime.</p>
555
+
556
+ <h2>Health</h2>
557
+ <table>
558
+ <thead>
559
+ <tr><th>Endpoint</th><th>Method</th><th>Purpose</th></tr>
560
+ </thead>
561
+ <tbody>
562
+ <tr><td><code>/api/health/schema</code></td><td><span class="method-badge method-get">GET</span></td><td>Verify required Supabase tables exist. Returns 503 if tables are missing.</td></tr>
563
+ </tbody>
564
+ </table>
565
+
566
+ <h2>Submissions</h2>
567
+ <table>
568
+ <thead>
569
+ <tr><th>Endpoint</th><th>Method</th><th>Purpose</th></tr>
570
+ </thead>
571
+ <tbody>
572
+ <tr><td><code>/api/submissions</code></td><td><span class="method-badge method-post">POST</span></td><td>Store a raw submission, queue background refinement. Body: CourseSubmission.</td></tr>
573
+ <tr><td><code>/api/submissions/{id}/refine</code></td><td><span class="method-badge method-post">POST</span></td><td>Manually trigger refinement for an existing submission.</td></tr>
574
+ </tbody>
575
+ </table>
576
+
577
+ <h2>Refined Courses</h2>
578
+ <table>
579
+ <thead>
580
+ <tr><th>Endpoint</th><th>Method</th><th>Purpose</th></tr>
581
+ </thead>
582
+ <tbody>
583
+ <tr><td><code>/api/refined/{refined_id}</code></td><td><span class="method-badge method-get">GET</span></td><td>Read template-ready course fields.</td></tr>
584
+ <tr><td><code>/api/refined/{refined_id}</code></td><td><span class="method-badge method-patch">PATCH</span></td><td>Update editable refined fields. Promotes draft-status courses to refined. Body: { fields: dict }.</td></tr>
585
+ </tbody>
586
+ </table>
587
+
588
+ <h2>Course Management</h2>
589
+ <table>
590
+ <thead>
591
+ <tr><th>Endpoint</th><th>Method</th><th>Purpose</th></tr>
592
+ </thead>
593
+ <tbody>
594
+ <tr><td><code>/api/courses</code></td><td><span class="method-badge method-get">GET</span></td><td>List active refined courses (cached 180s).</td></tr>
595
+ <tr><td><code>/api/courses/{refined_id}/visible</code></td><td><span class="method-badge method-patch">PATCH</span></td><td>Toggle course visibility. Body: { visible: bool }.</td></tr>
596
+ <tr><td><code>/api/courses/{refined_id}</code></td><td><span class="method-badge method-delete">DELETE</span></td><td>Soft-delete (archive) a course.</td></tr>
597
+ </tbody>
598
+ </table>
599
+
600
+ <h2>Preview &amp; PDF</h2>
601
+ <table>
602
+ <thead>
603
+ <tr><th>Endpoint</th><th>Method</th><th>Purpose</th></tr>
604
+ </thead>
605
+ <tbody>
606
+ <tr><td><code>/api/preview/course/{refined_id}</code></td><td><span class="method-badge method-get">GET</span></td><td>Render one course as HTML.</td></tr>
607
+ <tr><td><code>/api/preview/course/{refined_id}/pdf</code></td><td><span class="method-badge method-get">GET</span></td><td>Generate PDF for one course. <code>?download=true</code> for attachment.</td></tr>
608
+ <tr><td><code>/api/preview/html</code></td><td><span class="method-badge method-get">GET</span></td><td>Render full curriculum as HTML.</td></tr>
609
+ <tr><td><code>/api/preview/pdf</code></td><td><span class="method-badge method-get">GET</span></td><td>Generate full curriculum PDF. <code>?download=true</code> for attachment.</td></tr>
610
+ <tr><td><code>/api/preview/semester/{sem}/pdf</code></td><td><span class="method-badge method-get">GET</span></td><td>Generate one semester as PDF.</td></tr>
611
+ <tr><td><code>/api/preview/semester/{sem}/courses</code></td><td><span class="method-badge method-get">GET</span></td><td>List course IDs for a semester.</td></tr>
612
+ <tr><td><code>/api/preview/pending-courses</code></td><td><span class="method-badge method-get">GET</span></td><td>List draft-status (pending) courses.</td></tr>
613
+ <tr><td><code>/api/preview/courses</code></td><td><span class="method-badge method-get">GET</span></td><td>List all visible refined course IDs.</td></tr>
614
+ </tbody>
615
+ </table>
616
+
617
+ <h2>Agent Drafts</h2>
618
+ <table>
619
+ <thead>
620
+ <tr><th>Endpoint</th><th>Method</th><th>Purpose</th></tr>
621
+ </thead>
622
+ <tbody>
623
+ <tr><td><code>/api/agent/drafts</code></td><td><span class="method-badge method-get">GET</span></td><td>List 100 most recent agent drafts.</td></tr>
624
+ <tr><td><code>/api/agent/drafts</code></td><td><span class="method-badge method-post">POST</span></td><td>Create a single-course draft. Body: { refined_id, fields, reason? }.</td></tr>
625
+ <tr><td><code>/api/agent/drafts/{draft_id}</code></td><td><span class="method-badge method-get">GET</span></td><td>Fetch a draft with base/proposed JSON and diff summary.</td></tr>
626
+ <tr><td><code>/api/agent/drafts/{draft_id}/apply</code></td><td><span class="method-badge method-post">POST</span></td><td>Apply a proposed draft. Rejects blocked drafts or protected-field changes.</td></tr>
627
+ <tr><td><code>/api/agent/drafts/{draft_id}/preview</code></td><td><span class="method-badge method-get">GET</span></td><td>Render draft as HTML. <code>?diff=true</code> for diff view.</td></tr>
628
+ </tbody>
629
+ </table>
630
+
631
+ <h2>Document Drafts (multi-course)</h2>
632
+ <table>
633
+ <thead>
634
+ <tr><th>Endpoint</th><th>Method</th><th>Purpose</th></tr>
635
+ </thead>
636
+ <tbody>
637
+ <tr><td><code>/api/agent/document-drafts</code></td><td><span class="method-badge method-get">GET</span></td><td>List 100 most recent document drafts.</td></tr>
638
+ <tr><td><code>/api/agent/document-drafts</code></td><td><span class="method-badge method-post">POST</span></td><td>Create a multi-course draft. Body: { courses: [{ refined_id, fields }], reason? }.</td></tr>
639
+ <tr><td><code>/api/agent/document-drafts/{id}</code></td><td><span class="method-badge method-get">GET</span></td><td>Fetch document draft with all linked course drafts.</td></tr>
640
+ <tr><td><code>/api/agent/document-drafts/{id}/apply</code></td><td><span class="method-badge method-post">POST</span></td><td>Apply all proposed sub-drafts.</td></tr>
641
+ <tr><td><code>/api/agent/document-drafts/{id}/preview</code></td><td><span class="method-badge method-get">GET</span></td><td>Render all proposed courses. <code>?diff=true</code> for diff view.</td></tr>
642
+ </tbody>
643
+ </table>
644
+
645
+ <h2>Agent Tools &amp; Diff</h2>
646
+ <table>
647
+ <thead>
648
+ <tr><th>Endpoint</th><th>Method</th><th>Purpose</th></tr>
649
+ </thead>
650
+ <tbody>
651
+ <tr><td><code>/api/agent/tools</code></td><td><span class="method-badge method-get">GET</span></td><td>List all agent tool schemas (OpenAI function-calling format).</td></tr>
652
+ <tr><td><code>/api/agent/tools/{tool_name}</code></td><td><span class="method-badge method-post">POST</span></td><td>Execute a tool directly. Body: { arguments: dict }.</td></tr>
653
+ <tr><td><code>/api/agent/context-length</code></td><td><span class="method-badge method-get">GET</span></td><td>Return current model's context window size.</td></tr>
654
+ <tr><td><code>/api/agent/diff</code></td><td><span class="method-badge method-post">POST</span></td><td>Compute structured diff between two course JSONs.</td></tr>
655
+ </tbody>
656
+ </table>
657
+
658
+ <h2>Chat</h2>
659
+ <table>
660
+ <thead>
661
+ <tr><th>Endpoint</th><th>Method</th><th>Purpose</th></tr>
662
+ </thead>
663
+ <tbody>
664
+ <tr><td><code>/api/chat/sessions</code></td><td><span class="method-badge method-get">GET</span></td><td>List active sessions. <code>?refined_id=</code> or <code>?document_draft_id=</code> to filter.</td></tr>
665
+ <tr><td><code>/api/chat/sessions</code></td><td><span class="method-badge method-post">POST</span></td><td>Create a session. Body: { refined_id?, document_draft_id?, title? }.</td></tr>
666
+ <tr><td><code>/api/chat/sessions/{id}</code></td><td><span class="method-badge method-patch">PATCH</span></td><td>Rename a session. Body: { title }.</td></tr>
667
+ <tr><td><code>/api/chat/sessions/{id}</code></td><td><span class="method-badge method-delete">DELETE</span></td><td>Soft-archive a session and its messages.</td></tr>
668
+ <tr><td><code>/api/chat/sessions/{id}/messages</code></td><td><span class="method-badge method-get">GET</span></td><td>Fetch all messages in a session.</td></tr>
669
+ <tr><td><code>/api/chat/sessions/{id}/messages</code></td><td><span class="method-badge method-post">POST</span></td><td>Send a message and stream response via SSE. Events: status, token, tool_call, tool_result, draft, document_draft, refined_course, context_usage, error, done.</td></tr>
670
+ <tr><td><code>/api/chat/sessions/{id}/attachments</code></td><td><span class="method-badge method-post">POST</span></td><td>Upload files (multipart/form-data). 10MB/file, 25MB total.</td></tr>
671
+ <tr><td><code>/api/chat/sessions/{id}/attachments/{att_id}/download</code></td><td><span class="method-badge method-get">GET</span></td><td>Download an attachment.</td></tr>
672
+ <tr><td><code>/api/chat/sessions/{id}/attachments/{att_id}/preview</code></td><td><span class="method-badge method-get">GET</span></td><td>Preview an attachment inline.</td></tr>
673
+ </tbody>
674
+ </table>
675
+
676
+ <h2>Versions</h2>
677
+ <table>
678
+ <thead>
679
+ <tr><th>Endpoint</th><th>Method</th><th>Purpose</th></tr>
680
+ </thead>
681
+ <tbody>
682
+ <tr><td><code>/api/versions</code></td><td><span class="method-badge method-get">GET</span></td><td>List all curriculum snapshots. Includes course_count and has_changes.</td></tr>
683
+ <tr><td><code>/api/versions</code></td><td><span class="method-badge method-post">POST</span></td><td>Create a snapshot. Returns 409 if no changes since last version. Body: { name, program?, academic_year?, status? }.</td></tr>
684
+ <tr><td><code>/api/versions/{version_id}</code></td><td><span class="method-badge method-get">GET</span></td><td>Fetch version with its course list.</td></tr>
685
+ <tr><td><code>/api/versions/{version_id}</code></td><td><span class="method-badge method-patch">PATCH</span></td><td>Update version metadata. Body: { name?, academic_year?, status? }.</td></tr>
686
+ <tr><td><code>/api/versions/{version_id}</code></td><td><span class="method-badge method-delete">DELETE</span></td><td>Delete a version and its finalized_submissions.</td></tr>
687
+ <tr><td><code>/api/versions/{version_id}/restore</code></td><td><span class="method-badge method-post">POST</span></td><td>Restore a snapshot. Archives absent courses. Resets applied drafts.</td></tr>
688
+ <tr><td><code>/api/versions/{version_id}/courses/{refined_id}</code></td><td><span class="method-badge method-get">GET</span></td><td>Fetch a single course snapshot from a version.</td></tr>
689
+ <tr><td><code>/api/versions/{version_id}/courses/{refined_id}/preview</code></td><td><span class="method-badge method-get">GET</span></td><td>Render a version course as HTML.</td></tr>
690
+ <tr><td><code>/api/versions/{version_id}/preview</code></td><td><span class="method-badge method-get">GET</span></td><td>Render all version courses. <code>?diff=true</code> for diff vs current.</td></tr>
691
+ <tr><td><code>/api/versions/{id1}/diff/{id2}</code></td><td><span class="method-badge method-get">GET</span></td><td>Render side-by-side diff between two versions.</td></tr>
692
+ </tbody>
693
+ </table>
694
+
695
+ <h2>Auth</h2>
696
+ <table>
697
+ <thead>
698
+ <tr><th>Endpoint</th><th>Method</th><th>Purpose</th></tr>
699
+ </thead>
700
+ <tbody>
701
+ <tr><td><code>/api/auth/check</code></td><td><span class="method-badge method-get">GET</span></td><td>Verify the bearer token is valid. Returns user info or 401.</td></tr>
702
+ <tr><td><code>/api/auth/logout</code></td><td><span class="method-badge method-post">POST</span></td><td>Sign out the current user.</td></tr>
703
+ </tbody>
704
+ </table>
705
+
706
+ <h2>Query Parameters</h2>
707
+ <table>
708
+ <thead>
709
+ <tr><th>Parameter</th><th>Applies to</th><th>Description</th></tr>
710
+ </thead>
711
+ <tbody>
712
+ <tr><td><code>curriculum_year</code></td><td>All preview/PDF endpoints</td><td>Pin the batch year (e.g. 2025-2026). Falls back to CURRICULUM_YEAR env var.</td></tr>
713
+ <tr><td><code>download</code></td><td>PDF endpoints</td><td>Set true to trigger Content-Disposition attachment.</td></tr>
714
+ <tr><td><code>diff</code></td><td>Draft/version preview</td><td>Set true to render diff view instead of proposed content.</td></tr>
715
+ </tbody>
716
+ </table>
717
+
718
+ <p><strong>Total:</strong> 49 endpoints across 9 route files.</p>
719
+ </div>
720
+ </div>
721
+
722
+ <!-- ==================== DATABASE SCHEMA ==================== -->
723
+ <div class="docs-section" id="db">
724
+ <div class="docs-card">
725
+ <h1>Database Schema</h1>
726
+ <p>Run <code>docs/schema.sql</code> in the Supabase SQL editor. Required tables:</p>
727
+
728
+ <p><code>submissions</code>, <code>refined_submissions</code>, <code>curriculum_versions</code>, <code>finalized_submissions</code>, <code>agent_drafts</code>, <code>agent_document_drafts</code>, <code>course_revision_history</code>, <code>chat_sessions</code>, <code>chat_messages</code>, <code>chat_attachments</code>, <code>specialization_definitions</code>, <code>course_specialization_assignments</code>.</p>
729
+
730
+ <h2>Course Status Lifecycle</h2>
731
+ <pre><code>submissions.status: pending -&gt; refined
732
+ refined_submissions: draft -&gt; refined -&gt; archived
733
+ agent_drafts: proposed -&gt; applied
734
+ blocked -&gt; proposed (on user edit)
735
+ chat_sessions: active -&gt; archived</code></pre>
736
+
737
+ <h2>Key Columns on refined_submissions</h2>
738
+ <p><code>course_code</code>, <code>course_title</code>, <code>semester</code>, <code>credit_category</code>, <code>program</code>, <code>lecture_hours</code>, <code>tutorial_hours</code>, <code>practical_hours</code>, <code>self_study</code>, <code>credits</code>, <code>course_type</code>, <code>is_elective</code>, <code>visible</code>, <code>units</code> (jsonb), <code>objectives</code>/<code>text_books</code>/... (arrays), <code>status</code>.</p>
739
+
740
+ <p><code>visible</code> (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.</p>
741
+
742
+ <p>Verify with <code>GET /api/health/schema</code>.</p>
743
+
744
+ <h2>Table Relationships</h2>
745
+ <pre><code>refined_submissions (1) ----&lt; (N) agent_drafts
746
+ refined_submissions (1) ----&lt; (N) course_revision_history
747
+ curriculum_versions (1) ----&lt; (N) finalized_submissions
748
+ agent_document_drafts (1) ----&lt; (N) agent_drafts
749
+ chat_sessions (1) ----&lt; (N) chat_messages
750
+ chat_sessions (1) ----&lt; (N) chat_attachments
751
+ specialization_definitions (1) ----&lt; (N) course_specialization_assignments
752
+ refined_submissions (1) ----&lt; (N) course_specialization_assignments</code></pre>
753
+
754
+ <h2>Specialization Tables</h2>
755
+ <table>
756
+ <thead>
757
+ <tr><th>Table</th><th>Schema</th></tr>
758
+ </thead>
759
+ <tbody>
760
+ <tr><td><code>specialization_definitions</code></td><td>One row per track: id, semester, letter (A/B/C...), name, key (SCC/MIDS/CSCS), academic_year</td></tr>
761
+ <tr><td><code>course_specialization_assignments</code></td><td>One row per (course, track) membership: id, refined_id, specialization_id</td></tr>
762
+ <tr><td><code>refined_submissions.is_elective</code></td><td>Boolean flag marking a course as an elective</td></tr>
763
+ </tbody>
764
+ </table>
765
+ </div>
766
+ </div>
767
+
768
+ <!-- ==================== ENVIRONMENT ==================== -->
769
+ <div class="docs-section" id="env">
770
+ <div class="docs-card">
771
+ <h1>Environment Variables</h1>
772
+
773
+ <h2>Required backend (/api server, loaded from repo-root .env)</h2>
774
+ <table>
775
+ <thead>
776
+ <tr><th>Variable</th></tr>
777
+ </thead>
778
+ <tbody>
779
+ <tr><td><code>SUPABASE_URL</code></td></tr>
780
+ <tr><td><code>SUPABASE_KEY</code></td></tr>
781
+ <tr><td><code>OPENROUTER_URL</code></td></tr>
782
+ <tr><td><code>OPENROUTER_API_KEY</code></td></tr>
783
+ <tr><td><code>OPENROUTER_MODEL</code></td></tr>
784
+ </tbody>
785
+ </table>
786
+
787
+ <h2>Optional</h2>
788
+ <table>
789
+ <thead>
790
+ <tr><th>Variable</th><th>Description</th></tr>
791
+ </thead>
792
+ <tbody>
793
+ <tr><td><code>CURRICULUM_YEAR</code></td><td>The active batch label (e.g. 2025-2026)</td></tr>
794
+ <tr><td><code>OPENROUTER_FALLBACK_MODEL</code></td><td>Backup model used when the primary model fails after retries (e.g. <code>google/gemma-3-27b-it:free</code>)</td></tr>
795
+ <tr><td><code>REDIS_URL</code></td><td>Upstash Redis URL for persistent caching (falls back to in-memory)</td></tr>
796
+ <tr><td><code>SENTRY_DSN</code>, <code>SENTRY_ENVIRONMENT</code>, <code>SENTRY_RELEASE</code></td><td>Error tracking configuration</td></tr>
797
+ </tbody>
798
+ </table>
799
+
800
+ <p>The frontend uses the public Supabase anon key directly in <code>shared/supabase-client.js</code>.</p>
801
+ </div>
802
+ </div>
803
+
804
+ <!-- ==================== DEPLOYMENT ==================== -->
805
+ <div class="docs-section" id="deploy">
806
+ <div class="docs-card">
807
+ <h1>Deployment</h1>
808
+
809
+ <h2>Backend (HF Spaces)</h2>
810
+ <p>Docker image runs <code>uvicorn app.main:app --host 0.0.0.0 --port 7860</code> (HF Space).</p>
811
+ <p><code>.github/workflows/sync-to-hub.yml</code> syncs <code>main</code> to the HF Space.</p>
812
+
813
+ <p><strong>Required HF Space secrets:</strong></p>
814
+ <table>
815
+ <thead>
816
+ <tr><th>Secret</th><th>Description</th></tr>
817
+ </thead>
818
+ <tbody>
819
+ <tr><td><code>SUPABASE_URL</code>, <code>SUPABASE_KEY</code></td><td>Database connection</td></tr>
820
+ <tr><td><code>OPENROUTER_URL</code>, <code>OPENROUTER_API_KEY</code>, <code>OPENROUTER_MODEL</code></td><td>LLM provider</td></tr>
821
+ <tr><td><code>OPENROUTER_FALLBACK_MODEL</code></td><td>Recommended fallback model</td></tr>
822
+ <tr><td><code>REDIS_URL</code></td><td>Optional persistent caching</td></tr>
823
+ <tr><td><code>SENTRY_DSN</code>, <code>SENTRY_ENVIRONMENT</code>, <code>SENTRY_RELEASE</code></td><td>Optional error tracking</td></tr>
824
+ </tbody>
825
+ </table>
826
+
827
+ <h2>Frontend (Vercel)</h2>
828
+ <p>Static hosting with <code>frontend/vercel.json</code> rewriting <code>/api/*</code> to the backend.</p>
829
+
830
+ <p><strong>Required Vercel environment variables:</strong></p>
831
+ <table>
832
+ <thead>
833
+ <tr><th>Variable</th><th>Description</th></tr>
834
+ </thead>
835
+ <tbody>
836
+ <tr><td><code>NEXT_PUBLIC_SUPABASE_URL</code></td><td>Public Supabase URL</td></tr>
837
+ <tr><td><code>NEXT_PUBLIC_SUPABASE_ANON_KEY</code></td><td>Public Supabase anon key</td></tr>
838
+ </tbody>
839
+ </table>
840
+
841
+ <h2>CI (.github/workflows/ci.yml)</h2>
842
+ <p>Runs on push/PR: checkout, Python 3.12, pip install (cached), <code>apt-get install poppler-utils</code>, <code>pytest</code>, <code>python -m compileall backend/app</code>.</p>
843
+
844
+ <h2>Local Development</h2>
845
+ <pre><code>python3 -m venv .venv
846
+ source .venv/bin/activate
847
+ pip install -r requirements.txt
848
+ cd backend &amp;&amp; fastapi dev app/main.py</code></pre>
849
+ <p>Server at <code>http://127.0.0.1:8000</code>. API under <code>/api</code>. Frontend served from <code>frontend/</code>.</p>
850
+
851
+ <pre><code>source .venv/bin/activate
852
+ pytest # all tests
853
+ python -m compileall backend/app # also runs in CI</code></pre>
854
+ </div>
855
+ </div>
856
+
857
+ </main>
858
+ </div>
859
+
860
+ <script>
861
+ var mermaidLoaded = false;
862
+
863
+ function loadMermaid(cb) {
864
+ if (mermaidLoaded) { cb(); return; }
865
+ var s = document.createElement("script");
866
+ s.src = "https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js";
867
+ s.onload = function () {
868
+ mermaid.initialize({ startOnLoad: false, theme: "default" });
869
+ mermaidLoaded = true;
870
+ cb();
871
+ };
872
+ document.head.appendChild(s);
873
+ }
874
+
875
+ function renderMermaid() {
876
+ if (!window.mermaid) return;
877
+ document.querySelectorAll("div.mermaid:not([data-processed])").forEach(function (el) {
878
+ mermaid.run({ nodes: [el] });
879
+ });
880
+ }
881
+
882
+ function showSection(id, linkEl) {
883
+ document.querySelectorAll(".docs-section").forEach(function (s) {
884
+ s.classList.remove("active");
885
+ });
886
+ document.getElementById(id).classList.add("active");
887
+ document.querySelectorAll(".docs-sidebar-link").forEach(function (a) {
888
+ a.classList.remove("active");
889
+ });
890
+ linkEl.classList.add("active");
891
+ if (id === "arch" && !mermaidLoaded) {
892
+ loadMermaid(renderMermaid);
893
+ }
894
+ }
895
+ </script>
896
+ </body>
897
+ </html>