This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. .dockerignore +2 -11
  2. .gitattributes +35 -0
  3. Dockerfile +1 -11
  4. README.md +3 -85
  5. backend/app/api.py +56 -4
  6. backend/app/main.py +2 -28
  7. backend/app/models/__init__.py +0 -1
  8. backend/app/models/agent.py +0 -23
  9. backend/app/models/chat.py +0 -26
  10. backend/app/models/submission.py +0 -21
  11. backend/app/preview.py +18 -76
  12. backend/app/rendering.py +0 -73
  13. backend/app/routes/__init__.py +0 -1
  14. backend/app/routes/agent.py +0 -225
  15. backend/app/routes/chat.py +0 -261
  16. backend/app/routes/courses.py +0 -33
  17. backend/app/routes/health.py +0 -18
  18. backend/app/routes/preview.py +0 -76
  19. backend/app/routes/refined.py +0 -24
  20. backend/app/routes/submissions.py +0 -41
  21. backend/app/routes/versions.py +0 -198
  22. backend/app/services/agent_tools.py +0 -319
  23. backend/app/services/attachments.py +0 -91
  24. backend/app/services/books.py +0 -105
  25. backend/app/services/curriculum.py +0 -154
  26. backend/app/services/deterministic.py +0 -27
  27. backend/app/services/diffing.py +0 -160
  28. backend/app/services/errors.py +0 -11
  29. backend/app/services/openrouter.py +0 -221
  30. backend/app/services/refinement.py +0 -491
  31. backend/app/services/schema.py +0 -28
  32. backend/app/supabase.py +4 -9
  33. {frontend → backend/app/templates}/images/image1.png +0 -0
  34. {frontend → backend/app/templates}/images/image2.png +0 -0
  35. backend/app/templates/jinja_1_to_8.html +0 -240
  36. backend/app/templates/jinja_sample.html +1415 -364
  37. backend/app/templates/jinja_sem_5_6.html +0 -238
  38. docs/index.md +0 -184
  39. docs/remote-db.md +0 -44
  40. docs/schema.sql +0 -149
  41. frontend/courses.html +0 -2
  42. frontend/courses/courses.css +0 -130
  43. frontend/courses/courses.js +0 -117
  44. frontend/courses/index.html +0 -57
  45. frontend/form.html +0 -11
  46. frontend/form/index.html +0 -100
  47. frontend/home/home.css +0 -70
  48. frontend/index.html +90 -14
  49. frontend/live-editor.html +0 -11
  50. frontend/live-editor/index.html +0 -112
.dockerignore CHANGED
@@ -1,11 +1,2 @@
1
- .venv/
2
- __pycache__/
3
- *.pyc
4
- .env
5
- .nottracked/
6
- .git/
7
- .gitignore
8
- AGENTS.md
9
- README.md
10
- LICENSE
11
- *.md
 
1
+ .venv/ __pycache__/ *.pyc .env .nottracked/ frontend/
2
+ .git/ .gitignore AGENTS.md README.md LICENSE *.md
 
 
 
 
 
 
 
 
 
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
Dockerfile CHANGED
@@ -2,22 +2,12 @@ FROM python:3.12-slim
2
 
3
  WORKDIR /app
4
 
