diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..e7acb09b33996bce5f1579f406f81dbc0ffebed3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libgdk-pixbuf-2.0-0 \ + libffi-dev \ + poppler-utils \ + shared-mime-info \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend/ . +COPY frontend/ frontend/ + +EXPOSE 7860 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..71cf14c84788c1bd304a9f86d53110282e6e7043 --- /dev/null +++ b/README.md @@ -0,0 +1,205 @@ +--- +title: Curriculum Backend +colorFrom: blue +colorTo: gray +sdk: docker +app_port: 7860 +pinned: false +--- + +# Syntagma + +
+ +![Python](https://img.shields.io/badge/python-3.12-3776AB?style=flat-square&logo=python&logoColor=white) +![FastAPI](https://img.shields.io/badge/fastapi-0.138-009688?style=flat-square&logo=fastapi&logoColor=white) +![Supabase](https://img.shields.io/badge/supabase-postgres-3FCF8E?style=flat-square&logo=supabase&logoColor=white) +![Upstash Redis](https://img.shields.io/badge/upstash--redis-cache-DD0000?style=flat-square&logo=redis&logoColor=white) +![OpenRouter](https://img.shields.io/badge/openrouter--llm-6366F1?style=flat-square&logo=openai&logoColor=white) +![WeasyPrint](https://img.shields.io/badge/weasyprint-pdf-E64B1A?style=flat-square&logo=markdown&logoColor=white) +![CI](https://img.shields.io/badge/CI-passing-brightgreen?style=flat-square&logo=githubactions&logoColor=white) +![Tests](https://img.shields.io/badge/tests-229-green?style=flat-square&logo=pytest&logoColor=white) +![Sentry](https://img.shields.io/badge/sentry--sdk-2.63-362D59?style=flat-square&logo=sentry&logoColor=white) +![HF Space](https://img.shields.io/badge/deploy-HF%20Spaces-yellow?style=flat-square&logo=huggingface&logoColor=white) +![Vercel](https://img.shields.io/badge/frontend-Vercel-black?style=flat-square&logo=vercel&logoColor=white) + +
+ +## The Problem + +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. + +## How Syntagma Solves It + +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. + +**Live Demo:** **[syntagma.lonelyguy.tech](https://syntagma.lonelyguy.tech/)** (preferred) + +Backup: [pesucurriculum.vercel.app](https://pesucurriculum.vercel.app/) + +## Architecture + +```mermaid +flowchart TB + subgraph Frontend + Auth["/auth/ Sign In"] + Dashboard["/ Dashboard"] + Form["/form/ Course Submission"] + Courses["/courses/ Course Management"] + Preview["/preview/ PDF Preview"] + Editor["/live-editor/ Agentic Editor"] + Versions["/versions/ Version History"] + Docs["/docs/ Documentation"] + end + + subgraph Backend["FastAPI Backend /api"] + direction TB + SubAPI["Submissions"] + CoursesAPI["Courses"] + PreviewAPI["Preview 8 endpoints"] + AgentAPI["Agent 13 endpoints"] + ChatAPI["Chat SSE streaming"] + VersionsAPI["Versions 10 endpoints"] + end + + subgraph External + LLM["OpenRouter Primary + Fallback"] + Redis[("Upstash Redis")] + Supa[(Supabase Postgres)] + WeasyPrint["WeasyPrint A4 PDF"] + end + + Auth -->|"JWT"| Dashboard + Dashboard --> Form & Courses & Preview & Editor & Versions & Docs + + Form -->|"POST /submissions"| SubAPI + SubAPI -->|"refine"| LLM + SubAPI --> Supa + + Courses --> CoursesAPI + CoursesAPI --> Redis + Redis -.->|"miss"| Supa + + Preview --> PreviewAPI + PreviewAPI --> WeasyPrint + + Editor -->|"SSE"| ChatAPI + ChatAPI --> LLM + LLM -->|"tool calls"| AgentAPI + AgentAPI --> Supa + + VersionsAPI --> Supa +``` + +| Layer | Stack | +|---|---| +| Backend | Python 3.12, FastAPI 0.138, Uvicorn | +| Frontend | Vanilla HTML/CSS/JS (no build step) | +| Database | Supabase (PostgreSQL) | +| Cache | Upstash Redis (optional, falls back to in-memory) | +| AI/LLM | OpenRouter (streaming, tool calling, fallback model retry) | +| PDF | Jinja2 + WeasyPrint (A4 layout with PES University letterhead) | +| Auth | Supabase Auth (JWT) | +| Deploy | Docker on HF Spaces, Vercel frontend proxy | +| Monitoring | Sentry SDK (optional) | + +## Features + +- **Course submission** with auto-parsed course codes (semester, department, credits extracted from the code itself) +- **AI refinement** that preserves all syllabus topics, only cleans and structures content +- **Full curriculum PDFs** in PES University's official A4 format with letterhead, summary tables, and course details +- **Agentic Editor** with AI assistant (SSE streaming, 35 tools, draft review, file attachments) +- **Reviewable drafts** - the agent never auto-applies changes; every edit goes through human review +- **Agent retry with fallback model** (Fibonacci backoff on 502/503, automatic model switch) +- **Chat persistence** (messages, tool calls, and tool results saved to database across sessions) +- **Dynamic specialization management** (DB-driven tracks, not hardcoded) +- **Version snapshots** with restore, revision history, and version-vs-version comparison +- **Course visibility toggle** and credit-based sorting +- **Dual cache layer** (Redis + in-memory, lazy invalidation) +- **Authentication** via Supabase Auth (JWT) +- **35 agent tools** for reading, writing, and managing curriculum data +- **49 API endpoints** across 9 route files + +## Quick Start + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cd backend && fastapi dev app/main.py +``` + +Server at `http://127.0.0.1:8000`. API under `/api`. Frontend served from `frontend/`. + +```bash +source .venv/bin/activate +pytest # 229 tests +python -m compileall backend/app # also runs in CI +``` + +## Documentation + +Full documentation is available on the **[Docs page](https://syntagma.lonelyguy.tech/docs/)** within the app, covering: + +- System architecture and data flow +- Submission pipeline, refinement, preview, specializations, agent system, versioning +- All 49 API endpoints with request/response schemas +- Database schema, 12 tables, status lifecycles +- Environment variables (required and optional) +- Deployment (Docker, Vercel, HF Spaces, CI/CD) +- All 35 agent tools with descriptions + +## Project Structure + +``` +backend/ FastAPI (Python) ASGI entrypoint at app/main.py +frontend/ Vanilla HTML/CSS/JS, no build step +tests/ 29 pytest files (229 tests) +docs/ Markdown docs source (rendered as frontend surface) +``` + +## Screenshots + +### Sign In + +![Sign In - email and password authentication via Supabase Auth](assets/images/auth_page.png) + +### Dashboard + +![Dashboard - navigation hub linking to all surfaces](assets/images/home_page.png) + +### Course Submission + +![Course Submission - faculty enter course code, title, content, and references](assets/images/submit_course_page.png) + +### Courses Management + +![Courses Default - filterable table with semester, code, title, credits, and visibility toggle](assets/images/courses_default.png) + +![Courses with Filter and Visibility Toggle - semester and visibility filters applied](assets/images/courses_filtered_visible_toggle.png) + +![Delete Confirmation - confirmation dialog before archiving a course](assets/images/courses_delete_modal.png) + +### PDF Preview + +![Full Document Preview - complete curriculum rendered as a multi-page PDF in the browser](assets/images/preview_full_doc.png) + +![Semester Preview - single semester PDF with summary tables and course details](assets/images/preview_full_sem.png) + +![Single Course Preview - individual course page with syllabus, textbooks, and outcomes](assets/images/preview_single_course.png) + +### Agentic Editor + +![Agentic Editor - AI assistant chat, JSON fields editor, and draft review in a three-tab side panel](assets/images/editor_sample_annotated.png) + +![Agentic Editor Single Course - course preview with agent chat and version controls](assets/images/editor_single_course.png) + +### Version History + +![Versions Default - sidebar list of curriculum snapshots with expand/collapse groups](assets/images/versions_default.png) + +![Version Comparison - side-by-side diff between two version snapshots](assets/images/versions_annotated_comparision.png) + +![Version Rename - inline rename form for version names and categories](assets/images/versions_rename_category.png) + +![Current Document Version - live curriculum state shown as the "Current" entry](assets/images/versions_current_document.png) diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/_logo_data.py b/backend/app/_logo_data.py new file mode 100644 index 0000000000000000000000000000000000000000..de2ed6ed836262d2004a875839e9baf982135933 --- /dev/null +++ b/backend/app/_logo_data.py @@ -0,0 +1 @@ +PES_LOGO_DATA_URI = "data:image/png;base64,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" diff --git a/backend/app/api.py b/backend/app/api.py new file mode 100644 index 0000000000000000000000000000000000000000..b8ea0442176e70af83997dcf382e7395530c5ceb --- /dev/null +++ b/backend/app/api.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from app.routes import agent, chat, courses, health, preview, refined, submissions, versions + +router = APIRouter() + +for route in (health, submissions, preview, refined, agent, chat, versions, courses): + router.include_router(route.router) diff --git a/backend/app/cache.py b/backend/app/cache.py new file mode 100644 index 0000000000000000000000000000000000000000..d030024b4557d6e7114c8fece34e5b1ebbd1ce1b --- /dev/null +++ b/backend/app/cache.py @@ -0,0 +1,134 @@ +import logging +import os +import pickle +import time +import threading + +logger = logging.getLogger(__name__) + +DEFAULT_TTL = 60 +_MAX_MEMORY_ENTRIES = 500 +_REDIS_RETRY_COOLDOWN = 60 + +_redis_client = None +_redis_initAttempted = False +_redis_lastFail = 0.0 +_lock = threading.Lock() +_memory: dict[str, tuple[float, int, object]] = {} + + +def _get_redis(): + global _redis_client, _redis_initAttempted, _redis_lastFail + if _redis_client is not None: + return _redis_client + if _redis_initAttempted: + if time.monotonic() - _redis_lastFail < _REDIS_RETRY_COOLDOWN: + return None + _redis_initAttempted = False + url = os.getenv("REDIS_URL", "").strip() + if not url: + _redis_initAttempted = True + return None + try: + import redis as _redis + pool = _redis.ConnectionPool.from_url( + url, + decode_responses=False, + max_connections=10, + socket_connect_timeout=5, + socket_timeout=10, + ) + client = _redis.Redis(connection_pool=pool) + client.ping() + _redis_client = client + _redis_initAttempted = True + logger.info("Redis connected to %s", url.split("@")[-1] if "@" in url else url) + return _redis_client + except Exception: + logger.exception("Redis connection failed, falling back to in-memory cache") + _redis_client = None + _redis_initAttempted = True + _redis_lastFail = time.monotonic() + return None + + +def _prune_memory(): + now = time.monotonic() + expired = [k for k, (ts, ttl, _) in _memory.items() if now - ts > ttl] + for k in expired: + del _memory[k] + if len(_memory) > _MAX_MEMORY_ENTRIES: + oldest = sorted(_memory, key=lambda k: _memory[k][0])[:len(_memory) - _MAX_MEMORY_ENTRIES] + for k in oldest: + del _memory[k] + + +def get(key: str) -> object | None: + r = _get_redis() + if r is not None: + try: + raw = r.get(key) + if raw is None: + return None + return pickle.loads(raw) + except Exception: + logger.debug("Redis GET failed for key=%s", key) + with _lock: + entry = _memory.get(key) + if entry is None: + return None + ts, ttl, value = entry + if time.monotonic() - ts > ttl: + del _memory[key] + return None + return value + + +def put(key: str, value: object, ttl: int = DEFAULT_TTL) -> None: + r = _get_redis() + if r is not None: + try: + r.setex(key, ttl, pickle.dumps(value)) + return + except Exception: + logger.debug("Redis SET failed for key=%s", key) + with _lock: + _memory[key] = (time.monotonic(), ttl, value) + if len(_memory) > _MAX_MEMORY_ENTRIES + 50: + _prune_memory() + + +def invalidate(prefix: str = "") -> None: + r = _get_redis() + if r is not None: + try: + if not prefix: + r.flushdb() + return + cursor = 0 + while True: + cursor, keys = r.scan(cursor, match=f"{prefix}*", count=100) + if keys: + r.delete(*keys) + if cursor == 0: + break + return + except Exception: + logger.debug("Redis INVALIDATE failed for prefix=%s", prefix) + with _lock: + if not prefix: + _memory.clear() + return + keys = [k for k in _memory if k.startswith(prefix)] + for k in keys: + del _memory[k] + + +def close() -> None: + global _redis_client + if _redis_client is not None: + try: + _redis_client.close() + except Exception: + pass + _redis_client = None diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..71999529b6685195f54d656cce0c69b1acf6b521 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,87 @@ +import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path + +import sentry_sdk +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from dotenv import load_dotenv + +from app.api import router +from app.routes.auth import router as auth_router + +load_dotenv(Path(__file__).resolve().parents[2] / ".env") + +sentry_dsn = os.getenv("SENTRY_DSN", "").strip() +if sentry_dsn: + sentry_config = { + "dsn": sentry_dsn, + "environment": os.getenv("SENTRY_ENVIRONMENT", "").strip() or "production", + } + sentry_release = os.getenv("SENTRY_RELEASE", "").strip() + if sentry_release: + sentry_config["release"] = sentry_release + sentry_sdk.init(**sentry_config) + + +def frontend_directory(): + app_root = Path(__file__).resolve().parents[1] + candidates = ( + app_root / "frontend", + app_root.parent / "frontend", + app_root.parent.parent / "frontend", + Path("/frontend"), + ) + return next((path for path in candidates if path.exists()), None) + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + from app.routes.preview import preload_pdfs + preload_pdfs() + _prewarm_cache() + yield + from app import cache + cache.close() + + +def _prewarm_cache(): + """Warm frequently-read caches at startup to avoid cold-start latency.""" + import logging + import threading + + logger = logging.getLogger(__name__) + + def _worker(): + try: + from app import cache + from app.supabase import supabase + + rows = supabase.table("refined_submissions").select("id,status").in_("status", ["refined"]).eq("visible", True).order("id").execute().data + ids = [row["id"] for row in rows] + cache.put("all_course_ids", ids, ttl=300) + logger.info("Cache prewarm complete: %d courses", len(ids)) + except Exception: + logger.debug("Cache prewarm failed", exc_info=True) + + threading.Thread(target=_worker, daemon=True, name="cache-prewarm").start() + + +app = FastAPI(title="Syntagma", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(router, prefix="/api") +app.include_router(auth_router, prefix="/api") + +frontend_dir = frontend_directory() +if not frontend_dir: + raise RuntimeError("Frontend directory not found") +app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/models/agent.py b/backend/app/models/agent.py new file mode 100644 index 0000000000000000000000000000000000000000..cba46ac7df4d5609bd65d0b0d29f58dbc18b5e1b --- /dev/null +++ b/backend/app/models/agent.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, Field + + +class AgentDraftPayload(BaseModel): + refined_id: int + fields: dict + reason: str = "" + + +class AgentDocumentCoursePayload(BaseModel): + refined_id: int + fields: dict + + +class AgentDocumentDraftPayload(BaseModel): + courses: list[AgentDocumentCoursePayload] = Field(min_length=1) + reason: str = "" + curriculum_version_id: int | None = None + uploaded_document_id: str = "" + + +class AgentToolPayload(BaseModel): + arguments: dict = Field(default_factory=dict) diff --git a/backend/app/models/chat.py b/backend/app/models/chat.py new file mode 100644 index 0000000000000000000000000000000000000000..4e5735010e521966addd624ab9d9f181b739a879 --- /dev/null +++ b/backend/app/models/chat.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, Field, field_validator + + +class ChatSessionPayload(BaseModel): + refined_id: int | None = None + document_draft_id: int | None = None + title: str = "" + + +class ChatSessionTitlePayload(BaseModel): + title: str = Field(min_length=1, max_length=120) + + @field_validator("title", mode="before") + @classmethod + def strip_title(cls, value): + return value.strip() if isinstance(value, str) else value + + +class ChatMessagePayload(BaseModel): + content: str = "" + metadata: dict = Field(default_factory=dict) + + @field_validator("content", mode="before") + @classmethod + def strip_content(cls, value): + return value.strip() if isinstance(value, str) else value diff --git a/backend/app/models/submission.py b/backend/app/models/submission.py new file mode 100644 index 0000000000000000000000000000000000000000..077d7adb659ed4054db80f7e2bd41f015af95423 --- /dev/null +++ b/backend/app/models/submission.py @@ -0,0 +1,101 @@ + +from pydantic import BaseModel, Field, field_validator + + +class ParsedCourseCode(BaseModel): + year: str + dept: str + semester: str + offering_dept: str + target_dept: str + credit_category: str + is_lateral: bool + base_code: str + + +def parse_course_code(code: str) -> ParsedCourseCode: + """Parse PESU course code like UE25CS242B into components.""" + code = code.strip().upper().replace(" ", "") + + if not code.startswith("UE"): + raise ValueError("Course code must start with 'UE'") + + # UE + year (2 digits) + dept (2 chars) + number (3 digits) + suffix + # UE25CS242B -> year=25, dept=CS, num=242, suffix=B + import re + match = re.match(r"^UE(\d{2})([A-Z]{2})(\d{3})([A-Z\*]+)$", code) + if not match: + raise ValueError(f"Invalid course code format: {code}") + + year, dept, num_str, suffix = match.groups() + num = int(num_str) + + # Number format: [semester_group][credits][sequence] + # e.g. 151 -> group=1, credits=5, seq=1 + # e.g. 242 -> group=2, credits=4, seq=2 + semester_group = num // 100 + credits_digit = (num // 10) % 10 + credit_category = str(credits_digit) if credits_digit in (0, 2, 4, 5) else "4" + + # Semester: each group covers 2 semesters. + # A/B suffix determines odd/even within the group. + is_even = suffix.startswith("B") or suffix.endswith("B") or suffix in ("XX", "XB") + semester = str(semester_group * 2 - 1 + (1 if is_even else 0)) + + # Map department to offering_department + dept_map = { + "CS": "CS", + "EC": "CS", + "EE": "CS", + "ME": "CS", + "BT": "CS", + "AI": "CS", + "ML": "CS", + "MA": "MA", + "PH": "MA", + "CH": "MA", + "HU": "UZ", + "UZ": "UZ", + } + offering_dept = dept_map.get(dept, "CS") + + # Target department is based on program (all map to CSE for CS dept courses) + target_map = { + "CS": "CSE", + "EC": "ECE", + "EE": "EEE", + "ME": "ME", + "BT": "BT", + "AI": "AIML", + "ML": "AIML", + } + target_dept = target_map.get(dept, "CSE") + + is_lateral = "*" in suffix + + return ParsedCourseCode( + year=year, + dept=dept, + semester=semester, + offering_dept=offering_dept, + target_dept=target_dept, + credit_category=credit_category, + is_lateral=is_lateral, + base_code=code, + ) + + +class CourseSubmission(BaseModel): + faculty_email: str = Field(default="", max_length=254) + course_title: str = Field(min_length=3, max_length=150) + course_code: str = Field(min_length=8, max_length=12) # e.g., UE25CS242B + raw_course_content: str = Field(min_length=50) + text_books: str = Field(min_length=5) + reference_books: str = "" + preferred_tools: str = "" + credit_category: str = "" + + @field_validator("faculty_email", "course_title", "course_code", "raw_course_content", "text_books", "reference_books", "preferred_tools", "credit_category", mode="before") + @classmethod + def strip(cls, value): + return value.strip() if isinstance(value, str) else value diff --git a/backend/app/preview.py b/backend/app/preview.py new file mode 100644 index 0000000000000000000000000000000000000000..49886821c8ed72bba054d02901b53fb3d6e5d68e --- /dev/null +++ b/backend/app/preview.py @@ -0,0 +1,108 @@ +import json + +from app.services.books import parse_books +from app.services.deterministic import compute_course_type, compute_hours, compute_program +from app.supabase import supabase + + +def _lines(value) -> list[str]: + if not value: + return [] + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + return [line.strip() for line in str(value).splitlines() if line.strip()] + + +def _content(row: dict) -> dict: + raw = row.get("refined_content") or {} + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw.strip(): + return json.loads(raw) + return {} + + +def _text(*values) -> str: + for value in values: + if isinstance(value, str): + text = value.strip() + if text and text != "-": + return text + return "" + + +def build_course_preview(row: dict) -> dict: + content = _content(row) + submission = row.get("_submission") or {} + credit_category = str(submission.get("credit_category") or row.get("credit_category") or content.get("credits") or "") + target_department = str(submission.get("target_department") or "") + + hours = { + "lecture_hours": row.get("lecture_hours", content.get("lecture_hours", 0)), + "tutorial_hours": row.get("tutorial_hours", content.get("tutorial_hours", 0)), + "practical_hours": row.get("practical_hours", content.get("practical_hours", 0)), + "self_study": row.get("self_study", content.get("self_study", 0)), + "credits": row.get("credits", content.get("credits", 0)), + } + if credit_category and not any(hours.values()): + hours = compute_hours(credit_category) + + program = row.get("program") or content.get("program") or "" + if target_department and not program: + program = compute_program(target_department) + + course_type = row.get("course_type") or content.get("course_type") or "" + if credit_category and not course_type: + course_type = compute_course_type(credit_category) + + practical_hours = int(hours["practical_hours"] or 0) + status = str(row.get("status") or content.get("status") or "") + + objectives = _lines(row.get("objectives") or content.get("objectives"))[:4] + course_outcomes = _lines(row.get("course_outcomes") or content.get("course_outcomes"))[:4] + units = row.get("units") or content.get("units") or [] + lab_experiments = _lines(row.get("lab_experiments") or content.get("lab_experiments"))[:10] if practical_hours else [] + text_books = parse_books(row.get("text_books") or content.get("text_books")) or parse_books(submission.get("text_books")) + reference_books = parse_books(row.get("reference_books") or content.get("reference_books")) or parse_books(submission.get("reference_books")) + has_content = any([objectives, course_outcomes, units, lab_experiments, text_books, reference_books]) + return { + "refined_id": row.get("id"), + "course_code": str(row.get("course_code") or content.get("course_code") or ""), + "course_title": str(row.get("course_title") or content.get("course_title") or ""), + "program": str(program), + "lecture_hours": str(hours["lecture_hours"]), + "tutorial_hours": str(hours["tutorial_hours"]), + "practical_hours": str(practical_hours), + "self_study": str(hours["self_study"]), + "credits": str(hours["credits"]), + "semester": str(row.get("semester") or content.get("semester") or ""), + "course_type": str(course_type), + "is_elective": bool(row.get("is_elective") or content.get("is_elective") or False), + "status": status, + "render_detail": status != "summary_only" and has_content, + "tools_languages": _text(row.get("tools_languages"), content.get("tools_languages"), submission.get("preferred_tools")), + "desirable_knowledge": _text(row.get("desirable_knowledge"), content.get("desirable_knowledge")), + "prelude": row.get("prelude") or content.get("prelude") or "", + "objectives": objectives, + "course_outcomes": course_outcomes, + "units": units, + "lab_experiments": lab_experiments, + "text_books": text_books, + "reference_books": reference_books, + } + + +def build_specialization_context(academic_year: str = "") -> dict: + """Load specialization tracks and their elective assignments for template rendering. + + academic_year is accepted for forward compatibility (multiple batches) but the + current single-batch deployment returns every track. Filtering by an empty or + mismatched year would hide all tracks, so we load them all and let the caller + scope by semester inside the template. + """ + specs = supabase.table("specialization_definitions").select("*").order("semester").order("letter").execute().data + assignments = supabase.table("course_specialization_assignments").select("*").execute().data + return { + "specializations": specs, + "specialization_assignments": assignments, + } diff --git a/backend/app/rendering.py b/backend/app/rendering.py new file mode 100644 index 0000000000000000000000000000000000000000..9882fe1e727dd87f477709a515d9144adfa7da96 --- /dev/null +++ b/backend/app/rendering.py @@ -0,0 +1,102 @@ +import base64 +import re +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader, select_autoescape +from jinja2.ext import do +from markupsafe import Markup, escape + +APP_DIR = Path(__file__).resolve().parent + + +def _find_frontend_dir() -> Path: + app_root = APP_DIR.parent + candidates = ( + app_root / "frontend", + app_root.parent / "frontend", + app_root.parent.parent / "frontend", + Path("/frontend"), + ) + result = next((p for p in candidates if p.exists()), None) + if result is None: + raise RuntimeError("Frontend directory not found; cannot resolve image assets for PDF rendering") + return result + + +def _load_pes_logo() -> str: + frontend = _find_frontend_dir() + logo_path = frontend / "images" / "image2.png" + if logo_path.exists(): + b64 = base64.b64encode(logo_path.read_bytes()).decode() + return f"data:image/png;base64,{b64}" + try: + from app._logo_data import PES_LOGO_DATA_URI + return PES_LOGO_DATA_URI + except ImportError: + return "" + + +FRONTEND_DIR = _find_frontend_dir() +templates = Environment(loader=FileSystemLoader(APP_DIR / "templates"), autoescape=select_autoescape(["html", "xml"]), extensions=[do]) +URL_RE = re.compile(r"https?://[^\s<>()]+") +YEAR_RE = re.compile(r"\d{4}") + + +def linkify(value: str) -> Markup: + text = str(value or "") + parts = [] + last = 0 + for match in URL_RE.finditer(text): + raw_url = match.group(0) + url = raw_url.rstrip(".,;:)]}") + trailing = raw_url[len(url) :] + safe_url = escape(url) + parts.append(escape(text[last : match.start()])) + parts.append(Markup(f'{safe_url}')) + parts.append(escape(trailing)) + last = match.end() + parts.append(escape(text[last:])) + return Markup("".join(str(part) for part in parts)) + + +def batch_start_year(semester, curriculum_year: str) -> int | None: + match = YEAR_RE.search(str(curriculum_year or "")) + if not match: + return None + try: + sem = int(semester) + except (TypeError, ValueError): + return None + return int(match.group()) - ((sem - 1) // 2) + + +def batch_label(semester, curriculum_year: str) -> str: + start = batch_start_year(semester, curriculum_year) + return f"({start}-{(start + 4) % 100:02d} BATCH)" if start else "" + + +def course_code_for_year(value: str, semester, curriculum_year: str) -> str: + code = str(value or "") + start = batch_start_year(semester, curriculum_year) + if not start or len(code) < 5 or code[:2] not in {"UE", "UZ"} or not code[2:4].isdigit(): + return code + return f"{code[:2]}{start % 100:02d}{code[4:]}" + + +templates.filters["linkify"] = linkify +templates.filters["course_code_for_year"] = course_code_for_year +templates.globals["batch_label"] = batch_label + +SEMESTER_NAMES = { + "1": "I", + "2": "II", + "3": "III", + "4": "IV", + "5": "V", + "6": "VI", + "7": "VII", + "8": "VIII", +} +templates.globals["SEMESTER_NAMES"] = SEMESTER_NAMES + +templates.globals["pes_logo"] = _load_pes_logo() diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/backend/app/routes/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/routes/agent.py b/backend/app/routes/agent.py new file mode 100644 index 0000000000000000000000000000000000000000..5ac70d9f54ea97806520356d2586657b83c05836 --- /dev/null +++ b/backend/app/routes/agent.py @@ -0,0 +1,339 @@ +import logging + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import HTMLResponse +from postgrest.exceptions import APIError + +from app.models.agent import AgentDocumentDraftPayload, AgentDraftPayload, AgentToolPayload +from app.preview import build_course_preview +from app.rendering import templates +from app.services.agent_tools import call_tool, list_tool_schemas +from app.services.curriculum import create_version_snapshot, draft_record, load_agent_draft, load_document_draft, selected_curriculum_year, update_refined_fields +from app.services.diffing import build_course_diff, diff_course +from app.services.errors import database_http_exception +from app.supabase import supabase + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.post("/agent/diff") +def compare_course(payload: dict): + current = payload.get("current") + proposed = payload.get("proposed") + if not isinstance(current, dict) or not isinstance(proposed, dict): + raise HTTPException(status_code=400, detail="current and proposed are required") + return diff_course(current, proposed) + + +@router.post("/agent/drafts") +def create_agent_draft(payload: AgentDraftPayload): + try: + record = draft_record(payload.refined_id, payload.fields, payload.reason) + result = supabase.table("agent_drafts").insert(record).execute() + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except APIError as exc: + raise database_http_exception(exc) from exc + return {"message": "Draft created", "draft": result.data[0]} + + +@router.get("/agent/drafts") +def list_agent_drafts(): + try: + rows = supabase.table("agent_drafts").select("*").order("id", desc=True).limit(100).execute().data + except APIError as exc: + raise database_http_exception(exc) from exc + return { + "drafts": [ + { + "id": row["id"], + "refined_id": row["refined_id"], + "status": row.get("status") or "", + "course_title": (row.get("proposed_json") or {}).get("course_title") or "", + "course_code": (row.get("proposed_json") or {}).get("course_code") or "", + "change_reason": row.get("change_reason") or "", + "created_at": row.get("created_at") or "", + } + for row in rows + ] + } + + +@router.get("/agent/drafts/{draft_id}") +def get_agent_draft(draft_id: int): + try: + return {"draft": load_agent_draft(draft_id)} + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except APIError as exc: + raise database_http_exception(exc) from exc + + +@router.get("/agent/drafts/{draft_id}/preview") +def preview_agent_draft(draft_id: int, diff: bool = False, curriculum_year: str | None = Query(None)): + try: + draft = load_agent_draft(draft_id) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except APIError as exc: + raise database_http_exception(exc) from exc + + if diff: + base = dict(draft.get("base_refined_json") or {}) + proposed = dict(draft.get("proposed_json") or {}) + course_diff = build_course_diff(base, proposed) + html = templates.get_template("jinja_diff.html").render( + base=base, + proposed=proposed, + course_diff=course_diff, + curriculum_year=selected_curriculum_year(curriculum_year), + asset_root="/", + ) + else: + proposed = build_course_preview(draft["proposed_json"]) + html = templates.get_template("jinja_sample.html").render(course=proposed, curriculum_year=selected_curriculum_year(curriculum_year), asset_root="/", show_thank_you=False) + return HTMLResponse(html, headers={"Cache-Control": "no-store"}) + + +def _generate_version_name(summary: dict, action: str) -> str: + """Generate a conventional-commit-style version name from diff summary.""" + parts = [] + if summary.get("syllabus_change_percent", 0) > 0: + parts.append(f"syllabus:{summary['syllabus_change_percent']}%") + if summary.get("topics_added"): + parts.append(f"+{len(summary['topics_added'])} topics") + if summary.get("topics_removed"): + parts.append(f"-{len(summary['topics_removed'])} topics") + if summary.get("protected_changes"): + parts.append("protected-fields") + if not parts: + parts.append("updates") + change_reason = summary.get("change_reason") or "" + prefix = action.capitalize() + if change_reason: + short = change_reason[:60].strip() + return f"{prefix}: {short} ({', '.join(parts)})" + return f"{prefix}: {', '.join(parts)}" + + +@router.post("/agent/drafts/{draft_id}/apply") +def apply_agent_draft(draft_id: int): + try: + draft = load_agent_draft(draft_id) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except APIError as exc: + raise database_http_exception(exc) from exc + + summary = draft.get("diff_summary") or {} + if draft.get("status") != "proposed": + raise HTTPException(status_code=400, detail="Only proposed drafts can be applied") + if summary.get("protected_changes"): + raise HTTPException(status_code=400, detail="Draft changes deterministic fields") + + try: + supabase.table("course_revision_history").insert( + { + "refined_id": draft["refined_id"], + "agent_draft_id": draft_id, + "previous_json": draft["base_refined_json"], + "next_json": draft["proposed_json"], + "json_patch": draft["json_patch"], + "diff_summary": summary, + "change_reason": draft.get("change_reason") or "", + } + ).execute() + data = update_refined_fields(int(draft["refined_id"]), draft["proposed_json"]) + supabase.table("refined_submissions").update({"status": "refined"}).eq("id", int(draft["refined_id"])).execute() + supabase.table("agent_drafts").update({"status": "applied"}).eq("id", draft_id).execute() + version_name = _generate_version_name(summary, "apply") + try: + version = create_version_snapshot(version_name) + except Exception: + logger.exception("Version snapshot failed after draft apply") + version = None + except APIError as exc: + raise database_http_exception(exc) from exc + return {"message": "Draft applied", "data": data, "version": version} + + +@router.post("/agent/document-drafts") +def create_agent_document_draft(payload: AgentDocumentDraftPayload): + try: + records = [draft_record(course.refined_id, course.fields, payload.reason) for course in payload.courses] + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except APIError as exc: + raise database_http_exception(exc) from exc + + summaries = [record["diff_summary"] for record in records] + document_summary = { + "courses_changed": len(records), + "courses_with_removed_topics": sum(1 for summary in summaries if summary.get("topics_removed")), + "courses_with_protected_changes": sum(1 for summary in summaries if summary.get("protected_changes")), + "max_syllabus_change_percent": max((summary.get("syllabus_change_percent") or 0 for summary in summaries), default=0), + } + status = "blocked" if document_summary["courses_with_protected_changes"] else "proposed" + + try: + document = ( + supabase.table("agent_document_drafts") + .insert( + { + "curriculum_version_id": payload.curriculum_version_id, + "uploaded_document_id": payload.uploaded_document_id.strip(), + "diff_summary": document_summary, + "change_reason": payload.reason.strip(), + "status": status, + } + ) + .execute() + .data[0] + ) + for record in records: + record["document_draft_id"] = document["id"] + drafts = supabase.table("agent_drafts").insert(records).execute().data + except APIError as exc: + raise database_http_exception(exc) from exc + return {"message": "Document draft created", "document_draft": document, "drafts": drafts} + + +@router.get("/agent/document-drafts") +def list_agent_document_drafts(): + try: + rows = supabase.table("agent_document_drafts").select("*").order("id", desc=True).limit(100).execute().data + except APIError as exc: + raise database_http_exception(exc) from exc + return { + "document_drafts": [ + { + "id": row["id"], + "status": row.get("status") or "", + "uploaded_document_id": row.get("uploaded_document_id") or "", + "change_reason": row.get("change_reason") or "", + "created_at": row.get("created_at") or "", + } + for row in rows + ] + } + + +@router.post("/agent/document-drafts/{document_draft_id}/apply") +def apply_agent_document_draft(document_draft_id: int): + try: + result = load_document_draft(document_draft_id) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except APIError as exc: + raise database_http_exception(exc) from exc + + document = result["document_draft"] + drafts = result["drafts"] + + if document.get("status") != "proposed": + raise HTTPException(status_code=400, detail="Only proposed document drafts can be applied") + + summary = document.get("diff_summary") or {} + if summary.get("courses_with_protected_changes"): + raise HTTPException(status_code=400, detail="Document draft contains protected field changes") + + applied = [] + for draft in drafts: + if draft.get("status") != "proposed": + continue + try: + supabase.table("course_revision_history").insert( + { + "refined_id": draft["refined_id"], + "agent_draft_id": draft["id"], + "previous_json": draft["base_refined_json"], + "next_json": draft["proposed_json"], + "json_patch": draft["json_patch"], + "diff_summary": draft["diff_summary"], + "change_reason": draft.get("change_reason") or "", + } + ).execute() + update_refined_fields(int(draft["refined_id"]), draft["proposed_json"]) + supabase.table("refined_submissions").update({"status": "refined"}).eq("id", int(draft["refined_id"])).execute() + supabase.table("agent_drafts").update({"status": "applied"}).eq("id", draft["id"]).execute() + applied.append(draft["id"]) + except APIError as exc: + raise database_http_exception(exc) from exc + + supabase.table("agent_document_drafts").update({"status": "applied"}).eq("id", document_draft_id).execute() + version_name = _generate_version_name(summary, "apply-multi") + try: + version = create_version_snapshot(version_name) + except Exception: + logger.exception("Version snapshot failed after document draft apply") + version = None + + return {"message": f"Applied {len(applied)} drafts", "applied_draft_ids": applied, "version": version} + + +@router.get("/agent/document-drafts/{document_draft_id}") +def get_agent_document_draft(document_draft_id: int): + try: + return load_document_draft(document_draft_id) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except APIError as exc: + raise database_http_exception(exc) from exc + + +@router.get("/agent/document-drafts/{document_draft_id}/preview") +def preview_agent_document_draft(document_draft_id: int, diff: bool = False, curriculum_year: str | None = Query(None)): + try: + result = load_document_draft(document_draft_id) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except APIError as exc: + raise database_http_exception(exc) from exc + drafts = result["drafts"] + if not drafts: + raise HTTPException(status_code=404, detail="Document draft not found") + + if diff: + course_diffs = [] + for child in drafts: + base = dict(child.get("base_refined_json") or {}) + proposed = dict(child.get("proposed_json") or {}) + course_diff = build_course_diff(base, proposed) + course_diffs.append({"base": base, "proposed": proposed, "course_diff": course_diff}) + html = templates.get_template("jinja_diff.html").render( + course_diffs=course_diffs, + curriculum_year=selected_curriculum_year(curriculum_year), + asset_root="/", + ) + else: + courses = sorted( + (build_course_preview(draft["proposed_json"]) for draft in drafts), + key=lambda course: (int(course.get("semester") or 0), str(course.get("course_code") or ""), str(course.get("course_title") or "")), + ) + html = templates.get_template("jinja_sample.html").render(courses=courses, semester="", curriculum_year=selected_curriculum_year(curriculum_year), asset_root="/", show_summaries=True) + return HTMLResponse(html, headers={"Cache-Control": "no-store"}) + + +@router.get("/agent/tools") +def get_agent_tools(): + return {"tools": list_tool_schemas()} + + +@router.get("/agent/context-length") +def get_context_length(): + from app.services.openrouter import context_length, MODEL + return {"context_length": context_length(), "model": MODEL} + + +@router.post("/agent/tools/{tool_name}") +def run_agent_tool(tool_name: str, payload: AgentToolPayload): + try: + return {"name": tool_name, "result": call_tool(tool_name, payload.arguments)} + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except APIError as exc: + raise database_http_exception(exc) from exc diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..42df410a2995b610c66daf468d2fb58642390220 --- /dev/null +++ b/backend/app/routes/auth.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +router = APIRouter() + + +@router.post("/auth/logout") +async def logout(): + return {"message": "Logged out"} + + +@router.get("/auth/check") +async def check_auth(request: Request): + auth_header = request.headers.get("authorization", "") + if not auth_header.startswith("Bearer "): + return JSONResponse({"authenticated": False}, status_code=401) + return {"authenticated": True} \ No newline at end of file diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py new file mode 100644 index 0000000000000000000000000000000000000000..a8ff902559f2ee2a8fc573a924f64ac2561f5cab --- /dev/null +++ b/backend/app/routes/chat.py @@ -0,0 +1,577 @@ +import base64 +import json +import logging + +import sentry_sdk +from fastapi import APIRouter, File, HTTPException, UploadFile +from fastapi.responses import Response, StreamingResponse +from postgrest.exceptions import APIError + +from app.models.chat import ChatMessagePayload, ChatSessionPayload, ChatSessionTitlePayload +from app.services.agent_tools import call_tool, list_tool_schemas +from app.services.attachments import extract_text +from app.services.curriculum import load_document_draft, refined_course +from app.services.errors import database_http_exception +from app.services.openrouter import OpenRouterError, context_length, stream_chat +from app.supabase import first_row, supabase + +router = APIRouter() +logger = logging.getLogger(__name__) + + +def load_chat_session(session_id: int) -> dict: + try: + row = first_row(supabase.table("chat_sessions").select("*").eq("id", session_id)) + except APIError as exc: + raise database_http_exception(exc) from exc + if not row: + raise HTTPException(status_code=404, detail="Chat session not found") + return row + + +def chat_messages(session_id: int) -> list[dict]: + rows = supabase.table("chat_messages").select("*").eq("session_id", session_id).order("id").execute().data + + if not rows: + return rows + + all_ids: list[int] = [] + for row in rows: + for att in (row.get("metadata") or {}).get("attachments") or []: + if isinstance(att, dict) and att.get("id"): + all_ids.append(int(att["id"])) + + known_ids = set(all_ids) + + if not known_ids: + orphan_rows = ( + supabase.table("chat_attachments") + .select("id,filename,content_type,size_bytes,status,error") + .eq("session_id", session_id) + .is_("message_id", "null") + .execute().data + ) + orphans = [r for r in orphan_rows if r["id"] not in known_ids] + + if orphans: + last_assistant = None + for row in reversed(rows): + if row.get("role") == "assistant": + last_assistant = row + break + if last_assistant: + meta = last_assistant.get("metadata") or {} + existing = meta.get("attachments") or [] + for o in orphans: + existing.append({"id": o["id"]}) + all_ids.append(o["id"]) + known_ids.add(o["id"]) + meta["attachments"] = existing + last_assistant["metadata"] = meta + + if not all_ids: + return rows + + att_rows = ( + supabase.table("chat_attachments") + .select("id,filename,content_type,size_bytes,status,error") + .eq("session_id", session_id) + .in_("id", all_ids) + .execute() + .data + ) + att_map = {str(a["id"]): a for a in att_rows} + + for row in rows: + meta = row.get("metadata") or {} + enriched = [] + for att in meta.get("attachments") or []: + if isinstance(att, dict) and att.get("id"): + full = att_map.get(str(att["id"]), {}) + enriched.append({ + "id": att["id"], + "name": full.get("filename") or "attachment", + "type": full.get("content_type") or "", + "size": full.get("size_bytes") or 0, + "status": full.get("status") or "", + "error": full.get("error") or "", + }) + else: + enriched.append(att) + meta["attachments"] = enriched + row["metadata"] = meta + + return rows + + +def insert_chat_message(session_id: int, role: str, content: str, metadata: dict | None = None) -> dict: + return ( + supabase.table("chat_messages") + .insert({"session_id": session_id, "role": role, "content": content, "metadata": metadata or {}}) + .execute() + .data[0] + ) + + +def update_attachment_message(session_id: int, attachment_ids: list[int], message_id: int) -> None: + if attachment_ids: + supabase.table("chat_attachments").update({"message_id": message_id}).eq("session_id", session_id).in_("id", attachment_ids).execute() + + +def attachment_ids(metadata: dict | None) -> list[int]: + ids = [] + for item in (metadata or {}).get("attachments") or []: + if isinstance(item, dict) and item.get("id"): + ids.append(int(item["id"])) + return ids + + +def attachment_context(session_id: int, metadata: dict | None) -> str: + ids = attachment_ids(metadata) + if not ids: + return "" + rows = supabase.table("chat_attachments").select("filename,status,error,extracted_text").eq("session_id", session_id).in_("id", ids).execute().data + blocks = [] + for row in rows: + name = row.get("filename") or "attachment" + status = row.get("status") or "" + text = str(row.get("extracted_text") or "").strip() + if text: + blocks.append(f"Attachment: {name}\n{text}") + else: + error = row.get("error") or "No extracted text" + blocks.append(f"Attachment: {name}\nStatus: {status}. {error}") + return "\n\n".join(blocks) + + +def stable_context(value: dict) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + + +def chat_system_prompt(session: dict) -> str: + session_id = session.get("id") + context = "" + if session.get("refined_id"): + course = refined_course(int(session["refined_id"])) + context = stable_context({"active_session_id": session_id, "active_refined_id": session["refined_id"], "active_course": course}) + elif session.get("document_draft_id"): + context = stable_context({"active_session_id": session_id, **load_document_draft(int(session["document_draft_id"]))}) + else: + context = stable_context({"active_session_id": session_id}) + return f"""You are the Syntagma live editor assistant. +Be concise, practical, and specific to the active curriculum data. +Keep conversations professional and friendly. Do not use em dashes in any output. Use standard hyphens or commas instead. +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. +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. +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. +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. +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. + +Curriculum structure (B.Tech CSE): +- 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. +- 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). +- Only credit_category "5" courses have a lab component (lab_experiments). All other courses (categories 4, 2, 0) must have empty lab_experiments. +- All target departments map to "B. TECH" program. +- 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. +- 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. +- 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. +- Unit hours: every unit must be assigned exactly 14 hours. Do not vary hours across units. +- 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. +- 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. +- 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. +- 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. +- Courses have a status: "draft" (newly created, not yet finalized), "refined" (approved and visible in curriculum), "archived" (hidden). +- 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. + +Course types and electives: +- 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). +- credit_category determines the hours and credits. is_elective determines whether the course appears in specialization track tables. +- 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). +- To assign an elective to specialization tracks: use assign_elective_to_tracks with the refined_id and specialization_ids. +- To create a new specialization track: use define_specialization. +- Electives can exist without being assigned to any track. Assignment to tracks is optional. + +Syllabus design rules (enforce strictly when creating or modifying courses): +- Every course must have 3-5 units. Each unit has a title, content (syllabus text), and exactly 14 hours. +- Every course must have 3-4 course_outcomes that are measurable (use verbs like "Analyze", "Implement", "Design", "Evaluate"). +- Every course must have 3-4 objectives describing what students will learn. +- text_books: at least 1 textbook reference with author, title, edition, publisher, year. +- reference_books: optional but recommended. Follow the same format as text_books. +- Course titles should be concise (under 80 chars) and descriptive. +- course_outcomes should NOT duplicate objectives. Outcomes are measurable assessments; objectives are learning goals. +- For credit_category "5": include 4-6 lab_experiments describing hands-on tasks. +- For credit_category "4" or "2": lab_experiments must be empty. +- prelude: 2-3 sentences describing the course scope and relevance. +- desirable_knowledge: list prerequisite courses or skills. Use only course codes that exist in the curriculum. + +Decision guidelines: +- 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. +- 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. +- If modifying an existing course that has status "refined", use create_course_draft (creates a reviewable draft). +- If modifying a course that has status "draft", use create_refined_course with the refined_id (direct update, no draft needed). +- When assigning electives to specializations, first list existing specializations with list_specializations, then use assign_elective_to_tracks. +- 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. +- When generating course content (units, objectives, etc.), follow the syllabus design rules above. + +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. +Always call signal_done at the end of a completed task with a concise summary. + +Read source documents with get_attachment_text, then call create_report to save generated content as a chat attachment. + +Tool selection for course changes (critical): +- 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. +- 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. +- 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. +- create_document_draft: For changes across multiple existing courses. +- update_deterministic_fields: ONLY for changing protected fields (program, hours, credits, course_type). Creates a blocked draft. Always confirm with the user first. + +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. +When the user asks what changed, call diff_course_json or read the relevant draft before answering. +For broad document requests, use get_curriculum_json to inspect the whole syllabus before proposing edits. Prefer filtering by semester to avoid oversized responses. +For version comparison, call get_version to load a snapshot, then diff_versions to compare two versions. +For statistics and summaries, call get_curriculum_stats for aggregate data or batch_read_courses to read fields from many courses at once. +For spreadsheet exports, call batch_read_courses to gather data, then create_spreadsheet to generate CSV or Excel files. +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. +To fetch a public URL, call fetch_url and use the returned text. +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. +Never apply a draft, never claim a draft was applied, and never claim the refined database was changed. +After creating a draft, tell the user to review the diff in the Review panel before applying it. +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. + +Course data access -- prefer granular tools over full JSON: +- get_course_codes: lightweight IDs (refined_id, course_code, title, semester) -- use for lists/lookups +- get_course_syllabus: units, objectives, course_outcomes +- get_course_textbooks: text_books, reference_books +- get_course_deterministic: program, hours, credits, course_type (read-only, agent-protected) +- get_course_lab: lab_experiments, tools_languages +- get_course_fields: arbitrary specific fields (provide field name list) +- batch_read_courses: read specific fields from multiple courses in one call (preferred over looping get_course_fields) +- get_current_course_json: full course JSON -- only use when you truly need everything +- get_curriculum_json: full curriculum or filtered by semester (ALWAYS pass semester filter to avoid oversized responses) + +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. + +Active context: +{context or "No active course or document draft is selected."}""" + + +def model_messages(session_id: int, rows: list[dict]) -> tuple[list[dict], dict]: + messages = [] + for row in rows: + if row.get("role") not in {"user", "assistant"}: + continue + content = str(row.get("content") or "").strip() + context = attachment_context(session_id, row.get("metadata")) if row.get("role") == "user" else "" + if context: + content = f"{content}\n\n{context}".strip() + if content: + messages.append({"role": row["role"], "content": content}) + + budget = context_length() * 3 + total = sum(len(m["content"]) for m in messages) + if total > budget and len(messages) >= 2: + while total > budget and len(messages) >= 2: + removed_user = messages.pop(0) + total -= len(removed_user["content"]) + if messages: + removed_assistant = messages.pop(0) + total -= len(removed_assistant["content"]) + messages.insert(0, { + "role": "user", + "content": "[Earlier conversation context has been truncated to manage token usage]" + }) + usage = {"total_chars": total, "budget_chars": budget, "message_count": len(messages), "context_length": context_length()} + return messages, usage + + +def sse(event: str, data: dict) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + + +@router.post("/chat/sessions") +def create_chat_session(payload: ChatSessionPayload): + record = {"refined_id": payload.refined_id, "document_draft_id": payload.document_draft_id, "title": payload.title.strip()} + result = supabase.table("chat_sessions").insert(record).execute() + session = result.data[0] + session_id = session["id"] + + if payload.refined_id: + 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?" + elif payload.document_draft_id: + 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?" + else: + 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?" + + supabase.table("chat_messages").insert({ + "session_id": session_id, + "role": "assistant", + "content": starter, + "metadata": {"starter": True} + }).execute() + + return {"session": session} + + +@router.get("/chat/sessions") +def list_chat_sessions(refined_id: int | None = None, document_draft_id: int | None = None): + query = supabase.table("chat_sessions").select("*").eq("status", "active") + if refined_id is not None: + query = query.eq("refined_id", refined_id) + if document_draft_id is not None: + query = query.eq("document_draft_id", document_draft_id) + rows = query.order("id", desc=True).limit(50).execute().data + return {"sessions": rows} + + +@router.get("/chat/sessions/{session_id}/messages") +def get_chat_messages(session_id: int): + load_chat_session(session_id) + return {"messages": chat_messages(session_id)} + + +@router.delete("/chat/sessions/{session_id}") +def clear_chat_session(session_id: int): + load_chat_session(session_id) + supabase.table("chat_sessions").delete().eq("id", session_id).execute() + return {"message": "Chat deleted"} + + +@router.patch("/chat/sessions/{session_id}") +def rename_chat_session(session_id: int, payload: ChatSessionTitlePayload): + load_chat_session(session_id) + row = supabase.table("chat_sessions").update({"title": payload.title}).eq("id", session_id).execute().data[0] + return {"message": "Chat renamed", "session": row} + + +@router.post("/chat/sessions/{session_id}/messages") +def create_chat_message(session_id: int, payload: ChatMessagePayload): + if not payload.content and not payload.metadata: + raise HTTPException(status_code=400, detail="Message content is required") + session = load_chat_session(session_id) + user_message = insert_chat_message(session_id, "user", payload.content, payload.metadata) + update_attachment_message(session_id, attachment_ids(payload.metadata), user_message["id"]) + + def stream(): + answer = [] + tool_results = [] + report_attachment_ids = [] + + def remember_tool_result(name: str, result: dict) -> None: + tool_results.append({"name": name, "result": result}) + attachment = (result or {}).get("attachment") + if attachment and attachment.get("id"): + report_attachment_ids.append(attachment["id"]) + + def flush_tool_results(): + while tool_results: + item = tool_results.pop(0) + result = item["result"] or {} + draft = result.get("draft") + document_draft = result.get("document_draft") + if item["name"] == "create_course_draft" and draft: + yield sse("draft", {"draft": draft}) + if item["name"] == "create_document_draft" and document_draft: + yield sse("document_draft", {"document_draft": document_draft}) + if item["name"] == "update_deterministic_fields" and draft: + yield sse("draft", {"draft": draft}) + if item["name"] == "create_refined_course" and result.get("refined_id"): + yield sse("refined_course", {"refined_id": result["refined_id"], "updated": result.get("updated", False)}) + + def save_assistant_message(): + content = "".join(answer).strip() + if not content: + return None + metadata = {} + if report_attachment_ids: + metadata["attachments"] = [{"id": aid} for aid in report_attachment_ids] + message = insert_chat_message(session_id, "assistant", content, metadata) + if report_attachment_ids: + update_attachment_message(session_id, report_attachment_ids, message["id"]) + return message + + try: + yield sse("status", {"message": "Analyzing your request..."}) + rows = chat_messages(session_id) + system = chat_system_prompt(session) + msgs, _ = model_messages(session_id, rows) + for item in stream_chat(system, msgs, list_tool_schemas(), call_tool, remember_tool_result): + if isinstance(item, dict) and "$status" in item: + yield sse("status", {"message": item["$status"]}) + continue + if isinstance(item, dict) and "$usage" in item: + yield sse("context_usage", item["$usage"]) + continue + if isinstance(item, dict) and "$event" in item: + event = item["$event"] + data = {k: v for k, v in item.items() if k != "$event"} + if event == "tool_call": + 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"}) + elif event == "tool_result": + status_char = "\u2713" if data.get("status") == "ok" else "\u2717" + 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"}) + yield sse(event, data) + continue + yield from flush_tool_results() + answer.append(item) + yield sse("token", {"text": item}) + yield from flush_tool_results() + message = save_assistant_message() + yield sse("done", {"message_id": message["id"] if message else 0}) + except OpenRouterError as exc: + yield from flush_tool_results() + save_assistant_message() + logger.warning( + "Chat model request failed for session %s: status=%s detail=%s", + session_id, + exc.status_code, + exc.provider_message[:300], + ) + err = exc.message + if exc.provider_message: + err = f"{err} ({exc.provider_message[:200]})" + yield sse("error", {"message": err}) + except Exception as exc: + yield from flush_tool_results() + save_assistant_message() + logger.exception("Chat stream failed for session %s", session_id) + sentry_sdk.capture_exception(exc) + yield sse("error", {"message": "An internal error occurred. Please try again later."}) + + return StreamingResponse(stream(), media_type="text/event-stream", headers={"Cache-Control": "no-store"}) + + +@router.post("/chat/sessions/{session_id}/attachments") +async def upload_chat_attachments(session_id: int, files: list[UploadFile] = File(...)): + load_chat_session(session_id) + attachments = [] + for file in files: + data = await file.read() + text, status, error = extract_text(file.filename or "attachment", file.content_type or "", data) + + # Store binary content as base64 for non-text files + content_base64 = "" + if file.content_type and not file.content_type.startswith("text/") and file.content_type != "application/json": + content_base64 = base64.b64encode(data).decode() + + row = ( + supabase.table("chat_attachments") + .insert( + { + "session_id": session_id, + "filename": file.filename or "attachment", + "content_type": file.content_type or "", + "size_bytes": len(data), + "extracted_text": text, + "content_base64": content_base64, + "status": status, + "error": error, + } + ) + .execute() + .data[0] + ) + attachments.append( + { + "id": row["id"], + "name": row["filename"], + "type": row["content_type"], + "size": row["size_bytes"], + "status": row["status"], + "error": row["error"], + "extracted_chars": len(row.get("extracted_text") or ""), + } + ) + return {"attachments": attachments} + + +@router.get("/chat/sessions/{session_id}/attachments/{attachment_id}/download") +def download_chat_attachment(session_id: int, attachment_id: int): + load_chat_session(session_id) + row = supabase.table("chat_attachments").select("*").eq("id", attachment_id).eq("session_id", session_id).execute().data + if not row: + raise HTTPException(status_code=404, detail="Attachment not found") + row = row[0] + + content = row.get("content_base64") + if content: + data = base64.b64decode(content) + else: + data = (row.get("extracted_text") or "").encode() + + return Response( + content=data, + media_type=row.get("content_type") or "application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="{row["filename"]}"', "Cache-Control": "no-store"}, + ) + + +@router.get("/chat/sessions/{session_id}/attachments/{attachment_id}/preview") +def preview_chat_attachment(session_id: int, attachment_id: int): + load_chat_session(session_id) + row = supabase.table("chat_attachments").select("*").eq("id", attachment_id).eq("session_id", session_id).execute().data + if not row: + raise HTTPException(status_code=404, detail="Attachment not found") + row = row[0] + + content_type = row.get("content_type") or "" + text = row.get("extracted_text") or "" + b64 = row.get("content_base64") or "" + filename = row.get("filename") or "" + + if content_type in ("text/markdown", "text/csv", "text/plain", "text/html") or content_type.startswith("text/"): + return Response( + content=text.encode(), + media_type=content_type, + headers={"Content-Disposition": "inline", "Cache-Control": "no-store"}, + ) + + if b64: + data = base64.b64decode(b64) + if filename.lower().endswith(".xlsx") and data[:2] == b"PK": + html = _xlsx_to_html(data) + return Response( + content=html.encode(), + media_type="text/html", + headers={"Content-Disposition": "inline", "Cache-Control": "no-store"}, + ) + return Response( + content=data, + media_type=content_type, + headers={"Content-Disposition": "inline", "Cache-Control": "no-store"}, + ) + + return Response( + content=text.encode(), + media_type="text/plain", + headers={"Content-Disposition": "inline", "Cache-Control": "no-store"}, + ) + + +def _xlsx_to_html(data: bytes) -> str: + import io + from openpyxl import load_workbook + + wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True) + parts = [""] + for ws in wb.worksheets: + rows = list(ws.iter_rows(values_only=True)) + if not rows: + continue + if len(wb.sheetnames) > 1: + parts.append(f"

{ws.title}

") + parts.append("") + for cell in rows[0]: + parts.append(f"") + parts.append("") + for row in rows[1:]: + parts.append("") + for cell in row: + parts.append(f"") + parts.append("") + parts.append("
{_esc(str(cell) if cell is not None else '')}
{_esc(str(cell) if cell is not None else '')}
") + wb.close() + return "\n".join(parts) + + +def _esc(s: str) -> str: + return s.replace("&", "&").replace("<", "<").replace(">", ">") diff --git a/backend/app/routes/courses.py b/backend/app/routes/courses.py new file mode 100644 index 0000000000000000000000000000000000000000..2dd770ddf76c01bfa2e56ecd428079984950c194 --- /dev/null +++ b/backend/app/routes/courses.py @@ -0,0 +1,63 @@ +from fastapi import APIRouter, HTTPException +from postgrest.exceptions import APIError + +from app import cache +from app.preview import build_course_preview +from app.services.curriculum import attach_submissions, invalidate_curriculum_cache +from app.services.errors import database_http_exception +from app.supabase import first_row, supabase + +router = APIRouter() + + +@router.get("/courses") +def list_courses(): + cached = cache.get("courses_list") + if cached is not None: + return {"courses": cached} + try: + rows = supabase.table("refined_submissions").select("*").in_("status", ["refined"]).execute().data + rows = attach_submissions(rows) + except APIError as exc: + raise database_http_exception(exc) from exc + courses = [build_course_preview(row) | {"id": row["id"], "visible": row.get("visible", True)} for row in rows] + courses.sort(key=lambda item: (int(item.get("semester") or 0), item.get("course_code") or "", item.get("course_title") or "")) + cache.put("courses_list", courses, ttl=300) + return {"courses": courses} + + +@router.patch("/courses/{refined_id}/visible") +def set_visibility(refined_id: int, body: dict): + visible = body.get("visible") + if not isinstance(visible, bool): + raise HTTPException(status_code=400, detail="visible must be a boolean") + try: + row = first_row(supabase.table("refined_submissions").select("id").eq("id", refined_id).neq("status", "archived")) + if not row: + raise HTTPException(status_code=404, detail="Course not found") + result = supabase.table("refined_submissions").update({"visible": visible}).eq("id", refined_id).execute() + except APIError as exc: + raise database_http_exception(exc) from exc + cache.invalidate("courses_list") + cache.invalidate("sem_courses:") + cache.invalidate("pending_courses") + cache.invalidate("all_course_ids") + invalidate_curriculum_cache() + return {"message": "Visibility updated", "course": result.data[0] if result.data else None} + + +@router.delete("/courses/{refined_id}") +def delete_course(refined_id: int): + try: + row = first_row(supabase.table("refined_submissions").select("id").eq("id", refined_id).neq("status", "archived")) + if not row: + raise HTTPException(status_code=404, detail="Course not found") + result = supabase.table("refined_submissions").update({"status": "archived"}).eq("id", refined_id).execute() + except APIError as exc: + raise database_http_exception(exc) from exc + cache.invalidate("courses_list") + cache.invalidate("sem_courses:") + cache.invalidate("pending_courses") + cache.invalidate("all_course_ids") + invalidate_curriculum_cache() + return {"message": "Course removed", "course": result.data[0] if result.data else None} diff --git a/backend/app/routes/health.py b/backend/app/routes/health.py new file mode 100644 index 0000000000000000000000000000000000000000..1e49cd1ba1663ba5bb180d0f837948f1deb9e9d1 --- /dev/null +++ b/backend/app/routes/health.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, HTTPException +from postgrest.exceptions import APIError + +from app.services.errors import database_http_exception +from app.services.schema import schema_status + +router = APIRouter() + + +@router.get("/health/schema") +def check_schema(): + try: + status = schema_status() + except APIError as exc: + raise database_http_exception(exc) from exc + if not status["ok"]: + raise HTTPException(status_code=503, detail=status) + return status diff --git a/backend/app/routes/preview.py b/backend/app/routes/preview.py new file mode 100644 index 0000000000000000000000000000000000000000..fa561c9c1ea2fe08a19bce80d27e9c67e208c33e --- /dev/null +++ b/backend/app/routes/preview.py @@ -0,0 +1,235 @@ +import logging +import os +import threading +import time as _time + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import HTMLResponse, Response +from weasyprint import HTML + +from app import cache +from app.preview import build_course_preview, build_specialization_context +from app.rendering import FRONTEND_DIR, course_code_for_year, templates +from app.services.curriculum import attach_submissions, ordered_courses, selected_curriculum_year +from app.supabase import first_row, supabase + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/preview/semester/{sem}/courses") +def list_courses(sem: int, curriculum_year: str | None = Query(None)): + cy = selected_curriculum_year(curriculum_year) + cache_key = f"sem_courses:{sem}:{cy}" + cached = cache.get(cache_key) + if cached is not None: + return cached + 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() + ids = [row["id"] for row in result.data] + 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] + out = {"course_ids": ids, "courses": courses} + cache.put(cache_key, out, ttl=300) + return out + + +@router.get("/preview/pending-courses") +def list_pending_courses(): + cache_key = "pending_courses" + cached = cache.get(cache_key) + if cached is not None: + return {"courses": cached} + result = supabase.table("refined_submissions").select("id,course_code,course_title").eq("status", "draft").eq("visible", True).order("id").execute() + courses = [{"id": row["id"], "course_code": row.get("course_code") or "", "course_title": row.get("course_title") or ""} for row in result.data] + cache.put(cache_key, courses, ttl=300) + return {"courses": courses} + + +@router.get("/preview/courses") +def list_all_courses(): + cache_key = "all_course_ids" + cached = cache.get(cache_key) + if cached is not None: + return {"course_ids": cached} + result = supabase.table("refined_submissions").select("id,semester,course_code").in_("status", ["refined"]).eq("visible", True).execute() + 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))) + ids = [row["id"] for row in rows] + cache.put(cache_key, ids, ttl=300) + return {"course_ids": ids} + + +@router.get("/preview/course/{refined_id}") +def preview_course(refined_id: int, curriculum_year: str | None = Query(None)): + cy = selected_curriculum_year(curriculum_year) + cache_key = f"course_html:{refined_id}:{cy}" + cached = cache.get(cache_key) + if cached is not None: + return HTMLResponse(cached, headers={"Cache-Control": "public, max-age=30, s-maxage=300, stale-while-revalidate=600"}) + try: + row = first_row(supabase.table("refined_submissions").select("*").eq("id", refined_id)) + if not row: + raise HTTPException(status_code=404, detail="Refined submission not found") + row = attach_submissions([row])[0] + html = templates.get_template("jinja_sample.html").render( + course=build_course_preview(row), + curriculum_year=cy, + asset_root="/", + show_thank_you=False, + ) + except HTTPException: + raise + except Exception: + logger.exception("Preview render failed for refined_id=%s", refined_id) + raise HTTPException(status_code=500, detail="Failed to render course preview.") + cache.put(cache_key, html, ttl=600) + return HTMLResponse(html, headers={"Cache-Control": "public, max-age=30, s-maxage=300, stale-while-revalidate=600"}) + + +@router.get("/preview/course/{refined_id}/pdf") +def download_course_pdf(refined_id: int, download: bool = Query(False), curriculum_year: str | None = Query(None)): + cy = selected_curriculum_year(curriculum_year) + cache_key = f"course_pdf:{refined_id}:{cy}" + cached = cache.get(cache_key) + if cached is not None: + return pdf_response(cached, f"course-{refined_id}.pdf", download) + try: + row = first_row(supabase.table("refined_submissions").select("*").eq("id", refined_id)) + if not row: + raise HTTPException(status_code=404, detail="Refined submission not found") + row = attach_submissions([row])[0] + html = templates.get_template("jinja_sample.html").render( + courses=[build_course_preview(row)], + semester="", + curriculum_year=cy, + asset_root="", + show_summaries=True, + show_thank_you=False, + **build_specialization_context(cy), + ) + pdf = HTML(string=html, base_url=str(FRONTEND_DIR)).write_pdf() + except HTTPException: + raise + except Exception: + logger.exception("Course PDF render failed for refined_id=%s", refined_id) + raise HTTPException(status_code=500, detail="Failed to render course PDF.") + cache.put(cache_key, pdf, ttl=600) + return pdf_response(pdf, f"course-{refined_id}.pdf", download) + + +@router.get("/preview/html") +def preview_all_html(curriculum_year: str | None = Query(None)): + cy = selected_curriculum_year(curriculum_year) + cache_key = f"full_html:{cy}" + cached = cache.get(cache_key) + if cached is not None: + return HTMLResponse(cached, headers={"Cache-Control": "public, max-age=30, s-maxage=300, stale-while-revalidate=600"}) + result = supabase.table("refined_submissions").select("*").in_("status", ["refined"]).eq("visible", True).execute() + courses = ordered_courses(result.data) + html = templates.get_template("jinja_sample.html").render( + courses=courses, + semester="", + curriculum_year=cy, + asset_root="/", + show_summaries=True, + **build_specialization_context(cy), + ) + cache.put(cache_key, html, ttl=600) + return HTMLResponse(html, headers={"Cache-Control": "public, max-age=30, s-maxage=300, stale-while-revalidate=600"}) + + +@router.get("/preview/pdf") +def download_all_pdf(download: bool = Query(False), curriculum_year: str | None = Query(None)): + cy = selected_curriculum_year(curriculum_year) + cache_key = f"full_pdf:{cy}" + cached = cache.get(cache_key) + if cached is not None: + return pdf_response(cached, "curriculum-preview.pdf", download, s_maxage=180) + result = supabase.table("refined_submissions").select("*").in_("status", ["refined"]).eq("visible", True).execute() + courses = ordered_courses(result.data) + html = templates.get_template("jinja_sample.html").render( + courses=courses, + semester="", + curriculum_year=cy, + asset_root="", + show_summaries=True, + **build_specialization_context(cy), + ) + pdf = HTML(string=html, base_url=str(FRONTEND_DIR)).write_pdf() + cache.put(cache_key, pdf, ttl=600) + return pdf_response(pdf, "curriculum-preview.pdf", download, s_maxage=180) + + +@router.get("/preview/semester/{sem}/pdf") +def download_pdf(sem: int, download: bool = Query(False), curriculum_year: str | None = Query(None)): + cy = selected_curriculum_year(curriculum_year) + cache_key = f"sem_pdf:{sem}:{cy}" + cached = cache.get(cache_key) + if cached is not None: + return pdf_response(cached, f"semester-{sem}.pdf", download) + result = supabase.table("refined_submissions").select("*").in_("status", ["refined"]).eq("visible", True).eq("semester", sem).order("id").execute() + courses = ordered_courses(result.data) + html = templates.get_template("jinja_sample.html").render( + courses=courses, + semester=sem, + curriculum_year=cy, + asset_root="", + show_summaries=True, + **build_specialization_context(cy), + ) + pdf = HTML(string=html, base_url=str(FRONTEND_DIR)).write_pdf() + cache.put(cache_key, pdf, ttl=600) + return pdf_response(pdf, f"semester-{sem}.pdf", download) + + +def pdf_response(pdf: bytes, filename: str, download: bool, s_maxage: int = 300) -> Response: + disposition = "attachment" if download else "inline" + return Response( + content=pdf, + media_type="application/pdf", + headers={ + "Content-Disposition": f'{disposition}; filename="{filename}"', + "Cache-Control": f"public, max-age=30, s-maxage={s_maxage}, stale-while-revalidate=600", + }, + ) + + +def _generate_pdf(cy: str) -> bytes | None: + try: + result = supabase.table("refined_submissions").select("*").in_("status", ["refined"]).eq("visible", True).execute() + courses = ordered_courses(result.data) + html = templates.get_template("jinja_sample.html").render( + courses=courses, + semester="", + curriculum_year=cy, + asset_root="", + show_summaries=True, + **build_specialization_context(cy), + ) + return HTML(string=html, base_url=str(FRONTEND_DIR)).write_pdf() + except Exception: + logger.exception("PDF preload failed for year=%s", cy) + return None + + +def preload_pdfs() -> None: + """Generate and cache PDFs for all configured curriculum years on startup.""" + years_raw = os.getenv("CURRICULUM_YEAR", "").strip() + years = [y.strip() for y in years_raw.split(",") if y.strip()] + if not years: + logger.info("No CURRICULUM_YEAR set, skipping PDF preload (user sets year from dashboard)") + return + + def _worker(): + for cy in years: + t0 = _time.monotonic() + pdf = _generate_pdf(cy) + if pdf: + existing = cache.get(f"full_pdf:{cy}") + if existing is None: + cache.put(f"full_pdf:{cy}", pdf, ttl=600) + elapsed = (_time.monotonic() - t0) * 1000 + logger.info("PDF preload complete for year=%s in %dms (%d bytes)", cy, elapsed, len(pdf)) + else: + logger.warning("PDF preload skipped for year=%s", cy) + + thread = threading.Thread(target=_worker, daemon=True, name="pdf-preload") + thread.start() diff --git a/backend/app/routes/refined.py b/backend/app/routes/refined.py new file mode 100644 index 0000000000000000000000000000000000000000..2af5efe0dc4c3daf8794becb035493cdb9807a21 --- /dev/null +++ b/backend/app/routes/refined.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, HTTPException + +from app.preview import build_course_preview +from app.services.curriculum import attach_submissions, update_refined_fields +from app.supabase import first_row, supabase + +router = APIRouter() + + +@router.get("/refined/{refined_id}") +def get_refined(refined_id: int): + row = first_row(supabase.table("refined_submissions").select("*").eq("id", refined_id)) + if not row: + raise HTTPException(status_code=404, detail="Refined submission not found") + row = attach_submissions([row])[0] + return {"id": refined_id, "fields": build_course_preview(row)} + + +@router.patch("/refined/{refined_id}") +def update_refined(refined_id: int, payload: dict): + fields = payload.get("fields") + if not isinstance(fields, dict): + raise HTTPException(status_code=400, detail="fields is required") + try: + data = update_refined_fields(refined_id, fields) + row = first_row(supabase.table("refined_submissions").select("status").eq("id", refined_id)) + if row and row.get("status") == "draft": + supabase.table("refined_submissions").update({"status": "refined"}).eq("id", refined_id).execute() + return {"message": "Updated", "data": data} + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) diff --git a/backend/app/routes/submissions.py b/backend/app/routes/submissions.py new file mode 100644 index 0000000000000000000000000000000000000000..9db7efad4ea2e2a2e68aca7381fec902b955b020 --- /dev/null +++ b/backend/app/routes/submissions.py @@ -0,0 +1,60 @@ +import sentry_sdk +from fastapi import APIRouter, BackgroundTasks, HTTPException +from postgrest.exceptions import APIError + +from app.models.submission import CourseSubmission, parse_course_code +from app.services.errors import database_http_exception +from app.services.refinement import refine +from app.supabase import supabase + +router = APIRouter() + + +def refine_later(submission_id: int) -> None: + try: + refine(submission_id) + except Exception as exc: + sentry_sdk.capture_exception(exc) + supabase.table("submissions").update({"status": "refine_failed"}).eq("id", submission_id).execute() + + +@router.post("/submissions") +def receive(data: CourseSubmission, background_tasks: BackgroundTasks): + parsed = parse_course_code(data.course_code) + payload = data.model_dump() + user_credit = (payload.get("credit_category") or "").strip() + if user_credit in ("0", "2", "4", "5"): + credit_cat = user_credit + else: + credit_cat = parsed.credit_category + raw = data.raw_course_content or "" + if "Course Code" not in raw: + raw = f"Course Code: {data.course_code}\n{raw}" + payload.pop("course_code", None) + payload["raw_course_content"] = raw + payload.update({ + "offering_department": parsed.offering_dept, + "target_department": parsed.target_dept, + "semester": int(parsed.semester), + "credit_category": credit_cat, + "status": "pending", + }) + try: + result = supabase.table("submissions").insert(payload).execute() + except APIError as exc: + raise database_http_exception(exc) from exc + submission = result.data[0] + background_tasks.add_task(refine_later, submission["id"]) + return {"message": "Submission Received!", "submission": submission} + + +@router.post("/submissions/{id}/refine") +def refine_submission(id: int): + try: + return {"message": "Refined", "data": refine(id)} + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except APIError as exc: + raise database_http_exception(exc) from exc + except (ValueError, KeyError) as exc: + raise HTTPException(status_code=422, detail=f"Refinement failed: {exc}") from exc diff --git a/backend/app/routes/versions.py b/backend/app/routes/versions.py new file mode 100644 index 0000000000000000000000000000000000000000..fc1ae53c2dd9e1be57cdf17187ae795724582c9a --- /dev/null +++ b/backend/app/routes/versions.py @@ -0,0 +1,425 @@ +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import HTMLResponse +from postgrest.exceptions import APIError + +from app import cache +from app.preview import build_course_preview, build_specialization_context +from app.rendering import templates +from app.services.curriculum import attach_submissions, DEFAULT_CURRICULUM_YEAR, invalidate_curriculum_cache, selected_curriculum_year, update_refined_fields +from app.services.diffing import build_course_diff, diff_course +from app.services.errors import database_http_exception +from app.supabase import first_row, supabase + +router = APIRouter() + + +def _version(version_id: int) -> dict: + row = first_row(supabase.table("curriculum_versions").select("*").eq("id", version_id)) + if not row: + raise HTTPException(status_code=404, detail="Version not found") + return row + + +def _snapshot(version_id: int, refined_id: int) -> dict: + row = first_row( + supabase.table("finalized_submissions") + .select("*") + .eq("curriculum_version_id", version_id) + .eq("refined_id", refined_id) + ) + if not row: + raise HTTPException(status_code=404, detail="Version course not found") + return row + + +def _course_summary(row: dict) -> dict: + course = row.get("course_json") or {} + return { + "id": row["id"], + "refined_id": row["refined_id"], + "semester": course.get("semester") or "", + "course_code": course.get("course_code") or "", + "course_title": course.get("course_title") or "", + } + + +@router.get("/versions") +def list_versions(): + cached = cache.get("versions_list") + if cached is not None: + return {"versions": cached} + try: + rows = supabase.table("curriculum_versions").select("*").order("id", desc=True).execute().data + version_ids = [row["id"] for row in rows] + if version_ids: + fs_rows = ( + supabase.table("finalized_submissions") + .select("curriculum_version_id,refined_id,course_json") + .in_("curriculum_version_id", version_ids) + .execute() + .data + ) + count_map: dict[int, int] = {} + version_courses: dict[int, dict[int, dict]] = {} + for c in fs_rows: + vid = c["curriculum_version_id"] + count_map[vid] = count_map.get(vid, 0) + 1 + version_courses.setdefault(vid, {})[c["refined_id"]] = c.get("course_json") or {} + for row in rows: + row["course_count"] = count_map.get(row["id"], 0) + current_rows = supabase.table("refined_submissions").select("*").in_("status", ["refined"]).execute().data + current_rows = attach_submissions(current_rows) + current_map = {row["id"]: build_course_preview(row) for row in current_rows} + current_rids = set(current_map.keys()) + for row in rows: + vid = row["id"] + v_courses = version_courses.get(vid, {}) + has_changes = False + version_rids = set(v_courses.keys()) + if current_rids - version_rids: + has_changes = True + if not has_changes: + for rid, v_json in v_courses.items(): + c_json = current_map.get(rid, {}) + if v_json != c_json: + has_changes = True + break + row["has_changes"] = has_changes + else: + for row in rows: + row["course_count"] = 0 + row["has_changes"] = False + except APIError as exc: + raise database_http_exception(exc) from exc + cache.put("versions_list", rows, ttl=300) + return {"versions": rows} + + +@router.patch("/versions/{version_id}") +def update_version(version_id: int, payload: dict): + _version(version_id) + updates = {} + if "name" in payload: + new_name = str(payload["name"]).strip() + if new_name: + existing = ( + supabase.table("curriculum_versions") + .select("id") + .eq("name", new_name) + .neq("id", version_id) + .limit(1) + .execute() + .data + ) + if existing: + raise HTTPException(status_code=400, detail="A version with this name already exists") + updates["name"] = new_name + if "academic_year" in payload: + updates["academic_year"] = str(payload["academic_year"]).strip() + if "status" in payload: + updates["status"] = str(payload["status"]).strip() + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + try: + row = supabase.table("curriculum_versions").update(updates).eq("id", version_id).execute().data[0] + except APIError as exc: + raise database_http_exception(exc) from exc + cache.invalidate("versions_list") + return {"version": row} + + +@router.post("/versions") +def create_version(payload: dict): + name = str(payload.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="Version name is required") + + try: + existing = ( + supabase.table("curriculum_versions") + .select("id") + .eq("name", name) + .limit(1) + .execute() + .data + ) + if existing: + raise HTTPException(status_code=400, detail="A version with this name already exists") + + rows = supabase.table("refined_submissions").select("*").in_("status", ["refined"]).execute().data + rows = attach_submissions(rows) + courses = [{"refined_id": row["id"], "course_json": build_course_preview(row)} for row in rows] + + latest = ( + supabase.table("curriculum_versions") + .select("id") + .order("id", desc=True) + .limit(1) + .execute() + .data + ) + if latest: + prev_fs = ( + supabase.table("finalized_submissions") + .select("refined_id,course_json") + .eq("curriculum_version_id", latest[0]["id"]) + .execute() + .data + ) + prev_map = {c["refined_id"]: c.get("course_json") or {} for c in prev_fs} + if prev_map and all( + prev_map.get(c["refined_id"]) == c["course_json"] for c in courses + ): + raise HTTPException(status_code=409, detail="No changes since last version") + + version = ( + supabase.table("curriculum_versions") + .insert( + { + "name": name, + "program": str(payload.get("program") or (courses[0]["course_json"].get("program") if courses else "") or "").strip(), + "academic_year": str(payload.get("academic_year") or DEFAULT_CURRICULUM_YEAR).strip(), + "status": str(payload.get("status") or "draft").strip(), + } + ) + .execute() + .data[0] + ) + if courses: + records = [{**course, "curriculum_version_id": version["id"]} for course in courses] + supabase.table("finalized_submissions").insert(records).execute() + except APIError as exc: + raise database_http_exception(exc) from exc + cache.invalidate("versions_list") + return {"version": version, "courses": len(courses)} + + +@router.post("/versions/{version_id}/restore") +def restore_version(version_id: int): + try: + version = _version(version_id) + rows = ( + supabase.table("finalized_submissions") + .select("*") + .eq("curriculum_version_id", version_id) + .execute() + .data + ) + if not rows: + raise HTTPException(status_code=400, detail="This version has no saved courses. Restore aborted.") + version_refined_ids = [row["refined_id"] for row in rows] + current_rows = supabase.table("refined_submissions").select("*").execute().data + current = {row["id"]: build_course_preview(row) for row in attach_submissions(current_rows)} + current_status = {row["id"]: row.get("status") for row in current_rows} + extra_ids = [row["id"] for row in current_rows if row["id"] not in version_refined_ids] + history = [] + restored = 0 + for row in rows: + refined_id = int(row["refined_id"]) + previous = current.get(refined_id) + restored_course = row["course_json"] + if not previous: + continue + if previous != restored_course: + summary = diff_course(previous, restored_course) + history.append( + { + "refined_id": refined_id, + "source": "version_restore", + "previous_json": previous, + "next_json": restored_course, + "json_patch": summary.pop("json_patch"), + "diff_summary": summary, + "change_reason": f"Restore version: {version['name']}", + } + ) + if previous != restored_course or current_status.get(refined_id) != "refined": + update_refined_fields(refined_id, {**restored_course, "status": "refined"}) + restored += 1 + if history: + supabase.table("course_revision_history").insert(history).execute() + if extra_ids: + supabase.table("refined_submissions").update({"status": "archived"}).in_("id", extra_ids).execute() + supabase.table("agent_drafts").update({"status": "proposed"}).eq("status", "applied").execute() + supabase.table("agent_document_drafts").update({"status": "proposed"}).eq("status", "applied").execute() + invalidate_curriculum_cache() + except APIError as exc: + raise database_http_exception(exc) from exc + cache.invalidate("ver_preview:") + cache.invalidate("versions_list") + return {"message": "Version restored", "version": version, "courses_restored": restored, "courses_archived": len(extra_ids)} + + +@router.get("/versions/{version_id}") +def get_version(version_id: int): + try: + version = _version(version_id) + rows = ( + supabase.table("finalized_submissions") + .select("*") + .eq("curriculum_version_id", version_id) + .execute() + .data + ) + except APIError as exc: + raise database_http_exception(exc) from exc + courses = [_course_summary(row) for row in rows] + courses.sort( + key=lambda course: ( + int(course.get("semester") or 0), + str(course.get("course_code") or ""), + str(course.get("course_title") or ""), + ) + ) + return {"version": version, "courses": courses} + + +@router.get("/versions/{version_id}/courses/{refined_id}") +def get_version_course(version_id: int, refined_id: int): + try: + version = _version(version_id) + snapshot = _snapshot(version_id, refined_id) + except APIError as exc: + raise database_http_exception(exc) from exc + return {"version": version, "refined_id": refined_id, "fields": snapshot["course_json"]} + + +@router.get("/versions/{version_id}/courses/{refined_id}/preview") +def preview_version_course(version_id: int, refined_id: int, curriculum_year: str | None = Query(None)): + try: + snapshot = _snapshot(version_id, refined_id) + except APIError as exc: + raise database_http_exception(exc) from exc + _version(version_id) + 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) + return HTMLResponse(html, headers={"Cache-Control": "no-store"}) + + +@router.get("/versions/{version_id}/preview") +def preview_version(version_id: int, diff: bool = Query(False), curriculum_year: str | None = Query(None)): + try: + _version(version_id) + rows = ( + supabase.table("finalized_submissions") + .select("*") + .eq("curriculum_version_id", version_id) + .order("refined_id") + .execute() + .data + ) + except APIError as exc: + raise database_http_exception(exc) from exc + + version_courses = sorted( + ({"refined_id": row["refined_id"], **row["course_json"]} for row in rows), + key=lambda course: (int(course.get("semester") or 0), str(course.get("course_code") or ""), str(course.get("course_title") or "")), + ) + + if not diff: + cache_key = f"ver_preview:{version_id}:clean:{selected_curriculum_year(curriculum_year)}" + cached = cache.get(cache_key) + if cached: + return HTMLResponse(cached, headers={"Cache-Control": "public, max-age=30"}) + html = templates.get_template("jinja_sample.html").render( + courses=version_courses, + semester="", + curriculum_year=selected_curriculum_year(curriculum_year), + asset_root="/", + show_summaries=True, + **build_specialization_context(selected_curriculum_year(curriculum_year)), + ) + cache.put(cache_key, html, ttl=120) + return HTMLResponse(html, headers={"Cache-Control": "public, max-age=30"}) + + # Diff mode: compare version against current refined submissions + cache_key = f"ver_preview:{version_id}:diff:{selected_curriculum_year(curriculum_year)}" + cached = cache.get(cache_key) + if cached: + return HTMLResponse(cached, headers={"Cache-Control": "public, max-age=30"}) + + refined_ids = [v["refined_id"] for v in version_courses if "refined_id" in v] + current_rows = supabase.table("refined_submissions").select("*").in_("id", refined_ids).execute().data if refined_ids else [] + current_rows = attach_submissions(current_rows) + current = {row["id"]: build_course_preview(row) for row in current_rows} + + course_diffs = [] + for v_course in version_courses: + refined_id = v_course.get("refined_id") + current_course = current.get(refined_id) + if current_course: + base = dict(current_course) + proposed = dict(v_course) + if base == proposed: + continue + course_diff = build_course_diff(base, proposed) + if any(v is not None for v in course_diff.values()): + course_diffs.append({"base": base, "proposed": proposed, "course_diff": course_diff}) + + html = templates.get_template("jinja_diff.html").render( + course_diffs=course_diffs, + curriculum_year=selected_curriculum_year(curriculum_year), + asset_root="/", + ) + cache.put(cache_key, html, ttl=120) + return HTMLResponse(html, headers={"Cache-Control": "public, max-age=30"}) + + +@router.delete("/versions/{version_id}") +def delete_version(version_id: int): + _version(version_id) + try: + supabase.table("finalized_submissions").delete().eq("curriculum_version_id", version_id).execute() + supabase.table("curriculum_versions").delete().eq("id", version_id).execute() + except APIError as exc: + raise database_http_exception(exc) from exc + cache.invalidate("versions_list") + cache.invalidate("ver_preview:") + return {"message": "Version deleted"} + + +@router.get("/versions/{version_id1}/diff/{version_id2}") +def diff_two_versions(version_id1: int, version_id2: int, curriculum_year: str | None = Query(None)): + cy = selected_curriculum_year(curriculum_year) + cache_key = f"ver_diff:{version_id1}:{version_id2}:{cy}" + cached = cache.get(cache_key) + if cached: + return HTMLResponse(cached, headers={"Cache-Control": "public, max-age=30"}) + try: + fs1 = ( + supabase.table("finalized_submissions") + .select("refined_id,course_json") + .eq("curriculum_version_id", version_id1) + .execute() + .data + ) + fs2 = ( + supabase.table("finalized_submissions") + .select("refined_id,course_json") + .eq("curriculum_version_id", version_id2) + .execute() + .data + ) + except APIError as exc: + raise database_http_exception(exc) from exc + + map1 = {row["refined_id"]: row["course_json"] for row in fs1} + map2 = {row["refined_id"]: row["course_json"] for row in fs2} + all_ids = sorted(set(map1.keys()) | set(map2.keys())) + + course_diffs = [] + for rid in all_ids: + base = map1.get(rid) or {} + proposed = map2.get(rid) or {} + if base == proposed: + continue + course_diff = build_course_diff(base, proposed) + if any(v is not None for v in course_diff.values()): + course_diffs.append({"base": base, "proposed": proposed, "course_diff": course_diff}) + + html = templates.get_template("jinja_diff.html").render( + course_diffs=course_diffs, + curriculum_year=cy, + asset_root="/", + ) + cache.put(cache_key, html, ttl=120) + return HTMLResponse(html, headers={"Cache-Control": "public, max-age=30"}) diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py new file mode 100644 index 0000000000000000000000000000000000000000..86b7e79217cc33472114fd03267ae09f5f9f9032 --- /dev/null +++ b/backend/app/services/agent_tools.py @@ -0,0 +1,1695 @@ +from collections.abc import Callable +from dataclasses import dataclass +import base64 +import json +import re +from urllib.parse import quote_plus + +import httpx + +from app.services.curriculum import ( + REFINED_FIELDS, + create_version_snapshot, + draft_record, + invalidate_curriculum_cache, + load_agent_draft, + load_document_draft, + ordered_courses, + refined_course, +) +from app.services.diffing import diff_course +from app.supabase import first_row, supabase + + +def _markdown_to_html(md: str) -> str: + """Convert basic markdown to HTML for PDF generation.""" + html = md + # Headers + html = re.sub(r"^### (.+)$", r"

\1

", html, flags=re.MULTILINE) + html = re.sub(r"^## (.+)$", r"

\1

", html, flags=re.MULTILINE) + html = re.sub(r"^# (.+)$", r"

\1

", html, flags=re.MULTILINE) + # Bold + html = re.sub(r"\*\*(.+?)\*\*", r"\1", html) + # Italic + html = re.sub(r"\*(.+?)\*", r"\1", html) + # Code inline + html = re.sub(r"`(.+?)`", r"\1", html) + # Code blocks + html = re.sub( + r"```(\w+)?\n(.+?)```", + r'
\2
', + html, + flags=re.DOTALL, + ) + # Links + html = re.sub(r"\[(.+?)\]\((.+?)\)", r'\1', html) + # Unordered lists + html = re.sub(r"^\- (.+)$", r"
  • \1
  • ", html, flags=re.MULTILINE) + html = re.sub(r"(
  • .*?
  • \n)+", r"", html) + # Tables (basic) + # Split lines + lines = html.split("\n") + in_table = False + result = [] + for line in lines: + if line.strip().startswith("|") and "|" in line[1:]: + if not in_table: + result.append("") + in_table = True + cells = [c.strip() for c in line.strip("|").split("|")] + tag = "th" if not in_table or not result[-1].startswith("
    ") else "td" + if len(result) == 1 and result[-1] == "
    ": + tag = "th" + result.append( + "" + "".join(f"<{tag}>{c}" for c in cells) + "" + ) + else: + if in_table: + result.append("
    ") + in_table = False + result.append(line) + if in_table: + result.append("") + html = "\n".join(result) + # Paragraphs + lines = html.split("\n") + result = [] + in_p = False + for line in lines: + if line.strip() and not line.startswith("<") and not line.startswith("") + in_p = True + result.append(line) + else: + if in_p: + result.append("

    ") + in_p = False + result.append(line) + if in_p: + result.append("

    ") + return "\n".join(result) + + +ToolHandler = Callable[[dict], dict] + + +@dataclass(frozen=True) +class AgentTool: + name: str + description: str + parameters: dict + handler: ToolHandler + + def schema(self) -> dict: + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": self.parameters, + }, + } + + +def list_tool_schemas() -> list[dict]: + return [tool.schema() for tool in TOOLS.values()] + + +def call_tool(name: str, arguments: dict | None = None) -> dict: + tool = TOOLS.get(name) + if not tool: + raise LookupError("Agent tool not found") + return tool.handler(arguments or {}) + + +def _require_int(arguments: dict, key: str) -> int: + value = arguments.get(key) + if value is None: + raise ValueError(f"{key} is required") + return int(value) + + +def _require_dict(arguments: dict, key: str) -> dict: + value = arguments.get(key) + if not isinstance(value, dict): + raise ValueError(f"{key} must be an object") + return value + + +def _get_current_course(arguments: dict) -> dict: + return {"course": refined_course(_require_int(arguments, "refined_id"))} + + +def _get_course_fields(arguments: dict) -> dict: + """Get specific field groups from a course. More efficient than full course JSON.""" + refined_id = _require_int(arguments, "refined_id") + fields = arguments.get("fields") + if not isinstance(fields, list) or not fields: + raise ValueError("fields must be a non-empty array of field names") + + course = refined_course(refined_id) + result = {"refined_id": refined_id} + for field in fields: + if field in course: + result[field] = course[field] + else: + result[field] = None + return result + + +def _get_course_codes(arguments: dict) -> dict: + """Get lightweight course identifiers: refined_id, course_code, course_title, semester.""" + refined_id = _require_int(arguments, "refined_id") + course = refined_course(refined_id) + return { + "refined_id": refined_id, + "course_code": course.get("course_code"), + "course_title": course.get("course_title"), + "semester": course.get("semester"), + "program": course.get("program"), + } + + +def _get_course_syllabus(arguments: dict) -> dict: + """Get syllabus content: units, objectives, course_outcomes.""" + refined_id = _require_int(arguments, "refined_id") + course = refined_course(refined_id) + return { + "refined_id": refined_id, + "units": course.get("units"), + "objectives": course.get("objectives"), + "course_outcomes": course.get("course_outcomes"), + } + + +def _get_course_textbooks(arguments: dict) -> dict: + """Get textbook fields: text_books, reference_books.""" + refined_id = _require_int(arguments, "refined_id") + course = refined_course(refined_id) + return { + "refined_id": refined_id, + "text_books": course.get("text_books"), + "reference_books": course.get("reference_books"), + } + + +def _get_course_deterministic(arguments: dict) -> dict: + """Get deterministic fields (program, hours, credits, course_type) — these are agent-protected.""" + refined_id = _require_int(arguments, "refined_id") + course = refined_course(refined_id) + return { + "refined_id": refined_id, + "program": course.get("program"), + "lecture_hours": course.get("lecture_hours"), + "tutorial_hours": course.get("tutorial_hours"), + "practical_hours": course.get("practical_hours"), + "self_study": course.get("self_study"), + "credits": course.get("credits"), + "course_type": course.get("course_type"), + } + + +def _get_course_lab(arguments: dict) -> dict: + """Get lab experiments and tools/languages.""" + refined_id = _require_int(arguments, "refined_id") + course = refined_course(refined_id) + return { + "refined_id": refined_id, + "lab_experiments": course.get("lab_experiments"), + "tools_languages": course.get("tools_languages"), + } + + +def _diff_course_json(arguments: dict) -> dict: + return diff_course( + _require_dict(arguments, "current"), _require_dict(arguments, "proposed") + ) + + +def _create_course_draft(arguments: dict) -> dict: + refined_id = _require_int(arguments, "refined_id") + if refined_id <= 0: + raise ValueError( + "refined_id must be a valid existing course ID. To create a brand-new course, use create_refined_course instead." + ) + fields = arguments.get("fields") + if not isinstance(fields, dict) or not fields: + raise ValueError( + '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.' + ) + for key in _ARRAY_FIELDS: + if key in fields: + fields[key] = _coerce_array(fields[key]) + row = first_row(supabase.table("refined_submissions").select("status").eq("id", refined_id)) + if not row: + raise ValueError(f"Course with refined_id {refined_id} not found.") + if row.get("status") == "draft": + raise ValueError(f"Course {refined_id} has status 'draft'. Use create_refined_course with the refined_id to update draft-status courses directly.") + record = draft_record(refined_id, fields, str(arguments.get("reason") or "")) + draft = supabase.table("agent_drafts").insert(record).execute().data[0] + return {"draft": draft} + + +def _update_agent_draft(arguments: dict) -> dict: + draft_id = _require_int(arguments, "draft_id") + fields = arguments.get("fields") + if not isinstance(fields, dict) or not fields: + raise ValueError( + 'fields must be a non-empty object containing only the fields to change, e.g. {"text_books": "new value"}.' + ) + for key in _ARRAY_FIELDS: + if key in fields: + fields[key] = _coerce_array(fields[key]) + row = first_row(supabase.table("agent_drafts").select("refined_id,proposed_json,status").eq("id", draft_id)) + if not row: + raise ValueError(f"Draft with id {draft_id} not found.") + if row.get("status") not in ("proposed", "blocked"): + raise ValueError(f"Draft {draft_id} has status '{row.get('status')}' and cannot be edited. Only proposed or blocked drafts can be updated.") + from app.services.curriculum import merge_fields, diff_course, validate_draft + proposed = merge_fields(row["proposed_json"], fields) + base = first_row(supabase.table("refined_submissions").select("*").eq("id", int(row["refined_id"]))) + base = base or {} + summary = diff_course(base, proposed) + issues = validate_draft(base, proposed) + summary["validation_issues"] = issues + update = { + "proposed_json": proposed, + "json_patch": summary.pop("json_patch"), + "diff_summary": summary, + "status": "blocked" if issues else "proposed", + } + result = supabase.table("agent_drafts").update(update).eq("id", draft_id).execute() + return {"draft": result.data[0] if result.data else row} + + +_ARRAY_FIELDS = { + "course_outcomes", + "lab_experiments", + "objectives", + "text_books", + "reference_books", + "units", +} + + +def _coerce_array(value): + if isinstance(value, list): + return value + if isinstance(value, str): + stripped = value.strip() + if stripped.startswith("["): + try: + parsed = json.loads(stripped) + if isinstance(parsed, list): + return parsed + except json.JSONDecodeError: + pass + lines = [line.strip() for line in stripped.splitlines()] + cleaned = [] + for line in lines: + for prefix in ("- ", "• ", "* "): + if line.startswith(prefix): + line = line[len(prefix):] + break + line = line.strip() + if line: + cleaned.append(line) + if len(cleaned) > 1: + return cleaned + return [stripped] if stripped else [] + return [value] if value is not None else [] + + +def _create_refined_course(arguments: dict) -> dict: + from app.services.deterministic import ( + compute_course_type, + compute_hours, + compute_program, + ) + + credit_category = str(arguments.get("credit_category") or "4") + target_dept = str(arguments.get("target_department") or "CSE") + det = compute_hours(credit_category) + + is_elective = bool(arguments.get("is_elective")) + computed = { + "program": compute_program(target_dept), + "course_type": "Elective" if is_elective else compute_course_type(credit_category), + "status": "draft", + } + for key in ( + "lecture_hours", + "tutorial_hours", + "practical_hours", + "self_study", + "credits", + ): + if key in arguments: + computed[key] = int(arguments[key] or 0) + else: + computed[key] = det[key] + if "semester" in arguments: + computed["semester"] = int(arguments["semester"] or 0) + + fields = { + k: v for k, v in arguments.items() if v is not None and k in REFINED_FIELDS + } + for key in _ARRAY_FIELDS: + if key in fields: + fields[key] = _coerce_array(fields[key]) + dk = str(fields.get("desirable_knowledge") or "").strip() + if dk and dk.lower() in ("none", "n/a", "na", "-"): + fields["desirable_knowledge"] = "" + if "is_elective" in arguments: + fields["is_elective"] = bool(arguments["is_elective"]) + fields.update(computed) + + refined_id = arguments.get("refined_id") + if not refined_id and "course_code" in fields: + dup = ( + supabase.table("refined_submissions") + .select("id") + .eq("course_code", fields["course_code"]) + .limit(1) + .execute() + .data + ) + if dup: + return {"error": f"Course code {fields['course_code']} already exists (refined_id: {dup[0]['id']}). Pick a unique code."} + + if refined_id: + result = ( + supabase.table("refined_submissions") + .update(fields) + .eq("id", int(refined_id)) + .execute() + ) + row = result.data[0] if result.data else None + invalidate_curriculum_cache() + return {"refined_id": int(refined_id), "updated": True, "course": row} + + if "submission_id" not in fields or not fields.get("submission_id"): + placeholder = ( + supabase.table("submissions") + .insert( + { + "faculty_email": "ai-generated@pes.edu", + "course_title": fields.get("course_title") or "", + "offering_department": "CS", + "target_department": arguments.get("target_department") or "CSE", + "semester": int(arguments.get("semester") or 1), + "credit_category": arguments.get("credit_category") or "4", + "raw_course_content": fields.get("prelude") or "AI-created course", + "text_books": "", + "reference_books": "", + "preferred_tools": "", + "status": "refined", + } + ) + .execute() + .data[0] + ) + fields["submission_id"] = placeholder["id"] + + result = supabase.table("refined_submissions").insert(fields).execute() + row = result.data[0] + invalidate_curriculum_cache() + return {"refined_id": row["id"], "updated": False, "course": row} + + +def _get_curriculum_json(arguments: dict) -> dict: + query = supabase.table("refined_submissions").select("*").in_("status", ["refined"]) + if arguments.get("semester") is not None: + query = query.eq("semester", int(arguments["semester"])) + return {"courses": ordered_courses(query.execute().data)} + + +def _create_document_draft(arguments: dict) -> dict: + courses = arguments.get("courses") + if not isinstance(courses, list) or not courses: + raise ValueError("courses must be a non-empty array") + + records = [] + for course in courses: + if not isinstance(course, dict): + raise ValueError("each course must be an object") + fields = _require_dict(course, "fields") + for key in _ARRAY_FIELDS: + if key in fields: + fields[key] = _coerce_array(fields[key]) + records.append( + draft_record( + int(course.get("refined_id")), + fields, + str(arguments.get("reason") or ""), + ) + ) + + summaries = [record["diff_summary"] for record in records] + document_summary = { + "courses_changed": len(records), + "courses_with_removed_topics": sum( + 1 for summary in summaries if summary.get("topics_removed") + ), + "courses_with_protected_changes": sum( + 1 for summary in summaries if summary.get("protected_changes") + ), + "max_syllabus_change_percent": max( + (summary.get("syllabus_change_percent") or 0 for summary in summaries), + default=0, + ), + } + document = ( + supabase.table("agent_document_drafts") + .insert( + { + "curriculum_version_id": arguments.get("curriculum_version_id"), + "uploaded_document_id": str( + arguments.get("uploaded_document_id") or "" + ).strip(), + "diff_summary": document_summary, + "change_reason": str(arguments.get("reason") or "").strip(), + "status": "blocked" + if document_summary["courses_with_protected_changes"] + else "proposed", + } + ) + .execute() + .data[0] + ) + for record in records: + record["document_draft_id"] = document["id"] + drafts = supabase.table("agent_drafts").insert(records).execute().data + return {"document_draft": document, "drafts": drafts} + + +def _get_course_draft(arguments: dict) -> dict: + return {"draft": load_agent_draft(_require_int(arguments, "draft_id"))} + + +def _get_document_draft(arguments: dict) -> dict: + return load_document_draft(_require_int(arguments, "document_draft_id")) + + +def _get_preview_url(arguments: dict) -> dict: + kind = str(arguments.get("kind") or "") + item_id = _require_int(arguments, "id") + paths = { + "course": f"/api/preview/course/{item_id}", + "draft": f"/api/agent/drafts/{item_id}/preview", + "document_draft": f"/api/agent/document-drafts/{item_id}/preview", + } + if kind not in paths: + raise ValueError("kind must be course, draft, or document_draft") + return {"url": paths[kind]} + + +def _list_courses(arguments: dict) -> dict: + query = ( + supabase.table("refined_submissions") + .select("id,semester,course_code,course_title") + .neq("status", "archived") + ) + if arguments.get("semester") is not None: + query = query.eq("semester", int(arguments["semester"])) + rows = query.execute().data + rows.sort( + key=lambda row: ( + int(row.get("semester") or 0), + str(row.get("course_code") or ""), + int(row.get("id") or 0), + ) + ) + return {"courses": rows} + + +def _fetch_url(arguments: dict) -> dict: + url = str(arguments.get("url") or "").strip() + if not url: + raise ValueError("url is required") + resp = httpx.get(url, timeout=30, follow_redirects=True) + resp.raise_for_status() + text = resp.text[:15000] + return {"url": url, "text": text, "chars": len(text)} + + +def _web_search(arguments: dict) -> dict: + """Search the web using DuckDuckGo's HTML endpoint and return top results.""" + query = str(arguments.get("query") or "").strip() + if not query: + raise ValueError("query is required") + num_results = int(arguments.get("num_results") or 5) + num_results = min(max(num_results, 1), 10) + + url = f"https://html.duckduckgo.com/html/?q={quote_plus(query)}" + resp = httpx.get( + url, timeout=15, follow_redirects=True, headers={"User-Agent": "Mozilla/5.0"} + ) + resp.raise_for_status() + + # Parse results from DuckDuckGo HTML + html = resp.text + results = [] + # Pattern for result snippets + result_pattern = re.compile(r'class="result__snippet">(.*?)', re.DOTALL) + title_pattern = re.compile(r'class="result__title">.*?>(.*?)', re.DOTALL) + url_pattern = re.compile(r'class="result__url">.*?>(.*?)', re.DOTALL) + + snippets = result_pattern.findall(html) + titles = title_pattern.findall(html) + urls = url_pattern.findall(html) + + for i in range(min(num_results, len(snippets))): + title = re.sub(r"<[^>]+>", "", titles[i] if i < len(titles) else "").strip() + snippet = re.sub(r"<[^>]+>", "", snippets[i]).strip() + link = re.sub(r"<[^>]+>", "", urls[i] if i < len(urls) else "").strip() + if title or snippet: + results.append({"title": title, "snippet": snippet[:300], "url": link}) + + return {"query": query, "results": results} + + +def _create_report(arguments: dict) -> dict: + session_id = _require_int(arguments, "session_id") + content = str(arguments.get("content") or "").strip() + if not content: + raise ValueError("content is required") + filename = str(arguments.get("filename") or "report.md").strip() + fmt = str(arguments.get("format") or "markdown").strip().lower() + if fmt not in ("markdown", "pdf"): + raise ValueError("format must be 'markdown' or 'pdf'") + + if fmt == "pdf": + # Convert markdown to HTML then to PDF + # Simple markdown to HTML conversion + html_content = _markdown_to_html(content) + html_full = f""" + + + + + + +{html_content} + +""" + + # Use weasyprint to generate PDF + from weasyprint import HTML + + pdf_bytes = HTML(string=html_full, base_url=".").write_pdf() + + if filename.endswith(".md"): + filename = filename[:-3] + ".pdf" + elif not filename.endswith(".pdf"): + filename = filename + ".pdf" + + content_type = "application/pdf" + size_bytes = len(pdf_bytes) + extracted_text = f"[PDF file - {size_bytes} bytes]" + content_base64 = base64.b64encode(pdf_bytes).decode() + else: + pdf_bytes = None + content_type = "text/markdown" + size_bytes = len(content.encode()) + extracted_text = content + content_base64 = "" + + row = ( + supabase.table("chat_attachments") + .insert( + { + "session_id": session_id, + "filename": filename, + "content_type": content_type, + "size_bytes": size_bytes, + "extracted_text": extracted_text, + "content_base64": content_base64, + "status": "ready", + } + ) + .execute() + .data[0] + ) + + return { + "attachment": { + "id": row["id"], + "filename": row["filename"], + "chars": size_bytes, + "format": fmt, + } + } + + +def _attachment_text(arguments: dict) -> dict: + session_id = _require_int(arguments, "session_id") + ids = [int(value) for value in arguments.get("attachment_ids") or []] + if not ids: + raise ValueError("attachment_ids is required") + rows = ( + supabase.table("chat_attachments") + .select("id,filename,status,error,extracted_text") + .eq("session_id", session_id) + .in_("id", ids) + .execute() + .data + ) + return {"attachments": rows} + + +def _create_curriculum_version(arguments: dict) -> dict: + name = str(arguments.get("name") or "").strip() + if not name: + raise ValueError("name is required") + version = create_version_snapshot(name) + return {"version": version} + + +def _define_specialization(arguments: dict) -> dict: + semester = _require_int(arguments, "semester") + name = str(arguments.get("name") or "").strip() + if not name: + raise ValueError("name is required") + letter = str(arguments.get("letter") or "").strip().upper() + if not letter: + existing = ( + supabase.table("specialization_definitions") + .select("letter") + .eq("semester", semester) + .execute() + .data + ) + used = {row["letter"] for row in existing} + for candidate in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": + if candidate not in used: + letter = candidate + break + if not letter: + raise ValueError( + "No free specialization letter available for this semester" + ) + academic_year = str(arguments.get("academic_year") or "").strip() + key = str(arguments.get("key") or "").strip().upper() + if not key: + key = name.split("(")[-1].rstrip(")") if "(" in name else name[:3].upper() + row = ( + supabase.table("specialization_definitions") + .insert( + { + "semester": semester, + "letter": letter, + "name": name, + "key": key, + "academic_year": academic_year, + } + ) + .execute() + .data[0] + ) + return {"specialization": row} + + +def _list_specializations(arguments: dict) -> dict: + query = supabase.table("specialization_definitions").select("*") + if arguments.get("semester") is not None: + query = query.eq("semester", int(arguments["semester"])) + return {"specializations": query.order("semester").order("letter").execute().data} + + +def _assign_elective_to_tracks(arguments: dict) -> dict: + refined_id = _require_int(arguments, "refined_id") + spec_ids = arguments.get("specialization_ids") + if not isinstance(spec_ids, list) or not spec_ids: + raise ValueError("specialization_ids must be a non-empty array") + created = 0 + for spec_id in spec_ids: + existing = ( + supabase.table("course_specialization_assignments") + .select("id") + .eq("refined_id", refined_id) + .eq("specialization_id", int(spec_id)) + .execute() + .data + ) + if not existing: + supabase.table("course_specialization_assignments").insert( + {"refined_id": refined_id, "specialization_id": int(spec_id)} + ).execute() + created += 1 + supabase.table("refined_submissions").update({"is_elective": True}).eq( + "id", refined_id + ).execute() + return {"assignments_created": created} + + +def _remove_elective_from_tracks(arguments: dict) -> dict: + refined_id = _require_int(arguments, "refined_id") + spec_ids = arguments.get("specialization_ids") + if not isinstance(spec_ids, list) or not spec_ids: + raise ValueError("specialization_ids must be a non-empty array") + removed = 0 + for spec_id in spec_ids: + result = ( + supabase.table("course_specialization_assignments") + .delete() + .eq("refined_id", refined_id) + .eq("specialization_id", int(spec_id)) + .execute() + ) + removed += len(result.data or []) + return {"assignments_removed": removed} + + +def _get_course_assignments(arguments: dict) -> dict: + refined_id = _require_int(arguments, "refined_id") + assignments = ( + supabase.table("course_specialization_assignments") + .select("*, specialization_definitions(*)") + .eq("refined_id", refined_id) + .execute() + .data + ) + return {"refined_id": refined_id, "assignments": assignments} + + +def _categorize_elective(arguments: dict) -> dict: + """Re-run guarded AI categorization for an elective needing human review.""" + from app.services.elective_categorization import categorize_refined_elective + + return categorize_refined_elective(_require_int(arguments, "refined_id")) + + +def _update_deterministic_fields(arguments: dict) -> dict: + from app.services.diffing import PROTECTED_FIELDS + + refined_id = _require_int(arguments, "refined_id") + fields = _require_dict(arguments, "fields") + protected = {key: value for key, value in fields.items() if key in PROTECTED_FIELDS} + if not protected: + raise ValueError( + "No deterministic fields provided. Use create_course_draft for other fields." + ) + record = draft_record( + refined_id, + protected, + str( + arguments.get("reason") + or "Explicit user request to change deterministic fields" + ), + ) + record["status"] = "blocked" + draft = supabase.table("agent_drafts").insert(record).execute().data[0] + return { + "draft": draft, + "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.", + } + + +def _signal_done(arguments: dict) -> dict: + summary = str(arguments.get("summary") or "").strip() + if not summary: + raise ValueError("summary is required") + return {"done": True, "summary": summary} + + +def _ask_user(arguments: dict) -> dict: + question = str(arguments.get("question") or "").strip() + if not question: + raise ValueError("question is required") + return {"asked": True, "question": question, "done": True, "summary": question} + + +def _create_spreadsheet(arguments: dict) -> dict: + """Generate CSV or Excel file from rows data and save as chat attachment.""" + session_id = _require_int(arguments, "session_id") + rows = arguments.get("rows") + if not isinstance(rows, list) or not rows: + raise ValueError("rows must be a non-empty array of objects") + columns = arguments.get("columns") + if not isinstance(columns, list) or not columns: + raise ValueError("columns must be a non-empty array of column names") + filename = str(arguments.get("filename") or "spreadsheet.csv").strip() + fmt = str(arguments.get("format") or "csv").strip().lower() + if fmt not in ("csv", "xlsx"): + raise ValueError("format must be 'csv' or 'xlsx'") + + if fmt == "xlsx": + import io + from openpyxl import Workbook + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + + wb = Workbook() + ws = wb.active + ws.title = "Data" + + header_font = Font(bold=True, color="FFFFFF", size=11) + header_fill = PatternFill( + start_color="00377B", end_color="00377B", fill_type="solid" + ) + thin_border = Border( + left=Side(style="thin"), + right=Side(style="thin"), + top=Side(style="thin"), + bottom=Side(style="thin"), + ) + + for col_idx, col_name in enumerate(columns, 1): + cell = ws.cell(row=1, column=col_idx, value=col_name) + cell.font = header_font + cell.fill = header_fill + cell.alignment = Alignment(horizontal="center") + cell.border = thin_border + + for row_idx, row_data in enumerate(rows, 2): + for col_idx, col_name in enumerate(columns, 1): + value = row_data.get(col_name, "") + cell = ws.cell(row=row_idx, column=col_idx, value=value) + cell.border = thin_border + cell.alignment = Alignment(wrap_text=True) + + for col_idx, col_name in enumerate(columns, 1): + max_len = len(str(col_name)) + for row_idx in range(2, len(rows) + 2): + val = str(ws.cell(row=row_idx, column=col_idx).value or "") + max_len = max(max_len, min(len(val), 60)) + ws.column_dimensions[ws.cell(row=1, column=col_idx).column_letter].width = ( + max_len + 2 + ) + + buf = io.BytesIO() + wb.save(buf) + buf.seek(0) + file_bytes = buf.read() + + if not filename.endswith(".xlsx"): + filename = filename.rsplit(".", 1)[0] + ".xlsx" + content_type = ( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + extracted_text = f"[Excel file - {len(file_bytes)} bytes, {len(rows)} rows]" + content_base64 = base64.b64encode(file_bytes).decode() + size_bytes = len(file_bytes) + else: + import io + import csv + + buf = io.StringIO() + writer = csv.DictWriter(buf, fieldnames=columns, extrasaction="ignore") + writer.writeheader() + for row_data in rows: + writer.writerow({k: v for k, v in row_data.items() if k in columns}) + csv_text = buf.getvalue() + + if not filename.endswith(".csv"): + filename = filename.rsplit(".", 1)[0] + ".csv" + content_type = "text/csv" + extracted_text = csv_text + content_base64 = "" + size_bytes = len(csv_text.encode()) + + row = ( + supabase.table("chat_attachments") + .insert( + { + "session_id": session_id, + "filename": filename, + "content_type": content_type, + "size_bytes": size_bytes, + "extracted_text": extracted_text, + "content_base64": content_base64, + "status": "ready", + } + ) + .execute() + .data[0] + ) + return { + "attachment": { + "id": row["id"], + "filename": row["filename"], + "rows": len(rows), + "columns": columns, + "format": fmt, + } + } + + +def _get_version(arguments: dict) -> dict: + """Load a curriculum version snapshot with its course list.""" + version_id = _require_int(arguments, "version_id") + version_row = first_row( + supabase.table("curriculum_versions").select("*").eq("id", version_id) + ) + if not version_row: + raise ValueError(f"Version {version_id} not found") + snapshot_rows = ( + supabase.table("finalized_submissions") + .select("refined_id,course_json") + .eq("curriculum_version_id", version_id) + .order("refined_id") + .execute() + .data + ) + courses = [] + for snap in snapshot_rows: + cj = snap.get("course_json") or {} + courses.append( + { + "refined_id": snap.get("refined_id"), + "course_code": cj.get("course_code", ""), + "course_title": cj.get("course_title", ""), + "semester": cj.get("semester", ""), + "credits": cj.get("credits", ""), + } + ) + return {"version": version_row, "courses": courses, "course_count": len(courses)} + + +def _diff_versions(arguments: dict) -> dict: + """Compare two curriculum version snapshots. Shows added, removed, and changed courses.""" + version_a_id = _require_int(arguments, "version_a") + version_b_id = _require_int(arguments, "version_b") + + def _load_snapshot(vid: int) -> dict: + rows = ( + supabase.table("finalized_submissions") + .select("refined_id,course_json") + .eq("curriculum_version_id", vid) + .execute() + .data + ) + return {row["refined_id"]: row.get("course_json") or {} for row in rows} + + snap_a = _load_snapshot(version_a_id) + snap_b = _load_snapshot(version_b_id) + + all_ids = set(snap_a.keys()) | set(snap_b.keys()) + added = [] + removed = [] + changed = [] + unchanged = [] + + for rid in sorted(all_ids): + a = snap_a.get(rid) + b = snap_b.get(rid) + if a and not b: + removed.append( + { + "refined_id": rid, + "course_code": a.get("course_code", ""), + "course_title": a.get("course_title", ""), + } + ) + elif b and not a: + added.append( + { + "refined_id": rid, + "course_code": b.get("course_code", ""), + "course_title": b.get("course_title", ""), + } + ) + elif a != b: + d = diff_course(a, b) + changed.append( + { + "refined_id": rid, + "course_code": b.get("course_code", ""), + "course_title": b.get("course_title", ""), + "change_percent": d.get("change_percent", 0), + "syllabus_change_percent": d.get("syllabus_change_percent", 0), + "topics_added": d.get("topics_added", []), + "topics_removed": d.get("topics_removed", []), + } + ) + else: + unchanged.append(rid) + + return { + "version_a": version_a_id, + "version_b": version_b_id, + "added": added, + "removed": removed, + "changed": changed, + "unchanged_count": len(unchanged), + "summary": { + "added": len(added), + "removed": len(removed), + "changed": len(changed), + "unchanged": len(unchanged), + }, + } + + +def _get_curriculum_stats(arguments: dict) -> dict: + """Compute aggregate statistics for the curriculum or a specific semester.""" + query = ( + supabase.table("refined_submissions") + .select( + "semester,credits,course_type,credit_category,lecture_hours,practical_hours,visible,is_elective" + ) + .in_("status", ["refined"]) + ) + if arguments.get("semester") is not None: + query = query.eq("semester", int(arguments["semester"])) + rows = query.execute().data + + if not rows: + return {"stats": {}, "message": "No courses found"} + + by_semester: dict[int, dict] = {} + for row in rows: + sem = int(row.get("semester") or 0) + if sem not in by_semester: + by_semester[sem] = { + "total": 0, + "visible": 0, + "total_credits": 0, + "electives": 0, + "course_types": {}, + "credit_categories": {}, + } + s = by_semester[sem] + s["total"] += 1 + if row.get("visible", True): + s["visible"] += 1 + s["total_credits"] += int(row.get("credits") or 0) + if row.get("is_elective"): + s["electives"] += 1 + ct = str(row.get("course_type") or "Unknown") + s["course_types"][ct] = s["course_types"].get(ct, 0) + 1 + cc = str(row.get("credit_category") or "?") + s["credit_categories"][cc] = s["credit_categories"].get(cc, 0) + 1 + + total_credits = sum(s["total_credits"] for s in by_semester.values()) + total_courses = sum(s["total"] for s in by_semester.values()) + + return { + "total_courses": total_courses, + "total_credits": total_credits, + "by_semester": {str(k): v for k, v in sorted(by_semester.items())}, + } + + +def _batch_read_courses(arguments: dict) -> dict: + """Read specific fields from multiple courses in one call. More efficient than multiple get_course_fields calls.""" + refined_ids = arguments.get("refined_ids") + if not isinstance(refined_ids, list) or not refined_ids: + raise ValueError("refined_ids must be a non-empty array of integers") + fields = arguments.get("fields") + if not isinstance(fields, list) or not fields: + raise ValueError("fields must be a non-empty array of field names") + + results = [] + for rid in refined_ids: + rid = int(rid) + course = refined_course(rid) + if course is None: + results.append({"refined_id": rid, "error": "Course not found"}) + continue + entry = {"refined_id": rid} + for field in fields: + entry[field] = course.get(field) + results.append(entry) + + return {"courses": results, "count": len(results)} + + +OBJECT = {"type": "object", "additionalProperties": False} + +TOOLS: dict[str, AgentTool] = { + "get_current_course_json": AgentTool( + "get_current_course_json", + "Read the current template-ready JSON for one refined course.", + { + **OBJECT, + "properties": {"refined_id": {"type": "integer"}}, + "required": ["refined_id"], + }, + _get_current_course, + ), + "get_course_codes": AgentTool( + "get_course_codes", + "Read lightweight course identifiers (refined_id, course_code, course_title, semester, program). Use for listing or quick lookups.", + { + **OBJECT, + "properties": {"refined_id": {"type": "integer"}}, + "required": ["refined_id"], + }, + _get_course_codes, + ), + "get_course_syllabus": AgentTool( + "get_course_syllabus", + "Read syllabus content: units, objectives, course_outcomes.", + { + **OBJECT, + "properties": {"refined_id": {"type": "integer"}}, + "required": ["refined_id"], + }, + _get_course_syllabus, + ), + "get_course_textbooks": AgentTool( + "get_course_textbooks", + "Read textbook fields: text_books, reference_books.", + { + **OBJECT, + "properties": {"refined_id": {"type": "integer"}}, + "required": ["refined_id"], + }, + _get_course_textbooks, + ), + "get_course_deterministic": AgentTool( + "get_course_deterministic", + "Read deterministic/protected fields: program, lecture_hours, tutorial_hours, practical_hours, self_study, credits, course_type. These cannot be changed by the agent.", + { + **OBJECT, + "properties": {"refined_id": {"type": "integer"}}, + "required": ["refined_id"], + }, + _get_course_deterministic, + ), + "get_course_lab": AgentTool( + "get_course_lab", + "Read lab experiments and tools/languages.", + { + **OBJECT, + "properties": {"refined_id": {"type": "integer"}}, + "required": ["refined_id"], + }, + _get_course_lab, + ), + "get_course_fields": AgentTool( + "get_course_fields", + "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.", + { + **OBJECT, + "properties": { + "refined_id": {"type": "integer"}, + "fields": {"type": "array", "items": {"type": "string"}, "minItems": 1}, + }, + "required": ["refined_id", "fields"], + }, + _get_course_fields, + ), + "diff_course_json": AgentTool( + "diff_course_json", + "Compare two course JSON objects and return patch operations, changed percent, and syllabus topic changes.", + { + **OBJECT, + "properties": { + "current": {"type": "object"}, + "proposed": {"type": "object"}, + }, + "required": ["current", "proposed"], + }, + _diff_course_json, + ), + "create_course_draft": AgentTool( + "create_course_draft", + "Create a human-reviewable draft for one course. This never applies changes to refined_submissions.", + { + **OBJECT, + "properties": { + "refined_id": {"type": "integer"}, + "fields": {"type": "object"}, + "reason": {"type": "string"}, + }, + "required": ["refined_id", "fields"], + }, + _create_course_draft, + ), + "update_agent_draft": AgentTool( + "update_agent_draft", + "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.", + { + **OBJECT, + "properties": { + "draft_id": {"type": "integer", "description": "The ID of the existing draft to update"}, + "fields": {"type": "object", "description": "Fields to update in the draft's proposed content"}, + "reason": {"type": "string", "description": "Why the draft is being updated"}, + }, + "required": ["draft_id", "fields"], + }, + _update_agent_draft, + ), + "create_refined_course": AgentTool( + "create_refined_course", + "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.", + { + **OBJECT, + "properties": { + "refined_id": { + "type": "integer", + "description": "If provided, update this existing course. If omitted, create a new one.", + }, + "course_code": {"type": "string", "description": "e.g. UE25CS353A"}, + "course_title": {"type": "string"}, + "semester": {"type": "integer", "minimum": 1, "maximum": 8}, + "target_department": { + "type": "string", + "description": "CSE, ECE, ME, BT, EEE, AIML", + }, + "credit_category": {"type": "string", "description": "0, 2, 4, or 5"}, + "is_elective": { + "type": "boolean", + "description": "Set true for elective courses (specialization electives in semesters 5-6). Default false (core course).", + }, + "units": { + "type": "array", + "description": "Course units. Each unit must have unit_number (int), title (str), content (str, the syllabus text), and hours (int).", + "items": { + "type": "object", + "properties": { + "unit_number": {"type": "integer"}, + "title": {"type": "string"}, + "content": {"type": "string"}, + "hours": {"type": "integer"}, + }, + "required": ["unit_number", "title", "content", "hours"], + }, + }, + "objectives": {"type": "array", "items": {"type": "string"}, "description": "Learning objectives. Send as JSON array of strings."}, + "course_outcomes": {"type": "array", "items": {"type": "string"}, "description": "Measurable course outcomes. Send as JSON array of strings."}, + "text_books": {"type": "array", "items": {"type": "string"}, "description": "Textbook references with author, title, edition, publisher, year. Send as JSON array."}, + "reference_books": {"type": "array", "items": {"type": "string"}, "description": "Reference books. Send as JSON array."}, + "lab_experiments": {"type": "array", "items": {"type": "string"}, "description": "Lab experiments (only for credit_category 5). Send as JSON array."}, + "tools_languages": {"type": "string"}, + "prelude": {"type": "string"}, + "desirable_knowledge": {"type": "string"}, + }, + "required": [ + "course_code", + "course_title", + "semester", + "target_department", + "credit_category", + ], + }, + _create_refined_course, + ), + "get_curriculum_json": AgentTool( + "get_curriculum_json", + "Read template-ready JSON for the full curriculum, optionally filtered by semester.", + { + **OBJECT, + "properties": {"semester": {"type": "integer", "minimum": 1, "maximum": 8}}, + }, + _get_curriculum_json, + ), + "create_document_draft": AgentTool( + "create_document_draft", + "Create one human-reviewable document draft containing proposed changes for multiple courses. This never applies changes.", + { + **OBJECT, + "properties": { + "courses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "refined_id": {"type": "integer"}, + "fields": {"type": "object"}, + }, + "required": ["refined_id", "fields"], + "additionalProperties": False, + }, + }, + "reason": {"type": "string"}, + "uploaded_document_id": {"type": "string"}, + "curriculum_version_id": {"type": "integer"}, + }, + "required": ["courses"], + }, + _create_document_draft, + ), + "get_course_draft": AgentTool( + "get_course_draft", + "Read one staged course draft and its diff summary.", + { + **OBJECT, + "properties": {"draft_id": {"type": "integer"}}, + "required": ["draft_id"], + }, + _get_course_draft, + ), + "get_document_draft": AgentTool( + "get_document_draft", + "Read one staged document draft and all linked course drafts.", + { + **OBJECT, + "properties": {"document_draft_id": {"type": "integer"}}, + "required": ["document_draft_id"], + }, + _get_document_draft, + ), + "get_preview_url": AgentTool( + "get_preview_url", + "Return the preview URL for a course, course draft, or document draft.", + { + **OBJECT, + "properties": { + "kind": { + "type": "string", + "enum": ["course", "draft", "document_draft"], + }, + "id": {"type": "integer"}, + }, + "required": ["kind", "id"], + }, + _get_preview_url, + ), + "list_courses": AgentTool( + "list_courses", + "List refined course IDs and titles, optionally filtered by semester.", + { + **OBJECT, + "properties": {"semester": {"type": "integer", "minimum": 1, "maximum": 8}}, + }, + _list_courses, + ), + "get_attachment_text": AgentTool( + "get_attachment_text", + "Read extracted text for uploaded chat attachments within a chat session.", + { + **OBJECT, + "properties": { + "session_id": {"type": "integer"}, + "attachment_ids": {"type": "array", "items": {"type": "integer"}}, + }, + "required": ["session_id", "attachment_ids"], + }, + _attachment_text, + ), + "fetch_url": AgentTool( + "fetch_url", + "Fetch a public URL and return its text content. Use to read web pages, public documents, and online resources.", + {**OBJECT, "properties": {"url": {"type": "string"}}, "required": ["url"]}, + _fetch_url, + ), + "web_search": AgentTool( + "web_search", + "Search the web and return top results with titles, snippets, and URLs. Use for finding current information, documentation, or references.", + { + **OBJECT, + "properties": { + "query": {"type": "string", "description": "Search query"}, + "num_results": { + "type": "integer", + "minimum": 1, + "maximum": 10, + "default": 5, + }, + }, + "required": ["query"], + }, + _web_search, + ), + "create_report": AgentTool( + "create_report", + "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.", + { + **OBJECT, + "properties": { + "session_id": {"type": "integer"}, + "content": { + "type": "string", + "description": "Full report/document content in markdown format", + }, + "filename": { + "type": "string", + "description": "Filename including extension, e.g. comparison-report.md or comparison-report.pdf", + }, + "format": { + "type": "string", + "enum": ["markdown", "pdf"], + "default": "markdown", + "description": "Output format: markdown or pdf", + }, + }, + "required": ["session_id", "content"], + }, + _create_report, + ), + "create_curriculum_version": AgentTool( + "create_curriculum_version", + "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'.", + { + **OBJECT, + "properties": { + "name": { + "type": "string", + "description": "Descriptive version name (conventional commit style encouraged)", + }, + }, + "required": ["name"], + }, + _create_curriculum_version, + ), + "define_specialization": AgentTool( + "define_specialization", + "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.", + { + **OBJECT, + "properties": { + "semester": { + "type": "integer", + "minimum": 1, + "maximum": 8, + "description": "Semester the specialization applies to (5 or 6 for electives)", + }, + "name": { + "type": "string", + "description": "Full specialization name, e.g. 'Machine Intelligence and Data Science (MIDS)'", + }, + "letter": { + "type": "string", + "description": "Optional letter label (A, B, C). Auto-assigned if omitted.", + }, + "key": { + "type": "string", + "description": "Optional short key (SCC, MIDS, CSCS). Derived from name if omitted.", + }, + "academic_year": { + "type": "string", + "description": "Optional academic year batch, e.g. 2025-26", + }, + }, + "required": ["semester", "name"], + }, + _define_specialization, + ), + "list_specializations": AgentTool( + "list_specializations", + "List specialization track definitions, optionally filtered by semester. Use to discover which tracks exist before assigning electives.", + { + **OBJECT, + "properties": {"semester": {"type": "integer", "minimum": 1, "maximum": 8}}, + }, + _list_specializations, + ), + "assign_elective_to_tracks": AgentTool( + "assign_elective_to_tracks", + "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.", + { + **OBJECT, + "properties": { + "refined_id": {"type": "integer"}, + "specialization_ids": { + "type": "array", + "items": {"type": "integer"}, + "description": "Specialization track IDs to assign the elective to", + }, + }, + "required": ["refined_id", "specialization_ids"], + }, + _assign_elective_to_tracks, + ), + "remove_elective_from_tracks": AgentTool( + "remove_elective_from_tracks", + "Remove an elective course from one or more specialization tracks.", + { + **OBJECT, + "properties": { + "refined_id": {"type": "integer"}, + "specialization_ids": { + "type": "array", + "items": {"type": "integer"}, + "description": "Specialization track IDs to remove the elective from", + }, + }, + "required": ["refined_id", "specialization_ids"], + }, + _remove_elective_from_tracks, + ), + "get_course_assignments": AgentTool( + "get_course_assignments", + "Return which specialization tracks a course currently belongs to, including the track definitions.", + { + **OBJECT, + "properties": {"refined_id": {"type": "integer"}}, + "required": ["refined_id"], + }, + _get_course_assignments, + ), + "categorize_elective": AgentTool( + "categorize_elective", + "Run guarded AI categorization for a refined elective. It assigns only existing semester tracks with high confidence; all other results require human confirmation.", + { + **OBJECT, + "properties": {"refined_id": {"type": "integer"}}, + "required": ["refined_id"], + }, + _categorize_elective, + ), + "update_deterministic_fields": AgentTool( + "update_deterministic_fields", + "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.", + { + **OBJECT, + "properties": { + "refined_id": {"type": "integer"}, + "fields": { + "type": "object", + "description": 'Protected fields to change, e.g. {"credits": 5, "lecture_hours": 4}', + }, + "reason": {"type": "string"}, + }, + "required": ["refined_id", "fields"], + }, + _update_deterministic_fields, + ), + "signal_done": AgentTool( + "signal_done", + "Signal that the agent has completed the user's request. Provide a concise summary of what was accomplished. This ends the agent's turn.", + { + **OBJECT, + "properties": { + "summary": { + "type": "string", + "description": "Brief summary of what was done, e.g. 'Created draft for CS201 adding Unit 5 on Graph Algorithms'", + }, + }, + "required": ["summary"], + }, + _signal_done, + ), + "ask_user": AgentTool( + "ask_user", + "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.", + { + **OBJECT, + "properties": { + "question": { + "type": "string", + "description": "The question to ask the user", + }, + }, + "required": ["question"], + }, + _ask_user, + ), + "create_spreadsheet": AgentTool( + "create_spreadsheet", + "Generate a CSV or Excel file from structured row data and save as a downloadable chat attachment. Provide column names and row objects.", + { + **OBJECT, + "properties": { + "session_id": {"type": "integer"}, + "columns": { + "type": "array", + "items": {"type": "string"}, + "description": "Column names in display order", + }, + "rows": { + "type": "array", + "items": {"type": "object"}, + "description": "Array of row objects, keys matching column names", + }, + "filename": { + "type": "string", + "description": "Filename including extension, e.g. semester-3-courses.xlsx", + }, + "format": {"type": "string", "enum": ["csv", "xlsx"], "default": "csv"}, + }, + "required": ["session_id", "columns", "rows"], + }, + _create_spreadsheet, + ), + "get_version": AgentTool( + "get_version", + "Load a curriculum version snapshot with its metadata and course list. Use to inspect what a version contains before comparing or restoring.", + { + **OBJECT, + "properties": { + "version_id": {"type": "integer", "description": "Version snapshot ID"}, + }, + "required": ["version_id"], + }, + _get_version, + ), + "diff_versions": AgentTool( + "diff_versions", + "Compare two curriculum version snapshots. Returns added, removed, and changed courses with per-course change percentages.", + { + **OBJECT, + "properties": { + "version_a": { + "type": "integer", + "description": "First version ID (base)", + }, + "version_b": { + "type": "integer", + "description": "Second version ID (target)", + }, + }, + "required": ["version_a", "version_b"], + }, + _diff_versions, + ), + "get_curriculum_stats": AgentTool( + "get_curriculum_stats", + "Compute aggregate curriculum statistics: total courses, credits per semester, course type distribution, visible vs hidden counts. Optionally filter to one semester.", + { + **OBJECT, + "properties": { + "semester": { + "type": "integer", + "minimum": 1, + "maximum": 8, + "description": "Optional: stats for one semester only", + }, + }, + }, + _get_curriculum_stats, + ), + "batch_read_courses": AgentTool( + "batch_read_courses", + "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.", + { + **OBJECT, + "properties": { + "refined_ids": { + "type": "array", + "items": {"type": "integer"}, + "description": "List of course refined IDs to read", + }, + "fields": { + "type": "array", + "items": {"type": "string"}, + "description": "Field names to read for each course", + }, + }, + "required": ["refined_ids", "fields"], + }, + _batch_read_courses, + ), +} diff --git a/backend/app/services/attachments.py b/backend/app/services/attachments.py new file mode 100644 index 0000000000000000000000000000000000000000..892f51b7b77e94a980af95344581d6e606051540 --- /dev/null +++ b/backend/app/services/attachments.py @@ -0,0 +1,91 @@ +import re +import subprocess +import tempfile +import zipfile +from pathlib import Path +from xml.etree import ElementTree + +MAX_FILE_BYTES = 8 * 1024 * 1024 +MAX_TEXT_CHARS = 200000 +SPACE = re.compile(r"[ \t]+") +BLANKS = re.compile(r"\n{3,}") + + +def extract_text(filename: str, content_type: str, data: bytes) -> tuple[str, str, str]: + if len(data) > MAX_FILE_BYTES: + return "", "failed", "File is larger than 8 MB" + suffix = Path(filename or "").suffix.lower() + try: + if suffix == ".pdf" or content_type == "application/pdf": + return _clean(_pdf_text(data)), "ready", "" + if suffix == ".docx": + return _clean(_docx_text(data)), "ready", "" + if suffix == ".xlsx": + return _clean(_xlsx_text(data)), "ready", "" + if suffix in {".txt", ".md", ".csv"} or content_type.startswith("text/"): + return _clean(_decode(data)), "ready", "" + if content_type.startswith("image/"): + return "", "unsupported", "Image OCR is not enabled yet" + return "", "unsupported", "File type is not supported yet" + except Exception as exc: + return "", "failed", str(exc) + + +def _pdf_text(data: bytes) -> str: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "upload.pdf" + path.write_bytes(data) + result = subprocess.run( + ["pdftotext", "-layout", str(path), "-"], + check=True, + capture_output=True, + text=True, + timeout=30, + ) + return result.stdout + + +def _docx_text(data: bytes) -> str: + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "upload.docx" + path.write_bytes(data) + with zipfile.ZipFile(path) as docx: + xml = docx.read("word/document.xml") + root = ElementTree.fromstring(xml) + ns = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" + lines = [] + for paragraph in root.iter(f"{ns}p"): + text = "".join(node.text or "" for node in paragraph.iter(f"{ns}t")).strip() + if text: + lines.append(text) + return "\n".join(lines) + + +def _xlsx_text(data: bytes) -> str: + import openpyxl + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "upload.xlsx" + path.write_bytes(data) + wb = openpyxl.load_workbook(path, read_only=True, data_only=True) + rows = [] + for sheet in wb: + for row in sheet.iter_rows(values_only=True): + line = "\t".join(str(c or "") for c in row) + if line.strip(): + rows.append(line) + return "\n".join(rows) + + +def _decode(data: bytes) -> str: + for encoding in ("utf-8", "utf-16", "latin-1"): + try: + return data.decode(encoding) + except UnicodeDecodeError: + continue + return data.decode("utf-8", errors="ignore") + + +def _clean(text: str) -> str: + text = SPACE.sub(" ", text.replace("\r\n", "\n").replace("\r", "\n")) + text = BLANKS.sub("\n\n", text).strip() + return text[:MAX_TEXT_CHARS] diff --git a/backend/app/services/books.py b/backend/app/services/books.py new file mode 100644 index 0000000000000000000000000000000000000000..3e92d310172500b5d546a2d2b299bb3dfe5451aa --- /dev/null +++ b/backend/app/services/books.py @@ -0,0 +1,105 @@ +import re + +WORD = re.compile(r"\w+") +BOOK_STOP = re.compile( + r"\bCourse\b[\s\S]{0,160}\bOutcome\b|" + r"\b(Course\s+Objectives?|Assignment\s*/|Laboratory|Recommended\s+Materials)\b", + re.IGNORECASE, +) +BOOK_LABEL = re.compile( + r"(?im)^\s*(?:Text\s*)?Book\(s\):\s*|" + r"^\s*Text\s+Book\(s\):\s*|" + r"^\s*Reference\s*(?:Book\(s\):)?\s*" +) +BOOK_NUMBER = re.compile(r"(?:^|\n|\s{2,}|(?<=\.)\s+)\d+\s*[:.)]\s*") +ORDINAL_LINE = re.compile(r"(?im)^\s*(st|nd|rd|th)\s*$") +PAGE_NOISE = re.compile( + r"P\.?\s*E\.?\s*S\.?\s*University|" + r"Curriculum|" + r"\s*:-\s*[A-Za-z]*\s*\d{4}\s*-\s*\d{4}\b|" + r"\b\d+\s*\|\s*Page\b", + re.IGNORECASE, +) +TEXT_BOOK_LINE = re.compile(r"\bText\s+Book\(s\):", re.IGNORECASE) +REFERENCE_LINE = re.compile(r"^\s*Reference\b", re.IGNORECASE) +SECTION_END_LINE = re.compile(r"^\s*(Course\s+Outcome|Assignment\s*/|Course\s+Objectives?|Laboratory)\b", re.IGNORECASE) + + +def _words(text: str) -> int: + return len(WORD.findall(text)) + + +def _flatten(values) -> str: + parts = [] + for value in values: + if not value: + continue + if isinstance(value, list): + parts.extend(str(item) for item in value if str(item).strip()) + else: + parts.append(str(value)) + text = "\n".join(parts) + match = BOOK_STOP.search(text) + if match: + text = text[: match.start()] + text = ORDINAL_LINE.sub("", text) + text = PAGE_NOISE.sub("\n", text) + return BOOK_LABEL.sub("", text) + + +def _clean(value: str) -> str: + value = BOOK_LABEL.sub("", value) + value = re.sub(r"\bBook\(s\):?\s*", "", value, flags=re.IGNORECASE) + value = re.sub(r"-\s+", "-", value) + return re.sub(r"\s+", " ", value).strip(" :") + + +def parse_books(*values) -> list[str]: + text = _flatten(values) + if not text.strip(): + return [] + + numbered = [part for part in BOOK_NUMBER.split(text) if part.strip()] + if numbered and (len(numbered) > 1 or BOOK_NUMBER.search(text)): + parts = numbered + else: + parts = [line for line in text.splitlines() if line.strip()] + + books = [] + seen = set() + for part in parts: + item = _clean(part) + if not item: + continue + if books and (item.startswith("(") or (_words(item) <= 3 and not re.search(r"\b\d{4}\b", item))): + books[-1] = f"{books[-1]} {item}".strip() + continue + key = re.sub(r"[^a-z0-9]+", " ", item.lower()).strip() + if key not in seen: + books.append(item) + seen.add(key) + return books + + +def raw_book_section(raw_content: str, kind: str) -> str: + lines = (raw_content or "").splitlines() + start = None + for index, line in enumerate(lines): + if kind == "text" and TEXT_BOOK_LINE.search(line): + start = index + break + if kind == "reference" and REFERENCE_LINE.search(line): + start = index + break + if start is None: + return "" + + end = len(lines) + for index in range(start + 1, len(lines)): + if kind == "text" and REFERENCE_LINE.search(lines[index]): + end = index + break + if SECTION_END_LINE.search(lines[index]): + end = index + break + return "\n".join(lines[start:end]) diff --git a/backend/app/services/curriculum.py b/backend/app/services/curriculum.py new file mode 100644 index 0000000000000000000000000000000000000000..3660ee60cd8fc80bade6f7908e74923f03fd7df9 --- /dev/null +++ b/backend/app/services/curriculum.py @@ -0,0 +1,225 @@ +from os import environ + +from app import cache +from app.preview import build_course_preview +from app.services.diffing import diff_course, merge_fields, validate_draft +from app.supabase import first_row, supabase + +DEFAULT_CURRICULUM_YEAR = environ.get("CURRICULUM_YEAR", "").strip() + + +def invalidate_curriculum_cache(): + """Invalidate all cached curriculum PDF/HTML after data changes.""" + cache.invalidate("full_pdf:") + cache.invalidate("full_html:") + cache.invalidate("sem_pdf:") + cache.invalidate("course:") + cache.invalidate("course_html:") + cache.invalidate("course_pdf:") + cache.invalidate("sem_courses:") + cache.invalidate("courses_list") + cache.invalidate("pending_courses") + cache.invalidate("all_course_ids") + cache.invalidate("ver_preview:") +SOURCE_ORDER = { + "UE25CS151A": 1, + "UE25CS151B": 1, + "UE24CS251A": 1, + "UE24CS252A": 2, + "UE24MA242A": 3, + "UE24CS241A": 4, + "UE24CS243A": 5, + "UZ24UZ221A": 6, + "UE25MA201A*": 7, + "UE24CS251B": 1, + "UE24CS252B": 2, + "UE24CS241B": 3, + "UE24CS242B": 4, + "UE24MA241B": 5, + "UZ24UZ221B": 6, + "UE25MA201B*": 7, + "UE23CS351A": 1, + "UE23CS352A": 2, + "UE23CS341A": 3, + "UE23CS342AAX": 4, + "UE23CS343ABX": 5, + "UE23CS320A": 6, + "UE23CS351B": 1, + "UE23CS352B": 2, + "UE23CS341B": 3, + "UE23CS342BAX": 4, + "UE23CS343BBX": 5, + "UE23CS320B": 6, + "UE22CS441A": 1, + "UZ22UZ422A": 2, + "UE22AM421AXX": 3, + "UE22CS421B": 1, + "UE22CS461XB": 2, +} + +REFINED_FIELDS = { + "semester", + "course_code", + "course_title", + "program", + "lecture_hours", + "tutorial_hours", + "practical_hours", + "self_study", + "credits", + "course_type", + "is_elective", + "tools_languages", + "desirable_knowledge", + "prelude", + "objectives", + "course_outcomes", + "units", + "lab_experiments", + "text_books", + "reference_books", + "status", +} + + +def attach_submissions(rows: list[dict]) -> list[dict]: + ids = [row["submission_id"] for row in rows if row.get("submission_id")] + if not ids: + return rows + cache_key = f"attach:{','.join(str(i) for i in sorted(ids))}" + cached = cache.get(cache_key) + if cached is not None: + for row in rows: + row["_submission"] = cached.get(row.get("submission_id"), {}) + return rows + submissions = supabase.table("submissions").select("*").in_("id", ids).execute().data + by_id = {row["id"]: row for row in submissions} + cache.put(cache_key, by_id, ttl=300) + for row in rows: + row["_submission"] = by_id.get(row.get("submission_id"), {}) + return rows + + +def ordered_courses(rows: list[dict]) -> list[dict]: + rows = attach_submissions(rows) + rows.sort(key=course_sort_key) + return [build_course_preview(row) for row in rows] + + +def course_credits(row: dict) -> int: + value = row.get("credits") + if value not in (None, ""): + return int(value) + category = str((row.get("_submission") or {}).get("credit_category") or "").strip() + if category.isdigit(): + return int(category) + return 0 + + +def course_sort_key(row: dict) -> tuple[int, int, int, int]: + semester = int(row.get("semester") or 0) + code = str(row.get("course_code") or "").replace(" ", "").upper() + order = SOURCE_ORDER.get(code) + if order is None and semester == 5: + order = elective_order(code, "AA", "AB") + if order is None and semester == 6: + order = elective_order(code, "BA", "BB") + return semester, -course_credits(row), order or 900, int(row.get("id") or 0) + + +def elective_order(code: str, first_group: str, second_group: str) -> int | None: + for offset, group in ((100, first_group), (200, second_group)): + if group in code: + suffix = code.rsplit(group, 1)[-1].rstrip("X") + return offset + int(suffix) if suffix.isdigit() else offset + return None + + +def create_version_snapshot(name: str) -> dict | None: + rows = supabase.table("refined_submissions").select("*").in_("status", ["refined"]).execute().data + rows = attach_submissions(rows) + courses = [{"refined_id": row["id"], "course_json": build_course_preview(row)} for row in rows] + program = courses[0]["course_json"].get("program") if courses else "" + result = ( + supabase.table("curriculum_versions") + .insert({"name": name, "program": program, "academic_year": selected_curriculum_year(), "status": "draft"}) + .execute() + ) + version = (result.data or [None])[0] + if not version: + return None + if courses: + records = [{**course, "curriculum_version_id": version["id"]} for course in courses] + supabase.table("finalized_submissions").insert(records).execute() + return version + + +def selected_curriculum_year(override: str | None = None) -> str: + if override and override.strip(): + return override.strip() + return DEFAULT_CURRICULUM_YEAR + + +def refined_course(refined_id: int) -> dict: + cache_key = f"course:{refined_id}" + cached = cache.get(cache_key) + if cached is not None: + return cached + row = first_row(supabase.table("refined_submissions").select("*").eq("id", refined_id)) + if not row: + raise LookupError("Refined submission not found") + result = build_course_preview(attach_submissions([row])[0]) + cache.put(cache_key, result, ttl=300) + return result + + +def update_refined_fields(refined_id: int, fields: dict) -> dict | None: + update = {key: fields[key] for key in REFINED_FIELDS if key in fields} + for key in ("semester", "lecture_hours", "tutorial_hours", "practical_hours", "self_study", "credits"): + if key in update: + try: + update[key] = int(update[key] or 0) + except (TypeError, ValueError) as exc: + raise ValueError(f"Invalid value for {key}: {update[key]!r}. Must be a number.") from exc + is_elective = update.get("is_elective") + if is_elective is None: + existing = first_row(supabase.table("refined_submissions").select("is_elective").eq("id", refined_id)) + is_elective = existing.get("is_elective") if existing else False + if is_elective: + update["course_type"] = "Elective" + result = supabase.table("refined_submissions").update(update).eq("id", refined_id).execute() + invalidate_curriculum_cache() + return result.data[0] if result.data else None + + +def draft_record(refined_id: int, fields: dict, reason: str = "", document_draft_id: int | None = None) -> dict: + base = refined_course(refined_id) + proposed = merge_fields(base, fields) + summary = diff_course(base, proposed) + issues = validate_draft(base, proposed) + summary["validation_issues"] = issues + return { + "refined_id": refined_id, + "document_draft_id": document_draft_id, + "base_refined_json": base, + "proposed_json": proposed, + "json_patch": summary.pop("json_patch"), + "diff_summary": summary, + "change_reason": reason.strip(), + "status": "blocked" if issues else "proposed", + } + + +def load_agent_draft(draft_id: int) -> dict: + draft = first_row(supabase.table("agent_drafts").select("*").eq("id", draft_id)) + if not draft: + raise LookupError("Agent draft not found") + return draft + + +def load_document_draft(document_draft_id: int) -> dict: + document = first_row(supabase.table("agent_document_drafts").select("*").eq("id", document_draft_id)) + if not document: + raise LookupError("Document draft not found") + drafts = supabase.table("agent_drafts").select("*").eq("document_draft_id", document_draft_id).order("id").execute().data + return {"document_draft": document, "drafts": drafts} diff --git a/backend/app/services/deterministic.py b/backend/app/services/deterministic.py new file mode 100644 index 0000000000000000000000000000000000000000..9b33cf86a45a20e50304031e911a79977f13eba0 --- /dev/null +++ b/backend/app/services/deterministic.py @@ -0,0 +1,43 @@ +_HOURS_MAP = { + "5": {"lecture_hours": 4, "tutorial_hours": 0, "practical_hours": 2, "self_study": 5, "credits": 5}, + "4": {"lecture_hours": 4, "tutorial_hours": 0, "practical_hours": 0, "self_study": 4, "credits": 4}, + "2": {"lecture_hours": 2, "tutorial_hours": 0, "practical_hours": 0, "self_study": 2, "credits": 2}, + "0": {"lecture_hours": 0, "tutorial_hours": 0, "practical_hours": 0, "self_study": 0, "credits": 0}, +} + +_PROGRAM_MAP = { + "CSE": "B. TECH", + "AIML": "B. TECH", + "ECE": "B. TECH", + "ME": "B. TECH", + "EEE": "B. TECH", + "BT": "B. TECH", +} + +_COURSE_TYPE_MAP = { + "0": "Foundation Course", + "5": "Core Course-Lab Integrated", + "4": "Core Course", + "2": "Core Theory", +} + + +def compute_hours(cat: str) -> dict: + result = _HOURS_MAP.get(cat) + if result is None: + raise ValueError(f"Unknown credit_category: {cat!r}. Expected one of: {sorted(_HOURS_MAP)}") + return result + + +def compute_program(dept: str) -> str: + result = _PROGRAM_MAP.get(dept) + if result is None: + raise ValueError(f"Unknown department: {dept!r}. Expected one of: {sorted(_PROGRAM_MAP)}") + return result + + +def compute_course_type(cat: str) -> str: + result = _COURSE_TYPE_MAP.get(cat) + if result is None: + raise ValueError(f"Unknown credit_category: {cat!r}. Expected one of: {sorted(_COURSE_TYPE_MAP)}") + return result diff --git a/backend/app/services/diffing.py b/backend/app/services/diffing.py new file mode 100644 index 0000000000000000000000000000000000000000..9282c47b749913eb14134485aa703aa16c0f0c50 --- /dev/null +++ b/backend/app/services/diffing.py @@ -0,0 +1,241 @@ +import difflib +import json +import re +from copy import deepcopy +from typing import Any + +PROTECTED_FIELDS = { + "program", + "lecture_hours", + "tutorial_hours", + "practical_hours", + "self_study", + "credits", + "course_type", +} + +TOPIC_SPLIT = re.compile(r"[,;\n]+") +SPACE = re.compile(r"\s+") + + +def stable_json(value: Any) -> str: + return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + + +def build_patch(old: Any, new: Any, path: str = "") -> list[dict]: + if isinstance(old, dict) and isinstance(new, dict): + patch = [] + old_keys = set(old) + new_keys = set(new) + for key in sorted(old_keys - new_keys): + patch.append({"op": "remove", "path": f"{path}/{_escape(key)}"}) + for key in sorted(new_keys - old_keys): + patch.append({"op": "add", "path": f"{path}/{_escape(key)}", "value": new[key]}) + for key in sorted(old_keys & new_keys): + patch.extend(build_patch(old[key], new[key], f"{path}/{_escape(key)}")) + return patch + + if old != new: + return [{"op": "replace", "path": path or "/", "value": new}] + return [] + + +def apply_patch(value: Any, patch: list[dict]) -> Any: + result = deepcopy(value) + for op in patch: + name = op.get("op") + path = op.get("path") + if name not in {"add", "remove", "replace"} or not isinstance(path, str): + raise ValueError("Unsupported patch operation") + parent, key = _resolve_parent(result, path) + if isinstance(parent, list): + index = int(key) + if name == "remove": + parent.pop(index) + elif name == "replace": + parent[index] = op.get("value") + else: + parent.insert(index, op.get("value")) + continue + if name == "remove": + parent.pop(key, None) + else: + parent[key] = op.get("value") + return result + + +def diff_course(old: dict, new: dict) -> dict: + old_text = stable_json(old) + new_text = stable_json(new) + patch = build_patch(old, new) + syllabus_old = _syllabus_text(old) + syllabus_new = _syllabus_text(new) + topics_old = _topics(syllabus_old) + topics_new = _topics(syllabus_new) + + def _is_protected(field: str) -> bool: + if field == "course_type" and new.get("is_elective"): + return False + return field in PROTECTED_FIELDS + + return { + "json_patch": patch, + "patch_operations": len(patch), + "change_percent": _change_percent(old_text, new_text), + "syllabus_change_percent": _change_percent(syllabus_old, syllabus_new), + "topics_added": sorted(topics_new - topics_old), + "topics_removed": sorted(topics_old - topics_new), + "protected_changes": sorted(field for field in PROTECTED_FIELDS if _is_protected(field) and _field_value(old, field) != _field_value(new, field)), + "unified_diff": "\n".join( + difflib.unified_diff( + old_text.splitlines(), + new_text.splitlines(), + fromfile="current", + tofile="proposed", + lineterm="", + ) + ), + } + + +def validate_draft(old: dict, new: dict) -> list[str]: + issues = [] + for field in PROTECTED_FIELDS: + if field == "course_type" and new.get("is_elective"): + continue + if _field_value(old, field) != _field_value(new, field): + issues.append(f"{field} is deterministic and cannot be changed by an agent draft") + return issues + + +def merge_fields(base: dict, fields: dict) -> dict: + merged = deepcopy(base) + for key, value in fields.items(): + if key in PROTECTED_FIELDS and str(base.get(key) or "") == str(value or ""): + value = base.get(key) + merged[key] = value + if merged.get("is_elective"): + merged["course_type"] = "Elective" + return merged + + +def _field_value(value: dict, field: str) -> str: + return str(value.get(field) or "") + + +def _escape(value: str) -> str: + return value.replace("~", "~0").replace("/", "~1") + + +def _unescape(value: str) -> str: + return value.replace("~1", "/").replace("~0", "~") + + +def _resolve_parent(value: Any, path: str) -> tuple[Any, str]: + if path in {"", "/"}: + raise ValueError("Root patch operations are not supported") + parts = [_unescape(part) for part in path.strip("/").split("/")] + current = value + for part in parts[:-1]: + current = current[int(part)] if isinstance(current, list) else current[part] + return current, parts[-1] + + +def _change_percent(old: str, new: str) -> float: + if not old and not new: + return 0.0 + ratio = difflib.SequenceMatcher(None, old, new).ratio() + return round((1 - ratio) * 100, 2) + + +def _syllabus_text(value: dict) -> str: + units = value.get("units") or [] + if not isinstance(units, list): + return "" + parts = [] + for unit in units: + if isinstance(unit, dict): + parts.append(str(unit.get("title") or "")) + parts.append(str(unit.get("content") or "")) + return SPACE.sub(" ", " ".join(parts)).strip() + + +def _topics(text: str) -> set[str]: + topics = set() + for item in TOPIC_SPLIT.split(text): + topic = SPACE.sub(" ", item).strip(" .:-").lower() + if len(topic) >= 4: + topics.add(topic) + return topics + + +def diff_text_field(old: str, new: str) -> dict | None: + if old == new: + return None + return {"kind": "text", "old": old or "", "new": new or ""} + + +def diff_list_field(old: list, new: list) -> dict | None: + if old == new: + return None + old_set = set(old) + new_set = set(new) + return { + "kind": "list", + "removed": sorted(old_set - new_set), + "added": sorted(new_set - old_set), + "unchanged": sorted(old_set & new_set), + } + + +def diff_units_field(old_units: list, new_units: list) -> dict | None: + if old_units == new_units: + return None + old_by_title = {u.get("title", ""): u for u in old_units if isinstance(u, dict)} + new_by_title = {u.get("title", ""): u for u in new_units if isinstance(u, dict)} + all_titles = sorted(set(old_by_title.keys()) | set(new_by_title.keys())) + units_diff = [] + for title in all_titles: + old_u = old_by_title.get(title) + new_u = new_by_title.get(title) + if old_u and not new_u: + units_diff.append({"kind": "removed", "unit": old_u}) + elif new_u and not old_u: + units_diff.append({"kind": "added", "unit": new_u}) + else: + unit_changes = {} + for field in ("title", "content", "hours"): + ov = old_u.get(field, "") + nv = new_u.get(field, "") + if ov != nv: + unit_changes[field] = {"old": ov, "new": nv} + if unit_changes: + units_diff.append({"kind": "changed", "unit": new_u, "changes": unit_changes}) + else: + units_diff.append({"kind": "unchanged", "unit": new_u}) + return {"kind": "units", "units": units_diff} + + +def build_course_diff(base: dict, proposed: dict) -> dict: + diff = { + "course_title": diff_text_field(base.get("course_title", ""), proposed.get("course_title", "")), + "course_code": diff_text_field(base.get("course_code", ""), proposed.get("course_code", "")), + "program": diff_text_field(base.get("program", ""), proposed.get("program", "")), + "lecture_hours": diff_text_field(str(base.get("lecture_hours", "")), str(proposed.get("lecture_hours", ""))), + "tutorial_hours": diff_text_field(str(base.get("tutorial_hours", "")), str(proposed.get("tutorial_hours", ""))), + "practical_hours": diff_text_field(str(base.get("practical_hours", "")), str(proposed.get("practical_hours", ""))), + "self_study": diff_text_field(str(base.get("self_study", "")), str(proposed.get("self_study", ""))), + "credits": diff_text_field(str(base.get("credits", "")), str(proposed.get("credits", ""))), + "course_type": diff_text_field(base.get("course_type", ""), proposed.get("course_type", "")), + "semester": diff_text_field(str(base.get("semester", "")), str(proposed.get("semester", ""))), + "tools_languages": diff_text_field(base.get("tools_languages", ""), proposed.get("tools_languages", "")), + "desirable_knowledge": diff_text_field(base.get("desirable_knowledge", ""), proposed.get("desirable_knowledge", "")), + "prelude": diff_text_field(base.get("prelude", ""), proposed.get("prelude", "")), + "objectives": diff_list_field(base.get("objectives") or [], proposed.get("objectives") or []), + "course_outcomes": diff_list_field(base.get("course_outcomes") or [], proposed.get("course_outcomes") or []), + "units": diff_units_field(base.get("units") or [], proposed.get("units") or []), + "lab_experiments": diff_list_field(base.get("lab_experiments") or [], proposed.get("lab_experiments") or []), + "text_books": diff_list_field(base.get("text_books") or [], proposed.get("text_books") or []), + "reference_books": diff_list_field(base.get("reference_books") or [], proposed.get("reference_books") or []), + } + return diff diff --git a/backend/app/services/elective_categorization.py b/backend/app/services/elective_categorization.py new file mode 100644 index 0000000000000000000000000000000000000000..d37f04433485a120bd8dd3f9c0e7e452e702fa85 --- /dev/null +++ b/backend/app/services/elective_categorization.py @@ -0,0 +1,202 @@ +"""Guarded AI categorization for newly refined elective courses.""" + +import logging + +from app.services.openrouter import call as llm +from app.supabase import first_row, supabase + +logger = logging.getLogger(__name__) + +MIN_CONFIDENCE = 0.80 +ELECTIVE_MARKERS = ("AA", "AB", "BA", "BB") +SYSTEM_PROMPT = """You categorize PES University elective courses into existing specialization tracks. +Return only valid JSON in this exact shape: +{"confidence": 0.0, "assignments": [{"specialization_id": 1, "confidence": 0.0, "reasoning": "Short evidence-based reason."}]} +Use only IDs supplied in the prompt. Never invent a track or ID. Assign only clear matches. +For no clear match, return an empty assignments list and confidence below 0.80. +""" + + +def is_elective_course(course: dict) -> bool: + """Mirror the legacy elective-code grouping rule for semesters 5 and 6.""" + code = str(course.get("course_code") or "").replace(" ", "").upper() + return int(course.get("semester") or 0) in (5, 6) and any( + marker in code for marker in ELECTIVE_MARKERS + ) + + +def _course_content(course: dict) -> str: + """Extract a flat text representation of course content for the LLM prompt.""" + units = course.get("units") or [] + unit_text = [] + if isinstance(units, list): + for unit in units: + if isinstance(unit, dict): + unit_text.append( + f"{unit.get('title', '')}: {unit.get('content', '')}".strip() + ) + else: + unit_text.append(str(unit)) + parts = [ + course.get("course_title"), + course.get("prelude"), + course.get("objectives"), + course.get("course_outcomes"), + *unit_text, + course.get("tools_languages"), + ] + return "\n".join(str(p) for p in parts if p).strip()[:18000] + + +def _confirmation(reason: str, **extra) -> dict: + return {"assigned": False, "needs_human_confirmation": True, "reason": reason, **extra} + + +def _fetch_tracks(semester: int) -> list[dict]: + """Return specialization definitions for the given semester.""" + return ( + supabase.table("specialization_definitions") + .select("id,semester,letter,name,key,academic_year") + .eq("semester", semester) + .order("letter") + .execute() + .data + ) + + +def _validate_assignments(assignments: list, valid_ids: set[int]) -> dict | None: + """Validate each assignment entry; returns an error confirmation or None on success.""" + seen: set[int] = set() + for assignment in assignments: + try: + spec_id = int(assignment["specialization_id"]) + conf = float(assignment["confidence"]) + reasoning = str(assignment["reasoning"]).strip() + except (KeyError, TypeError, ValueError): + return _confirmation("invalid_model_assignment") + if spec_id not in valid_ids: + return _confirmation( + "unknown_specialization_id", specialization_id=spec_id + ) + if conf < MIN_CONFIDENCE or not reasoning: + return _confirmation( + "low_assignment_confidence", specialization_id=spec_id + ) + if spec_id not in seen: + seen.add(spec_id) + return None + + +def _persist_assignments(refined_id: int, accepted: list[dict]) -> int: + """Insert accepted assignments into the DB; returns the number created.""" + created = 0 + for assignment in accepted: + spec_id = assignment["specialization_id"] + exists = ( + supabase.table("course_specialization_assignments") + .select("id") + .eq("refined_id", refined_id) + .eq("specialization_id", spec_id) + .execute() + .data + ) + if not exists: + supabase.table("course_specialization_assignments").insert( + {"refined_id": refined_id, "specialization_id": spec_id} + ).execute() + created += 1 + return created + + +def _parse_llm_response(result) -> tuple[float, list[dict]] | None: + """Parse and validate the LLM response; returns (confidence, assignments) or None.""" + if not isinstance(result, dict): + return None + try: + confidence = float(result.get("confidence")) + except (TypeError, ValueError): + return None + assignments = result.get("assignments") + if not isinstance(assignments, list) or not assignments: + return None + return confidence, assignments + + +def categorize_refined_elective(refined_id: int) -> dict: + """Assign only existing specialization tracks with validated high-confidence output.""" + course = first_row( + supabase.table("refined_submissions").select("*").eq("id", refined_id) + ) + if not course: + raise LookupError("Refined submission not found") + if not course.get("is_elective") and not is_elective_course(course): + return { + "assigned": False, + "needs_human_confirmation": False, + "reason": "not_an_elective", + } + if not course.get("is_elective"): + supabase.table("refined_submissions").update({"is_elective": True}).eq( + "id", refined_id + ).execute() + + tracks = _fetch_tracks(int(course["semester"])) + if not tracks: + return _confirmation("no_specializations_for_semester", refined_id=refined_id) + + try: + result = llm( + SYSTEM_PROMPT, + f"""Course ID: {refined_id} +Course code: {course.get("course_code") or ""} +Course content: +{_course_content(course)} +Available specialization tracks: {tracks}""", + ) + except Exception: + logger.exception( + "Elective categorization model call failed for refined_id=%s", refined_id + ) + return _confirmation("model_error", refined_id=refined_id) + + parsed = _parse_llm_response(result) + if parsed is None: + return _confirmation("invalid_model_response", refined_id=refined_id) + + confidence, assignments = parsed + if confidence < MIN_CONFIDENCE: + return _confirmation( + "low_confidence_or_no_match", refined_id=refined_id, confidence=confidence + ) + + valid_ids = {int(track["id"]) for track in tracks} + validation_error = _validate_assignments(assignments, valid_ids) + if validation_error: + validation_error["refined_id"] = refined_id + return validation_error + + seen: set[int] = set() + accepted: list[dict] = [] + for assignment in assignments: + spec_id = int(assignment["specialization_id"]) + if spec_id not in seen: + seen.add(spec_id) + accepted.append( + { + "specialization_id": spec_id, + "confidence": float(assignment["confidence"]), + "reasoning": str(assignment["reasoning"]).strip(), + } + ) + + created = _persist_assignments(refined_id, accepted) + logger.info( + "Elective categorization refined_id=%s assignments=%s", refined_id, accepted + ) + return { + "assigned": True, + "needs_human_confirmation": False, + "refined_id": refined_id, + "assignments_created": created, + "assignments": accepted, + } diff --git a/backend/app/services/errors.py b/backend/app/services/errors.py new file mode 100644 index 0000000000000000000000000000000000000000..e74f8b53aec81b2949b2e2202fa8694a4c64029a --- /dev/null +++ b/backend/app/services/errors.py @@ -0,0 +1,16 @@ +import logging + +import sentry_sdk +from fastapi import HTTPException +from postgrest.exceptions import APIError + +logger = logging.getLogger(__name__) + + +def database_http_exception(exc: APIError) -> HTTPException: + message = str(exc) + if "schema cache" in message and "Could not find the table" in message: + return HTTPException(status_code=503, detail="Required database tables are missing. Run docs/schema.sql in Supabase.") + logger.error("Supabase APIError: %s", message) + sentry_sdk.capture_exception(exc) + return HTTPException(status_code=500, detail=f"Database request failed: {message[:200]}") diff --git a/backend/app/services/openrouter.py b/backend/app/services/openrouter.py new file mode 100644 index 0000000000000000000000000000000000000000..b91851ddda6cc3b0f98180c119e3b047e8e25cb8 --- /dev/null +++ b/backend/app/services/openrouter.py @@ -0,0 +1,475 @@ +import json +import logging +import os +import random +import time + +import httpx +from dotenv import load_dotenv +from httpx import Client, HTTPError, HTTPStatusError + +load_dotenv("../.env") + +URL = os.environ["OPENROUTER_URL"].strip() +KEY = os.environ["OPENROUTER_API_KEY"].strip() +MODEL = os.environ["OPENROUTER_MODEL"].strip() +FALLBACK_MODEL = os.environ.get("OPENROUTER_FALLBACK_MODEL", "").strip() or None +logger = logging.getLogger(__name__) + +_context_length: int | None = None +_last_fetch_attempt: float = 0.0 + + +def fetch_context_length() -> int: + global _context_length, _last_fetch_attempt + if _context_length is not None: + return _context_length + now = time.time() + if now - _last_fetch_attempt < 60: + return _context_length or 128000 + _last_fetch_attempt = now + api_base = URL.rsplit("/chat/completions", 1)[0] + try: + with Client(timeout=15) as client: + resp = client.get(f"{api_base}/model/{MODEL}", headers=_headers()) + resp.raise_for_status() + data = resp.json().get("data") or {} + _context_length = int(data.get("context_length") or 128000) + logger.info("Model %s context_length=%d", MODEL, _context_length) + return _context_length + except HTTPStatusError as exc: + if exc.response.status_code == 404: + logger.debug("Model info endpoint not available for %s (provider does not support it)", MODEL) + else: + logger.warning("Failed to fetch context_length for %s: %s", MODEL, exc) + return 128000 + except Exception as exc: + logger.warning("Failed to fetch context_length for %s: %s", MODEL, exc) + return 128000 + + +def context_length() -> int: + return _context_length or 128000 + + +_TOOL_LABELS = { + "get_course_codes": "Looking up courses", + "get_current_course_json": "Reading course data", + "get_course_syllabus": "Reading syllabus", + "get_course_textbooks": "Reading textbooks", + "get_course_deterministic": "Reading course properties", + "get_course_lab": "Reading lab details", + "get_course_fields": "Reading course fields", + "batch_read_courses": "Reading courses", + "get_curriculum_json": "Loading curriculum", + "get_curriculum_stats": "Computing statistics", + "create_course_draft": "Creating draft", + "create_refined_course": "Creating course", + "create_document_draft": "Creating document draft", + "create_report": "Generating report", + "create_spreadsheet": "Generating spreadsheet", + "diff_course_json": "Comparing courses", + "diff_versions": "Comparing versions", + "get_version": "Loading snapshot", + "update_deterministic_fields": "Updating course", + "get_attachment_text": "Reading attachment", + "list_specializations": "Loading specializations", + "define_specialization": "Creating specialization", + "assign_elective_to_tracks": "Categorizing elective", + "get_course_assignments": "Reading elective assignments", + "categorize_elective": "Categorizing elective", + "fetch_url": "Fetching URL", + "web_search": "Searching the web", + "signal_done": "Finalizing", + "get_document_draft": "Reading document draft", + "create_curriculum_version": "Creating snapshot", + "get_course_draft": "Reading draft", + "remove_elective_from_tracks": "Removing elective", + "ask_user": "Asking user", + "get_preview_url": "Getting preview URL", + "list_courses": "Looking up courses", + "update_agent_draft": "Updating draft", +} + + +def tool_status_label(name: str) -> str: + return _TOOL_LABELS.get(name, name.replace("_", " ").title()) + + +class OpenRouterError(RuntimeError): + def __init__(self, status_code: int, retry_after: str | None = None, message: str | None = None, provider_message: str = ""): + self.status_code = status_code + self.retry_after = retry_after + self._message = message + self.provider_message = provider_message + super().__init__(self.message) + + @property + def message(self) -> str: + if self._message: + return self._message + if self.status_code in {429, 503}: + if self.retry_after: + return f"Model provider is rate limited. Try again in {self.retry_after} seconds." + return "Model provider is rate limited. Please try again shortly." + return "Model provider request failed. Please try again later." + + +def _messages(system: str, messages: list[dict]) -> list[dict]: + return [{"role": "system", "content": system}, *messages] + + +def _headers() -> dict: + return {"Authorization": f"Bearer {KEY}"} + + +def _message_content(data: dict) -> str: + try: + return _assistant_message(data)["content"].strip() + except (KeyError, TypeError, AttributeError) as exc: + 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 + + +def _raise_for_status(response) -> None: + try: + response.raise_for_status() + except HTTPStatusError as exc: + raise OpenRouterError( + exc.response.status_code, + exc.response.headers.get("retry-after"), + provider_message=exc.response.text[:500], + ) from exc + + +_RETRYABLE = (httpx.ReadError, httpx.ConnectError, httpx.RemoteProtocolError, httpx.TimeoutException) +_RETRYABLE_STATUS = {502, 503} +_MAX_RETRIES = 3 +_FIB = [1, 1, 2, 3, 5, 8, 13] + + +def _retry_sleep(attempt: int, retry_after: str | None = None) -> None: + if retry_after: + try: + time.sleep(min(float(retry_after), 30)) + return + except (ValueError, TypeError): + pass + base = _FIB[min(attempt, len(_FIB) - 1)] + jitter = base * 0.25 * (2 * random.random() - 1) + time.sleep(max(0.5, base + jitter)) + + +def _provider_error(data: dict) -> str: + error = data.get("error") if isinstance(data, dict) else None + if isinstance(error, dict): + return str(error.get("message") or error) + return str(error or data)[:500] + + +def _public_shape_error(provider_message: str) -> str: + lowered = provider_message.lower() + if "tool" in lowered and ("support" in lowered or "unsupported" in lowered): + return "The selected model does not support editor tools. Switch to a tool-calling model and try again." + if "rate" in lowered or "quota" in lowered: + return "Model provider is rate limited. Please try again shortly." + return "Model provider returned an invalid response. Please try again later." + + +def _assistant_message(data: dict) -> dict: + try: + message = data["choices"][0]["message"] + except (KeyError, IndexError, TypeError) as exc: + provider_message = _provider_error(data) + raise OpenRouterError(502, message=_public_shape_error(provider_message), provider_message=provider_message) from exc + if not isinstance(message, dict): + raise OpenRouterError(502, message="Model provider returned an invalid response. Please try again later.", provider_message=str(message)[:500]) + return message + + +def _chat_message(messages: list[dict], tools: list[dict], model: str = MODEL) -> tuple[dict, int]: + with Client(timeout=120) as client: + response = client.post(URL, headers=_headers(), json={"model": model, "messages": messages, "tools": tools}) + _raise_for_status(response) + data = response.json() + prompt_tokens = (data.get("usage") or {}).get("prompt_tokens") or 0 + return _assistant_message(data), prompt_tokens + + +def _chat_with_tools(messages: list[dict], tools: list[dict], tool_runner, on_tool_result=None): + last_prompt_tokens = 0 + active_model = MODEL + switched_to_fallback = False + + for i in range(50): + yield {"$status": f"Thinking (step {i + 1})..."} + message = None + prompt_tokens = 0 + + models_to_try = [active_model] + + for model in models_to_try: + for attempt in range(_MAX_RETRIES): + try: + message, prompt_tokens = _chat_message(messages, tools, model=model) + break + except OpenRouterError as exc: + if exc.status_code == 429: + yield {"$status": f"Rate limited by {model}. {exc.message}"} + logger.warning("Rate limited by %s (attempt %d/%d): %s", model, attempt + 1, _MAX_RETRIES, exc.message) + raise + if exc.status_code in _RETRYABLE_STATUS: + logger.warning("Provider %s error %d (attempt %d/%d): %s", model, exc.status_code, attempt + 1, _MAX_RETRIES, exc.message) + if attempt < _MAX_RETRIES - 1: + yield {"$status": f"Server error ({exc.status_code}). Retrying {model} (attempt {attempt + 2}/{_MAX_RETRIES})..."} + _retry_sleep(attempt, exc.retry_after) + continue + raise + except Exception as exc: + logger.warning("Network error with %s (attempt %d/%d): %s", model, attempt + 1, _MAX_RETRIES, exc) + if attempt < _MAX_RETRIES - 1: + yield {"$status": f"Connection error. Retrying {model} (attempt {attempt + 2}/{_MAX_RETRIES})..."} + _retry_sleep(attempt) + continue + + if message is not None: + break + + if model == MODEL and FALLBACK_MODEL and not switched_to_fallback: + switched_to_fallback = True + active_model = FALLBACK_MODEL + yield {"$status": "Primary model unavailable. Switching to backup model..."} + logger.info("Primary model %s failed after %d attempts, switching to %s", MODEL, _MAX_RETRIES, FALLBACK_MODEL) + models_to_try.append(FALLBACK_MODEL) + + if message is None: + raise OpenRouterError(502, message="All model providers failed after retries. Please try again later.") + + if prompt_tokens: + last_prompt_tokens = prompt_tokens + + tool_calls = message.get("tool_calls") or [] + if not tool_calls: + text = str(message.get("content") or "").strip() + if text: + yield text + elif i > 0: + yield "The agent finished without a response. Try rephrasing your request or breaking it into smaller steps." + if last_prompt_tokens: + yield {"$usage": {"prompt_tokens": last_prompt_tokens, "context_length": context_length()}} + return + messages.append({"role": "assistant", "content": message.get("content") or "", "tool_calls": tool_calls}) + + ack_text = str(message.get("content") or "").strip() + if ack_text: + yield ack_text + + done = False + result = {} + for tool_call in tool_calls: + name = (tool_call.get("function") or {}).get("name") or "" + try: + arguments = _tool_arguments(tool_call) + except (ValueError, json.JSONDecodeError) as exc: + result = {"error": f"Invalid tool arguments: {exc}"} + yield {"$status": f"{tool_status_label(name)}..."} + yield {"$event": "tool_result", "name": name, "status": "error"} + messages.append({"role": "tool", "tool_call_id": tool_call.get("id") or name, "name": name, "content": json.dumps(result, ensure_ascii=False)}) + continue + yield {"$status": f"{tool_status_label(name)}..."} + yield {"$event": "tool_call", "name": name, "arguments": arguments} + try: + result = _run_tool(tool_runner, name, arguments) + except ValueError as exc: + result = {"error": str(exc)} + yield {"$event": "tool_result", "name": name, "status": "ok" if "error" not in result else "error"} + if on_tool_result: + on_tool_result(name, result) + content = json.dumps(result, ensure_ascii=False) + if len(content) > 50000: + content = content[:50000] + '... [truncated; use semester filter or batch_read_courses for smaller results]' + messages.append( + { + "role": "tool", + "tool_call_id": tool_call.get("id") or name, + "name": name, + "content": content, + } + ) + if (result or {}).get("done"): + done = True + if done: + summary = str((result or {}).get("summary") or "").strip() + if summary: + yield summary + if last_prompt_tokens: + yield {"$usage": {"prompt_tokens": last_prompt_tokens, "context_length": context_length()}} + return + yield "I created tool results, but could not finish the response. Please check the Review panel." + if last_prompt_tokens: + yield {"$usage": {"prompt_tokens": last_prompt_tokens, "context_length": context_length()}} + + +def _tool_arguments(tool_call: dict) -> dict: + raw = (tool_call.get("function") or {}).get("arguments") or "{}" + arguments = json.loads(raw) if isinstance(raw, str) else raw + if not isinstance(arguments, dict): + raise ValueError("Tool arguments must be an object") + return arguments + + +def _run_tool(tool_runner, name: str, arguments: dict) -> dict: + try: + return tool_runner(name, arguments) + except Exception as exc: + return {"error": str(exc)} + + +def _log_stream_fallback(exc: Exception) -> None: + if isinstance(exc, HTTPStatusError): + response = exc.response + logger.warning( + "OpenRouter streaming failed; retrying without stream. status=%s model=%s body=%s", + response.status_code, + MODEL, + response.text[:500], + ) + return + logger.warning("OpenRouter streaming failed; retrying without stream. error=%s model=%s", exc.__class__.__name__, MODEL) + + +def _stream_chat(messages: list[dict], model: str = MODEL): + payload = {"model": model, "messages": messages, "stream": True} + last_exc = None + for attempt in range(_MAX_RETRIES): + try: + with Client(timeout=120) as client: + with client.stream("POST", URL, headers=_headers(), json=payload) as response: + if response.is_error: + response.read() + response.raise_for_status() + for line in response.iter_lines(): + token = _stream_token(line) + if token: + yield token + return + except _RETRYABLE as exc: + last_exc = exc + logger.warning("Network error in stream with %s (attempt %d/%d): %s", model, attempt + 1, _MAX_RETRIES, exc) + if attempt < _MAX_RETRIES - 1: + _retry_sleep(attempt) + except OpenRouterError as exc: + last_exc = exc + if exc.status_code in _RETRYABLE_STATUS: + logger.warning("Provider %s error %d in stream (attempt %d/%d): %s", model, exc.status_code, attempt + 1, _MAX_RETRIES, exc.message) + if attempt < _MAX_RETRIES - 1: + _retry_sleep(attempt, exc.retry_after) + continue + raise + if model == MODEL and FALLBACK_MODEL: + logger.info("Primary model %s failed in stream, falling back to %s", MODEL, FALLBACK_MODEL) + yield from _stream_chat(messages, model=FALLBACK_MODEL) + return + raise OpenRouterError(502, message="Network error connecting to model provider. Please try again.") from last_exc + + +def _chat_text(messages: list[dict], model: str = MODEL) -> str: + last_exc = None + for attempt in range(_MAX_RETRIES): + try: + with Client(timeout=120) as client: + response = client.post(URL, headers=_headers(), json={"model": model, "messages": messages}) + _raise_for_status(response) + return _message_content(response.json()) + except _RETRYABLE as exc: + last_exc = exc + logger.warning("Network error in chat_text with %s (attempt %d/%d): %s", model, attempt + 1, _MAX_RETRIES, exc) + if attempt < _MAX_RETRIES - 1: + _retry_sleep(attempt) + except OpenRouterError as exc: + last_exc = exc + if exc.status_code in _RETRYABLE_STATUS: + logger.warning("Provider %s error %d in chat_text (attempt %d/%d): %s", model, exc.status_code, attempt + 1, _MAX_RETRIES, exc.message) + if attempt < _MAX_RETRIES - 1: + _retry_sleep(attempt, exc.retry_after) + continue + raise + if model == MODEL and FALLBACK_MODEL: + logger.info("Primary model %s failed in chat_text, falling back to %s", MODEL, FALLBACK_MODEL) + return _chat_text(messages, model=FALLBACK_MODEL) + raise OpenRouterError(502, message="Network error connecting to model provider. Please try again.") from last_exc + + +def call(system: str, user: str, model: str = MODEL) -> dict: + last_exc = None + for attempt in range(_MAX_RETRIES): + try: + with Client(timeout=120) as c: + r = c.post( + URL, + headers=_headers(), + json={"model": model, "messages": _messages(system, [{"role": "user", "content": user}])}, + ) + _raise_for_status(r) + data = r.json() + text = _message_content(data) + text = text.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip() + return json.loads(text) + except _RETRYABLE as exc: + last_exc = exc + logger.warning("Network error in call with %s (attempt %d/%d): %s", model, attempt + 1, _MAX_RETRIES, exc) + if attempt < _MAX_RETRIES - 1: + _retry_sleep(attempt) + except OpenRouterError as exc: + last_exc = exc + if exc.status_code in _RETRYABLE_STATUS: + logger.warning("Provider %s error %d in call (attempt %d/%d): %s", model, exc.status_code, attempt + 1, _MAX_RETRIES, exc.message) + if attempt < _MAX_RETRIES - 1: + _retry_sleep(attempt, exc.retry_after) + continue + raise + if model == MODEL and FALLBACK_MODEL: + logger.info("Primary model %s failed in call, falling back to %s", MODEL, FALLBACK_MODEL) + return call(system, user, model=FALLBACK_MODEL) + raise OpenRouterError(502, message="Network error connecting to model provider. Please try again.") from last_exc + + +def stream_chat(system: str, messages: list[dict], tools: list[dict] | None = None, tool_runner=None, on_tool_result=None): + chat_messages = _messages(system, messages) + if tools and tool_runner: + yield from _chat_with_tools(chat_messages, tools, tool_runner, on_tool_result) + return + + emitted = False + try: + for token in _stream_chat(chat_messages): + emitted = True + yield token + except (HTTPError, json.JSONDecodeError) as exc: + _log_stream_fallback(exc) + + if emitted: + return + + text = _chat_text(chat_messages) + if text: + yield text + + +def _stream_token(line: str) -> str: + if not line or line.startswith(":"): + return "" + if not line.startswith("data:"): + return "" + data = line.removeprefix("data:").strip() + if data == "[DONE]": + return "" + chunk = json.loads(data) + choice = (chunk.get("choices") or [{}])[0] + delta = choice.get("delta") or {} + return delta.get("content") or "" + + +try: + fetch_context_length() +except Exception: + pass diff --git a/backend/app/services/refinement.py b/backend/app/services/refinement.py new file mode 100644 index 0000000000000000000000000000000000000000..ce6fc2b5f2e1d43ab99844ea689e6fb1a9f85aca --- /dev/null +++ b/backend/app/services/refinement.py @@ -0,0 +1,630 @@ +import logging +import re + +from app.supabase import first_row, supabase +from app.services.books import parse_books, raw_book_section +from app.services.curriculum import invalidate_curriculum_cache +from app.services.deterministic import compute_hours, compute_program, compute_course_type +from app.services.openrouter import call as llm + +logger = logging.getLogger(__name__) + +WORD = re.compile(r"\w+") +UNIT_LINE = re.compile(r"\b(Unit\s+\d+|Module\s+(?:\d+|[IVX]+))\s*[:.-]?\s*(.*)$", re.IGNORECASE) +HOURS_SUFFIX = re.compile(r"\s*\b\d+\s+Hours?\b\s*$", re.IGNORECASE) +DESIRABLE_LINE = re.compile(r"\bDesirable\s+([^\n]+)", re.IGNORECASE) +COURSE_CODE = re.compile(r"\bCourse\s+Code\s*:?\s*([A-Z0-9]+)", re.IGNORECASE) +PAGE_NOISE = re.compile( + r"P\.?\s*E\.?\s*S\.?\s*University|" + r"Curriculum|" + r"\s*:-\s*[A-Za-z]*\s*\d{4}\s*-\s*\d{4}\b|" + r"\b\d+\s*\|\s*Page\b", + re.IGNORECASE, +) + +SYS = """You refine PES University course submissions for the UG curriculum template. +Return only valid JSON. No markdown. No commentary. + +Your job: take a raw faculty submission and produce a clean, template-ready course record. + +CRITICAL -- you MUST fix all of the following in every field: +- Spelling errors (e.g. "lerning" -> "learning", "neual" -> "neural", "introducton" -> "introduction") +- Title case for the course title (e.g. "machine lerning" -> "Machine Learning") +- Title case for unit titles (e.g. "Unit 1: Introducton to ML" -> "Unit 1: Introduction to ML") +- Grammar and punctuation in all text fields +- Capitalize the first letter of every sentence in content fields +- Fix malformed abbreviations (e.g. "ML" is fine, but "ml" should be "ML") + +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. + +Editing rules: +- If prelude, objectives, outcomes, units, tools, or books are already present, clean them instead of replacing them. +- Generate a field only when that field is missing, incomplete, or too rough for the template. +- Preserve every syllabus topic and subtopic from Raw Course Content. +- Do not summarize away, omit, replace, merge away, or simplify syllabus topics. +- Do not add advanced, fashionable, or unrelated topics. +- Do not invent course codes, books, references, departments, credits, or external prerequisites. +- Keep objectives and outcomes direct, concise, and aligned with the submitted scope. +- 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. +- Unit hours must sum to the supplied total unit hours. +- For tools/languages, use Preferred Tools / Languages when provided; otherwise identify course-specific tools, languages, platforms, or AI tools from Raw Course Content. +- Do not use canned defaults for tools/languages. +- For desirable knowledge, use only relevant knowledge from Previously Completed Courses. Return an empty string when none apply. +- Generate laboratory experiments only when practical hours are non-zero. Keep them tied to submitted topics. +- Copy and clean books from the submitted book fields only. +- Use empty strings or empty arrays for truly unavailable optional content. Do not output "-". +""" + +SCHEMA = """{ + "course_title": "corrected course title", + "prelude": "one short paragraph", + "objectives": ["3 to 4 objectives"], + "course_outcomes": ["3 to 4 measurable outcomes"], + "units": [{"title": "Unit 1: Title", "content": "compact topic list", "hours": 14}], + "lab_experiments": ["concise lab item"], + "tools_languages": "course-specific tools, languages, platforms, or AI tools", + "desirable_knowledge": "short text based only on previously completed courses, or empty string", + "text_books": ["submitted text books only"], + "reference_books": ["submitted reference books only"] +}""" + +EXAMPLES = """Behavior examples calibrated to PESU curriculum style. +Use them only to learn fidelity, field shape, and level of detail. Do not copy example facts into the real answer. + +Example 1: already refined input +Input: +Course Title: Web Technologies +Weekly Hours: L 4, T 0, P 0, S 4, C 4 +Total Unit Hours: 56 +Raw Course Content: +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 +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 +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 +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 +Preferred Tools / Languages: HTML, CSS, JavaScript, MERN Technologies, GitHub, AI tools: Copilot and Tabnine +Previously Completed Courses: None +Text Books: +1. Robin Nixon, Learning PHP, MySQL and JavaScript, 5th Edition, O'Reilly Media, 2018. +2. Vasan Subramanian, Pro MERN Stack: Full Stack Web App Development with Mongo, Express, React, and Node, Apress, 2017. +Output: +{ + "course_title": "Web Technologies", + "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.", + "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."], + "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."], + "units": [ + {"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}, + {"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}, + {"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}, + {"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} + ], + "lab_experiments": [], + "tools_languages": "HTML, CSS, JavaScript, MERN Technologies, GitHub; AI tools: Copilot, Tabnine", + "desirable_knowledge": "", + "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."], + "reference_books": [] +} + +Example 2: detailed lab-integrated input with title and formatting issues +Input: +Course Title: data structures & its aplications +Weekly Hours: L 4, T 0, P 2, S 5, C 5 +Total Unit Hours: 56 +Raw Course Content: +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 +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 +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 +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 +Laboratory: linked list operations; stack applications; queue applications; binary tree and BST applications; graph data structure applications; hashing techniques. +Preferred Tools / Languages: C-Programming language; AI tools: VisuAlgo (Interactive Visualizations), Algorithm Visualizer (AI Explanations) +Previously Completed Courses: Problem Solving with C +Text Books: 1. Langsam Yedidyah, Moshe J. Augenstein, Aaron M. Tenenbaum, Data Structures using C / C++, Pearson Education Inc., 2nd Edition, 2015. +Output: +{ + "course_title": "Data Structures and Applications", + "prelude": "This course introduces fundamental data structure concepts with emphasis on their theoretical foundations, implementation techniques, and practical applications using a programming language.", + "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."], + "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."], + "units": [ + {"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}, + {"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}, + {"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}, + {"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} + ], + "lab_experiments": ["Linked list and advanced operations.", "Stack applications.", "Queue applications.", "Binary tree and binary search tree applications.", "Graph data structure applications.", "Hashing techniques."], + "tools_languages": "C-Programming language; AI tools: VisuAlgo (Interactive Visualizations), Algorithm Visualizer (AI Explanations)", + "desirable_knowledge": "Problem Solving with C", + "text_books": ["Langsam Yedidyah, Moshe J. Augenstein, Aaron M. Tenenbaum, Data Structures using C / C++, Pearson Education Inc., 2nd Edition, 2015."], + "reference_books": [] +} +""" + + +def _lines(value) -> list[str]: + if not value: + return [] + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + return [line.strip() for line in str(value).splitlines() if line.strip()] + + +def _clean_noise(value: str) -> str: + return re.sub(r"\s+", " ", PAGE_NOISE.sub(" ", value)).strip() + + +def _books(*values) -> list[str]: + return parse_books(*values) + + +def _raw_book_section(raw_content: str, kind: str) -> str: + return raw_book_section(raw_content, kind) + + +def _words(text: str) -> int: + return len(WORD.findall(text)) + + +def _clean_part(value: str) -> str: + return _clean_noise(value.strip(" \t-*\u2022")) + + +def _split_parts(text: str) -> list[str]: + lines = [_clean_part(line) for line in text.splitlines() if _clean_part(line)] + if len(lines) >= 4: + return lines + compact = _clean_part(text) + parts = [_clean_part(part) for part in re.split(r"(?<=[.!?])\s+", compact) if _clean_part(part)] + if len(parts) >= 4: + return parts + words = compact.split() + if not words: + return [] + return [ + " ".join(words[(index * len(words)) // 4 : ((index + 1) * len(words)) // 4]) + for index in range(4) + ] + + +def _four_units_from_raw(raw_content: str) -> list[dict]: + parts = _split_parts(raw_content) + if not parts: + return [] + buckets = [[] for _ in range(4)] + for index, part in enumerate(parts): + buckets[min(index * 4 // len(parts), 3)].append(part) + return [ + {"title": f"Unit {index + 1}", "content": " ".join(bucket).strip(), "hours": 0} + for index, bucket in enumerate(buckets) + if bucket + ] + + +def _course_contents(raw_content: str) -> str: + lines = raw_content.splitlines() + start = None + for index, line in enumerate(lines): + if "Course" in line and "Contents" in line: + start = index + break + if "Course" in line and any("Contents" in item for item in lines[index + 1 : index + 3]): + start = index + break + if start is None: + return raw_content + + end = len(lines) + for index in range(start + 1, len(lines)): + marker = lines[index].strip() + if not marker: + continue + if any(marker.startswith(label) for label in ("Laboratory", "Text Book", "Reference", "Course Outcome", "Assignment /")): + end = index + break + return "\n".join(lines[start:end]).strip() + + +def _syllabus_line(line: str) -> str: + clean = _clean_part(line) + clean = re.sub(r"^(Course\s+Contents|Contents)\s+", "", clean, flags=re.IGNORECASE) + clean = re.sub(r"^Course\s+(?=Unit\s+\d+|Module\s+(?:\d+|[IVX]+))", "", clean, flags=re.IGNORECASE) + return HOURS_SUFFIX.sub("", clean).strip() + + +def _units_from_course_contents(raw_content: str, fallback: bool = True) -> list[dict]: + content = _course_contents(raw_content) + lines = [line.strip() for line in content.splitlines() if line.strip()] + units = [] + current = None + + for line in lines: + clean = _syllabus_line(line) + if not clean: + continue + match = UNIT_LINE.search(clean) + if match: + if current: + units.append(current) + label = match.group(1).strip().title() + rest = match.group(2).strip(" :-") + if " - " in rest: + title, inline_content = rest.split(" - ", 1) + elif _words(rest) > 12: + title, inline_content = "", rest + else: + title, inline_content = rest, "" + current = { + "title": f"{label}: {title.strip()}" if title.strip() else label, + "content": inline_content.strip(), + "hours": 0, + } + continue + if current: + current["content"] = f"{current['content']} {clean}".strip() + + if current: + units.append(current) + if len(units) == 4: + return units + return _four_units_from_raw(content) if fallback else [] + + +def _unit_text(unit: dict) -> str: + title = str(unit.get("title", "")).strip() + content = str(unit.get("content", "")).strip() + if title and content: + return f"{title}: {content}" + return title or content + + +def _fit_four_units(units: list[dict], raw_content: str) -> list[dict]: + raw = _course_contents(raw_content).strip() + raw_units = _units_from_course_contents(raw_content, fallback=False) + raw_unit_words = _words(" ".join(_unit_text(unit) for unit in raw_units)) + if raw_units and raw_unit_words >= _words(raw) * 0.7: + return raw_units + if raw and _words(" ".join(_unit_text(unit) for unit in units)) < _words(raw) * 0.8: + return raw_units or _units_from_course_contents(raw_content) + if len(units) > 4: + fourth = units[3].copy() + fourth["content"] = " ".join(_unit_text(unit) for unit in units[3:]).strip() + return units[:3] + [fourth] + if len(units) == 4: + return units + if raw: + return raw_units or _units_from_course_contents(raw_content) + return units + + +def _units(value) -> list[dict]: + units = [] + for item in value if isinstance(value, list) else []: + if not isinstance(item, dict): + continue + title = str(item.get("title", "")).strip() + content = str(item.get("content", "")).strip() + hours_raw = str(item.get("hours", "0")) + hours = int("".join(ch for ch in hours_raw if ch.isdigit()) or 0) + if title or content: + units.append({"title": title, "content": content, "hours": hours}) + return units + + +def _assign_hours(units: list[dict], total_hours: int) -> list[dict]: + if not units: + return units + for unit in units: + unit["hours"] = 14 + return units + + +def _text(*values) -> str: + for value in values: + if isinstance(value, str): + text = value.strip() + if text and text != "-": + return text + return "" + + +def _course_code(raw_content: str) -> str: + match = COURSE_CODE.search(raw_content or "") + return match.group(1).upper() if match else "" + + +def _prior_course_titles(sub: dict) -> list[str]: + semester = int(sub["semester"]) + if semester <= 1: + return [] + rows = ( + supabase.table("refined_submissions") + .select("submission_id,course_code,course_title,semester") + .lt("semester", semester) + .order("semester") + .execute() + .data + ) + ids = [row["submission_id"] for row in rows if row.get("submission_id")] + submissions = supabase.table("submissions").select("id,target_department,raw_course_content").in_("id", ids).execute().data if ids else [] + departments = {row["id"]: row.get("target_department") for row in submissions} + raw_codes = {row["id"]: _course_code(row.get("raw_course_content") or "") for row in submissions} + titles = [] + for row in rows: + if departments.get(row.get("submission_id")) != sub.get("target_department"): + continue + title = str(row.get("course_title") or "").strip() + code = str(row.get("course_code") or raw_codes.get(row.get("submission_id")) or "").strip() + label = f"{code} - {title}" if code else title + if title and label not in titles: + titles.append(label) + return titles + + +def _normalized(text: str) -> str: + return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip() + + +def _singularized(text: str) -> str: + return " ".join(word[:-1] if len(word) > 3 and word.endswith("s") else word for word in _normalized(text).split()) + + +def _core_key(text: str) -> str: + skip = {"a", "an", "and", "for", "in", "its", "of", "the", "to", "with"} + return " ".join(word for word in _singularized(text).split() if word not in skip) + + +def _prior_keys(course: str) -> list[str]: + match = re.match(r"\s*([A-Z]{2}\d{2}[A-Z0-9]+)\s*[-:]\s*(.+)", course) + code = match.group(1).lower() if match else "" + title = match.group(2) if match else course + return [key for key in (code, _normalized(title), _singularized(title), _core_key(title)) if key] + + +SPELLING_FIXES = { + "Introducton": "Introduction", + "introducton": "Introduction", + "Introdcution": "Introduction", + "Neual": "Neural", + "neual": "neural", + "Adavnced": "Advanced", + "adavnced": "advanced", + "Applicatons": "Applications", + "applicatons": "applications", + "preprocesing": "preprocessing", + "recurent": "recurrent", + "anomal": "anomaly", + "stuides": "studies", + "lerning": "learning", + "inteligence": "intelligence", + "artifical": "artificial", + "concurrncy": "concurrency", + "procesing": "processing", + "optimisation": "optimization", + "maximisation": "maximization", + "minimisation": "minimization", + "behaviour": "behavior", + "colour": "color", + " favour ": " favor ", + "analyse": "analyze", + "recognise": "recognize", + "summarise": "summarize", + "characterise": "characterize", + "utilise": "use", + "initialise": "initialize", + "licence": "license", + "defence": "defense", + "programme": "program", + "centre": "center", + "metre": "meter", + "litre": "liter", + "catalogue": "catalog", + "dialogue": "dialog", + "analysing": "analyzing", + "recognising": "recognizing", + "summarising": "summarizing", + "utilising": "using", + "initialising": "initializing", + "labelling": "labeling", + "modelling": "modeling", + "travelling": "traveling", + "cancelling": "canceling", + "fulfil ": "fulfill ", + "enrol ": "enroll ", + "skillset": "skill set", + "dataset": "data set", + "email ": "e-mail ", + "e mail": "e-mail", +} + + +def _fix_spelling(text: str) -> str: + if not text: + return text + for wrong, right in SPELLING_FIXES.items(): + text = text.replace(wrong, right) + return text + + +def _title_case(text: str) -> str: + if not text: + return text + words = text.split() + minor = {"a", "an", "the", "and", "but", "or", "for", "nor", "in", "on", "at", "to", "by", "of", "with", "vs"} + result = [] + for i, w in enumerate(words): + if i == 0 or w.lower() not in minor: + result.append(w[:1].upper() + w[1:] if w.islower() else w) + else: + result.append(w.lower()) + return " ".join(result) + + +def _capitalize_sentences(text: str) -> str: + if not text: + return text + sentences = re.split(r"(?<=[.!?])\s+", text) + return " ".join(s[0].upper() + s[1:] if s else s for s in sentences) + + +def _post_process(out: dict) -> dict: + if not out: + return out + + title = out.get("course_title") or "" + title = _fix_spelling(title) + title = _title_case(title) + out["course_title"] = title + + for field in ("prelude", "tools_languages", "desirable_knowledge"): + val = out.get(field) or "" + if val: + out[field] = _capitalize_sentences(_fix_spelling(val)) + + for field in ("objectives", "course_outcomes", "lab_experiments"): + items = out.get(field) or [] + if isinstance(items, list): + out[field] = [_capitalize_sentences(_fix_spelling(item)) for item in items] + + for unit in out.get("units") or []: + if isinstance(unit, dict): + unit["title"] = _fix_spelling(unit.get("title") or "") + unit["content"] = _capitalize_sentences(_fix_spelling(unit.get("content") or "")) + + for field in ("text_books", "reference_books"): + items = out.get(field) or [] + if isinstance(items, list): + out[field] = [_fix_spelling(item) for item in items] + + return out + + +def _prior_matches(text: str, prior_courses: list[str]) -> str: + normalized = _normalized(text) + singularized = _singularized(text) + core = _core_key(text) + 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))] + return ", ".join(matches) + + +def _submitted_desirable(raw_content: str, prior_courses: list[str]) -> str | None: + match = DESIRABLE_LINE.search(raw_content or "") + if not match: + return None + value = _text(match.group(1)) + if not value: + return "" + return _prior_matches(value, prior_courses) or None + + +def _desirable(value, prior_courses: list[str], raw_content: str = "") -> str: + if not prior_courses: + return "" + submitted = _submitted_desirable(raw_content, prior_courses) + if submitted is not None: + return submitted + return _prior_matches(_text(value), prior_courses) + + +def _courses_text(courses: list[str]) -> str: + return "\n".join(f"- {course}" for course in courses) if courses else "None" + + +def build_refined_payload(sub: dict, out: dict, prior_courses: list[str] | None = None) -> dict: + out = out or {} + prior_courses = prior_courses or [] + det = compute_hours(sub["credit_category"]) + total_unit_hours = det["lecture_hours"] * 14 + raw_content = sub.get("raw_course_content") or "" + text_book_source = _raw_book_section(raw_content, "text") or sub["text_books"] + reference_book_source = _raw_book_section(raw_content, "reference") or sub.get("reference_books") + units = _assign_hours(_fit_four_units(_units(out.get("units")), sub.get("raw_course_content") or ""), total_unit_hours) + + objectives = _lines(out.get("objectives"))[:4] + course_outcomes = _lines(out.get("course_outcomes"))[:4] or objectives + + from app.services.elective_categorization import is_elective_course + + code = _course_code(raw_content) or sub.get("course_code", "") + is_elective = is_elective_course({"course_code": code, "semester": sub["semester"]}) + return { + "submission_id": sub["id"], + "semester": int(sub["semester"]), + "course_code": code, + "course_title": sub["course_title"], + "program": compute_program(sub["target_department"]), + "course_type": "Elective" if is_elective else compute_course_type(sub["credit_category"]), + "is_elective": is_elective, + **det, + "prelude": _text(out.get("prelude"), f"This course covers {sub['course_title'].strip()}."), + "objectives": objectives, + "course_outcomes": course_outcomes, + "units": units, + "lab_experiments": _lines(out.get("lab_experiments"))[:10] if det["practical_hours"] else [], + "tools_languages": _text(out.get("tools_languages"), sub.get("preferred_tools")), + "desirable_knowledge": _desirable(out.get("desirable_knowledge"), prior_courses, raw_content), + "text_books": _books(text_book_source), + "reference_books": _books(reference_book_source), + "status": "refined", + } + + +def refine(submission_id: int): + sub = first_row(supabase.table("submissions").select("*").eq("id", submission_id)) + if not sub: + raise LookupError("Submission not found") + det = compute_hours(sub["credit_category"]) + ctype = compute_course_type(sub["credit_category"]) + total_unit_hours = det["lecture_hours"] * 14 + prior_courses = _prior_course_titles(sub) + + prompt = f"""Return JSON matching this schema. Include every key: +{SCHEMA} + +{EXAMPLES} + +Now refine the real submission below. + +Course Title: {sub["course_title"]} +Offering Department: {sub["offering_department"]} +Target Department: {sub["target_department"]} +Semester: {sub["semester"]} +Credit Category: {sub["credit_category"]} +Course Type: {ctype} +Weekly Hours: L {det["lecture_hours"]}, T {det["tutorial_hours"]}, P {det["practical_hours"]}, S {det["self_study"]}, C {det["credits"]} +Total Unit Hours: {total_unit_hours} +Raw Course Content: +{sub["raw_course_content"]} + +Previously Completed Courses: +{_courses_text(prior_courses)} + +Text Books: +{sub["text_books"]} + +Reference Books: +{sub.get("reference_books") or "-"} + +Preferred Tools / Languages: +{sub.get("preferred_tools") or "-"}""" + + out = llm(SYS, prompt) + merged = build_refined_payload(sub, out, prior_courses) + _post_process(merged) + + existing = supabase.table("refined_submissions").select("id").eq("submission_id", submission_id).execute().data + if existing: + supabase.table("refined_submissions").update(merged).eq("submission_id", submission_id).execute() + refined_id = existing[0]["id"] + else: + refined_id = supabase.table("refined_submissions").insert(merged).execute().data[0]["id"] + + supabase.table("submissions").update({"status": "refined"}).eq("id", submission_id).execute() + invalidate_curriculum_cache() + + if merged["is_elective"]: + try: + from app.services.elective_categorization import categorize_refined_elective + categorize_refined_elective(int(refined_id)) + except Exception: + logger.exception("Elective categorization failed for refined_id=%s", refined_id) + + return merged diff --git a/backend/app/services/schema.py b/backend/app/services/schema.py new file mode 100644 index 0000000000000000000000000000000000000000..663f22b6011602eda10ac45405f3308e1c651fa8 --- /dev/null +++ b/backend/app/services/schema.py @@ -0,0 +1,30 @@ +from postgrest.exceptions import APIError + +from app.supabase import supabase + +REQUIRED_TABLES = ( + "submissions", + "refined_submissions", + "curriculum_versions", + "finalized_submissions", + "agent_document_drafts", + "agent_drafts", + "course_revision_history", + "chat_sessions", + "chat_messages", + "chat_attachments", + "specialization_definitions", + "course_specialization_assignments", +) + + +def schema_status() -> dict: + missing = [] + for table in REQUIRED_TABLES: + try: + supabase.table(table).select("id").limit(1).execute() + except APIError as exc: + if "schema cache" not in str(exc): + raise + missing.append(table) + return {"ok": not missing, "missing_tables": missing, "required_tables": list(REQUIRED_TABLES)} diff --git a/backend/app/supabase.py b/backend/app/supabase.py new file mode 100644 index 0000000000000000000000000000000000000000..262f9c6338fd1f578208f85613651e58a013cd87 --- /dev/null +++ b/backend/app/supabase.py @@ -0,0 +1,24 @@ +from os import environ +from supabase import create_client, ClientOptions +from dotenv import load_dotenv +import httpx + +load_dotenv("../.env") + +url = environ["SUPABASE_URL"].strip() +key = environ["SUPABASE_KEY"].strip() +supabase = create_client( + supabase_url=url, + supabase_key=key, + options=ClientOptions( + httpx_client=httpx.Client( + http2=False, + timeout=httpx.Timeout(connect=10, read=30, write=10, pool=10), + ), + ), +) + + +def first_row(query) -> dict | None: + rows = query.limit(1).execute().data + return rows[0] if rows else None diff --git a/backend/app/templates/jinja_1_to_8.html b/backend/app/templates/jinja_1_to_8.html new file mode 100644 index 0000000000000000000000000000000000000000..070a5961f82dfc71676b045a1a9bfbb2939764aa --- /dev/null +++ b/backend/app/templates/jinja_1_to_8.html @@ -0,0 +1,236 @@ +{% macro course_row(course, number) -%} + + {{ number }} + {{ course.course_code | course_code_for_year(course.semester, curriculum_year) }} + {{ course.course_title }} + {{ course.lecture_hours }} + {{ course.tutorial_hours }} + {{ course.practical_hours }} + {{ course.self_study }} + {{ course.credits }} + {{ course.tools_languages }} + {{ course.course_type }} + +{%- endmacro %} + +{% macro final_year_row(course, number) -%} + + {{ number }} + {{ course.course_code | course_code_for_year(course.semester, curriculum_year) }} + {{ course.course_title }} + {{ course.lecture_hours }} + {{ course.tutorial_hours }} + {{ course.practical_hours }} + {{ course.self_study }} + {{ course.credits }} + {{ course.course_type }} + +{%- endmacro %} + +{% macro total_cell(total, credit_total) -%} + {% if total != credit_total %}{{ credit_total }}/{{ total }}{% else %}{{ total }}{% endif %} +{%- endmacro %} + +{% macro regular_table(summary_semester, show_total) -%} + {% set totals = namespace(count=0, lecture=0, lecture_credit=0, tutorial=0, practical=0, self_study=0, credits=0) %} + {% for summary_course in course_list %} + {% if summary_course.semester|string == summary_semester|string %} + {% set credit_value = summary_course.credits|int %} + {% set totals.count = totals.count + 1 %} + {% set totals.lecture = totals.lecture + summary_course.lecture_hours|int %} + {% set totals.tutorial = totals.tutorial + summary_course.tutorial_hours|int %} + {% set totals.practical = totals.practical + summary_course.practical_hours|int %} + {% set totals.self_study = totals.self_study + summary_course.self_study|int %} + {% set totals.credits = totals.credits + credit_value %} + {% if credit_value > 0 %} + {% set totals.lecture_credit = totals.lecture_credit + summary_course.lecture_hours|int %} + {% endif %} + {% endif %} + {% endfor %} + + {% if totals.count %} +

    {{ SEMESTER_NAMES[summary_semester|string] }} SEMESTER {{ batch_label(summary_semester, curriculum_year) }}

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% set row = namespace(number=1) %} + {% for summary_course in course_list %} + {% if summary_course.semester|string == summary_semester|string %} + {{ course_row(summary_course, row.number) }} + {% set row.number = row.number + 1 %} + {% endif %} + {% endfor %} + + {% if show_total %} + + + + + + + + + + + {% endif %} + +
    Sl. No.Course CodeCourse TitleHours per weekCreditsAI Tools / Tools /
    Languages
    Course Type
    LTPSC
    Total{{ total_cell(totals.lecture, totals.lecture_credit) }}{{ totals.tutorial }}{{ totals.practical }}{{ totals.self_study }}{{ totals.credits }}
    + {% endif %} +{%- endmacro %} + +{% set requested_semester = semester|string if semester is defined and semester else "" %} +{% if summary_range | default("") != "final" %} + {% set early = namespace(count=0) %} + {% for summary_course in course_list %} + {% if summary_course.semester|string in ["1", "2"] and (not requested_semester or requested_semester == summary_course.semester|string) %} + {% set early.count = early.count + 1 %} + {% endif %} + {% endfor %} + + {% if early.count %} +
    +
    + PES University logo + Computer Science and Engineering +
    + {% for summary_semester in [1, 2] %} + {% if not requested_semester or requested_semester == summary_semester|string %} + {{ regular_table(summary_semester, false) }} + {% endif %} + {% endfor %} +
    + {% endif %} + + {% for summary_semester in [3, 4] %} + {% if not requested_semester or requested_semester == summary_semester|string %} + {% set current = namespace(count=0) %} + {% for summary_course in course_list %} + {% if summary_course.semester|string == summary_semester|string %} + {% set current.count = current.count + 1 %} + {% endif %} + {% endfor %} + + {% if current.count %} +
    +
    + PES University logo + Computer Science and Engineering +
    + {{ regular_table(summary_semester, true) }} +
    + {% endif %} + {% endif %} + {% endfor %} +{% endif %} + +{% if summary_range | default("") != "early" and (not requested_semester or requested_semester in ["7", "8"]) %} + {% set final = namespace(count=0) %} + {% for summary_course in course_list %} + {% if summary_course.semester|string in ["7", "8"] and (not requested_semester or requested_semester == summary_course.semester|string) %} + {% set final.count = final.count + 1 %} + {% endif %} + {% endfor %} + + {% if final.count %} +
    +
    + PES University logo + Computer Science and Engineering +
    + + {% for summary_semester in [7, 8] %} + {% if not requested_semester or requested_semester == summary_semester|string %} + {% set totals = namespace(count=0, lecture=0, tutorial=0, practical=0, self_study=0, credits=0) %} + {% for summary_course in course_list %} + {% if summary_course.semester|string == summary_semester|string %} + {% set totals.count = totals.count + 1 %} + {% set totals.lecture = totals.lecture + summary_course.lecture_hours|int %} + {% set totals.tutorial = totals.tutorial + summary_course.tutorial_hours|int %} + {% set totals.practical = totals.practical + summary_course.practical_hours|int %} + {% set totals.self_study = totals.self_study + summary_course.self_study|int %} + {% set totals.credits = totals.credits + summary_course.credits|int %} + {% endif %} + {% endfor %} + + {% if totals.count %} +

    {{ SEMESTER_NAMES[summary_semester|string] }} SEMESTER {{ batch_label(summary_semester, curriculum_year) }}

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% set row = namespace(number=1) %} + {% for summary_course in course_list %} + {% if summary_course.semester|string == summary_semester|string %} + {{ final_year_row(summary_course, row.number) }} + {% set row.number = row.number + 1 %} + {% endif %} + {% endfor %} + + + + + + + + + + +
    Sl. No.Course CodeCourse TitleHours/weekCreditsCourse Type
    LTPSC
    Total{{ totals.lecture }}{{ totals.tutorial }}{{ totals.practical }}{{ totals.self_study }}{{ totals.credits }}
    + {% endif %} + {% endif %} + {% endfor %} +
    + {% endif %} +{% endif %} diff --git a/backend/app/templates/jinja_diff.html b/backend/app/templates/jinja_diff.html new file mode 100644 index 0000000000000000000000000000000000000000..cc188ae3e3a96a4d1ccaecd07e58903b38715ca0 --- /dev/null +++ b/backend/app/templates/jinja_diff.html @@ -0,0 +1,266 @@ + + + + + + + + + + {% macro diff_inline(diff, fallback="") %} + {% if diff %} + {% if diff.old != "" %} +
    {{ diff.old }}
    + {% endif %} + {% if diff.new != "" %} +
    +{{ diff.new }}
    + {% endif %} + {% else %} + {{ fallback }} + {% endif %} + {% endmacro %} + + {% macro diff_cell(diff, proposed_value) %} + + {% if diff %} + {% if diff.old != "" %} +
    {{ diff.old }}
    + {% endif %} + {% if diff.new != "" %} +
    +{{ diff.new }}
    + {% endif %} + {% else %} + {{ proposed_value }} + {% endif %} + + {% endmacro %} + + {% macro render_list(course_diff, proposed_course, field_name, ordered=false) %} + {% set diff = course_diff[field_name] %} + {% if diff %} + {% if ordered %}
      {% else %}
    {% else %}{% endif %} + {% else %} + {% if ordered %}
      {% else %}
    {% else %}{% endif %} + {% endif %} + {% endmacro %} + + {% macro render_units(course_diff, proposed_course) %} + {% set units_diff = course_diff.units %} + {% if units_diff %} + {% for unit_entry in units_diff.units %} + {% if unit_entry.kind == "removed" %} +
    +
    − {{ unit_entry.unit.title }} ({{ unit_entry.unit.hours }} Hours)
    +

    {{ unit_entry.unit.content }}

    +
    + {% elif unit_entry.kind == "added" %} +
    +
    + {{ unit_entry.unit.title }} ({{ unit_entry.unit.hours }} Hours)
    +

    {{ unit_entry.unit.content }}

    +
    + {% elif unit_entry.kind == "changed" %} +
    +
    ~ {{ unit_entry.unit.title }} ({{ unit_entry.unit.hours }} Hours)
    +

    {{ unit_entry.unit.content }}

    +
    + {% for field, change in unit_entry.changes.items() %} +
    − {{ field }}: {{ change.old }}
    +
    + {{ field }}: {{ change.new }}
    + {% endfor %} +
    +
    + {% else %} +
    +
    {{ unit_entry.unit.title }}
    +

    {{ unit_entry.unit.content }}

    +

    {{ unit_entry.unit.hours }} Hours

    +
    + {% endif %} + {% endfor %} + {% else %} + {% for unit in proposed_course.units %} +
    +

    {{ unit.title }}

    +

    {{ unit.content }}

    +

    {{ unit.hours }} Hours

    +
    + {% endfor %} + {% endif %} + {% endmacro %} + + {% macro render_course(course_diff, proposed_course) %} +
    +
    + PES University logo + Computer Science and Engineering +
    + + + + + + + + + + + + + + + + {{ diff_cell(course_diff.course_code, proposed_course.course_code | course_code_for_year(proposed_course.semester, curriculum_year)) }} + + + + + + + + + + + + + + + + + + + + + + + + + {{ diff_cell(course_diff.tools_languages, proposed_course.tools_languages) }} + + + + +
    Course CodeCourse Title{{ diff_inline(course_diff.course_title, proposed_course.course_title) }}
    Program{{ proposed_course.program }}Hours per week / Credit AssignedLTPSC
    {{ proposed_course.lecture_hours }}{{ proposed_course.tutorial_hours }}{{ proposed_course.practical_hours }}{{ proposed_course.self_study }}{{ proposed_course.credits }}
    Semester{{ proposed_course.semester }}Type of Course{{ proposed_course.course_type }}
    AI Tools / Tools / LanguagesDesirable Knowledge{{ diff_inline(course_diff.desirable_knowledge, proposed_course.desirable_knowledge) }}
    + + + + + + + + + + + + + + + + + + + + {% if proposed_course.lab_experiments %} + + + + + {% endif %} + + + + + {% if proposed_course.reference_books %} + + + + + {% endif %} + {% if proposed_course.course_outcomes %} + + + + + {% endif %} + +
    Prelude{{ diff_inline(course_diff.prelude, proposed_course.prelude) }}
    Course Objectives:{{ render_list(course_diff, proposed_course, "objectives") }}
    Course Contents{{ render_units(course_diff, proposed_course) }}
    Laboratory{{ render_list(course_diff, proposed_course, "lab_experiments", ordered=true) }}
    Text Book(s):{{ render_list(course_diff, proposed_course, "text_books", ordered=true) }}
    Reference Book(s):{{ render_list(course_diff, proposed_course, "reference_books", ordered=true) }}
    Course Outcome{{ render_list(course_diff, proposed_course, "course_outcomes") }}
    +
    + {% endmacro %} + + {% if course_diff is defined and course_diff %} + {{ render_course(course_diff, proposed) }} + {% elif course_diffs is defined and course_diffs %} + {% for cd in course_diffs %} + {{ render_course(cd.course_diff, cd.proposed) }} + {% endfor %} + {% else %} +
    No diff data available
    + {% endif %} + + \ No newline at end of file diff --git a/backend/app/templates/jinja_program.html b/backend/app/templates/jinja_program.html new file mode 100644 index 0000000000000000000000000000000000000000..96401606e85aec313f693846d14e4379ab020dd8 --- /dev/null +++ b/backend/app/templates/jinja_program.html @@ -0,0 +1,42 @@ +
    +
    + PES University logo +

    Computer Science and Engineering

    +

    P.E.S. University

    +

    Curriculum :- {{ curriculum_year }}

    +
    +
    + +
    +
    + PES University logo +
    + +

    Computer Science and Engineering

    +

    B. TECH IN COMPUTER SCIENCE AND ENGINEERING

    + +

    PROGRAM EDUCATIONAL OBJECTIVES

    + + +

    PROGRAM OUTCOMES

    + +
    diff --git a/backend/app/templates/jinja_sample.html b/backend/app/templates/jinja_sample.html new file mode 100644 index 0000000000000000000000000000000000000000..5110767dc0297c5bf29b4594041f14cad0d6bc2d --- /dev/null +++ b/backend/app/templates/jinja_sample.html @@ -0,0 +1,516 @@ + + + + + + + + + {% set course_list = courses if courses is defined else [course] %} + {% if course_list %} + {% if diff_changes is defined and diff_changes %} +
    +
    + Changed fields ({{ diff_changes|length }}) + {% if diff_changes[0].course is defined %} + {% for group in diff_changes %} +
    {{ group.course }}
    + + + {% for c in group.changes %} + + {% endfor %} +
    FieldOld ValueNew Value
    {{ c.field }}{{ c.old }}{{ c.new }}
    + {% endfor %} + {% else %} + + + {% for c in diff_changes %} + + {% endfor %} +
    FieldOld ValueNew Value
    {{ c.field }}{{ c.old }}{{ c.new }}
    + {% endif %} +
    +
    + {% endif %} + {% if show_summaries | default(false) %} + {% include "jinja_program.html" %} + {% set summary_range = "early" %} + {% include "jinja_1_to_8.html" %} + {% include "jinja_sem_5_6.html" %} + {% set summary_range = "final" %} + {% include "jinja_1_to_8.html" %} + {% endif %} + {% for course in course_list %} + {% if not show_summaries | default(false) or course.render_detail | default(true) %} +
    + {% if course._version_label %} +
    {{ course._version_label }}
    + {% endif %} +
    + PES University logo + Computer Science and Engineering +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Course Code{{ course.course_code | course_code_for_year(course.semester, curriculum_year) }}Course Title{{ course.course_title }}
    Program{{ course.program }}Hours per week/ Credit AssignedLTPSC
    {{ course.lecture_hours }}{{ course.tutorial_hours }}{{ course.practical_hours }}{{ course.self_study }}{{ course.credits }}
    Semester{{ course.semester }}Type of Course{{ course.course_type }}
    AI Tools / Tools / Languages{{ course.tools_languages }}Desirable Knowledge{{ course.desirable_knowledge }}
    + + + + + + + + + + + + + + + + + + + + {% if course.lab_experiments %} + + + + + {% endif %} + + + + + {% if course.reference_books %} + + + + + {% endif %} + {% if course.course_outcomes %} + + + + + {% endif %} + +
    Prelude{{ course.prelude }}
    Course Objectives: +
      + {% for objective in course.objectives %} +
    • {{ objective }}
    • + {% endfor %} +
    +
    Course Contents + {% if course.units %} + {% for unit in course.units %} +
    +

    {{ unit.title }}

    +

    {{ unit.content }}

    +

    {{ unit.hours }} Hours

    +
    + {% endfor %} + {% else %} + - + {% endif %} +
    Laboratory +
      + {% for experiment in course.lab_experiments %} +
    1. {{ experiment }}
    2. + {% endfor %} +
    +
    Text Book(s): + {% if course.text_books %} +
      + {% for book in course.text_books %} +
    1. {{ book | linkify }}
    2. + {% endfor %} +
    + {% else %} + - + {% endif %} +
    Reference Book(s): +
      + {% for book in course.reference_books %} +
    1. {{ book | linkify }}
    2. + {% endfor %} +
    +
    Course Outcome +
      + {% for outcome in course.course_outcomes %} +
    • {{ outcome }}
    • + {% endfor %} +
    +
    +
    + {% endif %} + {% endfor %} + {% else %} +
    No refined courses found for Semester {{ semester }}.
    + {% endif %} + {% if show_thank_you | default(true) %} +
    +
    + PES University logo +

    Thank You

    +

    P.E.S. University - Curriculum {{ curriculum_year }}

    +
    +
    + {% endif %} + + diff --git a/backend/app/templates/jinja_sem_5_6.html b/backend/app/templates/jinja_sem_5_6.html new file mode 100644 index 0000000000000000000000000000000000000000..204eaaa63504989b53780cde4a3fd27988795f60 --- /dev/null +++ b/backend/app/templates/jinja_sem_5_6.html @@ -0,0 +1,263 @@ +{% macro course_row(course, number) -%} + {% set code = course.course_code|string|upper|replace(" ", "") %} + {% set title = course.course_title|string|lower %} + {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %} + {% set is_no_course = "no course offered" in title %} + {% set is_elective = course.is_elective %} + + {{ number }} + {{ course.course_code | course_code_for_year(course.semester, curriculum_year) }} + {{ course.course_title }} + {% if is_no_course %}{% elif is_placeholder %}{% else %}{{ course.lecture_hours }}{% endif %} + {% if is_no_course %}{% elif is_placeholder %}{% else %}{{ course.tutorial_hours }}{% endif %} + {% if is_no_course %}{% elif is_placeholder %}{% else %}{{ course.practical_hours }}{% endif %} + {% if is_no_course %}{% elif is_placeholder %}{% else %}{{ course.self_study }}{% endif %} + {% if is_no_course %}{% elif is_placeholder %}{% else %}{{ course.credits }}{% endif %} + {{ course.tools_languages }} + {% if is_no_course %}{% elif is_elective or is_placeholder %}Elective Course{% else %}{{ course.course_type }}{% endif %} + +{%- endmacro %} + +{% macro total_cell(total, credit_total) -%} + {% if total != credit_total %}{{ credit_total }}/{{ total }}{% else %}{{ total }}{% endif %} +{%- endmacro %} + +{% macro spec_codes_for(spec, summary_semester) -%} + {% set first = [] %} + {% set second = [] %} + {% for assign in specialization_assignments|default([]) %} + {% if assign.specialization_id == spec.id %} + {% for c in course_list %} + {% set code = c.course_code|string|upper|replace(" ", "") %} + {% set title = c.course_title|string|lower %} + {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %} + {% set is_no_course = "no course offered" in title %} + {% if c.refined_id|string == assign.refined_id|string + and c.semester|string == summary_semester|string + and c.is_elective + and not is_placeholder + and not is_no_course %} + {% set adjusted = c.course_code | course_code_for_year(c.semester, curriculum_year) %} + {% if (summary_semester == 5 and "AA" in code) or (summary_semester == 6 and "BA" in code) %} + {% do first.append(adjusted) %} + {% elif (summary_semester == 5 and "AB" in code) or (summary_semester == 6 and "BB" in code) %} + {% do second.append(adjusted) %} + {% else %} + {% do first.append(adjusted) %} + {% endif %} + {% endif %} + {% endfor %} + {% endif %} + {% endfor %} + + {{ spec.letter }} + {{ spec.name }} + {% for code in first %}

    {{ code }}

    {% endfor %} + {% for code in second %}

    {{ code }}

    {% endfor %} + +{%- endmacro %} + +{% set requested_semester = semester|string if semester is defined and semester else "" %} +{% for summary_semester in [5, 6] %} + {% if not requested_semester or requested_semester == summary_semester|string %} + {% set totals = namespace(count=0, lecture=0, lecture_credit=0, tutorial=0, practical=0, self_study=0, credits=0) %} + {% for summary_course in course_list %} + {% set code = summary_course.course_code|string|upper|replace(" ", "") %} + {% set title = summary_course.course_title|string|lower %} + {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %} + {% set is_no_course = "no course offered" in title %} + {% if summary_course.semester|string == summary_semester|string and not summary_course.is_elective and not is_placeholder and not is_no_course %} + {% set credit_value = summary_course.credits|int %} + {% set totals.count = totals.count + 1 %} + {% set totals.lecture = totals.lecture + summary_course.lecture_hours|int %} + {% set totals.tutorial = totals.tutorial + summary_course.tutorial_hours|int %} + {% set totals.practical = totals.practical + summary_course.practical_hours|int %} + {% set totals.self_study = totals.self_study + summary_course.self_study|int %} + {% set totals.credits = totals.credits + credit_value %} + {% if credit_value > 0 %} + {% set totals.lecture_credit = totals.lecture_credit + summary_course.lecture_hours|int %} + {% endif %} + {% endif %} + {% endfor %} + {% set placeholder_names = ["Elective I", "Elective II"] if summary_semester == 5 else ["Elective III", "Elective IV"] %} + {% set has_real_courses = totals.count > 0 %} + {% if has_real_courses %} + {% set totals.count = totals.count + placeholder_names|length %} + {% set totals.credits = totals.credits + placeholder_names|length * 4 %} + {% endif %} + + {% set elective_a = namespace(count=0) %} + {% set elective_b = namespace(count=0) %} + {% for summary_course in course_list %} + {% set code = summary_course.course_code|string|upper|replace(" ", "") %} + {% set title = summary_course.course_title|string|lower %} + {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %} + {% set is_no_course = "no course offered" in title %} + {% if summary_course.semester|string == summary_semester|string and summary_course.is_elective and not is_placeholder and not is_no_course %} + {% if (summary_semester == 5 and "AA" in code) or (summary_semester == 6 and "BA" in code) %} + {% set elective_a.count = elective_a.count + 1 %} + {% elif (summary_semester == 5 and "AB" in code) or (summary_semester == 6 and "BB" in code) %} + {% set elective_b.count = elective_b.count + 1 %} + {% endif %} + {% endif %} + {% endfor %} + + {% if totals.count or elective_a.count or elective_b.count %} +
    +
    + PES University logo + Computer Science and Engineering +
    +

    {{ SEMESTER_NAMES[summary_semester|string] }} SEMESTER {{ batch_label(summary_semester, curriculum_year) }}

    + + {% if totals.count %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% set row = namespace(number=1) %} + {% for summary_course in course_list %} + {% set code = summary_course.course_code|string|upper|replace(" ", "") %} + {% set title = summary_course.course_title|string|lower %} + {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %} + {% set is_no_course = "no course offered" in title %} + {% if summary_course.semester|string == summary_semester|string and not summary_course.is_elective and not is_placeholder and not is_no_course %} + {{ course_row(summary_course, row.number) }} + {% set row.number = row.number + 1 %} + {% endif %} + {% endfor %} + {% if has_real_courses %} + {% for pname in placeholder_names %} + + + + + + + + + + + + + {% set row.number = row.number + 1 %} + {% endfor %} + {% endif %} + + + + + + + + + + + +
    Sl. No.Course CodeCourse TitleHours per weekCreditsAI Tools / Tools /
    Languages
    Course Type
    LTPSC
    {{ row.number }}{{ pname }}4Elective Course
    Total{{ total_cell(totals.lecture, totals.lecture_credit) }}{{ totals.tutorial }}{{ totals.practical }}{{ totals.self_study }}{{ totals.credits }}
    + {% endif %} + + {% for group in ["a", "b"] %} + {% set group_count = elective_a.count if group == "a" else elective_b.count %} + {% if group_count %} + + + + + + + + + + + + + + + + + + {% set start_number = totals.count + 1 if group == "a" else totals.count + elective_a.count + 1 %} + {% set row = namespace(number=start_number) %} + {% for summary_course in course_list %} + {% set code = summary_course.course_code|string|upper|replace(" ", "") %} + {% set title = summary_course.course_title|string|lower %} + {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %} + {% set is_no_course = "no course offered" in title %} + {% set is_group_a = (summary_semester == 5 and "AA" in code) or (summary_semester == 6 and "BA" in code) %} + {% set is_group_b = (summary_semester == 5 and "AB" in code) or (summary_semester == 6 and "BB" in code) %} + {% 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)) %} + {{ course_row(summary_course, row.number) }} + {% set row.number = row.number + 1 %} + {% endif %} + {% endfor %} + +
    + {% if summary_semester == 5 and group == "a" %}Elective-I{% endif %} + {% if summary_semester == 5 and group == "b" %}Elective-II{% endif %} + {% if summary_semester == 6 and group == "a" %}Elective-III{% endif %} + {% if summary_semester == 6 and group == "b" %}Elective-IV{% endif %} +
    + {% endif %} + {% endfor %} + + {% set semester_specs = [] %} + {% for spec in specializations|default([]) %} + {% if spec.semester|string == summary_semester|string %} + {% do semester_specs.append(spec) %} + {% endif %} + {% endfor %} + {% if semester_specs %} + + + + + + + + + + + + + + + + + + {% for spec in semester_specs %} + {{ spec_codes_for(spec, summary_semester) }} + {% endfor %} + +
    ELECTIVES TO BE OPTED FOR SPECIALIZATION
    Sl. No.Specialization{{ "Elective-I" if summary_semester == 5 else "Elective-III" }}{{ "Elective-II" if summary_semester == 5 else "Elective-IV" }}
    + {% endif %} +
    + {% endif %} + {% endif %} +{% endfor %} diff --git a/docs/schema.sql b/docs/schema.sql new file mode 100644 index 0000000000000000000000000000000000000000..f3e48bfdb2f00ea07f208b38ae47c0860d569e96 --- /dev/null +++ b/docs/schema.sql @@ -0,0 +1,157 @@ +create table if not exists submissions ( + id bigint generated always as identity primary key, + faculty_email text not null, + course_title text not null, + course_code text not null, + offering_department text not null default 'CS', + target_department text not null default 'CSE', + semester int not null default 1, + credit_category text not null default '4', + raw_course_content text not null, + text_books text not null default '', + reference_books text not null default '', + preferred_tools text not null default '', + status text not null default 'pending', + created_at timestamptz default now() +); + +-- Refined / template-ready courses +create table if not exists refined_submissions ( + id bigint generated always as identity primary key, + submission_id bigint references submissions(id), + faculty_email text not null default '', + course_title text not null default '', + course_code text not null default '', + offering_department text not null default 'CS', + target_department text not null default 'CSE', + semester int not null default 1, + credit_category text not null default '4', + program text not null default 'B. TECH', + lecture_hours int not null default 0, + tutorial_hours int not null default 0, + practical_hours int not null default 0, + self_study int not null default 0, + credits int not null default 0, + course_type text not null default '', + is_elective boolean not null default false, + visible boolean not null default true, + units jsonb not null default '[]'::jsonb, + objectives text not null default '', + course_outcomes text not null default '', + text_books text not null default '', + reference_books text not null default '', + lab_experiments text not null default '', + tools_languages text not null default '', + raw_course_content text not null default '', + status text not null default 'refined', + created_at timestamptz default now() +); + +-- Specialization track definitions (e.g. SCC, MIDS, CSCS for semesters 5 & 6) +create table if not exists specialization_definitions ( + id bigint generated always as identity primary key, + semester int not null, + letter text not null, + name text not null, + key text not null, + academic_year text not null default '', + created_at timestamptz default now() +); + +-- Which electives belong to which specialization tracks +create table if not exists course_specialization_assignments ( + id bigint generated always as identity primary key, + refined_id bigint references refined_submissions(id) on delete cascade, + specialization_id bigint references specialization_definitions(id) on delete cascade, + created_at timestamptz default now(), + unique (refined_id, specialization_id) +); + +-- Curriculum version snapshots +create table if not exists curriculum_versions ( + id bigint generated always as identity primary key, + name text not null default '', + program text not null default '', + academic_year text not null default '', + status text not null default 'draft', + created_at timestamptz default now() +); + +-- Course snapshots within a version +create table if not exists finalized_submissions ( + id bigint generated always as identity primary key, + curriculum_version_id bigint references curriculum_versions(id) on delete cascade, + refined_id bigint references refined_submissions(id), + course_json jsonb not null default '{}'::jsonb, + created_at timestamptz default now() +); + +-- Revision history for applied drafts +create table if not exists course_revision_history ( + id bigint generated always as identity primary key, + refined_id bigint references refined_submissions(id), + draft_id bigint, + old_json jsonb not null default '{}'::jsonb, + new_json jsonb not null default '{}'::jsonb, + created_at timestamptz default now() +); + +-- Agent-proposed course drafts +create table if not exists agent_drafts ( + id bigint generated always as identity primary key, + refined_id bigint references refined_submissions(id), + document_draft_id bigint, + base_refined_json jsonb not null default '{}'::jsonb, + proposed_json jsonb not null default '{}'::jsonb, + json_patch jsonb not null default '[]'::jsonb, + diff_summary jsonb not null default '{}'::jsonb, + change_reason text not null default '', + status text not null default 'proposed', + created_at timestamptz default now() +); + +-- Agent-proposed document drafts (multi-course) +create table if not exists agent_document_drafts ( + id bigint generated always as identity primary key, + curriculum_version_id bigint, + uploaded_document_id text not null default '', + diff_summary jsonb not null default '{}'::jsonb, + change_reason text not null default '', + status text not null default 'proposed', + created_at timestamptz default now() +); + +-- Chat sessions +create table if not exists chat_sessions ( + id bigint generated always as identity primary key, + refined_id bigint, + document_draft_id bigint, + title text not null default '', + status text not null default 'active', + created_at timestamptz default now() +); + +-- Chat messages +create table if not exists chat_messages ( + id bigint generated always as identity primary key, + session_id bigint references chat_sessions(id) on delete cascade, + role text not null default 'user', + content text not null default '', + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz default now() +); + +-- Chat attachments (files, reports) +create table if not exists chat_attachments ( + id bigint generated always as identity primary key, + session_id bigint references chat_sessions(id) on delete cascade, + message_id bigint, + filename text not null default '', + content_type text not null default '', + size_bytes bigint not null default 0, + extracted_text text not null default '', + content_base64 text not null default '', + status text not null default 'pending', + error text not null default '', + created_at timestamptz default now() +); diff --git a/frontend/auth/auth.css b/frontend/auth/auth.css new file mode 100644 index 0000000000000000000000000000000000000000..7d54e73a8dd7e83371e334a6cfbc0b4647c8277d --- /dev/null +++ b/frontend/auth/auth.css @@ -0,0 +1,129 @@ +@import "../shared.css"; + +.auth-shell { + min-height: 100dvh; + display: grid; + place-items: center; + background: var(--page-bg); + padding: 24px; +} + +.auth-card { + width: 100%; + max-width: 400px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 20px; + padding: 2.5rem; + box-shadow: var(--shadow-md); +} + +.auth-header { + text-align: center; + margin-bottom: 28px; +} + +.auth-header h1 { + margin: 0 0 8px; + font-size: 24px; + font-weight: 700; + color: var(--pesu-blue); +} + +.auth-subtitle { + margin: 0; + color: var(--muted); + font-size: 14px; +} + +.auth-form { + display: grid; + gap: 18px; +} + +.form-group { + display: grid; + gap: 6px; +} + +.form-group label { + font-size: 13px; + font-weight: 600; + color: var(--text); +} + +.form-group input { + width: 100%; + box-sizing: border-box; +} + +.password-wrapper { + position: relative; +} + +.password-wrapper input { + padding-right: 48px; +} + +.toggle-password { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + background: transparent; + border: none; + padding: 4px; + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.toggle-password:hover { + color: var(--pesu-blue); +} + +.toggle-password .eye-closed { + display: none; +} + +.toggle-password.active .eye-open { + display: none; +} + +.toggle-password.active .eye-closed { + display: block; +} + +.auth-error { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + border: 1px solid var(--error); + border-radius: 8px; + background: var(--error-bg); + color: var(--error); + font-size: 13px; +} + +.auth-error:empty { + display: none; +} + +.btn-primary { + border: none; + background: var(--pesu-orange); + color: #fff; + border-radius: 10px; + transition: background 0.15s, border-color 0.15s; +} + +.btn-primary:hover { + border-color: color-mix(in srgb, var(--pesu-orange) 80%, black); +} + +.btn-block { + width: 100%; +} \ No newline at end of file diff --git a/frontend/auth/auth.js b/frontend/auth/auth.js new file mode 100644 index 0000000000000000000000000000000000000000..85978196f9b9a61f215abccd753c5b70f8f6948e --- /dev/null +++ b/frontend/auth/auth.js @@ -0,0 +1,44 @@ +import { supabase } from '../shared/supabase-client.js'; + +const form = document.getElementById('login-form'); +const emailInput = document.getElementById('email'); +const passwordInput = document.getElementById('password'); +const submitBtn = document.getElementById('submit-btn'); +const errorEl = document.getElementById('auth-error'); +const toggleBtn = document.querySelector('.toggle-password'); + +toggleBtn.addEventListener('click', () => { + const type = passwordInput.type === 'password' ? 'text' : 'password'; + passwordInput.type = type; + toggleBtn.classList.toggle('active', type === 'text'); +}); + +form.addEventListener('submit', async (e) => { + e.preventDefault(); + errorEl.hidden = true; + errorEl.textContent = ''; + submitBtn.disabled = true; + submitBtn.textContent = 'Signing in...'; + + const email = emailInput.value.trim(); + const password = passwordInput.value; + + const { error } = await supabase.auth.signInWithPassword({ email, password }); + + if (error) { + errorEl.textContent = error.message; + errorEl.hidden = false; + submitBtn.disabled = false; + submitBtn.textContent = 'Sign In'; + return; + } + + location.href = '/'; +}); + +// Check if already signed in +supabase.auth.getSession().then(({ data: { session } }) => { + if (session) { + location.href = '/'; + } +}); \ No newline at end of file diff --git a/frontend/auth/index.html b/frontend/auth/index.html new file mode 100644 index 0000000000000000000000000000000000000000..8899534b33793560021b737947d88f2ece469959 --- /dev/null +++ b/frontend/auth/index.html @@ -0,0 +1,41 @@ + + + + + + + Sign In - Syntagma + + + + +
    +
    +
    +

    Syntagma

    +
    +

    Sign in to continue

    +
    +
    +
    + + +
    +
    + +
    + + +
    +
    + + +
    +
    +
    + + + \ No newline at end of file diff --git a/frontend/courses.html b/frontend/courses.html new file mode 100644 index 0000000000000000000000000000000000000000..b0af6751cd6c709a28e11f5627c8569ae74455ce --- /dev/null +++ b/frontend/courses.html @@ -0,0 +1,11 @@ + + + + + + Courses - PESU + + + Courses + + diff --git a/frontend/courses/courses.css b/frontend/courses/courses.css new file mode 100644 index 0000000000000000000000000000000000000000..407a0c5ce27bfaf71bad2068e0ce589d365f6f33 --- /dev/null +++ b/frontend/courses/courses.css @@ -0,0 +1,236 @@ +@import "../shared.css"; + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100dvh; + padding: 2rem; + background: var(--page-bg); + color: var(--text); +} + +.shell { + max-width: 1200px; + margin: 0 auto; + display: grid; + gap: 1.25rem; +} + +.topbar, +.panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + box-shadow: var(--shadow-md); +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 1.5rem 2rem; +} + +h1 { + margin: 0; + color: var(--pesu-blue); + font-size: 1.5rem; + font-weight: 700; +} + +p { + margin: 4px 0 0; + color: var(--muted-foreground); + font-size: 13px; +} + +.actions, +.filters { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.filters { + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} + +.filters input { + flex: 1; +} + +#status { + margin-left: auto; +} + +.table-wrap { + overflow: auto; + position: relative; + min-height: 320px; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 10px 12px; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: top; + font-size: 14px; +} + +th { + color: var(--pesu-blue); + background: #f8fafc; + font-size: 12px; + text-transform: uppercase; + font-weight: 700; +} + +.course-row { + cursor: pointer; + transition: background 0.15s; +} + +.course-row:hover { + background: #f8fafc; +} + +td.center { + text-align: center; + vertical-align: middle; +} + +.hidden-row { + opacity: 0.5; +} + +.hidden-row td:not(.center) { + text-decoration: line-through; +} + +td.center input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 36px; + height: 20px; + min-height: 0; + padding: 0; + border: 1.5px solid var(--border); + border-radius: 10px; + background: var(--muted); + position: relative; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + vertical-align: middle; + outline: none; +} + +td.center input[type="checkbox"]::before { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--muted-foreground); + transition: transform 0.15s, background 0.15s; + pointer-events: none; +} + +td.center input[type="checkbox"]:checked { + background: var(--pesu-orange); + border-color: var(--pesu-orange); +} + +td.center input[type="checkbox"]:checked::before { + transform: translateX(16px); + background: white; +} + +td.center input[type="checkbox"]:focus { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +.details { + display: grid; + gap: 8px; + color: #374151; + line-height: 1.45; +} + +.details strong { + color: var(--pesu-blue); +} + +.empty { + padding: 20px; + color: var(--muted-foreground); + text-align: center; +} + +.viewer-loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 24px 32px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: var(--shadow-md); + z-index: 5; + color: var(--muted-foreground); + font-size: 13px; +} + +.viewer-loading[hidden] { + display: none; +} + +.spinner { + width: 24px; + height: 24px; + border: 2.5px solid var(--border); + border-top-color: var(--pesu-blue); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@media (max-width: 760px) { + body { + padding: 1rem; + } + + .topbar, + .actions, + .filters { + align-items: stretch; + flex-direction: column; + } + + #status { + margin-left: 0; + } +} diff --git a/frontend/courses/courses.js b/frontend/courses/courses.js new file mode 100644 index 0000000000000000000000000000000000000000..f543c8cea4bc23ed57df03a6620c042588bbf1fa --- /dev/null +++ b/frontend/courses/courses.js @@ -0,0 +1,165 @@ +const semester = document.getElementById("semester"); +const visibility = document.getElementById("visibility"); +const search = document.getElementById("search"); +const statusText = document.getElementById("status"); +const table = document.getElementById("course-table"); +const tableWrap = table ? table.closest(".table-wrap") : null; +const loading = document.getElementById("loading"); +let courses = []; +let openId = ""; + +function setStatus(text, kind = "") { + statusText.textContent = text; + statusText.className = `status-line ${kind}`.trim(); +} + +function cell(text) { + const td = document.createElement("td"); + td.textContent = text || ""; + return td; +} + +function courseMatches(course) { + const query = search.value.trim().toLowerCase(); + if (semester.value && String(course.semester) !== semester.value) return false; + if (visibility.value === "visible" && course.visible === false) return false; + if (visibility.value === "hidden" && course.visible !== false) return false; + if (!query) return true; + return `${course.course_code} ${course.course_title}`.toLowerCase().includes(query); +} + +function detailsRow(course) { + const row = document.createElement("tr"); + const td = document.createElement("td"); + td.colSpan = 6; + const details = document.createElement("div"); + details.className = "details"; + details.append( + line("Type", course.course_type), + line("Tools", course.tools_languages || "Not specified"), + line("Desirable knowledge", course.desirable_knowledge || "None"), + line("Prelude", course.prelude || "Not specified"), + ); + td.appendChild(details); + row.appendChild(td); + return row; +} + +function line(label, value) { + const div = document.createElement("div"); + const strong = document.createElement("strong"); + strong.textContent = `${label}: `; + div.append(strong, document.createTextNode(value)); + return div; +} + +function render() { + table.replaceChildren(); + const visible = courses.filter(courseMatches); + if (!visible.length) { + const row = document.createElement("tr"); + const td = document.createElement("td"); + td.colSpan = 6; + td.className = "empty"; + td.textContent = "No courses found."; + row.appendChild(td); + table.appendChild(row); + setStatus("No courses found."); + return; + } + + visible.forEach((course) => { + const row = document.createElement("tr"); + row.className = course.visible === false ? "course-row hidden-row" : "course-row"; + row.append(cell(course.semester), cell(course.course_code), cell(course.course_title), cell(course.credits)); + + const visibleCell = document.createElement("td"); + visibleCell.className = "center"; + const toggle = document.createElement("input"); + toggle.type = "checkbox"; + toggle.checked = course.visible !== false; + toggle.title = "Include in rendered documents"; + toggle.addEventListener("click", (event) => event.stopPropagation()); + toggle.addEventListener("change", () => setVisibility(course, toggle)); + visibleCell.appendChild(toggle); + row.appendChild(visibleCell); + + const action = document.createElement("td"); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "danger"; + remove.textContent = "Delete"; + remove.addEventListener("click", (event) => { + event.stopPropagation(); + deleteCourse(course).catch(showError); + }); + action.appendChild(remove); + row.appendChild(action); + + row.addEventListener("click", () => { + openId = openId === String(course.id) ? "" : String(course.id); + render(); + }); + table.appendChild(row); + if (openId === String(course.id)) table.appendChild(detailsRow(course)); + }); + setStatus(`${visible.length} course${visible.length === 1 ? "" : "s"}.`, "ready"); +} + +async function setVisibility(course, toggle) { + const next = toggle.checked; + course.visible = next; + const row = toggle.closest("tr"); + if (row) row.classList.toggle("hidden-row", !next); + try { + const response = await fetch(`/api/courses/${course.id}/visible`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ visible: next }), + }); + if (!response.ok) throw new Error(); + } catch { + course.visible = !next; + toggle.checked = !next; + if (row) row.classList.toggle("hidden-row", !next); + setStatus("Unable to update visibility.", "error"); + } +} + +async function deleteCourse(course) { + if (!await showConfirm(`Delete ${course.course_code || course.course_title}?`)) return; + setStatus("Deleting course..."); + const response = await fetch(`/api/courses/${course.id}`, { method: "DELETE" }); + if (!response.ok) throw new Error("Delete failed"); + courses = courses.filter((item) => item.id !== course.id); + if (openId === String(course.id)) openId = ""; + render(); +} + +function yearParam(base) { + const y = localStorage.getItem("curriculumYear") || ""; + if (!y) return base; + const sep = base.includes("?") ? "&" : "?"; + return `${base}${sep}curriculum_year=${encodeURIComponent(y)}`; +} + +async function loadCourses() { + if (loading) loading.hidden = false; + if (tableWrap) tableWrap.querySelector("table").hidden = true; + setStatus("Loading courses..."); + const response = await fetch(yearParam("/api/courses")); + if (!response.ok) throw new Error("Unable to load courses"); + const body = await response.json(); + courses = body.courses || []; + if (loading) loading.hidden = true; + if (tableWrap) tableWrap.querySelector("table").hidden = false; + render(); +} + +semester.addEventListener("change", render); +visibility.addEventListener("change", render); +search.addEventListener("input", render); + +loadCourses().catch((error) => { + setStatus(error instanceof Error ? error.message : "Unable to load courses.", "error"); +}); diff --git a/frontend/courses/index.html b/frontend/courses/index.html new file mode 100644 index 0000000000000000000000000000000000000000..e4e00018fea5ba4fe84975b2cd50eed913ccac08 --- /dev/null +++ b/frontend/courses/index.html @@ -0,0 +1,85 @@ + + + + + + + Courses - PESU + + + + + + + + +
    +
    +
    + + + +
    +

    Courses

    +

    Manage refined courses used by preview, editor, and versions.

    +
    +
    + +
    + +
    +
    + + + + +
    + +
    +
    +
    + Loading courses... +
    + + + + + + + + + + + + + + + + + + + + +
    SemesterCodeCourseCreditsVisible
    +
    +
    +
    + + diff --git a/frontend/docs/docs.css b/frontend/docs/docs.css new file mode 100644 index 0000000000000000000000000000000000000000..52d92c3ada42fe78262805799ff3f31315b23c67 --- /dev/null +++ b/frontend/docs/docs.css @@ -0,0 +1,287 @@ +@import "../shared.css"; + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + min-height: 100dvh; + background: var(--page-bg); + color: var(--text); +} + +.docs-toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 24px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + margin: 16px 24px 0; + box-shadow: var(--shadow-sm); + position: sticky; + top: 16px; + z-index: 100; +} + +.docs-toolbar-title { + font-weight: 700; + font-size: 1rem; + color: var(--pesu-blue); +} + +.docs-toolbar-sep { + width: 1px; + height: 20px; + background: var(--border); +} + +.docs-toolbar-subtitle { + color: var(--muted-foreground); + font-size: 0.85rem; +} + +.docs-layout { + display: flex; + max-width: 1200px; + margin: 0 auto; + padding: 24px; + gap: 24px; +} + +.docs-sidebar { + width: 220px; + flex-shrink: 0; + position: sticky; + top: 72px; + align-self: flex-start; + max-height: calc(100dvh - 88px); + overflow-y: auto; +} + +.docs-sidebar-nav { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px 0; +} + +.docs-sidebar-heading { + padding: 8px 16px 4px; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted-foreground); +} + +.docs-sidebar-link { + display: block; + padding: 6px 16px; + font-size: 0.85rem; + color: var(--text); + text-decoration: none; + border-left: 2px solid transparent; + transition: border-color 0.15s, color 0.15s; +} + +.docs-sidebar-link:hover { + color: var(--pesu-orange); +} + +.docs-sidebar-link.active { + border-left-color: var(--pesu-orange); + color: var(--pesu-orange); + font-weight: 600; +} + +.docs-sidebar-link.sub { + padding-left: 28px; + font-size: 0.8rem; + color: var(--muted-foreground); +} + +.docs-content { + flex: 1; + min-width: 0; +} + +.docs-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 32px 36px; + box-shadow: var(--shadow-sm); +} + +.docs-card h1 { + font-size: 1.75rem; + font-weight: 700; + color: var(--pesu-blue); + margin-bottom: 8px; +} + +.docs-card h2 { + font-size: 1.35rem; + font-weight: 700; + color: var(--pesu-blue); + margin-top: 40px; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +.docs-card h3 { + font-size: 1.1rem; + font-weight: 600; + color: var(--text); + margin-top: 28px; + margin-bottom: 8px; +} + +.docs-card h4 { + font-size: 0.95rem; + font-weight: 600; + color: var(--muted-foreground); + margin-top: 20px; + margin-bottom: 6px; +} + +.docs-card p { + margin-bottom: 12px; + line-height: 1.65; +} + +.docs-card ul, .docs-card ol { + margin-bottom: 12px; + padding-left: 20px; +} + +.docs-card li { + margin-bottom: 4px; + line-height: 1.6; +} + +.docs-card a { + color: var(--pesu-blue); + text-decoration: none; +} + +.docs-card a:hover { + text-decoration: underline; +} + +.docs-card strong { + font-weight: 600; +} + +.docs-card hr { + border: none; + border-top: 1px solid var(--border); + margin: 32px 0; +} + +.docs-card code { + background: var(--muted); + padding: 1px 5px; + border-radius: 4px; + font-size: 0.85em; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + color: var(--pesu-blue); +} + +.docs-card pre { + background: #1e1e2e; + color: #cdd6f4; + padding: 16px 20px; + border-radius: 8px; + overflow-x: auto; + margin-bottom: 16px; + font-size: 0.85rem; + line-height: 1.6; +} + +.docs-card pre code { + background: none; + padding: 0; + color: inherit; + font-size: inherit; +} + +.docs-card table { + width: 100%; + border-collapse: collapse; + margin-bottom: 16px; + font-size: 0.85rem; +} + +.docs-card th { + text-align: left; + padding: 8px 12px; + border: 1px solid var(--border); + background: var(--muted); + font-weight: 600; +} + +.docs-card td { + padding: 8px 12px; + border: 1px solid var(--border); +} + +.docs-card blockquote { + border-left: 3px solid var(--pesu-orange); + padding: 8px 16px; + margin-bottom: 12px; + background: var(--muted); + border-radius: 0 6px 6px 0; + color: var(--muted-foreground); +} + +.docs-card img { + max-width: 100%; + border-radius: 8px; + border: 1px solid var(--border); + margin: 12px 0; +} + +.docs-card .method-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 700; + font-family: monospace; +} + +.docs-card .method-get { background: #dbeafe; color: #1e40af; } +.docs-card .method-post { background: #dcfce7; color: #166534; } +.docs-card .method-patch { background: #fef3c7; color: #92400e; } +.docs-card .method-delete { background: #fee2e2; color: #991b1b; } + +.docs-section { + display: none; +} + +.docs-section.active { + display: block; +} + +@media (max-width: 768px) { + .docs-layout { + flex-direction: column; + padding: 16px; + } + + .docs-sidebar { + width: 100%; + position: static; + max-height: none; + } + + .docs-card { + padding: 20px; + } +} diff --git a/frontend/docs/index.html b/frontend/docs/index.html new file mode 100644 index 0000000000000000000000000000000000000000..8bf14190e2b49a892ec6c15f5ba0b1714be7ba76 --- /dev/null +++ b/frontend/docs/index.html @@ -0,0 +1,897 @@ + + + + + + + + Documentation - Syntagma + + + + +
    + + + + Syntagma + + Documentation +
    + +
    + + +
    + + +
    +
    +

    Syntagma

    +

    An Agentic Curriculum Lifecycle Management System for PES University

    + +

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

    + +

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

    + +

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

    + + Sign In + Dashboard + +

    Features

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

    Tech Stack

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

    Quick Start

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

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

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

    Architecture

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

    Layer Responsibilities

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

    Backend Project Structure

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

    Frontend Project Structure

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

    How It Works

    +

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

    + +

    Authentication

    + Sign In +

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

    + +

    Dashboard

    + Dashboard +

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

    + +

    Submission Form

    + Course Submission Form +

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

    + +

    Courses

    + Courses Page + Filtered Courses + Delete Modal +

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

    + +

    Preview

    + Single Course Preview + Full Document Preview + Semester Preview +

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

    + +

    Agentic Editor

    + Agentic Editor + Editor with Chat + Editor Annotated +

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

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

    Versions

    + Versions Page + Current Document + Version Comparison + Rename Category +

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

    +
    +
    + + +
    +
    +

    Backend Internals

    + +

    Submission Pipeline

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

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

    + +

    Course Code Encoding

    +

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

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

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

    + +

    Deterministic Fields

    +

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

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

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

    + +

    Preview & PDF Generation

    +

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

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

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

    + +

    Specialization System

    +

    Fully data-driven. No hardcoded specialization brackets.

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

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

    +

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

    + +

    Agent System

    +

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

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

    Agent Tools (35 total)

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

    SSE Event Types

    +

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

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

    Chat Persistence

    +

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

    + +

    Agent Output Validation

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

    Versioning

    +

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

    + +

    Caching

    +

    Dual cache layer (services/cache.py):

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

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

    + +

    Error Handling & Retry

    +

    OpenRouter client (services/openrouter.py):

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

    Fallback model:

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

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

    +

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

    +
    +
    + + +
    +
    +

    API Reference

    +

    All paths are prefixed with /api at runtime.

    + +

    Health

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

    Submissions

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

    Refined Courses

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

    Course Management

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

    Preview & PDF

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

    Agent Drafts

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

    Document Drafts (multi-course)

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

    Agent Tools & Diff

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

    Chat

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

    Versions

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

    Auth

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

    Query Parameters

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

    Total: 49 endpoints across 9 route files.

    +
    +
    + + +
    +
    +

    Database Schema

    +

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

    + +

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

    + +

    Course Status Lifecycle

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

    Key Columns on refined_submissions

    +

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

    + +

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

    + +

    Verify with GET /api/health/schema.

    + +

    Table Relationships

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

    Specialization Tables

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

    Environment Variables

    + +

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

    + + + + + + + + + + + +
    Variable
    SUPABASE_URL
    SUPABASE_KEY
    OPENROUTER_URL
    OPENROUTER_API_KEY
    OPENROUTER_MODEL
    + +

    Optional

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

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

    +
    +
    + + +
    +
    +

    Deployment

    + +

    Backend (HF Spaces)

    +

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

    +

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

    + +

    Required HF Space secrets:

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

    Frontend (Vercel)

    +

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

    + +

    Required Vercel environment variables:

    + + + + + + + + +
    VariableDescription
    NEXT_PUBLIC_SUPABASE_URLPublic Supabase URL
    NEXT_PUBLIC_SUPABASE_ANON_KEYPublic Supabase anon key
    + +

    CI (.github/workflows/ci.yml)

    +

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

    + +

    Local Development

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

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

    + +
    source .venv/bin/activate
    +pytest                              # all tests
    +python -m compileall backend/app    # also runs in CI
    +
    +
    + +
    +
    + + + + diff --git a/frontend/form.html b/frontend/form.html new file mode 100644 index 0000000000000000000000000000000000000000..aed38b04847c1859187641560ab2009000829571 --- /dev/null +++ b/frontend/form.html @@ -0,0 +1,11 @@ + + + + + + Course Submission - PESU + + + Course Submission + + diff --git a/frontend/form/form.css b/frontend/form/form.css new file mode 100644 index 0000000000000000000000000000000000000000..42d1a231f8ec0fdccf3884aa7b17ec754ca74807 --- /dev/null +++ b/frontend/form/form.css @@ -0,0 +1,242 @@ +@import "../shared.css"; + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + display: grid; + grid-template-rows: auto 1fr auto; + height: 100dvh; + padding: 1.5rem; + gap: 1rem; + background: var(--page-bg); + overflow: hidden; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + padding: 1rem 1.5rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + box-shadow: var(--shadow-md); +} + +.toolbar-title { + font-weight: 700; + font-size: 1.25rem; + color: var(--pesu-blue); +} + +.toolbar-sep { + width: 1px; + height: 20px; + background: var(--border); + flex-shrink: 0; +} + +.subtitle { + color: var(--muted-foreground); + font-size: 13px; +} + +.form-grid { + display: grid; + grid-template-columns: 280px 1fr 1fr; + gap: 1rem; + min-height: 0; + overflow: auto; +} + +.form-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 1.25rem 1.5rem; + box-shadow: var(--shadow-md); + display: flex; + flex-direction: column; + gap: 8px; +} + +h2 { + font-size: 0.95rem; + font-weight: 700; + color: var(--pesu-blue); + margin-bottom: 4px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +label { + font-size: 0.8125rem; + font-weight: 500; + color: var(--foreground); + margin-top: 4px; +} + +input, textarea { + width: 100%; + padding: 8px 10px; + font-family: inherit; + font-size: 0.875rem; + border: 1px solid var(--input); + border-radius: var(--radius); + background: var(--background); + color: var(--foreground); + transition: border-color 0.15s, box-shadow 0.15s; +} + +input:focus, textarea:focus { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +textarea { + resize: vertical; + min-height: 0; +} + +.hint { + display: block; + font-size: 0.6875rem; + color: var(--muted-foreground); + margin-top: 2px; +} + +.optional { + color: var(--muted-foreground); + font-weight: 400; + font-size: 0.6875rem; +} + +.form-footer { + display: flex; + align-items: center; + gap: 16px; + padding: 1rem 1.5rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + box-shadow: var(--shadow-md); +} + +button.primary { + height: 38px; + padding: 0 24px; + font-size: 0.875rem; +} + +.form-links { + display: flex; + gap: 16px; + margin-left: auto; +} + +.form-link { + display: inline-flex; + align-items: center; + height: 38px; + padding: 0 16px; + font-size: 0.8125rem; + color: var(--foreground); + text-decoration: none; + font-weight: 500; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--background); + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.form-link:hover { + border-color: var(--pesu-orange); + color: var(--pesu-orange); +} + +.code-preview { + padding: 8px 10px; + background: var(--muted); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.75rem; + color: var(--foreground); + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.code-preview.hidden { + display: none; +} + +.code-preview strong { + color: var(--pesu-orange); + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.badge { + display: inline-block; + padding: 2px 8px; + font-size: 0.625rem; + font-weight: 600; + border-radius: 4px; + background: var(--pesu-blue); + color: white; +} + +.badge.lateral { + background: var(--pesu-orange); +} + +#result { + position: fixed; + bottom: 5.5rem; + left: 50%; + transform: translateX(-50%); + padding: 8px 16px; + border-radius: var(--radius); + font-size: 0.8125rem; + font-weight: 500; + box-shadow: var(--shadow-md); + white-space: nowrap; + z-index: 100; + transition: opacity 0.3s; +} + +#result.success { + background: var(--success-bg); + color: var(--success); + border: 1px solid var(--success); +} + +#result.error { + background: var(--error-bg); + color: var(--error); + border: 1px solid var(--error); +} + +.hidden { + display: none; +} + +@media (max-width: 900px) { + .form-grid { + grid-template-columns: 1fr; + overflow: auto; + } + + body { + height: auto; + min-height: 100dvh; + overflow: auto; + } +} diff --git a/frontend/form/form.js b/frontend/form/form.js new file mode 100644 index 0000000000000000000000000000000000000000..e63570c72e1d600fb872548fc3383364b43b135c --- /dev/null +++ b/frontend/form/form.js @@ -0,0 +1,119 @@ +const form = document.getElementById("course-form"); +const codeInput = document.getElementById("course_code"); +const previewDiv = document.getElementById("code-preview"); +const creditSelect = document.getElementById("credit_category"); +const submitBtn = document.getElementById("submit-btn"); +const resultDiv = document.getElementById("result"); + +const DEPT_OFFERING = { + CS: "CS", EC: "CS", EE: "CS", ME: "CS", BT: "CS", AI: "CS", ML: "CS", + MA: "MA", PH: "MA", CH: "MA", + HU: "UZ", UZ: "UZ", +}; + +const DEPT_TARGET = { + CS: "CSE", EC: "ECE", EE: "EEE", ME: "ME", + BT: "BT", AI: "AIML", ML: "AIML", +}; + +function parseCode(code) { + const c = code.trim().toUpperCase().replace(/\s+/g, ""); + const m = c.match(/^UE(\d{2})([A-Z]{2})(\d{3})([A-Z\*]+)$/); + if (!m) return null; + const [, year, dept, numStr, suffix] = m; + const num = parseInt(numStr, 10); + const semesterGroup = Math.floor(num / 100); + const creditsDigit = Math.floor(num / 10) % 10; + const credit = [0, 2, 4, 5].includes(creditsDigit) ? String(creditsDigit) : "4"; + const isEven = suffix.startsWith("B") || suffix.endsWith("B") || suffix === "XX" || suffix === "XB"; + const semester = semesterGroup * 2 - 1 + (isEven ? 1 : 0); + const offering = DEPT_OFFERING[dept] || "CS"; + const target = DEPT_TARGET[dept] || "CSE"; + const isLateral = suffix.includes("*"); + return { year, dept, semester, offering, target, credit, isLateral, baseCode: c }; +} + +function updatePreview() { + const parsed = parseCode(codeInput.value); + if (!parsed) { + previewDiv.classList.add("hidden"); + previewDiv.innerHTML = ""; + return; + } + previewDiv.classList.remove("hidden"); + previewDiv.innerHTML = ` + Decoded: + Year: ${parsed.year} + Dept: ${parsed.dept} + Semester: ${parsed.semester} + Offering: ${parsed.offering} + Target: ${parsed.target} + Credits: ${parsed.credit} + ${parsed.isLateral ? 'Lateral Entry' : ''} + `; + creditSelect.value = parsed.credit; +} + +codeInput.addEventListener("input", updatePreview); +codeInput.addEventListener("blur", updatePreview); + +form.addEventListener("submit", async (e) => { + e.preventDefault(); + resultDiv.classList.add("hidden"); + resultDiv.textContent = ""; + submitBtn.disabled = true; + submitBtn.textContent = "Submitting..."; + + const fd = new FormData(form); + const data = Object.fromEntries(fd.entries()); + + const parsed = parseCode(data.course_code); + if (!parsed) { + showError("Invalid course code format. Use format like UE25CS242B"); + submitBtn.disabled = false; + submitBtn.textContent = "Submit Course"; + return; + } + + data.semester = String(parsed.semester); + data.offering_department = parsed.offering; + data.target_department = parsed.target; + data.credit_category = creditSelect.value || parsed.credit; + + try { + const res = await fetch("/api/submissions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + const json = await res.json(); + if (!res.ok) throw new Error(json.detail || "Submission failed"); + showSuccess(`Submission received! ID: ${json.submission.id}`); + form.reset(); + previewDiv.classList.add("hidden"); + } catch (err) { + showError(err.message); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = "Submit Course"; + } +}); + +let resultTimer = null; + +function showError(msg) { + clearTimeout(resultTimer); + resultDiv.classList.remove("hidden", "success"); + resultDiv.classList.add("error"); + resultDiv.textContent = msg; +} + +function showSuccess(msg) { + clearTimeout(resultTimer); + resultDiv.classList.remove("hidden", "error"); + resultDiv.classList.add("success"); + resultDiv.textContent = msg; + resultTimer = setTimeout(() => { + resultDiv.classList.add("hidden"); + }, 3500); +} \ No newline at end of file diff --git a/frontend/form/index.html b/frontend/form/index.html new file mode 100644 index 0000000000000000000000000000000000000000..5fcce1c1c91db8668d143b55b3f579d91c5d8c50 --- /dev/null +++ b/frontend/form/index.html @@ -0,0 +1,78 @@ + + + + + + + Submit Course - PESU + + + + + + +
    + + + + Submit Course + + Fill the mandatory fields and our agent will extrapolate the other content for you. +
    + +
    +
    +

    Faculty

    + + +
    + +
    +

    Course Details

    + + + + + + + + + + + Override if the code does not match your intended category. + + + + Topics, units, or rough syllabus. +
    + +
    +

    References

    + + + + + + + + + +
    +
    + + + + + + diff --git a/frontend/home/home.css b/frontend/home/home.css new file mode 100644 index 0000000000000000000000000000000000000000..330dbdb9da91a0463a3e701c97e99a5682e02c5c --- /dev/null +++ b/frontend/home/home.css @@ -0,0 +1,177 @@ +@import "../shared.css"; + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + min-height: 100dvh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + background: var(--page-bg); + color: var(--text); +} + +.home-shell { + width: min(95vw, 800px); +} + +.home-panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 20px; + padding: 3rem; + box-shadow: var(--shadow-md); +} + +h1 { + font-size: 1.75rem; + font-weight: 700; + color: var(--pesu-blue); +} + +.year-bar { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 1.25rem; + font-size: 0.875rem; +} + +.year-label { + font-weight: 600; + white-space: nowrap; +} + +.year-bar input { + width: 110px; + padding: 0.35rem 0.5rem; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.875rem; + font-family: inherit; +} + +.year-bar input:focus { + outline: 2px solid var(--pesu-orange); + outline-offset: -1px; +} + +#save-year { + padding: 0.35rem 0.75rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + font-size: 0.875rem; + font-family: inherit; + cursor: pointer; + transition: border-color 0.15s; +} + +#save-year:hover { + border-color: var(--pesu-orange); +} + +.year-status { + min-height: 28px; + display: inline-flex; + align-items: center; + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--muted); + color: var(--muted-foreground); + font-size: 13px; +} + +.year-status[hidden] { + display: none; +} + +.year-status.ready { + border-color: var(--success); + background: var(--success-bg); + color: var(--success); +} + +.surface-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1rem; + margin-top: 1.75rem; +} + +.surface-link { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.75rem; + min-height: 120px; + padding: 1.25rem; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + color: var(--pesu-blue); + text-decoration: none; + font-size: 0.95rem; + font-weight: 600; + overflow: hidden; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.surface-link::before { + content: ""; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 3px; + background: var(--pesu-orange); + border-radius: 12px 12px 0 0; + transform: scaleX(0); + transform-origin: left; + transition: transform 0.2s ease; +} + +.surface-link svg { + width: 28px; + height: 28px; + stroke: currentColor; + fill: none; + stroke-width: 1.8; + transition: stroke 0.15s; +} + +.surface-link:hover { + border-color: var(--pesu-orange); + box-shadow: var(--shadow-md); +} + +.surface-link:hover::before { + transform: scaleX(1); +} + +.surface-link:hover svg { + stroke: var(--pesu-orange); +} + +@media (max-width: 640px) { + .home-panel { + padding: 2rem; + } + .surface-grid { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 400px) { + .surface-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..857ea26b31eb1bbf7e52ffa90f05d5b90440756d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,71 @@ + + + + + + + Syntagma + + + + + +
    +
    +

    Syntagma

    +
    +
    + + + + +
    + +
    +
    + + + diff --git a/frontend/live-editor.html b/frontend/live-editor.html new file mode 100644 index 0000000000000000000000000000000000000000..ba1eb611e0b5f19fc00748dcbf5406a89b1afb26 --- /dev/null +++ b/frontend/live-editor.html @@ -0,0 +1,11 @@ + + + + + + Agentic Editor - PESU + + + Agentic Editor + + diff --git a/frontend/live-editor/index.html b/frontend/live-editor/index.html new file mode 100644 index 0000000000000000000000000000000000000000..e54de8aae290b38552676c7df51f514dac518c0e --- /dev/null +++ b/frontend/live-editor/index.html @@ -0,0 +1,164 @@ + + + + + + + Agentic Editor - PESU + + + + + + + +
    + + + +
    + + + + + +
    + +
    + + + + +
    + +
    + +
    + +
    + +
    + + +
    + +
    +
    + +
    Loading...
    +
    + + +
    + + + + + + + + diff --git a/frontend/live-editor/live-editor.css b/frontend/live-editor/live-editor.css new file mode 100644 index 0000000000000000000000000000000000000000..0b03dd96d41996e6c7c942eb35cecf5b815223e2 --- /dev/null +++ b/frontend/live-editor/live-editor.css @@ -0,0 +1,1095 @@ +@import "../shared.css"; + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +body { + margin: 0; + height: 100dvh; + display: grid; + grid-template-rows: auto 1fr; + padding: 1.25rem; + gap: 1rem; + background: var(--page-bg); + color: var(--foreground); + overflow: hidden; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + min-height: 56px; + padding: 10px 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + box-shadow: var(--shadow-md); +} + +.toolbar-group { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding-right: 8px; + border-right: 1px solid var(--border); +} + +.toolbar-group:last-of-type { + border-right: 0; +} + +select, +input, +button, +a { + height: 36px; + font-size: 14px; +} + +button { + background: #e5e7eb; +} + +.danger { + color: #dc2626; + background: #fef2f2; + border-color: #fecaca; +} + +input { + min-width: 0; + padding: 0 8px; +} + +.course-controls { + flex: 1 1 400px; + min-width: 0; +} + +.course-controls #course { + flex: 1 1 200px; + min-width: 0; +} + +.version-controls { + flex: 1 1 400px; + min-width: 0; +} + +.version-controls input { + flex: 1 1 120px; + min-width: 0; +} + +.version-controls select { + flex: 1 1 160px; + min-width: 0; +} + +button, +a { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 12px; + text-decoration: none; + cursor: pointer; + border-radius: 10px; + font-weight: 500; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.primary { + background: #00377b; + color: #fff; + border-color: #00377b; +} + +#status { + margin-left: auto; + max-width: 360px; + min-height: 28px; + display: inline-flex; + align-items: center; + padding: 4px 8px; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #f8fafc; + color: #374151; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#status[hidden] { + display: none; +} + +#status.error { + border-color: #fecaca; + background: #fef2f2; + color: #991b1b; +} + +#status.ready { + border-color: #bbf7d0; + background: #f0fdf4; + color: #166534; +} + +.message.tool { + background: #f8fafc; + border-left: 3px solid #64748b; + color: #475569; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + padding: 4px 8px; +} + +.message.tool .message-body { + white-space: pre-wrap; + word-break: break-word; +} + +.message.tool.tool-call { + border-left-color: #3b82f6; +} + +.message.tool.tool-result { + border-left-color: #22c55e; +} + +.chat-status { + padding: 4px 12px; + font-size: 12px; + color: #6b7280; + border-top: 1px solid #e5e7eb; + background: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-height: 0; + line-height: 20px; + display: flex; + align-items: center; + gap: 8px; +} + +.spinner { + display: none; + width: 16px; + height: 16px; + border: 2px solid #e5e7eb; + border-top-color: #00377b; + border-radius: 50%; + animation: spin 0.8s linear infinite; + flex-shrink: 0; +} + +.spinner.active { + display: block; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 460px; + gap: 12px; + min-height: 0; + transition: grid-template-columns 0.18s ease; +} + +.workspace.chat-focus { + grid-template-columns: minmax(0, 1fr) minmax(360px, 1.5fr); +} + +.preview-pane, +.side-pane { + min-height: 0; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + overflow: hidden; + box-shadow: var(--shadow-md); +} + +.preview-pane { + position: relative; +} + +iframe { + display: block; + width: 100%; + height: 100%; + border: 0; +} + +.loading { + position: absolute; + inset: 0; + display: none; + place-items: center; + background: rgba(255, 255, 255, 0.82); + color: #00377b; + font-weight: 700; +} + +.loading.active { + display: grid; +} + +.side-pane { + display: grid; + grid-template-rows: auto 1fr; + min-width: 0; +} + +.tabs { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + border-bottom: 1px solid #d1d5db; +} + +.tab { + width: 100%; + border: 0; + border-radius: 0; + margin: 0; + color: #00377b; + font-weight: 700; +} + +.tab.active { + background: #00377b; + color: #fff; +} + +.panel { + display: none; + min-height: 0; +} + +.panel.active { + display: grid; + min-height: 0; +} + +.chat-panel { + grid-template-rows: auto 1fr auto auto; +} + +.thread-bar { + display: grid; + grid-template-columns: 1fr auto auto auto; + gap: 8px; + padding: 10px; + border-bottom: 1px solid #d1d5db; + background: #fff; + align-items: center; +} + +.thread-selector { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.thread-selector select { + flex: 1; + min-width: 0; + max-width: none; + width: 100%; +} + +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + padding: 0; + border: 1px solid #9ca3af; + border-radius: 10px; + background: #fff; + cursor: pointer; + color: #6b7280; + flex-shrink: 0; + transition: background 0.15s, color 0.15s; +} + +.icon-btn:hover { + background: #f3f4f6; + color: #00377b; +} + +.icon-btn.active { + background: #00377b; + border-color: #00377b; + color: #fff; +} + +.context-badge { + font-size: 11px; + font-weight: 500; + color: #6b7280; + background: #f3f4f6; + border: 1px solid #d1d5db; + border-radius: 10px; + padding: 2px 8px; + white-space: nowrap; + flex-shrink: 0; +} + +.context-info { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 1px; + flex-shrink: 0; +} + +.context-usage { + font-size: 10px; + color: #9ca3af; + white-space: nowrap; +} + +.chat-log { + min-height: 0; + overflow: auto; + padding: 12px; + background: #f8fafc; +} + +.message { + max-width: 92%; + margin-bottom: 10px; + padding: 9px 10px; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + color: #111827; + font-size: 14px; + line-height: 1.4; + white-space: pre-wrap; + overflow: hidden; + overflow-wrap: break-word; +} + +.message.error { + border-color: #fecaca; + background: #fef2f2; +} + +.message.user { + margin-left: auto; + background: #eaf2ff; + border-color: #b9d1f5; +} + +.message.tool { + margin-left: auto; + margin-right: auto; + background: #f3f4f6; + border-color: #d1d5db; + color: #374151; + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + max-width: 92%; + padding: 6px 10px; +} + +.message.tool.tool-call { + background: #eef2ff; + border-color: #c7d2fe; + color: #312e81; +} + +.message.tool.tool-result { + background: #f0fdf4; + border-color: #bbf7d0; + color: #166534; +} + +.message-time { + margin-top: 4px; + color: #6b7280; + font-size: 11px; + white-space: normal; +} + +.message-body { + white-space: pre-wrap; + overflow-wrap: break-word; + word-break: break-word; +} + +.message-body h1, .message-body h2, .message-body h3, .message-body h4 { + margin: 8px 0 4px; + font-weight: 600; +} +.message-body h1 { font-size: 16px; } +.message-body h2 { font-size: 14px; } +.message-body h3 { font-size: 13px; } +.message-body h4 { font-size: 12px; } + +.message-body hr { + border: 0; + border-top: 1px solid #d1d5db; + margin: 8px 0; +} + +.message-body ul, .message-body ol { + margin: 4px 0; + padding-left: 20px; +} + +.message-body li { + list-style: disc; +} + +.message-body ol li { + list-style: decimal; +} + +.message-body table { + border-collapse: collapse; + width: 100%; + margin: 8px 0; + font-size: 12px; +} +.message-body th, .message-body td { + border: 1px solid #d1d5db; + padding: 4px 8px; + text-align: left; +} +.message-body th { + background: #f3f4f6; + font-weight: 600; +} + +.message-body pre { + background: #1e293b; + color: #e2e8f0; + padding: 10px 12px; + border-radius: 6px; + overflow-x: auto; + margin: 8px 0; + font-size: 12px; + line-height: 1.5; +} +.message-body pre code { + background: none; + border: 0; + padding: 0; + color: inherit; + font-size: inherit; +} + +.message-body code { + padding: 1px 4px; + border: 1px solid #d1d5db; + border-radius: 3px; + background: #f3f4f6; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 12px; +} + +.message-body code a { + padding: 0; + border: none; + border-radius: 0; + background: none; + font-family: inherit; + font-size: inherit; + color: #00377b; + text-decoration: underline; + word-break: break-all; +} + +.message-body blockquote { + border-left: 3px solid #d1d5db; + margin: 8px 0; + padding: 4px 12px; + color: #4b5563; +} + +.message-body a { + color: #00377b; + text-decoration: underline; + display: inline; + word-break: break-all; + background: none; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +.message-body strong { + font-weight: 600; +} + +.message-body p { + margin: 4px 0; +} + +.message-body p:first-child { + margin-top: 0; +} +.message-body p:last-child { + margin-bottom: 0; +} + +.attachments { + display: grid; + gap: 6px; + margin-top: 8px; +} + +.attachment { + display: grid; + grid-template-columns: 42px 1fr auto; + align-items: center; + gap: 8px; + padding: 6px; + border: 1px solid #d1d5db; + border-radius: 4px; + background: #fff; + font-size: 12px; +} + +.attachment img { + width: 42px; + height: 42px; + object-fit: cover; + border: 1px solid #d1d5db; +} + +.attachment-icon { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + width: 48px; + height: 48px; + border: 1px solid #d1d5db; + border-radius: 6px; + color: #00377b; + background: #f8fafc; + flex-shrink: 0; +} + +.attachment-icon-label { + font-size: 8px; + font-weight: 700; + letter-spacing: 0.5px; + color: #6b7280; + line-height: 1; +} + +.attachment-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attachment-meta { + color: #6b7280; + font-size: 11px; +} + +.attachment-meta.ready { + color: #166534; +} + +.attachment-meta.failed, +.attachment-meta.unsupported { + color: #991b1b; +} + +.attachment-remove { + height: 26px; + padding: 0 8px; +} + +.attachment-download { + width: 26px; + height: 26px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: #00377b; + background: #eaf2ff; + border: 1px solid #b3cde8; + border-radius: 4px; + text-decoration: none; + cursor: pointer; + flex-shrink: 0; +} + +.attachment-download:hover { + background: #dbeafe; +} + +.attachment-preview { + height: 26px; + padding: 0 8px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 500; + color: #166534; + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 4px; + cursor: pointer; +} + +.attachment-preview:hover { + background: #dcfce7; +} + +.attachment-actions { + display: flex; + gap: 6px; + align-items: center; + flex-shrink: 0; +} + +.attachment-download:hover { + background: #d4e4f7; +} + +.composer { + display: grid; + gap: 8px; + padding: 10px; + border-top: 1px solid #d1d5db; + background: #fff; +} + +.composer textarea, +.fields-panel textarea { + width: 100%; + border: 1px solid #d1d5db; + border-radius: 4px; + color: #111827; + font: 13px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.composer textarea { + min-height: 84px; + resize: vertical; + padding: 9px; +} + +.composer-actions { + display: grid; + grid-template-columns: auto 1fr auto auto; + gap: 8px; + align-items: center; +} + +.composer-actions #send, +.composer-actions #stop-btn { + display: inline-flex !important; + visibility: visible !important; + min-width: 60px; + justify-content: center; +} + +.stop-btn { + background: #dc2626; + color: #fff; + border: 2px solid #b91c1c; + border-radius: 8px; + padding: 0 16px; + height: 32px; + font: bold 13px/1 ui-monospace, SFMono-Regular, Consolas, monospace; + cursor: pointer; + box-shadow: 0 2px 4px rgba(220, 38, 38, 0.3); +} + +.stop-btn:hover { + background: #b91c1c; +} + +.version-display { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0 10px; + height: 26px; + font-size: 12px; + font-weight: 600; + color: var(--pesu-blue); + background: var(--muted); + border: 1px solid var(--border-strong); + border-radius: 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 220px; +} + +.fields-panel { + grid-template-rows: 1fr auto; +} + +.review-panel { + grid-template-rows: auto 1fr auto; + min-height: 0; +} + +.review-summary { + display: grid; + gap: 6px; + padding: 12px; + border-bottom: 1px solid #d1d5db; + font-size: 13px; +} + +.review-summary strong { + color: #00377b; +} + +.document-draft { + display: grid; + gap: 8px; + padding: 10px; + border-bottom: 1px solid #d1d5db; +} + +.draft-loader { + display: grid; + grid-template-columns: minmax(0, 1fr) 118px; + gap: 8px; +} + +.draft-loader select { + max-width: none; + min-width: 0; + overflow: hidden; +} + +.diff-view { + min-height: 0; + overflow: auto; + margin: 0; + background: #fff; + color: #24292f; + font: 12px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; + border-top: 1px solid #d1d5db; + border-bottom: 1px solid #d1d5db; +} + +.diff-empty { + padding: 12px; + color: #6b7280; +} + +.diff-row { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + min-height: 25px; + border-bottom: 1px solid rgba(31, 35, 40, 0.06); +} + +.diff-row.added { + background: #dafbe1; +} + +.diff-row.removed { + background: #ffebe9; +} + +.diff-row.meta, +.diff-row.header, +.diff-row.title { + background: #f6f8fa; + color: #57606a; + font-weight: 700; +} + +.diff-row.header { + color: #0969da; +} + +.diff-sign { + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 3px; + color: #57606a; + user-select: none; + border-right: 1px solid rgba(31, 35, 40, 0.08); +} + +.diff-code { + padding: 3px 8px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.fields-panel textarea { + min-height: 0; + resize: none; + border: 0; + border-radius: 0; + padding: 12px; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.field-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + padding: 10px; + border-top: 1px solid #d1d5db; +} + +@media (max-width: 980px) { + .toolbar { + flex-wrap: wrap; + } + + .toolbar-group { + width: 100%; + border-right: 0; + padding-right: 0; + } + + .course-controls, + .version-controls { + flex-wrap: wrap; + } + + #status { + width: 100%; + max-width: none; + margin-left: 0; + } + + .workspace { + grid-template-columns: 1fr; + height: auto; + } + + .preview-pane, + .side-pane { + height: 72dvh; + } + + .thread-bar { + grid-template-columns: 1fr; + } + + .thread-selector { + width: 100%; + } + + #chat-title { + width: 100%; + } +} + +.preview-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); +} + +.preview-overlay[hidden] { + display: none; +} + +.preview-modal { + background: #fff; + border-radius: 8px; + width: 90vw; + max-width: 900px; + height: 80vh; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.preview-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid #e5e7eb; + background: #f9fafb; + flex-shrink: 0; +} + +.preview-filename { + font-size: 14px; + font-weight: 600; + color: #111827; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.preview-close { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + color: #6b7280; + background: none; + border: none; + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; +} + +.preview-close:hover { + background: #e5e7eb; + color: #111827; +} + +.preview-body { + flex: 1; + overflow: auto; + padding: 0; +} + +.preview-loading, +.preview-error { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #6b7280; + font-size: 14px; + padding: 16px; +} + +.preview-error { + color: #991b1b; +} + +.preview-iframe { + width: 100%; + height: 100%; + border: none; +} + +.preview-rendered { + line-height: 1.6; + color: #1f2937; + padding: 16px; +} + +.preview-rendered h1, +.preview-rendered h2, +.preview-rendered h3 { + color: #00377b; + margin: 16px 0 8px; +} + +.preview-rendered table { + border-collapse: collapse; + width: 100%; + margin: 8px 0; +} + +.preview-rendered th, +.preview-rendered td { + border: 1px solid #d1d5db; + padding: 6px 10px; + text-align: left; + font-size: 13px; +} + +.preview-rendered th { + background: #f3f4f6; + font-weight: 600; +} + +.preview-table { + border-collapse: collapse; + width: 100%; + margin: 0; + font-size: 13px; +} + +.preview-table th, +.preview-table td { + border: 1px solid #d1d5db; + padding: 6px 10px; + text-align: left; +} + +.preview-table th { + background: #00377b; + color: #fff; + font-weight: 600; + position: sticky; + top: 0; +} + +.preview-table tbody tr:nth-child(even) { + background: #f8fafc; +} + +.preview-table tbody tr:hover { + background: #e0e7ff; +} + +.preview-text { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 13px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + margin: 0; + color: #1f2937; + padding: 16px; +} + +@media (max-width: 980px) { + .toolbar { min-height: auto; padding: 8px 10px; gap: 6px; } + .toolbar-group { padding-right: 6px; gap: 6px; } + .toolbar-group.version-controls { display: none; } + .side-pane { min-width: 0; } +} + +@media (max-width: 768px) { + .workspace { grid-template-columns: 1fr !important; } + .preview-pane { display: none; } + .side-pane { border-left: 0; } + .toolbar-group.version-controls { display: none; } + .toolbar-group.layout-controls { display: none; } + .toolbar-group.auth-controls { margin-left: auto; } +} diff --git a/frontend/live-editor/live-editor.js b/frontend/live-editor/live-editor.js new file mode 100644 index 0000000000000000000000000000000000000000..55ff77b2faef2d33255f8ea1ff51aa5bc610b75d --- /dev/null +++ b/frontend/live-editor/live-editor.js @@ -0,0 +1,1464 @@ +if (typeof marked !== "undefined") { + marked.use({ gfm: true, breaks: false }); +} + +function preprocessUrls(text) { + return text.replace( + /(?"')\]]+)/g, + (url) => { + const clean = url.replace(/[.,;:!?]+$/, ""); + const trailing = url.slice(clean.length); + return `[${clean}](${clean})${trailing}`; + } + ); +} + +function yearParam(base) { + const y = localStorage.getItem("curriculumYear") || ""; + if (!y) return base; + const sep = base.includes("?") ? "&" : "?"; + return `${base}${sep}curriculum_year=${encodeURIComponent(y)}`; +} + +const semester = document.getElementById("semester"); +const course = document.getElementById("course"); +const viewMode = document.getElementById("view-mode"); +const versionName = document.getElementById("version-name"); +const saveVersion = document.getElementById("save-version"); +const versionSelect = document.getElementById("version-select"); +const restoreVersion = document.getElementById("restore-version"); +const preview = document.getElementById("preview"); +const viewer = document.getElementById("viewer"); +const loading = document.getElementById("loading"); +const statusText = document.getElementById("status"); +const chatTab = document.getElementById("chat-tab"); +const fieldsTab = document.getElementById("fields-tab"); +const reviewTab = document.getElementById("review-tab"); +const chatPanel = document.getElementById("chat-panel"); +const fieldsPanel = document.getElementById("fields-panel"); +const reviewPanel = document.getElementById("review-panel"); +const chatSession = document.getElementById("chat-session"); +const chatTitle = document.getElementById("chat-title"); +const renameChat = document.getElementById("rename-chat"); +const deleteChat = document.getElementById("delete-chat"); +const newChat = document.getElementById("new-chat"); +const chatLog = document.getElementById("chat-log"); +const chatStatus = document.getElementById("chat-status"); +const chatStatusText = document.getElementById("chat-status-text"); +const chatSpinner = document.getElementById("chat-spinner"); +const message = document.getElementById("message"); +const attach = document.getElementById("attach"); +const files = document.getElementById("files"); +const draftAttachments = document.getElementById("draft-attachments"); +const send = document.getElementById("send"); +const stopBtn = document.getElementById("stop-btn"); +const versionDisplay = document.getElementById("version-display"); + +const editor = document.getElementById("editor"); +const draft = document.getElementById("draft"); +const save = document.getElementById("save"); +const pendingCourseSelect = document.getElementById("pending-course-select"); +const loadPendingCourse = document.getElementById("load-pending-course"); +const courseDraftSelect = document.getElementById("course-draft-select"); +const loadCourseDraft = document.getElementById("load-course-draft"); +const documentDraftSelect = document.getElementById("document-draft-select"); +const loadDocumentDraft = document.getElementById("load-document-draft"); +const reviewSummary = document.getElementById("review-summary"); +const diffView = document.getElementById("diff-view"); +const previewDraft = document.getElementById("preview-draft"); +const applyDraft = document.getElementById("apply-draft"); +const togglePane = document.getElementById("toggle-pane"); +const logoutBtn = document.getElementById("logout-btn"); +const previewOverlay = document.getElementById("preview-overlay"); +const previewFilename = document.getElementById("preview-filename"); +const previewBody = document.getElementById("preview-body"); +const previewClose = document.getElementById("preview-close"); +const contextBadge = document.getElementById("context-badge"); +const contextUsage = document.getElementById("context-usage"); + +fetch("/api/agent/context-length").then((r) => r.json()).then((d) => { + const tokens = d.context_length || 0; + contextBadge.textContent = tokens >= 1000000 ? `${tokens / 1000000}M` : tokens >= 1000 ? `${Math.round(tokens / 1000)}K` : `${tokens}`; + contextBadge.title = `${d.model} - ${tokens.toLocaleString()} tokens`; +}).catch(() => {}); + +const TOOL_LABELS = { + get_course_codes: "Looking up courses", + get_current_course_json: "Reading course data", + get_course_syllabus: "Reading syllabus", + get_course_textbooks: "Reading textbooks", + get_course_deterministic: "Reading course properties", + get_course_lab: "Reading lab details", + get_course_fields: "Reading course fields", + batch_read_courses: "Reading courses", + get_curriculum_json: "Loading curriculum", + get_curriculum_stats: "Computing statistics", + create_course_draft: "Creating draft", + create_refined_course: "Creating course", + create_document_draft: "Creating document draft", + create_report: "Generating report", + create_spreadsheet: "Generating spreadsheet", + diff_course_json: "Comparing courses", + diff_versions: "Comparing versions", + get_version: "Loading snapshot", + update_deterministic_fields: "Updating course", + get_attachment_text: "Reading attachment", + list_specializations: "Loading specializations", + define_specialization: "Creating specialization", + assign_elective_to_tracks: "Categorizing elective", + get_course_assignments: "Reading elective assignments", + fetch_url: "Fetching URL", + web_search: "Searching the web", + signal_done: "Finalizing", + get_document_draft: "Reading document draft", + create_curriculum_version: "Creating snapshot", + get_course_draft: "Reading draft", + remove_elective_from_tracks: "Removing elective", + get_preview_url: "Getting preview URL", + list_courses: "Looking up courses", +}; + +let activeCourseId = ""; +let activeDraftId = ""; +let activeDraftRefinedId = ""; +let activeDocumentDraftId = ""; +let activeSessionId = ""; +let chatLoadSeq = 0; +let queuedFiles = []; +let versionMode = false; +let activeAbortController = null; +let currentVersionName = ""; +const initialParams = new URLSearchParams(location.search); + +if (logoutBtn) { + logoutBtn.addEventListener("click", () => { + localStorage.removeItem("sb-supgrlinqgxvifijgbns-auth-token"); + location.href = "/auth/"; + }); +} + +if (stopBtn) { + stopBtn.addEventListener("click", () => { + if (activeAbortController) { + activeAbortController.abort(); + activeAbortController = null; + } + }); +} + +function hideCourseControls() { + semester.hidden = true; + course.hidden = true; + const previewBtn = document.getElementById("preview"); + if (previewBtn) previewBtn.hidden = false; +} + +function showCourseControls() { + semester.hidden = false; + course.hidden = false; + const previewBtn = document.getElementById("preview"); + if (previewBtn) previewBtn.hidden = true; +} + +function updateRestoreVisibility() { + if (!restoreVersion) return; + const firstOption = versionSelect.options[0]; + const isLatest = !firstOption || firstOption.value === versionSelect.value; + restoreVersion.hidden = isLatest || versionMode; +} + +function updateVersionDisplay(name) { + currentVersionName = name || ""; + if (versionDisplay) { + if (name) { + versionDisplay.textContent = name; + versionDisplay.hidden = false; + } else { + versionDisplay.hidden = true; + } + } +} + +function setStatus(text, kind = "") { + statusText.textContent = text || ""; + statusText.className = kind; + statusText.hidden = !text; +} + +function showError(error, fallback = "Action failed.") { + loading.classList.remove("active"); + const text = error instanceof Error ? error.message : fallback; + setStatus(text || fallback, "error"); +} + +async function errorMessage(response, fallback) { + try { + const body = await response.json(); + return body.detail || body.message || fallback; + } catch { + return fallback; + } +} + +async function courseIds(sem) { + const response = await fetch(`/api/preview/semester/${sem}/courses`); + if (!response.ok) return []; + const body = await response.json(); + return body.course_ids || []; +} + +async function courseEntries(sem) { + const response = await fetch(`/api/preview/semester/${sem}/courses`); + if (!response.ok) return []; + const body = await response.json(); + return body.courses || []; +} + +async function firstAvailableSemester() { + for (let sem = 1; sem <= 8; sem += 1) { + if ((await courseIds(sem)).length) return String(sem); + } + return "1"; +} + +function setTab(name) { + const chat = name === "chat"; + const fields = name === "fields"; + const review = name === "review"; + chatTab.classList.toggle("active", chat); + fieldsTab.classList.toggle("active", fields); + reviewTab.classList.toggle("active", review); + chatTab.setAttribute("aria-selected", String(chat)); + fieldsTab.setAttribute("aria-selected", String(fields)); + reviewTab.setAttribute("aria-selected", String(review)); + chatPanel.classList.toggle("active", chat); + fieldsPanel.classList.toggle("active", fields); + reviewPanel.classList.toggle("active", review); + if (review) refreshDraftSelectors().catch(() => {}); +} + +function chatKey() { + return `pesu-live-editor-session:${activeCourseId || "document"}`; +} + +function option(value, text) { + const item = document.createElement("option"); + item.value = value; + item.textContent = text; + return item; +} + +function versionLabel(item) { + const year = item.academic_year ? ` ${item.academic_year}` : ""; + return `${item.name}${year}`; +} + +async function refreshVersions() { + const response = await fetch("/api/versions"); + if (!response.ok) return; + const body = await response.json(); + versionSelect.replaceChildren(...(body.versions || []).map((item) => option(String(item.id), versionLabel(item)))); + updateRestoreVisibility(); +} + +async function saveCurrentVersion() { + const name = versionName.value.trim(); + if (!name) { + setStatus("Version name is required.", "error"); + return; + } + saveVersion.disabled = true; + try { + setStatus("Saving full version..."); + const response = await fetch("/api/versions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + if (!response.ok) throw new Error(await errorMessage(response, "Version save failed")); + const body = await response.json(); + versionName.value = ""; + await refreshVersions(); + versionSelect.value = String(body.version.id); + setStatus(`Saved ${body.courses} courses.`, "ready"); + } finally { + saveVersion.disabled = false; + } +} + +async function restoreSelectedVersion() { + if (!versionSelect.value) return; + if (!await showConfirm("Restore this version? This will archive all current courses and replace them with the snapshot.")) return; + restoreVersion.disabled = true; + try { + setStatus("Restoring full version..."); + const response = await fetch(`/api/versions/${versionSelect.value}/restore`, { method: "POST" }); + if (!response.ok) throw new Error(await errorMessage(response, "Version restore failed")); + const body = await response.json(); + if (activeCourseId) await loadCourse(activeCourseId); + setStatus(`Restored ${body.courses_restored} courses. Archived ${body.courses_archived || 0}.`, "ready"); + } finally { + restoreVersion.disabled = false; + } +} + +function chatScopeQuery() { + return ""; +} + +function sessionTitle(item) { + if (item.title) { + if (item.refined_id && !item.title.includes("Course " + item.refined_id)) { + const courseOpt = course.querySelector(`option[value="${item.refined_id}"]`); + const courseName = courseOpt ? courseOpt.textContent.split(" - ").pop().trim() : ""; + if (courseName && !item.title.includes(courseName)) return `${courseName}: ${item.title}`; + } + return item.title; + } + const date = item.created_at ? new Date(item.created_at).toLocaleDateString() : ""; + return date ? `Thread ${item.id} - ${date}` : `Thread ${item.id}`; +} + +async function refreshChatSessions() { + const response = await fetch(`/api/chat/sessions${chatScopeQuery()}`); + if (!response.ok) return; + const body = await response.json(); + const sessions = body.sessions || []; + chatSession.replaceChildren(...sessions.map((item) => option(String(item.id), sessionTitle(item)))); + if (activeSessionId) chatSession.value = activeSessionId; + const selected = sessions.find((item) => String(item.id) === chatSession.value); + if (chatTitle.hidden) chatTitle.value = selected?.title || ""; +} + +async function createChatSession() { + const response = await fetch("/api/chat/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(activeCourseId ? { refined_id: Number(activeCourseId), title: statusText.textContent } : { title: "New Thread" }), + }); + if (!response.ok) throw new Error("Unable to create agent session"); + const body = await response.json(); + activeSessionId = String(body.session.id); + localStorage.setItem(chatKey(), activeSessionId); + await refreshChatSessions(); + chatSession.value = activeSessionId; + return activeSessionId; +} + +function isDefaultTitle(title) { + if (!title) return true; + if (title === "New Thread") return true; + if (/^Thread \d+/.test(title)) return true; + if (title === statusText.textContent) return true; + return false; +} + +async function autoRenameSession(userMessage) { + if (!activeSessionId) return; + const selected = chatSession.options[chatSession.selectedIndex]; + if (selected && !isDefaultTitle(selected.text)) return; + const trimmed = userMessage.trim().replace(/\s+/g, " "); + const title = trimmed.length > 60 ? trimmed.slice(0, 57) + "..." : trimmed; + if (!title) return; + await fetch(`/api/chat/sessions/${activeSessionId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title }), + }); + await refreshChatSessions(); + chatSession.value = activeSessionId; +} + +async function renameActiveChat() { + if (!activeSessionId || !chatTitle.value.trim()) { + exitRenameMode(); + return; + } + const response = await fetch(`/api/chat/sessions/${activeSessionId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: chatTitle.value.trim() }), + }); + if (!response.ok) throw new Error(await errorMessage(response, "Rename failed")); + await refreshChatSessions(); + setStatus("Thread renamed.", "ready"); + exitRenameMode(); +} + +function enterRenameMode() { + if (!activeSessionId) return; + const currentTitle = chatSession.options[chatSession.selectedIndex]?.text || ""; + chatTitle.value = currentTitle; + chatTitle.hidden = false; + document.querySelector(".thread-selector").hidden = true; + chatTitle.focus(); + chatTitle.select(); +} + +function exitRenameMode() { + chatTitle.hidden = true; + const selector = document.querySelector(".thread-selector"); + if (selector) selector.hidden = false; +} + +async function deleteActiveChat() { + if (!activeSessionId) return; + if (!await showConfirm("Delete this agent thread permanently?")) return; + const deleted = activeSessionId; + const response = await fetch(`/api/chat/sessions/${deleted}`, { method: "DELETE" }); + if (!response.ok) throw new Error(await errorMessage(response, "Delete failed")); + localStorage.removeItem(chatKey()); + activeSessionId = ""; + chatLog.replaceChildren(); + await ensureChatSession(); + await renderMessages(); + setStatus("Thread deleted.", "ready"); +} + +async function ensureChatSession() { + const key = chatKey(); + const existing = localStorage.getItem(key); + if (existing) { + activeSessionId = existing; + await refreshChatSessions(); + if ([...chatSession.options].some((item) => item.value === existing)) return existing; + localStorage.removeItem(key); + } + activeSessionId = ""; + await refreshChatSessions(); + if (chatSession.value) { + activeSessionId = chatSession.value; + localStorage.setItem(key, activeSessionId); + return activeSessionId; + } + return createChatSession(); +} + +async function loadMessages() { + if (!activeSessionId) return []; + const response = await fetch(`/api/chat/sessions/${activeSessionId}/messages`); + if (!response.ok) { + localStorage.removeItem(chatKey()); + activeSessionId = ""; + await ensureChatSession(); + return []; + } + const body = await response.json(); + return body.messages || []; +} + +function formatSize(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function attachmentNode(file, index, removable) { + const item = document.createElement("div"); + item.className = "attachment"; + const visual = file.preview ? document.createElement("img") : document.createElement("div"); + if (file.preview) { + visual.src = file.preview; + visual.alt = ""; + } else { + visual.className = "attachment-icon"; + const mime = (file.type || "").toLowerCase(); + let icon = ``; + let label = "FILE"; + if (mime.includes("pdf")) { + icon = ``; + label = "PDF"; + } else if (mime.includes("spreadsheet") || mime.includes("excel") || mime.endsWith(".xlsx") || mime.endsWith(".csv")) { + icon = ``; + label = "XLS"; + } else if (mime.includes("word") || mime.includes("document") || mime.endsWith(".doc") || mime.endsWith(".docx")) { + icon = ``; + label = "DOC"; + } else if (mime.includes("markdown") || (file.name || "").endsWith(".md")) { + icon = ``; + label = "MD"; + } else if (mime.includes("text") || mime.endsWith(".txt")) { + icon = ``; + label = "TXT"; + } else if (mime.includes("image")) { + icon = ``; + label = "IMG"; + } + visual.innerHTML = `${label}${icon}`; + } + const text = document.createElement("div"); + const name = document.createElement("div"); + name.className = "attachment-name"; + name.textContent = file.name; + const meta = document.createElement("div"); + meta.className = "attachment-meta"; + if (file.status) meta.classList.add(file.status); + const status = file.status ? ` - ${file.status}` : ""; + const extracted = file.extracted_chars ? ` - ${file.extracted_chars} chars` : ""; + meta.textContent = `${file.type || "file"} - ${formatSize(file.size || 0)}${status}${extracted}`; + text.append(name, meta); + item.append(visual, text); + const actions = document.createElement("div"); + actions.className = "attachment-actions"; + if (removable) { + const remove = document.createElement("button"); + remove.className = "attachment-remove"; + remove.type = "button"; + remove.textContent = "Remove"; + remove.addEventListener("click", () => { + queuedFiles.splice(index, 1); + renderDraftAttachments(); + }); + actions.appendChild(remove); + } else if (file.id) { + const preview = document.createElement("button"); + preview.className = "attachment-preview"; + preview.type = "button"; + preview.textContent = "Preview"; + preview.addEventListener("click", () => openPreview(file)); + actions.appendChild(preview); + const dl = document.createElement("a"); + dl.className = "attachment-download"; + dl.href = `/api/chat/sessions/${activeSessionId}/attachments/${file.id}/download`; + dl.download = file.name || "download"; + dl.title = "Download"; + dl.innerHTML = ''; + actions.appendChild(dl); + } + item.appendChild(actions); + return item; +} + +function renderDraftAttachments() { + draftAttachments.replaceChildren(...queuedFiles.map((file, index) => attachmentNode(file, index, true))); +} + +async function openPreview(file) { + if (!file.id || !activeSessionId) return; + previewFilename.textContent = file.name || "Preview"; + previewBody.innerHTML = '
    Loading preview...
    '; + previewOverlay.hidden = false; + const mime = (file.type || "").toLowerCase(); + const name = (file.name || "").toLowerCase(); + try { + const url = `/api/chat/sessions/${activeSessionId}/attachments/${file.id}/preview`; + const response = await fetch(url); + if (!response.ok) throw new Error("Preview failed"); + if (mime === "application/pdf" || name.endsWith(".pdf")) { + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); + previewBody.innerHTML = ``; + } else if (mime.includes("markdown") || name.endsWith(".md")) { + const text = await response.text(); + const html = typeof DOMPurify !== "undefined" ? DOMPurify.sanitize(marked.parse(preprocessUrls(text))) : marked.parse(preprocessUrls(text)); + previewBody.innerHTML = `
    ${html}
    `; + } else if (mime.includes("spreadsheet") || mime.includes("excel") || name.endsWith(".xlsx") || name.endsWith(".csv")) { + if (name.endsWith(".xlsx")) { + const html = await response.text(); + previewBody.innerHTML = html; + } else { + const text = await response.text(); + const table = csvToTable(text, true); + previewBody.innerHTML = table; + } + } else if (mime.includes("word") || name.endsWith(".doc") || name.endsWith(".docx")) { + const text = await response.text(); + previewBody.innerHTML = `
    ${escapeHtml(text)}
    `; + } else { + const text = await response.text(); + previewBody.innerHTML = `
    ${escapeHtml(text)}
    `; + } + } catch (error) { + previewBody.innerHTML = `
    Could not load preview.
    `; + } +} + +function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +function csvToTable(text, isCsv) { + const lines = text.split("\n").filter((l) => l.trim()); + if (!lines.length) return `
    ${escapeHtml(text)}
    `; + const rows = lines.map((line) => { + const cells = []; + let current = ""; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inQuotes) { + if (ch === '"' && line[i + 1] === '"') { current += '"'; i++; } + else if (ch === '"') inQuotes = false; + else current += ch; + } else { + if (ch === '"') inQuotes = true; + else if (ch === "\t" || (isCsv && ch === ",")) { cells.push(current); current = ""; } + else current += ch; + } + } + cells.push(current); + return cells; + }); + const header = rows.shift(); + if (!header) return `
    ${escapeHtml(text)}
    `; + let html = ''; + header.forEach((h) => { html += ``; }); + html += ""; + rows.forEach((row) => { + html += ""; + row.forEach((cell) => { html += ``; }); + html += ""; + }); + html += "
    ${escapeHtml(h)}
    ${escapeHtml(cell)}
    "; + return html; +} + +function closePreview() { + const iframe = previewBody.querySelector("iframe"); + if (iframe && iframe.src.startsWith("blob:")) URL.revokeObjectURL(iframe.src); + previewOverlay.hidden = true; + previewBody.innerHTML = ""; +} + +previewClose.addEventListener("click", closePreview); +previewOverlay.addEventListener("click", (e) => { if (e.target === previewOverlay) closePreview(); }); +document.addEventListener("keydown", (e) => { if (e.key === "Escape" && !previewOverlay.hidden) closePreview(); }); + +function messageNode(item) { + const bubble = document.createElement("div"); + bubble.className = `message ${item.role}`; + if (item.role === "tool") { + const callType = item.metadata?.tool_call_type || (item.content || "").startsWith("\u2699") ? "call" : "result"; + bubble.classList.add(callType === "call" ? "tool-call" : "tool-result"); + } + const content = document.createElement("div"); + content.className = "message-body"; + renderMessageContent(content, item.content || ""); + bubble.appendChild(content); + const attachments = item.attachments || item.metadata?.attachments || []; + if (attachments.length) { + const list = document.createElement("div"); + list.className = "attachments"; + attachments.forEach((file) => list.appendChild(attachmentNode(file, 0, false))); + bubble.appendChild(list); + } + const time = document.createElement("div"); + time.className = "message-time"; + time.textContent = new Date(item.created_at || Date.now()).toLocaleString(); + bubble.appendChild(time); + return { bubble, content }; +} + +function renderInline(parent, text) { + if (typeof marked !== "undefined") { + parent.innerHTML = typeof DOMPurify !== "undefined" ? DOMPurify.sanitize(marked.parse(preprocessUrls(text))) : marked.parse(preprocessUrls(text)); + return; + } + const pattern = /(\*\*[^*]+\*\*|`[^`]+`|https?:\/\/[^\s<>"']+)/g; + let last = 0; + for (const match of text.matchAll(pattern)) { + if (match.index > last) parent.appendChild(document.createTextNode(text.slice(last, match.index))); + const token = match[0]; + if (token.startsWith("**")) { + const strong = document.createElement("strong"); + strong.textContent = token.slice(2, -2); + parent.appendChild(strong); + } else if (token.startsWith("`")) { + const code = document.createElement("code"); + code.textContent = token.slice(1, -1); + parent.appendChild(code); + } else { + const link = document.createElement("a"); + const url = token.replace(/[.,;:)}!?]+$/, "").replace(/[<>]+/g, ""); + const trailing = token.slice(url.length); + link.href = url; + link.target = "_blank"; + link.rel = "noreferrer"; + link.textContent = url; + parent.appendChild(link); + if (trailing) parent.appendChild(document.createTextNode(trailing)); + } + last = match.index + token.length; + } + if (last < text.length) parent.appendChild(document.createTextNode(text.slice(last))); +} + +function renderMessageContent(target, value) { + target.replaceChildren(); + const text = String(value || ""); + if (typeof marked !== "undefined") { + target.innerHTML = typeof DOMPurify !== "undefined" ? DOMPurify.sanitize(marked.parse(preprocessUrls(text))) : marked.parse(preprocessUrls(text)); + return; + } + const lines = text.split("\n"); + let inList = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + if (!trimmed) { + inList = false; + continue; + } + if (/^---+$/.test(trimmed)) { + if (inList) inList = false; + target.appendChild(document.createElement("hr")); + continue; + } + if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) { + if (!inList) { + inList = true; + target.appendChild(document.createElement("ul")); + } + const li = document.createElement("li"); + renderInline(li, trimmed.slice(2)); + target.lastElementChild.appendChild(li); + continue; + } + if (inList) inList = false; + const block = document.createElement("div"); + renderInline(block, line); + target.appendChild(block); + } +} + +function appendMessage(item) { + const node = messageNode(item); + chatLog.appendChild(node.bubble); + chatLog.scrollTop = chatLog.scrollHeight; + return node; +} + +async function renderMessages() { + const seq = ++chatLoadSeq; + const messages = await loadMessages(); + if (seq !== chatLoadSeq) return; + chatLog.replaceChildren(); + messages.forEach((item) => appendMessage(item)); + chatStatusText.textContent = ""; + chatSpinner.classList.remove("active"); +} + +function parseEvent(raw) { + let event = "message"; + const data = []; + raw.split("\n").forEach((line) => { + if (line.startsWith("event:")) event = line.slice(6).trim(); + if (line.startsWith("data:")) data.push(line.slice(5).trim()); + }); + return { event, data: data.length ? JSON.parse(data.join("\n")) : {} }; +} + +async function readEventStream(response, onEvent) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let index = buffer.indexOf("\n\n"); + while (index >= 0) { + const raw = buffer.slice(0, index).trim(); + buffer = buffer.slice(index + 2); + if (raw) { + try { + onEvent(parseEvent(raw)); + } catch (e) { + console.warn("SSE parse error:", e); + } + } + index = buffer.indexOf("\n\n"); + } + } +} + +function resetReview() { + activeDraftId = ""; + activeDraftRefinedId = ""; + activeDocumentDraftId = ""; + reviewSummary.replaceChildren(document.createTextNode("No draft loaded.")); + renderDiff(""); + previewDraft.disabled = true; + applyDraft.disabled = true; +} + +function renderDiff(text) { + diffView.replaceChildren(); + if (!text) { + const empty = document.createElement("div"); + empty.className = "diff-empty"; + empty.textContent = "No diff loaded."; + diffView.appendChild(empty); + return; + } + + text.split("\n").forEach((line) => { + let kind = "context"; + let sign = ""; + let code = line; + + if (line.startsWith("Course draft ")) { + kind = "title"; + } else if (line.startsWith("@@")) { + kind = "header"; + } else if (line.startsWith("---") || line.startsWith("+++")) { + kind = "meta"; + } else if (line.startsWith("-")) { + kind = "removed"; + sign = "-"; + code = line.slice(1).replace(/^ /, ""); + } else if (line.startsWith("+")) { + kind = "added"; + sign = "+"; + code = line.slice(1).replace(/^ /, ""); + } + + const row = document.createElement("div"); + row.className = `diff-row ${kind}`; + const signCell = document.createElement("div"); + signCell.className = "diff-sign"; + signCell.textContent = sign; + const codeCell = document.createElement("div"); + codeCell.className = "diff-code"; + codeCell.textContent = code; + row.append(signCell, codeCell); + diffView.appendChild(row); + }); +} + +function summaryLine(label, value) { + const line = document.createElement("div"); + const strong = document.createElement("strong"); + strong.textContent = `${label}: `; + line.append(strong, document.createTextNode(value)); + return line; +} + +function renderDraftReview(draftRow) { + const summary = draftRow.diff_summary || {}; + activeDraftId = String(draftRow.id || ""); + activeDraftRefinedId = String(draftRow.refined_id || ""); + reviewSummary.replaceChildren( + summaryLine("Draft", activeDraftId || "unsaved"), + summaryLine("Status", draftRow.status || ""), + summaryLine("Change", `${summary.change_percent || 0}%`), + summaryLine("Syllabus change", `${summary.syllabus_change_percent || 0}%`), + summaryLine("Topics added", (summary.topics_added || []).join(", ") || "None"), + summaryLine("Topics removed", (summary.topics_removed || []).join(", ") || "None"), + summaryLine("Protected changes", (summary.protected_changes || []).join(", ") || "None"), + ); + renderDiff(summary.unified_diff || "No changes."); + previewDraft.disabled = !activeDraftId; + applyDraft.disabled = draftRow.status !== "proposed" || Boolean((summary.protected_changes || []).length); +} + +function showCourseDraft(draftRow) { + renderDraftReview(draftRow); + viewer.src = yearParam(`/api/agent/drafts/${draftRow.id}/preview?diff=1`); + setStatus("Draft ready for review.", "ready"); + refreshDraftSelectors().catch(showError); + setTab("review"); +} + +async function loadCourseDraftById(id) { + if (!id) return; + setStatus("Loading course draft..."); + const response = await fetch(`/api/agent/drafts/${id}`); + if (!response.ok) throw new Error(await errorMessage(response, "Course draft not found")); + const body = await response.json(); + showCourseDraft(body.draft); + setStatus("Course draft loaded.", "ready"); +} + +async function loadDocumentDraftById(id) { + if (!id) return; + setStatus("Loading document draft..."); + const response = await fetch(`/api/agent/document-drafts/${id}`); + if (!response.ok) throw new Error(await errorMessage(response, "Document draft not found")); + const body = await response.json(); + const summary = body.document_draft.diff_summary || {}; + activeDraftId = ""; + activeDocumentDraftId = String(id); + reviewSummary.replaceChildren( + summaryLine("Document draft", id), + summaryLine("Status", body.document_draft.status || ""), + summaryLine("Courses changed", String(summary.courses_changed || 0)), + summaryLine("Removed-topic courses", String(summary.courses_with_removed_topics || 0)), + summaryLine("Protected-change courses", String(summary.courses_with_protected_changes || 0)), + summaryLine("Max syllabus change", `${summary.max_syllabus_change_percent || 0}%`), + ); + renderDiff( + (body.drafts || []) + .map((item) => `Course draft ${item.id}\n${item.diff_summary?.unified_diff || ""}`) + .join("\n\n"), + ); + previewDraft.disabled = false; + applyDraft.disabled = body.document_draft.status !== "proposed" || Boolean(summary.courses_with_protected_changes); + viewer.src = yearParam(`/api/agent/document-drafts/${id}/preview?diff=1`); + await refreshDraftSelectors(); + documentDraftSelect.value = String(id); + setTab("review"); + setStatus("Document draft loaded.", "ready"); +} + +function courseDraftLabel(item) { + const title = item.course_title || `Course ${item.refined_id}`; + const code = item.course_code ? `${item.course_code} - ` : ""; + return `${item.id}: ${code}${title} (${item.status})`; +} + +function documentDraftLabel(item) { + const name = item.uploaded_document_id || item.change_reason || `Document draft ${item.id}`; + return `${item.id}: ${name} (${item.status})`; +} + +async function refreshDraftSelectors() { + const [courseResponse, documentResponse] = await Promise.all([ + fetch("/api/agent/drafts"), + fetch("/api/agent/document-drafts"), + ]); + if (courseResponse.ok) { + const body = await courseResponse.json(); + courseDraftSelect.replaceChildren(...(body.drafts || []).filter((item) => item.status !== "applied").map((item) => option(String(item.id), courseDraftLabel(item)))); + } + if (documentResponse.ok) { + const body = await documentResponse.json(); + documentDraftSelect.replaceChildren(...(body.document_drafts || []).filter((item) => item.status !== "applied").map((item) => option(String(item.id), documentDraftLabel(item)))); + } + if (activeDraftId) courseDraftSelect.value = activeDraftId; + await refreshPendingCourses(); +} + +function filePreview(file) { + return new Promise((resolve) => { + if (!file.type.startsWith("image/") || file.size > 900000) { + resolve(""); + return; + } + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => resolve(""); + reader.readAsDataURL(file); + }); +} + +async function queueFiles(fileList) { + await ensureChatSession(); + setStatus("Uploading attachments..."); + const pending = await Promise.all( + Array.from(fileList).map(async (file) => ({ + name: file.name, + type: file.type, + size: file.size, + status: "uploading", + preview: await filePreview(file), + })), + ); + queuedFiles = queuedFiles.concat(pending); + renderDraftAttachments(); + const form = new FormData(); + Array.from(fileList).forEach((file) => form.append("files", file)); + const response = await fetch(`/api/chat/sessions/${activeSessionId}/attachments`, { + method: "POST", + body: form, + }); + if (!response.ok) { + throw new Error(await errorMessage(response, "Attachment upload failed")); + } + const body = await response.json(); + const uploaded = (body.attachments || []).map((file, index) => ({ + ...file, + preview: pending[index]?.preview || "", + })); + queuedFiles.splice(queuedFiles.length - pending.length, pending.length, ...uploaded); + renderDraftAttachments(); + setStatus("Attachments ready.", "ready"); + files.value = ""; +} + +async function loadCourse(id) { + activeCourseId = String(id); + history.replaceState({ courseId: activeCourseId }, "", `?course=${activeCourseId}`); + versionMode = false; + save.disabled = false; + showCourseControls(); + course.disabled = false; + semester.disabled = false; + updateVersionDisplay(""); + updateRestoreVisibility(); + resetReview(); + loading.classList.add("active"); + const response = await fetch(`/api/refined/${id}`); + if (!response.ok) throw new Error("Unable to load course"); + const row = await response.json(); + editor.value = JSON.stringify(row.fields || {}, null, 2); + viewer.src = yearParam(`/api/preview/course/${id}`); + const title = row.fields?.course_title || `Course ${id}`; + setStatus(title); + const selected = course.querySelector(`option[value="${id}"]`); + if (selected) selected.textContent = title; + queuedFiles = []; + renderDraftAttachments(); + await ensureChatSession(); + await renderMessages(); +} + +async function loadVersionCourse(versionId, refinedId) { + activeCourseId = String(refinedId); + history.replaceState({ courseId: activeCourseId }, "", `?version=${versionId}&course=${activeCourseId}`); + versionMode = true; + save.disabled = true; + showCourseControls(); + course.disabled = true; + semester.disabled = true; + resetReview(); + loading.classList.add("active"); + const response = await fetch(`/api/versions/${versionId}/courses/${refinedId}`); + if (!response.ok) throw new Error("Unable to load version course"); + const body = await response.json(); + editor.value = JSON.stringify(body.fields || {}, null, 2); + viewer.src = yearParam(`/api/versions/${versionId}/courses/${refinedId}/preview`); + updateVersionDisplay(body.version.name); + setStatus(`${body.version.name}: ${body.fields?.course_title || `Course ${refinedId}`}`); + queuedFiles = []; + renderDraftAttachments(); + await ensureChatSession(); + await renderMessages(); +} + +async function loadVersionInEditor(versionId) { + const response = await fetch(`/api/versions/${versionId}`); + if (!response.ok) throw new Error("Unable to load version"); + const body = await response.json(); + const version = body.version; + const courses = body.courses || []; + updateVersionDisplay(version.name); + if (!courses.length) { + setStatus(`${version.name}: No courses in this version.`, "error"); + return; + } + versionMode = true; + await loadVersionCourse(versionId, courses[0].refined_id); +} + +async function loadDocumentPreview() { + activeCourseId = ""; + activeDraftId = ""; + activeDraftRefinedId = ""; + versionMode = false; + viewMode.value = "document"; + save.disabled = true; + hideCourseControls(); + updateVersionDisplay(""); + updateRestoreVisibility(); + editor.value = ""; + resetReview(); + chatStatusText.textContent = ""; + loading.classList.add("active"); + viewer.src = yearParam("/api/preview/pdf"); + setStatus("Full Document"); + await ensureChatSession(); + await renderMessages(); +} + +async function refreshCourseDropdown() { + const sem = semester.value; + const entries = await courseEntries(sem); + const prev = course.value; + course.replaceChildren(...entries.map((c) => option(String(c.id), `${c.course_code ? c.course_code + " - " : ""}${c.course_title || "Course " + c.id}`))); + if (prev && entries.some((c) => String(c.id) === prev)) course.value = prev; +} + +async function loadCourseForReview(id) { + activeCourseId = String(id); + history.replaceState({ courseId: activeCourseId }, "", `?course=${activeCourseId}`); + versionMode = false; + save.disabled = false; + showCourseControls(); + course.disabled = false; + semester.disabled = false; + updateVersionDisplay(""); + updateRestoreVisibility(); + resetReview(); + loading.classList.add("active"); + const response = await fetch(`/api/refined/${id}`); + if (!response.ok) throw new Error("Unable to load course"); + const row = await response.json(); + const fields = row.fields || {}; + editor.value = JSON.stringify(fields, null, 2); + viewer.src = yearParam(`/api/preview/course/${id}`); + const title = fields.course_title || `Course ${id}`; + setStatus(title); + const sem = String(fields.semester || ""); + if (sem && semester.value !== sem) semester.value = sem; + await refreshCourseDropdown(); + if (!course.querySelector(`option[value="${id}"]`)) { + course.appendChild(option(String(id), title)); + } + course.value = String(id); + queuedFiles = []; + renderDraftAttachments(); + setTab("fields"); + ensureChatSession().catch(() => {}); +} + +async function refreshPendingCourses() { + try { + const response = await fetch("/api/preview/pending-courses"); + if (!response.ok) return; + const body = await response.json(); + const courses = body.courses || []; + pendingCourseSelect.replaceChildren(...courses.map((c) => option(String(c.id), `${c.course_code ? c.course_code + " - " : ""}${c.course_title || "Course " + c.id}`))); + } catch {} +} + +async function loadSemester(sem) { + versionMode = false; + save.disabled = false; + semester.value = sem; + course.replaceChildren(); + editor.value = ""; + viewer.removeAttribute("src"); + loading.classList.add("active"); + setStatus("Loading..."); + + const entries = await courseEntries(sem); + if (!entries.length) { + loading.classList.remove("active"); + setStatus(`No refined courses found for Semester ${sem}.`); + return; + } + + course.replaceChildren(...entries.map((c) => option(String(c.id), `${c.course_code ? c.course_code + " - " : ""}${c.course_title || "Course " + c.id}`))); + await loadCourse(String(entries[0].id)); +} + +chatTab.addEventListener("click", () => setTab("chat")); +fieldsTab.addEventListener("click", () => setTab("fields")); +reviewTab.addEventListener("click", () => setTab("review")); +if (togglePane) { + togglePane.addEventListener("click", () => { + const workspace = document.querySelector(".workspace"); + const focused = workspace.classList.toggle("chat-focus"); + togglePane.title = focused ? "Collapse agent / expand preview" : "Expand agent / collapse preview"; + togglePane.classList.toggle("active", focused); + }); +} +viewer.addEventListener("load", () => loading.classList.remove("active")); +preview.addEventListener("click", () => { + if (viewer.src && viewer.src !== "about:blank") viewer.src = viewer.src; +}); +semester.addEventListener("change", () => loadSemester(semester.value).catch(showError)); +course.addEventListener("change", () => loadCourse(course.value).catch(showError)); +versionSelect.addEventListener("change", () => updateRestoreVisibility()); +viewMode.addEventListener("change", async () => { + try { + if (viewMode.value === "document") { + await loadDocumentPreview(); + return; + } + showCourseControls(); + course.disabled = false; + semester.disabled = false; + await loadSemester(semester.value); + } catch (error) { + showError(error); + } +}); +attach.addEventListener("click", () => files.click()); +files.addEventListener("change", () => queueFiles(files.files).catch(showError)); +message.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + send.click(); + } +}); +saveVersion.addEventListener("click", () => saveCurrentVersion().catch((error) => { + showError(error, "Version save failed."); +})); +restoreVersion.addEventListener("click", () => restoreSelectedVersion().catch((error) => { + showError(error, "Version restore failed."); +})); + +chatSession.addEventListener("change", async () => { + activeSessionId = chatSession.value; + localStorage.setItem(chatKey(), activeSessionId); + chatLog.replaceChildren(); + chatStatusText.textContent = "Loading..."; + chatSpinner.classList.add("active"); + await renderMessages(); +}); + +renameChat.addEventListener("click", () => enterRenameMode()); +chatTitle.addEventListener("keydown", async (e) => { + if (e.key === "Enter") { + e.preventDefault(); + try { + await renameActiveChat(); + } catch (error) { + showError(error); + } + } + if (e.key === "Escape") { + exitRenameMode(); + } +}); +chatTitle.addEventListener("blur", () => { + setTimeout(() => { + if (!chatTitle.hidden) exitRenameMode(); + }, 150); +}); +deleteChat.addEventListener("click", () => deleteActiveChat().catch(showError)); + +newChat.addEventListener("click", async () => { + localStorage.removeItem(chatKey()); + activeSessionId = ""; + chatLog.replaceChildren(); + await createChatSession(); + await renderMessages(); +}); + +send.addEventListener("click", async () => { + const content = message.value.trim(); + if (!content && !queuedFiles.length) return; + send.disabled = true; + if (stopBtn) stopBtn.hidden = false; + chatStatusText.textContent = "Analyzing..."; + chatSpinner.classList.add("active"); + let assistant = null; + const controller = new AbortController(); + activeAbortController = controller; + try { + await ensureChatSession(); + const attachments = queuedFiles; + appendMessage({ + role: "user", + content, + attachments, + created_at: new Date().toISOString(), + }); + message.value = ""; + queuedFiles = []; + renderDraftAttachments(); + + const response = await fetch(`/api/chat/sessions/${activeSessionId}/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content, metadata: { attachments } }), + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(await errorMessage(response, "Agent failed")); + } + + let answer = ""; + await readEventStream(response, ({ event, data }) => { + if (event === "status") { + chatStatusText.textContent = data.message || ""; + chatSpinner.classList.add("active"); + } + if (event === "context_usage") { + const used = data.prompt_tokens || 0; + const max = data.context_length || 0; + const pct = max ? Math.round((used / max) * 100) : 0; + const fmt = (n) => n >= 1000000 ? `${(n / 1000000).toFixed(1)}M` : `${Math.round(n / 1000)}K`; + contextUsage.textContent = `${fmt(used)} / ${fmt(max)} (${pct}%)`; + } + if (event === "token") { + if (!assistant) assistant = appendMessage({ role: "assistant", content: "", created_at: new Date().toISOString() }); + answer += data.text || ""; + renderMessageContent(assistant.content, answer); + chatLog.scrollTop = chatLog.scrollHeight; + } + if (event === "tool_call") { + const label = TOOL_LABELS[data.name] || data.name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + chatStatusText.textContent = `${label}...`; + const toolMsg = `\u2699 ${data.name}(${JSON.stringify(data.arguments)})`; + const node = appendMessage({ role: "tool", content: toolMsg, created_at: new Date().toISOString() }); + node.bubble.classList.add("tool-call"); + } + if (event === "tool_result") { + const status = data.status === "ok" ? "\u2713" : "\u2717"; + const toolMsg = `${status} ${data.name} completed`; + const node = appendMessage({ role: "tool", content: toolMsg, created_at: new Date().toISOString() }); + node.bubble.classList.add("tool-result"); + } + if (event === "draft" && data.draft) { + if (!assistant) assistant = appendMessage({ role: "assistant", content: "", created_at: new Date().toISOString() }); + if (!answer) { answer = "Draft ready for review."; renderMessageContent(assistant.content, answer); } + showCourseDraft(data.draft); + } + if (event === "document_draft" && data.document_draft) { + if (!assistant) assistant = appendMessage({ role: "assistant", content: "", created_at: new Date().toISOString() }); + if (!answer) { answer = "Document draft ready for review."; renderMessageContent(assistant.content, answer); } + loadDocumentDraftById(data.document_draft.id).catch(showError); + } + if (event === "refined_course" && data.refined_id) { + if (!assistant) assistant = appendMessage({ role: "assistant", content: "", created_at: new Date().toISOString() }); + if (!answer) { answer = data.updated ? `Updated course ${data.refined_id}. Review in Fields tab and click Save to approve.` : `Created course ${data.refined_id}. Review in Fields tab and click Save to approve.`; renderMessageContent(assistant.content, answer); } + loadCourseForReview(data.refined_id).catch(() => {}); + } + if (event === "error") throw new Error(data.message || "Agent failed"); + if (event === "done") { + chatStatusText.textContent = ""; + chatSpinner.classList.remove("active"); + if (data.summary) { + setStatus(data.summary, "ready"); + } else { + setStatus("Response saved.", "ready"); + } + renderMessages().catch(() => {}); + } + }); + autoRenameSession(content).catch(() => {}); + } catch (error) { + const aborted = controller.signal.aborted; + const text = aborted ? "Stopped." : (error instanceof Error ? error.message : "Agent failed"); + chatStatusText.textContent = text; + chatSpinner.classList.remove("active"); + setStatus(text, aborted ? "" : "error"); + if (!aborted) { + if (!assistant) assistant = appendMessage({ role: "assistant", content: "", created_at: new Date().toISOString() }); + assistant.bubble.classList.add("error"); + renderMessageContent(assistant.content, text); + } + } finally { + send.disabled = false; + if (stopBtn) stopBtn.hidden = true; + activeAbortController = null; + } +}); + +draft.addEventListener("click", async () => { + const refinedId = activeCourseId || course.value; + if (!refinedId || viewMode.value !== "course") return; + setStatus("Creating draft..."); + let parsed; + try { parsed = JSON.parse(editor.value); } catch { showError("Invalid JSON in editor. Please fix syntax errors."); return; } + const response = await fetch("/api/agent/drafts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refined_id: Number(refinedId), fields: parsed, reason: versionMode ? "Version rollback draft" : "Live editor draft" }), + }); + if (!response.ok) { + throw new Error(await errorMessage(response, "Draft failed")); + } + const body = await response.json(); + showCourseDraft(body.draft); +}); + +previewDraft.addEventListener("click", () => { + if (activeDocumentDraftId) { + viewer.src = yearParam(`/api/agent/document-drafts/${activeDocumentDraftId}/preview`); + return; + } + if (!activeDraftId) return; + viewer.src = yearParam(`/api/agent/drafts/${activeDraftId}/preview`); +}); + +applyDraft.addEventListener("click", async () => { + if (!activeDraftId && !activeDocumentDraftId) return; + if (!await showConfirm("Apply this draft? This will overwrite current course data.")) return; + applyDraft.disabled = true; + setStatus("Applying draft..."); + + try { + if (activeDocumentDraftId) { + const response = await fetch(`/api/agent/document-drafts/${activeDocumentDraftId}/apply`, { method: "POST" }); + if (!response.ok) { + throw new Error(await errorMessage(response, "Apply failed")); + } + const body = await response.json(); + if (body.version) { + setStatus(`Document draft applied. Version: ${body.version.name}`, "ready"); + updateVersionDisplay(body.version.name); + await refreshVersions(); + } else { + setStatus("Document draft applied.", "ready"); + } + await loadDocumentPreview(); + setTab("chat"); + return; + } + + const targetId = activeDraftRefinedId || activeCourseId || course.value; + const response = await fetch(`/api/agent/drafts/${activeDraftId}/apply`, { method: "POST" }); + if (!response.ok) { + throw new Error(await errorMessage(response, "Apply failed")); + } + const body = await response.json(); + if (body.version) { + setStatus(`Draft applied. Version: ${body.version.name}`, "ready"); + updateVersionDisplay(body.version.name); + await refreshVersions(); + } else { + setStatus("Draft applied.", "ready"); + } + activeDraftId = ""; + activeDraftRefinedId = ""; + resetReview(); + if (targetId) { + await loadCourse(targetId); + } + setTab("chat"); + } finally { + applyDraft.disabled = false; + } +}); + +loadDocumentDraft.addEventListener("click", () => loadDocumentDraftById(documentDraftSelect.value).catch(showError)); + +loadCourseDraft.addEventListener("click", () => loadCourseDraftById(courseDraftSelect.value).catch(showError)); + +loadPendingCourse.addEventListener("click", () => loadCourseForReview(pendingCourseSelect.value).catch(showError)); + +save.addEventListener("click", async () => { + if (versionMode) return; + const targetId = activeCourseId || course.value; + if (!targetId) return; + setStatus("Saving..."); + let parsed; + try { parsed = JSON.parse(editor.value); } catch { showError("Invalid JSON in editor. Please fix syntax errors."); return; } + const response = await fetch(`/api/refined/${targetId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fields: parsed }), + }); + if (!response.ok) { + throw new Error(await errorMessage(response, "Save failed")); + } + setStatus("Saved.", "ready"); + await refreshCourseDropdown(); + await refreshPendingCourses(); + await loadCourse(targetId); + setTab("chat"); +}); + +window.addEventListener("error", (event) => { + showError(new Error(event.message)); +}); + +window.addEventListener("unhandledrejection", (event) => { + event.preventDefault(); + showError(event.reason); +}); + +const initialVersion = initialParams.get("version"); +const initialCourse = initialParams.get("course") || history.state?.courseId; +let initialLoad; +if (initialVersion && initialCourse) { + initialLoad = loadVersionCourse(initialVersion, initialCourse); +} else if (initialVersion) { + initialLoad = loadVersionInEditor(initialVersion); +} else if (initialCourse) { + initialLoad = loadCourse(initialCourse); +} else { + initialLoad = loadDocumentPreview(); +} + +Promise.all([refreshVersions(), initialLoad]).catch(() => { + loading.classList.remove("active"); + setStatus("Backend unavailable.", "error"); +}); diff --git a/frontend/preview.html b/frontend/preview.html new file mode 100644 index 0000000000000000000000000000000000000000..badb9720df132c64449b7ae4e448dfc50d406186 --- /dev/null +++ b/frontend/preview.html @@ -0,0 +1,11 @@ + + + + + + Curriculum Preview + + + Curriculum Preview + + diff --git a/frontend/preview/index.html b/frontend/preview/index.html new file mode 100644 index 0000000000000000000000000000000000000000..f813a0cdd4d2861c440dc3b6df7f757db8684c99 --- /dev/null +++ b/frontend/preview/index.html @@ -0,0 +1,44 @@ + + + + + + + Preview - PESU + + + + + +
    + + + + + + + Open PDF + Download PDF + +
    +
    + + +
    + + + + diff --git a/frontend/preview/preview.css b/frontend/preview/preview.css new file mode 100644 index 0000000000000000000000000000000000000000..9b2db75b38eb9813297a87d244e021b7e3cff37a --- /dev/null +++ b/frontend/preview/preview.css @@ -0,0 +1,103 @@ +@import "../shared.css"; + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100dvh; + padding: 2rem; + background: var(--page-bg); + color: var(--text); +} + +.toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 1rem 1.5rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + box-shadow: var(--shadow-md); + margin-bottom: 1.25rem; +} + +select { + min-width: 150px; +} + +input { + width: 120px; + padding: 0 10px; +} + +.year-control { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--muted-foreground); + font-size: 14px; +} + +a { + display: inline-flex; + align-items: center; + padding: 0 12px; + text-decoration: none; +} + +#status { + margin-left: auto; +} + +.loading { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + background: var(--background); + z-index: 5; + color: var(--muted-foreground); + font-size: 14px; + border-radius: 16px; +} + +.loading[hidden] { + display: none; +} + +.spinner { + width: 24px; + height: 24px; + border: 2.5px solid var(--border); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +iframe { + display: block; + width: 100%; + height: calc(100dvh - 140px); + border: 0; + border-radius: 16px; + background: var(--surface); + box-shadow: var(--shadow-md); +} + +iframe[hidden] { + display: none; +} + +.viewer-wrap { + position: relative; + min-height: calc(100dvh - 140px); +} diff --git a/frontend/preview/preview.js b/frontend/preview/preview.js new file mode 100644 index 0000000000000000000000000000000000000000..ef9a1ce9a6737d1259aeafd1623c0d686643635d --- /dev/null +++ b/frontend/preview/preview.js @@ -0,0 +1,153 @@ +const params = new URLSearchParams(location.search); +const requestedSemester = params.get("sem"); +const storedYear = localStorage.getItem("curriculumYear") || ""; +const requestedYear = params.get("curriculum_year") || params.get("year") || storedYear; +const semester = document.getElementById("semester"); +const course = document.getElementById("course"); +const curriculumYear = document.getElementById("curriculum-year"); +const viewer = document.getElementById("viewer"); +const openLink = document.getElementById("open"); +const downloadLink = document.getElementById("download"); +const statusText = document.getElementById("status"); +const loading = document.getElementById("loading"); +curriculumYear.value = requestedYear; +if (requestedYear) localStorage.setItem("curriculumYear", requestedYear); + +function clearPreview(message) { + viewer.removeAttribute("src"); + openLink.removeAttribute("href"); + downloadLink.removeAttribute("href"); + statusText.textContent = message; + if (loading) loading.hidden = true; +} + +function yearValue() { + return curriculumYear.value.trim(); +} + +function saveYear() { + if (yearValue()) localStorage.setItem("curriculumYear", yearValue()); +} + +function pdfUrl(sem, download = false) { + const path = sem === "all" ? "/api/preview/pdf" : `/api/preview/semester/${sem}/pdf`; + const query = new URLSearchParams({ curriculum_year: yearValue() }); + if (download) query.set("download", "true"); + return `${path}?${query}`; +} + +function coursePdfUrl(refinedId, download = false) { + const query = new URLSearchParams({ curriculum_year: yearValue() }); + if (download) query.set("download", "true"); + return `/api/preview/course/${refinedId}/pdf?${query}`; +} + +async function courseIds(sem) { + const url = sem === "all" ? "/api/preview/courses" : `/api/preview/semester/${sem}/courses`; + const response = await fetch(url); + if (!response.ok) return []; + const body = await response.json(); + return body.course_ids || []; +} + +async function fetchCourses(sem) { + if (sem === "all") return []; + const url = `/api/preview/semester/${sem}/courses?curriculum_year=${encodeURIComponent(yearValue())}`; + const response = await fetch(url); + if (!response.ok) return []; + const body = await response.json(); + return body.courses || []; +} + +async function firstAvailableSemester() { + if ((await courseIds("all")).length) return "all"; + for (let sem = 1; sem <= 8; sem += 1) { + if ((await courseIds(sem)).length) return String(sem); + } + return "all"; +} + +function loadIntoViewer(url, label) { + if (loading) loading.hidden = false; + viewer.hidden = true; + + const onLoad = () => { + if (loading) loading.hidden = true; + viewer.hidden = false; + viewer.removeEventListener("load", onLoad); + }; + viewer.addEventListener("load", onLoad); + + viewer.src = url; + openLink.href = url; + openLink.target = "_blank"; + downloadLink.href = url.includes("?") ? url + "&download=true" : url + "?download=true"; + statusText.textContent = label; +} + +async function loadSemester(sem) { + semester.value = sem; + statusText.textContent = "Loading..."; + + if (!/^\d{4}-\d{4}$/.test(yearValue())) { + clearPreview("Set academic year on the dashboard to preview."); + course.hidden = true; + return; + } + + if (!(await courseIds(sem)).length) { + clearPreview(sem === "all" ? "No refined courses found." : `No refined courses found for Semester ${sem}.`); + course.hidden = true; + return; + } + + if (sem === "all") { + course.hidden = true; + course.innerHTML = ""; + loadIntoViewer(pdfUrl(sem), "Overall"); + return; + } + + const courses = await fetchCourses(sem); + course.innerHTML = ""; + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = `All Semester ${sem} courses`; + course.appendChild(placeholder); + for (const c of courses) { + const opt = document.createElement("option"); + opt.value = c.id; + opt.textContent = `${c.course_code} - ${c.course_title}`; + opt.dataset.code = c.course_code; + course.appendChild(opt); + } + course.value = ""; + course.hidden = false; + + loadIntoViewer(pdfUrl(sem), `Semester ${sem}`); +} + +function loadSelectedCourse() { + const selectedId = course.value; + if (!selectedId) { + loadIntoViewer(pdfUrl(semester.value), `Semester ${semester.value}`); + return; + } + const opt = course.options[course.selectedIndex]; + loadIntoViewer(coursePdfUrl(selectedId), opt.dataset.code); +} + +semester.addEventListener("change", () => { + loadSemester(semester.value); +}); + +course.addEventListener("change", loadSelectedCourse); + +curriculumYear.addEventListener("change", () => { + saveYear(); + loadSemester(semester.value); +}); + +(requestedSemester ? Promise.resolve(requestedSemester) : firstAvailableSemester()).then(loadSemester).catch(() => { + statusText.textContent = "Backend unavailable."; +}); diff --git a/frontend/shared.css b/frontend/shared.css new file mode 100644 index 0000000000000000000000000000000000000000..2a5bf16d220942de0f87e2aaea4e11efe5d18fe2 --- /dev/null +++ b/frontend/shared.css @@ -0,0 +1,257 @@ +@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap'); + +:root { + --radius: 6px; + --radius-sm: 4px; + + --background: #ffffff; + --foreground: #0a0a0a; + --card: #ffffff; + --card-foreground: #0a0a0a; + + --primary: #00377b; + --primary-foreground: #ffffff; + --secondary: #f4f4f5; + --secondary-foreground: #18181b; + --muted: #f4f4f5; + --muted-foreground: #71717a; + --accent: #f4f4f5; + --accent-foreground: #18181b; + + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --success: #16a34a; + --success-foreground: #ffffff; + + --border: #e4e4e7; + --input: #e4e4e7; + --ring: #00377b; + + --page-bg: #f4f4f5; + --surface: #ffffff; + --text: #0a0a0a; + --muted-text: #71717a; + --border-strong: #d4d4d8; + + --pesu-blue: #00377b; + --pesu-orange: #ff6c00; + --error: #dc2626; + --error-bg: #fef2f2; + --success-bg: #f0fdf4; + + --shadow-sm: 0 1px 3px rgba(18, 32, 67, 0.10), 0 1px 2px rgba(18, 32, 67, 0.08); + --shadow-md: 0 4px 16px -2px rgba(18, 32, 67, 0.18), 0 2px 6px rgba(18, 32, 67, 0.10); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100dvh; + background: var(--page-bg); + color: var(--text); + font-family: 'Outfit', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +select, +input, +textarea, +button, +a.button-link { + min-width: 0; + border-radius: var(--radius); + font: inherit; + font-family: inherit; +} + +select, +input, +textarea { + border: 1px solid var(--input); + background-color: var(--background); + color: var(--foreground); + transition: border-color 0.15s, box-shadow 0.15s; +} + +select, +input, +button, +a.button-link { + min-height: 36px; +} + +select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + min-width: 160px; + padding: 0 36px 0 12px; + background-color: var(--background); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' fill='none' stroke='%2371717a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 12px; + cursor: pointer; + border-radius: 10px; + font-weight: 500; + transition: border-color 0.15s, box-shadow 0.15s; +} + +select:hover { + border-color: var(--border-strong); +} + +input, +textarea { + padding: 8px 12px; +} + +button, +a.button-link { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border: 1px solid var(--border); + background: var(--background); + color: var(--foreground); + padding: 0 16px; + text-decoration: none; + cursor: pointer; + font-weight: 500; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +button:hover, +a.button-link:hover { + border-color: var(--pesu-orange); +} + +button.primary, +a.button-link.primary { + border-color: var(--primary); + background: var(--primary); + color: var(--primary-foreground); +} + +button.primary:hover, +a.button-link.primary:hover { + border-color: color-mix(in srgb, var(--primary) 70%, white); +} + +button.danger { + border-color: var(--destructive); + background: var(--destructive); + color: var(--destructive-foreground); +} + +button.danger:hover { + border-color: color-mix(in srgb, var(--destructive) 70%, white); +} + +button:disabled, +a.button-link.disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +select:focus, +input:focus, +textarea:focus, +button:focus-visible, +a.button-link:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +.status-line { + min-height: 28px; + display: inline-flex; + align-items: center; + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--muted); + color: var(--muted-foreground); + font-size: 13px; +} + +.status-line.ready { + border-color: var(--success); + background: var(--success-bg); + color: var(--success); +} + +.status-line.error { + border-color: var(--destructive); + background: var(--error-bg); + color: var(--destructive); +} + +@media (max-width: 768px) { + body { font-size: 13px; } + select, input, button, a.button-link { min-height: 34px; } +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-track { + background: var(--muted); +} +::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--muted-foreground); +} + +* { + scrollbar-width: thin; + scrollbar-color: var(--border-strong) var(--muted); +} + +.rule { + width: 64px; + height: 4px; + margin-top: 0.5rem; + border-radius: 999px; + background: var(--pesu-orange); +} + +.home-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border: 1px solid var(--border); + background: var(--background); + color: var(--muted-foreground); + text-decoration: none; + border-radius: 10px; + flex-shrink: 0; + transition: background 0.15s, color 0.15s, border-color 0.15s; +} + +.home-btn svg { + width: 20px; + height: 20px; +} + +.home-btn:hover { + background: var(--accent); + color: var(--foreground); + border-color: var(--pesu-orange); +} diff --git a/frontend/shared/auth-guard.js b/frontend/shared/auth-guard.js new file mode 100644 index 0000000000000000000000000000000000000000..59ed4d1d819ae7c0906dde6cb0fef7d28faff457 --- /dev/null +++ b/frontend/shared/auth-guard.js @@ -0,0 +1,20 @@ +(function () { + document.documentElement.style.display = "none"; + var KEY = "sb-supgrlinqgxvifijgbns-auth-token"; + try { + var stored = localStorage.getItem(KEY); + if (!stored) { + window.location.href = "/auth/"; + return; + } + var parsed = JSON.parse(stored); + if (!parsed || !parsed.access_token) { + window.location.href = "/auth/"; + return; + } + } catch (e) { + window.location.href = "/auth/"; + return; + } + document.documentElement.style.display = ""; +})(); diff --git a/frontend/shared/dialog.css b/frontend/shared/dialog.css new file mode 100644 index 0000000000000000000000000000000000000000..9164fe766a2fe52359bc9033a4336c4caf86868f --- /dev/null +++ b/frontend/shared/dialog.css @@ -0,0 +1,38 @@ +.dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + opacity: 0; + transition: opacity 0.15s ease; +} +.dialog-overlay.open { opacity: 1; } + +.dialog-box { + background: var(--background); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15); + padding: 24px; + max-width: 420px; + width: calc(100% - 32px); + transform: translateY(8px); + transition: transform 0.15s ease; +} +.dialog-overlay.open .dialog-box { transform: translateY(0); } + +.dialog-box p { + margin: 0 0 20px; + color: var(--foreground); + font-size: 14px; + line-height: 1.5; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} diff --git a/frontend/shared/dialog.js b/frontend/shared/dialog.js new file mode 100644 index 0000000000000000000000000000000000000000..bcf2ca20331c2d5f1a7bdd338afdc4600df62810 --- /dev/null +++ b/frontend/shared/dialog.js @@ -0,0 +1,42 @@ +window.showConfirm = function (message) { + return new Promise((resolve) => { + const overlay = document.createElement("div"); + overlay.className = "dialog-overlay"; + + const box = document.createElement("div"); + box.className = "dialog-box"; + + const p = document.createElement("p"); + p.textContent = message; + + const actions = document.createElement("div"); + actions.className = "dialog-actions"; + + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.textContent = "Cancel"; + cancelBtn.addEventListener("click", () => { overlay.remove(); resolve(false); }); + + const confirmBtn = document.createElement("button"); + confirmBtn.type = "button"; + confirmBtn.className = "primary"; + confirmBtn.textContent = "Confirm"; + confirmBtn.addEventListener("click", () => { overlay.remove(); resolve(true); }); + + actions.appendChild(cancelBtn); + actions.appendChild(confirmBtn); + box.appendChild(p); + box.appendChild(actions); + overlay.appendChild(box); + document.body.appendChild(overlay); + + requestAnimationFrame(() => overlay.classList.add("open")); + + overlay.addEventListener("click", (e) => { if (e.target === overlay) { overlay.remove(); resolve(false); } }); + document.addEventListener("keydown", function onKey(e) { + if (e.key === "Escape") { overlay.remove(); resolve(false); document.removeEventListener("keydown", onKey); } + }); + + confirmBtn.focus(); + }); +}; diff --git a/frontend/shared/supabase-client.js b/frontend/shared/supabase-client.js new file mode 100644 index 0000000000000000000000000000000000000000..00962c5baf1393f4873864ebe300d264dc58d331 --- /dev/null +++ b/frontend/shared/supabase-client.js @@ -0,0 +1,11 @@ +import { createClient } from 'https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2/+esm'; + +const SUPABASE_URL = 'https://supgrlinqgxvifijgbns.supabase.co'; +const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InN1cGdybGlucWd4dmlmaWpnYm5zIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODIzNjA2MjcsImV4cCI6MjA5NzkzNjYyN30.UYaQLXjBnah2YOQs65zrZskYP7Cw4CnGroYUWOrjxqg'; + +export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + auth: { + persistSession: true, + autoRefreshToken: true, + }, +}); \ No newline at end of file diff --git a/frontend/vercel.json b/frontend/vercel.json new file mode 100644 index 0000000000000000000000000000000000000000..104547401ae9a8d0f600b7b224c4457599b2c089 --- /dev/null +++ b/frontend/vercel.json @@ -0,0 +1,8 @@ +{ + "rewrites":[ + { + "source": "/api/(.*)", + "destination": "https://lonelyguyse1-curriculum-backend.hf.space/api/$1" + } + ] +} \ No newline at end of file diff --git a/frontend/versions.html b/frontend/versions.html new file mode 100644 index 0000000000000000000000000000000000000000..6fcfe7cce33299478ccfe21c476bd43333d37616 --- /dev/null +++ b/frontend/versions.html @@ -0,0 +1,11 @@ + + + + + + Curriculum Versions + + + Curriculum Versions + + diff --git a/frontend/versions/index.html b/frontend/versions/index.html new file mode 100644 index 0000000000000000000000000000000000000000..c1468704e7a5fbd62984219bd1e8500829ed2da8 --- /dev/null +++ b/frontend/versions/index.html @@ -0,0 +1,69 @@ + + + + + + + Version History + + + + + + + + +
    + + + + + Version History + + + + + vs + + + +
    + +
    +
    + +
    + + +
    + + +
    + +
    +
    + +
    Select a version to preview its diff against the current curriculum.
    +
    +
    +
    + + diff --git a/frontend/versions/versions.css b/frontend/versions/versions.css new file mode 100644 index 0000000000000000000000000000000000000000..ef71c5d7c0ff0b2ab20f5cb9f47be307d7e5959d --- /dev/null +++ b/frontend/versions/versions.css @@ -0,0 +1,569 @@ +@import "../shared.css"; + +* { box-sizing: border-box; } + +body { + display: grid; + grid-template-rows: auto 1fr; + height: 100dvh; + padding: 2rem; + gap: 1.25rem; + background: var(--page-bg); + overflow: hidden; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + padding: 1rem 1.5rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + box-shadow: var(--shadow-md); +} + +.toolbar-title { + font-weight: 700; + font-size: 1.25rem; + color: var(--pesu-blue); +} + +.toolbar-sep { + width: 1px; + height: 20px; + background: var(--border); + flex-shrink: 0; +} + +.toolbar-actions { + margin-left: auto; + display: flex; + gap: 4px; +} + +.workspace { + display: grid; + grid-template-columns: 280px 1fr; + gap: 1.25rem; + min-height: 0; + transition: grid-template-columns 0.3s ease; +} + +.workspace.sidebar-collapsed { + grid-template-columns: 0px 1fr; +} + +.sidebar { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + display: grid; + grid-template-rows: 1fr; + min-width: 0; + overflow: hidden; + box-shadow: var(--shadow-md); + transition: opacity 0.3s ease; +} + +.sidebar-collapsed .sidebar { + opacity: 0; + pointer-events: none; + overflow: hidden; +} + +.version-list { + overflow: auto; + padding: 8px; +} + +.viewer-pane { + display: grid; + grid-template-rows: auto 1fr; + position: relative; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + overflow: hidden; + box-shadow: var(--shadow-md); + min-width: 0; +} + +.viewer-bar { + display: flex; + gap: 8px; + align-items: center; + padding: 6px 12px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.viewer-frame { + padding: 16px; + display: flex; + min-height: 0; + background: var(--page-bg); +} + +.viewer-loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 24px 32px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: var(--shadow-md); + z-index: 5; + color: var(--muted-foreground); + font-size: 13px; +} + +.viewer-loading[hidden] { + display: none; +} + +.spinner { + width: 24px; + height: 24px; + border: 2.5px solid var(--border); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +.sidebar-overlay { display: none; } + +.tree-group { + margin-bottom: 4px; +} + +.tree-group-header { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + cursor: pointer; + border-radius: var(--radius-sm); + font-weight: 600; + font-size: 13px; + color: var(--foreground); + user-select: none; + transition: background 0.1s; +} + +.tree-group-header:hover { background: var(--accent); } + +.tree-group-header .group-edit-btn { + opacity: 0; + flex-shrink: 0; + width: 22px; + height: 22px; + padding: 0; + border: 0; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + margin-left: auto; +} + +.tree-group-header:hover .group-edit-btn { opacity: 1; } +.tree-group-header .group-edit-btn:hover { background: var(--accent); color: var(--foreground); } + +.tree-group-header .expand-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 0.15s ease; + color: var(--muted-foreground); +} + +.tree-group.collapsed .expand-icon { transform: rotate(-90deg); } + +.tree-group-header .folder-icon { + color: var(--muted-foreground); +} + +.tree-group-header .version-count { + font-weight: 400; + font-size: 11px; + color: var(--muted-foreground); + background: var(--muted); + padding: 1px 6px; + border-radius: 9999px; +} + +.tree-items { + padding-left: 12px; + overflow: hidden; + max-height: 2000px; + transition: max-height 0.3s ease, opacity 0.2s ease; +} + +.tree-group.collapsed .tree-items { + max-height: 0; + opacity: 0; + padding-left: 0; +} + +.tree-item { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; + padding: 6px 28px 6px 8px; + border-radius: var(--radius-sm); + font-size: 13px; + cursor: pointer; + position: relative; + transition: background 0.1s; +} + +.tree-item:hover { background: var(--accent); } + +.tree-item.current-version { + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +.tree-item.current-version .item-name { + font-weight: 600; +} + +.tree-item:not(.active).current-version .item-name { + color: var(--pesu-blue); +} + +.tree-item.active { + background: var(--primary); + color: var(--primary-foreground); + font-weight: 500; + border-left: 3px solid var(--pesu-orange); +} + +.tree-item .item-info { flex: 1; min-width: 0; } + +.tree-item .item-name { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tree-item .item-meta { + display: flex; + gap: 8px; + font-size: 11px; + color: var(--muted-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tree-item.active .item-meta { color: var(--primary-foreground); opacity: 0.8; } + +.tree-item .edit-btn { + opacity: 0; + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + width: 24px; + height: 24px; + padding: 0; + border: 0; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.15s, background 0.15s, color 0.15s; +} + +.tree-item:hover .edit-btn { opacity: 1; } +.tree-item .edit-btn:hover { background: var(--accent); color: var(--foreground); } +.tree-item.active .edit-btn { color: var(--primary-foreground); } +.tree-item.active .edit-btn:hover { background: rgba(255,255,255,0.15); } + +.tree-item .delete-btn { + opacity: 0; + position: absolute; + right: 30px; + top: 50%; + transform: translateY(-50%); + width: 24px; + height: 24px; + padding: 0; + border: 1px solid transparent; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.15s, background 0.15s, color 0.15s, border-color 0.15s; +} + +.tree-item:hover .delete-btn { + opacity: 1; + background: #fef2f2; + border-color: #fecaca; + color: #dc2626; +} +.tree-item .delete-btn:hover { + background: #dc2626; + border-color: #dc2626; + color: #fff; +} +.tree-item.active .delete-btn { + color: #fca5a5; + background: rgba(220,38,38,0.15); + border-color: rgba(220,38,38,0.3); +} +.tree-item.active .delete-btn:hover { + background: #dc2626; + border-color: #dc2626; + color: #fff; +} + +.edit-form { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + margin: 4px 0; + width: 100%; + background: var(--background); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); + order: 10; +} + +.tree-item:has(.edit-form) .edit-btn, +.tree-item:has(.edit-form) .delete-btn { + display: none; +} + +.edit-form input { + width: 100%; + height: 30px; + padding: 0 8px; + font-size: 12px; + font-family: inherit; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--foreground); + outline: none; +} + +.edit-form input:focus { + border-color: var(--primary); + box-shadow: 0 0 0 2px rgba(0, 55, 123, 0.15); +} + +.edit-actions { + display: flex; + gap: 6px; +} + +.edit-actions button { + height: 28px; + font-size: 12px; + padding: 0 10px; + border-radius: var(--radius-sm); + cursor: pointer; + font-weight: 500; + font-family: inherit; + border: 1px solid var(--border); + background: var(--surface); + color: var(--foreground); +} + +.edit-actions button.primary { + background: var(--primary); + color: var(--primary-foreground); + border-color: var(--primary); +} + +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: 0; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + border-radius: var(--radius); + transition: background 0.15s, color 0.15s; +} + +.icon-btn:hover { background: var(--accent); color: var(--foreground); } + +#viewer { + width: min(100%, 780px); + flex: 1; + margin: 0 auto; + border: 0; + border-radius: 8px; + background: var(--background); + box-shadow: var(--shadow-sm); +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 48px 24px; + text-align: center; + color: var(--muted-foreground); + font-size: 14px; + position: absolute; + inset: 0; + z-index: 1; +} + +.empty-state[hidden] { + display: none; +} + +.button-link { + height: 32px; + font-size: 13px; + border-radius: var(--radius); + background: var(--primary); + color: var(--primary-foreground); + border-color: var(--primary); + text-decoration: none; + font-weight: 500; + padding: 0 12px; +} + +.button-link:hover { border-color: color-mix(in srgb, var(--primary) 70%, white); } + +.mobile-only { display: none; } + +.filter-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: 12px; + font-size: 13px; + color: var(--muted-foreground); + cursor: pointer; + user-select: none; +} + +.filter-toggle input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 32px; + height: 18px; + min-height: 0; + padding: 0; + border-radius: 9px; + border: 1.5px solid var(--border); + background: var(--muted); + position: relative; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + vertical-align: middle; +} + +.filter-toggle input[type="checkbox"]::before { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--muted-foreground); + transition: transform 0.15s, background 0.15s; +} + +.filter-toggle input[type="checkbox"]:checked { + background: var(--pesu-orange); + border-color: var(--pesu-orange); +} + +.filter-toggle input[type="checkbox"]:checked::before { + transform: translateX(14px); + background: white; +} + +.compare-select { + min-width: 120px; + max-width: 160px; + height: 30px; + font-size: 12px; + padding: 0 28px 0 8px; + border-radius: 8px; +} + +.versus { + font-size: 12px; + font-weight: 600; + color: var(--muted-foreground); + padding: 0 2px; +} + +@media (max-width: 768px) { + .mobile-only { display: inline-flex; } + + .workspace { grid-template-columns: 1fr; } + + .sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 280px; + z-index: 50; + border-right: 1px solid var(--border); + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.1); + transform: translateX(-100%); + transition: transform 0.2s ease; + } + + .sidebar.open { + transform: translateX(0); + } + + .sidebar-overlay { + display: block; + position: fixed; + inset: 0; + z-index: 40; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(2px); + } + + .sidebar-overlay[hidden] { display: none; } +} diff --git a/frontend/versions/versions.js b/frontend/versions/versions.js new file mode 100644 index 0000000000000000000000000000000000000000..c5d85bae610fdb87ce853116d0f95deb812c818d --- /dev/null +++ b/frontend/versions/versions.js @@ -0,0 +1,504 @@ +const statusText = document.getElementById("status"); +const viewer = document.getElementById("viewer"); +const versionList = document.getElementById("version-list"); +const openEditor = document.getElementById("open-editor"); +const viewerLoading = document.getElementById("viewer-loading"); +const emptyState = document.getElementById("empty-state"); +const sidebar = document.getElementById("sidebar"); +const sidebarOverlay = document.getElementById("sidebar-overlay"); +const mobileMenuBtn = document.getElementById("mobile-menu"); +const toggleGroupsBtn = document.getElementById("toggle-groups"); +const showEmptyToggle = document.getElementById("show-empty"); +const baseVersionSelect = document.getElementById("base-version"); +const compareVersionSelect = document.getElementById("compare-version"); +const compareBtn = document.getElementById("compare-btn"); + +function setStatus(text, kind = "") { + statusText.textContent = text || ""; + statusText.className = kind; + statusText.hidden = !text; +} + +function icon(name) { + const icons = { + chevron: '', + folder: '', + edit: '', + trash: '', + }; + return icons[name] || ""; +} + +function formatDate(dateStr) { + if (!dateStr) return ""; + return new Date(dateStr).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); +} + +function groupVersions(versions) { + const groups = new Map(); + for (const v of versions) { + const year = v.academic_year || "Uncategorized"; + if (!groups.has(year)) groups.set(year, []); + groups.get(year).push(v); + } + return [...groups.entries()].sort((a, b) => b[0].localeCompare(a[0])); +} + +let activeVersionId = null; + +function renderVersionTree(groups) { + versionList.replaceChildren(); + + const current = document.createElement("div"); + current.className = "tree-item current-version"; + current.tabIndex = 0; + current.role = "button"; + if (activeVersionId === "current") current.classList.add("active"); + current.dataset.versionId = "current"; + + const info = document.createElement("div"); + info.className = "item-info"; + + const nameRow = document.createElement("div"); + nameRow.className = "item-name"; + nameRow.textContent = "Current"; + + const meta = document.createElement("div"); + meta.className = "item-meta"; + meta.textContent = "Live curriculum state"; + + info.append(nameRow, meta); + current.appendChild(info); + + const activateCurrent = () => loadCurrent(); + current.addEventListener("click", activateCurrent); + current.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); activateCurrent(); } + }); + + versionList.appendChild(current); + + if (!groups.length) { + const empty = document.createElement("div"); + empty.className = "empty"; + empty.textContent = "No snapshots saved. Create one from the Agentic Editor."; + versionList.appendChild(empty); + return; + } + + for (const [year, versions] of groups) { + const group = document.createElement("div"); + group.className = "tree-group"; + + const header = document.createElement("div"); + header.className = "tree-group-header"; + header.tabIndex = 0; + header.role = "button"; + header.setAttribute("aria-expanded", "true"); + + const expandIcon = document.createElement("span"); + expandIcon.className = "expand-icon"; + expandIcon.innerHTML = icon("chevron"); + + const folderIcon = document.createElement("span"); + folderIcon.className = "folder-icon"; + folderIcon.innerHTML = icon("folder"); + + const label = document.createElement("span"); + label.className = "group-label"; + label.textContent = year; + + const count = document.createElement("span"); + count.className = "version-count"; + count.textContent = String(versions.length); + + const groupEditBtn = document.createElement("button"); + groupEditBtn.className = "icon-btn group-edit-btn"; + groupEditBtn.title = "Rename category"; + groupEditBtn.innerHTML = icon("edit"); + groupEditBtn.addEventListener("click", (e) => { + e.stopPropagation(); + startGroupEdit(year, versions, label, groupEditBtn); + }); + + header.append(expandIcon, folderIcon, label, count, groupEditBtn); + + const toggleGroup = () => { + const collapsed = group.classList.toggle("collapsed"); + header.setAttribute("aria-expanded", String(!collapsed)); + }; + header.addEventListener("click", toggleGroup); + header.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleGroup(); } + }); + + const items = document.createElement("div"); + items.className = "tree-items"; + + for (const v of versions) { + const item = document.createElement("div"); + item.className = "tree-item"; + item.tabIndex = 0; + item.role = "button"; + if (String(v.id) === String(activeVersionId)) item.classList.add("active"); + item.dataset.versionId = v.id; + + const info = document.createElement("div"); + info.className = "item-info"; + + const nameRow = document.createElement("div"); + nameRow.className = "item-name"; + nameRow.textContent = v.name || `Snapshot ${v.id}`; + + const meta = document.createElement("div"); + meta.className = "item-meta"; + meta.textContent = `${formatDate(v.created_at)}${v.status ? ` \u2022 ${v.status}` : ""}`; + + info.append(nameRow, meta); + + const editBtn = document.createElement("button"); + editBtn.className = "icon-btn edit-btn"; + editBtn.title = "Edit name / category"; + editBtn.innerHTML = icon("edit"); + editBtn.addEventListener("click", (e) => { + e.stopPropagation(); + startEdit(v, item); + }); + + const deleteBtn = document.createElement("button"); + deleteBtn.className = "icon-btn delete-btn"; + deleteBtn.title = "Delete version"; + deleteBtn.innerHTML = icon("trash"); + deleteBtn.addEventListener("click", (e) => { + e.stopPropagation(); + deleteVersion(v.id, v.name || `Version ${v.id}`); + }); + + item.append(info, editBtn, deleteBtn); + + const activate = (e) => { + if (e.target.closest(".edit-btn") || e.target.closest(".delete-btn")) return; + loadVersion(v.id); + }; + item.addEventListener("click", activate); + item.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); activate(e); } + }); + + items.appendChild(item); + } + + group.append(header, items); + versionList.appendChild(group); + } +} + +function startEdit(v, itemEl) { + const existing = itemEl.querySelector(".edit-form"); + if (existing) { existing.remove(); return; } + + const form = document.createElement("div"); + form.className = "edit-form"; + + const nameInput = document.createElement("input"); + nameInput.type = "text"; + nameInput.value = v.name || ""; + nameInput.placeholder = "Version name"; + nameInput.maxLength = 200; + + const yearInput = document.createElement("input"); + yearInput.type = "text"; + yearInput.value = v.academic_year || ""; + yearInput.placeholder = "Category (e.g. 2025-2026)"; + yearInput.maxLength = 50; + + const actions = document.createElement("div"); + actions.className = "edit-actions"; + + const saveBtn = document.createElement("button"); + saveBtn.className = "primary"; + saveBtn.textContent = "Save"; + saveBtn.addEventListener("click", () => saveEdit(v.id, nameInput.value, yearInput.value, form)); + + const cancelBtn = document.createElement("button"); + cancelBtn.textContent = "Cancel"; + cancelBtn.addEventListener("click", () => form.remove()); + + actions.append(saveBtn, cancelBtn); + form.append(nameInput, yearInput, actions); + itemEl.appendChild(form); + nameInput.focus(); +} + +async function saveEdit(versionId, name, academicYear, formEl) { + const trimmedName = name.trim(); + if (!trimmedName) { + setStatus("Version name cannot be empty.", "error"); + return; + } + try { + const res = await fetch(`/api/versions/${versionId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: trimmedName, academic_year: academicYear.trim() }), + }); + if (!res.ok) throw new Error((await res.json()).detail || "Update failed"); + formEl.remove(); + setStatus("Updated.", "ready"); + await loadVersions(); + } catch (e) { + setStatus(e.message, "error"); + } +} + +async function deleteVersion(versionId, name) { + if (!await showConfirm(`Delete "${name}"? This cannot be undone.`)) return; + try { + const res = await fetch(`/api/versions/${versionId}`, { method: "DELETE" }); + if (!res.ok) throw new Error((await res.json()).detail || "Delete failed"); + if (String(activeVersionId) === String(versionId)) { + activeVersionId = null; + viewer.src = ""; + emptyState.hidden = false; + viewer.hidden = true; + openEditor.hidden = true; + } + setStatus("Deleted.", "ready"); + await loadVersions(); + } catch (e) { + setStatus(e.message, "error"); + } +} + +function startGroupEdit(year, versions, labelEl, btnEl) { + if (labelEl.querySelector("input")) return; + const prevText = labelEl.textContent; + const input = document.createElement("input"); + input.type = "text"; + input.value = year; + input.maxLength = 50; + input.style.cssText = "width:100%;height:24px;font:inherit;font-size:12px;padding:0 4px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--background);color:var(--foreground);outline:none;"; + labelEl.textContent = ""; + labelEl.appendChild(input); + input.focus(); + input.select(); + + const finish = async (save) => { + const newVal = input.value.trim(); + input.remove(); + if (!save || newVal === year || !newVal) { + labelEl.textContent = prevText; + return; + } + const ids = versions.map((v) => v.id); + try { + for (const id of ids) { + const res = await fetch(`/api/versions/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ academic_year: newVal }), + }); + if (!res.ok) throw new Error((await res.json()).detail || "Update failed"); + } + setStatus("Category renamed.", "ready"); + await loadVersions(); + } catch (e) { + setStatus(e.message, "error"); + labelEl.textContent = prevText; + } + }; + + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") finish(true); + if (e.key === "Escape") finish(false); + }); + input.addEventListener("blur", () => finish(true)); +} + +let allVersions = []; + +async function loadVersions() { + setStatus("Loading versions..."); + try { + const res = await fetch("/api/versions"); + if (!res.ok) throw new Error("Failed to load versions"); + const body = await res.json(); + allVersions = body.versions || []; + renderFilteredVersions(); + populateCompareSelects(); + } catch (e) { + setStatus(e.message, "error"); + } +} + +function renderFilteredVersions() { + const showEmpty = showEmptyToggle && showEmptyToggle.checked; + const filtered = showEmpty ? allVersions : allVersions.filter((v) => (v.course_count || 0) > 0 && v.has_changes !== false); + const groups = groupVersions(filtered); + renderVersionTree(groups); + if (!filtered.length) { + setStatus(allVersions.length ? "No versions with changes." : "No snapshots saved.", "ready"); + } else { + setStatus("Select a version to preview diff.", "ready"); + } +} + +function populateCompareSelects() { + const currentOption = ''; + const options = allVersions.map((v) => ``).join(""); + const placeholder = ''; + baseVersionSelect.innerHTML = placeholder + currentOption + options; + compareVersionSelect.innerHTML = placeholder + currentOption + options; +} + +if (compareBtn) { + compareBtn.addEventListener("click", () => { + const id1 = baseVersionSelect.value; + const id2 = compareVersionSelect.value; + if (!id1 || !id2) { + setStatus("Select both versions to compare.", "error"); + return; + } + if (id1 === id2) { + setStatus("Select two different versions.", "error"); + return; + } + const v1 = id1 === "current" ? { name: "Current" } : allVersions.find((v) => String(v.id) === id1); + const v2 = id2 === "current" ? { name: "Current" } : allVersions.find((v) => String(v.id) === id2); + emptyState.hidden = true; + viewerLoading.hidden = false; + viewer.hidden = true; + + const onLoad = () => { + viewerLoading.hidden = true; + viewer.hidden = false; + viewer.removeEventListener("load", onLoad); + }; + viewer.addEventListener("load", onLoad); + + let url; + if (id1 === "current" || id2 === "current") { + const versionId = id1 === "current" ? id2 : id1; + url = `/api/versions/${versionId}/preview?diff=1`; + } else { + url = `/api/versions/${id1}/diff/${id2}`; + } + viewer.src = url; + openEditor.hidden = true; + setStatus(`Comparing: ${v1?.name || id1} vs ${v2?.name || id2}`); + closeSidebar(); + }); +} + +let loadSeq = 0; + +function loadVersion(versionId) { + activeVersionId = String(versionId); + versionList.querySelectorAll(".tree-item").forEach((el) => el.classList.toggle("active", el.dataset.versionId === String(versionId))); + + if (baseVersionSelect) baseVersionSelect.value = ""; + if (compareVersionSelect) compareVersionSelect.value = ""; + + emptyState.hidden = true; + viewerLoading.hidden = false; + viewer.hidden = true; + + const seq = ++loadSeq; + let timer; + + const handler = () => { + clearTimeout(timer); + viewerLoading.hidden = true; + viewer.hidden = false; + viewer.removeEventListener("load", handler); + }; + viewer.addEventListener("load", handler); + + viewer.addEventListener("error", function errorHandler() { + clearTimeout(timer); + viewerLoading.hidden = true; + viewer.hidden = false; + viewer.removeEventListener("error", errorHandler); + viewer.removeEventListener("load", handler); + setStatus("Preview failed to load.", "error"); + }); + + timer = setTimeout(() => { + if (seq === loadSeq) { + viewerLoading.hidden = true; + viewer.hidden = false; + viewer.removeEventListener("load", handler); + setStatus("Preview timed out.", "error"); + } + }, 30000); + + viewer.src = `/api/versions/${versionId}/preview?diff=1`; + openEditor.href = `/live-editor/?version=${versionId}`; + openEditor.hidden = false; + const v = allVersions.find((x) => String(x.id) === String(versionId)); + setStatus(`Comparing: ${v?.name || `Version ${versionId}`} against current curriculum`); + closeSidebar(); +} + +function closeSidebar() { + sidebar.classList.remove("open"); + sidebarOverlay.hidden = true; +} + +function loadCurrent() { + activeVersionId = "current"; + versionList.querySelectorAll(".tree-item").forEach((el) => el.classList.toggle("active", el.dataset.versionId === "current")); + + emptyState.hidden = true; + viewerLoading.hidden = false; + viewer.hidden = true; + + const onLoad = () => { + viewerLoading.hidden = true; + viewer.hidden = false; + viewer.removeEventListener("load", onLoad); + }; + viewer.addEventListener("load", onLoad); + viewer.addEventListener("error", function errorHandler() { + viewerLoading.hidden = true; + viewer.hidden = false; + viewer.removeEventListener("error", errorHandler); + viewer.removeEventListener("load", onLoad); + setStatus("Preview failed to load.", "error"); + }); + + const y = localStorage.getItem("curriculumYear") || ""; + let url = "/api/preview/html"; + if (y) url += `?curriculum_year=${encodeURIComponent(y)}`; + viewer.src = url; + openEditor.hidden = true; + setStatus("Viewing current curriculum state"); + closeSidebar(); +} + +function openSidebar() { + sidebar.classList.add("open"); + sidebarOverlay.hidden = false; +} + +if (mobileMenuBtn) mobileMenuBtn.addEventListener("click", () => sidebar.classList.contains("open") ? closeSidebar() : openSidebar()); +if (sidebarOverlay) sidebarOverlay.addEventListener("click", closeSidebar); + +if (toggleGroupsBtn) toggleGroupsBtn.addEventListener("click", () => { + const workspace = document.querySelector(".workspace"); + const collapsed = workspace.classList.toggle("sidebar-collapsed"); + toggleGroupsBtn.dataset.collapsed = String(collapsed); + if (collapsed) { + toggleGroupsBtn.title = "Show sidebar"; + } else { + toggleGroupsBtn.title = "Hide sidebar"; + } +}); + +if (showEmptyToggle) showEmptyToggle.addEventListener("change", renderFilteredVersions); + +document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && sidebar.classList.contains("open")) closeSidebar(); +}); + +loadVersions().catch(() => setStatus("Failed to load versions.", "error")); diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..452d7cecbb2f34c4c409bebcd69a9a23f94d7314 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +fastapi==0.138.0 +openpyxl==3.1.5 +httpx==0.28.1 +Jinja2==3.1.6 +MarkupSafe==3.0.3 +pydantic==2.13.4 +postgrest==2.31.0 +pytest==9.1.1 +python-dotenv==1.2.2 +python-multipart==0.0.32 +redis>=5.0.0 +sentry-sdk==2.63.0 +supabase==2.31.0 +uvicorn[standard]==0.49.0 +weasyprint==69.0 +ruff==0.15.21