-
- diff --git a/.dockerignore b/.dockerignore index d053355dda4dbb633b161751b617e310a78378c9..3ee815c756013e28bd0aa1ca0ac51011221c1724 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,2 @@ -.venv/ -__pycache__/ -*.pyc -.env -.nottracked/ -.git/ -.gitignore -AGENTS.md -README.md -LICENSE -*.md +.venv/ __pycache__/ *.pyc .env .nottracked/ frontend/ +.git/ .gitignore AGENTS.md README.md LICENSE *.md \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..a6344aac8c09253b3b630fb776ae94478aa0275b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,35 @@ +*.7z filter=lfs diff=lfs merge=lfs -text +*.arrow filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.ckpt filter=lfs diff=lfs merge=lfs -text +*.ftz filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.h5 filter=lfs diff=lfs merge=lfs -text +*.joblib filter=lfs diff=lfs merge=lfs -text +*.lfs.* filter=lfs diff=lfs merge=lfs -text +*.mlmodel filter=lfs diff=lfs merge=lfs -text +*.model filter=lfs diff=lfs merge=lfs -text +*.msgpack filter=lfs diff=lfs merge=lfs -text +*.npy filter=lfs diff=lfs merge=lfs -text +*.npz filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text +*.ot filter=lfs diff=lfs merge=lfs -text +*.parquet filter=lfs diff=lfs merge=lfs -text +*.pb filter=lfs diff=lfs merge=lfs -text +*.pickle filter=lfs diff=lfs merge=lfs -text +*.pkl filter=lfs diff=lfs merge=lfs -text +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.safetensors filter=lfs diff=lfs merge=lfs -text +saved_model/**/* filter=lfs diff=lfs merge=lfs -text +*.tar.* filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tflite filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.wasm filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.zst filter=lfs diff=lfs merge=lfs -text +*tfevents* filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile index e7acb09b33996bce5f1579f406f81dbc0ffebed3..e3a12cb34ca5e754d7ab034674e04a0fdf7ee825 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,22 +2,12 @@ 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"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"] \ No newline at end of file diff --git a/README.md b/README.md index 1cde02f3389a5adc865a02016a7b8db93b651052..80bddc10994d9766dfe3ce657d85475de4bdf4e2 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,10 @@ --- title: Curriculum Backend -colorFrom: blue +emoji: 👁 +colorFrom: pink colorTo: gray sdk: docker -app_port: 7860 pinned: false --- -# Curriculum Automation - -## Doc link - -https://lonelyguy-se1.github.io/PESU-Curriculum-Automation/ - -## Phase 1 Flow Proposal - -```mermaid id="phase1-flow" -flowchart TD - - A[Professor opens form] --> B[Enter course details] - B --> C[Submit content, semester, credits, department, books] - - C --> D[FastAPI receives submission] - D --> E[Check mandatory fields] - - E -->|Invalid| F[Reject submission] - F --> G[Email professor with reasons] - G --> H[Professor opens edit link] - H --> I[Cached form shows old data and flagged issues] - I --> C - - E -->|Valid| J[Generate derived fields] - J --> J1[Course code] - J --> J2[Credit pattern] - J --> J3[Course type] - J --> J4[Template data] - - J1 --> K[AI refinement] - J2 --> K - J3 --> K - J4 --> K - - K --> K1[Refine content] - K --> K2[Generate prelude] - K --> K3[Generate objectives] - K --> K4[Generate outcomes] - K --> K5[Recommend tools] - - K1 --> L[Rubric check] - K2 --> L - K3 --> L - K4 --> L - K5 --> L - - L -->|Low score| M[Reject draft] - M --> N[Store flagged issues] - N --> G - - L -->|Pass| O[Create draft record] - O --> P[Render preview with Jinja2] - - P --> Q[Faculty or admin review] - - Q -->|Rejected| R[Store rejection reasons] - R --> G - - Q -->|Accepted| S[Update remote database] - S --> T[Update curriculum template fields] - T --> U[Keep incomplete template pending] - - U --> V{All course submissions received?} - - V -->|No| W[Wait for remaining professors] - W --> U - - V -->|Yes| X[Compile final curriculum document] - X --> Y[Final review] - Y --> Z[Export final PDF or DOCX] -``` - -## Phase 1 Summary - -Professors submit only the required academic material. - -The system generates derived fields, refines the content, checks it using a rubric, and stores accepted drafts in a remote database. - -Rejected submissions are returned by email with reasons. The professor gets an edit link where previous data is already cached and flagged issues are shown. - -Accepted submissions do not immediately become the final document. They update the shared curriculum template and remain pending until all required course submissions are received. - -The final curriculum document is compiled only after every required course entry is complete. +Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference diff --git a/backend/app/api.py b/backend/app/api.py index b8ea0442176e70af83997dcf382e7395530c5ceb..72f8d9020944d3694cdf7d4aa47deebd79f88242 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -1,8 +1,60 @@ from fastapi import APIRouter +from fastapi.responses import HTMLResponse +from jinja2 import Environment, FileSystemLoader +from pydantic import BaseModel,Field, field_validator +from typing import Literal -from app.routes import agent, chat, courses, health, preview, refined, submissions, versions +from app.preview import build_course_preview +from app.supabase import supabase -router = APIRouter() +router=APIRouter() -for route in (health, submissions, preview, refined, agent, chat, versions, courses): - router.include_router(route.router) +templates=Environment(loader=FileSystemLoader("app/templates")) + +class CourseSubmission(BaseModel): + faculty_email: str=Field(min_length=3, max_length=254) + course_title: str=Field(min_length=3, max_length=150) + offering_department: Literal["MA", "CS", "UZ"] + target_department: Literal["CSE", "ECE", "ME", "BT", "EEE", "AIML"] + semester: Literal["1", "2", "3", "4", "5", "6", "7", "8"] + credit_category: Literal["0", "2", "4", "5"] + raw_course_content: str=Field(min_length=50) + text_books: str=Field(min_length=5) + reference_books: str="" + preferred_tools: str="" + + @field_validator( + "faculty_email", "course_title", "raw_course_content", "text_books", "reference_books", "preferred_tools", mode="before",) + @classmethod + def strip(cls, v): + return v.strip() if isinstance(v, str) else v + +@router.post("/submissions") +def receive(data: CourseSubmission): + """ + print("=== NEW SUBMISSION ===") + for field, values in data.model_dump().items(): + print(f"{field}: {values}") + print("======================") + return {"message": "Submission received successfully!", "submission": data.model_dump()} + """ + + data_dict=data.model_dump() + data_dict["status"]="pending" + result=supabase.table("submissions").insert(data_dict).execute() + print("New Submission Received! Course Title: ", data.course_title) + return {"message":"Submission Received!", "submission":result.data[0]} + +@router.get("/preview/semester/{sem}/courses") +def list_courses(sem:str): # sem will be used once semester column is added + result=supabase.table("refined_submissions").select("id").execute() + rows: list[dict] = result.data + return {"course_ids": [r["id"] for r in rows]} + +@router.get("/preview/course/{refined_id}") +def preview_course(refined_id:int): + result=supabase.table("refined_submissions").select("*").eq("id", refined_id).single().execute() + row: dict = result.data + course=build_course_preview(row) + html=templates.get_template("jinja_sample.html").render(course=course, curriculum_year="", page_number="") + return HTMLResponse(content=html) diff --git a/backend/app/main.py b/backend/app/main.py index c8f52f772b9c1e622e2a54a319d18fba8c9826f6..e1e73f4cf94649e67241378bd62f1acf60752807 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,35 +1,11 @@ -import os 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 - -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) from app.api import router - -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) - app = FastAPI(title="PESU Curriculum Automation") app.add_middleware( @@ -41,7 +17,5 @@ app.add_middleware( app.include_router(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") +if Path("../frontend").exists(): + app.mount("/", StaticFiles(directory="../frontend", html=True), name="frontend") \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py deleted file mode 100644 index 8b137891791fe96927ad78e64b0aad7bded08bdc..0000000000000000000000000000000000000000 --- a/backend/app/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/backend/app/models/agent.py b/backend/app/models/agent.py deleted file mode 100644 index cba46ac7df4d5609bd65d0b0d29f58dbc18b5e1b..0000000000000000000000000000000000000000 --- a/backend/app/models/agent.py +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 4e5735010e521966addd624ab9d9f181b739a879..0000000000000000000000000000000000000000 --- a/backend/app/models/chat.py +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 5088bb5148a671de58a591cf3f79f139f27cddd6..0000000000000000000000000000000000000000 --- a/backend/app/models/submission.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Literal - -from pydantic import BaseModel, Field, field_validator - - -class CourseSubmission(BaseModel): - faculty_email: str = Field(min_length=3, max_length=254) - course_title: str = Field(min_length=3, max_length=150) - offering_department: Literal["MA", "CS", "UZ"] - target_department: Literal["CSE", "ECE", "ME", "BT", "EEE", "AIML"] - semester: Literal["1", "2", "3", "4", "5", "6", "7", "8"] - credit_category: Literal["0", "2", "4", "5"] - raw_course_content: str = Field(min_length=50) - text_books: str = Field(min_length=5) - reference_books: str = "" - preferred_tools: str = "" - - @field_validator("faculty_email", "course_title", "raw_course_content", "text_books", "reference_books", "preferred_tools", 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 index b4a53d73ab4d38ba74af2b6c5ce2271f8c6107ee..0b12d80ed18d86a80ac52b49990a5a0f8298168d 100644 --- a/backend/app/preview.py +++ b/backend/app/preview.py @@ -1,81 +1,23 @@ import json -from app.services.books import parse_books -from app.services.deterministic import compute_course_type, compute_hours, compute_program - - -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 "") + content = json.loads(row.get("refined_content") or "{}") return { - "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), - "status": status, - "render_detail": status != "summary_only", - "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": _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")), - } + "course_code": str(content.get("course_code", "")), + "course_title": str(content.get("course_title", "")), + "program": str(content.get("program", "")), + "lecture_hours": str(content.get("lecture_hours", 0)), + "tutorial_hours": str(content.get("tutorial_hours", 0)), + "practical_hours": str(content.get("practical_hours", 0)), + "self_study": str(content.get("self_study", 0)), + "credits": str(content.get("credits", 0)), + "semester": str(content.get("semester", "")), + "course_type": str(content.get("course_type", "")), + "tools_languages": str(content.get("tools_languages", "")), + "desirable_knowledge": str(content.get("desirable_knowledge", "")), + "prelude": row.get("prelude") or content.get("prelude", ""), + "objectives": row.get("objectives") or content.get("objectives", []), + "units": content.get("units", []), + "lab_experiments": content.get("lab_experiments", []), + } \ No newline at end of file diff --git a/backend/app/rendering.py b/backend/app/rendering.py deleted file mode 100644 index f8be9058a62937f8dc418739ccd3e9d201cdfc94..0000000000000000000000000000000000000000 --- a/backend/app/rendering.py +++ /dev/null @@ -1,73 +0,0 @@ -import re -from pathlib import Path - -from jinja2 import Environment, FileSystemLoader, select_autoescape -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 - - -FRONTEND_DIR = _find_frontend_dir() -templates = Environment(loader=FileSystemLoader(APP_DIR / "templates"), autoescape=select_autoescape(["html", "xml"])) -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 diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py deleted file mode 100644 index 8b137891791fe96927ad78e64b0aad7bded08bdc..0000000000000000000000000000000000000000 --- a/backend/app/routes/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/backend/app/routes/agent.py b/backend/app/routes/agent.py deleted file mode 100644 index 72be20e4f841822411add7368e09f66a1d2f2ec4..0000000000000000000000000000000000000000 --- a/backend/app/routes/agent.py +++ /dev/null @@ -1,225 +0,0 @@ -from fastapi import APIRouter, HTTPException -from fastapi.responses import HTMLResponse -from postgrest.exceptions import APIError - -from app.models.agent import AgentDocumentDraftPayload, AgentDraftPayload, AgentToolPayload -from app.rendering import templates -from app.services.agent_tools import call_tool, list_tool_schemas -from app.services.curriculum import draft_record, load_agent_draft, load_document_draft, selected_curriculum_year, update_refined_fields -from app.services.diffing import diff_course -from app.services.errors import database_http_exception -from app.supabase import supabase - -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): - 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 - html = templates.get_template("jinja_sample.html").render(course=draft["proposed_json"], curriculum_year=selected_curriculum_year(), asset_root="/") - return HTMLResponse(html, headers={"Cache-Control": "no-store"}) - - -@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("agent_drafts").update({"status": "applied"}).eq("id", draft_id).execute() - except APIError as exc: - raise database_http_exception(exc) from exc - return {"message": "Draft applied", "data": data} - - -@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.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): - try: - drafts = load_document_draft(document_draft_id)["drafts"] - 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 not drafts: - raise HTTPException(status_code=404, detail="Document draft not found") - - courses = sorted( - (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(), - 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.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/chat.py b/backend/app/routes/chat.py deleted file mode 100644 index a5d7199da4d38bc61916bb6fd1cf1fa2ccfeff0c..0000000000000000000000000000000000000000 --- a/backend/app/routes/chat.py +++ /dev/null @@ -1,261 +0,0 @@ -import json -import logging - -import sentry_sdk -from fastapi import APIRouter, File, HTTPException, UploadFile -from fastapi.responses import 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, stream_chat -from app.supabase import first_row, supabase - -router = APIRouter() -MAX_ATTACHMENT_CONTEXT = 12000 -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 - return rows[-24:] - - -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[:MAX_ATTACHMENT_CONTEXT]}") - 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)[:12000] - - -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 PESU Curriculum Automation live editor assistant. -Be concise, practical, and specific to the active curriculum data. -Always respond by calling a tool — never state limitations or guess. The available tools handle course data, fetching URLs, generating reports, and creating drafts. -Read source documents with get_attachment_text, then call create_report to save generated content as a chat attachment. -When the user asks to change the active course, call create_course_draft with the active_refined_id, only the fields that should change, and a short reason. -When the user asks for changes across multiple courses or an uploaded document, inspect the curriculum or attachment text, then call create_document_draft with the affected courses. -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. -To fetch a public URL, call fetch_url and use the returned text. -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. -After calling a tool, summarize the result for the user in natural language. Do not call another tool — stop and respond to the user. -Do not change deterministic fields such as program, hours, credits, or course type. - -Active context: -{context or "No active course or document draft is selected."}""" - - -def model_messages(session_id: int, rows: list[dict]) -> list[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}) - return messages - - -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() - return {"session": result.data[0]} - - -@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 = [] - - def remember_tool_result(name: str, result: dict) -> None: - tool_results.append({"name": name, "result": result}) - - def flush_tool_results(): - while tool_results: - item = tool_results.pop(0) - draft = (item["result"] or {}).get("draft") - document_draft = (item["result"] or {}).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}) - - try: - yield sse("status", {"message": "Message saved"}) - rows = chat_messages(session_id) - yield sse("status", {"message": "Loading context"}) - system = chat_system_prompt(session) - yield sse("status", {"message": "Streaming response"}) - for item in stream_chat(system, model_messages(session_id, rows), list_tool_schemas(), call_tool, remember_tool_result): - if isinstance(item, dict) and "$status" in item: - yield sse("status", {"message": item["$status"]}) - continue - yield from flush_tool_results() - answer.append(item) - yield sse("token", {"text": item}) - yield from flush_tool_results() - message = insert_chat_message(session_id, "assistant", "".join(answer).strip()) - yield sse("done", {"message_id": message["id"]}) - except OpenRouterError as exc: - yield from flush_tool_results() - 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() - 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) - 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, - "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} diff --git a/backend/app/routes/courses.py b/backend/app/routes/courses.py deleted file mode 100644 index 3c8ee0d6c3e3df73fff94b15e3fe5c9fca475e5e..0000000000000000000000000000000000000000 --- a/backend/app/routes/courses.py +++ /dev/null @@ -1,33 +0,0 @@ -from fastapi import APIRouter, HTTPException -from postgrest.exceptions import APIError - -from app.preview import build_course_preview -from app.services.curriculum import attach_submissions -from app.services.errors import database_http_exception -from app.supabase import first_row, supabase - -router = APIRouter() - - -@router.get("/courses") -def list_courses(): - try: - rows = supabase.table("refined_submissions").select("*").neq("status", "archived").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"]} 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 "")) - return {"courses": courses} - - -@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 - 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 deleted file mode 100644 index 1e49cd1ba1663ba5bb180d0f837948f1deb9e9d1..0000000000000000000000000000000000000000 --- a/backend/app/routes/health.py +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index e450de25446fcedbf23af5fbb7034a86071517f1..0000000000000000000000000000000000000000 --- a/backend/app/routes/preview.py +++ /dev/null @@ -1,76 +0,0 @@ -from fastapi import APIRouter, HTTPException, Query -from fastapi.responses import HTMLResponse, Response -from weasyprint import HTML - -from app.preview import build_course_preview -from app.rendering import FRONTEND_DIR, templates -from app.services.curriculum import attach_submissions, ordered_courses, selected_curriculum_year -from app.supabase import first_row, supabase - -router = APIRouter() - - -@router.get("/preview/semester/{sem}/courses") -def list_courses(sem: int): - result = supabase.table("refined_submissions").select("id").neq("status", "archived").eq("semester", sem).order("id").execute() - return {"course_ids": [row["id"] for row in result.data]} - - -@router.get("/preview/courses") -def list_all_courses(): - result = supabase.table("refined_submissions").select("id,semester,course_code").neq("status", "archived").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))) - return {"course_ids": [row["id"] for row in rows]} - - -@router.get("/preview/course/{refined_id}") -def preview_course(refined_id: int, curriculum_year: str = Query("")): - 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=selected_curriculum_year(curriculum_year), - asset_root="/", - ) - return HTMLResponse(html, headers={"Cache-Control": "no-store"}) - - -@router.get("/preview/pdf") -def download_all_pdf(download: bool = Query(False), curriculum_year: str = Query("")): - result = supabase.table("refined_submissions").select("*").neq("status", "archived").execute() - courses = ordered_courses(result.data) - html = templates.get_template("jinja_sample.html").render( - courses=courses, - semester="", - curriculum_year=selected_curriculum_year(curriculum_year), - asset_root="", - show_summaries=True, - ) - pdf = HTML(string=html, base_url=str(FRONTEND_DIR)).write_pdf() - return pdf_response(pdf, "curriculum-preview.pdf", download) - - -@router.get("/preview/semester/{sem}/pdf") -def download_pdf(sem: int, download: bool = Query(False), curriculum_year: str = Query("")): - result = supabase.table("refined_submissions").select("*").neq("status", "archived").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=selected_curriculum_year(curriculum_year), - asset_root="", - show_summaries=True, - ) - pdf = HTML(string=html, base_url=str(FRONTEND_DIR)).write_pdf() - return pdf_response(pdf, f"semester-{sem}.pdf", download) - - -def pdf_response(pdf: bytes, filename: str, download: bool) -> Response: - disposition = "attachment" if download else "inline" - return Response( - content=pdf, - media_type="application/pdf", - headers={"Content-Disposition": f'{disposition}; filename="{filename}"', "Cache-Control": "no-store"}, - ) diff --git a/backend/app/routes/refined.py b/backend/app/routes/refined.py deleted file mode 100644 index 0942f6d01d10de055c68b20d98322f8ea0ddcf3a..0000000000000000000000000000000000000000 --- a/backend/app/routes/refined.py +++ /dev/null @@ -1,24 +0,0 @@ -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") - return {"message": "Updated", "data": update_refined_fields(refined_id, fields)} diff --git a/backend/app/routes/submissions.py b/backend/app/routes/submissions.py deleted file mode 100644 index 46f91c0d5a2686fc09293b750801dfd1b23438ec..0000000000000000000000000000000000000000 --- a/backend/app/routes/submissions.py +++ /dev/null @@ -1,41 +0,0 @@ -import sentry_sdk -from fastapi import APIRouter, BackgroundTasks, HTTPException -from postgrest.exceptions import APIError - -from app.models.submission import CourseSubmission -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): - payload = data.model_dump() - payload["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 diff --git a/backend/app/routes/versions.py b/backend/app/routes/versions.py deleted file mode 100644 index 12ff6193b5e8b625c9d6b4b6dce81b0d8e173e57..0000000000000000000000000000000000000000 --- a/backend/app/routes/versions.py +++ /dev/null @@ -1,198 +0,0 @@ -from fastapi import APIRouter, HTTPException -from fastapi.responses import HTMLResponse -from postgrest.exceptions import APIError - -from app.preview import build_course_preview -from app.rendering import templates -from app.services.curriculum import attach_submissions, selected_curriculum_year, update_refined_fields -from app.services.diffing import 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(): - try: - rows = supabase.table("curriculum_versions").select("*").order("id", desc=True).execute().data - except APIError as exc: - raise database_http_exception(exc) from exc - return {"versions": rows} - - -@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: - rows = supabase.table("refined_submissions").select("*").execute().data - rows = attach_submissions(rows) - courses = [{"refined_id": row["id"], "course_json": build_course_preview(row)} for row in rows] - 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": selected_curriculum_year(payload.get("academic_year")), - "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 - 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 - ) - 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() - except APIError as exc: - raise database_http_exception(exc) from exc - 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): - try: - snapshot = _snapshot(version_id, refined_id) - except APIError as exc: - raise database_http_exception(exc) from exc - version = _version(version_id) - html = templates.get_template("jinja_sample.html").render(course=snapshot["course_json"], curriculum_year=selected_curriculum_year(version.get("academic_year")), asset_root="/") - return HTMLResponse(html, headers={"Cache-Control": "no-store"}) - - -@router.get("/versions/{version_id}/preview") -def preview_version(version_id: int): - try: - version = _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 - courses = sorted( - (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 "")), - ) - html = templates.get_template("jinja_sample.html").render( - courses=courses, - semester="", - curriculum_year=selected_curriculum_year(version.get("academic_year")), - asset_root="/", - show_summaries=True, - ) - return HTMLResponse(html, headers={"Cache-Control": "no-store"}) diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py deleted file mode 100644 index 6bc40b77fa903fd8eb961647f5a555993b567e46..0000000000000000000000000000000000000000 --- a/backend/app/services/agent_tools.py +++ /dev/null @@ -1,319 +0,0 @@ -from collections.abc import Callable -from dataclasses import dataclass - -import httpx - -from app.services.curriculum import draft_record, load_agent_draft, load_document_draft, ordered_courses, refined_course -from app.services.diffing import diff_course -from app.supabase import supabase - -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 _diff_course_json(arguments: dict) -> dict: - return diff_course(_require_dict(arguments, "current"), _require_dict(arguments, "proposed")) - - -def _create_course_draft(arguments: dict) -> dict: - record = draft_record( - _require_int(arguments, "refined_id"), - _require_dict(arguments, "fields"), - str(arguments.get("reason") or ""), - ) - draft = supabase.table("agent_drafts").insert(record).execute().data[0] - return {"draft": draft} - - -def _get_curriculum_json(arguments: dict) -> dict: - query = supabase.table("refined_submissions").select("*").neq("status", "archived") - 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") - records.append(draft_record(int(course.get("refined_id")), _require_dict(course, "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 _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() - row = ( - supabase.table("chat_attachments") - .insert({ - "session_id": session_id, - "filename": filename, - "content_type": "text/markdown", - "size_bytes": len(content.encode()), - "extracted_text": content, - "status": "ready", - }) - .execute() - .data[0] - ) - return {"attachment": {"id": row["id"], "filename": row["filename"], "chars": len(content)}} - - -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} - - -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, - ), - "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, - ), - "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, - ), - "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.", - { - **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"}, - }, - "required": ["session_id", "content"], - }, - _create_report, - ), -} diff --git a/backend/app/services/attachments.py b/backend/app/services/attachments.py deleted file mode 100644 index ca5e6d7355356cbd309ccfa0fa66153db5cf06a5..0000000000000000000000000000000000000000 --- a/backend/app/services/attachments.py +++ /dev/null @@ -1,91 +0,0 @@ -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 = 50000 -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 deleted file mode 100644 index 3e92d310172500b5d546a2d2b299bb3dfe5451aa..0000000000000000000000000000000000000000 --- a/backend/app/services/books.py +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index c57f2b453998d7d0837f3582f59e676e2b510e5b..0000000000000000000000000000000000000000 --- a/backend/app/services/curriculum.py +++ /dev/null @@ -1,154 +0,0 @@ -from os import environ - -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() -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", - "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 - submissions = supabase.table("submissions").select("*").in_("id", ids).execute().data - by_id = {row["id"]: row for row in submissions} - 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_sort_key(row: dict) -> tuple[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, 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 selected_curriculum_year(value: str | None = None) -> str: - return str(value or "").strip() or DEFAULT_CURRICULUM_YEAR - - -def refined_course(refined_id: int) -> dict: - row = first_row(supabase.table("refined_submissions").select("*").eq("id", refined_id)) - if not row: - raise LookupError("Refined submission not found") - return build_course_preview(attach_submissions([row])[0]) - - -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: - update[key] = int(update[key] or 0) - result = supabase.table("refined_submissions").update(update).eq("id", refined_id).execute() - 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 deleted file mode 100644 index e2e15045a33e9e21d74a792f4c1b53e64a534c2a..0000000000000000000000000000000000000000 --- a/backend/app/services/deterministic.py +++ /dev/null @@ -1,27 +0,0 @@ -def compute_hours(cat: str) -> dict: - return { - "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}, - }[cat] - - -def compute_program(dept: str) -> str: - return { - "CSE": "B. TECH", - "AIML": "B. TECH", - "ECE": "B. TECH", - "ME": "B. TECH", - "EEE": "B. TECH", - "BT": "B. TECH", - }[dept] - - -def compute_course_type(cat: str) -> str: - return { - "0": "Foundation Course", - "5": "Core Course-Lab Integrated", - "4": "Core Course", - "2": "Core Theory", - }[cat] diff --git a/backend/app/services/diffing.py b/backend/app/services/diffing.py deleted file mode 100644 index 2d3ac75dd3bf57419cb180003ff60dd41330cddf..0000000000000000000000000000000000000000 --- a/backend/app/services/diffing.py +++ /dev/null @@ -1,160 +0,0 @@ -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) - - 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 _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_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 - 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 diff --git a/backend/app/services/errors.py b/backend/app/services/errors.py deleted file mode 100644 index bce35d152826aa032d2ee459fe8adca33cfefa5b..0000000000000000000000000000000000000000 --- a/backend/app/services/errors.py +++ /dev/null @@ -1,11 +0,0 @@ -import sentry_sdk -from fastapi import HTTPException -from postgrest.exceptions import APIError - - -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.") - sentry_sdk.capture_exception(exc) - return HTTPException(status_code=500, detail="Database request failed.") diff --git a/backend/app/services/openrouter.py b/backend/app/services/openrouter.py deleted file mode 100644 index a995c9e0679255352b76d46420a347b980387a5a..0000000000000000000000000000000000000000 --- a/backend/app/services/openrouter.py +++ /dev/null @@ -1,221 +0,0 @@ -import json -import logging -import os - -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() -logger = logging.getLogger(__name__) - - -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 - - -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 call(system: str, user: str) -> dict: - 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) - - -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_chat(messages: list[dict]): - payload = {"model": MODEL, "messages": messages, "stream": True} - 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 - - -def _chat_text(messages: list[dict]) -> str: - 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()) - - -def _chat_message(messages: list[dict], tools: list[dict]) -> dict: - with Client(timeout=120) as client: - response = client.post(URL, headers=_headers(), json={"model": MODEL, "messages": messages, "tools": tools}) - _raise_for_status(response) - return _assistant_message(response.json()) - - -def _chat_with_tools(messages: list[dict], tools: list[dict], tool_runner, on_tool_result=None): - for _ in range(5): - yield {"$status": f"Thinking... (attempt {_ + 1}/5)"} - message = _chat_message(messages, tools) - tool_calls = message.get("tool_calls") or [] - if not tool_calls: - text = str(message.get("content") or "").strip() - if text: - yield text - return - - messages.append({"role": "assistant", "content": message.get("content") or "", "tool_calls": tool_calls}) - for tool_call in tool_calls: - name = (tool_call.get("function") or {}).get("name") or "" - try: - result = _run_tool(tool_runner, name, _tool_arguments(tool_call)) - except ValueError as exc: - result = {"error": str(exc)} - if on_tool_result: - on_tool_result(name, result) - messages.append( - { - "role": "tool", - "tool_call_id": tool_call.get("id") or name, - "name": name, - "content": json.dumps(result, ensure_ascii=False), - } - ) - yield "I created tool results, but could not finish the response. Please check the Review panel." - - -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_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 "" diff --git a/backend/app/services/refinement.py b/backend/app/services/refinement.py deleted file mode 100644 index 5aad26b17035a357ddbe23429fe105c6ee6356e8..0000000000000000000000000000000000000000 --- a/backend/app/services/refinement.py +++ /dev/null @@ -1,491 +0,0 @@ -import re - -from app.supabase import first_row, supabase -from app.services.books import parse_books, raw_book_section -from app.services.deterministic import compute_hours, compute_program, compute_course_type -from app.services.openrouter import call as llm - -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. -Priority order: -1. Preserve the submitted academic content. -2. Correct spelling, grammar, casing, and formatting. -3. Fill missing template fields from the submitted course scope. -4. Return the required JSON shape. - -Editing rules: -- Refine, do not rewrite. If the input is already curriculum-ready, keep its wording and structure except for small correctness fixes. -- 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. -- Correct course title typos, casing, spacing, and grammar. -- 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 - if sum(unit["hours"] for unit in units) == total_hours: - return units - base, extra = divmod(total_hours, len(units)) - for index, unit in enumerate(units): - unit["hours"] = base + (1 if index < extra else 0) - 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] - - -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 - - return { - "submission_id": sub["id"], - "semester": int(sub["semester"]), - "course_code": _course_code(raw_content), - "course_title": _text(out.get("course_title"), sub["course_title"]), - "program": compute_program(sub["target_department"]), - "course_type": compute_course_type(sub["credit_category"]), - **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) - - 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() - else: - supabase.table("refined_submissions").insert(merged).execute() - - supabase.table("submissions").update({"status": "refined"}).eq("id", submission_id).execute() - - return merged diff --git a/backend/app/services/schema.py b/backend/app/services/schema.py deleted file mode 100644 index f31dcbd8fa1b776b8a5c2a1a20b9a9dc5e354e70..0000000000000000000000000000000000000000 --- a/backend/app/services/schema.py +++ /dev/null @@ -1,28 +0,0 @@ -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", -) - - -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 index bb56c8315b23093e14ae754a6b02841bde82ea39..0cdba28a0e3deee72cfdee4f04192ed92b240dd0 100644 --- a/backend/app/supabase.py +++ b/backend/app/supabase.py @@ -5,17 +5,12 @@ import httpx load_dotenv("../.env") -url = environ["SUPABASE_URL"].strip() -key = environ["SUPABASE_KEY"].strip() -supabase = create_client( +url=environ["SUPABASE_URL"] +key=environ["SUPABASE_KEY"] +supabase=create_client( supabase_url=url, supabase_key=key, options=ClientOptions( httpx_client=httpx.Client(http2=False), ), -) - - -def first_row(query) -> dict | None: - rows = query.limit(1).execute().data - return rows[0] if rows else None +) \ No newline at end of file diff --git a/frontend/images/image1.png b/backend/app/templates/images/image1.png similarity index 100% rename from frontend/images/image1.png rename to backend/app/templates/images/image1.png diff --git a/frontend/images/image2.png b/backend/app/templates/images/image2.png similarity index 100% rename from frontend/images/image2.png rename to backend/app/templates/images/image2.png diff --git a/backend/app/templates/jinja_1_to_8.html b/backend/app/templates/jinja_1_to_8.html deleted file mode 100644 index b9f845e3b3a19a99edf0fa6e89840079629b5f13..0000000000000000000000000000000000000000 --- a/backend/app/templates/jinja_1_to_8.html +++ /dev/null @@ -1,240 +0,0 @@ -{% macro semester_name(value) -%} - {{ {"1": "I", "2": "II", "3": "III", "4": "IV", "7": "VII", "8": "VIII"}.get(value|string, value) }} -{%- endmacro %} - -{% macro course_row(course, number) -%} -
| Sl. No. | -Course Code | -Course Title | -Hours per week | -Credits | -AI Tools / Tools / Languages |
- Course Type | -|||
|---|---|---|---|---|---|---|---|---|---|
| L | -T | -P | -S | -C | -|||||
| Total | -{{ total_cell(totals.lecture, totals.lecture_credit) }} | -{{ totals.tutorial }} | -{{ totals.practical }} | -{{ totals.self_study }} | -{{ totals.credits }} | -- | - | ||
-
-
-
-
-
- | Sl. No. | -Course Code | -Course Title | -Hours/week | -Credits | -Course Type | -|||
|---|---|---|---|---|---|---|---|---|
| L | -T | -P | -S | -C | -||||
| Total | -{{ totals.lecture }} | -{{ totals.tutorial }} | -{{ totals.practical }} | -{{ totals.self_study }} | -{{ totals.credits }} | -- | ||
-
- | 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 Assigned | -L | -T | -P | -S | -C | -
| {{ 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 }} | -||||
| Prelude | -{{ course.prelude }} | -
|---|---|
| Course Objectives: | -
-
|
-
| Course Contents | -
- {% if course.units %}
- {% for unit in course.units %}
-
-
- {% endfor %}
- {% else %}
- -
- {% endif %}
- {{ unit.title }} -{{ unit.content }} -{{ unit.hours }} Hours - |
-
| Laboratory | -
-
|
-
| Text Book(s): | -
- {% if course.text_books %}
-
|
-
| Reference Book(s): | -
-
|
-
| Course Outcome | -
-
|
-
+ +
++
|
+ + |
+
+ Course Code + |
+
+ {{ course.course_code }} + |
+
+ Course Title + |
+
+ + {{ course.course_title }} + + |
+
+ + |
+ |||||
|
+ Program + |
+
+ {{ course.program }} + |
+
+ + Hours per week/ Credit Assigned + + |
+
+ L + |
+
+ T + |
+
+ P + |
+
+ S + |
+
+ C + |
+ |||
|
+ {{ 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/Languag es + |
+
+ + {{ course.tools_languages }} + + |
+
+ Desirable Knowledge + |
+
+ {{ course.desirable_knowledge }} + |
+ |||||||
|
+ Prelude + |
+
+ + {{ course.prelude }} + + |
+ |||||||||
|
+ Course Objectives: + |
+
+
|
+ |||||||||
|
+ Course Contents + |
+
+ {% for unit in course.units %}
+ {{ unit.title }} ++ {{ unit.content }} + +{{ unit.hours }} Hours + {% endfor %} + |
+ |||||||||
|
+ Laboratory + |
+
+
|
+ |||||||||
| + + + | +||||||||||
{{ ("UE00CS" ~ code) | course_code_for_year(summary_semester, curriculum_year) }}
- {% 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 course_type = summary_course.course_type|string|lower %} - {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %} - {% set has_elective_code = "AA" in code or "AB" in code or "BA" in code or "BB" in code %} - {% set is_actual_elective = has_elective_code and not is_placeholder %} - {% if summary_course.semester|string == summary_semester|string and not is_actual_elective %} - {% 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 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 course_type = summary_course.course_type|string|lower %} - {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %} - {% set is_elective = not is_placeholder %} - {% if summary_course.semester|string == summary_semester|string and is_elective %} - {% if summary_semester == 5 and "AA" in code %} - {% set elective_a.count = elective_a.count + 1 %} - {% elif summary_semester == 5 and "AB" in code %} - {% set elective_b.count = elective_b.count + 1 %} - {% elif summary_semester == 6 and "BA" in code %} - {% set elective_a.count = elective_a.count + 1 %} - {% elif 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 %} -
-
- | Sl. No. | -Course Code | -Course Title | -Hours per week | -Credits | -AI Tools / Tools / Languages |
- Course Type | -|||
|---|---|---|---|---|---|---|---|---|---|
| L | -T | -P | -S | -C | -|||||
| Total | -{{ total_cell(totals.lecture, totals.lecture_credit) }} | -{{ totals.tutorial }} | -{{ totals.practical }} | -{{ totals.self_study }} | -{{ totals.credits }} | -- | - | ||
| - {% 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 %} - | -
|---|
| ELECTIVES TO BE OPTED FOR SPECIALIZATION | -|||
|---|---|---|---|
| Sl. No. | -Specialization | -{{ first_label }} | -{{ second_label }} | -
| {{ specialization.letter }} | -{{ specialization.name }} | -{{ specialization_codes(specialization.first, summary_semester, curriculum_year) }} | -{{ specialization_codes(specialization.second, summary_semester, curriculum_year) }} | -
Manage refined courses used by preview, editor, and versions.
-| Semester | -Code | -Course | -Credits | -- |
|---|
Fill in the course details below.
- - - - -Fill in the course details below.
+ + + + +