5
- RUN apt-get update && apt-get install -y \
6
- libpango-1.0-0 \
7
- libpangocairo-1.0-0 \
8
- libgdk-pixbuf-2.0-0 \
9
- libffi-dev \
10
- poppler-utils \
11
- shared-mime-info \
12
- && rm -rf /var/lib/apt/lists/*
13
-
14
  COPY requirements.txt .
15
 
16
  RUN pip install --no-cache-dir -r requirements.txt
17
 
18
  COPY backend/ .
19
- COPY frontend/ frontend/
20
 
21
  EXPOSE 7860
22
 
23
- CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
 
2
 
3
  WORKDIR /app
4
 
 
 
 
 
 
 
 
 
 
5
  COPY requirements.txt .
6
 
7
  RUN pip install --no-cache-dir -r requirements.txt
8
 
9
  COPY backend/ .
 
10
 
11
  EXPOSE 7860
12
 
13
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,92 +1,10 @@
1
  ---
2
  title: Curriculum Backend
3
- colorFrom: blue
 
4
  colorTo: gray
5
  sdk: docker
6
- app_port: 7860
7
  pinned: false
8
  ---
9
 
10
- # Curriculum Automation
11
-
12
- ## Doc link
13
-
14
- https://lonelyguy-se1.github.io/PESU-Curriculum-Automation/
15
-
16
- ## Phase 1 Flow Proposal
17
-
18
- ```mermaid id="phase1-flow"
19
- flowchart TD
20
-
21
- A[Professor opens form] --> B[Enter course details]
22
- B --> C[Submit content, semester, credits, department, books]
23
-
24
- C --> D[FastAPI receives submission]
25
- D --> E[Check mandatory fields]
26
-
27
- E -->|Invalid| F[Reject submission]
28
- F --> G[Email professor with reasons]
29
- G --> H[Professor opens edit link]
30
- H --> I[Cached form shows old data and flagged issues]
31
- I --> C
32
-
33
- E -->|Valid| J[Generate derived fields]
34
- J --> J1[Course code]
35
- J --> J2[Credit pattern]
36
- J --> J3[Course type]
37
- J --> J4[Template data]
38
-
39
- J1 --> K[AI refinement]
40
- J2 --> K
41
- J3 --> K
42
- J4 --> K
43
-
44
- K --> K1[Refine content]
45
- K --> K2[Generate prelude]
46
- K --> K3[Generate objectives]
47
- K --> K4[Generate outcomes]
48
- K --> K5[Recommend tools]
49
-
50
- K1 --> L[Rubric check]
51
- K2 --> L
52
- K3 --> L
53
- K4 --> L
54
- K5 --> L
55
-
56
- L -->|Low score| M[Reject draft]
57
- M --> N[Store flagged issues]
58
- N --> G
59
-
60
- L -->|Pass| O[Create draft record]
61
- O --> P[Render preview with Jinja2]
62
-
63
- P --> Q[Faculty or admin review]
64
-
65
- Q -->|Rejected| R[Store rejection reasons]
66
- R --> G
67
-
68
- Q -->|Accepted| S[Update remote database]
69
- S --> T[Update curriculum template fields]
70
- T --> U[Keep incomplete template pending]
71
-
72
- U --> V{All course submissions received?}
73
-
74
- V -->|No| W[Wait for remaining professors]
75
- W --> U
76
-
77
- V -->|Yes| X[Compile final curriculum document]
78
- X --> Y[Final review]
79
- Y --> Z[Export final PDF or DOCX]
80
- ```
81
-
82
- ## Phase 1 Summary
83
-
84
- Professors submit only the required academic material.
85
-
86
- The system generates derived fields, refines the content, checks it using a rubric, and stores accepted drafts in a remote database.
87
-
88
- 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.
89
-
90
- 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.
91
-
92
- The final curriculum document is compiled only after every required course entry is complete.
 
1
  ---
2
  title: Curriculum Backend
3
+ emoji: 👁
4
+ colorFrom: pink
5
  colorTo: gray
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/api.py CHANGED
@@ -1,8 +1,60 @@
1
  from fastapi import APIRouter
 
 
 
 
2
 
3
- from app.routes import agent, chat, courses, health, preview, refined, submissions, versions
 
4
 
5
- router = APIRouter()
6
 
7
- for route in (health, submissions, preview, refined, agent, chat, versions, courses):
8
- router.include_router(route.router)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from fastapi import APIRouter
2
+ from fastapi.responses import HTMLResponse
3
+ from jinja2 import Environment, FileSystemLoader
4
+ from pydantic import BaseModel,Field, field_validator
5
+ from typing import Literal
6
 
7
+ from app.preview import build_course_preview
8
+ from app.supabase import supabase
9
 
10
+ router=APIRouter()
11
 
12
+ templates=Environment(loader=FileSystemLoader("app/templates"))
13
+
14
+ class CourseSubmission(BaseModel):
15
+ faculty_email: str=Field(min_length=3, max_length=254)
16
+ course_title: str=Field(min_length=3, max_length=150)
17
+ offering_department: Literal["MA", "CS", "UZ"]
18
+ target_department: Literal["CSE", "ECE", "ME", "BT", "EEE", "AIML"]
19
+ semester: Literal["1", "2", "3", "4", "5", "6", "7", "8"]
20
+ credit_category: Literal["0", "2", "4", "5"]
21
+ raw_course_content: str=Field(min_length=50)
22
+ text_books: str=Field(min_length=5)
23
+ reference_books: str=""
24
+ preferred_tools: str=""
25
+
26
+ @field_validator(
27
+ "faculty_email", "course_title", "raw_course_content", "text_books", "reference_books", "preferred_tools", mode="before",)
28
+ @classmethod
29
+ def strip(cls, v):
30
+ return v.strip() if isinstance(v, str) else v
31
+
32
+ @router.post("/submissions")
33
+ def receive(data: CourseSubmission):
34
+ """
35
+ print("=== NEW SUBMISSION ===")
36
+ for field, values in data.model_dump().items():
37
+ print(f"{field}: {values}")
38
+ print("======================")
39
+ return {"message": "Submission received successfully!", "submission": data.model_dump()}
40
+ """
41
+
42
+ data_dict=data.model_dump()
43
+ data_dict["status"]="pending"
44
+ result=supabase.table("submissions").insert(data_dict).execute()
45
+ print("New Submission Received! Course Title: ", data.course_title)
46
+ return {"message":"Submission Received!", "submission":result.data[0]}
47
+
48
+ @router.get("/preview/semester/{sem}/courses")
49
+ def list_courses(sem:str): # sem will be used once semester column is added
50
+ result=supabase.table("refined_submissions").select("id").execute()
51
+ rows: list[dict] = result.data
52
+ return {"course_ids": [r["id"] for r in rows]}
53
+
54
+ @router.get("/preview/course/{refined_id}")
55
+ def preview_course(refined_id:int):
56
+ result=supabase.table("refined_submissions").select("*").eq("id", refined_id).single().execute()
57
+ row: dict = result.data
58
+ course=build_course_preview(row)
59
+ html=templates.get_template("jinja_sample.html").render(course=course, curriculum_year="", page_number="")
60
+ return HTMLResponse(content=html)
backend/app/main.py CHANGED
@@ -1,35 +1,11 @@
1
- import os
2
  from pathlib import Path
3
 
4
- import sentry_sdk
5
  from fastapi import FastAPI
6
  from fastapi.middleware.cors import CORSMiddleware
7
  from fastapi.staticfiles import StaticFiles
8
- from dotenv import load_dotenv
9
-
10
- load_dotenv(Path(__file__).resolve().parents[2] / ".env")
11
-
12
- sentry_dsn = os.getenv("SENTRY_DSN", "").strip()
13
- if sentry_dsn:
14
- sentry_config = {"dsn": sentry_dsn, "environment": os.getenv("SENTRY_ENVIRONMENT", "").strip() or "production"}
15
- sentry_release = os.getenv("SENTRY_RELEASE", "").strip()
16
- if sentry_release:
17
- sentry_config["release"] = sentry_release
18
- sentry_sdk.init(**sentry_config)
19
 
20
  from app.api import router
21
 
22
-
23
- def frontend_directory():
24
- app_root = Path(__file__).resolve().parents[1]
25
- candidates = (
26
- app_root / "frontend",
27
- app_root.parent / "frontend",
28
- app_root.parent.parent / "frontend",
29
- Path("/frontend"),
30
- )
31
- return next((path for path in candidates if path.exists()), None)
32
-
33
  app = FastAPI(title="PESU Curriculum Automation")
34
 
35
  app.add_middleware(
@@ -41,7 +17,5 @@ app.add_middleware(
41
 
42
  app.include_router(router, prefix="/api")
43
 
44
- frontend_dir = frontend_directory()
45
- if not frontend_dir:
46
- raise RuntimeError("Frontend directory not found")
47
- app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend")
 
 
1
  from pathlib import Path
2
 
 
3
  from fastapi import FastAPI
4
  from fastapi.middleware.cors import CORSMiddleware
5
  from fastapi.staticfiles import StaticFiles
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  from app.api import router
8
 
 
 
 
 
 
 
 
 
 
 
 
9
  app = FastAPI(title="PESU Curriculum Automation")
10
 
11
  app.add_middleware(
 
17
 
18
  app.include_router(router, prefix="/api")
19
 
20
+ if Path("../frontend").exists():
21
+ app.mount("/", StaticFiles(directory="../frontend", html=True), name="frontend")
 
 
backend/app/models/__init__.py DELETED
@@ -1 +0,0 @@
1
-
 
 
backend/app/models/agent.py DELETED
@@ -1,23 +0,0 @@
1
- from pydantic import BaseModel, Field
2
-
3
-
4
- class AgentDraftPayload(BaseModel):
5
- refined_id: int
6
- fields: dict
7
- reason: str = ""
8
-
9
-
10
- class AgentDocumentCoursePayload(BaseModel):
11
- refined_id: int
12
- fields: dict
13
-
14
-
15
- class AgentDocumentDraftPayload(BaseModel):
16
- courses: list[AgentDocumentCoursePayload] = Field(min_length=1)
17
- reason: str = ""
18
- curriculum_version_id: int | None = None
19
- uploaded_document_id: str = ""
20
-
21
-
22
- class AgentToolPayload(BaseModel):
23
- arguments: dict = Field(default_factory=dict)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/models/chat.py DELETED
@@ -1,26 +0,0 @@
1
- from pydantic import BaseModel, Field, field_validator
2
-
3
-
4
- class ChatSessionPayload(BaseModel):
5
- refined_id: int | None = None
6
- document_draft_id: int | None = None
7
- title: str = ""
8
-
9
-
10
- class ChatSessionTitlePayload(BaseModel):
11
- title: str = Field(min_length=1, max_length=120)
12
-
13
- @field_validator("title", mode="before")
14
- @classmethod
15
- def strip_title(cls, value):
16
- return value.strip() if isinstance(value, str) else value
17
-
18
-
19
- class ChatMessagePayload(BaseModel):
20
- content: str = ""
21
- metadata: dict = Field(default_factory=dict)
22
-
23
- @field_validator("content", mode="before")
24
- @classmethod
25
- def strip_content(cls, value):
26
- return value.strip() if isinstance(value, str) else value
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/models/submission.py DELETED
@@ -1,21 +0,0 @@
1
- from typing import Literal
2
-
3
- from pydantic import BaseModel, Field, field_validator
4
-
5
-
6
- class CourseSubmission(BaseModel):
7
- faculty_email: str = Field(min_length=3, max_length=254)
8
- course_title: str = Field(min_length=3, max_length=150)
9
- offering_department: Literal["MA", "CS", "UZ"]
10
- target_department: Literal["CSE", "ECE", "ME", "BT", "EEE", "AIML"]
11
- semester: Literal["1", "2", "3", "4", "5", "6", "7", "8"]
12
- credit_category: Literal["0", "2", "4", "5"]
13
- raw_course_content: str = Field(min_length=50)
14
- text_books: str = Field(min_length=5)
15
- reference_books: str = ""
16
- preferred_tools: str = ""
17
-
18
- @field_validator("faculty_email", "course_title", "raw_course_content", "text_books", "reference_books", "preferred_tools", mode="before")
19
- @classmethod
20
- def strip(cls, value):
21
- return value.strip() if isinstance(value, str) else value
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/preview.py CHANGED
@@ -1,81 +1,23 @@
1
  import json
2
 
3
- from app.services.books import parse_books
4
- from app.services.deterministic import compute_course_type, compute_hours, compute_program
5
-
6
-
7
- def _lines(value) -> list[str]:
8
- if not value:
9
- return []
10
- if isinstance(value, list):
11
- return [str(item).strip() for item in value if str(item).strip()]
12
- return [line.strip() for line in str(value).splitlines() if line.strip()]
13
-
14
-
15
- def _content(row: dict) -> dict:
16
- raw = row.get("refined_content") or {}
17
- if isinstance(raw, dict):
18
- return raw
19
- if isinstance(raw, str) and raw.strip():
20
- return json.loads(raw)
21
- return {}
22
-
23
-
24
- def _text(*values) -> str:
25
- for value in values:
26
- if isinstance(value, str):
27
- text = value.strip()
28
- if text and text != "-":
29
- return text
30
- return ""
31
-
32
 
33
  def build_course_preview(row: dict) -> dict:
34
- content = _content(row)
35
- submission = row.get("_submission") or {}
36
- credit_category = str(submission.get("credit_category") or row.get("credit_category") or content.get("credits") or "")
37
- target_department = str(submission.get("target_department") or "")
38
-
39
- hours = {
40
- "lecture_hours": row.get("lecture_hours", content.get("lecture_hours", 0)),
41
- "tutorial_hours": row.get("tutorial_hours", content.get("tutorial_hours", 0)),
42
- "practical_hours": row.get("practical_hours", content.get("practical_hours", 0)),
43
- "self_study": row.get("self_study", content.get("self_study", 0)),
44
- "credits": row.get("credits", content.get("credits", 0)),
45
- }
46
- if credit_category and not any(hours.values()):
47
- hours = compute_hours(credit_category)
48
-
49
- program = row.get("program") or content.get("program") or ""
50
- if target_department and not program:
51
- program = compute_program(target_department)
52
-
53
- course_type = row.get("course_type") or content.get("course_type") or ""
54
- if credit_category and not course_type:
55
- course_type = compute_course_type(credit_category)
56
-
57
- practical_hours = int(hours["practical_hours"] or 0)
58
- status = str(row.get("status") or content.get("status") or "")
59
  return {
60
- "course_code": str(row.get("course_code") or content.get("course_code") or ""),
61
- "course_title": str(row.get("course_title") or content.get("course_title") or ""),
62
- "program": str(program),
63
- "lecture_hours": str(hours["lecture_hours"]),
64
- "tutorial_hours": str(hours["tutorial_hours"]),
65
- "practical_hours": str(practical_hours),
66
- "self_study": str(hours["self_study"]),
67
- "credits": str(hours["credits"]),
68
- "semester": str(row.get("semester") or content.get("semester") or ""),
69
- "course_type": str(course_type),
70
- "status": status,
71
- "render_detail": status != "summary_only",
72
- "tools_languages": _text(row.get("tools_languages"), content.get("tools_languages"), submission.get("preferred_tools")),
73
- "desirable_knowledge": _text(row.get("desirable_knowledge"), content.get("desirable_knowledge")),
74
- "prelude": row.get("prelude") or content.get("prelude") or "",
75
- "objectives": _lines(row.get("objectives") or content.get("objectives"))[:4],
76
- "course_outcomes": _lines(row.get("course_outcomes") or content.get("course_outcomes"))[:4],
77
- "units": row.get("units") or content.get("units") or [],
78
- "lab_experiments": _lines(row.get("lab_experiments") or content.get("lab_experiments"))[:10] if practical_hours else [],
79
- "text_books": parse_books(row.get("text_books") or content.get("text_books")) or parse_books(submission.get("text_books")),
80
- "reference_books": parse_books(row.get("reference_books") or content.get("reference_books")) or parse_books(submission.get("reference_books")),
81
- }
 
1
  import json
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
  def build_course_preview(row: dict) -> dict:
5
+ content = json.loads(row.get("refined_content") or "{}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  return {
7
+ "course_code": str(content.get("course_code", "")),
8
+ "course_title": str(content.get("course_title", "")),
9
+ "program": str(content.get("program", "")),
10
+ "lecture_hours": str(content.get("lecture_hours", 0)),
11
+ "tutorial_hours": str(content.get("tutorial_hours", 0)),
12
+ "practical_hours": str(content.get("practical_hours", 0)),
13
+ "self_study": str(content.get("self_study", 0)),
14
+ "credits": str(content.get("credits", 0)),
15
+ "semester": str(content.get("semester", "")),
16
+ "course_type": str(content.get("course_type", "")),
17
+ "tools_languages": str(content.get("tools_languages", "")),
18
+ "desirable_knowledge": str(content.get("desirable_knowledge", "")),
19
+ "prelude": row.get("prelude") or content.get("prelude", ""),
20
+ "objectives": row.get("objectives") or content.get("objectives", []),
21
+ "units": content.get("units", []),
22
+ "lab_experiments": content.get("lab_experiments", []),
23
+ }
 
 
 
 
 
backend/app/rendering.py DELETED
@@ -1,73 +0,0 @@
1
- import re
2
- from pathlib import Path
3
-
4
- from jinja2 import Environment, FileSystemLoader, select_autoescape
5
- from markupsafe import Markup, escape
6
-
7
- APP_DIR = Path(__file__).resolve().parent
8
-
9
-
10
- def _find_frontend_dir() -> Path:
11
- app_root = APP_DIR.parent
12
- candidates = (
13
- app_root / "frontend",
14
- app_root.parent / "frontend",
15
- app_root.parent.parent / "frontend",
16
- Path("/frontend"),
17
- )
18
- result = next((p for p in candidates if p.exists()), None)
19
- if result is None:
20
- raise RuntimeError("Frontend directory not found; cannot resolve image assets for PDF rendering")
21
- return result
22
-
23
-
24
- FRONTEND_DIR = _find_frontend_dir()
25
- templates = Environment(loader=FileSystemLoader(APP_DIR / "templates"), autoescape=select_autoescape(["html", "xml"]))
26
- URL_RE = re.compile(r"https?://[^\s<>()]+")
27
- YEAR_RE = re.compile(r"\d{4}")
28
-
29
-
30
- def linkify(value: str) -> Markup:
31
- text = str(value or "")
32
- parts = []
33
- last = 0
34
- for match in URL_RE.finditer(text):
35
- raw_url = match.group(0)
36
- url = raw_url.rstrip(".,;:)]}")
37
- trailing = raw_url[len(url) :]
38
- safe_url = escape(url)
39
- parts.append(escape(text[last : match.start()]))
40
- parts.append(Markup(f'<a class="resource-link" href="{safe_url}" target="_blank" rel="noopener noreferrer">{safe_url}</a>'))
41
- parts.append(escape(trailing))
42
- last = match.end()
43
- parts.append(escape(text[last:]))
44
- return Markup("".join(str(part) for part in parts))
45
-
46
-
47
- def batch_start_year(semester, curriculum_year: str) -> int | None:
48
- match = YEAR_RE.search(str(curriculum_year or ""))
49
- if not match:
50
- return None
51
- try:
52
- sem = int(semester)
53
- except (TypeError, ValueError):
54
- return None
55
- return int(match.group()) - ((sem - 1) // 2)
56
-
57
-
58
- def batch_label(semester, curriculum_year: str) -> str:
59
- start = batch_start_year(semester, curriculum_year)
60
- return f"({start}-{(start + 4) % 100:02d} BATCH)" if start else ""
61
-
62
-
63
- def course_code_for_year(value: str, semester, curriculum_year: str) -> str:
64
- code = str(value or "")
65
- start = batch_start_year(semester, curriculum_year)
66
- if not start or len(code) < 5 or code[:2] not in {"UE", "UZ"} or not code[2:4].isdigit():
67
- return code
68
- return f"{code[:2]}{start % 100:02d}{code[4:]}"
69
-
70
-
71
- templates.filters["linkify"] = linkify
72
- templates.filters["course_code_for_year"] = course_code_for_year
73
- templates.globals["batch_label"] = batch_label
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/routes/__init__.py DELETED
@@ -1 +0,0 @@
1
-
 
 
backend/app/routes/agent.py DELETED
@@ -1,225 +0,0 @@
1
- from fastapi import APIRouter, HTTPException
2
- from fastapi.responses import HTMLResponse
3
- from postgrest.exceptions import APIError
4
-
5
- from app.models.agent import AgentDocumentDraftPayload, AgentDraftPayload, AgentToolPayload
6
- from app.rendering import templates
7
- from app.services.agent_tools import call_tool, list_tool_schemas
8
- from app.services.curriculum import draft_record, load_agent_draft, load_document_draft, selected_curriculum_year, update_refined_fields
9
- from app.services.diffing import diff_course
10
- from app.services.errors import database_http_exception
11
- from app.supabase import supabase
12
-
13
- router = APIRouter()
14
-
15
-
16
- @router.post("/agent/diff")
17
- def compare_course(payload: dict):
18
- current = payload.get("current")
19
- proposed = payload.get("proposed")
20
- if not isinstance(current, dict) or not isinstance(proposed, dict):
21
- raise HTTPException(status_code=400, detail="current and proposed are required")
22
- return diff_course(current, proposed)
23
-
24
-
25
- @router.post("/agent/drafts")
26
- def create_agent_draft(payload: AgentDraftPayload):
27
- try:
28
- record = draft_record(payload.refined_id, payload.fields, payload.reason)
29
- result = supabase.table("agent_drafts").insert(record).execute()
30
- except LookupError as exc:
31
- raise HTTPException(status_code=404, detail=str(exc)) from exc
32
- except APIError as exc:
33
- raise database_http_exception(exc) from exc
34
- return {"message": "Draft created", "draft": result.data[0]}
35
-
36
-
37
- @router.get("/agent/drafts")
38
- def list_agent_drafts():
39
- try:
40
- rows = supabase.table("agent_drafts").select("*").order("id", desc=True).limit(100).execute().data
41
- except APIError as exc:
42
- raise database_http_exception(exc) from exc
43
- return {
44
- "drafts": [
45
- {
46
- "id": row["id"],
47
- "refined_id": row["refined_id"],
48
- "status": row.get("status") or "",
49
- "course_title": (row.get("proposed_json") or {}).get("course_title") or "",
50
- "course_code": (row.get("proposed_json") or {}).get("course_code") or "",
51
- "change_reason": row.get("change_reason") or "",
52
- "created_at": row.get("created_at") or "",
53
- }
54
- for row in rows
55
- ]
56
- }
57
-
58
-
59
- @router.get("/agent/drafts/{draft_id}")
60
- def get_agent_draft(draft_id: int):
61
- try:
62
- return {"draft": load_agent_draft(draft_id)}
63
- except LookupError as exc:
64
- raise HTTPException(status_code=404, detail=str(exc)) from exc
65
- except APIError as exc:
66
- raise database_http_exception(exc) from exc
67
-
68
-
69
- @router.get("/agent/drafts/{draft_id}/preview")
70
- def preview_agent_draft(draft_id: int):
71
- try:
72
- draft = load_agent_draft(draft_id)
73
- except LookupError as exc:
74
- raise HTTPException(status_code=404, detail=str(exc)) from exc
75
- except APIError as exc:
76
- raise database_http_exception(exc) from exc
77
- html = templates.get_template("jinja_sample.html").render(course=draft["proposed_json"], curriculum_year=selected_curriculum_year(), asset_root="/")
78
- return HTMLResponse(html, headers={"Cache-Control": "no-store"})
79
-
80
-
81
- @router.post("/agent/drafts/{draft_id}/apply")
82
- def apply_agent_draft(draft_id: int):
83
- try:
84
- draft = load_agent_draft(draft_id)
85
- except LookupError as exc:
86
- raise HTTPException(status_code=404, detail=str(exc)) from exc
87
- except APIError as exc:
88
- raise database_http_exception(exc) from exc
89
-
90
- summary = draft.get("diff_summary") or {}
91
- if draft.get("status") != "proposed":
92
- raise HTTPException(status_code=400, detail="Only proposed drafts can be applied")
93
- if summary.get("protected_changes"):
94
- raise HTTPException(status_code=400, detail="Draft changes deterministic fields")
95
-
96
- try:
97
- supabase.table("course_revision_history").insert(
98
- {
99
- "refined_id": draft["refined_id"],
100
- "agent_draft_id": draft_id,
101
- "previous_json": draft["base_refined_json"],
102
- "next_json": draft["proposed_json"],
103
- "json_patch": draft["json_patch"],
104
- "diff_summary": summary,
105
- "change_reason": draft.get("change_reason") or "",
106
- }
107
- ).execute()
108
- data = update_refined_fields(int(draft["refined_id"]), draft["proposed_json"])
109
- supabase.table("agent_drafts").update({"status": "applied"}).eq("id", draft_id).execute()
110
- except APIError as exc:
111
- raise database_http_exception(exc) from exc
112
- return {"message": "Draft applied", "data": data}
113
-
114
-
115
- @router.post("/agent/document-drafts")
116
- def create_agent_document_draft(payload: AgentDocumentDraftPayload):
117
- try:
118
- records = [draft_record(course.refined_id, course.fields, payload.reason) for course in payload.courses]
119
- except LookupError as exc:
120
- raise HTTPException(status_code=404, detail=str(exc)) from exc
121
- except APIError as exc:
122
- raise database_http_exception(exc) from exc
123
-
124
- summaries = [record["diff_summary"] for record in records]
125
- document_summary = {
126
- "courses_changed": len(records),
127
- "courses_with_removed_topics": sum(1 for summary in summaries if summary.get("topics_removed")),
128
- "courses_with_protected_changes": sum(1 for summary in summaries if summary.get("protected_changes")),
129
- "max_syllabus_change_percent": max((summary.get("syllabus_change_percent") or 0 for summary in summaries), default=0),
130
- }
131
- status = "blocked" if document_summary["courses_with_protected_changes"] else "proposed"
132
-
133
- try:
134
- document = (
135
- supabase.table("agent_document_drafts")
136
- .insert(
137
- {
138
- "curriculum_version_id": payload.curriculum_version_id,
139
- "uploaded_document_id": payload.uploaded_document_id.strip(),
140
- "diff_summary": document_summary,
141
- "change_reason": payload.reason.strip(),
142
- "status": status,
143
- }
144
- )
145
- .execute()
146
- .data[0]
147
- )
148
- for record in records:
149
- record["document_draft_id"] = document["id"]
150
- drafts = supabase.table("agent_drafts").insert(records).execute().data
151
- except APIError as exc:
152
- raise database_http_exception(exc) from exc
153
- return {"message": "Document draft created", "document_draft": document, "drafts": drafts}
154
-
155
-
156
- @router.get("/agent/document-drafts")
157
- def list_agent_document_drafts():
158
- try:
159
- rows = supabase.table("agent_document_drafts").select("*").order("id", desc=True).limit(100).execute().data
160
- except APIError as exc:
161
- raise database_http_exception(exc) from exc
162
- return {
163
- "document_drafts": [
164
- {
165
- "id": row["id"],
166
- "status": row.get("status") or "",
167
- "uploaded_document_id": row.get("uploaded_document_id") or "",
168
- "change_reason": row.get("change_reason") or "",
169
- "created_at": row.get("created_at") or "",
170
- }
171
- for row in rows
172
- ]
173
- }
174
-
175
-
176
- @router.get("/agent/document-drafts/{document_draft_id}")
177
- def get_agent_document_draft(document_draft_id: int):
178
- try:
179
- return load_document_draft(document_draft_id)
180
- except LookupError as exc:
181
- raise HTTPException(status_code=404, detail=str(exc)) from exc
182
- except APIError as exc:
183
- raise database_http_exception(exc) from exc
184
-
185
-
186
- @router.get("/agent/document-drafts/{document_draft_id}/preview")
187
- def preview_agent_document_draft(document_draft_id: int):
188
- try:
189
- drafts = load_document_draft(document_draft_id)["drafts"]
190
- except LookupError as exc:
191
- raise HTTPException(status_code=404, detail=str(exc)) from exc
192
- except APIError as exc:
193
- raise database_http_exception(exc) from exc
194
- if not drafts:
195
- raise HTTPException(status_code=404, detail="Document draft not found")
196
-
197
- courses = sorted(
198
- (draft["proposed_json"] for draft in drafts),
199
- key=lambda course: (int(course.get("semester") or 0), str(course.get("course_code") or ""), str(course.get("course_title") or "")),
200
- )
201
- html = templates.get_template("jinja_sample.html").render(
202
- courses=courses,
203
- semester="",
204
- curriculum_year=selected_curriculum_year(),
205
- asset_root="/",
206
- show_summaries=True,
207
- )
208
- return HTMLResponse(html, headers={"Cache-Control": "no-store"})
209
-
210
-
211
- @router.get("/agent/tools")
212
- def get_agent_tools():
213
- return {"tools": list_tool_schemas()}
214
-
215
-
216
- @router.post("/agent/tools/{tool_name}")
217
- def run_agent_tool(tool_name: str, payload: AgentToolPayload):
218
- try:
219
- return {"name": tool_name, "result": call_tool(tool_name, payload.arguments)}
220
- except LookupError as exc:
221
- raise HTTPException(status_code=404, detail=str(exc)) from exc
222
- except ValueError as exc:
223
- raise HTTPException(status_code=400, detail=str(exc)) from exc
224
- except APIError as exc:
225
- raise database_http_exception(exc) from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/routes/chat.py DELETED
@@ -1,261 +0,0 @@
1
- import json
2
- import logging
3
-
4
- import sentry_sdk
5
- from fastapi import APIRouter, File, HTTPException, UploadFile
6
- from fastapi.responses import StreamingResponse
7
- from postgrest.exceptions import APIError
8
-
9
- from app.models.chat import ChatMessagePayload, ChatSessionPayload, ChatSessionTitlePayload
10
- from app.services.agent_tools import call_tool, list_tool_schemas
11
- from app.services.attachments import extract_text
12
- from app.services.curriculum import load_document_draft, refined_course
13
- from app.services.errors import database_http_exception
14
- from app.services.openrouter import OpenRouterError, stream_chat
15
- from app.supabase import first_row, supabase
16
-
17
- router = APIRouter()
18
- MAX_ATTACHMENT_CONTEXT = 12000
19
- logger = logging.getLogger(__name__)
20
-
21
-
22
- def load_chat_session(session_id: int) -> dict:
23
- try:
24
- row = first_row(supabase.table("chat_sessions").select("*").eq("id", session_id))
25
- except APIError as exc:
26
- raise database_http_exception(exc) from exc
27
- if not row:
28
- raise HTTPException(status_code=404, detail="Chat session not found")
29
- return row
30
-
31
-
32
- def chat_messages(session_id: int) -> list[dict]:
33
- rows = supabase.table("chat_messages").select("*").eq("session_id", session_id).order("id").execute().data
34
- return rows[-24:]
35
-
36
-
37
- def insert_chat_message(session_id: int, role: str, content: str, metadata: dict | None = None) -> dict:
38
- return (
39
- supabase.table("chat_messages")
40
- .insert({"session_id": session_id, "role": role, "content": content, "metadata": metadata or {}})
41
- .execute()
42
- .data[0]
43
- )
44
-
45
-
46
- def update_attachment_message(session_id: int, attachment_ids: list[int], message_id: int) -> None:
47
- if attachment_ids:
48
- supabase.table("chat_attachments").update({"message_id": message_id}).eq("session_id", session_id).in_("id", attachment_ids).execute()
49
-
50
-
51
- def attachment_ids(metadata: dict | None) -> list[int]:
52
- ids = []
53
- for item in (metadata or {}).get("attachments") or []:
54
- if isinstance(item, dict) and item.get("id"):
55
- ids.append(int(item["id"]))
56
- return ids
57
-
58
-
59
- def attachment_context(session_id: int, metadata: dict | None) -> str:
60
- ids = attachment_ids(metadata)
61
- if not ids:
62
- return ""
63
- rows = supabase.table("chat_attachments").select("filename,status,error,extracted_text").eq("session_id", session_id).in_("id", ids).execute().data
64
- blocks = []
65
- for row in rows:
66
- name = row.get("filename") or "attachment"
67
- status = row.get("status") or ""
68
- text = str(row.get("extracted_text") or "").strip()
69
- if text:
70
- blocks.append(f"Attachment: {name}\n{text[:MAX_ATTACHMENT_CONTEXT]}")
71
- else:
72
- error = row.get("error") or "No extracted text"
73
- blocks.append(f"Attachment: {name}\nStatus: {status}. {error}")
74
- return "\n\n".join(blocks)
75
-
76
-
77
- def stable_context(value: dict) -> str:
78
- return json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2)[:12000]
79
-
80
-
81
- def chat_system_prompt(session: dict) -> str:
82
- session_id = session.get("id")
83
- context = ""
84
- if session.get("refined_id"):
85
- course = refined_course(int(session["refined_id"]))
86
- context = stable_context({"active_session_id": session_id, "active_refined_id": session["refined_id"], "active_course": course})
87
- elif session.get("document_draft_id"):
88
- context = stable_context({"active_session_id": session_id, **load_document_draft(int(session["document_draft_id"]))})
89
- else:
90
- context = stable_context({"active_session_id": session_id})
91
- return f"""You are the PESU Curriculum Automation live editor assistant.
92
- Be concise, practical, and specific to the active curriculum data.
93
- Always respond by calling a tool — never state limitations or guess. The available tools handle course data, fetching URLs, generating reports, and creating drafts.
94
- Read source documents with get_attachment_text, then call create_report to save generated content as a chat attachment.
95
- 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.
96
- 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.
97
- When the user asks what changed, call diff_course_json or read the relevant draft before answering.
98
- For broad document requests, use get_curriculum_json to inspect the whole syllabus before proposing edits.
99
- To fetch a public URL, call fetch_url and use the returned text.
100
- Never apply a draft, never claim a draft was applied, and never claim the refined database was changed.
101
- After creating a draft, tell the user to review the diff in the Review panel before applying it.
102
- After calling a tool, summarize the result for the user in natural language. Do not call another tool — stop and respond to the user.
103
- Do not change deterministic fields such as program, hours, credits, or course type.
104
-
105
- Active context:
106
- {context or "No active course or document draft is selected."}"""
107
-
108
-
109
- def model_messages(session_id: int, rows: list[dict]) -> list[dict]:
110
- messages = []
111
- for row in rows:
112
- if row.get("role") not in {"user", "assistant"}:
113
- continue
114
- content = str(row.get("content") or "").strip()
115
- context = attachment_context(session_id, row.get("metadata")) if row.get("role") == "user" else ""
116
- if context:
117
- content = f"{content}\n\n{context}".strip()
118
- if content:
119
- messages.append({"role": row["role"], "content": content})
120
- return messages
121
-
122
-
123
- def sse(event: str, data: dict) -> str:
124
- return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
125
-
126
-
127
- @router.post("/chat/sessions")
128
- def create_chat_session(payload: ChatSessionPayload):
129
- record = {"refined_id": payload.refined_id, "document_draft_id": payload.document_draft_id, "title": payload.title.strip()}
130
- result = supabase.table("chat_sessions").insert(record).execute()
131
- return {"session": result.data[0]}
132
-
133
-
134
- @router.get("/chat/sessions")
135
- def list_chat_sessions(refined_id: int | None = None, document_draft_id: int | None = None):
136
- query = supabase.table("chat_sessions").select("*").eq("status", "active")
137
- if refined_id is not None:
138
- query = query.eq("refined_id", refined_id)
139
- if document_draft_id is not None:
140
- query = query.eq("document_draft_id", document_draft_id)
141
- rows = query.order("id", desc=True).limit(50).execute().data
142
- return {"sessions": rows}
143
-
144
-
145
- @router.get("/chat/sessions/{session_id}/messages")
146
- def get_chat_messages(session_id: int):
147
- load_chat_session(session_id)
148
- return {"messages": chat_messages(session_id)}
149
-
150
-
151
- @router.delete("/chat/sessions/{session_id}")
152
- def clear_chat_session(session_id: int):
153
- load_chat_session(session_id)
154
- supabase.table("chat_sessions").delete().eq("id", session_id).execute()
155
- return {"message": "Chat deleted"}
156
-
157
-
158
- @router.patch("/chat/sessions/{session_id}")
159
- def rename_chat_session(session_id: int, payload: ChatSessionTitlePayload):
160
- load_chat_session(session_id)
161
- row = supabase.table("chat_sessions").update({"title": payload.title}).eq("id", session_id).execute().data[0]
162
- return {"message": "Chat renamed", "session": row}
163
-
164
-
165
- @router.post("/chat/sessions/{session_id}/messages")
166
- def create_chat_message(session_id: int, payload: ChatMessagePayload):
167
- if not payload.content and not payload.metadata:
168
- raise HTTPException(status_code=400, detail="Message content is required")
169
- session = load_chat_session(session_id)
170
- user_message = insert_chat_message(session_id, "user", payload.content, payload.metadata)
171
- update_attachment_message(session_id, attachment_ids(payload.metadata), user_message["id"])
172
-
173
- def stream():
174
- answer = []
175
- tool_results = []
176
-
177
- def remember_tool_result(name: str, result: dict) -> None:
178
- tool_results.append({"name": name, "result": result})
179
-
180
- def flush_tool_results():
181
- while tool_results:
182
- item = tool_results.pop(0)
183
- draft = (item["result"] or {}).get("draft")
184
- document_draft = (item["result"] or {}).get("document_draft")
185
- if item["name"] == "create_course_draft" and draft:
186
- yield sse("draft", {"draft": draft})
187
- if item["name"] == "create_document_draft" and document_draft:
188
- yield sse("document_draft", {"document_draft": document_draft})
189
-
190
- try:
191
- yield sse("status", {"message": "Message saved"})
192
- rows = chat_messages(session_id)
193
- yield sse("status", {"message": "Loading context"})
194
- system = chat_system_prompt(session)
195
- yield sse("status", {"message": "Streaming response"})
196
- for item in stream_chat(system, model_messages(session_id, rows), list_tool_schemas(), call_tool, remember_tool_result):
197
- if isinstance(item, dict) and "$status" in item:
198
- yield sse("status", {"message": item["$status"]})
199
- continue
200
- yield from flush_tool_results()
201
- answer.append(item)
202
- yield sse("token", {"text": item})
203
- yield from flush_tool_results()
204
- message = insert_chat_message(session_id, "assistant", "".join(answer).strip())
205
- yield sse("done", {"message_id": message["id"]})
206
- except OpenRouterError as exc:
207
- yield from flush_tool_results()
208
- logger.warning(
209
- "Chat model request failed for session %s: status=%s detail=%s",
210
- session_id,
211
- exc.status_code,
212
- exc.provider_message[:300],
213
- )
214
- err = exc.message
215
- if exc.provider_message:
216
- err = f"{err} ({exc.provider_message[:200]})"
217
- yield sse("error", {"message": err})
218
- except Exception as exc:
219
- yield from flush_tool_results()
220
- logger.exception("Chat stream failed for session %s", session_id)
221
- sentry_sdk.capture_exception(exc)
222
- yield sse("error", {"message": "An internal error occurred. Please try again later."})
223
-
224
- return StreamingResponse(stream(), media_type="text/event-stream", headers={"Cache-Control": "no-store"})
225
-
226
-
227
- @router.post("/chat/sessions/{session_id}/attachments")
228
- async def upload_chat_attachments(session_id: int, files: list[UploadFile] = File(...)):
229
- load_chat_session(session_id)
230
- attachments = []
231
- for file in files:
232
- data = await file.read()
233
- text, status, error = extract_text(file.filename or "attachment", file.content_type or "", data)
234
- row = (
235
- supabase.table("chat_attachments")
236
- .insert(
237
- {
238
- "session_id": session_id,
239
- "filename": file.filename or "attachment",
240
- "content_type": file.content_type or "",
241
- "size_bytes": len(data),
242
- "extracted_text": text,
243
- "status": status,
244
- "error": error,
245
- }
246
- )
247
- .execute()
248
- .data[0]
249
- )
250
- attachments.append(
251
- {
252
- "id": row["id"],
253
- "name": row["filename"],
254
- "type": row["content_type"],
255
- "size": row["size_bytes"],
256
- "status": row["status"],
257
- "error": row["error"],
258
- "extracted_chars": len(row.get("extracted_text") or ""),
259
- }
260
- )
261
- return {"attachments": attachments}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/routes/courses.py DELETED
@@ -1,33 +0,0 @@
1
- from fastapi import APIRouter, HTTPException
2
- from postgrest.exceptions import APIError
3
-
4
- from app.preview import build_course_preview
5
- from app.services.curriculum import attach_submissions
6
- from app.services.errors import database_http_exception
7
- from app.supabase import first_row, supabase
8
-
9
- router = APIRouter()
10
-
11
-
12
- @router.get("/courses")
13
- def list_courses():
14
- try:
15
- rows = supabase.table("refined_submissions").select("*").neq("status", "archived").execute().data
16
- rows = attach_submissions(rows)
17
- except APIError as exc:
18
- raise database_http_exception(exc) from exc
19
- courses = [build_course_preview(row) | {"id": row["id"]} for row in rows]
20
- courses.sort(key=lambda item: (int(item.get("semester") or 0), item.get("course_code") or "", item.get("course_title") or ""))
21
- return {"courses": courses}
22
-
23
-
24
- @router.delete("/courses/{refined_id}")
25
- def delete_course(refined_id: int):
26
- try:
27
- row = first_row(supabase.table("refined_submissions").select("id").eq("id", refined_id).neq("status", "archived"))
28
- if not row:
29
- raise HTTPException(status_code=404, detail="Course not found")
30
- result = supabase.table("refined_submissions").update({"status": "archived"}).eq("id", refined_id).execute()
31
- except APIError as exc:
32
- raise database_http_exception(exc) from exc
33
- return {"message": "Course removed", "course": result.data[0] if result.data else None}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/routes/health.py DELETED
@@ -1,18 +0,0 @@
1
- from fastapi import APIRouter, HTTPException
2
- from postgrest.exceptions import APIError
3
-
4
- from app.services.errors import database_http_exception
5
- from app.services.schema import schema_status
6
-
7
- router = APIRouter()
8
-
9
-
10
- @router.get("/health/schema")
11
- def check_schema():
12
- try:
13
- status = schema_status()
14
- except APIError as exc:
15
- raise database_http_exception(exc) from exc
16
- if not status["ok"]:
17
- raise HTTPException(status_code=503, detail=status)
18
- return status
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/routes/preview.py DELETED
@@ -1,76 +0,0 @@
1
- from fastapi import APIRouter, HTTPException, Query
2
- from fastapi.responses import HTMLResponse, Response
3
- from weasyprint import HTML
4
-
5
- from app.preview import build_course_preview
6
- from app.rendering import FRONTEND_DIR, templates
7
- from app.services.curriculum import attach_submissions, ordered_courses, selected_curriculum_year
8
- from app.supabase import first_row, supabase
9
-
10
- router = APIRouter()
11
-
12
-
13
- @router.get("/preview/semester/{sem}/courses")
14
- def list_courses(sem: int):
15
- result = supabase.table("refined_submissions").select("id").neq("status", "archived").eq("semester", sem).order("id").execute()
16
- return {"course_ids": [row["id"] for row in result.data]}
17
-
18
-
19
- @router.get("/preview/courses")
20
- def list_all_courses():
21
- result = supabase.table("refined_submissions").select("id,semester,course_code").neq("status", "archived").execute()
22
- 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)))
23
- return {"course_ids": [row["id"] for row in rows]}
24
-
25
-
26
- @router.get("/preview/course/{refined_id}")
27
- def preview_course(refined_id: int, curriculum_year: str = Query("")):
28
- row = first_row(supabase.table("refined_submissions").select("*").eq("id", refined_id))
29
- if not row:
30
- raise HTTPException(status_code=404, detail="Refined submission not found")
31
- row = attach_submissions([row])[0]
32
- html = templates.get_template("jinja_sample.html").render(
33
- course=build_course_preview(row),
34
- curriculum_year=selected_curriculum_year(curriculum_year),
35
- asset_root="/",
36
- )
37
- return HTMLResponse(html, headers={"Cache-Control": "no-store"})
38
-
39
-
40
- @router.get("/preview/pdf")
41
- def download_all_pdf(download: bool = Query(False), curriculum_year: str = Query("")):
42
- result = supabase.table("refined_submissions").select("*").neq("status", "archived").execute()
43
- courses = ordered_courses(result.data)
44
- html = templates.get_template("jinja_sample.html").render(
45
- courses=courses,
46
- semester="",
47
- curriculum_year=selected_curriculum_year(curriculum_year),
48
- asset_root="",
49
- show_summaries=True,
50
- )
51
- pdf = HTML(string=html, base_url=str(FRONTEND_DIR)).write_pdf()
52
- return pdf_response(pdf, "curriculum-preview.pdf", download)
53
-
54
-
55
- @router.get("/preview/semester/{sem}/pdf")
56
- def download_pdf(sem: int, download: bool = Query(False), curriculum_year: str = Query("")):
57
- result = supabase.table("refined_submissions").select("*").neq("status", "archived").eq("semester", sem).order("id").execute()
58
- courses = ordered_courses(result.data)
59
- html = templates.get_template("jinja_sample.html").render(
60
- courses=courses,
61
- semester=sem,
62
- curriculum_year=selected_curriculum_year(curriculum_year),
63
- asset_root="",
64
- show_summaries=True,
65
- )
66
- pdf = HTML(string=html, base_url=str(FRONTEND_DIR)).write_pdf()
67
- return pdf_response(pdf, f"semester-{sem}.pdf", download)
68
-
69
-
70
- def pdf_response(pdf: bytes, filename: str, download: bool) -> Response:
71
- disposition = "attachment" if download else "inline"
72
- return Response(
73
- content=pdf,
74
- media_type="application/pdf",
75
- headers={"Content-Disposition": f'{disposition}; filename="{filename}"', "Cache-Control": "no-store"},
76
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/routes/refined.py DELETED
@@ -1,24 +0,0 @@
1
- from fastapi import APIRouter, HTTPException
2
-
3
- from app.preview import build_course_preview
4
- from app.services.curriculum import attach_submissions, update_refined_fields
5
- from app.supabase import first_row, supabase
6
-
7
- router = APIRouter()
8
-
9
-
10
- @router.get("/refined/{refined_id}")
11
- def get_refined(refined_id: int):
12
- row = first_row(supabase.table("refined_submissions").select("*").eq("id", refined_id))
13
- if not row:
14
- raise HTTPException(status_code=404, detail="Refined submission not found")
15
- row = attach_submissions([row])[0]
16
- return {"id": refined_id, "fields": build_course_preview(row)}
17
-
18
-
19
- @router.patch("/refined/{refined_id}")
20
- def update_refined(refined_id: int, payload: dict):
21
- fields = payload.get("fields")
22
- if not isinstance(fields, dict):
23
- raise HTTPException(status_code=400, detail="fields is required")
24
- return {"message": "Updated", "data": update_refined_fields(refined_id, fields)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/routes/submissions.py DELETED
@@ -1,41 +0,0 @@
1
- import sentry_sdk
2
- from fastapi import APIRouter, BackgroundTasks, HTTPException
3
- from postgrest.exceptions import APIError
4
-
5
- from app.models.submission import CourseSubmission
6
- from app.services.errors import database_http_exception
7
- from app.services.refinement import refine
8
- from app.supabase import supabase
9
-
10
- router = APIRouter()
11
-
12
-
13
- def refine_later(submission_id: int) -> None:
14
- try:
15
- refine(submission_id)
16
- except Exception as exc:
17
- sentry_sdk.capture_exception(exc)
18
- supabase.table("submissions").update({"status": "refine_failed"}).eq("id", submission_id).execute()
19
-
20
-
21
- @router.post("/submissions")
22
- def receive(data: CourseSubmission, background_tasks: BackgroundTasks):
23
- payload = data.model_dump()
24
- payload["status"] = "pending"
25
- try:
26
- result = supabase.table("submissions").insert(payload).execute()
27
- except APIError as exc:
28
- raise database_http_exception(exc) from exc
29
- submission = result.data[0]
30
- background_tasks.add_task(refine_later, submission["id"])
31
- return {"message": "Submission Received!", "submission": submission}
32
-
33
-
34
- @router.post("/submissions/{id}/refine")
35
- def refine_submission(id: int):
36
- try:
37
- return {"message": "Refined", "data": refine(id)}
38
- except LookupError as exc:
39
- raise HTTPException(status_code=404, detail=str(exc)) from exc
40
- except APIError as exc:
41
- raise database_http_exception(exc) from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/routes/versions.py DELETED
@@ -1,198 +0,0 @@
1
- from fastapi import APIRouter, HTTPException
2
- from fastapi.responses import HTMLResponse
3
- from postgrest.exceptions import APIError
4
-
5
- from app.preview import build_course_preview
6
- from app.rendering import templates
7
- from app.services.curriculum import attach_submissions, selected_curriculum_year, update_refined_fields
8
- from app.services.diffing import diff_course
9
- from app.services.errors import database_http_exception
10
- from app.supabase import first_row, supabase
11
-
12
- router = APIRouter()
13
-
14
-
15
- def _version(version_id: int) -> dict:
16
- row = first_row(supabase.table("curriculum_versions").select("*").eq("id", version_id))
17
- if not row:
18
- raise HTTPException(status_code=404, detail="Version not found")
19
- return row
20
-
21
-
22
- def _snapshot(version_id: int, refined_id: int) -> dict:
23
- row = first_row(
24
- supabase.table("finalized_submissions")
25
- .select("*")
26
- .eq("curriculum_version_id", version_id)
27
- .eq("refined_id", refined_id)
28
- )
29
- if not row:
30
- raise HTTPException(status_code=404, detail="Version course not found")
31
- return row
32
-
33
-
34
- def _course_summary(row: dict) -> dict:
35
- course = row.get("course_json") or {}
36
- return {
37
- "id": row["id"],
38
- "refined_id": row["refined_id"],
39
- "semester": course.get("semester") or "",
40
- "course_code": course.get("course_code") or "",
41
- "course_title": course.get("course_title") or "",
42
- }
43
-
44
-
45
- @router.get("/versions")
46
- def list_versions():
47
- try:
48
- rows = supabase.table("curriculum_versions").select("*").order("id", desc=True).execute().data
49
- except APIError as exc:
50
- raise database_http_exception(exc) from exc
51
- return {"versions": rows}
52
-
53
-
54
- @router.post("/versions")
55
- def create_version(payload: dict):
56
- name = str(payload.get("name") or "").strip()
57
- if not name:
58
- raise HTTPException(status_code=400, detail="Version name is required")
59
-
60
- try:
61
- rows = supabase.table("refined_submissions").select("*").execute().data
62
- rows = attach_submissions(rows)
63
- courses = [{"refined_id": row["id"], "course_json": build_course_preview(row)} for row in rows]
64
- version = (
65
- supabase.table("curriculum_versions")
66
- .insert(
67
- {
68
- "name": name,
69
- "program": str(payload.get("program") or (courses[0]["course_json"].get("program") if courses else "") or "").strip(),
70
- "academic_year": selected_curriculum_year(payload.get("academic_year")),
71
- "status": str(payload.get("status") or "draft").strip(),
72
- }
73
- )
74
- .execute()
75
- .data[0]
76
- )
77
- if courses:
78
- records = [{**course, "curriculum_version_id": version["id"]} for course in courses]
79
- supabase.table("finalized_submissions").insert(records).execute()
80
- except APIError as exc:
81
- raise database_http_exception(exc) from exc
82
- return {"version": version, "courses": len(courses)}
83
-
84
-
85
- @router.post("/versions/{version_id}/restore")
86
- def restore_version(version_id: int):
87
- try:
88
- version = _version(version_id)
89
- rows = (
90
- supabase.table("finalized_submissions")
91
- .select("*")
92
- .eq("curriculum_version_id", version_id)
93
- .execute()
94
- .data
95
- )
96
- version_refined_ids = [row["refined_id"] for row in rows]
97
- current_rows = supabase.table("refined_submissions").select("*").execute().data
98
- current = {row["id"]: build_course_preview(row) for row in attach_submissions(current_rows)}
99
- current_status = {row["id"]: row.get("status") for row in current_rows}
100
- extra_ids = [row["id"] for row in current_rows if row["id"] not in version_refined_ids]
101
- history = []
102
- restored = 0
103
- for row in rows:
104
- refined_id = int(row["refined_id"])
105
- previous = current.get(refined_id)
106
- restored_course = row["course_json"]
107
- if not previous:
108
- continue
109
- if previous != restored_course:
110
- summary = diff_course(previous, restored_course)
111
- history.append(
112
- {
113
- "refined_id": refined_id,
114
- "source": "version_restore",
115
- "previous_json": previous,
116
- "next_json": restored_course,
117
- "json_patch": summary.pop("json_patch"),
118
- "diff_summary": summary,
119
- "change_reason": f"Restore version: {version['name']}",
120
- }
121
- )
122
- if previous != restored_course or current_status.get(refined_id) != "refined":
123
- update_refined_fields(refined_id, {**restored_course, "status": "refined"})
124
- restored += 1
125
- if history:
126
- supabase.table("course_revision_history").insert(history).execute()
127
- if extra_ids:
128
- supabase.table("refined_submissions").update({"status": "archived"}).in_("id", extra_ids).execute()
129
- except APIError as exc:
130
- raise database_http_exception(exc) from exc
131
- return {"message": "Version restored", "version": version, "courses_restored": restored, "courses_archived": len(extra_ids)}
132
-
133
-
134
- @router.get("/versions/{version_id}")
135
- def get_version(version_id: int):
136
- try:
137
- version = _version(version_id)
138
- rows = (
139
- supabase.table("finalized_submissions")
140
- .select("*")
141
- .eq("curriculum_version_id", version_id)
142
- .execute()
143
- .data
144
- )
145
- except APIError as exc:
146
- raise database_http_exception(exc) from exc
147
- courses = [_course_summary(row) for row in rows]
148
- courses.sort(key=lambda course: (int(course.get("semester") or 0), str(course.get("course_code") or ""), str(course.get("course_title") or "")))
149
- return {"version": version, "courses": courses}
150
-
151
-
152
- @router.get("/versions/{version_id}/courses/{refined_id}")
153
- def get_version_course(version_id: int, refined_id: int):
154
- try:
155
- version = _version(version_id)
156
- snapshot = _snapshot(version_id, refined_id)
157
- except APIError as exc:
158
- raise database_http_exception(exc) from exc
159
- return {"version": version, "refined_id": refined_id, "fields": snapshot["course_json"]}
160
-
161
-
162
- @router.get("/versions/{version_id}/courses/{refined_id}/preview")
163
- def preview_version_course(version_id: int, refined_id: int):
164
- try:
165
- snapshot = _snapshot(version_id, refined_id)
166
- except APIError as exc:
167
- raise database_http_exception(exc) from exc
168
- version = _version(version_id)
169
- html = templates.get_template("jinja_sample.html").render(course=snapshot["course_json"], curriculum_year=selected_curriculum_year(version.get("academic_year")), asset_root="/")
170
- return HTMLResponse(html, headers={"Cache-Control": "no-store"})
171
-
172
-
173
- @router.get("/versions/{version_id}/preview")
174
- def preview_version(version_id: int):
175
- try:
176
- version = _version(version_id)
177
- rows = (
178
- supabase.table("finalized_submissions")
179
- .select("*")
180
- .eq("curriculum_version_id", version_id)
181
- .order("refined_id")
182
- .execute()
183
- .data
184
- )
185
- except APIError as exc:
186
- raise database_http_exception(exc) from exc
187
- courses = sorted(
188
- (row["course_json"] for row in rows),
189
- key=lambda course: (int(course.get("semester") or 0), str(course.get("course_code") or ""), str(course.get("course_title") or "")),
190
- )
191
- html = templates.get_template("jinja_sample.html").render(
192
- courses=courses,
193
- semester="",
194
- curriculum_year=selected_curriculum_year(version.get("academic_year")),
195
- asset_root="/",
196
- show_summaries=True,
197
- )
198
- return HTMLResponse(html, headers={"Cache-Control": "no-store"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/services/agent_tools.py DELETED
@@ -1,319 +0,0 @@
1
- from collections.abc import Callable
2
- from dataclasses import dataclass
3
-
4
- import httpx
5
-
6
- from app.services.curriculum import draft_record, load_agent_draft, load_document_draft, ordered_courses, refined_course
7
- from app.services.diffing import diff_course
8
- from app.supabase import supabase
9
-
10
- ToolHandler = Callable[[dict], dict]
11
-
12
-
13
- @dataclass(frozen=True)
14
- class AgentTool:
15
- name: str
16
- description: str
17
- parameters: dict
18
- handler: ToolHandler
19
-
20
- def schema(self) -> dict:
21
- return {
22
- "type": "function",
23
- "function": {
24
- "name": self.name,
25
- "description": self.description,
26
- "parameters": self.parameters,
27
- },
28
- }
29
-
30
-
31
- def list_tool_schemas() -> list[dict]:
32
- return [tool.schema() for tool in TOOLS.values()]
33
-
34
-
35
- def call_tool(name: str, arguments: dict | None = None) -> dict:
36
- tool = TOOLS.get(name)
37
- if not tool:
38
- raise LookupError("Agent tool not found")
39
- return tool.handler(arguments or {})
40
-
41
-
42
- def _require_int(arguments: dict, key: str) -> int:
43
- value = arguments.get(key)
44
- if value is None:
45
- raise ValueError(f"{key} is required")
46
- return int(value)
47
-
48
-
49
- def _require_dict(arguments: dict, key: str) -> dict:
50
- value = arguments.get(key)
51
- if not isinstance(value, dict):
52
- raise ValueError(f"{key} must be an object")
53
- return value
54
-
55
-
56
- def _get_current_course(arguments: dict) -> dict:
57
- return {"course": refined_course(_require_int(arguments, "refined_id"))}
58
-
59
-
60
- def _diff_course_json(arguments: dict) -> dict:
61
- return diff_course(_require_dict(arguments, "current"), _require_dict(arguments, "proposed"))
62
-
63
-
64
- def _create_course_draft(arguments: dict) -> dict:
65
- record = draft_record(
66
- _require_int(arguments, "refined_id"),
67
- _require_dict(arguments, "fields"),
68
- str(arguments.get("reason") or ""),
69
- )
70
- draft = supabase.table("agent_drafts").insert(record).execute().data[0]
71
- return {"draft": draft}
72
-
73
-
74
- def _get_curriculum_json(arguments: dict) -> dict:
75
- query = supabase.table("refined_submissions").select("*").neq("status", "archived")
76
- if arguments.get("semester") is not None:
77
- query = query.eq("semester", int(arguments["semester"]))
78
- return {"courses": ordered_courses(query.execute().data)}
79
-
80
-
81
- def _create_document_draft(arguments: dict) -> dict:
82
- courses = arguments.get("courses")
83
- if not isinstance(courses, list) or not courses:
84
- raise ValueError("courses must be a non-empty array")
85
-
86
- records = []
87
- for course in courses:
88
- if not isinstance(course, dict):
89
- raise ValueError("each course must be an object")
90
- records.append(draft_record(int(course.get("refined_id")), _require_dict(course, "fields"), str(arguments.get("reason") or "")))
91
-
92
- summaries = [record["diff_summary"] for record in records]
93
- document_summary = {
94
- "courses_changed": len(records),
95
- "courses_with_removed_topics": sum(1 for summary in summaries if summary.get("topics_removed")),
96
- "courses_with_protected_changes": sum(1 for summary in summaries if summary.get("protected_changes")),
97
- "max_syllabus_change_percent": max((summary.get("syllabus_change_percent") or 0 for summary in summaries), default=0),
98
- }
99
- document = (
100
- supabase.table("agent_document_drafts")
101
- .insert(
102
- {
103
- "curriculum_version_id": arguments.get("curriculum_version_id"),
104
- "uploaded_document_id": str(arguments.get("uploaded_document_id") or "").strip(),
105
- "diff_summary": document_summary,
106
- "change_reason": str(arguments.get("reason") or "").strip(),
107
- "status": "blocked" if document_summary["courses_with_protected_changes"] else "proposed",
108
- }
109
- )
110
- .execute()
111
- .data[0]
112
- )
113
- for record in records:
114
- record["document_draft_id"] = document["id"]
115
- drafts = supabase.table("agent_drafts").insert(records).execute().data
116
- return {"document_draft": document, "drafts": drafts}
117
-
118
-
119
- def _get_course_draft(arguments: dict) -> dict:
120
- return {"draft": load_agent_draft(_require_int(arguments, "draft_id"))}
121
-
122
-
123
- def _get_document_draft(arguments: dict) -> dict:
124
- return load_document_draft(_require_int(arguments, "document_draft_id"))
125
-
126
-
127
- def _get_preview_url(arguments: dict) -> dict:
128
- kind = str(arguments.get("kind") or "")
129
- item_id = _require_int(arguments, "id")
130
- paths = {
131
- "course": f"/api/preview/course/{item_id}",
132
- "draft": f"/api/agent/drafts/{item_id}/preview",
133
- "document_draft": f"/api/agent/document-drafts/{item_id}/preview",
134
- }
135
- if kind not in paths:
136
- raise ValueError("kind must be course, draft, or document_draft")
137
- return {"url": paths[kind]}
138
-
139
-
140
- def _list_courses(arguments: dict) -> dict:
141
- query = supabase.table("refined_submissions").select("id,semester,course_code,course_title").neq("status", "archived")
142
- if arguments.get("semester") is not None:
143
- query = query.eq("semester", int(arguments["semester"]))
144
- rows = query.execute().data
145
- rows.sort(key=lambda row: (int(row.get("semester") or 0), str(row.get("course_code") or ""), int(row.get("id") or 0)))
146
- return {"courses": rows}
147
-
148
-
149
- def _fetch_url(arguments: dict) -> dict:
150
- url = str(arguments.get("url") or "").strip()
151
- if not url:
152
- raise ValueError("url is required")
153
- resp = httpx.get(url, timeout=30, follow_redirects=True)
154
- resp.raise_for_status()
155
- text = resp.text[:15000]
156
- return {"url": url, "text": text, "chars": len(text)}
157
-
158
-
159
- def _create_report(arguments: dict) -> dict:
160
- session_id = _require_int(arguments, "session_id")
161
- content = str(arguments.get("content") or "").strip()
162
- if not content:
163
- raise ValueError("content is required")
164
- filename = str(arguments.get("filename") or "report.md").strip()
165
- row = (
166
- supabase.table("chat_attachments")
167
- .insert({
168
- "session_id": session_id,
169
- "filename": filename,
170
- "content_type": "text/markdown",
171
- "size_bytes": len(content.encode()),
172
- "extracted_text": content,
173
- "status": "ready",
174
- })
175
- .execute()
176
- .data[0]
177
- )
178
- return {"attachment": {"id": row["id"], "filename": row["filename"], "chars": len(content)}}
179
-
180
-
181
- def _attachment_text(arguments: dict) -> dict:
182
- session_id = _require_int(arguments, "session_id")
183
- ids = [int(value) for value in arguments.get("attachment_ids") or []]
184
- if not ids:
185
- raise ValueError("attachment_ids is required")
186
- rows = (
187
- supabase.table("chat_attachments")
188
- .select("id,filename,status,error,extracted_text")
189
- .eq("session_id", session_id)
190
- .in_("id", ids)
191
- .execute()
192
- .data
193
- )
194
- return {"attachments": rows}
195
-
196
-
197
- OBJECT = {"type": "object", "additionalProperties": False}
198
-
199
- TOOLS: dict[str, AgentTool] = {
200
- "get_current_course_json": AgentTool(
201
- "get_current_course_json",
202
- "Read the current template-ready JSON for one refined course.",
203
- {**OBJECT, "properties": {"refined_id": {"type": "integer"}}, "required": ["refined_id"]},
204
- _get_current_course,
205
- ),
206
- "diff_course_json": AgentTool(
207
- "diff_course_json",
208
- "Compare two course JSON objects and return patch operations, changed percent, and syllabus topic changes.",
209
- {**OBJECT, "properties": {"current": {"type": "object"}, "proposed": {"type": "object"}}, "required": ["current", "proposed"]},
210
- _diff_course_json,
211
- ),
212
- "create_course_draft": AgentTool(
213
- "create_course_draft",
214
- "Create a human-reviewable draft for one course. This never applies changes to refined_submissions.",
215
- {
216
- **OBJECT,
217
- "properties": {
218
- "refined_id": {"type": "integer"},
219
- "fields": {"type": "object"},
220
- "reason": {"type": "string"},
221
- },
222
- "required": ["refined_id", "fields"],
223
- },
224
- _create_course_draft,
225
- ),
226
- "get_curriculum_json": AgentTool(
227
- "get_curriculum_json",
228
- "Read template-ready JSON for the full curriculum, optionally filtered by semester.",
229
- {**OBJECT, "properties": {"semester": {"type": "integer", "minimum": 1, "maximum": 8}}},
230
- _get_curriculum_json,
231
- ),
232
- "create_document_draft": AgentTool(
233
- "create_document_draft",
234
- "Create one human-reviewable document draft containing proposed changes for multiple courses. This never applies changes.",
235
- {
236
- **OBJECT,
237
- "properties": {
238
- "courses": {
239
- "type": "array",
240
- "items": {
241
- "type": "object",
242
- "properties": {"refined_id": {"type": "integer"}, "fields": {"type": "object"}},
243
- "required": ["refined_id", "fields"],
244
- "additionalProperties": False,
245
- },
246
- },
247
- "reason": {"type": "string"},
248
- "uploaded_document_id": {"type": "string"},
249
- "curriculum_version_id": {"type": "integer"},
250
- },
251
- "required": ["courses"],
252
- },
253
- _create_document_draft,
254
- ),
255
- "get_course_draft": AgentTool(
256
- "get_course_draft",
257
- "Read one staged course draft and its diff summary.",
258
- {**OBJECT, "properties": {"draft_id": {"type": "integer"}}, "required": ["draft_id"]},
259
- _get_course_draft,
260
- ),
261
- "get_document_draft": AgentTool(
262
- "get_document_draft",
263
- "Read one staged document draft and all linked course drafts.",
264
- {**OBJECT, "properties": {"document_draft_id": {"type": "integer"}}, "required": ["document_draft_id"]},
265
- _get_document_draft,
266
- ),
267
- "get_preview_url": AgentTool(
268
- "get_preview_url",
269
- "Return the preview URL for a course, course draft, or document draft.",
270
- {
271
- **OBJECT,
272
- "properties": {
273
- "kind": {"type": "string", "enum": ["course", "draft", "document_draft"]},
274
- "id": {"type": "integer"},
275
- },
276
- "required": ["kind", "id"],
277
- },
278
- _get_preview_url,
279
- ),
280
- "list_courses": AgentTool(
281
- "list_courses",
282
- "List refined course IDs and titles, optionally filtered by semester.",
283
- {**OBJECT, "properties": {"semester": {"type": "integer", "minimum": 1, "maximum": 8}}},
284
- _list_courses,
285
- ),
286
- "get_attachment_text": AgentTool(
287
- "get_attachment_text",
288
- "Read extracted text for uploaded chat attachments within a chat session.",
289
- {
290
- **OBJECT,
291
- "properties": {
292
- "session_id": {"type": "integer"},
293
- "attachment_ids": {"type": "array", "items": {"type": "integer"}},
294
- },
295
- "required": ["session_id", "attachment_ids"],
296
- },
297
- _attachment_text,
298
- ),
299
- "fetch_url": AgentTool(
300
- "fetch_url",
301
- "Fetch a public URL and return its text content. Use to read web pages, public documents, and online resources.",
302
- {**OBJECT, "properties": {"url": {"type": "string"}}, "required": ["url"]},
303
- _fetch_url,
304
- ),
305
- "create_report": AgentTool(
306
- "create_report",
307
- "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.",
308
- {
309
- **OBJECT,
310
- "properties": {
311
- "session_id": {"type": "integer"},
312
- "content": {"type": "string", "description": "Full report/document content in markdown format"},
313
- "filename": {"type": "string", "description": "Filename including extension, e.g. comparison-report.md"},
314
- },
315
- "required": ["session_id", "content"],
316
- },
317
- _create_report,
318
- ),
319
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/services/attachments.py DELETED
@@ -1,91 +0,0 @@
1
- import re
2
- import subprocess
3
- import tempfile
4
- import zipfile
5
- from pathlib import Path
6
- from xml.etree import ElementTree
7
-
8
- MAX_FILE_BYTES = 8 * 1024 * 1024
9
- MAX_TEXT_CHARS = 50000
10
- SPACE = re.compile(r"[ \t]+")
11
- BLANKS = re.compile(r"\n{3,}")
12
-
13
-
14
- def extract_text(filename: str, content_type: str, data: bytes) -> tuple[str, str, str]:
15
- if len(data) > MAX_FILE_BYTES:
16
- return "", "failed", "File is larger than 8 MB"
17
- suffix = Path(filename or "").suffix.lower()
18
- try:
19
- if suffix == ".pdf" or content_type == "application/pdf":
20
- return _clean(_pdf_text(data)), "ready", ""
21
- if suffix == ".docx":
22
- return _clean(_docx_text(data)), "ready", ""
23
- if suffix == ".xlsx":
24
- return _clean(_xlsx_text(data)), "ready", ""
25
- if suffix in {".txt", ".md", ".csv"} or content_type.startswith("text/"):
26
- return _clean(_decode(data)), "ready", ""
27
- if content_type.startswith("image/"):
28
- return "", "unsupported", "Image OCR is not enabled yet"
29
- return "", "unsupported", "File type is not supported yet"
30
- except Exception as exc:
31
- return "", "failed", str(exc)
32
-
33
-
34
- def _pdf_text(data: bytes) -> str:
35
- with tempfile.TemporaryDirectory() as temp_dir:
36
- path = Path(temp_dir) / "upload.pdf"
37
- path.write_bytes(data)
38
- result = subprocess.run(
39
- ["pdftotext", "-layout", str(path), "-"],
40
- check=True,
41
- capture_output=True,
42
- text=True,
43
- timeout=30,
44
- )
45
- return result.stdout
46
-
47
-
48
- def _docx_text(data: bytes) -> str:
49
- with tempfile.TemporaryDirectory() as temp_dir:
50
- path = Path(temp_dir) / "upload.docx"
51
- path.write_bytes(data)
52
- with zipfile.ZipFile(path) as docx:
53
- xml = docx.read("word/document.xml")
54
- root = ElementTree.fromstring(xml)
55
- ns = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
56
- lines = []
57
- for paragraph in root.iter(f"{ns}p"):
58
- text = "".join(node.text or "" for node in paragraph.iter(f"{ns}t")).strip()
59
- if text:
60
- lines.append(text)
61
- return "\n".join(lines)
62
-
63
-
64
- def _xlsx_text(data: bytes) -> str:
65
- import openpyxl
66
- with tempfile.TemporaryDirectory() as temp_dir:
67
- path = Path(temp_dir) / "upload.xlsx"
68
- path.write_bytes(data)
69
- wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
70
- rows = []
71
- for sheet in wb:
72
- for row in sheet.iter_rows(values_only=True):
73
- line = "\t".join(str(c or "") for c in row)
74
- if line.strip():
75
- rows.append(line)
76
- return "\n".join(rows)
77
-
78
-
79
- def _decode(data: bytes) -> str:
80
- for encoding in ("utf-8", "utf-16", "latin-1"):
81
- try:
82
- return data.decode(encoding)
83
- except UnicodeDecodeError:
84
- continue
85
- return data.decode("utf-8", errors="ignore")
86
-
87
-
88
- def _clean(text: str) -> str:
89
- text = SPACE.sub(" ", text.replace("\r\n", "\n").replace("\r", "\n"))
90
- text = BLANKS.sub("\n\n", text).strip()
91
- return text[:MAX_TEXT_CHARS]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/services/books.py DELETED
@@ -1,105 +0,0 @@
1
- import re
2
-
3
- WORD = re.compile(r"\w+")
4
- BOOK_STOP = re.compile(
5
- r"\bCourse\b[\s\S]{0,160}\bOutcome\b|"
6
- r"\b(Course\s+Objectives?|Assignment\s*/|Laboratory|Recommended\s+Materials)\b",
7
- re.IGNORECASE,
8
- )
9
- BOOK_LABEL = re.compile(
10
- r"(?im)^\s*(?:Text\s*)?Book\(s\):\s*|"
11
- r"^\s*Text\s+Book\(s\):\s*|"
12
- r"^\s*Reference\s*(?:Book\(s\):)?\s*"
13
- )
14
- BOOK_NUMBER = re.compile(r"(?:^|\n|\s{2,}|(?<=\.)\s+)\d+\s*[:.)]\s*")
15
- ORDINAL_LINE = re.compile(r"(?im)^\s*(st|nd|rd|th)\s*$")
16
- PAGE_NOISE = re.compile(
17
- r"P\.?\s*E\.?\s*S\.?\s*University|"
18
- r"Curriculum|"
19
- r"\s*:-\s*[A-Za-z]*\s*\d{4}\s*-\s*\d{4}\b|"
20
- r"\b\d+\s*\|\s*Page\b",
21
- re.IGNORECASE,
22
- )
23
- TEXT_BOOK_LINE = re.compile(r"\bText\s+Book\(s\):", re.IGNORECASE)
24
- REFERENCE_LINE = re.compile(r"^\s*Reference\b", re.IGNORECASE)
25
- SECTION_END_LINE = re.compile(r"^\s*(Course\s+Outcome|Assignment\s*/|Course\s+Objectives?|Laboratory)\b", re.IGNORECASE)
26
-
27
-
28
- def _words(text: str) -> int:
29
- return len(WORD.findall(text))
30
-
31
-
32
- def _flatten(values) -> str:
33
- parts = []
34
- for value in values:
35
- if not value:
36
- continue
37
- if isinstance(value, list):
38
- parts.extend(str(item) for item in value if str(item).strip())
39
- else:
40
- parts.append(str(value))
41
- text = "\n".join(parts)
42
- match = BOOK_STOP.search(text)
43
- if match:
44
- text = text[: match.start()]
45
- text = ORDINAL_LINE.sub("", text)
46
- text = PAGE_NOISE.sub("\n", text)
47
- return BOOK_LABEL.sub("", text)
48
-
49
-
50
- def _clean(value: str) -> str:
51
- value = BOOK_LABEL.sub("", value)
52
- value = re.sub(r"\bBook\(s\):?\s*", "", value, flags=re.IGNORECASE)
53
- value = re.sub(r"-\s+", "-", value)
54
- return re.sub(r"\s+", " ", value).strip(" :")
55
-
56
-
57
- def parse_books(*values) -> list[str]:
58
- text = _flatten(values)
59
- if not text.strip():
60
- return []
61
-
62
- numbered = [part for part in BOOK_NUMBER.split(text) if part.strip()]
63
- if numbered and (len(numbered) > 1 or BOOK_NUMBER.search(text)):
64
- parts = numbered
65
- else:
66
- parts = [line for line in text.splitlines() if line.strip()]
67
-
68
- books = []
69
- seen = set()
70
- for part in parts:
71
- item = _clean(part)
72
- if not item:
73
- continue
74
- if books and (item.startswith("(") or (_words(item) <= 3 and not re.search(r"\b\d{4}\b", item))):
75
- books[-1] = f"{books[-1]} {item}".strip()
76
- continue
77
- key = re.sub(r"[^a-z0-9]+", " ", item.lower()).strip()
78
- if key not in seen:
79
- books.append(item)
80
- seen.add(key)
81
- return books
82
-
83
-
84
- def raw_book_section(raw_content: str, kind: str) -> str:
85
- lines = (raw_content or "").splitlines()
86
- start = None
87
- for index, line in enumerate(lines):
88
- if kind == "text" and TEXT_BOOK_LINE.search(line):
89
- start = index
90
- break
91
- if kind == "reference" and REFERENCE_LINE.search(line):
92
- start = index
93
- break
94
- if start is None:
95
- return ""
96
-
97
- end = len(lines)
98
- for index in range(start + 1, len(lines)):
99
- if kind == "text" and REFERENCE_LINE.search(lines[index]):
100
- end = index
101
- break
102
- if SECTION_END_LINE.search(lines[index]):
103
- end = index
104
- break
105
- return "\n".join(lines[start:end])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/services/curriculum.py DELETED
@@ -1,154 +0,0 @@
1
- from os import environ
2
-
3
- from app.preview import build_course_preview
4
- from app.services.diffing import diff_course, merge_fields, validate_draft
5
- from app.supabase import first_row, supabase
6
-
7
- DEFAULT_CURRICULUM_YEAR = environ.get("CURRICULUM_YEAR", "").strip()
8
- SOURCE_ORDER = {
9
- "UE25CS151A": 1,
10
- "UE25CS151B": 1,
11
- "UE24CS251A": 1,
12
- "UE24CS252A": 2,
13
- "UE24MA242A": 3,
14
- "UE24CS241A": 4,
15
- "UE24CS243A": 5,
16
- "UZ24UZ221A": 6,
17
- "UE25MA201A*": 7,
18
- "UE24CS251B": 1,
19
- "UE24CS252B": 2,
20
- "UE24CS241B": 3,
21
- "UE24CS242B": 4,
22
- "UE24MA241B": 5,
23
- "UZ24UZ221B": 6,
24
- "UE25MA201B*": 7,
25
- "UE23CS351A": 1,
26
- "UE23CS352A": 2,
27
- "UE23CS341A": 3,
28
- "UE23CS342AAX": 4,
29
- "UE23CS343ABX": 5,
30
- "UE23CS320A": 6,
31
- "UE23CS351B": 1,
32
- "UE23CS352B": 2,
33
- "UE23CS341B": 3,
34
- "UE23CS342BAX": 4,
35
- "UE23CS343BBX": 5,
36
- "UE23CS320B": 6,
37
- "UE22CS441A": 1,
38
- "UZ22UZ422A": 2,
39
- "UE22AM421AXX": 3,
40
- "UE22CS421B": 1,
41
- "UE22CS461XB": 2,
42
- }
43
-
44
- REFINED_FIELDS = {
45
- "semester",
46
- "course_code",
47
- "course_title",
48
- "program",
49
- "lecture_hours",
50
- "tutorial_hours",
51
- "practical_hours",
52
- "self_study",
53
- "credits",
54
- "course_type",
55
- "tools_languages",
56
- "desirable_knowledge",
57
- "prelude",
58
- "objectives",
59
- "course_outcomes",
60
- "units",
61
- "lab_experiments",
62
- "text_books",
63
- "reference_books",
64
- "status",
65
- }
66
-
67
-
68
- def attach_submissions(rows: list[dict]) -> list[dict]:
69
- ids = [row["submission_id"] for row in rows if row.get("submission_id")]
70
- if not ids:
71
- return rows
72
- submissions = supabase.table("submissions").select("*").in_("id", ids).execute().data
73
- by_id = {row["id"]: row for row in submissions}
74
- for row in rows:
75
- row["_submission"] = by_id.get(row.get("submission_id"), {})
76
- return rows
77
-
78
-
79
- def ordered_courses(rows: list[dict]) -> list[dict]:
80
- rows = attach_submissions(rows)
81
- rows.sort(key=course_sort_key)
82
- return [build_course_preview(row) for row in rows]
83
-
84
-
85
- def course_sort_key(row: dict) -> tuple[int, int, int]:
86
- semester = int(row.get("semester") or 0)
87
- code = str(row.get("course_code") or "").replace(" ", "").upper()
88
- order = SOURCE_ORDER.get(code)
89
- if order is None and semester == 5:
90
- order = elective_order(code, "AA", "AB")
91
- if order is None and semester == 6:
92
- order = elective_order(code, "BA", "BB")
93
- return semester, order or 900, int(row.get("id") or 0)
94
-
95
-
96
- def elective_order(code: str, first_group: str, second_group: str) -> int | None:
97
- for offset, group in ((100, first_group), (200, second_group)):
98
- if group in code:
99
- suffix = code.rsplit(group, 1)[-1].rstrip("X")
100
- return offset + int(suffix) if suffix.isdigit() else offset
101
- return None
102
-
103
-
104
- def selected_curriculum_year(value: str | None = None) -> str:
105
- return str(value or "").strip() or DEFAULT_CURRICULUM_YEAR
106
-
107
-
108
- def refined_course(refined_id: int) -> dict:
109
- row = first_row(supabase.table("refined_submissions").select("*").eq("id", refined_id))
110
- if not row:
111
- raise LookupError("Refined submission not found")
112
- return build_course_preview(attach_submissions([row])[0])
113
-
114
-
115
- def update_refined_fields(refined_id: int, fields: dict) -> dict | None:
116
- update = {key: fields[key] for key in REFINED_FIELDS if key in fields}
117
- for key in ("semester", "lecture_hours", "tutorial_hours", "practical_hours", "self_study", "credits"):
118
- if key in update:
119
- update[key] = int(update[key] or 0)
120
- result = supabase.table("refined_submissions").update(update).eq("id", refined_id).execute()
121
- return result.data[0] if result.data else None
122
-
123
-
124
- def draft_record(refined_id: int, fields: dict, reason: str = "", document_draft_id: int | None = None) -> dict:
125
- base = refined_course(refined_id)
126
- proposed = merge_fields(base, fields)
127
- summary = diff_course(base, proposed)
128
- issues = validate_draft(base, proposed)
129
- summary["validation_issues"] = issues
130
- return {
131
- "refined_id": refined_id,
132
- "document_draft_id": document_draft_id,
133
- "base_refined_json": base,
134
- "proposed_json": proposed,
135
- "json_patch": summary.pop("json_patch"),
136
- "diff_summary": summary,
137
- "change_reason": reason.strip(),
138
- "status": "blocked" if issues else "proposed",
139
- }
140
-
141
-
142
- def load_agent_draft(draft_id: int) -> dict:
143
- draft = first_row(supabase.table("agent_drafts").select("*").eq("id", draft_id))
144
- if not draft:
145
- raise LookupError("Agent draft not found")
146
- return draft
147
-
148
-
149
- def load_document_draft(document_draft_id: int) -> dict:
150
- document = first_row(supabase.table("agent_document_drafts").select("*").eq("id", document_draft_id))
151
- if not document:
152
- raise LookupError("Document draft not found")
153
- drafts = supabase.table("agent_drafts").select("*").eq("document_draft_id", document_draft_id).order("id").execute().data
154
- return {"document_draft": document, "drafts": drafts}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/services/deterministic.py DELETED
@@ -1,27 +0,0 @@
1
- def compute_hours(cat: str) -> dict:
2
- return {
3
- "5": {"lecture_hours": 4, "tutorial_hours": 0, "practical_hours": 2, "self_study": 5, "credits": 5},
4
- "4": {"lecture_hours": 4, "tutorial_hours": 0, "practical_hours": 0, "self_study": 4, "credits": 4},
5
- "2": {"lecture_hours": 2, "tutorial_hours": 0, "practical_hours": 0, "self_study": 2, "credits": 2},
6
- "0": {"lecture_hours": 0, "tutorial_hours": 0, "practical_hours": 0, "self_study": 0, "credits": 0},
7
- }[cat]
8
-
9
-
10
- def compute_program(dept: str) -> str:
11
- return {
12
- "CSE": "B. TECH",
13
- "AIML": "B. TECH",
14
- "ECE": "B. TECH",
15
- "ME": "B. TECH",
16
- "EEE": "B. TECH",
17
- "BT": "B. TECH",
18
- }[dept]
19
-
20
-
21
- def compute_course_type(cat: str) -> str:
22
- return {
23
- "0": "Foundation Course",
24
- "5": "Core Course-Lab Integrated",
25
- "4": "Core Course",
26
- "2": "Core Theory",
27
- }[cat]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/services/diffing.py DELETED
@@ -1,160 +0,0 @@
1
- import difflib
2
- import json
3
- import re
4
- from copy import deepcopy
5
- from typing import Any
6
-
7
- PROTECTED_FIELDS = {
8
- "program",
9
- "lecture_hours",
10
- "tutorial_hours",
11
- "practical_hours",
12
- "self_study",
13
- "credits",
14
- "course_type",
15
- }
16
-
17
- TOPIC_SPLIT = re.compile(r"[,;\n]+")
18
- SPACE = re.compile(r"\s+")
19
-
20
-
21
- def stable_json(value: Any) -> str:
22
- return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False)
23
-
24
-
25
- def build_patch(old: Any, new: Any, path: str = "") -> list[dict]:
26
- if isinstance(old, dict) and isinstance(new, dict):
27
- patch = []
28
- old_keys = set(old)
29
- new_keys = set(new)
30
- for key in sorted(old_keys - new_keys):
31
- patch.append({"op": "remove", "path": f"{path}/{_escape(key)}"})
32
- for key in sorted(new_keys - old_keys):
33
- patch.append({"op": "add", "path": f"{path}/{_escape(key)}", "value": new[key]})
34
- for key in sorted(old_keys & new_keys):
35
- patch.extend(build_patch(old[key], new[key], f"{path}/{_escape(key)}"))
36
- return patch
37
-
38
- if old != new:
39
- return [{"op": "replace", "path": path or "/", "value": new}]
40
- return []
41
-
42
-
43
- def apply_patch(value: Any, patch: list[dict]) -> Any:
44
- result = deepcopy(value)
45
- for op in patch:
46
- name = op.get("op")
47
- path = op.get("path")
48
- if name not in {"add", "remove", "replace"} or not isinstance(path, str):
49
- raise ValueError("Unsupported patch operation")
50
- parent, key = _resolve_parent(result, path)
51
- if isinstance(parent, list):
52
- index = int(key)
53
- if name == "remove":
54
- parent.pop(index)
55
- elif name == "replace":
56
- parent[index] = op.get("value")
57
- else:
58
- parent.insert(index, op.get("value"))
59
- continue
60
- if name == "remove":
61
- parent.pop(key, None)
62
- else:
63
- parent[key] = op.get("value")
64
- return result
65
-
66
-
67
- def diff_course(old: dict, new: dict) -> dict:
68
- old_text = stable_json(old)
69
- new_text = stable_json(new)
70
- patch = build_patch(old, new)
71
- syllabus_old = _syllabus_text(old)
72
- syllabus_new = _syllabus_text(new)
73
- topics_old = _topics(syllabus_old)
74
- topics_new = _topics(syllabus_new)
75
-
76
- return {
77
- "json_patch": patch,
78
- "patch_operations": len(patch),
79
- "change_percent": _change_percent(old_text, new_text),
80
- "syllabus_change_percent": _change_percent(syllabus_old, syllabus_new),
81
- "topics_added": sorted(topics_new - topics_old),
82
- "topics_removed": sorted(topics_old - topics_new),
83
- "protected_changes": sorted(field for field in PROTECTED_FIELDS if _field_value(old, field) != _field_value(new, field)),
84
- "unified_diff": "\n".join(
85
- difflib.unified_diff(
86
- old_text.splitlines(),
87
- new_text.splitlines(),
88
- fromfile="current",
89
- tofile="proposed",
90
- lineterm="",
91
- )
92
- ),
93
- }
94
-
95
-
96
- def validate_draft(old: dict, new: dict) -> list[str]:
97
- issues = []
98
- for field in PROTECTED_FIELDS:
99
- if _field_value(old, field) != _field_value(new, field):
100
- issues.append(f"{field} is deterministic and cannot be changed by an agent draft")
101
- return issues
102
-
103
-
104
- def merge_fields(base: dict, fields: dict) -> dict:
105
- merged = deepcopy(base)
106
- for key, value in fields.items():
107
- if key in PROTECTED_FIELDS and str(base.get(key) or "") == str(value or ""):
108
- value = base.get(key)
109
- merged[key] = value
110
- return merged
111
-
112
-
113
- def _field_value(value: dict, field: str) -> str:
114
- return str(value.get(field) or "")
115
-
116
-
117
- def _escape(value: str) -> str:
118
- return value.replace("~", "~0").replace("/", "~1")
119
-
120
-
121
- def _unescape(value: str) -> str:
122
- return value.replace("~1", "/").replace("~0", "~")
123
-
124
-
125
- def _resolve_parent(value: Any, path: str) -> tuple[Any, str]:
126
- if path in {"", "/"}:
127
- raise ValueError("Root patch operations are not supported")
128
- parts = [_unescape(part) for part in path.strip("/").split("/")]
129
- current = value
130
- for part in parts[:-1]:
131
- current = current[int(part)] if isinstance(current, list) else current[part]
132
- return current, parts[-1]
133
-
134
-
135
- def _change_percent(old: str, new: str) -> float:
136
- if not old and not new:
137
- return 0.0
138
- ratio = difflib.SequenceMatcher(None, old, new).ratio()
139
- return round((1 - ratio) * 100, 2)
140
-
141
-
142
- def _syllabus_text(value: dict) -> str:
143
- units = value.get("units") or []
144
- if not isinstance(units, list):
145
- return ""
146
- parts = []
147
- for unit in units:
148
- if isinstance(unit, dict):
149
- parts.append(str(unit.get("title") or ""))
150
- parts.append(str(unit.get("content") or ""))
151
- return SPACE.sub(" ", " ".join(parts)).strip()
152
-
153
-
154
- def _topics(text: str) -> set[str]:
155
- topics = set()
156
- for item in TOPIC_SPLIT.split(text):
157
- topic = SPACE.sub(" ", item).strip(" .:-").lower()
158
- if len(topic) >= 4:
159
- topics.add(topic)
160
- return topics
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/services/errors.py DELETED
@@ -1,11 +0,0 @@
1
- import sentry_sdk
2
- from fastapi import HTTPException
3
- from postgrest.exceptions import APIError
4
-
5
-
6
- def database_http_exception(exc: APIError) -> HTTPException:
7
- message = str(exc)
8
- if "schema cache" in message and "Could not find the table" in message:
9
- return HTTPException(status_code=503, detail="Required database tables are missing. Run docs/schema.sql in Supabase.")
10
- sentry_sdk.capture_exception(exc)
11
- return HTTPException(status_code=500, detail="Database request failed.")
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/services/openrouter.py DELETED
@@ -1,221 +0,0 @@
1
- import json
2
- import logging
3
- import os
4
-
5
- from dotenv import load_dotenv
6
- from httpx import Client, HTTPError, HTTPStatusError
7
-
8
- load_dotenv("../.env")
9
-
10
- URL = os.environ["OPENROUTER_URL"].strip()
11
- KEY = os.environ["OPENROUTER_API_KEY"].strip()
12
- MODEL = os.environ["OPENROUTER_MODEL"].strip()
13
- logger = logging.getLogger(__name__)
14
-
15
-
16
- class OpenRouterError(RuntimeError):
17
- def __init__(self, status_code: int, retry_after: str | None = None, message: str | None = None, provider_message: str = ""):
18
- self.status_code = status_code
19
- self.retry_after = retry_after
20
- self._message = message
21
- self.provider_message = provider_message
22
- super().__init__(self.message)
23
-
24
- @property
25
- def message(self) -> str:
26
- if self._message:
27
- return self._message
28
- if self.status_code in {429, 503}:
29
- if self.retry_after:
30
- return f"Model provider is rate limited. Try again in {self.retry_after} seconds."
31
- return "Model provider is rate limited. Please try again shortly."
32
- return "Model provider request failed. Please try again later."
33
-
34
-
35
- def _messages(system: str, messages: list[dict]) -> list[dict]:
36
- return [{"role": "system", "content": system}, *messages]
37
-
38
-
39
- def _headers() -> dict:
40
- return {"Authorization": f"Bearer {KEY}"}
41
-
42
-
43
- def _message_content(data: dict) -> str:
44
- try:
45
- return _assistant_message(data)["content"].strip()
46
- except (KeyError, TypeError, AttributeError) as exc:
47
- 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
48
-
49
-
50
- def _raise_for_status(response) -> None:
51
- try:
52
- response.raise_for_status()
53
- except HTTPStatusError as exc:
54
- raise OpenRouterError(
55
- exc.response.status_code,
56
- exc.response.headers.get("retry-after"),
57
- provider_message=exc.response.text[:500],
58
- ) from exc
59
-
60
-
61
- def _provider_error(data: dict) -> str:
62
- error = data.get("error") if isinstance(data, dict) else None
63
- if isinstance(error, dict):
64
- return str(error.get("message") or error)
65
- return str(error or data)[:500]
66
-
67
-
68
- def _public_shape_error(provider_message: str) -> str:
69
- lowered = provider_message.lower()
70
- if "tool" in lowered and ("support" in lowered or "unsupported" in lowered):
71
- return "The selected model does not support editor tools. Switch to a tool-calling model and try again."
72
- if "rate" in lowered or "quota" in lowered:
73
- return "Model provider is rate limited. Please try again shortly."
74
- return "Model provider returned an invalid response. Please try again later."
75
-
76
-
77
- def _assistant_message(data: dict) -> dict:
78
- try:
79
- message = data["choices"][0]["message"]
80
- except (KeyError, IndexError, TypeError) as exc:
81
- provider_message = _provider_error(data)
82
- raise OpenRouterError(502, message=_public_shape_error(provider_message), provider_message=provider_message) from exc
83
- if not isinstance(message, dict):
84
- raise OpenRouterError(502, message="Model provider returned an invalid response. Please try again later.", provider_message=str(message)[:500])
85
- return message
86
-
87
-
88
- def call(system: str, user: str) -> dict:
89
- with Client(timeout=120) as c:
90
- r = c.post(
91
- URL,
92
- headers=_headers(),
93
- json={"model": MODEL, "messages": _messages(system, [{"role": "user", "content": user}])},
94
- )
95
- _raise_for_status(r)
96
- data = r.json()
97
- text = _message_content(data)
98
- text = text.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip()
99
- return json.loads(text)
100
-
101
-
102
- def stream_chat(system: str, messages: list[dict], tools: list[dict] | None = None, tool_runner=None, on_tool_result=None):
103
- chat_messages = _messages(system, messages)
104
- if tools and tool_runner:
105
- yield from _chat_with_tools(chat_messages, tools, tool_runner, on_tool_result)
106
- return
107
-
108
- emitted = False
109
- try:
110
- for token in _stream_chat(chat_messages):
111
- emitted = True
112
- yield token
113
- except (HTTPError, json.JSONDecodeError) as exc:
114
- _log_stream_fallback(exc)
115
-
116
- if emitted:
117
- return
118
-
119
- text = _chat_text(chat_messages)
120
- if text:
121
- yield text
122
-
123
-
124
- def _stream_chat(messages: list[dict]):
125
- payload = {"model": MODEL, "messages": messages, "stream": True}
126
- with Client(timeout=120) as client:
127
- with client.stream("POST", URL, headers=_headers(), json=payload) as response:
128
- if response.is_error:
129
- response.read()
130
- response.raise_for_status()
131
- for line in response.iter_lines():
132
- token = _stream_token(line)
133
- if token:
134
- yield token
135
-
136
-
137
- def _chat_text(messages: list[dict]) -> str:
138
- with Client(timeout=120) as client:
139
- response = client.post(URL, headers=_headers(), json={"model": MODEL, "messages": messages})
140
- _raise_for_status(response)
141
- return _message_content(response.json())
142
-
143
-
144
- def _chat_message(messages: list[dict], tools: list[dict]) -> dict:
145
- with Client(timeout=120) as client:
146
- response = client.post(URL, headers=_headers(), json={"model": MODEL, "messages": messages, "tools": tools})
147
- _raise_for_status(response)
148
- return _assistant_message(response.json())
149
-
150
-
151
- def _chat_with_tools(messages: list[dict], tools: list[dict], tool_runner, on_tool_result=None):
152
- for _ in range(5):
153
- yield {"$status": f"Thinking... (attempt {_ + 1}/5)"}
154
- message = _chat_message(messages, tools)
155
- tool_calls = message.get("tool_calls") or []
156
- if not tool_calls:
157
- text = str(message.get("content") or "").strip()
158
- if text:
159
- yield text
160
- return
161
-
162
- messages.append({"role": "assistant", "content": message.get("content") or "", "tool_calls": tool_calls})
163
- for tool_call in tool_calls:
164
- name = (tool_call.get("function") or {}).get("name") or ""
165
- try:
166
- result = _run_tool(tool_runner, name, _tool_arguments(tool_call))
167
- except ValueError as exc:
168
- result = {"error": str(exc)}
169
- if on_tool_result:
170
- on_tool_result(name, result)
171
- messages.append(
172
- {
173
- "role": "tool",
174
- "tool_call_id": tool_call.get("id") or name,
175
- "name": name,
176
- "content": json.dumps(result, ensure_ascii=False),
177
- }
178
- )
179
- yield "I created tool results, but could not finish the response. Please check the Review panel."
180
-
181
-
182
- def _tool_arguments(tool_call: dict) -> dict:
183
- raw = (tool_call.get("function") or {}).get("arguments") or "{}"
184
- arguments = json.loads(raw) if isinstance(raw, str) else raw
185
- if not isinstance(arguments, dict):
186
- raise ValueError("Tool arguments must be an object")
187
- return arguments
188
-
189
-
190
- def _run_tool(tool_runner, name: str, arguments: dict) -> dict:
191
- try:
192
- return tool_runner(name, arguments)
193
- except Exception as exc:
194
- return {"error": str(exc)}
195
-
196
-
197
- def _log_stream_fallback(exc: Exception) -> None:
198
- if isinstance(exc, HTTPStatusError):
199
- response = exc.response
200
- logger.warning(
201
- "OpenRouter streaming failed; retrying without stream. status=%s model=%s body=%s",
202
- response.status_code,
203
- MODEL,
204
- response.text[:500],
205
- )
206
- return
207
- logger.warning("OpenRouter streaming failed; retrying without stream. error=%s model=%s", exc.__class__.__name__, MODEL)
208
-
209
-
210
- def _stream_token(line: str) -> str:
211
- if not line or line.startswith(":"):
212
- return ""
213
- if not line.startswith("data:"):
214
- return ""
215
- data = line.removeprefix("data:").strip()
216
- if data == "[DONE]":
217
- return ""
218
- chunk = json.loads(data)
219
- choice = (chunk.get("choices") or [{}])[0]
220
- delta = choice.get("delta") or {}
221
- return delta.get("content") or ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/services/refinement.py DELETED
@@ -1,491 +0,0 @@
1
- import re
2
-
3
- from app.supabase import first_row, supabase
4
- from app.services.books import parse_books, raw_book_section
5
- from app.services.deterministic import compute_hours, compute_program, compute_course_type
6
- from app.services.openrouter import call as llm
7
-
8
- WORD = re.compile(r"\w+")
9
- UNIT_LINE = re.compile(r"\b(Unit\s+\d+|Module\s+(?:\d+|[IVX]+))\s*[:.-]?\s*(.*)$", re.IGNORECASE)
10
- HOURS_SUFFIX = re.compile(r"\s*\b\d+\s+Hours?\b\s*$", re.IGNORECASE)
11
- DESIRABLE_LINE = re.compile(r"\bDesirable\s+([^\n]+)", re.IGNORECASE)
12
- COURSE_CODE = re.compile(r"\bCourse\s+Code\s*:?\s*([A-Z0-9]+)", re.IGNORECASE)
13
- PAGE_NOISE = re.compile(
14
- r"P\.?\s*E\.?\s*S\.?\s*University|"
15
- r"Curriculum|"
16
- r"\s*:-\s*[A-Za-z]*\s*\d{4}\s*-\s*\d{4}\b|"
17
- r"\b\d+\s*\|\s*Page\b",
18
- re.IGNORECASE,
19
- )
20
-
21
- SYS = """You refine PES University course submissions for the UG curriculum template.
22
- Return only valid JSON. No markdown. No commentary.
23
- Priority order:
24
- 1. Preserve the submitted academic content.
25
- 2. Correct spelling, grammar, casing, and formatting.
26
- 3. Fill missing template fields from the submitted course scope.
27
- 4. Return the required JSON shape.
28
-
29
- Editing rules:
30
- - Refine, do not rewrite. If the input is already curriculum-ready, keep its wording and structure except for small correctness fixes.
31
- - If prelude, objectives, outcomes, units, tools, or books are already present, clean them instead of replacing them.
32
- - Generate a field only when that field is missing, incomplete, or too rough for the template.
33
- - Preserve every syllabus topic and subtopic from Raw Course Content.
34
- - Do not summarize away, omit, replace, merge away, or simplify syllabus topics.
35
- - Do not add advanced, fashionable, or unrelated topics.
36
- - Do not invent course codes, books, references, departments, credits, or external prerequisites.
37
- - Correct course title typos, casing, spacing, and grammar.
38
- - Keep objectives and outcomes direct, concise, and aligned with the submitted scope.
39
- - 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.
40
- - Unit hours must sum to the supplied total unit hours.
41
- - For tools/languages, use Preferred Tools / Languages when provided; otherwise identify course-specific tools, languages, platforms, or AI tools from Raw Course Content.
42
- - Do not use canned defaults for tools/languages.
43
- - For desirable knowledge, use only relevant knowledge from Previously Completed Courses. Return an empty string when none apply.
44
- - Generate laboratory experiments only when practical hours are non-zero. Keep them tied to submitted topics.
45
- - Copy and clean books from the submitted book fields only.
46
- - Use empty strings or empty arrays for truly unavailable optional content. Do not output "-".
47
- """
48
-
49
- SCHEMA = """{
50
- "course_title": "corrected course title",
51
- "prelude": "one short paragraph",
52
- "objectives": ["3 to 4 objectives"],
53
- "course_outcomes": ["3 to 4 measurable outcomes"],
54
- "units": [{"title": "Unit 1: Title", "content": "compact topic list", "hours": 14}],
55
- "lab_experiments": ["concise lab item"],
56
- "tools_languages": "course-specific tools, languages, platforms, or AI tools",
57
- "desirable_knowledge": "short text based only on previously completed courses, or empty string",
58
- "text_books": ["submitted text books only"],
59
- "reference_books": ["submitted reference books only"]
60
- }"""
61
-
62
- EXAMPLES = """Behavior examples calibrated to PESU curriculum style.
63
- Use them only to learn fidelity, field shape, and level of detail. Do not copy example facts into the real answer.
64
-
65
- Example 1: already refined input
66
- Input:
67
- Course Title: Web Technologies
68
- Weekly Hours: L 4, T 0, P 0, S 4, C 4
69
- Total Unit Hours: 56
70
- Raw Course Content:
71
- 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
72
- 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
73
- 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
74
- 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
75
- Preferred Tools / Languages: HTML, CSS, JavaScript, MERN Technologies, GitHub, AI tools: Copilot and Tabnine
76
- Previously Completed Courses: None
77
- Text Books:
78
- 1. Robin Nixon, Learning PHP, MySQL and JavaScript, 5th Edition, O'Reilly Media, 2018.
79
- 2. Vasan Subramanian, Pro MERN Stack: Full Stack Web App Development with Mongo, Express, React, and Node, Apress, 2017.
80
- Output:
81
- {
82
- "course_title": "Web Technologies",
83
- "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.",
84
- "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."],
85
- "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."],
86
- "units": [
87
- {"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},
88
- {"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},
89
- {"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},
90
- {"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}
91
- ],
92
- "lab_experiments": [],
93
- "tools_languages": "HTML, CSS, JavaScript, MERN Technologies, GitHub; AI tools: Copilot, Tabnine",
94
- "desirable_knowledge": "",
95
- "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."],
96
- "reference_books": []
97
- }
98
-
99
- Example 2: detailed lab-integrated input with title and formatting issues
100
- Input:
101
- Course Title: data structures & its aplications
102
- Weekly Hours: L 4, T 0, P 2, S 5, C 5
103
- Total Unit Hours: 56
104
- Raw Course Content:
105
- 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
106
- 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
107
- 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
108
- 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
109
- Laboratory: linked list operations; stack applications; queue applications; binary tree and BST applications; graph data structure applications; hashing techniques.
110
- Preferred Tools / Languages: C-Programming language; AI tools: VisuAlgo (Interactive Visualizations), Algorithm Visualizer (AI Explanations)
111
- Previously Completed Courses: Problem Solving with C
112
- Text Books: 1. Langsam Yedidyah, Moshe J. Augenstein, Aaron M. Tenenbaum, Data Structures using C / C++, Pearson Education Inc., 2nd Edition, 2015.
113
- Output:
114
- {
115
- "course_title": "Data Structures and Applications",
116
- "prelude": "This course introduces fundamental data structure concepts with emphasis on their theoretical foundations, implementation techniques, and practical applications using a programming language.",
117
- "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."],
118
- "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."],
119
- "units": [
120
- {"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},
121
- {"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},
122
- {"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},
123
- {"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}
124
- ],
125
- "lab_experiments": ["Linked list and advanced operations.", "Stack applications.", "Queue applications.", "Binary tree and binary search tree applications.", "Graph data structure applications.", "Hashing techniques."],
126
- "tools_languages": "C-Programming language; AI tools: VisuAlgo (Interactive Visualizations), Algorithm Visualizer (AI Explanations)",
127
- "desirable_knowledge": "Problem Solving with C",
128
- "text_books": ["Langsam Yedidyah, Moshe J. Augenstein, Aaron M. Tenenbaum, Data Structures using C / C++, Pearson Education Inc., 2nd Edition, 2015."],
129
- "reference_books": []
130
- }
131
- """
132
-
133
-
134
- def _lines(value) -> list[str]:
135
- if not value:
136
- return []
137
- if isinstance(value, list):
138
- return [str(item).strip() for item in value if str(item).strip()]
139
- return [line.strip() for line in str(value).splitlines() if line.strip()]
140
-
141
-
142
- def _clean_noise(value: str) -> str:
143
- return re.sub(r"\s+", " ", PAGE_NOISE.sub(" ", value)).strip()
144
-
145
-
146
- def _books(*values) -> list[str]:
147
- return parse_books(*values)
148
-
149
-
150
- def _raw_book_section(raw_content: str, kind: str) -> str:
151
- return raw_book_section(raw_content, kind)
152
-
153
-
154
- def _words(text: str) -> int:
155
- return len(WORD.findall(text))
156
-
157
-
158
- def _clean_part(value: str) -> str:
159
- return _clean_noise(value.strip(" \t-*\u2022"))
160
-
161
-
162
- def _split_parts(text: str) -> list[str]:
163
- lines = [_clean_part(line) for line in text.splitlines() if _clean_part(line)]
164
- if len(lines) >= 4:
165
- return lines
166
- compact = _clean_part(text)
167
- parts = [_clean_part(part) for part in re.split(r"(?<=[.!?])\s+", compact) if _clean_part(part)]
168
- if len(parts) >= 4:
169
- return parts
170
- words = compact.split()
171
- if not words:
172
- return []
173
- return [
174
- " ".join(words[(index * len(words)) // 4 : ((index + 1) * len(words)) // 4])
175
- for index in range(4)
176
- ]
177
-
178
-
179
- def _four_units_from_raw(raw_content: str) -> list[dict]:
180
- parts = _split_parts(raw_content)
181
- if not parts:
182
- return []
183
- buckets = [[] for _ in range(4)]
184
- for index, part in enumerate(parts):
185
- buckets[min(index * 4 // len(parts), 3)].append(part)
186
- return [
187
- {"title": f"Unit {index + 1}", "content": " ".join(bucket).strip(), "hours": 0}
188
- for index, bucket in enumerate(buckets)
189
- if bucket
190
- ]
191
-
192
-
193
- def _course_contents(raw_content: str) -> str:
194
- lines = raw_content.splitlines()
195
- start = None
196
- for index, line in enumerate(lines):
197
- if "Course" in line and "Contents" in line:
198
- start = index
199
- break
200
- if "Course" in line and any("Contents" in item for item in lines[index + 1 : index + 3]):
201
- start = index
202
- break
203
- if start is None:
204
- return raw_content
205
-
206
- end = len(lines)
207
- for index in range(start + 1, len(lines)):
208
- marker = lines[index].strip()
209
- if not marker:
210
- continue
211
- if any(marker.startswith(label) for label in ("Laboratory", "Text Book", "Reference", "Course Outcome", "Assignment /")):
212
- end = index
213
- break
214
- return "\n".join(lines[start:end]).strip()
215
-
216
-
217
- def _syllabus_line(line: str) -> str:
218
- clean = _clean_part(line)
219
- clean = re.sub(r"^(Course\s+Contents|Contents)\s+", "", clean, flags=re.IGNORECASE)
220
- clean = re.sub(r"^Course\s+(?=Unit\s+\d+|Module\s+(?:\d+|[IVX]+))", "", clean, flags=re.IGNORECASE)
221
- return HOURS_SUFFIX.sub("", clean).strip()
222
-
223
-
224
- def _units_from_course_contents(raw_content: str, fallback: bool = True) -> list[dict]:
225
- content = _course_contents(raw_content)
226
- lines = [line.strip() for line in content.splitlines() if line.strip()]
227
- units = []
228
- current = None
229
-
230
- for line in lines:
231
- clean = _syllabus_line(line)
232
- if not clean:
233
- continue
234
- match = UNIT_LINE.search(clean)
235
- if match:
236
- if current:
237
- units.append(current)
238
- label = match.group(1).strip().title()
239
- rest = match.group(2).strip(" :-")
240
- if " - " in rest:
241
- title, inline_content = rest.split(" - ", 1)
242
- elif _words(rest) > 12:
243
- title, inline_content = "", rest
244
- else:
245
- title, inline_content = rest, ""
246
- current = {
247
- "title": f"{label}: {title.strip()}" if title.strip() else label,
248
- "content": inline_content.strip(),
249
- "hours": 0,
250
- }
251
- continue
252
- if current:
253
- current["content"] = f"{current['content']} {clean}".strip()
254
-
255
- if current:
256
- units.append(current)
257
- if len(units) == 4:
258
- return units
259
- return _four_units_from_raw(content) if fallback else []
260
-
261
-
262
- def _unit_text(unit: dict) -> str:
263
- title = str(unit.get("title", "")).strip()
264
- content = str(unit.get("content", "")).strip()
265
- if title and content:
266
- return f"{title}: {content}"
267
- return title or content
268
-
269
-
270
- def _fit_four_units(units: list[dict], raw_content: str) -> list[dict]:
271
- raw = _course_contents(raw_content).strip()
272
- raw_units = _units_from_course_contents(raw_content, fallback=False)
273
- raw_unit_words = _words(" ".join(_unit_text(unit) for unit in raw_units))
274
- if raw_units and raw_unit_words >= _words(raw) * 0.7:
275
- return raw_units
276
- if raw and _words(" ".join(_unit_text(unit) for unit in units)) < _words(raw) * 0.8:
277
- return raw_units or _units_from_course_contents(raw_content)
278
- if len(units) > 4:
279
- fourth = units[3].copy()
280
- fourth["content"] = " ".join(_unit_text(unit) for unit in units[3:]).strip()
281
- return units[:3] + [fourth]
282
- if len(units) == 4:
283
- return units
284
- if raw:
285
- return raw_units or _units_from_course_contents(raw_content)
286
- return units
287
-
288
-
289
- def _units(value) -> list[dict]:
290
- units = []
291
- for item in value if isinstance(value, list) else []:
292
- if not isinstance(item, dict):
293
- continue
294
- title = str(item.get("title", "")).strip()
295
- content = str(item.get("content", "")).strip()
296
- hours_raw = str(item.get("hours", "0"))
297
- hours = int("".join(ch for ch in hours_raw if ch.isdigit()) or 0)
298
- if title or content:
299
- units.append({"title": title, "content": content, "hours": hours})
300
- return units
301
-
302
-
303
- def _assign_hours(units: list[dict], total_hours: int) -> list[dict]:
304
- if not units:
305
- return units
306
- if sum(unit["hours"] for unit in units) == total_hours:
307
- return units
308
- base, extra = divmod(total_hours, len(units))
309
- for index, unit in enumerate(units):
310
- unit["hours"] = base + (1 if index < extra else 0)
311
- return units
312
-
313
-
314
- def _text(*values) -> str:
315
- for value in values:
316
- if isinstance(value, str):
317
- text = value.strip()
318
- if text and text != "-":
319
- return text
320
- return ""
321
-
322
-
323
- def _course_code(raw_content: str) -> str:
324
- match = COURSE_CODE.search(raw_content or "")
325
- return match.group(1).upper() if match else ""
326
-
327
-
328
- def _prior_course_titles(sub: dict) -> list[str]:
329
- semester = int(sub["semester"])
330
- if semester <= 1:
331
- return []
332
- rows = (
333
- supabase.table("refined_submissions")
334
- .select("submission_id,course_code,course_title,semester")
335
- .lt("semester", semester)
336
- .order("semester")
337
- .execute()
338
- .data
339
- )
340
- ids = [row["submission_id"] for row in rows if row.get("submission_id")]
341
- submissions = supabase.table("submissions").select("id,target_department,raw_course_content").in_("id", ids).execute().data if ids else []
342
- departments = {row["id"]: row.get("target_department") for row in submissions}
343
- raw_codes = {row["id"]: _course_code(row.get("raw_course_content") or "") for row in submissions}
344
- titles = []
345
- for row in rows:
346
- if departments.get(row.get("submission_id")) != sub.get("target_department"):
347
- continue
348
- title = str(row.get("course_title") or "").strip()
349
- code = str(row.get("course_code") or raw_codes.get(row.get("submission_id")) or "").strip()
350
- label = f"{code} - {title}" if code else title
351
- if title and label not in titles:
352
- titles.append(label)
353
- return titles
354
-
355
-
356
- def _normalized(text: str) -> str:
357
- return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip()
358
-
359
-
360
- def _singularized(text: str) -> str:
361
- return " ".join(word[:-1] if len(word) > 3 and word.endswith("s") else word for word in _normalized(text).split())
362
-
363
-
364
- def _core_key(text: str) -> str:
365
- skip = {"a", "an", "and", "for", "in", "its", "of", "the", "to", "with"}
366
- return " ".join(word for word in _singularized(text).split() if word not in skip)
367
-
368
-
369
- def _prior_keys(course: str) -> list[str]:
370
- match = re.match(r"\s*([A-Z]{2}\d{2}[A-Z0-9]+)\s*[-:]\s*(.+)", course)
371
- code = match.group(1).lower() if match else ""
372
- title = match.group(2) if match else course
373
- return [key for key in (code, _normalized(title), _singularized(title), _core_key(title)) if key]
374
-
375
-
376
- def _prior_matches(text: str, prior_courses: list[str]) -> str:
377
- normalized = _normalized(text)
378
- singularized = _singularized(text)
379
- core = _core_key(text)
380
- 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))]
381
- return ", ".join(matches)
382
-
383
-
384
- def _submitted_desirable(raw_content: str, prior_courses: list[str]) -> str | None:
385
- match = DESIRABLE_LINE.search(raw_content or "")
386
- if not match:
387
- return None
388
- value = _text(match.group(1))
389
- if not value:
390
- return ""
391
- return _prior_matches(value, prior_courses) or None
392
-
393
-
394
- def _desirable(value, prior_courses: list[str], raw_content: str = "") -> str:
395
- if not prior_courses:
396
- return ""
397
- submitted = _submitted_desirable(raw_content, prior_courses)
398
- if submitted is not None:
399
- return submitted
400
- return _prior_matches(_text(value), prior_courses)
401
-
402
-
403
- def _courses_text(courses: list[str]) -> str:
404
- return "\n".join(f"- {course}" for course in courses) if courses else "None"
405
-
406
-
407
- def build_refined_payload(sub: dict, out: dict, prior_courses: list[str] | None = None) -> dict:
408
- out = out or {}
409
- prior_courses = prior_courses or []
410
- det = compute_hours(sub["credit_category"])
411
- total_unit_hours = det["lecture_hours"] * 14
412
- raw_content = sub.get("raw_course_content") or ""
413
- text_book_source = _raw_book_section(raw_content, "text") or sub["text_books"]
414
- reference_book_source = _raw_book_section(raw_content, "reference") or sub.get("reference_books")
415
- units = _assign_hours(_fit_four_units(_units(out.get("units")), sub.get("raw_course_content") or ""), total_unit_hours)
416
-
417
- objectives = _lines(out.get("objectives"))[:4]
418
- course_outcomes = _lines(out.get("course_outcomes"))[:4] or objectives
419
-
420
- return {
421
- "submission_id": sub["id"],
422
- "semester": int(sub["semester"]),
423
- "course_code": _course_code(raw_content),
424
- "course_title": _text(out.get("course_title"), sub["course_title"]),
425
- "program": compute_program(sub["target_department"]),
426
- "course_type": compute_course_type(sub["credit_category"]),
427
- **det,
428
- "prelude": _text(out.get("prelude"), f"This course covers {sub['course_title'].strip()}."),
429
- "objectives": objectives,
430
- "course_outcomes": course_outcomes,
431
- "units": units,
432
- "lab_experiments": _lines(out.get("lab_experiments"))[:10] if det["practical_hours"] else [],
433
- "tools_languages": _text(out.get("tools_languages"), sub.get("preferred_tools")),
434
- "desirable_knowledge": _desirable(out.get("desirable_knowledge"), prior_courses, raw_content),
435
- "text_books": _books(text_book_source),
436
- "reference_books": _books(reference_book_source),
437
- "status": "refined",
438
- }
439
-
440
-
441
- def refine(submission_id: int):
442
- sub = first_row(supabase.table("submissions").select("*").eq("id", submission_id))
443
- if not sub:
444
- raise LookupError("Submission not found")
445
- det = compute_hours(sub["credit_category"])
446
- ctype = compute_course_type(sub["credit_category"])
447
- total_unit_hours = det["lecture_hours"] * 14
448
- prior_courses = _prior_course_titles(sub)
449
-
450
- prompt = f"""Return JSON matching this schema. Include every key:
451
- {SCHEMA}
452
-
453
- {EXAMPLES}
454
-
455
- Now refine the real submission below.
456
-
457
- Course Title: {sub["course_title"]}
458
- Offering Department: {sub["offering_department"]}
459
- Target Department: {sub["target_department"]}
460
- Semester: {sub["semester"]}
461
- Credit Category: {sub["credit_category"]}
462
- Course Type: {ctype}
463
- Weekly Hours: L {det["lecture_hours"]}, T {det["tutorial_hours"]}, P {det["practical_hours"]}, S {det["self_study"]}, C {det["credits"]}
464
- Total Unit Hours: {total_unit_hours}
465
- Raw Course Content:
466
- {sub["raw_course_content"]}
467
-
468
- Previously Completed Courses:
469
- {_courses_text(prior_courses)}
470
-
471
- Text Books:
472
- {sub["text_books"]}
473
-
474
- Reference Books:
475
- {sub.get("reference_books") or "-"}
476
-
477
- Preferred Tools / Languages:
478
- {sub.get("preferred_tools") or "-"}"""
479
-
480
- out = llm(SYS, prompt)
481
- merged = build_refined_payload(sub, out, prior_courses)
482
-
483
- existing = supabase.table("refined_submissions").select("id").eq("submission_id", submission_id).execute().data
484
- if existing:
485
- supabase.table("refined_submissions").update(merged).eq("submission_id", submission_id).execute()
486
- else:
487
- supabase.table("refined_submissions").insert(merged).execute()
488
-
489
- supabase.table("submissions").update({"status": "refined"}).eq("id", submission_id).execute()
490
-
491
- return merged
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/services/schema.py DELETED
@@ -1,28 +0,0 @@
1
- from postgrest.exceptions import APIError
2
-
3
- from app.supabase import supabase
4
-
5
- REQUIRED_TABLES = (
6
- "submissions",
7
- "refined_submissions",
8
- "curriculum_versions",
9
- "finalized_submissions",
10
- "agent_document_drafts",
11
- "agent_drafts",
12
- "course_revision_history",
13
- "chat_sessions",
14
- "chat_messages",
15
- "chat_attachments",
16
- )
17
-
18
-
19
- def schema_status() -> dict:
20
- missing = []
21
- for table in REQUIRED_TABLES:
22
- try:
23
- supabase.table(table).select("id").limit(1).execute()
24
- except APIError as exc:
25
- if "schema cache" not in str(exc):
26
- raise
27
- missing.append(table)
28
- return {"ok": not missing, "missing_tables": missing, "required_tables": list(REQUIRED_TABLES)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/supabase.py CHANGED
@@ -5,17 +5,12 @@ import httpx
5
 
6
  load_dotenv("../.env")
7
 
8
- url = environ["SUPABASE_URL"].strip()
9
- key = environ["SUPABASE_KEY"].strip()
10
- supabase = create_client(
11
  supabase_url=url,
12
  supabase_key=key,
13
  options=ClientOptions(
14
  httpx_client=httpx.Client(http2=False),
15
  ),
16
- )
17
-
18
-
19
- def first_row(query) -> dict | None:
20
- rows = query.limit(1).execute().data
21
- return rows[0] if rows else None
 
5
 
6
  load_dotenv("../.env")
7
 
8
+ url=environ["SUPABASE_URL"]
9
+ key=environ["SUPABASE_KEY"]
10
+ supabase=create_client(
11
  supabase_url=url,
12
  supabase_key=key,
13
  options=ClientOptions(
14
  httpx_client=httpx.Client(http2=False),
15
  ),
16
+ )
 
 
 
 
 
{frontend → backend/app/templates}/images/image1.png RENAMED
File without changes
{frontend → backend/app/templates}/images/image2.png RENAMED
File without changes
backend/app/templates/jinja_1_to_8.html DELETED
@@ -1,240 +0,0 @@
1
- {% macro semester_name(value) -%}
2
- {{ {"1": "I", "2": "II", "3": "III", "4": "IV", "7": "VII", "8": "VIII"}.get(value|string, value) }}
3
- {%- endmacro %}
4
-
5
- {% macro course_row(course, number) -%}
6
- <tr>
7
- <td class="center">{{ number }}</td>
8
- <td>{{ course.course_code | course_code_for_year(course.semester, curriculum_year) }}</td>
9
- <td>{{ course.course_title }}</td>
10
- <td class="center">{{ course.lecture_hours }}</td>
11
- <td class="center">{{ course.tutorial_hours }}</td>
12
- <td class="center">{{ course.practical_hours }}</td>
13
- <td class="center">{{ course.self_study }}</td>
14
- <td class="center">{{ course.credits }}</td>
15
- <td>{{ course.tools_languages }}</td>
16
- <td>{{ course.course_type }}</td>
17
- </tr>
18
- {%- endmacro %}
19
-
20
- {% macro final_year_row(course, number) -%}
21
- <tr>
22
- <td class="center">{{ number }}</td>
23
- <td>{{ course.course_code | course_code_for_year(course.semester, curriculum_year) }}</td>
24
- <td>{{ course.course_title }}</td>
25
- <td class="center">{{ course.lecture_hours }}</td>
26
- <td class="center">{{ course.tutorial_hours }}</td>
27
- <td class="center">{{ course.practical_hours }}</td>
28
- <td class="center">{{ course.self_study }}</td>
29
- <td class="center">{{ course.credits }}</td>
30
- <td class="center">{{ course.course_type }}</td>
31
- </tr>
32
- {%- endmacro %}
33
-
34
- {% macro total_cell(total, credit_total) -%}
35
- {% if total != credit_total %}{{ credit_total }}/{{ total }}{% else %}{{ total }}{% endif %}
36
- {%- endmacro %}
37
-
38
- {% macro regular_table(summary_semester, show_total) -%}
39
- {% set totals = namespace(count=0, lecture=0, lecture_credit=0, tutorial=0, practical=0, self_study=0, credits=0) %}
40
- {% for summary_course in course_list %}
41
- {% if summary_course.semester|string == summary_semester|string %}
42
- {% set credit_value = summary_course.credits|int %}
43
- {% set totals.count = totals.count + 1 %}
44
- {% set totals.lecture = totals.lecture + summary_course.lecture_hours|int %}
45
- {% set totals.tutorial = totals.tutorial + summary_course.tutorial_hours|int %}
46
- {% set totals.practical = totals.practical + summary_course.practical_hours|int %}
47
- {% set totals.self_study = totals.self_study + summary_course.self_study|int %}
48
- {% set totals.credits = totals.credits + credit_value %}
49
- {% if credit_value > 0 %}
50
- {% set totals.lecture_credit = totals.lecture_credit + summary_course.lecture_hours|int %}
51
- {% endif %}
52
- {% endif %}
53
- {% endfor %}
54
-
55
- {% if totals.count %}
56
- <h2 class="semester-title">{{ semester_name(summary_semester) }} SEMESTER {{ batch_label(summary_semester, curriculum_year) }}</h2>
57
- <table class="meta summary-table">
58
- <colgroup>
59
- <col class="summary-number" />
60
- <col class="summary-code" />
61
- <col class="summary-title-column" />
62
- <col class="summary-hour" />
63
- <col class="summary-hour" />
64
- <col class="summary-hour" />
65
- <col class="summary-hour" />
66
- <col class="summary-credit" />
67
- <col class="summary-tools" />
68
- <col class="summary-type" />
69
- </colgroup>
70
- <tbody>
71
- <tr>
72
- <th rowspan="2" class="center">Sl. No.</th>
73
- <th rowspan="2">Course Code</th>
74
- <th rowspan="2">Course Title</th>
75
- <th colspan="4" class="center">Hours per week</th>
76
- <th class="center nowrap">Credits</th>
77
- <th rowspan="2">AI Tools / Tools /<br /> Languages</th>
78
- <th rowspan="2">Course Type</th>
79
- </tr>
80
- <tr>
81
- <th class="center">L</th>
82
- <th class="center">T</th>
83
- <th class="center">P</th>
84
- <th class="center">S</th>
85
- <th class="center">C</th>
86
- </tr>
87
-
88
- {% set row = namespace(number=1) %}
89
- {% for summary_course in course_list %}
90
- {% if summary_course.semester|string == summary_semester|string %}
91
- {{ course_row(summary_course, row.number) }}
92
- {% set row.number = row.number + 1 %}
93
- {% endif %}
94
- {% endfor %}
95
-
96
- {% if show_total %}
97
- <tr>
98
- <th colspan="3" class="center">Total</th>
99
- <td class="center">{{ total_cell(totals.lecture, totals.lecture_credit) }}</td>
100
- <td class="center">{{ totals.tutorial }}</td>
101
- <td class="center">{{ totals.practical }}</td>
102
- <td class="center">{{ totals.self_study }}</td>
103
- <td class="center">{{ totals.credits }}</td>
104
- <td></td>
105
- <td></td>
106
- </tr>
107
- {% endif %}
108
- </tbody>
109
- </table>
110
- {% endif %}
111
- {%- endmacro %}
112
-
113
- {% set requested_semester = semester|string if semester is defined and semester else "" %}
114
- {% if summary_range | default("") != "final" %}
115
- {% set early = namespace(count=0) %}
116
- {% for summary_course in course_list %}
117
- {% if summary_course.semester|string in ["1", "2"] and (not requested_semester or requested_semester == summary_course.semester|string) %}
118
- {% set early.count = early.count + 1 %}
119
- {% endif %}
120
- {% endfor %}
121
-
122
- {% if early.count %}
123
- <article class="summary-page">
124
- <header class="letterhead">
125
- <img class="seal" src="{{ asset_root | default('') }}images/image2.png" alt="PES University logo" />
126
- <img class="wordmark" src="{{ asset_root | default('') }}images/image1.png" alt="PES University" />
127
- </header>
128
- {% for summary_semester in [1, 2] %}
129
- {% if not requested_semester or requested_semester == summary_semester|string %}
130
- {{ regular_table(summary_semester, false) }}
131
- {% endif %}
132
- {% endfor %}
133
- </article>
134
- {% endif %}
135
-
136
- {% for summary_semester in [3, 4] %}
137
- {% if not requested_semester or requested_semester == summary_semester|string %}
138
- {% set current = namespace(count=0) %}
139
- {% for summary_course in course_list %}
140
- {% if summary_course.semester|string == summary_semester|string %}
141
- {% set current.count = current.count + 1 %}
142
- {% endif %}
143
- {% endfor %}
144
-
145
- {% if current.count %}
146
- <article class="summary-page">
147
- <header class="letterhead">
148
- <img class="seal" src="{{ asset_root | default('') }}images/image2.png" alt="PES University logo" />
149
- <img class="wordmark" src="{{ asset_root | default('') }}images/image1.png" alt="PES University" />
150
- </header>
151
- {{ regular_table(summary_semester, true) }}
152
- </article>
153
- {% endif %}
154
- {% endif %}
155
- {% endfor %}
156
- {% endif %}
157
-
158
- {% if summary_range | default("") != "early" and (not requested_semester or requested_semester in ["7", "8"]) %}
159
- {% set final = namespace(count=0) %}
160
- {% for summary_course in course_list %}
161
- {% if summary_course.semester|string in ["7", "8"] and (not requested_semester or requested_semester == summary_course.semester|string) %}
162
- {% set final.count = final.count + 1 %}
163
- {% endif %}
164
- {% endfor %}
165
-
166
- {% if final.count %}
167
- <article class="summary-page final-year-summary">
168
- <header class="letterhead">
169
- <img class="seal" src="{{ asset_root | default('') }}images/image2.png" alt="PES University logo" />
170
- <img class="wordmark" src="{{ asset_root | default('') }}images/image1.png" alt="PES University" />
171
- </header>
172
-
173
- {% for summary_semester in [7, 8] %}
174
- {% if not requested_semester or requested_semester == summary_semester|string %}
175
- {% set totals = namespace(count=0, lecture=0, tutorial=0, practical=0, self_study=0, credits=0) %}
176
- {% for summary_course in course_list %}
177
- {% if summary_course.semester|string == summary_semester|string %}
178
- {% set totals.count = totals.count + 1 %}
179
- {% set totals.lecture = totals.lecture + summary_course.lecture_hours|int %}
180
- {% set totals.tutorial = totals.tutorial + summary_course.tutorial_hours|int %}
181
- {% set totals.practical = totals.practical + summary_course.practical_hours|int %}
182
- {% set totals.self_study = totals.self_study + summary_course.self_study|int %}
183
- {% set totals.credits = totals.credits + summary_course.credits|int %}
184
- {% endif %}
185
- {% endfor %}
186
-
187
- {% if totals.count %}
188
- <h2 class="semester-title">{{ semester_name(summary_semester) }} SEMESTER {{ batch_label(summary_semester, curriculum_year) }}</h2>
189
- <table class="meta summary-table final-year-table">
190
- <colgroup>
191
- <col class="final-number" />
192
- <col class="final-code" />
193
- <col class="final-title" />
194
- <col class="final-hour" />
195
- <col class="final-hour" />
196
- <col class="final-hour" />
197
- <col class="final-hour" />
198
- <col class="final-credit" />
199
- <col class="final-type" />
200
- </colgroup>
201
- <tbody>
202
- <tr>
203
- <th rowspan="2" class="center">Sl. No.</th>
204
- <th rowspan="2">Course Code</th>
205
- <th rowspan="2">Course Title</th>
206
- <th colspan="4" class="center">Hours/week</th>
207
- <th class="center nowrap">Credits</th>
208
- <th rowspan="2" class="center">Course Type</th>
209
- </tr>
210
- <tr>
211
- <th class="center">L</th>
212
- <th class="center">T</th>
213
- <th class="center">P</th>
214
- <th class="center">S</th>
215
- <th class="center">C</th>
216
- </tr>
217
- {% set row = namespace(number=1) %}
218
- {% for summary_course in course_list %}
219
- {% if summary_course.semester|string == summary_semester|string %}
220
- {{ final_year_row(summary_course, row.number) }}
221
- {% set row.number = row.number + 1 %}
222
- {% endif %}
223
- {% endfor %}
224
- <tr>
225
- <th colspan="3" class="center">Total</th>
226
- <td class="center">{{ totals.lecture }}</td>
227
- <td class="center">{{ totals.tutorial }}</td>
228
- <td class="center">{{ totals.practical }}</td>
229
- <td class="center">{{ totals.self_study }}</td>
230
- <td class="center">{{ totals.credits }}</td>
231
- <td></td>
232
- </tr>
233
- </tbody>
234
- </table>
235
- {% endif %}
236
- {% endif %}
237
- {% endfor %}
238
- </article>
239
- {% endif %}
240
- {% endif %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/templates/jinja_sample.html CHANGED
@@ -1,427 +1,1478 @@
1
- <!doctype html>
2
- <html lang="en">
3
  <head>
4
- <meta charset="UTF-8" />
5
- <style>
6
- @page {
7
- size: A4;
8
- margin: 14mm 12mm 18mm;
9
-
10
- @bottom-left {
11
- content: "P.E.S. University";
12
- color: #fff;
13
- font: 700 10pt Arial, sans-serif;
14
- padding: 4pt 0 4pt 12mm;
15
- }
16
-
17
- @bottom-center {
18
- content: "Curriculum :- {{ curriculum_year }}";
19
- color: #fff;
20
- font: 700 10pt Arial, sans-serif;
21
- padding: 4pt 0;
22
- }
23
-
24
- @bottom-right {
25
- content: counter(page) " | Page";
26
- color: #fff;
27
- font: 700 10pt Arial, sans-serif;
28
- padding: 4pt 12mm 4pt 0;
29
- text-align: right;
30
- }
31
  }
32
-
33
- * {
34
- box-sizing: border-box;
35
  }
36
-
37
- body {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  margin: 0;
39
- color: #000;
40
- font-family: Calibri, Arial, sans-serif;
41
- font-size: 10pt;
42
- line-height: 1.2;
43
  }
44
-
45
- .course + .course,
46
- .summary-page + .summary-page,
47
- .summary-page + .course {
48
- break-before: page;
49
- page-break-before: always;
50
  }
51
-
52
- .letterhead {
53
- display: flex;
54
- align-items: center;
55
- justify-content: center;
56
- gap: 12pt;
57
- margin-bottom: 10pt;
 
 
 
 
 
 
 
 
 
58
  }
59
-
60
- .seal {
61
- width: 41px;
62
- height: auto;
 
 
 
 
 
 
 
 
 
 
 
 
63
  }
64
-
65
- .wordmark {
66
- width: 265px;
67
- height: auto;
 
 
 
 
 
 
 
 
 
 
 
 
68
  }
69
-
70
- table {
71
- width: 100%;
72
- border-collapse: collapse;
73
- table-layout: fixed;
 
 
 
 
 
 
 
 
 
 
 
74
  }
75
-
76
- .meta {
77
- margin-bottom: -1pt;
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  }
79
-
80
- th,
81
- td {
82
- border: 1pt solid #000;
83
- padding: 4pt 5pt;
 
84
  vertical-align: top;
85
- overflow-wrap: anywhere;
 
 
 
 
 
 
 
 
86
  }
87
-
88
- th {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  font-weight: 700;
 
 
 
 
 
 
 
 
 
 
 
 
90
  text-align: left;
 
91
  }
92
-
93
- .center {
94
- text-align: center;
 
 
 
 
 
95
  }
96
-
97
- .label {
98
- width: 18%;
 
 
 
 
 
99
  }
100
-
101
- .hour {
102
- width: 7%;
 
 
 
 
 
103
  }
104
-
105
- p {
106
- margin: 0 0 5pt;
 
 
 
 
 
107
  }
108
-
109
- p:last-child {
110
- margin-bottom: 0;
 
 
 
 
 
111
  }
112
-
113
- ul,
114
- ol {
115
- margin: 0;
116
- padding-left: 16pt;
 
 
 
117
  }
118
-
119
- li {
120
- margin-bottom: 3pt;
 
 
 
 
 
121
  }
122
-
123
- li:last-child {
124
- margin-bottom: 0;
 
 
 
 
 
125
  }
126
-
127
- .unit-title,
128
- .hours {
129
  font-weight: 700;
 
 
 
 
 
130
  }
131
-
132
- .unit {
133
- margin-bottom: 8pt;
 
 
 
 
 
134
  }
135
-
136
- .unit:last-child {
137
- margin-bottom: 0;
 
 
 
 
 
138
  }
139
-
140
- .hours {
141
- text-align: right;
 
 
 
 
 
142
  }
143
-
144
- .resource-link {
145
- color: inherit;
146
- text-decoration: underline;
 
 
 
 
147
  }
148
-
149
- .semester-title {
150
- margin: 0 0 8pt;
 
 
 
 
 
 
 
 
 
 
151
  text-align: center;
152
- font-size: 11pt;
153
- font-weight: 700;
154
  }
155
-
156
- .summary-table {
157
- margin-bottom: 8pt;
 
 
 
 
158
  }
159
-
160
- .specialization-table p {
161
- margin: 0 0 2pt;
 
 
 
 
162
  }
163
-
164
- .summary-table th,
165
- .summary-table td {
166
- padding: 3pt 4pt;
167
- font-size: 9pt;
 
 
168
  }
169
-
170
- .summary-table th {
171
- font-size: 8.5pt;
 
 
 
 
172
  }
173
-
174
- .summary-number {
175
- width: 6%;
 
 
 
 
176
  }
177
-
178
- .summary-code {
179
- width: 16%;
 
 
 
 
180
  }
181
-
182
- .summary-title-column {
183
- width: 18%;
 
 
 
 
184
  }
185
-
186
- .summary-hour {
187
- width: 5.5%;
 
 
 
 
188
  }
189
-
190
- .summary-credit {
191
- width: 8%;
 
 
 
 
192
  }
193
-
194
- .summary-tools {
195
- width: 18%;
 
 
 
196
  }
197
-
198
- .summary-type {
199
- width: 12%;
 
 
 
200
  }
201
-
202
- .nowrap {
203
- white-space: nowrap;
 
 
 
204
  }
205
-
206
- .summary-table td:nth-child(8),
207
- .summary-table th:nth-child(8) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  text-align: center;
 
209
  }
210
-
211
- .summary-table td:nth-child(n + 4):nth-child(-n + 8),
212
- .summary-table th:nth-child(n + 4):nth-child(-n + 8) {
213
- padding-left: 2pt;
214
- padding-right: 2pt;
215
- white-space: nowrap;
216
  }
217
-
218
- .summary-table td:nth-child(2),
219
- .summary-table th:nth-child(2) {
220
- white-space: nowrap;
 
 
221
  }
222
-
223
- .final-number {
224
- width: 7%;
 
 
 
225
  }
226
-
227
- .final-code {
228
- width: 16%;
 
 
 
229
  }
230
-
231
- .final-title {
232
- width: 23%;
 
 
 
233
  }
234
-
235
- .final-hour {
236
- width: 6%;
 
 
 
237
  }
238
-
239
- .final-credit {
240
- width: 8%;
 
 
 
241
  }
242
-
243
- .final-type {
244
- width: 22%;
 
 
 
245
  }
246
-
247
- .empty {
248
- margin-top: 18pt;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  text-align: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  font-weight: 700;
251
  }
252
-
253
- .footer-bar {
254
- position: fixed;
255
- left: 0;
256
- right: 0;
257
- bottom: -14mm;
258
- height: 9mm;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  background: #000;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  }
261
  </style>
262
  </head>
263
- <body>
264
- <div class="footer-bar"></div>
265
- {% set course_list = courses if courses is defined else [course] %}
266
- {% if course_list %}
267
- {% if show_summaries | default(false) %}
268
- {% set summary_range = "early" %}
269
- {% include "jinja_1_to_8.html" %}
270
- {% include "jinja_sem_5_6.html" %}
271
- {% set summary_range = "final" %}
272
- {% include "jinja_1_to_8.html" %}
273
- {% endif %}
274
- {% for course in course_list %}
275
- {% if not show_summaries | default(false) or course.render_detail | default(true) %}
276
- <article class="course">
277
- <header class="letterhead">
278
- <img class="seal" src="{{ asset_root | default('') }}images/image2.png" alt="PES University logo" />
279
- <img class="wordmark" src="{{ asset_root | default('') }}images/image1.png" alt="PES University" />
280
- </header>
281
-
282
- <table class="meta">
283
- <colgroup>
284
- <col class="label" />
285
- <col />
286
- <col class="label" />
287
- <col class="hour" />
288
- <col class="hour" />
289
- <col class="hour" />
290
- <col class="hour" />
291
- <col class="hour" />
292
- </colgroup>
293
- <tbody>
294
- <tr>
295
- <th>Course Code</th>
296
- <td>{{ course.course_code | course_code_for_year(course.semester, curriculum_year) }}</td>
297
- <th>Course Title</th>
298
- <td colspan="5">{{ course.course_title }}</td>
299
- </tr>
300
- <tr>
301
- <th rowspan="2">Program</th>
302
- <td rowspan="2">{{ course.program }}</td>
303
- <th rowspan="2">Hours per week/ Credit Assigned</th>
304
- <th class="center">L</th>
305
- <th class="center">T</th>
306
- <th class="center">P</th>
307
- <th class="center">S</th>
308
- <th class="center">C</th>
309
- </tr>
310
- <tr>
311
- <td class="center">{{ course.lecture_hours }}</td>
312
- <td class="center">{{ course.tutorial_hours }}</td>
313
- <td class="center">{{ course.practical_hours }}</td>
314
- <td class="center">{{ course.self_study }}</td>
315
- <td class="center">{{ course.credits }}</td>
316
- </tr>
317
- <tr>
318
- <th>Semester</th>
319
- <td>{{ course.semester }}</td>
320
- <th>Type of Course</th>
321
- <td colspan="5">{{ course.course_type }}</td>
322
- </tr>
323
- <tr>
324
- <th>AI Tools / Tools / Languages</th>
325
- <td>{{ course.tools_languages }}</td>
326
- <th>Desirable Knowledge</th>
327
- <td colspan="5">{{ course.desirable_knowledge }}</td>
328
- </tr>
329
- </tbody>
330
- </table>
331
-
332
- <table>
333
- <colgroup>
334
- <col class="label" />
335
- <col />
336
- </colgroup>
337
- <tbody>
338
- <tr>
339
- <th>Prelude</th>
340
- <td>{{ course.prelude }}</td>
341
- </tr>
342
- <tr>
343
- <th>Course Objectives:</th>
344
- <td>
345
- <ul>
346
- {% for objective in course.objectives %}
347
- <li>{{ objective }}</li>
348
- {% endfor %}
349
- </ul>
350
- </td>
351
- </tr>
352
- <tr>
353
- <th>Course Contents</th>
354
- <td>
355
- {% if course.units %}
356
- {% for unit in course.units %}
357
- <div class="unit">
358
- <p class="unit-title">{{ unit.title }}</p>
359
- <p>{{ unit.content }}</p>
360
- <p class="hours">{{ unit.hours }} Hours</p>
361
- </div>
362
- {% endfor %}
363
- {% else %}
364
- -
365
- {% endif %}
366
- </td>
367
- </tr>
368
- {% if course.lab_experiments %}
369
- <tr>
370
- <th>Laboratory</th>
371
- <td>
372
- <ol>
373
- {% for experiment in course.lab_experiments %}
374
- <li>{{ experiment }}</li>
375
- {% endfor %}
376
- </ol>
377
- </td>
378
- </tr>
379
- {% endif %}
380
- <tr>
381
- <th>Text Book(s):</th>
382
- <td>
383
- {% if course.text_books %}
384
- <ol>
385
- {% for book in course.text_books %}
386
- <li>{{ book | linkify }}</li>
387
- {% endfor %}
388
- </ol>
389
- {% else %}
390
- -
391
- {% endif %}
392
- </td>
393
- </tr>
394
- {% if course.reference_books %}
395
- <tr>
396
- <th>Reference Book(s):</th>
397
- <td>
398
- <ol>
399
- {% for book in course.reference_books %}
400
- <li>{{ book | linkify }}</li>
401
- {% endfor %}
402
- </ol>
403
- </td>
404
- </tr>
405
- {% endif %}
406
- {% if course.course_outcomes %}
407
- <tr>
408
- <th>Course Outcome</th>
409
- <td>
410
- <ul>
411
- {% for outcome in course.course_outcomes %}
412
- <li>{{ outcome }}</li>
413
- {% endfor %}
414
- </ul>
415
- </td>
416
- </tr>
417
- {% endif %}
418
- </tbody>
419
- </table>
420
- </article>
421
- {% endif %}
422
- {% endfor %}
423
- {% else %}
424
- <div class="empty">No refined courses found for Semester {{ semester }}.</div>
425
- {% endif %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
  </body>
427
  </html>
 
 
1
+ <html>
 
2
  <head>
3
+ <meta content="text/html; charset=UTF-8" http-equiv="content-type" />
4
+ <style type="text/css">
5
+ .lst-kix_list_2-6 > li:before {
6
+ content: "\002022 ";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  }
8
+ .lst-kix_list_2-7 > li:before {
9
+ content: "\002022 ";
 
10
  }
11
+ ol.lst-kix_list_1-0 {
12
+ list-style-type: none;
13
+ }
14
+ .lst-kix_list_2-4 > li:before {
15
+ content: "\002022 ";
16
+ }
17
+ .lst-kix_list_2-5 > li:before {
18
+ content: "\002022 ";
19
+ }
20
+ .lst-kix_list_2-8 > li:before {
21
+ content: "\002022 ";
22
+ }
23
+ ul.lst-kix_list_2-8 {
24
+ list-style-type: none;
25
+ }
26
+ ul.lst-kix_list_1-3 {
27
+ list-style-type: none;
28
+ }
29
+ ul.lst-kix_list_2-2 {
30
+ list-style-type: none;
31
+ }
32
+ .lst-kix_list_1-0 > li:before {
33
+ content: "" counter(lst-ctn-kix_list_1-0, decimal) ". ";
34
+ }
35
+ ul.lst-kix_list_1-4 {
36
+ list-style-type: none;
37
+ }
38
+ ul.lst-kix_list_2-3 {
39
+ list-style-type: none;
40
+ }
41
+ ul.lst-kix_list_1-1 {
42
+ list-style-type: none;
43
+ }
44
+ ul.lst-kix_list_2-0 {
45
+ list-style-type: none;
46
+ }
47
+ ul.lst-kix_list_1-2 {
48
+ list-style-type: none;
49
+ }
50
+ ul.lst-kix_list_2-1 {
51
+ list-style-type: none;
52
+ }
53
+ ul.lst-kix_list_1-7 {
54
+ list-style-type: none;
55
+ }
56
+ ul.lst-kix_list_2-6 {
57
+ list-style-type: none;
58
+ }
59
+ .lst-kix_list_1-1 > li:before {
60
+ content: "\002022 ";
61
+ }
62
+ .lst-kix_list_1-2 > li:before {
63
+ content: "\002022 ";
64
+ }
65
+ ul.lst-kix_list_1-8 {
66
+ list-style-type: none;
67
+ }
68
+ ul.lst-kix_list_2-7 {
69
+ list-style-type: none;
70
+ }
71
+ ul.lst-kix_list_1-5 {
72
+ list-style-type: none;
73
+ }
74
+ ul.lst-kix_list_2-4 {
75
+ list-style-type: none;
76
+ }
77
+ ul.lst-kix_list_1-6 {
78
+ list-style-type: none;
79
+ }
80
+ ul.lst-kix_list_2-5 {
81
+ list-style-type: none;
82
+ }
83
+ .lst-kix_list_1-3 > li:before {
84
+ content: "\002022 ";
85
+ }
86
+ .lst-kix_list_1-4 > li:before {
87
+ content: "\002022 ";
88
+ }
89
+ ol.lst-kix_list_1-0.start {
90
+ counter-reset: lst-ctn-kix_list_1-0 0;
91
+ }
92
+ .lst-kix_list_1-0 > li {
93
+ counter-increment: lst-ctn-kix_list_1-0;
94
+ }
95
+ li.li-bullet-1:before {
96
+ margin-left: -36.1pt;
97
+ white-space: nowrap;
98
+ display: inline-block;
99
+ min-width: 36.1pt;
100
+ }
101
+ .lst-kix_list_1-7 > li:before {
102
+ content: "\002022 ";
103
+ }
104
+ .lst-kix_list_1-5 > li:before {
105
+ content: "\002022 ";
106
+ }
107
+ .lst-kix_list_1-6 > li:before {
108
+ content: "\002022 ";
109
+ }
110
+ li.li-bullet-0:before {
111
+ margin-left: -18pt;
112
+ white-space: nowrap;
113
+ display: inline-block;
114
+ min-width: 18pt;
115
+ }
116
+ .lst-kix_list_2-0 > li:before {
117
+ content: "\0025cf ";
118
+ }
119
+ .lst-kix_list_2-1 > li:before {
120
+ content: "\002022 ";
121
+ }
122
+ .lst-kix_list_1-8 > li:before {
123
+ content: "\002022 ";
124
+ }
125
+ .lst-kix_list_2-2 > li:before {
126
+ content: "\002022 ";
127
+ }
128
+ .lst-kix_list_2-3 > li:before {
129
+ content: "\002022 ";
130
+ }
131
+ ol {
132
  margin: 0;
133
+ padding: 0;
 
 
 
134
  }
135
+ table td,
136
+ table th {
137
+ padding: 0;
 
 
 
138
  }
139
+ .c11 {
140
+ border-right-style: solid;
141
+ border-bottom-color: #000000;
142
+ border-top-width: 1pt;
143
+ border-right-width: 1pt;
144
+ border-left-color: #000000;
145
+ vertical-align: top;
146
+ border-right-color: #000000;
147
+ border-left-width: 1pt;
148
+ border-top-style: solid;
149
+ background-color: #000000;
150
+ border-left-style: solid;
151
+ border-bottom-width: 1pt;
152
+ width: 71pt;
153
+ border-top-color: #d9d9d9;
154
+ border-bottom-style: solid;
155
  }
156
+ .c74 {
157
+ border-right-style: solid;
158
+ border-bottom-color: #000000;
159
+ border-top-width: 1pt;
160
+ border-right-width: 1pt;
161
+ border-left-color: #000000;
162
+ vertical-align: top;
163
+ border-right-color: #000000;
164
+ border-left-width: 1pt;
165
+ border-top-style: solid;
166
+ background-color: #000000;
167
+ border-left-style: solid;
168
+ border-bottom-width: 1pt;
169
+ width: 48.9pt;
170
+ border-top-color: #d9d9d9;
171
+ border-bottom-style: solid;
172
  }
173
+ .c19 {
174
+ border-right-style: solid;
175
+ border-bottom-color: #000000;
176
+ border-top-width: 1pt;
177
+ border-right-width: 1pt;
178
+ border-left-color: #000000;
179
+ vertical-align: top;
180
+ border-right-color: #000000;
181
+ border-left-width: 1pt;
182
+ border-top-style: solid;
183
+ background-color: #000000;
184
+ border-left-style: solid;
185
+ border-bottom-width: 1pt;
186
+ width: 42.5pt;
187
+ border-top-color: #d9d9d9;
188
+ border-bottom-style: solid;
189
  }
190
+ .c60 {
191
+ border-right-style: solid;
192
+ border-bottom-color: #000000;
193
+ border-top-width: 1pt;
194
+ border-right-width: 1pt;
195
+ border-left-color: #000000;
196
+ vertical-align: top;
197
+ border-right-color: #000000;
198
+ border-left-width: 1pt;
199
+ border-top-style: solid;
200
+ background-color: #000000;
201
+ border-left-style: solid;
202
+ border-bottom-width: 1pt;
203
+ width: 441.2pt;
204
+ border-top-color: #d9d9d9;
205
+ border-bottom-style: solid;
206
  }
207
+ .c72 {
208
+ border-right-style: solid;
209
+ border-bottom-color: #000000;
210
+ border-top-width: 1pt;
211
+ border-right-width: 1pt;
212
+ border-left-color: #000000;
213
+ vertical-align: top;
214
+ border-right-color: #000000;
215
+ border-left-width: 1pt;
216
+ border-top-style: solid;
217
+ background-color: #000000;
218
+ border-left-style: solid;
219
+ border-bottom-width: 1pt;
220
+ width: 42.1pt;
221
+ border-top-color: #d9d9d9;
222
+ border-bottom-style: solid;
223
  }
224
+ .c102 {
225
+ border-right-style: solid;
226
+ border-bottom-color: #000000;
227
+ border-top-width: 1pt;
228
+ border-right-width: 1pt;
229
+ border-left-color: #000000;
230
  vertical-align: top;
231
+ border-right-color: #000000;
232
+ border-left-width: 1pt;
233
+ border-top-style: solid;
234
+ background-color: #000000;
235
+ border-left-style: solid;
236
+ border-bottom-width: 1pt;
237
+ width: 35.5pt;
238
+ border-top-color: #d9d9d9;
239
+ border-bottom-style: solid;
240
  }
241
+ .c24 {
242
+ border-right-style: solid;
243
+ border-bottom-color: #000000;
244
+ border-top-width: 1pt;
245
+ border-right-width: 1pt;
246
+ border-left-color: #000000;
247
+ vertical-align: top;
248
+ border-right-color: #000000;
249
+ border-left-width: 1pt;
250
+ border-top-style: solid;
251
+ background-color: #000000;
252
+ border-left-style: solid;
253
+ border-bottom-width: 1pt;
254
+ width: 49.6pt;
255
+ border-top-color: #d9d9d9;
256
+ border-bottom-style: solid;
257
+ }
258
+ .c32 {
259
+ border-right-style: solid;
260
+ border-bottom-color: #d9d9d9;
261
+ border-top-width: 1pt;
262
+ border-right-width: 1pt;
263
+ border-left-color: #000000;
264
+ vertical-align: top;
265
+ border-right-color: #000000;
266
+ border-left-width: 1pt;
267
+ border-top-style: solid;
268
+ border-left-style: solid;
269
+ border-bottom-width: 1pt;
270
+ width: 441.2pt;
271
+ border-top-color: #000000;
272
+ border-bottom-style: solid;
273
+ }
274
+ .c61 {
275
+ border-right-style: solid;
276
+ border-bottom-color: #000000;
277
+ border-top-width: 1pt;
278
+ border-right-width: 0pt;
279
+ border-left-color: #000000;
280
+ vertical-align: top;
281
+ border-right-color: #000000;
282
+ border-left-width: 1pt;
283
+ border-top-style: solid;
284
+ border-left-style: solid;
285
+ border-bottom-width: 0pt;
286
+ width: 30.4pt;
287
+ border-top-color: #d9d9d9;
288
+ border-bottom-style: solid;
289
+ }
290
+ .c44 {
291
+ border-right-style: solid;
292
+ border-bottom-color: #000000;
293
+ border-top-width: 1pt;
294
+ border-right-width: 1pt;
295
+ border-left-color: #000000;
296
+ vertical-align: top;
297
+ border-right-color: #000000;
298
+ border-left-width: 1pt;
299
+ border-top-style: solid;
300
+ border-left-style: solid;
301
+ border-bottom-width: 1pt;
302
+ width: 441.2pt;
303
+ border-top-color: #000000;
304
+ border-bottom-style: solid;
305
+ }
306
+ .c15 {
307
+ border-right-style: solid;
308
+ border-bottom-color: #000000;
309
+ border-top-width: 1pt;
310
+ border-right-width: 1pt;
311
+ border-left-color: #000000;
312
+ vertical-align: top;
313
+ border-right-color: #000000;
314
+ border-left-width: 1pt;
315
+ border-top-style: solid;
316
+ border-left-style: solid;
317
+ border-bottom-width: 1pt;
318
+ width: 48.9pt;
319
+ border-top-color: #000000;
320
+ border-bottom-style: solid;
321
+ }
322
+ .c14 {
323
+ border-right-style: solid;
324
+ border-bottom-color: #000000;
325
+ border-top-width: 1pt;
326
+ border-right-width: 1pt;
327
+ border-left-color: #000000;
328
+ vertical-align: top;
329
+ border-right-color: #000000;
330
+ border-left-width: 1pt;
331
+ border-top-style: solid;
332
+ border-left-style: solid;
333
+ border-bottom-width: 1pt;
334
+ width: 49.6pt;
335
+ border-top-color: #000000;
336
+ border-bottom-style: solid;
337
+ }
338
+ .c49 {
339
+ border-right-style: solid;
340
+ border-bottom-color: #d9d9d9;
341
+ border-top-width: 0pt;
342
+ border-right-width: 1pt;
343
+ border-left-color: #000000;
344
+ vertical-align: top;
345
+ border-right-color: #000000;
346
+ border-left-width: 0pt;
347
+ border-top-style: solid;
348
+ border-left-style: solid;
349
+ border-bottom-width: 1pt;
350
+ width: 17.3pt;
351
+ border-top-color: #000000;
352
+ border-bottom-style: solid;
353
+ }
354
+ .c80 {
355
+ border-right-style: solid;
356
+ border-bottom-color: #d9d9d9;
357
+ border-top-width: 1pt;
358
+ border-right-width: 1pt;
359
+ border-left-color: #000000;
360
+ vertical-align: top;
361
+ border-right-color: #000000;
362
+ border-left-width: 1pt;
363
+ border-top-style: solid;
364
+ border-left-style: solid;
365
+ border-bottom-width: 1pt;
366
+ width: 48.9pt;
367
+ border-top-color: #000000;
368
+ border-bottom-style: solid;
369
+ }
370
+ .c63 {
371
+ border-right-style: solid;
372
+ border-bottom-color: #000000;
373
+ border-top-width: 1pt;
374
+ border-right-width: 1pt;
375
+ border-left-color: #000000;
376
+ vertical-align: top;
377
+ border-right-color: #000000;
378
+ border-left-width: 1pt;
379
+ border-top-style: solid;
380
+ border-left-style: solid;
381
+ border-bottom-width: 1pt;
382
+ width: 76pt;
383
+ border-top-color: #d9d9d9;
384
+ border-bottom-style: solid;
385
+ }
386
+ .c43 {
387
+ border-right-style: solid;
388
+ border-bottom-color: #000000;
389
+ border-top-width: 1pt;
390
+ border-right-width: 1pt;
391
+ border-left-color: #000000;
392
+ vertical-align: top;
393
+ border-right-color: #000000;
394
+ border-left-width: 1pt;
395
+ border-top-style: solid;
396
+ border-left-style: solid;
397
+ border-bottom-width: 1pt;
398
+ width: 42.5pt;
399
+ border-top-color: #000000;
400
+ border-bottom-style: solid;
401
+ }
402
+ .c46 {
403
+ border-right-style: solid;
404
+ border-bottom-color: #d9d9d9;
405
+ border-top-width: 1pt;
406
+ border-right-width: 1pt;
407
+ border-left-color: #000000;
408
+ vertical-align: top;
409
+ border-right-color: #000000;
410
+ border-left-width: 1pt;
411
+ border-top-style: solid;
412
+ border-left-style: solid;
413
+ border-bottom-width: 1pt;
414
+ width: 42.5pt;
415
+ border-top-color: #000000;
416
+ border-bottom-style: solid;
417
+ }
418
+ .c34 {
419
+ border-right-style: solid;
420
+ border-bottom-color: #d9d9d9;
421
+ border-top-width: 1pt;
422
+ border-right-width: 1pt;
423
+ border-left-color: #000000;
424
+ vertical-align: top;
425
+ border-right-color: #000000;
426
+ border-left-width: 1pt;
427
+ border-top-style: solid;
428
+ border-left-style: solid;
429
+ border-bottom-width: 1pt;
430
+ width: 35.5pt;
431
+ border-top-color: #000000;
432
+ border-bottom-style: solid;
433
+ }
434
+ .c52 {
435
+ border-right-style: solid;
436
+ border-bottom-color: #d9d9d9;
437
+ border-top-width: 1pt;
438
+ border-right-width: 1pt;
439
+ border-left-color: #000000;
440
+ vertical-align: top;
441
+ border-right-color: #000000;
442
+ border-left-width: 1pt;
443
+ border-top-style: solid;
444
+ border-left-style: solid;
445
+ border-bottom-width: 1pt;
446
+ width: 42.1pt;
447
+ border-top-color: #000000;
448
+ border-bottom-style: solid;
449
+ }
450
+ .c18 {
451
+ border-right-style: solid;
452
+ border-bottom-color: #000000;
453
+ border-top-width: 0pt;
454
+ border-right-width: 1pt;
455
+ border-left-color: #000000;
456
+ vertical-align: top;
457
+ border-right-color: #000000;
458
+ border-left-width: 0pt;
459
+ border-top-style: solid;
460
+ border-left-style: solid;
461
+ border-bottom-width: 0pt;
462
+ width: 17.3pt;
463
+ border-top-color: #000000;
464
+ border-bottom-style: solid;
465
+ }
466
+ .c30 {
467
+ border-right-style: solid;
468
+ border-bottom-color: #d9d9d9;
469
+ border-top-width: 1pt;
470
+ border-right-width: 1pt;
471
+ border-left-color: #000000;
472
+ vertical-align: top;
473
+ border-right-color: #000000;
474
+ border-left-width: 1pt;
475
+ border-top-style: solid;
476
+ border-left-style: solid;
477
+ border-bottom-width: 1pt;
478
+ width: 71pt;
479
+ border-top-color: #000000;
480
+ border-bottom-style: solid;
481
+ }
482
+ .c29 {
483
+ border-right-style: solid;
484
+ border-bottom-color: #000000;
485
+ border-top-width: 0pt;
486
+ border-right-width: 0pt;
487
+ border-left-color: #000000;
488
+ vertical-align: top;
489
+ border-right-color: #000000;
490
+ border-left-width: 1pt;
491
+ border-top-style: solid;
492
+ border-left-style: solid;
493
+ border-bottom-width: 0pt;
494
+ width: 30.4pt;
495
+ border-top-color: #000000;
496
+ border-bottom-style: solid;
497
+ }
498
+ .c31 {
499
+ border-right-style: solid;
500
+ border-bottom-color: #d9d9d9;
501
+ border-top-width: 1pt;
502
+ border-right-width: 1pt;
503
+ border-left-color: #000000;
504
+ vertical-align: top;
505
+ border-right-color: #000000;
506
+ border-left-width: 1pt;
507
+ border-top-style: solid;
508
+ border-left-style: solid;
509
+ border-bottom-width: 1pt;
510
+ width: 76pt;
511
+ border-top-color: #000000;
512
+ border-bottom-style: solid;
513
+ }
514
+ .c37 {
515
+ border-right-style: solid;
516
+ border-bottom-color: #000000;
517
+ border-top-width: 1pt;
518
+ border-right-width: 1pt;
519
+ border-left-color: #000000;
520
+ vertical-align: top;
521
+ border-right-color: #000000;
522
+ border-left-width: 0pt;
523
+ border-top-style: solid;
524
+ border-left-style: solid;
525
+ border-bottom-width: 0pt;
526
+ width: 17.3pt;
527
+ border-top-color: #d9d9d9;
528
+ border-bottom-style: solid;
529
+ }
530
+ .c5 {
531
+ border-right-style: solid;
532
+ border-bottom-color: #000000;
533
+ border-top-width: 1pt;
534
+ border-right-width: 1pt;
535
+ border-left-color: #000000;
536
+ vertical-align: top;
537
+ border-right-color: #000000;
538
+ border-left-width: 1pt;
539
+ border-top-style: solid;
540
+ border-left-style: solid;
541
+ border-bottom-width: 1pt;
542
+ width: 76pt;
543
+ border-top-color: #000000;
544
+ border-bottom-style: solid;
545
+ }
546
+ .c64 {
547
+ border-right-style: solid;
548
+ border-bottom-color: #000000;
549
+ border-top-width: 1pt;
550
+ border-right-width: 1pt;
551
+ border-left-color: #000000;
552
+ vertical-align: top;
553
+ border-right-color: #000000;
554
+ border-left-width: 1pt;
555
+ border-top-style: solid;
556
+ border-left-style: solid;
557
+ border-bottom-width: 1pt;
558
+ width: 218.6pt;
559
+ border-top-color: #000000;
560
+ border-bottom-style: solid;
561
+ }
562
+ .c10 {
563
+ border-right-style: solid;
564
+ border-bottom-color: #d9d9d9;
565
+ border-top-width: 1pt;
566
+ border-right-width: 1pt;
567
+ border-left-color: #000000;
568
+ vertical-align: top;
569
+ border-right-color: #000000;
570
+ border-left-width: 1pt;
571
+ border-top-style: solid;
572
+ border-left-style: solid;
573
+ border-bottom-width: 1pt;
574
+ width: 49.6pt;
575
+ border-top-color: #000000;
576
+ border-bottom-style: solid;
577
+ }
578
+ .c59 {
579
+ border-right-style: solid;
580
+ border-bottom-color: #000000;
581
+ border-top-width: 1pt;
582
+ border-right-width: 1pt;
583
+ border-left-color: #000000;
584
+ vertical-align: top;
585
+ border-right-color: #000000;
586
+ border-left-width: 1pt;
587
+ border-top-style: solid;
588
+ border-left-style: solid;
589
+ border-bottom-width: 1pt;
590
+ width: 42.1pt;
591
+ border-top-color: #000000;
592
+ border-bottom-style: solid;
593
+ }
594
+ .c99 {
595
+ border-right-style: solid;
596
+ border-bottom-color: #d9d9d9;
597
+ border-top-width: 0pt;
598
+ border-right-width: 0pt;
599
+ border-left-color: #000000;
600
+ vertical-align: top;
601
+ border-right-color: #000000;
602
+ border-left-width: 1pt;
603
+ border-top-style: solid;
604
+ border-left-style: solid;
605
+ border-bottom-width: 1pt;
606
+ width: 30.4pt;
607
+ border-top-color: #000000;
608
+ border-bottom-style: solid;
609
+ }
610
+ .c23 {
611
+ border-right-style: solid;
612
+ border-bottom-color: #000000;
613
+ border-top-width: 1pt;
614
+ border-right-width: 1pt;
615
+ border-left-color: #000000;
616
+ vertical-align: top;
617
+ border-right-color: #000000;
618
+ border-left-width: 1pt;
619
+ border-top-style: solid;
620
+ border-left-style: solid;
621
+ border-bottom-width: 1pt;
622
+ width: 71pt;
623
+ border-top-color: #000000;
624
+ border-bottom-style: solid;
625
+ }
626
+ .c22 {
627
+ border-right-style: solid;
628
+ border-bottom-color: #000000;
629
+ border-top-width: 1pt;
630
+ border-right-width: 1pt;
631
+ border-left-color: #000000;
632
+ vertical-align: top;
633
+ border-right-color: #000000;
634
+ border-left-width: 1pt;
635
+ border-top-style: solid;
636
+ border-left-style: solid;
637
+ border-bottom-width: 1pt;
638
+ width: 35.5pt;
639
+ border-top-color: #000000;
640
+ border-bottom-style: solid;
641
+ }
642
+ .c20 {
643
+ border-right-style: solid;
644
+ border-bottom-color: #000000;
645
+ border-top-width: 1pt;
646
+ border-right-width: 1pt;
647
+ border-left-color: #000000;
648
+ vertical-align: top;
649
+ border-right-color: #000000;
650
+ border-left-width: 1pt;
651
+ border-top-style: solid;
652
+ border-left-style: solid;
653
+ border-bottom-width: 1pt;
654
+ width: 151.5pt;
655
+ border-top-color: #000000;
656
+ border-bottom-style: solid;
657
+ }
658
+ .c16 {
659
+ color: #000000;
660
  font-weight: 700;
661
+ text-decoration: none;
662
+ vertical-align: baseline;
663
+ font-size: 12pt;
664
+ font-family: "Calibri";
665
+ font-style: normal;
666
+ }
667
+ .c40 {
668
+ margin-left: 4.8pt;
669
+ padding-top: 5.3pt;
670
+ text-indent: -0.2pt;
671
+ padding-bottom: 0pt;
672
+ line-height: 1.0374999999999999;
673
  text-align: left;
674
+ margin-right: 32.5pt;
675
  }
676
+ .c1 {
677
+ color: #000000;
678
+ font-weight: 400;
679
+ text-decoration: none;
680
+ vertical-align: baseline;
681
+ font-size: 10pt;
682
+ font-family: "Calibri";
683
+ font-style: normal;
684
  }
685
+ .c67 {
686
+ color: #000000;
687
+ font-weight: 700;
688
+ text-decoration: none;
689
+ vertical-align: baseline;
690
+ font-size: 10pt;
691
+ font-family: "Arial";
692
+ font-style: normal;
693
  }
694
+ .c62 {
695
+ margin-left: 40.2pt;
696
+ padding-top: 0.5pt;
697
+ padding-left: 0pt;
698
+ padding-bottom: 0pt;
699
+ line-height: 1.0374999999999999;
700
+ text-align: left;
701
+ margin-right: 19pt;
702
  }
703
+ .c86 {
704
+ color: #000000;
705
+ font-weight: 400;
706
+ text-decoration: none;
707
+ vertical-align: baseline;
708
+ font-size: 10pt;
709
+ font-family: "Verdana";
710
+ font-style: normal;
711
  }
712
+ .c51 {
713
+ margin-left: 4.3pt;
714
+ padding-top: 0.4pt;
715
+ text-indent: -0.2pt;
716
+ padding-bottom: 0pt;
717
+ line-height: 1.0374999999999999;
718
+ text-align: justify;
719
+ margin-right: 4.7pt;
720
  }
721
+ .c76 {
722
+ margin-left: 4.3pt;
723
+ padding-top: 0.5pt;
724
+ text-indent: -0.2pt;
725
+ padding-bottom: 0pt;
726
+ line-height: 1.0374999999999999;
727
+ text-align: justify;
728
+ margin-right: 4.5pt;
729
  }
730
+ .c48 {
731
+ color: #000000;
732
+ font-weight: 400;
733
+ text-decoration: none;
734
+ vertical-align: baseline;
735
+ font-size: 7pt;
736
+ font-family: "Times New Roman";
737
+ font-style: normal;
738
  }
739
+ .c25 {
740
+ margin-left: 4.3pt;
741
+ padding-top: 0.6pt;
742
+ text-indent: -0.2pt;
743
+ padding-bottom: 0pt;
744
+ line-height: 1.0374999999999999;
745
+ text-align: justify;
746
+ margin-right: 4.9pt;
747
  }
748
+ .c101 {
749
+ color: #ffffff;
 
750
  font-weight: 700;
751
+ text-decoration: none;
752
+ vertical-align: baseline;
753
+ font-size: 10pt;
754
+ font-family: "Arial";
755
+ font-style: normal;
756
  }
757
+ .c2 {
758
+ color: #000000;
759
+ font-weight: 400;
760
+ text-decoration: none;
761
+ vertical-align: baseline;
762
+ font-size: 12pt;
763
+ font-family: "Times New Roman";
764
+ font-style: normal;
765
  }
766
+ .c3 {
767
+ color: #000000;
768
+ font-weight: 700;
769
+ text-decoration: none;
770
+ vertical-align: baseline;
771
+ font-size: 10pt;
772
+ font-family: "Calibri";
773
+ font-style: normal;
774
  }
775
+ .c96 {
776
+ margin-left: 4.3pt;
777
+ padding-top: 0.6pt;
778
+ text-indent: -0.2pt;
779
+ padding-bottom: 0pt;
780
+ line-height: 1.0374999999999999;
781
+ text-align: justify;
782
+ margin-right: 5.2pt;
783
  }
784
+ .c9 {
785
+ color: #000000;
786
+ font-weight: 400;
787
+ text-decoration: none;
788
+ vertical-align: baseline;
789
+ font-size: 9pt;
790
+ font-family: "Times New Roman";
791
+ font-style: normal;
792
  }
793
+ .c12 {
794
+ margin-left: 22.2pt;
795
+ padding-top: 0.5pt;
796
+ padding-bottom: 0pt;
797
+ line-height: 1;
798
+ padding-left: 18.1pt;
799
+ text-align: left;
800
+ }
801
+ .c47 {
802
+ margin-left: 10.3pt;
803
+ padding-top: 5.3pt;
804
+ padding-bottom: 0pt;
805
+ line-height: 1.0374999999999999;
806
  text-align: center;
807
+ margin-right: 11.1pt;
 
808
  }
809
+ .c17 {
810
+ margin-left: 40.2pt;
811
+ padding-top: 0.5pt;
812
+ padding-bottom: 0pt;
813
+ line-height: 1;
814
+ padding-left: 0pt;
815
+ text-align: left;
816
  }
817
+ .c35 {
818
+ margin-left: 22.2pt;
819
+ padding-top: 5.2pt;
820
+ padding-bottom: 0pt;
821
+ line-height: 1;
822
+ padding-left: 18.1pt;
823
+ text-align: left;
824
  }
825
+ .c28 {
826
+ margin-left: 40.2pt;
827
+ padding-top: 5.2pt;
828
+ padding-bottom: 0pt;
829
+ line-height: 1;
830
+ padding-left: 0pt;
831
+ text-align: left;
832
  }
833
+ .c97 {
834
+ margin-left: 22.2pt;
835
+ padding-top: 0.6pt;
836
+ padding-bottom: 0pt;
837
+ line-height: 1;
838
+ padding-left: 18.1pt;
839
+ text-align: left;
840
  }
841
+ .c13 {
842
+ margin-left: 4.3pt;
843
+ padding-top: 5.3pt;
844
+ padding-bottom: 0pt;
845
+ line-height: 1.0374999999999999;
846
+ text-indent: -0.2pt;
847
+ text-align: left;
848
  }
849
+ .c91 {
850
+ margin-left: 40.2pt;
851
+ padding-top: 0.3pt;
852
+ padding-bottom: 0pt;
853
+ line-height: 1;
854
+ padding-left: 0pt;
855
+ text-align: left;
856
  }
857
+ .c65 {
858
+ margin-left: 4.8pt;
859
+ padding-top: 5.3pt;
860
+ padding-bottom: 0pt;
861
+ line-height: 1.0291666666666666;
862
+ text-indent: -0.2pt;
863
+ text-align: left;
864
  }
865
+ .c90 {
866
+ margin-left: 16pt;
867
+ padding-top: 1.1pt;
868
+ padding-bottom: 0pt;
869
+ line-height: 0.6958333333333333;
870
+ text-align: left;
871
+ margin-right: -2.2pt;
872
  }
873
+ .c57 {
874
+ margin-left: 0.6pt;
875
+ padding-top: 1.1pt;
876
+ padding-bottom: 0pt;
877
+ line-height: 0.7000000000000001;
878
+ text-align: left;
879
+ margin-right: -2.9pt;
880
  }
881
+ .c36 {
882
+ margin-left: 4.2pt;
883
+ padding-top: 0.1pt;
884
+ padding-bottom: 0pt;
885
+ line-height: 1;
886
+ text-align: left;
887
  }
888
+ .c92 {
889
+ margin-left: 4.8pt;
890
+ padding-top: 5.2pt;
891
+ padding-bottom: 0pt;
892
+ line-height: 1;
893
+ text-align: left;
894
  }
895
+ .c69 {
896
+ margin-left: 19.5pt;
897
+ padding-top: 11.3pt;
898
+ padding-bottom: 0pt;
899
+ line-height: 1;
900
+ text-align: left;
901
  }
902
+ .c83 {
903
+ margin-left: 399.5pt;
904
+ padding-top: 0.1pt;
905
+ padding-bottom: 0pt;
906
+ line-height: 1;
907
+ text-align: justify;
908
+ }
909
+ .c45 {
910
+ margin-left: 398.5pt;
911
+ padding-top: 0.1pt;
912
+ padding-bottom: 0pt;
913
+ line-height: 1;
914
+ text-align: justify;
915
+ }
916
+ .c55 {
917
+ padding-top: 5.3pt;
918
+ padding-bottom: 0pt;
919
+ line-height: 1;
920
  text-align: center;
921
+ margin-right: 0.6pt;
922
  }
923
+ .c103 {
924
+ margin-left: 4.2pt;
925
+ padding-top: 5.3pt;
926
+ padding-bottom: 0pt;
927
+ line-height: 1;
928
+ text-align: left;
929
  }
930
+ .c21 {
931
+ margin-left: 4.2pt;
932
+ padding-top: 0.5pt;
933
+ padding-bottom: 0pt;
934
+ line-height: 1;
935
+ text-align: justify;
936
  }
937
+ .c6 {
938
+ padding-top: 5.3pt;
939
+ padding-bottom: 0pt;
940
+ line-height: 1;
941
+ text-align: center;
942
+ margin-right: 0.3pt;
943
  }
944
+ .c33 {
945
+ padding-top: 5.3pt;
946
+ padding-bottom: 0pt;
947
+ line-height: 1;
948
+ text-align: center;
949
+ margin-right: 0.4pt;
950
  }
951
+ .c4 {
952
+ padding-top: 0pt;
953
+ padding-bottom: 0pt;
954
+ line-height: 1;
955
+ text-align: left;
956
+ height: 11pt;
957
  }
958
+ .c77 {
959
+ padding-top: 5.3pt;
960
+ padding-bottom: 0pt;
961
+ line-height: 1;
962
+ text-align: center;
963
+ margin-right: 0.7pt;
964
  }
965
+ .c7 {
966
+ margin-left: 398.5pt;
967
+ padding-top: 0pt;
968
+ padding-bottom: 0pt;
969
+ line-height: 1;
970
+ text-align: justify;
971
  }
972
+ .c27 {
973
+ padding-top: 0pt;
974
+ padding-bottom: 0pt;
975
+ line-height: 1;
976
+ text-align: right;
977
+ margin-right: 3.4pt;
978
  }
979
+ .c89 {
980
+ margin-left: 398.5pt;
981
+ padding-top: 0pt;
982
+ padding-bottom: 0pt;
983
+ line-height: 1.0125;
984
+ text-align: justify;
985
+ }
986
+ .c75 {
987
+ margin-left: 4.8pt;
988
+ padding-top: 5.3pt;
989
+ padding-bottom: 0pt;
990
+ line-height: 1;
991
+ text-align: left;
992
+ }
993
+ .c0 {
994
+ margin-left: 4.5pt;
995
+ padding-top: 0.6pt;
996
+ padding-bottom: 0pt;
997
+ line-height: 1.0374999999999999;
998
+ text-align: left;
999
+ }
1000
+ .c95 {
1001
+ margin-left: 4.3pt;
1002
+ padding-top: 5.3pt;
1003
+ padding-bottom: 0pt;
1004
+ line-height: 1;
1005
+ text-align: left;
1006
+ }
1007
+ .c39 {
1008
+ margin-left: 4.5pt;
1009
+ padding-top: 5.3pt;
1010
+ padding-bottom: 0pt;
1011
+ line-height: 1;
1012
+ text-align: left;
1013
+ }
1014
+ .c87 {
1015
+ padding-top: 5.3pt;
1016
+ padding-bottom: 0pt;
1017
+ line-height: 1;
1018
  text-align: center;
1019
+ margin-right: 0.2pt;
1020
+ }
1021
+ .c88 {
1022
+ margin-left: 4.2pt;
1023
+ padding-top: 5.3pt;
1024
+ padding-bottom: 0pt;
1025
+ line-height: 1;
1026
+ text-align: justify;
1027
+ }
1028
+ .c93 {
1029
+ margin-left: 48.5pt;
1030
+ padding-top: 5.3pt;
1031
+ padding-bottom: 0pt;
1032
+ line-height: 1;
1033
+ text-align: left;
1034
+ }
1035
+ .c81 {
1036
+ margin-left: 10.1pt;
1037
+ padding-top: 5.3pt;
1038
+ padding-bottom: 0pt;
1039
+ line-height: 1;
1040
+ text-align: left;
1041
+ }
1042
+ .c53 {
1043
+ margin-left: 18.9pt;
1044
+ padding-top: 5.3pt;
1045
+ padding-bottom: 0pt;
1046
+ line-height: 1;
1047
+ text-align: left;
1048
+ }
1049
+ .c58 {
1050
+ margin-left: 11.3pt;
1051
+ padding-top: 5.3pt;
1052
+ padding-bottom: 0pt;
1053
+ line-height: 1;
1054
+ text-align: left;
1055
+ }
1056
+ .c8 {
1057
+ padding-top: 0pt;
1058
+ padding-bottom: 0pt;
1059
+ line-height: 1.15;
1060
+ text-align: left;
1061
+ height: 11pt;
1062
+ }
1063
+ .c42 {
1064
+ display: flex;
1065
+ align-items: center;
1066
+ justify-content: flex-start;
1067
+ gap: 10px;
1068
+ padding-top: 6pt;
1069
+ padding-bottom: 0pt;
1070
+ text-align: left;
1071
+ }
1072
+ .c82 {
1073
+ padding-top: 12.9pt;
1074
+ padding-bottom: 0pt;
1075
+ line-height: 1;
1076
+ text-align: left;
1077
+ }
1078
+ .c38 {
1079
+ margin-left: 3.4pt;
1080
+ border-spacing: 0;
1081
+ border-collapse: collapse;
1082
+ margin-right: auto;
1083
+ }
1084
+ .c79 {
1085
+ color: #7e7e7e;
1086
+ font-size: 12pt;
1087
  font-weight: 700;
1088
  }
1089
+ .c71 {
1090
+ background-color: #ffffff;
1091
+ max-width: 576pt;
1092
+ margin: 0 auto;
1093
+ padding: 75pt 24pt 14pt 24pt;
1094
+ }
1095
+ .c70 {
1096
+ padding: 0;
1097
+ margin: 0;
1098
+ }
1099
+ .c41 {
1100
+ height: 81pt;
1101
+ }
1102
+ .c56 {
1103
+ margin-right: 19.3pt;
1104
+ }
1105
+ .c50 {
1106
+ height: 38pt;
1107
+ }
1108
+ .c68 {
1109
+ height: 35.6pt;
1110
+ }
1111
+ .c94 {
1112
+ height: 282.2pt;
1113
+ }
1114
+ .c98 {
1115
+ height: 10.5pt;
1116
+ }
1117
+ .c78 {
1118
+ height: 24.3pt;
1119
+ }
1120
+ .c85 {
1121
+ height: 66.1pt;
1122
+ }
1123
+ .c66 {
1124
+ margin-left: 0.1pt;
1125
+ }
1126
+ .c84 {
1127
+ height: 24.2pt;
1128
+ }
1129
+ .c73 {
1130
+ height: 50.2pt;
1131
+ }
1132
+ .c54 {
1133
+ margin-right: 34.5pt;
1134
+ }
1135
+ .c26 {
1136
+ margin-right: 3.8pt;
1137
+ }
1138
+ .c100 {
1139
+ height: 62.5pt;
1140
+ }
1141
+ .title {
1142
+ padding-top: 24pt;
1143
+ color: #000000;
1144
+ font-weight: 700;
1145
+ font-size: 36pt;
1146
+ padding-bottom: 6pt;
1147
+ font-family: "Calibri";
1148
+ line-height: 1;
1149
+ page-break-after: avoid;
1150
+ text-align: left;
1151
+ }
1152
+ .subtitle {
1153
+ padding-top: 18pt;
1154
+ color: #666666;
1155
+ font-size: 24pt;
1156
+ padding-bottom: 4pt;
1157
+ font-family: "Georgia";
1158
+ line-height: 1;
1159
+ page-break-after: avoid;
1160
+ font-style: italic;
1161
+ text-align: left;
1162
+ }
1163
+ li {
1164
+ color: #000000;
1165
+ font-size: 11pt;
1166
+ font-family: "Calibri";
1167
+ }
1168
+ p {
1169
+ margin: 0;
1170
+ color: #000000;
1171
+ font-size: 11pt;
1172
+ font-family: "Calibri";
1173
+ }
1174
+ h1 {
1175
+ padding-top: 24pt;
1176
+ color: #000000;
1177
+ font-weight: 700;
1178
+ font-size: 24pt;
1179
+ padding-bottom: 6pt;
1180
+ font-family: "Calibri";
1181
+ line-height: 1;
1182
+ page-break-after: avoid;
1183
+ text-align: left;
1184
+ }
1185
+ h2 {
1186
+ padding-top: 18pt;
1187
+ color: #000000;
1188
+ font-weight: 700;
1189
+ font-size: 18pt;
1190
+ padding-bottom: 4pt;
1191
+ font-family: "Calibri";
1192
+ line-height: 1;
1193
+ page-break-after: avoid;
1194
+ text-align: left;
1195
+ }
1196
+ h3 {
1197
+ padding-top: 14pt;
1198
+ color: #000000;
1199
+ font-weight: 700;
1200
+ font-size: 14pt;
1201
+ padding-bottom: 4pt;
1202
+ font-family: "Calibri";
1203
+ line-height: 1;
1204
+ page-break-after: avoid;
1205
+ text-align: left;
1206
+ }
1207
+ h4 {
1208
+ padding-top: 12pt;
1209
+ color: #000000;
1210
+ font-weight: 700;
1211
+ font-size: 12pt;
1212
+ padding-bottom: 2pt;
1213
+ font-family: "Calibri";
1214
+ line-height: 1;
1215
+ page-break-after: avoid;
1216
+ text-align: left;
1217
+ }
1218
+ h5 {
1219
+ padding-top: 11pt;
1220
+ color: #000000;
1221
+ font-weight: 700;
1222
+ font-size: 11pt;
1223
+ padding-bottom: 2pt;
1224
+ font-family: "Calibri";
1225
+ line-height: 1;
1226
+ page-break-after: avoid;
1227
+ text-align: left;
1228
+ }
1229
+ h6 {
1230
+ padding-top: 10pt;
1231
+ color: #000000;
1232
+ font-weight: 700;
1233
+ font-size: 10pt;
1234
+ padding-bottom: 2pt;
1235
+ font-family: "Calibri";
1236
+ line-height: 1;
1237
+ page-break-after: avoid;
1238
+ text-align: left;
1239
+ }
1240
+ </style>
1241
+ <style>
1242
+ .page-footer {
1243
  background: #000;
1244
+ display: flex;
1245
+ align-items: center;
1246
+ justify-content: space-between;
1247
+ padding: 4pt 16pt;
1248
+ }
1249
+ .footer-text {
1250
+ color: #fff;
1251
+ font-weight: 700;
1252
+ font-size: 10pt;
1253
+ font-family: Arial, sans-serif;
1254
+ white-space: nowrap;
1255
+ }
1256
+ .footer-page {
1257
+ color: #fff;
1258
+ font-weight: 700;
1259
+ font-size: 12pt;
1260
+ font-family: Calibri, sans-serif;
1261
+ white-space: nowrap;
1262
  }
1263
  </style>
1264
  </head>
1265
+ <body class="c71 doc-content">
1266
+ <div>
1267
+ <p class="c42">
1268
+ <span
1269
+ style="
1270
+ overflow: hidden;
1271
+ display: inline-block;
1272
+ margin: 0px 0px;
1273
+ border: 0px solid #000000;
1274
+ transform: rotate(0rad) translateZ(0px);
1275
+ -webkit-transform: rotate(0rad) translateZ(0px);
1276
+ width: 41.07px;
1277
+ height: 49.96px;
1278
+ "
1279
+ ><img
1280
+ alt
1281
+ src="images/image2.png"
1282
+ style="
1283
+ width: 41.07px;
1284
+ height: 49.96px;
1285
+ margin-left: 0px;
1286
+ margin-top: 0px;
1287
+ transform: rotate(0rad) translateZ(0px);
1288
+ -webkit-transform: rotate(0rad) translateZ(0px);
1289
+ "
1290
+ title /></span
1291
+ ><span
1292
+ style="
1293
+ overflow: hidden;
1294
+ display: inline-block;
1295
+ margin: 0px 0px;
1296
+ border: 0px solid #000000;
1297
+ transform: rotate(0rad) translateZ(0px);
1298
+ -webkit-transform: rotate(0rad) translateZ(0px);
1299
+ width: 264.73px;
1300
+ height: 19.87px;
1301
+ "
1302
+ ><img
1303
+ alt
1304
+ src="images/image1.png"
1305
+ style="
1306
+ width: 264.73px;
1307
+ height: 19.87px;
1308
+ margin-left: 0px;
1309
+ margin-top: 0px;
1310
+ transform: rotate(0rad) translateZ(0px);
1311
+ -webkit-transform: rotate(0rad) translateZ(0px);
1312
+ "
1313
+ title
1314
+ /></span>
1315
+ </p>
1316
+ </div>
1317
+ <p class="c4"><span class="c2"></span></p>
1318
+ <table class="c38">
1319
+ <tr class="c78">
1320
+ <td class="c18" colspan="1" rowspan="9">
1321
+ <p class="c4"><span class="c9"></span></p>
1322
+ </td>
1323
+ <td class="c5" colspan="1" rowspan="1">
1324
+ <p class="c58"><span class="c3">Course Code</span></p>
1325
+ </td>
1326
+ <td class="c20" colspan="1" rowspan="1">
1327
+ <p class="c93"><span class="c3">{{ course.course_code }}</span></p>
1328
+ </td>
1329
+ <td class="c23" colspan="1" rowspan="1">
1330
+ <p class="c81"><span class="c3">Course Title</span></p>
1331
+ </td>
1332
+ <td class="c64" colspan="5" rowspan="1">
1333
+ <p class="c53">
1334
+ <span class="c3">{{ course.course_title }}</span>
1335
+ </p>
1336
+ </td>
1337
+ <td class="c29" colspan="1" rowspan="9">
1338
+ <p class="c4"><span class="c9"></span></p>
1339
+ </td>
1340
+ </tr>
1341
+ <tr class="c84">
1342
+ <td class="c5" colspan="1" rowspan="2">
1343
+ <p class="c69"><span class="c3">Program</span></p>
1344
+ </td>
1345
+ <td class="c20" colspan="1" rowspan="2">
1346
+ <p class="c77"><span class="c1">{{ course.program }}</span></p>
1347
+ </td>
1348
+ <td class="c23" colspan="1" rowspan="2">
1349
+ <p class="c47">
1350
+ <span class="c3">Hours per week/ Credit Assigned</span>
1351
+ </p>
1352
+ </td>
1353
+ <td class="c59" colspan="1" rowspan="1">
1354
+ <p class="c55 c66"><span class="c3">L</span></p>
1355
+ </td>
1356
+ <td class="c15" colspan="1" rowspan="1">
1357
+ <p class="c6"><span class="c3">T</span></p>
1358
+ </td>
1359
+ <td class="c14" colspan="1" rowspan="1">
1360
+ <p class="c87 c66"><span class="c3">P</span></p>
1361
+ </td>
1362
+ <td class="c22" colspan="1" rowspan="1">
1363
+ <p class="c6 c66"><span class="c3">S</span></p>
1364
+ </td>
1365
+ <td class="c43" colspan="1" rowspan="1">
1366
+ <p class="c33"><span class="c3">C</span></p>
1367
+ </td>
1368
+ </tr>
1369
+ <tr class="c68">
1370
+ <td class="c59" colspan="1" rowspan="1">
1371
+ <p class="c55"><span class="c3">{{ course.lecture_hours }}</span></p>
1372
+ </td>
1373
+ <td class="c15" colspan="1" rowspan="1">
1374
+ <p class="c6 c66"><span class="c3">{{ course.tutorial_hours }}</span></p>
1375
+ </td>
1376
+ <td class="c14" colspan="1" rowspan="1">
1377
+ <p class="c87"><span class="c3">{{ course.practical_hours }}</span></p>
1378
+ </td>
1379
+ <td class="c22" colspan="1" rowspan="1">
1380
+ <p class="c6"><span class="c3">{{ course.self_study }}</span></p>
1381
+ </td>
1382
+ <td class="c43" colspan="1" rowspan="1">
1383
+ <p class="c33"><span class="c3">{{ course.credits }}</span></p>
1384
+ </td>
1385
+ </tr>
1386
+ <tr class="c50">
1387
+ <td class="c5" colspan="1" rowspan="1">
1388
+ <p class="c95"><span class="c3">Semester</span></p>
1389
+ </td>
1390
+ <td class="c20" colspan="1" rowspan="1">
1391
+ <p class="c103"><span class="c1">{{ course.semester }}</span></p>
1392
+ </td>
1393
+ <td class="c23" colspan="1" rowspan="1">
1394
+ <p class="c13 c54"><span class="c3">Type of Course</span></p>
1395
+ </td>
1396
+ <td class="c64" colspan="5" rowspan="1">
1397
+ <p class="c39"><span class="c1">{{ course.course_type }}</span></p>
1398
+ </td>
1399
+ </tr>
1400
+ <tr class="c73">
1401
+ <td class="c5" colspan="1" rowspan="1">
1402
+ <p class="c95"><span class="c3">AI Tools</span></p>
1403
+ <p class="c0"><span class="c3">/Tools/Languag es</span></p>
1404
+ </td>
1405
+ <td class="c20" colspan="1" rowspan="1">
1406
+ <p class="c13">
1407
+ <span class="c1">{{ course.tools_languages }}</span>
1408
+ </p>
1409
+ </td>
1410
+ <td class="c23" colspan="1" rowspan="1">
1411
+ <p class="c13 c56"><span class="c3">Desirable Knowledge</span></p>
1412
+ </td>
1413
+ <td class="c64" colspan="5" rowspan="1">
1414
+ <p class="c39"><span class="c1">{{ course.desirable_knowledge }}</span></p>
1415
+ </td>
1416
+ </tr>
1417
+ <tr class="c85">
1418
+ <td class="c5" colspan="1" rowspan="1">
1419
+ <p class="c75"><span class="c3">Prelude</span></p>
1420
+ </td>
1421
+ <td class="c44" colspan="7" rowspan="1">
1422
+ <p class="c13 c26">
1423
+ <span class="c1">{{ course.prelude }}</span>
1424
+ </p>
1425
+ </td>
1426
+ </tr>
1427
+ <tr class="c41">
1428
+ <td class="c5" colspan="1" rowspan="1">
1429
+ <p class="c65"><span class="c3">Course Objectives:</span></p>
1430
+ </td>
1431
+ <td class="c44" colspan="7" rowspan="1">
1432
+ <ul class="c70 lst-kix_list_2-0 start">
1433
+ {% for obj in course.objectives %}
1434
+ <li class="c28 li-bullet-0">
1435
+ <span class="c1">{{ obj }}</span>
1436
+ </li>
1437
+ {% endfor %}
1438
+ </ul>
1439
+ </td>
1440
+ </tr>
1441
+ <tr class="c94">
1442
+ <td class="c5" colspan="1" rowspan="1">
1443
+ <p class="c40"><span class="c3">Course Contents</span></p>
1444
+ </td>
1445
+ <td class="c44" colspan="7" rowspan="1">
1446
+ {% for unit in course.units %}
1447
+ <p class="c88"><span class="c3">{{ unit.title }}</span></p>
1448
+ <p class="c25">
1449
+ <span class="c1">{{ unit.content }}</span>
1450
+ </p>
1451
+ <p class="c7"><span class="c3">{{ unit.hours }} Hours</span></p>
1452
+ {% endfor %}
1453
+ </td>
1454
+ </tr>
1455
+ <tr class="c100">
1456
+ <td class="c31" colspan="1" rowspan="1">
1457
+ <p class="c92"><span class="c3">Laboratory</span></p>
1458
+ </td>
1459
+ <td class="c32" colspan="7" rowspan="1">
1460
+ <ol class="c70 lst-kix_list_1-0 start">
1461
+ {% for exp in course.lab_experiments %}
1462
+ <li class="c35 li-bullet-1">
1463
+ <span class="c1">{{ exp }}</span>
1464
+ </li>
1465
+ {% endfor %}
1466
+ </ol>
1467
+ </td>
1468
+ </tr>
1469
+ <tr>
1470
+ <td colspan="11" class="page-footer">
1471
+ <span class="footer-text">P.E.S. University</span>
1472
+ <span class="footer-text">Curriculum :- {{ curriculum_year }} <span class="footer-page">{{ page_number }} |</span></span>
1473
+ </td>
1474
+ </tr>
1475
+ </table>
1476
  </body>
1477
  </html>
1478
+
backend/app/templates/jinja_sem_5_6.html DELETED
@@ -1,238 +0,0 @@
1
- {% macro semester_name(value) -%}
2
- {{ {"5": "V", "6": "VI"}.get(value|string, value) }}
3
- {%- endmacro %}
4
-
5
- {% macro course_row(course, number) -%}
6
- {% set code = course.course_code|string|upper|replace(" ", "") %}
7
- {% set title = course.course_title|string|lower %}
8
- {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
9
- {% set is_no_course = "no course offered" in title %}
10
- {% set is_elective = ("AA" in code or "AB" in code or "BA" in code or "BB" in code) and not is_placeholder and not is_no_course %}
11
- <tr>
12
- <td class="center">{{ number }}</td>
13
- <td>{{ course.course_code | course_code_for_year(course.semester, curriculum_year) }}</td>
14
- <td>{{ course.course_title }}</td>
15
- <td class="center">{% if is_no_course %}{% elif is_elective %}4{% else %}{{ course.lecture_hours }}{% endif %}</td>
16
- <td class="center">{% if is_no_course %}{% elif is_elective %}0{% else %}{{ course.tutorial_hours }}{% endif %}</td>
17
- <td class="center">{% if is_no_course %}{% elif is_elective %}0{% else %}{{ course.practical_hours }}{% endif %}</td>
18
- <td class="center">{% if is_no_course %}{% elif is_elective %}4{% else %}{{ course.self_study }}{% endif %}</td>
19
- <td class="center">{% if is_no_course %}{% elif is_elective %}4{% else %}{{ course.credits }}{% endif %}</td>
20
- <td>{{ course.tools_languages }}</td>
21
- <td>{% if is_no_course %}{% elif is_elective or is_placeholder %}Elective Course{% else %}{{ course.course_type }}{% endif %}</td>
22
- </tr>
23
- {%- endmacro %}
24
-
25
- {% macro total_cell(total, credit_total) -%}
26
- {% if total != credit_total %}{{ credit_total }}/{{ total }}{% else %}{{ total }}{% endif %}
27
- {%- endmacro %}
28
-
29
- {% macro specialization_codes(codes, summary_semester, curriculum_year) -%}
30
- {% for code in codes %}
31
- <p>{{ ("UE00CS" ~ code) | course_code_for_year(summary_semester, curriculum_year) }}</p>
32
- {% endfor %}
33
- {%- endmacro %}
34
-
35
- {% set requested_semester = semester|string if semester is defined and semester else "" %}
36
- {% for summary_semester in [5, 6] %}
37
- {% if not requested_semester or requested_semester == summary_semester|string %}
38
- {% set totals = namespace(count=0, lecture=0, lecture_credit=0, tutorial=0, practical=0, self_study=0, credits=0) %}
39
- {% for summary_course in course_list %}
40
- {% set code = summary_course.course_code|string|upper|replace(" ", "") %}
41
- {% set title = summary_course.course_title|string|lower %}
42
- {% set course_type = summary_course.course_type|string|lower %}
43
- {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
44
- {% set has_elective_code = "AA" in code or "AB" in code or "BA" in code or "BB" in code %}
45
- {% set is_actual_elective = has_elective_code and not is_placeholder %}
46
- {% if summary_course.semester|string == summary_semester|string and not is_actual_elective %}
47
- {% set credit_value = summary_course.credits|int %}
48
- {% set totals.count = totals.count + 1 %}
49
- {% set totals.lecture = totals.lecture + summary_course.lecture_hours|int %}
50
- {% set totals.tutorial = totals.tutorial + summary_course.tutorial_hours|int %}
51
- {% set totals.practical = totals.practical + summary_course.practical_hours|int %}
52
- {% set totals.self_study = totals.self_study + summary_course.self_study|int %}
53
- {% set totals.credits = totals.credits + credit_value %}
54
- {% if credit_value > 0 %}
55
- {% set totals.lecture_credit = totals.lecture_credit + summary_course.lecture_hours|int %}
56
- {% endif %}
57
- {% endif %}
58
- {% endfor %}
59
-
60
- {% set elective_a = namespace(count=0) %}
61
- {% set elective_b = namespace(count=0) %}
62
- {% for summary_course in course_list %}
63
- {% set code = summary_course.course_code|string|upper|replace(" ", "") %}
64
- {% set title = summary_course.course_title|string|lower %}
65
- {% set course_type = summary_course.course_type|string|lower %}
66
- {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
67
- {% set is_elective = not is_placeholder %}
68
- {% if summary_course.semester|string == summary_semester|string and is_elective %}
69
- {% if summary_semester == 5 and "AA" in code %}
70
- {% set elective_a.count = elective_a.count + 1 %}
71
- {% elif summary_semester == 5 and "AB" in code %}
72
- {% set elective_b.count = elective_b.count + 1 %}
73
- {% elif summary_semester == 6 and "BA" in code %}
74
- {% set elective_a.count = elective_a.count + 1 %}
75
- {% elif summary_semester == 6 and "BB" in code %}
76
- {% set elective_b.count = elective_b.count + 1 %}
77
- {% endif %}
78
- {% endif %}
79
- {% endfor %}
80
-
81
- {% if totals.count or elective_a.count or elective_b.count %}
82
- <article class="summary-page">
83
- <header class="letterhead">
84
- <img class="seal" src="{{ asset_root | default('') }}images/image2.png" alt="PES University logo" />
85
- <img class="wordmark" src="{{ asset_root | default('') }}images/image1.png" alt="PES University" />
86
- </header>
87
- <h2 class="semester-title">{{ semester_name(summary_semester) }} SEMESTER {{ batch_label(summary_semester, curriculum_year) }}</h2>
88
-
89
- {% if totals.count %}
90
- <table class="meta summary-table">
91
- <colgroup>
92
- <col class="summary-number" />
93
- <col class="summary-code" />
94
- <col class="summary-title-column" />
95
- <col class="summary-hour" />
96
- <col class="summary-hour" />
97
- <col class="summary-hour" />
98
- <col class="summary-hour" />
99
- <col class="summary-credit" />
100
- <col class="summary-tools" />
101
- <col class="summary-type" />
102
- </colgroup>
103
- <tbody>
104
- <tr>
105
- <th rowspan="2" class="center">Sl. No.</th>
106
- <th rowspan="2">Course Code</th>
107
- <th rowspan="2">Course Title</th>
108
- <th colspan="4" class="center">Hours per week</th>
109
- <th class="center nowrap">Credits</th>
110
- <th rowspan="2">AI Tools / Tools /<br /> Languages</th>
111
- <th rowspan="2">Course Type</th>
112
- </tr>
113
- <tr>
114
- <th class="center">L</th>
115
- <th class="center">T</th>
116
- <th class="center">P</th>
117
- <th class="center">S</th>
118
- <th class="center">C</th>
119
- </tr>
120
- {% set row = namespace(number=1) %}
121
- {% for summary_course in course_list %}
122
- {% set code = summary_course.course_code|string|upper|replace(" ", "") %}
123
- {% set title = summary_course.course_title|string|lower %}
124
- {% set course_type = summary_course.course_type|string|lower %}
125
- {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
126
- {% set has_elective_code = "AA" in code or "AB" in code or "BA" in code or "BB" in code %}
127
- {% set is_actual_elective = has_elective_code and not is_placeholder %}
128
- {% if summary_course.semester|string == summary_semester|string and not is_actual_elective %}
129
- {{ course_row(summary_course, row.number) }}
130
- {% set row.number = row.number + 1 %}
131
- {% endif %}
132
- {% endfor %}
133
- <tr>
134
- <th colspan="3" class="center">Total</th>
135
- <td class="center">{{ total_cell(totals.lecture, totals.lecture_credit) }}</td>
136
- <td class="center">{{ totals.tutorial }}</td>
137
- <td class="center">{{ totals.practical }}</td>
138
- <td class="center">{{ totals.self_study }}</td>
139
- <td class="center">{{ totals.credits }}</td>
140
- <td></td>
141
- <td></td>
142
- </tr>
143
- </tbody>
144
- </table>
145
- {% endif %}
146
-
147
- {% for group in ["a", "b"] %}
148
- <table class="meta summary-table">
149
- <colgroup>
150
- <col class="summary-number" />
151
- <col class="summary-code" />
152
- <col class="summary-title-column" />
153
- <col class="summary-hour" />
154
- <col class="summary-hour" />
155
- <col class="summary-hour" />
156
- <col class="summary-hour" />
157
- <col class="summary-credit" />
158
- <col class="summary-tools" />
159
- <col class="summary-type" />
160
- </colgroup>
161
- <tbody>
162
- <tr>
163
- <th colspan="10" class="center">
164
- {% if summary_semester == 5 and group == "a" %}Elective-I{% endif %}
165
- {% if summary_semester == 5 and group == "b" %}Elective-II{% endif %}
166
- {% if summary_semester == 6 and group == "a" %}Elective-III{% endif %}
167
- {% if summary_semester == 6 and group == "b" %}Elective-IV{% endif %}
168
- </th>
169
- </tr>
170
- {% set start_number = totals.count + 1 if group == "a" else totals.count + elective_a.count + 1 %}
171
- {% set row = namespace(number=start_number) %}
172
- {% for summary_course in course_list %}
173
- {% set code = summary_course.course_code|string|upper|replace(" ", "") %}
174
- {% set title = summary_course.course_title|string|lower %}
175
- {% set course_type = summary_course.course_type|string|lower %}
176
- {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %}
177
- {% set is_elective = not is_placeholder %}
178
- {% set is_group_a = (summary_semester == 5 and "AA" in code) or (summary_semester == 6 and "BA" in code) %}
179
- {% set is_group_b = (summary_semester == 5 and "AB" in code) or (summary_semester == 6 and "BB" in code) %}
180
- {% if summary_course.semester|string == summary_semester|string and is_elective and ((group == "a" and is_group_a) or (group == "b" and is_group_b)) %}
181
- {{ course_row(summary_course, row.number) }}
182
- {% set row.number = row.number + 1 %}
183
- {% endif %}
184
- {% endfor %}
185
- </tbody>
186
- </table>
187
- {% endfor %}
188
-
189
- {% if summary_semester in [5, 6] %}
190
- {% if summary_semester == 5 %}
191
- {% set first_label = "Elective-I" %}
192
- {% set second_label = "Elective-II" %}
193
- {% set specializations = [
194
- {"letter": "A", "name": "System and Core Computing (SCC)", "first": ["342AA1", "342AA8", "342AA9"], "second": ["343AB1", "343AB2", "343AB3", "343AB4", "343AB8"]},
195
- {"letter": "B", "name": "Machine Intelligence and Data Science (MIDS)", "first": ["342AA2", "342AA5", "342AA6", "342AA8"], "second": ["343AB1", "342AB2", "342AB7"]},
196
- {"letter": "C", "name": "Cyber Security and Connected Systems (CSCS)", "first": ["342AA3", "342AA4", "342AA5", "342AA7"], "second": ["343AB5", "342AB6", "342AB7"]},
197
- ] %}
198
- {% else %}
199
- {% set first_label = "Elective-III" %}
200
- {% set second_label = "Elective-IV" %}
201
- {% set specializations = [
202
- {"letter": "A", "name": "System and Core Computing (SCC)", "first": ["342BA1", "342BA2", "342BA3"], "second": ["343BB1", "343BB3", "343BB5", "343BB10"]},
203
- {"letter": "B", "name": "Machine Intelligence and Data Science (MIDS)", "first": ["342BA2", "342BA3", "342BA4", "342BA7", "342BA10"], "second": ["343BB2", "343BB4", "343BB7", "343BB9", "343BB10"]},
204
- {"letter": "C", "name": "Cyber Security and Connected Systems (CSCS)", "first": ["342BA5", "342BA6", "342BA7", "342BA8"], "second": ["343BB6", "343BB7", "343BB8", "343BB9", "343BB11", "343BB12"]},
205
- ] %}
206
- {% endif %}
207
- <table class="meta summary-table specialization-table">
208
- <colgroup>
209
- <col class="summary-number" />
210
- <col />
211
- <col class="summary-title-column" />
212
- <col class="summary-title-column" />
213
- </colgroup>
214
- <tbody>
215
- <tr>
216
- <th colspan="4" class="center">ELECTIVES TO BE OPTED FOR SPECIALIZATION</th>
217
- </tr>
218
- <tr>
219
- <th class="center">Sl. No.</th>
220
- <th>Specialization</th>
221
- <th>{{ first_label }}</th>
222
- <th>{{ second_label }}</th>
223
- </tr>
224
- {% for specialization in specializations %}
225
- <tr>
226
- <td class="center">{{ specialization.letter }}</td>
227
- <td>{{ specialization.name }}</td>
228
- <td>{{ specialization_codes(specialization.first, summary_semester, curriculum_year) }}</td>
229
- <td>{{ specialization_codes(specialization.second, summary_semester, curriculum_year) }}</td>
230
- </tr>
231
- {% endfor %}
232
- </tbody>
233
- </table>
234
- {% endif %}
235
- </article>
236
- {% endif %}
237
- {% endif %}
238
- {% endfor %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/index.md DELETED
@@ -1,184 +0,0 @@
1
- ---
2
- title: PESU Curriculum Automation
3
- permalink: /
4
- ---
5
-
6
- # PESU Curriculum Automation
7
-
8
- PESU Curriculum Automation is a FastAPI and static frontend application for collecting course submissions, refining them into curriculum-ready records, previewing PDF output, staging reviewable edits, and preserving curriculum snapshots.
9
-
10
- ## System Overview
11
-
12
- | Layer | Location | Responsibility |
13
- | --- | --- | --- |
14
- | Static frontend | `frontend/` | Course entry, course management, PDF preview, live editor, and version history |
15
- | API backend | `backend/app/` | FastAPI routes, validation, refinement, previews, drafts, chat, and snapshots |
16
- | Persistence | Supabase | Raw submissions, refined courses, agent drafts, chat history, attachments, and curriculum versions |
17
- | Rendering | Jinja2 and WeasyPrint | Curriculum summary pages, course detail pages, and PDF exports |
18
- | Model provider | OpenRouter | Submission refinement and live editor chat with tool calls |
19
-
20
- ## Runtime Flow
21
-
22
- 1. Faculty submit raw course data through `frontend/form/`.
23
- 2. `POST /api/submissions` validates the payload and stores it in `submissions`.
24
- 3. A background refinement task builds deterministic academic fields, calls the model for prose fields, and writes `refined_submissions`.
25
- 4. Course management and preview pages read refined records through `/api/courses` and `/api/preview/*`.
26
- 5. The live editor can edit JSON fields directly or create draft changes through agent tools.
27
- 6. Drafts are reviewed with generated diffs before being applied to refined records.
28
- 7. Version snapshots store complete curriculum states and can restore a prior snapshot.
29
-
30
- ## Frontend Surfaces
31
-
32
- | Route | Purpose |
33
- | --- | --- |
34
- | `/` | Dashboard for the available application surfaces |
35
- | `/form/` | Raw course submission form |
36
- | `/courses/` | Refined course list with filtering and soft delete |
37
- | `/preview/` | Overall or semester PDF preview and download |
38
- | `/live-editor/` | Course preview, chat assistant, JSON editor, draft review, and version restore |
39
- | `/versions/` | Snapshot list, snapshot preview, and editor handoff |
40
-
41
- The frontend is plain HTML, CSS, and JavaScript. There is no Node build step.
42
-
43
- ## API Surface
44
-
45
- | Endpoint | Method | Purpose |
46
- | --- | --- | --- |
47
- | `/api/submissions` | `POST` | Store a raw submission and queue refinement |
48
- | `/api/submissions/{id}/refine` | `POST` | Manually refine a submission |
49
- | `/api/refined/{refined_id}` | `GET` | Read template-ready course fields |
50
- | `/api/refined/{refined_id}` | `PATCH` | Update editable refined fields |
51
- | `/api/courses` | `GET` | List active refined courses |
52
- | `/api/courses/{refined_id}` | `DELETE` | Archive a refined course |
53
- | `/api/preview/course/{refined_id}` | `GET` | Render one course as HTML |
54
- | `/api/preview/pdf` | `GET` | Render the full curriculum as PDF |
55
- | `/api/preview/semester/{sem}/pdf` | `GET` | Render one semester as PDF |
56
- | `/api/versions` | `GET`, `POST` | List or create curriculum snapshots |
57
- | `/api/versions/{version_id}/restore` | `POST` | Restore a saved curriculum snapshot |
58
- | `/api/agent/drafts` | `GET`, `POST` | List or create reviewable course drafts |
59
- | `/api/agent/drafts/{draft_id}/apply` | `POST` | Apply a proposed draft after review |
60
- | `/api/agent/document-drafts` | `GET`, `POST` | List or create multi-course drafts |
61
- | `/api/chat/sessions` | `GET`, `POST` | Manage live editor chat sessions |
62
- | `/api/chat/sessions/{session_id}/messages` | `GET`, `POST` | Read or stream chat messages |
63
- | `/api/chat/sessions/{session_id}/attachments` | `POST` | Upload document context for chat |
64
- | `/api/health/schema` | `GET` | Check required Supabase tables |
65
-
66
- ## Submission Contract
67
-
68
- `CourseSubmission` is defined in `backend/app/models/submission.py`.
69
-
70
- Required fields:
71
-
72
- - `faculty_email`
73
- - `course_title`
74
- - `offering_department`: `MA`, `CS`, or `UZ`
75
- - `target_department`: `CSE`, `ECE`, `ME`, `BT`, `EEE`, or `AIML`
76
- - `semester`: string value from `1` to `8`
77
- - `credit_category`: `0`, `2`, `4`, or `5`
78
- - `raw_course_content`: at least 50 characters
79
- - `text_books`: at least 5 characters
80
-
81
- Optional fields:
82
-
83
- - `reference_books`
84
- - `preferred_tools`
85
-
86
- Successful submissions return `message` and the inserted `submission` row.
87
-
88
- Validation errors use FastAPI's standard `422` response with a `detail` array.
89
-
90
- ## Deterministic Fields
91
-
92
- The backend computes these fields in `backend/app/services/deterministic.py`:
93
-
94
- | Credit category | L | T | P | S | C | Course type |
95
- | --- | ---: | ---: | ---: | ---: | ---: | --- |
96
- | `5` | 4 | 0 | 2 | 5 | 5 | Core Course-Lab Integrated |
97
- | `4` | 4 | 0 | 0 | 4 | 4 | Core Course |
98
- | `2` | 2 | 0 | 0 | 2 | 2 | Core Theory |
99
- | `0` | 0 | 0 | 0 | 0 | 0 | Foundation Course |
100
-
101
- All configured target departments currently map to `B. TECH`.
102
-
103
- Agent drafts are not allowed to change deterministic fields such as program, hours, credits, or course type. Drafts that attempt these changes are blocked.
104
-
105
- ## Database
106
-
107
- Run `docs/schema.sql` in the Supabase SQL editor before using the application. The schema creates:
108
-
109
- - `submissions`
110
- - `refined_submissions`
111
- - `curriculum_versions`
112
- - `finalized_submissions`
113
- - `agent_document_drafts`
114
- - `agent_drafts`
115
- - `course_revision_history`
116
- - `chat_sessions`
117
- - `chat_messages`
118
- - `chat_attachments`
119
-
120
- Use `GET /api/health/schema` to verify that the required tables exist.
121
-
122
- ## Environment
123
-
124
- Required backend variables:
125
-
126
- - `SUPABASE_URL`
127
- - `SUPABASE_KEY`
128
- - `OPENROUTER_URL`
129
- - `OPENROUTER_API_KEY`
130
- - `OPENROUTER_MODEL`
131
-
132
- Optional backend variables:
133
-
134
- - `CURRICULUM_YEAR`
135
- - `SENTRY_DSN`
136
- - `SENTRY_ENVIRONMENT`
137
- - `SENTRY_RELEASE`
138
-
139
- Keep environment values in `.env` or platform secrets. Do not expose backend credentials in frontend code.
140
-
141
- ## Local Development
142
-
143
- ```bash
144
- python3 -m venv .venv
145
- source .venv/bin/activate
146
- pip install -r requirements.txt
147
- cd backend
148
- fastapi dev app/main.py
149
- ```
150
-
151
- The backend serves the static frontend at `http://127.0.0.1:8000/` and mounts the API under `/api`.
152
-
153
- Run checks from the repository root:
154
-
155
- ```bash
156
- source .venv/bin/activate
157
- pytest
158
- python3 -m compileall backend/app
159
- ```
160
-
161
- ## Deployment
162
-
163
- The Docker image installs the Python dependencies, copies `backend/` and `frontend/`, and runs:
164
-
165
- ```bash
166
- uvicorn app.main:app --host 0.0.0.0 --port 7860
167
- ```
168
-
169
- The included GitHub Actions workflow syncs `main` to the Hugging Face Space configured in `.github/workflows/sync-to-hub.yml`.
170
-
171
- The static frontend can also be deployed from `frontend/`. The included `frontend/vercel.json` rewrites `/api/*` requests to the deployed backend.
172
-
173
- ## Current Checks
174
-
175
- The test suite covers:
176
-
177
- - Deterministic credit, hour, program, and course type mapping
178
- - Submission refinement helpers
179
- - Preview rendering and curriculum summary behavior
180
- - Agent diffing, protected field checks, and draft tooling
181
- - OpenRouter streaming and error handling
182
- - Static frontend route structure
183
- - Supabase schema status checks
184
- - Attachment extraction safeguards and text/DOCX parsing
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/remote-db.md DELETED
@@ -1,44 +0,0 @@
1
- # Remote Database Access
2
-
3
- This project uses Supabase as the remote database. The backend client is configured in `backend/app/supabase.py` and reads credentials from the repo root `.env` file.
4
-
5
- ## Required Access
6
-
7
- Ask the project owner for:
8
-
9
- - Supabase dashboard access.
10
- - The backend `SUPABASE_URL`.
11
- - The backend `SUPABASE_KEY`.
12
-
13
- Keep these values in `.env` only. Do not commit credentials or paste them into frontend code.
14
-
15
- ## Current Data Flow
16
-
17
- - `submissions` stores the raw form input.
18
- - `refined_submissions` stores the template-ready refined fields used by the preview template.
19
- - `agent_drafts` and `agent_document_drafts` store proposed AI changes before human approval.
20
- - `finalized_submissions` and `curriculum_versions` store approved curriculum snapshots.
21
-
22
- ## Schema
23
-
24
- Run `docs/schema.sql` in the Supabase SQL editor to create the public project tables. This file is schema-only: it does not rename tables, backfill data, or insert test rows.
25
-
26
- ## Read Data From Code
27
-
28
- Run these commands from the repo root after `.env` is configured:
29
-
30
- ```bash
31
- source .venv/bin/activate
32
- cd backend
33
- python3 - <<'PY'
34
- from app.supabase import supabase
35
-
36
- raw = supabase.table("submissions").select("id,course_title,status,semester").limit(5).execute()
37
- refined = supabase.table("refined_submissions").select("id,submission_id,course_title,semester").limit(5).execute()
38
-
39
- print("submissions")
40
- print(raw.data)
41
- print("refined_submissions")
42
- print(refined.data)
43
- PY
44
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/schema.sql DELETED
@@ -1,149 +0,0 @@
1
- create table if not exists submissions (
2
- id bigint generated by default as identity primary key,
3
- faculty_email text not null,
4
- course_title text not null,
5
- offering_department text not null check (offering_department in ('MA', 'CS', 'UZ')),
6
- target_department text not null check (target_department in ('CSE', 'ECE', 'ME', 'BT', 'EEE', 'AIML')),
7
- semester integer not null check (semester between 1 and 8),
8
- credit_category text not null check (credit_category in ('0', '2', '4', '5')),
9
- raw_course_content text not null,
10
- text_books text not null,
11
- reference_books text not null default '',
12
- preferred_tools text not null default '',
13
- status text not null default 'pending',
14
- created_at timestamptz not null default now(),
15
- updated_at timestamptz not null default now()
16
- );
17
-
18
- create table if not exists refined_submissions (
19
- id bigint generated by default as identity primary key,
20
- submission_id bigint not null unique references submissions(id) on delete cascade,
21
- semester integer not null check (semester between 1 and 8),
22
- course_code text not null default '',
23
- course_title text not null,
24
- program text not null,
25
- lecture_hours integer not null default 0,
26
- tutorial_hours integer not null default 0,
27
- practical_hours integer not null default 0,
28
- self_study integer not null default 0,
29
- credits integer not null default 0,
30
- course_type text not null,
31
- tools_languages text not null default '',
32
- desirable_knowledge text not null default '',
33
- prelude text not null default '',
34
- objectives text[] not null default '{}',
35
- course_outcomes text[] not null default '{}',
36
- units jsonb not null default '[]'::jsonb check (jsonb_typeof(units) = 'array'),
37
- lab_experiments text[] not null default '{}',
38
- text_books text[] not null default '{}',
39
- reference_books text[] not null default '{}',
40
- status text not null default 'refined',
41
- created_at timestamptz not null default now(),
42
- updated_at timestamptz not null default now()
43
- );
44
-
45
- create table if not exists curriculum_versions (
46
- id bigint generated by default as identity primary key,
47
- name text not null,
48
- program text not null,
49
- academic_year text not null,
50
- status text not null default 'draft' check (status in ('draft', 'published', 'archived')),
51
- created_at timestamptz not null default now(),
52
- updated_at timestamptz not null default now()
53
- );
54
-
55
- create table if not exists finalized_submissions (
56
- id bigint generated by default as identity primary key,
57
- curriculum_version_id bigint not null references curriculum_versions(id) on delete cascade,
58
- refined_id bigint not null references refined_submissions(id) on delete restrict,
59
- course_json jsonb not null check (jsonb_typeof(course_json) = 'object'),
60
- created_at timestamptz not null default now(),
61
- updated_at timestamptz not null default now(),
62
- unique (curriculum_version_id, refined_id)
63
- );
64
-
65
- create table if not exists agent_document_drafts (
66
- id bigint generated by default as identity primary key,
67
- curriculum_version_id bigint references curriculum_versions(id) on delete set null,
68
- uploaded_document_id text not null default '',
69
- diff_summary jsonb not null default '{}'::jsonb check (jsonb_typeof(diff_summary) = 'object'),
70
- change_reason text not null default '',
71
- status text not null default 'proposed' check (status in ('proposed', 'blocked', 'approved', 'rejected', 'applied')),
72
- created_at timestamptz not null default now(),
73
- updated_at timestamptz not null default now()
74
- );
75
-
76
- create table if not exists agent_drafts (
77
- id bigint generated by default as identity primary key,
78
- refined_id bigint not null references refined_submissions(id) on delete cascade,
79
- document_draft_id bigint references agent_document_drafts(id) on delete cascade,
80
- base_refined_json jsonb not null check (jsonb_typeof(base_refined_json) = 'object'),
81
- proposed_json jsonb not null check (jsonb_typeof(proposed_json) = 'object'),
82
- json_patch jsonb not null default '[]'::jsonb check (jsonb_typeof(json_patch) = 'array'),
83
- diff_summary jsonb not null default '{}'::jsonb check (jsonb_typeof(diff_summary) = 'object'),
84
- change_reason text not null default '',
85
- status text not null default 'proposed' check (status in ('proposed', 'blocked', 'approved', 'rejected', 'applied')),
86
- created_at timestamptz not null default now(),
87
- updated_at timestamptz not null default now()
88
- );
89
-
90
- create table if not exists course_revision_history (
91
- id bigint generated by default as identity primary key,
92
- refined_id bigint not null references refined_submissions(id) on delete cascade,
93
- agent_draft_id bigint references agent_drafts(id) on delete set null,
94
- source text not null default 'agent_draft',
95
- previous_json jsonb not null check (jsonb_typeof(previous_json) = 'object'),
96
- next_json jsonb not null check (jsonb_typeof(next_json) = 'object'),
97
- json_patch jsonb not null default '[]'::jsonb check (jsonb_typeof(json_patch) = 'array'),
98
- diff_summary jsonb not null default '{}'::jsonb check (jsonb_typeof(diff_summary) = 'object'),
99
- change_reason text not null default '',
100
- changed_by text not null default '',
101
- created_at timestamptz not null default now()
102
- );
103
-
104
- create table if not exists chat_sessions (
105
- id bigint generated by default as identity primary key,
106
- refined_id bigint references refined_submissions(id) on delete set null,
107
- document_draft_id bigint references agent_document_drafts(id) on delete set null,
108
- title text not null default '',
109
- status text not null default 'active' check (status in ('active', 'archived')),
110
- created_at timestamptz not null default now(),
111
- updated_at timestamptz not null default now()
112
- );
113
-
114
- create table if not exists chat_messages (
115
- id bigint generated by default as identity primary key,
116
- session_id bigint not null references chat_sessions(id) on delete cascade,
117
- role text not null check (role in ('user', 'assistant', 'system', 'tool')),
118
- content text not null default '',
119
- metadata jsonb not null default '{}'::jsonb check (jsonb_typeof(metadata) = 'object'),
120
- created_at timestamptz not null default now()
121
- );
122
-
123
- create table if not exists chat_attachments (
124
- id bigint generated by default as identity primary key,
125
- session_id bigint not null references chat_sessions(id) on delete cascade,
126
- message_id bigint references chat_messages(id) on delete set null,
127
- filename text not null,
128
- content_type text not null default '',
129
- size_bytes integer not null default 0,
130
- extracted_text text not null default '',
131
- status text not null default 'ready' check (status in ('ready', 'unsupported', 'failed')),
132
- error text not null default '',
133
- created_at timestamptz not null default now()
134
- );
135
-
136
- create index if not exists submissions_semester_idx on submissions (semester);
137
- create index if not exists submissions_status_idx on submissions (status);
138
- create index if not exists refined_submissions_semester_idx on refined_submissions (semester);
139
- create index if not exists agent_drafts_refined_id_idx on agent_drafts (refined_id);
140
- create index if not exists agent_drafts_document_draft_id_idx on agent_drafts (document_draft_id);
141
- create index if not exists agent_drafts_status_idx on agent_drafts (status);
142
- create index if not exists agent_document_drafts_status_idx on agent_document_drafts (status);
143
- create index if not exists finalized_submissions_curriculum_version_idx on finalized_submissions (curriculum_version_id);
144
- create index if not exists course_revision_history_refined_id_idx on course_revision_history (refined_id);
145
- create index if not exists chat_sessions_refined_id_idx on chat_sessions (refined_id);
146
- create index if not exists chat_sessions_document_draft_id_idx on chat_sessions (document_draft_id);
147
- create index if not exists chat_messages_session_id_idx on chat_messages (session_id, created_at);
148
- create index if not exists chat_attachments_session_id_idx on chat_attachments (session_id);
149
- create index if not exists chat_attachments_message_id_idx on chat_attachments (message_id);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/courses.html DELETED
@@ -1,2 +0,0 @@
1
- <!doctype html>
2
- <meta http-equiv="refresh" content="0;url=courses/" />
 
 
 
frontend/courses/courses.css DELETED
@@ -1,130 +0,0 @@
1
- @import "../shared.css";
2
-
3
- * {
4
- box-sizing: border-box;
5
- }
6
-
7
- body {
8
- margin: 0;
9
- min-height: 100dvh;
10
- background: var(--page-bg);
11
- color: var(--text);
12
- font-family: Arial, sans-serif;
13
- }
14
-
15
- .shell {
16
- display: grid;
17
- gap: 12px;
18
- padding: 16px;
19
- }
20
-
21
- .topbar,
22
- .panel {
23
- border: 1px solid var(--border);
24
- border-radius: 8px;
25
- background: var(--surface);
26
- }
27
-
28
- .topbar {
29
- display: flex;
30
- align-items: center;
31
- justify-content: space-between;
32
- gap: 12px;
33
- padding: 14px;
34
- }
35
-
36
- h1 {
37
- margin: 0;
38
- color: var(--pesu-blue);
39
- font-size: 22px;
40
- }
41
-
42
- p {
43
- margin: 4px 0 0;
44
- color: var(--muted);
45
- font-size: 13px;
46
- }
47
-
48
- .actions,
49
- .filters {
50
- display: flex;
51
- align-items: center;
52
- gap: 8px;
53
- min-width: 0;
54
- }
55
-
56
- .filters {
57
- padding: 12px;
58
- border-bottom: 1px solid var(--border);
59
- }
60
-
61
- .filters input {
62
- flex: 1;
63
- }
64
-
65
- #status {
66
- margin-left: auto;
67
- }
68
-
69
- .table-wrap {
70
- overflow: auto;
71
- }
72
-
73
- table {
74
- width: 100%;
75
- border-collapse: collapse;
76
- }
77
-
78
- th,
79
- td {
80
- padding: 10px 12px;
81
- border-bottom: 1px solid var(--border);
82
- text-align: left;
83
- vertical-align: top;
84
- font-size: 14px;
85
- }
86
-
87
- th {
88
- color: var(--pesu-blue);
89
- background: #f8fafc;
90
- font-size: 12px;
91
- text-transform: uppercase;
92
- }
93
-
94
- .course-row {
95
- cursor: pointer;
96
- }
97
-
98
- .course-row:hover {
99
- background: #f8fafc;
100
- }
101
-
102
- .details {
103
- display: grid;
104
- gap: 8px;
105
- color: #374151;
106
- line-height: 1.45;
107
- }
108
-
109
- .details strong {
110
- color: var(--pesu-blue);
111
- }
112
-
113
- .empty {
114
- padding: 20px;
115
- color: var(--muted);
116
- text-align: center;
117
- }
118
-
119
- @media (max-width: 760px) {
120
- .topbar,
121
- .actions,
122
- .filters {
123
- align-items: stretch;
124
- flex-direction: column;
125
- }
126
-
127
- #status {
128
- margin-left: 0;
129
- }
130
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/courses/courses.js DELETED
@@ -1,117 +0,0 @@
1
- const semester = document.getElementById("semester");
2
- const search = document.getElementById("search");
3
- const statusText = document.getElementById("status");
4
- const table = document.getElementById("course-table");
5
- let courses = [];
6
- let openId = "";
7
-
8
- function setStatus(text, kind = "") {
9
- statusText.textContent = text;
10
- statusText.className = `status-line ${kind}`.trim();
11
- }
12
-
13
- function cell(text) {
14
- const td = document.createElement("td");
15
- td.textContent = text || "";
16
- return td;
17
- }
18
-
19
- function courseMatches(course) {
20
- const query = search.value.trim().toLowerCase();
21
- if (semester.value && String(course.semester) !== semester.value) return false;
22
- if (!query) return true;
23
- return `${course.course_code} ${course.course_title}`.toLowerCase().includes(query);
24
- }
25
-
26
- function detailsRow(course) {
27
- const row = document.createElement("tr");
28
- const td = document.createElement("td");
29
- td.colSpan = 5;
30
- const details = document.createElement("div");
31
- details.className = "details";
32
- details.append(
33
- line("Type", course.course_type),
34
- line("Tools", course.tools_languages || "Not specified"),
35
- line("Desirable knowledge", course.desirable_knowledge || "None"),
36
- line("Prelude", course.prelude || "Not specified"),
37
- );
38
- td.appendChild(details);
39
- row.appendChild(td);
40
- return row;
41
- }
42
-
43
- function line(label, value) {
44
- const div = document.createElement("div");
45
- const strong = document.createElement("strong");
46
- strong.textContent = `${label}: `;
47
- div.append(strong, document.createTextNode(value));
48
- return div;
49
- }
50
-
51
- function render() {
52
- table.replaceChildren();
53
- const visible = courses.filter(courseMatches);
54
- if (!visible.length) {
55
- const row = document.createElement("tr");
56
- const td = document.createElement("td");
57
- td.colSpan = 5;
58
- td.className = "empty";
59
- td.textContent = "No courses found.";
60
- row.appendChild(td);
61
- table.appendChild(row);
62
- setStatus("No courses found.");
63
- return;
64
- }
65
-
66
- visible.forEach((course) => {
67
- const row = document.createElement("tr");
68
- row.className = "course-row";
69
- row.append(cell(course.semester), cell(course.course_code), cell(course.course_title), cell(course.credits));
70
-
71
- const action = document.createElement("td");
72
- const remove = document.createElement("button");
73
- remove.type = "button";
74
- remove.className = "danger";
75
- remove.textContent = "Delete";
76
- remove.addEventListener("click", (event) => {
77
- event.stopPropagation();
78
- deleteCourse(course);
79
- });
80
- action.appendChild(remove);
81
- row.appendChild(action);
82
-
83
- row.addEventListener("click", () => {
84
- openId = openId === String(course.id) ? "" : String(course.id);
85
- render();
86
- });
87
- table.appendChild(row);
88
- if (openId === String(course.id)) table.appendChild(detailsRow(course));
89
- });
90
- setStatus(`${visible.length} course${visible.length === 1 ? "" : "s"}.`, "ready");
91
- }
92
-
93
- async function deleteCourse(course) {
94
- if (!confirm(`Delete ${course.course_code || course.course_title}?`)) return;
95
- setStatus("Deleting course...");
96
- const response = await fetch(`/api/courses/${course.id}`, { method: "DELETE" });
97
- if (!response.ok) throw new Error("Delete failed");
98
- courses = courses.filter((item) => item.id !== course.id);
99
- if (openId === String(course.id)) openId = "";
100
- render();
101
- }
102
-
103
- async function loadCourses() {
104
- setStatus("Loading courses...");
105
- const response = await fetch("/api/courses");
106
- if (!response.ok) throw new Error("Unable to load courses");
107
- const body = await response.json();
108
- courses = body.courses || [];
109
- render();
110
- }
111
-
112
- semester.addEventListener("change", render);
113
- search.addEventListener("input", render);
114
-
115
- loadCourses().catch((error) => {
116
- setStatus(error instanceof Error ? error.message : "Unable to load courses.", "error");
117
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/courses/index.html DELETED
@@ -1,57 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Course Management</title>
7
- <link rel="stylesheet" href="courses.css" />
8
- <script src="courses.js" defer></script>
9
- </head>
10
- <body>
11
- <main class="shell">
12
- <header class="topbar">
13
- <div>
14
- <h1>Course Management</h1>
15
- <p>Manage refined courses used by preview, editor, and versions.</p>
16
- </div>
17
- <div class="actions">
18
- <a class="button-link" href="../form/">Add Course</a>
19
- <a class="button-link" href="../live-editor/">Live Editor</a>
20
- </div>
21
- </header>
22
-
23
- <section class="panel">
24
- <div class="filters">
25
- <select id="semester" aria-label="Semester">
26
- <option value="">All semesters</option>
27
- <option value="1">Semester 1</option>
28
- <option value="2">Semester 2</option>
29
- <option value="3">Semester 3</option>
30
- <option value="4">Semester 4</option>
31
- <option value="5">Semester 5</option>
32
- <option value="6">Semester 6</option>
33
- <option value="7">Semester 7</option>
34
- <option value="8">Semester 8</option>
35
- </select>
36
- <input id="search" type="search" aria-label="Search courses" />
37
- <span class="status-line" id="status"></span>
38
- </div>
39
-
40
- <div class="table-wrap">
41
- <table>
42
- <thead>
43
- <tr>
44
- <th>Semester</th>
45
- <th>Code</th>
46
- <th>Course</th>
47
- <th>Credits</th>
48
- <th></th>
49
- </tr>
50
- </thead>
51
- <tbody id="course-table"></tbody>
52
- </table>
53
- </div>
54
- </section>
55
- </main>
56
- </body>
57
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/form.html DELETED
@@ -1,11 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta http-equiv="refresh" content="0;url=form/" />
6
- <title>Course Submission - PESU</title>
7
- </head>
8
- <body>
9
- <a href="form/">Course Submission</a>
10
- </body>
11
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
frontend/form/index.html DELETED
@@ -1,100 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Course Submission - PESU</title>
7
- <link rel="stylesheet" href="form.css" />
8
- <script src="form.js" defer></script>
9
- </head>
10
- <body>
11
- <div class="container">
12
- <div class="card">
13
- <h1>Course Submission</h1>
14
- <p class="subtitle">Fill in the course details below.</p>
15
-
16
- <form id="course-form">
17
- <fieldset>
18
- <legend>Faculty Details</legend>
19
-
20
- <label for="faculty_email">Email</label>
21
- <input type="email" id="faculty_email" name="faculty_email" required />
22
-
23
- <label for="offering_department">Offering Department</label>
24
-
25
- <select id="offering_department" name="offering_department" required>
26
- <option value="">Select offering department</option>
27
- <option value="MA">Mathematics (MA)</option>
28
- <option value="CS">Computer Science (CS)</option>
29
- <option value="UZ">(UZ)</option>
30
- </select>
31
-
32
- <span class="hint">e.g. Mathematics, Computer Science (MA, CS)</span>
33
- </fieldset>
34
-
35
- <fieldset>
36
- <legend>Course Details</legend>
37
-
38
- <label for="target_department">Target Department / Program</label>
39
- <select id="target_department" name="target_department" required>
40
- <option value="">Select target department</option>
41
- <option value="CSE">Computer Science and Engineering (CSE)</option>
42
- <option value="AIML">Artificial Intelligence and Machine Learning (AIML)</option>
43
- <option value="ECE">Electronics and Communication Engineering (ECE)</option>
44
- <option value="ME">Mechanical Engineering (ME)</option>
45
- <option value="EEE">Electronics and Electrical Engineering (EEE)</option>
46
- <option value="BT">Biotechnology (BT)</option>
47
- </select>
48
-
49
- <label for="semester">Semester</label>
50
- <select id="semester" name="semester" required>
51
- <option value="">Select semester</option>
52
- <option value="1">Semester 1</option>
53
- <option value="2">Semester 2</option>
54
- <option value="3">Semester 3</option>
55
- <option value="4">Semester 4</option>
56
- <option value="5">Semester 5</option>
57
- <option value="6">Semester 6</option>
58
- <option value="7">Semester 7</option>
59
- <option value="8">Semester 8</option>
60
- </select>
61
-
62
- <label for="course_title">Course Title</label>
63
- <input type="text" id="course_title" name="course_title" required />
64
-
65
- <label for="credit_category">Credit Category</label>
66
- <select id="credit_category" name="credit_category" required>
67
- <option value="">Select credit category</option>
68
- <option value="0">0 Credits</option>
69
- <option value="2">2 Credits</option>
70
- <option value="4">4 Credits</option>
71
- <option value="5">5 Credits</option>
72
- </select>
73
- </fieldset>
74
-
75
- <fieldset>
76
- <legend>Academic Content</legend>
77
- <label for="raw_course_content">Course Content</label>
78
- <textarea id="raw_course_content" name="raw_course_content" rows="8" required></textarea>
79
- <span class="hint">Topics, units, or rough syllabus.</span>
80
-
81
- <label for="text_books">Textbook References</label>
82
- <textarea id="text_books" name="text_books" rows="4" required></textarea>
83
-
84
- <label for="reference_books">Other Suggested Reference Books <span class="optional">(optional)</span></label>
85
- <textarea id="reference_books" name="reference_books" rows="4"></textarea>
86
-
87
- <label for="preferred_tools">Preferred Tools / Languages <span class="optional">(optional)</span></label>
88
- <textarea id="preferred_tools" name="preferred_tools" rows="3"></textarea>
89
- </fieldset>
90
-
91
- <button type="submit" id="submit-btn">Submit Course</button>
92
- <a class="form-link" href="../courses/">Manage Courses</a>
93
- <a class="form-link" href="../preview/">View Preview</a>
94
- </form>
95
-
96
- <div id="result" class="hidden"></div>
97
- </div>
98
- </div>
99
- </body>
100
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/home/home.css DELETED
@@ -1,70 +0,0 @@
1
- @import "../shared.css";
2
-
3
- *, *::before, *::after {
4
- box-sizing: border-box;
5
- margin: 0;
6
- padding: 0;
7
- }
8
-
9
- :root {
10
- --bg: var(--page-bg);
11
- --text: var(--pesu-blue);
12
- --accent: var(--pesu-orange);
13
- }
14
-
15
- body {
16
- min-height: 100dvh;
17
- display: flex;
18
- align-items: center;
19
- justify-content: center;
20
- padding: 1rem;
21
- background: var(--bg);
22
- color: var(--text);
23
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
24
- }
25
-
26
- .home-shell {
27
- width: 100%;
28
- max-width: 760px;
29
- }
30
-
31
- .home-panel {
32
- background: var(--surface);
33
- border: 1px solid var(--border);
34
- border-radius: 12px;
35
- padding: 2rem;
36
- }
37
-
38
- h1 {
39
- font-size: 1.5rem;
40
- }
41
-
42
- .surface-grid {
43
- display: grid;
44
- grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
45
- gap: 1rem;
46
- margin-top: 1.5rem;
47
- }
48
-
49
- .surface-link {
50
- display: flex;
51
- align-items: center;
52
- justify-content: center;
53
- min-height: 96px;
54
- border: 1px solid var(--border);
55
- border-radius: 8px;
56
- color: var(--text);
57
- background: var(--surface);
58
- text-decoration: none;
59
- font-weight: 700;
60
- }
61
-
62
- .surface-link:hover {
63
- border-color: var(--accent);
64
- }
65
-
66
- @media (max-width: 640px) {
67
- .surface-grid {
68
- grid-template-columns: 1fr;
69
- }
70
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/index.html CHANGED
@@ -3,21 +3,97 @@
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>PESU Curriculum Automation</title>
7
- <link rel="stylesheet" href="home/home.css" />
 
8
  </head>
9
  <body>
10
- <main class="home-shell">
11
- <section class="home-panel">
12
- <h1>PESU Curriculum Automation</h1>
13
- <nav class="surface-grid" aria-label="Curriculum surfaces">
14
- <a class="surface-link" href="form/">Raw Data</a>
15
- <a class="surface-link" href="courses/">Courses</a>
16
- <a class="surface-link" href="preview/">Preview</a>
17
- <a class="surface-link" href="live-editor/">Live Editor</a>
18
- <a class="surface-link" href="versions/">Versions</a>
19
- </nav>
20
- </section>
21
- </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  </body>
23
  </html>
 
3
  <head>
4
  <meta charset="UTF-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Course Submission — PESU</title>
7
+ <link rel="stylesheet" href="styles.css" />
8
+ <script src="script.js" defer></script>
9
  </head>
10
  <body>
11
+ <div class="container">
12
+ <div class="card">
13
+ <h1>Course Submission</h1>
14
+ <p class="subtitle">Fill in the course details below.</p>
15
+
16
+ <form id="course-form">
17
+ <fieldset>
18
+ <legend>Faculty Details</legend>
19
+
20
+ <label for="faculty_email">Email</label>
21
+ <input type="email" id="faculty_email" name="faculty_email" required />
22
+
23
+ <label for="offering_department">Offering Department</label>
24
+
25
+ <select id="offering_department" name="offering_department" required>
26
+ <option value="">Select offering department</option>
27
+ <option value="MA">Mathematics (MA)</option>
28
+ <option value="CS">Computer Science (CS)</option>
29
+ <option value="UZ">(UZ)</option>
30
+ </select>
31
+
32
+ <span class="hint">e.g. Mathematics, Computer Science (MA, CS)</span>
33
+
34
+ </fieldset>
35
+
36
+ <fieldset>
37
+ <legend>Course Details</legend>
38
+
39
+ <label for="target_department">Target Department / Program</label>
40
+ <select id="target_department" name="target_department" required>
41
+ <option value="">Select target department</option>
42
+ <option value="CSE">Computer Science and Engineering (CSE)</option>
43
+ <option value="AIML">Artificial Intelligence and Machine Learning (AIML)</option>
44
+ <option value="ECE">Electronics and Communication Engineering (ECE)</option>
45
+ <option value="ME">Mechanical Engineering (ME)</option>
46
+ <option value="EEE">Electronics and Electrical Engineering (EEE)</option>
47
+ <option value="BT">Biotechnology (BT)</option>
48
+ </select>
49
+
50
+ <label for="semester">Semester</label>
51
+ <select id="semester" name="semester" required>
52
+ <option value="">Select semester</option>
53
+ <option value="1">Semester 1</option>
54
+ <option value="2">Semester 2</option>
55
+ <option value="3">Semester 3</option>
56
+ <option value="4">Semester 4</option>
57
+ <option value="5">Semester 5</option>
58
+ <option value="6">Semester 6</option>
59
+ <option value="7">Semester 7</option>
60
+ <option value="8">Semester 8</option>
61
+ </select>
62
+
63
+ <label for="course_title">Course Title</label>
64
+ <input type="text" id="course_title" name="course_title" required />
65
+
66
+ <label for="credit_category">Credit Category</label>
67
+ <select id="credit_category" name="credit_category" required>
68
+ <option value="">Select credit category</option>
69
+ <option value="0">0 Credits</option>
70
+ <option value="2">2 Credits</option>
71
+ <option value="4">4 Credits</option>
72
+ <option value="5">5 Credits</option>
73
+ </select>
74
+ </fieldset>
75
+
76
+ <fieldset>
77
+ <legend>Academic Content</legend>
78
+ <label for="raw_course_content">Course Content</label>
79
+ <textarea id="raw_course_content" name="raw_course_content" rows="8" required></textarea>
80
+ <span class="hint">Topics, units, or rough syllabus.</span>
81
+
82
+ <label for="text_books">Textbook References</label>
83
+ <textarea id="text_books" name="text_books" rows="4" required></textarea>
84
+
85
+ <label for="reference_books">Other Suggested Reference Books <span class="optional">(optional)</span></label>
86
+ <textarea id="reference_books" name="reference_books" rows="4"></textarea>
87
+
88
+ <label for="preferred_tools">Preferred Tools / Languages <span class="optional">(optional)</span></label>
89
+ <textarea id="preferred_tools" name="preferred_tools" rows="3"></textarea>
90
+ </fieldset>
91
+
92
+ <button type="submit" id="submit-btn">Submit Course</button>
93
+ </form>
94
+
95
+ <div id="result" class="hidden"></div>
96
+ </div>
97
+ </div>
98
  </body>
99
  </html>
frontend/live-editor.html DELETED
@@ -1,11 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta http-equiv="refresh" content="0;url=live-editor/" />
6
- <title>Live Editor - PESU</title>
7
- </head>
8
- <body>
9
- <a href="live-editor/">Live Editor</a>
10
- </body>
11
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
frontend/live-editor/index.html DELETED
@@ -1,112 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Live Editor - PESU</title>
7
- <link rel="stylesheet" href="live-editor.css" />
8
- </head>
9
- <body>
10
- <header class="toolbar">
11
- <div class="toolbar-group course-controls">
12
- <select id="semester" aria-label="Semester">
13
- <option value="1">Semester 1</option>
14
- <option value="2">Semester 2</option>
15
- <option value="3">Semester 3</option>
16
- <option value="4">Semester 4</option>
17
- <option value="5">Semester 5</option>
18
- <option value="6">Semester 6</option>
19
- <option value="7">Semester 7</option>
20
- <option value="8">Semester 8</option>
21
- </select>
22
- <select id="course" aria-label="Course"></select>
23
- <select id="view-mode" aria-label="Preview mode">
24
- <option value="course">Course Preview</option>
25
- <option value="document">Full Document</option>
26
- </select>
27
- <a class="button-link" id="preview" target="_blank" rel="noreferrer">Open</a>
28
- </div>
29
-
30
- <div class="toolbar-group version-controls">
31
- <input id="version-name" type="text" aria-label="Version name" />
32
- <button id="save-version" type="button">Save Version</button>
33
- <select id="version-select" aria-label="Saved version"></select>
34
- <button id="restore-version" type="button">Restore</button>
35
- </div>
36
-
37
- <span class="status-line" id="status" role="status" hidden></span>
38
- </header>
39
-
40
- <main class="workspace">
41
- <section class="preview-pane" aria-label="Course preview">
42
- <iframe id="viewer" title="Course preview"></iframe>
43
- <div class="loading" id="loading">Loading...</div>
44
- </section>
45
-
46
- <aside class="side-pane">
47
- <div class="tabs" role="tablist" aria-label="Editor tools">
48
- <button class="tab active" id="chat-tab" type="button" role="tab" aria-selected="true" aria-controls="chat-panel">Chat</button>
49
- <button class="tab" id="fields-tab" type="button" role="tab" aria-selected="false" aria-controls="fields-panel">Fields</button>
50
- <button class="tab" id="review-tab" type="button" role="tab" aria-selected="false" aria-controls="review-panel">Review</button>
51
- </div>
52
-
53
- <section class="panel chat-panel active" id="chat-panel" role="tabpanel" aria-labelledby="chat-tab">
54
- <div class="thread-bar">
55
- <div class="thread-selector">
56
- <select id="chat-session" aria-label="Chat thread"></select>
57
- <button id="rename-chat" type="button" class="icon-btn" title="Rename thread">
58
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
59
- <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
60
- <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
61
- </svg>
62
- </button>
63
- </div>
64
- <input id="chat-title" type="text" aria-label="Chat title" hidden />
65
- <button class="danger" id="delete-chat" type="button">Delete</button>
66
- <button id="new-chat" type="button">New</button>
67
- </div>
68
- <div class="chat-status" id="chat-status" role="status"></div>
69
- <div class="chat-log" id="chat-log" aria-live="polite"></div>
70
- <div class="composer">
71
- <div class="attachments" id="draft-attachments"></div>
72
- <textarea id="message" aria-label="Message"></textarea>
73
- <div class="composer-actions">
74
- <button id="attach" type="button">Attach</button>
75
- <input id="files" type="file" multiple accept="image/*,.pdf,.txt,.doc,.docx" hidden />
76
- <button class="primary" id="send" type="button">Send</button>
77
- </div>
78
- </div>
79
- </section>
80
-
81
- <section class="panel fields-panel" id="fields-panel" role="tabpanel" aria-labelledby="fields-tab">
82
- <textarea id="editor" spellcheck="false" aria-label="Course fields"></textarea>
83
- <div class="field-actions">
84
- <button id="draft" type="button">Create Draft</button>
85
- <button class="primary" id="save" type="button">Save</button>
86
- </div>
87
- </section>
88
-
89
- <section class="panel review-panel" id="review-panel" role="tabpanel" aria-labelledby="review-tab">
90
- <div class="document-draft">
91
- <div class="draft-loader">
92
- <select id="course-draft-select" aria-label="Course draft"></select>
93
- <button id="load-course-draft" type="button" title="Load single‑course draft from agent">Load Course</button>
94
- </div>
95
- <div class="draft-loader">
96
- <select id="document-draft-select" aria-label="Document draft"></select>
97
- <button id="load-document-draft" type="button" title="Load multi‑course document draft from agent">Load Document</button>
98
- </div>
99
- </div>
100
- <div class="review-summary" id="review-summary"></div>
101
- <div class="diff-view" id="diff-view"></div>
102
- <div class="field-actions">
103
- <button id="preview-draft" type="button">Preview Draft</button>
104
- <button class="primary" id="apply-draft" type="button">Apply Draft</button>
105
- </div>
106
- </section>
107
- </aside>
108
- </main>
109
-
110
- <script src="live-editor.js" defer></script>
111
- </body>
112
- </html>