diff --git a/.dockerignore b/.dockerignore index d053355dda4dbb633b161751b617e310a78378c9..3ee815c756013e28bd0aa1ca0ac51011221c1724 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,2 @@ -.venv/ -__pycache__/ -*.pyc -.env -.nottracked/ -.git/ -.gitignore -AGENTS.md -README.md -LICENSE -*.md +.venv/ __pycache__/ *.pyc .env .nottracked/ frontend/ +.git/ .gitignore AGENTS.md README.md LICENSE *.md \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..a6344aac8c09253b3b630fb776ae94478aa0275b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,35 @@ +*.7z filter=lfs diff=lfs merge=lfs -text +*.arrow filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.ckpt filter=lfs diff=lfs merge=lfs -text +*.ftz filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.h5 filter=lfs diff=lfs merge=lfs -text +*.joblib filter=lfs diff=lfs merge=lfs -text +*.lfs.* filter=lfs diff=lfs merge=lfs -text +*.mlmodel filter=lfs diff=lfs merge=lfs -text +*.model filter=lfs diff=lfs merge=lfs -text +*.msgpack filter=lfs diff=lfs merge=lfs -text +*.npy filter=lfs diff=lfs merge=lfs -text +*.npz filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text +*.ot filter=lfs diff=lfs merge=lfs -text +*.parquet filter=lfs diff=lfs merge=lfs -text +*.pb filter=lfs diff=lfs merge=lfs -text +*.pickle filter=lfs diff=lfs merge=lfs -text +*.pkl filter=lfs diff=lfs merge=lfs -text +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.safetensors filter=lfs diff=lfs merge=lfs -text +saved_model/**/* filter=lfs diff=lfs merge=lfs -text +*.tar.* filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tflite filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.wasm filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.zst filter=lfs diff=lfs merge=lfs -text +*tfevents* filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile index e7acb09b33996bce5f1579f406f81dbc0ffebed3..e3a12cb34ca5e754d7ab034674e04a0fdf7ee825 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,22 +2,12 @@ FROM python:3.12-slim WORKDIR /app -RUN apt-get update && apt-get install -y \ - libpango-1.0-0 \ - libpangocairo-1.0-0 \ - libgdk-pixbuf-2.0-0 \ - libffi-dev \ - poppler-utils \ - shared-mime-info \ - && rm -rf /var/lib/apt/lists/* - COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY backend/ . -COPY frontend/ frontend/ EXPOSE 7860 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"] \ No newline at end of file diff --git a/README.md b/README.md index 1cde02f3389a5adc865a02016a7b8db93b651052..80bddc10994d9766dfe3ce657d85475de4bdf4e2 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,10 @@ --- title: Curriculum Backend -colorFrom: blue +emoji: 👁 +colorFrom: pink colorTo: gray sdk: docker -app_port: 7860 pinned: false --- -# Curriculum Automation - -## Doc link - -https://lonelyguy-se1.github.io/PESU-Curriculum-Automation/ - -## Phase 1 Flow Proposal - -```mermaid id="phase1-flow" -flowchart TD - - A[Professor opens form] --> B[Enter course details] - B --> C[Submit content, semester, credits, department, books] - - C --> D[FastAPI receives submission] - D --> E[Check mandatory fields] - - E -->|Invalid| F[Reject submission] - F --> G[Email professor with reasons] - G --> H[Professor opens edit link] - H --> I[Cached form shows old data and flagged issues] - I --> C - - E -->|Valid| J[Generate derived fields] - J --> J1[Course code] - J --> J2[Credit pattern] - J --> J3[Course type] - J --> J4[Template data] - - J1 --> K[AI refinement] - J2 --> K - J3 --> K - J4 --> K - - K --> K1[Refine content] - K --> K2[Generate prelude] - K --> K3[Generate objectives] - K --> K4[Generate outcomes] - K --> K5[Recommend tools] - - K1 --> L[Rubric check] - K2 --> L - K3 --> L - K4 --> L - K5 --> L - - L -->|Low score| M[Reject draft] - M --> N[Store flagged issues] - N --> G - - L -->|Pass| O[Create draft record] - O --> P[Render preview with Jinja2] - - P --> Q[Faculty or admin review] - - Q -->|Rejected| R[Store rejection reasons] - R --> G - - Q -->|Accepted| S[Update remote database] - S --> T[Update curriculum template fields] - T --> U[Keep incomplete template pending] - - U --> V{All course submissions received?} - - V -->|No| W[Wait for remaining professors] - W --> U - - V -->|Yes| X[Compile final curriculum document] - X --> Y[Final review] - Y --> Z[Export final PDF or DOCX] -``` - -## Phase 1 Summary - -Professors submit only the required academic material. - -The system generates derived fields, refines the content, checks it using a rubric, and stores accepted drafts in a remote database. - -Rejected submissions are returned by email with reasons. The professor gets an edit link where previous data is already cached and flagged issues are shown. - -Accepted submissions do not immediately become the final document. They update the shared curriculum template and remain pending until all required course submissions are received. - -The final curriculum document is compiled only after every required course entry is complete. +Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference diff --git a/backend/app/api.py b/backend/app/api.py index b8ea0442176e70af83997dcf382e7395530c5ceb..72f8d9020944d3694cdf7d4aa47deebd79f88242 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -1,8 +1,60 @@ from fastapi import APIRouter +from fastapi.responses import HTMLResponse +from jinja2 import Environment, FileSystemLoader +from pydantic import BaseModel,Field, field_validator +from typing import Literal -from app.routes import agent, chat, courses, health, preview, refined, submissions, versions +from app.preview import build_course_preview +from app.supabase import supabase -router = APIRouter() +router=APIRouter() -for route in (health, submissions, preview, refined, agent, chat, versions, courses): - router.include_router(route.router) +templates=Environment(loader=FileSystemLoader("app/templates")) + +class CourseSubmission(BaseModel): + faculty_email: str=Field(min_length=3, max_length=254) + course_title: str=Field(min_length=3, max_length=150) + offering_department: Literal["MA", "CS", "UZ"] + target_department: Literal["CSE", "ECE", "ME", "BT", "EEE", "AIML"] + semester: Literal["1", "2", "3", "4", "5", "6", "7", "8"] + credit_category: Literal["0", "2", "4", "5"] + raw_course_content: str=Field(min_length=50) + text_books: str=Field(min_length=5) + reference_books: str="" + preferred_tools: str="" + + @field_validator( + "faculty_email", "course_title", "raw_course_content", "text_books", "reference_books", "preferred_tools", mode="before",) + @classmethod + def strip(cls, v): + return v.strip() if isinstance(v, str) else v + +@router.post("/submissions") +def receive(data: CourseSubmission): + """ + print("=== NEW SUBMISSION ===") + for field, values in data.model_dump().items(): + print(f"{field}: {values}") + print("======================") + return {"message": "Submission received successfully!", "submission": data.model_dump()} + """ + + data_dict=data.model_dump() + data_dict["status"]="pending" + result=supabase.table("submissions").insert(data_dict).execute() + print("New Submission Received! Course Title: ", data.course_title) + return {"message":"Submission Received!", "submission":result.data[0]} + +@router.get("/preview/semester/{sem}/courses") +def list_courses(sem:str): # sem will be used once semester column is added + result=supabase.table("refined_submissions").select("id").execute() + rows: list[dict] = result.data + return {"course_ids": [r["id"] for r in rows]} + +@router.get("/preview/course/{refined_id}") +def preview_course(refined_id:int): + result=supabase.table("refined_submissions").select("*").eq("id", refined_id).single().execute() + row: dict = result.data + course=build_course_preview(row) + html=templates.get_template("jinja_sample.html").render(course=course, curriculum_year="", page_number="") + return HTMLResponse(content=html) diff --git a/backend/app/main.py b/backend/app/main.py index c8f52f772b9c1e622e2a54a319d18fba8c9826f6..e1e73f4cf94649e67241378bd62f1acf60752807 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,35 +1,11 @@ -import os from pathlib import Path -import sentry_sdk from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from dotenv import load_dotenv - -load_dotenv(Path(__file__).resolve().parents[2] / ".env") - -sentry_dsn = os.getenv("SENTRY_DSN", "").strip() -if sentry_dsn: - sentry_config = {"dsn": sentry_dsn, "environment": os.getenv("SENTRY_ENVIRONMENT", "").strip() or "production"} - sentry_release = os.getenv("SENTRY_RELEASE", "").strip() - if sentry_release: - sentry_config["release"] = sentry_release - sentry_sdk.init(**sentry_config) from app.api import router - -def frontend_directory(): - app_root = Path(__file__).resolve().parents[1] - candidates = ( - app_root / "frontend", - app_root.parent / "frontend", - app_root.parent.parent / "frontend", - Path("/frontend"), - ) - return next((path for path in candidates if path.exists()), None) - app = FastAPI(title="PESU Curriculum Automation") app.add_middleware( @@ -41,7 +17,5 @@ app.add_middleware( app.include_router(router, prefix="/api") -frontend_dir = frontend_directory() -if not frontend_dir: - raise RuntimeError("Frontend directory not found") -app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend") +if Path("../frontend").exists(): + app.mount("/", StaticFiles(directory="../frontend", html=True), name="frontend") \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py deleted file mode 100644 index 8b137891791fe96927ad78e64b0aad7bded08bdc..0000000000000000000000000000000000000000 --- a/backend/app/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/backend/app/models/agent.py b/backend/app/models/agent.py deleted file mode 100644 index cba46ac7df4d5609bd65d0b0d29f58dbc18b5e1b..0000000000000000000000000000000000000000 --- a/backend/app/models/agent.py +++ /dev/null @@ -1,23 +0,0 @@ -from pydantic import BaseModel, Field - - -class AgentDraftPayload(BaseModel): - refined_id: int - fields: dict - reason: str = "" - - -class AgentDocumentCoursePayload(BaseModel): - refined_id: int - fields: dict - - -class AgentDocumentDraftPayload(BaseModel): - courses: list[AgentDocumentCoursePayload] = Field(min_length=1) - reason: str = "" - curriculum_version_id: int | None = None - uploaded_document_id: str = "" - - -class AgentToolPayload(BaseModel): - arguments: dict = Field(default_factory=dict) diff --git a/backend/app/models/chat.py b/backend/app/models/chat.py deleted file mode 100644 index 4e5735010e521966addd624ab9d9f181b739a879..0000000000000000000000000000000000000000 --- a/backend/app/models/chat.py +++ /dev/null @@ -1,26 +0,0 @@ -from pydantic import BaseModel, Field, field_validator - - -class ChatSessionPayload(BaseModel): - refined_id: int | None = None - document_draft_id: int | None = None - title: str = "" - - -class ChatSessionTitlePayload(BaseModel): - title: str = Field(min_length=1, max_length=120) - - @field_validator("title", mode="before") - @classmethod - def strip_title(cls, value): - return value.strip() if isinstance(value, str) else value - - -class ChatMessagePayload(BaseModel): - content: str = "" - metadata: dict = Field(default_factory=dict) - - @field_validator("content", mode="before") - @classmethod - def strip_content(cls, value): - return value.strip() if isinstance(value, str) else value diff --git a/backend/app/models/submission.py b/backend/app/models/submission.py deleted file mode 100644 index 5088bb5148a671de58a591cf3f79f139f27cddd6..0000000000000000000000000000000000000000 --- a/backend/app/models/submission.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Literal - -from pydantic import BaseModel, Field, field_validator - - -class CourseSubmission(BaseModel): - faculty_email: str = Field(min_length=3, max_length=254) - course_title: str = Field(min_length=3, max_length=150) - offering_department: Literal["MA", "CS", "UZ"] - target_department: Literal["CSE", "ECE", "ME", "BT", "EEE", "AIML"] - semester: Literal["1", "2", "3", "4", "5", "6", "7", "8"] - credit_category: Literal["0", "2", "4", "5"] - raw_course_content: str = Field(min_length=50) - text_books: str = Field(min_length=5) - reference_books: str = "" - preferred_tools: str = "" - - @field_validator("faculty_email", "course_title", "raw_course_content", "text_books", "reference_books", "preferred_tools", mode="before") - @classmethod - def strip(cls, value): - return value.strip() if isinstance(value, str) else value diff --git a/backend/app/preview.py b/backend/app/preview.py index b4a53d73ab4d38ba74af2b6c5ce2271f8c6107ee..0b12d80ed18d86a80ac52b49990a5a0f8298168d 100644 --- a/backend/app/preview.py +++ b/backend/app/preview.py @@ -1,81 +1,23 @@ import json -from app.services.books import parse_books -from app.services.deterministic import compute_course_type, compute_hours, compute_program - - -def _lines(value) -> list[str]: - if not value: - return [] - if isinstance(value, list): - return [str(item).strip() for item in value if str(item).strip()] - return [line.strip() for line in str(value).splitlines() if line.strip()] - - -def _content(row: dict) -> dict: - raw = row.get("refined_content") or {} - if isinstance(raw, dict): - return raw - if isinstance(raw, str) and raw.strip(): - return json.loads(raw) - return {} - - -def _text(*values) -> str: - for value in values: - if isinstance(value, str): - text = value.strip() - if text and text != "-": - return text - return "" - def build_course_preview(row: dict) -> dict: - content = _content(row) - submission = row.get("_submission") or {} - credit_category = str(submission.get("credit_category") or row.get("credit_category") or content.get("credits") or "") - target_department = str(submission.get("target_department") or "") - - hours = { - "lecture_hours": row.get("lecture_hours", content.get("lecture_hours", 0)), - "tutorial_hours": row.get("tutorial_hours", content.get("tutorial_hours", 0)), - "practical_hours": row.get("practical_hours", content.get("practical_hours", 0)), - "self_study": row.get("self_study", content.get("self_study", 0)), - "credits": row.get("credits", content.get("credits", 0)), - } - if credit_category and not any(hours.values()): - hours = compute_hours(credit_category) - - program = row.get("program") or content.get("program") or "" - if target_department and not program: - program = compute_program(target_department) - - course_type = row.get("course_type") or content.get("course_type") or "" - if credit_category and not course_type: - course_type = compute_course_type(credit_category) - - practical_hours = int(hours["practical_hours"] or 0) - status = str(row.get("status") or content.get("status") or "") + content = json.loads(row.get("refined_content") or "{}") return { - "course_code": str(row.get("course_code") or content.get("course_code") or ""), - "course_title": str(row.get("course_title") or content.get("course_title") or ""), - "program": str(program), - "lecture_hours": str(hours["lecture_hours"]), - "tutorial_hours": str(hours["tutorial_hours"]), - "practical_hours": str(practical_hours), - "self_study": str(hours["self_study"]), - "credits": str(hours["credits"]), - "semester": str(row.get("semester") or content.get("semester") or ""), - "course_type": str(course_type), - "status": status, - "render_detail": status != "summary_only", - "tools_languages": _text(row.get("tools_languages"), content.get("tools_languages"), submission.get("preferred_tools")), - "desirable_knowledge": _text(row.get("desirable_knowledge"), content.get("desirable_knowledge")), - "prelude": row.get("prelude") or content.get("prelude") or "", - "objectives": _lines(row.get("objectives") or content.get("objectives"))[:4], - "course_outcomes": _lines(row.get("course_outcomes") or content.get("course_outcomes"))[:4], - "units": row.get("units") or content.get("units") or [], - "lab_experiments": _lines(row.get("lab_experiments") or content.get("lab_experiments"))[:10] if practical_hours else [], - "text_books": parse_books(row.get("text_books") or content.get("text_books")) or parse_books(submission.get("text_books")), - "reference_books": parse_books(row.get("reference_books") or content.get("reference_books")) or parse_books(submission.get("reference_books")), - } + "course_code": str(content.get("course_code", "")), + "course_title": str(content.get("course_title", "")), + "program": str(content.get("program", "")), + "lecture_hours": str(content.get("lecture_hours", 0)), + "tutorial_hours": str(content.get("tutorial_hours", 0)), + "practical_hours": str(content.get("practical_hours", 0)), + "self_study": str(content.get("self_study", 0)), + "credits": str(content.get("credits", 0)), + "semester": str(content.get("semester", "")), + "course_type": str(content.get("course_type", "")), + "tools_languages": str(content.get("tools_languages", "")), + "desirable_knowledge": str(content.get("desirable_knowledge", "")), + "prelude": row.get("prelude") or content.get("prelude", ""), + "objectives": row.get("objectives") or content.get("objectives", []), + "units": content.get("units", []), + "lab_experiments": content.get("lab_experiments", []), + } \ No newline at end of file diff --git a/backend/app/rendering.py b/backend/app/rendering.py deleted file mode 100644 index f8be9058a62937f8dc418739ccd3e9d201cdfc94..0000000000000000000000000000000000000000 --- a/backend/app/rendering.py +++ /dev/null @@ -1,73 +0,0 @@ -import re -from pathlib import Path - -from jinja2 import Environment, FileSystemLoader, select_autoescape -from markupsafe import Markup, escape - -APP_DIR = Path(__file__).resolve().parent - - -def _find_frontend_dir() -> Path: - app_root = APP_DIR.parent - candidates = ( - app_root / "frontend", - app_root.parent / "frontend", - app_root.parent.parent / "frontend", - Path("/frontend"), - ) - result = next((p for p in candidates if p.exists()), None) - if result is None: - raise RuntimeError("Frontend directory not found; cannot resolve image assets for PDF rendering") - return result - - -FRONTEND_DIR = _find_frontend_dir() -templates = Environment(loader=FileSystemLoader(APP_DIR / "templates"), autoescape=select_autoescape(["html", "xml"])) -URL_RE = re.compile(r"https?://[^\s<>()]+") -YEAR_RE = re.compile(r"\d{4}") - - -def linkify(value: str) -> Markup: - text = str(value or "") - parts = [] - last = 0 - for match in URL_RE.finditer(text): - raw_url = match.group(0) - url = raw_url.rstrip(".,;:)]}") - trailing = raw_url[len(url) :] - safe_url = escape(url) - parts.append(escape(text[last : match.start()])) - parts.append(Markup(f'{safe_url}')) - parts.append(escape(trailing)) - last = match.end() - parts.append(escape(text[last:])) - return Markup("".join(str(part) for part in parts)) - - -def batch_start_year(semester, curriculum_year: str) -> int | None: - match = YEAR_RE.search(str(curriculum_year or "")) - if not match: - return None - try: - sem = int(semester) - except (TypeError, ValueError): - return None - return int(match.group()) - ((sem - 1) // 2) - - -def batch_label(semester, curriculum_year: str) -> str: - start = batch_start_year(semester, curriculum_year) - return f"({start}-{(start + 4) % 100:02d} BATCH)" if start else "" - - -def course_code_for_year(value: str, semester, curriculum_year: str) -> str: - code = str(value or "") - start = batch_start_year(semester, curriculum_year) - if not start or len(code) < 5 or code[:2] not in {"UE", "UZ"} or not code[2:4].isdigit(): - return code - return f"{code[:2]}{start % 100:02d}{code[4:]}" - - -templates.filters["linkify"] = linkify -templates.filters["course_code_for_year"] = course_code_for_year -templates.globals["batch_label"] = batch_label diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py deleted file mode 100644 index 8b137891791fe96927ad78e64b0aad7bded08bdc..0000000000000000000000000000000000000000 --- a/backend/app/routes/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/backend/app/routes/agent.py b/backend/app/routes/agent.py deleted file mode 100644 index 72be20e4f841822411add7368e09f66a1d2f2ec4..0000000000000000000000000000000000000000 --- a/backend/app/routes/agent.py +++ /dev/null @@ -1,225 +0,0 @@ -from fastapi import APIRouter, HTTPException -from fastapi.responses import HTMLResponse -from postgrest.exceptions import APIError - -from app.models.agent import AgentDocumentDraftPayload, AgentDraftPayload, AgentToolPayload -from app.rendering import templates -from app.services.agent_tools import call_tool, list_tool_schemas -from app.services.curriculum import draft_record, load_agent_draft, load_document_draft, selected_curriculum_year, update_refined_fields -from app.services.diffing import diff_course -from app.services.errors import database_http_exception -from app.supabase import supabase - -router = APIRouter() - - -@router.post("/agent/diff") -def compare_course(payload: dict): - current = payload.get("current") - proposed = payload.get("proposed") - if not isinstance(current, dict) or not isinstance(proposed, dict): - raise HTTPException(status_code=400, detail="current and proposed are required") - return diff_course(current, proposed) - - -@router.post("/agent/drafts") -def create_agent_draft(payload: AgentDraftPayload): - try: - record = draft_record(payload.refined_id, payload.fields, payload.reason) - result = supabase.table("agent_drafts").insert(record).execute() - except LookupError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except APIError as exc: - raise database_http_exception(exc) from exc - return {"message": "Draft created", "draft": result.data[0]} - - -@router.get("/agent/drafts") -def list_agent_drafts(): - try: - rows = supabase.table("agent_drafts").select("*").order("id", desc=True).limit(100).execute().data - except APIError as exc: - raise database_http_exception(exc) from exc - return { - "drafts": [ - { - "id": row["id"], - "refined_id": row["refined_id"], - "status": row.get("status") or "", - "course_title": (row.get("proposed_json") or {}).get("course_title") or "", - "course_code": (row.get("proposed_json") or {}).get("course_code") or "", - "change_reason": row.get("change_reason") or "", - "created_at": row.get("created_at") or "", - } - for row in rows - ] - } - - -@router.get("/agent/drafts/{draft_id}") -def get_agent_draft(draft_id: int): - try: - return {"draft": load_agent_draft(draft_id)} - except LookupError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except APIError as exc: - raise database_http_exception(exc) from exc - - -@router.get("/agent/drafts/{draft_id}/preview") -def preview_agent_draft(draft_id: int): - try: - draft = load_agent_draft(draft_id) - except LookupError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except APIError as exc: - raise database_http_exception(exc) from exc - html = templates.get_template("jinja_sample.html").render(course=draft["proposed_json"], curriculum_year=selected_curriculum_year(), asset_root="/") - return HTMLResponse(html, headers={"Cache-Control": "no-store"}) - - -@router.post("/agent/drafts/{draft_id}/apply") -def apply_agent_draft(draft_id: int): - try: - draft = load_agent_draft(draft_id) - except LookupError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except APIError as exc: - raise database_http_exception(exc) from exc - - summary = draft.get("diff_summary") or {} - if draft.get("status") != "proposed": - raise HTTPException(status_code=400, detail="Only proposed drafts can be applied") - if summary.get("protected_changes"): - raise HTTPException(status_code=400, detail="Draft changes deterministic fields") - - try: - supabase.table("course_revision_history").insert( - { - "refined_id": draft["refined_id"], - "agent_draft_id": draft_id, - "previous_json": draft["base_refined_json"], - "next_json": draft["proposed_json"], - "json_patch": draft["json_patch"], - "diff_summary": summary, - "change_reason": draft.get("change_reason") or "", - } - ).execute() - data = update_refined_fields(int(draft["refined_id"]), draft["proposed_json"]) - supabase.table("agent_drafts").update({"status": "applied"}).eq("id", draft_id).execute() - except APIError as exc: - raise database_http_exception(exc) from exc - return {"message": "Draft applied", "data": data} - - -@router.post("/agent/document-drafts") -def create_agent_document_draft(payload: AgentDocumentDraftPayload): - try: - records = [draft_record(course.refined_id, course.fields, payload.reason) for course in payload.courses] - except LookupError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except APIError as exc: - raise database_http_exception(exc) from exc - - summaries = [record["diff_summary"] for record in records] - document_summary = { - "courses_changed": len(records), - "courses_with_removed_topics": sum(1 for summary in summaries if summary.get("topics_removed")), - "courses_with_protected_changes": sum(1 for summary in summaries if summary.get("protected_changes")), - "max_syllabus_change_percent": max((summary.get("syllabus_change_percent") or 0 for summary in summaries), default=0), - } - status = "blocked" if document_summary["courses_with_protected_changes"] else "proposed" - - try: - document = ( - supabase.table("agent_document_drafts") - .insert( - { - "curriculum_version_id": payload.curriculum_version_id, - "uploaded_document_id": payload.uploaded_document_id.strip(), - "diff_summary": document_summary, - "change_reason": payload.reason.strip(), - "status": status, - } - ) - .execute() - .data[0] - ) - for record in records: - record["document_draft_id"] = document["id"] - drafts = supabase.table("agent_drafts").insert(records).execute().data - except APIError as exc: - raise database_http_exception(exc) from exc - return {"message": "Document draft created", "document_draft": document, "drafts": drafts} - - -@router.get("/agent/document-drafts") -def list_agent_document_drafts(): - try: - rows = supabase.table("agent_document_drafts").select("*").order("id", desc=True).limit(100).execute().data - except APIError as exc: - raise database_http_exception(exc) from exc - return { - "document_drafts": [ - { - "id": row["id"], - "status": row.get("status") or "", - "uploaded_document_id": row.get("uploaded_document_id") or "", - "change_reason": row.get("change_reason") or "", - "created_at": row.get("created_at") or "", - } - for row in rows - ] - } - - -@router.get("/agent/document-drafts/{document_draft_id}") -def get_agent_document_draft(document_draft_id: int): - try: - return load_document_draft(document_draft_id) - except LookupError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except APIError as exc: - raise database_http_exception(exc) from exc - - -@router.get("/agent/document-drafts/{document_draft_id}/preview") -def preview_agent_document_draft(document_draft_id: int): - try: - drafts = load_document_draft(document_draft_id)["drafts"] - except LookupError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except APIError as exc: - raise database_http_exception(exc) from exc - if not drafts: - raise HTTPException(status_code=404, detail="Document draft not found") - - courses = sorted( - (draft["proposed_json"] for draft in drafts), - key=lambda course: (int(course.get("semester") or 0), str(course.get("course_code") or ""), str(course.get("course_title") or "")), - ) - html = templates.get_template("jinja_sample.html").render( - courses=courses, - semester="", - curriculum_year=selected_curriculum_year(), - asset_root="/", - show_summaries=True, - ) - return HTMLResponse(html, headers={"Cache-Control": "no-store"}) - - -@router.get("/agent/tools") -def get_agent_tools(): - return {"tools": list_tool_schemas()} - - -@router.post("/agent/tools/{tool_name}") -def run_agent_tool(tool_name: str, payload: AgentToolPayload): - try: - return {"name": tool_name, "result": call_tool(tool_name, payload.arguments)} - except LookupError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - except APIError as exc: - raise database_http_exception(exc) from exc diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py deleted file mode 100644 index a5d7199da4d38bc61916bb6fd1cf1fa2ccfeff0c..0000000000000000000000000000000000000000 --- a/backend/app/routes/chat.py +++ /dev/null @@ -1,261 +0,0 @@ -import json -import logging - -import sentry_sdk -from fastapi import APIRouter, File, HTTPException, UploadFile -from fastapi.responses import StreamingResponse -from postgrest.exceptions import APIError - -from app.models.chat import ChatMessagePayload, ChatSessionPayload, ChatSessionTitlePayload -from app.services.agent_tools import call_tool, list_tool_schemas -from app.services.attachments import extract_text -from app.services.curriculum import load_document_draft, refined_course -from app.services.errors import database_http_exception -from app.services.openrouter import OpenRouterError, stream_chat -from app.supabase import first_row, supabase - -router = APIRouter() -MAX_ATTACHMENT_CONTEXT = 12000 -logger = logging.getLogger(__name__) - - -def load_chat_session(session_id: int) -> dict: - try: - row = first_row(supabase.table("chat_sessions").select("*").eq("id", session_id)) - except APIError as exc: - raise database_http_exception(exc) from exc - if not row: - raise HTTPException(status_code=404, detail="Chat session not found") - return row - - -def chat_messages(session_id: int) -> list[dict]: - rows = supabase.table("chat_messages").select("*").eq("session_id", session_id).order("id").execute().data - return rows[-24:] - - -def insert_chat_message(session_id: int, role: str, content: str, metadata: dict | None = None) -> dict: - return ( - supabase.table("chat_messages") - .insert({"session_id": session_id, "role": role, "content": content, "metadata": metadata or {}}) - .execute() - .data[0] - ) - - -def update_attachment_message(session_id: int, attachment_ids: list[int], message_id: int) -> None: - if attachment_ids: - supabase.table("chat_attachments").update({"message_id": message_id}).eq("session_id", session_id).in_("id", attachment_ids).execute() - - -def attachment_ids(metadata: dict | None) -> list[int]: - ids = [] - for item in (metadata or {}).get("attachments") or []: - if isinstance(item, dict) and item.get("id"): - ids.append(int(item["id"])) - return ids - - -def attachment_context(session_id: int, metadata: dict | None) -> str: - ids = attachment_ids(metadata) - if not ids: - return "" - rows = supabase.table("chat_attachments").select("filename,status,error,extracted_text").eq("session_id", session_id).in_("id", ids).execute().data - blocks = [] - for row in rows: - name = row.get("filename") or "attachment" - status = row.get("status") or "" - text = str(row.get("extracted_text") or "").strip() - if text: - blocks.append(f"Attachment: {name}\n{text[:MAX_ATTACHMENT_CONTEXT]}") - else: - error = row.get("error") or "No extracted text" - blocks.append(f"Attachment: {name}\nStatus: {status}. {error}") - return "\n\n".join(blocks) - - -def stable_context(value: dict) -> str: - return json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2)[:12000] - - -def chat_system_prompt(session: dict) -> str: - session_id = session.get("id") - context = "" - if session.get("refined_id"): - course = refined_course(int(session["refined_id"])) - context = stable_context({"active_session_id": session_id, "active_refined_id": session["refined_id"], "active_course": course}) - elif session.get("document_draft_id"): - context = stable_context({"active_session_id": session_id, **load_document_draft(int(session["document_draft_id"]))}) - else: - context = stable_context({"active_session_id": session_id}) - return f"""You are the PESU Curriculum Automation live editor assistant. -Be concise, practical, and specific to the active curriculum data. -Always respond by calling a tool — never state limitations or guess. The available tools handle course data, fetching URLs, generating reports, and creating drafts. -Read source documents with get_attachment_text, then call create_report to save generated content as a chat attachment. -When the user asks to change the active course, call create_course_draft with the active_refined_id, only the fields that should change, and a short reason. -When the user asks for changes across multiple courses or an uploaded document, inspect the curriculum or attachment text, then call create_document_draft with the affected courses. -When the user asks what changed, call diff_course_json or read the relevant draft before answering. -For broad document requests, use get_curriculum_json to inspect the whole syllabus before proposing edits. -To fetch a public URL, call fetch_url and use the returned text. -Never apply a draft, never claim a draft was applied, and never claim the refined database was changed. -After creating a draft, tell the user to review the diff in the Review panel before applying it. -After calling a tool, summarize the result for the user in natural language. Do not call another tool — stop and respond to the user. -Do not change deterministic fields such as program, hours, credits, or course type. - -Active context: -{context or "No active course or document draft is selected."}""" - - -def model_messages(session_id: int, rows: list[dict]) -> list[dict]: - messages = [] - for row in rows: - if row.get("role") not in {"user", "assistant"}: - continue - content = str(row.get("content") or "").strip() - context = attachment_context(session_id, row.get("metadata")) if row.get("role") == "user" else "" - if context: - content = f"{content}\n\n{context}".strip() - if content: - messages.append({"role": row["role"], "content": content}) - return messages - - -def sse(event: str, data: dict) -> str: - return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" - - -@router.post("/chat/sessions") -def create_chat_session(payload: ChatSessionPayload): - record = {"refined_id": payload.refined_id, "document_draft_id": payload.document_draft_id, "title": payload.title.strip()} - result = supabase.table("chat_sessions").insert(record).execute() - return {"session": result.data[0]} - - -@router.get("/chat/sessions") -def list_chat_sessions(refined_id: int | None = None, document_draft_id: int | None = None): - query = supabase.table("chat_sessions").select("*").eq("status", "active") - if refined_id is not None: - query = query.eq("refined_id", refined_id) - if document_draft_id is not None: - query = query.eq("document_draft_id", document_draft_id) - rows = query.order("id", desc=True).limit(50).execute().data - return {"sessions": rows} - - -@router.get("/chat/sessions/{session_id}/messages") -def get_chat_messages(session_id: int): - load_chat_session(session_id) - return {"messages": chat_messages(session_id)} - - -@router.delete("/chat/sessions/{session_id}") -def clear_chat_session(session_id: int): - load_chat_session(session_id) - supabase.table("chat_sessions").delete().eq("id", session_id).execute() - return {"message": "Chat deleted"} - - -@router.patch("/chat/sessions/{session_id}") -def rename_chat_session(session_id: int, payload: ChatSessionTitlePayload): - load_chat_session(session_id) - row = supabase.table("chat_sessions").update({"title": payload.title}).eq("id", session_id).execute().data[0] - return {"message": "Chat renamed", "session": row} - - -@router.post("/chat/sessions/{session_id}/messages") -def create_chat_message(session_id: int, payload: ChatMessagePayload): - if not payload.content and not payload.metadata: - raise HTTPException(status_code=400, detail="Message content is required") - session = load_chat_session(session_id) - user_message = insert_chat_message(session_id, "user", payload.content, payload.metadata) - update_attachment_message(session_id, attachment_ids(payload.metadata), user_message["id"]) - - def stream(): - answer = [] - tool_results = [] - - def remember_tool_result(name: str, result: dict) -> None: - tool_results.append({"name": name, "result": result}) - - def flush_tool_results(): - while tool_results: - item = tool_results.pop(0) - draft = (item["result"] or {}).get("draft") - document_draft = (item["result"] or {}).get("document_draft") - if item["name"] == "create_course_draft" and draft: - yield sse("draft", {"draft": draft}) - if item["name"] == "create_document_draft" and document_draft: - yield sse("document_draft", {"document_draft": document_draft}) - - try: - yield sse("status", {"message": "Message saved"}) - rows = chat_messages(session_id) - yield sse("status", {"message": "Loading context"}) - system = chat_system_prompt(session) - yield sse("status", {"message": "Streaming response"}) - for item in stream_chat(system, model_messages(session_id, rows), list_tool_schemas(), call_tool, remember_tool_result): - if isinstance(item, dict) and "$status" in item: - yield sse("status", {"message": item["$status"]}) - continue - yield from flush_tool_results() - answer.append(item) - yield sse("token", {"text": item}) - yield from flush_tool_results() - message = insert_chat_message(session_id, "assistant", "".join(answer).strip()) - yield sse("done", {"message_id": message["id"]}) - except OpenRouterError as exc: - yield from flush_tool_results() - logger.warning( - "Chat model request failed for session %s: status=%s detail=%s", - session_id, - exc.status_code, - exc.provider_message[:300], - ) - err = exc.message - if exc.provider_message: - err = f"{err} ({exc.provider_message[:200]})" - yield sse("error", {"message": err}) - except Exception as exc: - yield from flush_tool_results() - logger.exception("Chat stream failed for session %s", session_id) - sentry_sdk.capture_exception(exc) - yield sse("error", {"message": "An internal error occurred. Please try again later."}) - - return StreamingResponse(stream(), media_type="text/event-stream", headers={"Cache-Control": "no-store"}) - - -@router.post("/chat/sessions/{session_id}/attachments") -async def upload_chat_attachments(session_id: int, files: list[UploadFile] = File(...)): - load_chat_session(session_id) - attachments = [] - for file in files: - data = await file.read() - text, status, error = extract_text(file.filename or "attachment", file.content_type or "", data) - row = ( - supabase.table("chat_attachments") - .insert( - { - "session_id": session_id, - "filename": file.filename or "attachment", - "content_type": file.content_type or "", - "size_bytes": len(data), - "extracted_text": text, - "status": status, - "error": error, - } - ) - .execute() - .data[0] - ) - attachments.append( - { - "id": row["id"], - "name": row["filename"], - "type": row["content_type"], - "size": row["size_bytes"], - "status": row["status"], - "error": row["error"], - "extracted_chars": len(row.get("extracted_text") or ""), - } - ) - return {"attachments": attachments} diff --git a/backend/app/routes/courses.py b/backend/app/routes/courses.py deleted file mode 100644 index 3c8ee0d6c3e3df73fff94b15e3fe5c9fca475e5e..0000000000000000000000000000000000000000 --- a/backend/app/routes/courses.py +++ /dev/null @@ -1,33 +0,0 @@ -from fastapi import APIRouter, HTTPException -from postgrest.exceptions import APIError - -from app.preview import build_course_preview -from app.services.curriculum import attach_submissions -from app.services.errors import database_http_exception -from app.supabase import first_row, supabase - -router = APIRouter() - - -@router.get("/courses") -def list_courses(): - try: - rows = supabase.table("refined_submissions").select("*").neq("status", "archived").execute().data - rows = attach_submissions(rows) - except APIError as exc: - raise database_http_exception(exc) from exc - courses = [build_course_preview(row) | {"id": row["id"]} for row in rows] - courses.sort(key=lambda item: (int(item.get("semester") or 0), item.get("course_code") or "", item.get("course_title") or "")) - return {"courses": courses} - - -@router.delete("/courses/{refined_id}") -def delete_course(refined_id: int): - try: - row = first_row(supabase.table("refined_submissions").select("id").eq("id", refined_id).neq("status", "archived")) - if not row: - raise HTTPException(status_code=404, detail="Course not found") - result = supabase.table("refined_submissions").update({"status": "archived"}).eq("id", refined_id).execute() - except APIError as exc: - raise database_http_exception(exc) from exc - return {"message": "Course removed", "course": result.data[0] if result.data else None} diff --git a/backend/app/routes/health.py b/backend/app/routes/health.py deleted file mode 100644 index 1e49cd1ba1663ba5bb180d0f837948f1deb9e9d1..0000000000000000000000000000000000000000 --- a/backend/app/routes/health.py +++ /dev/null @@ -1,18 +0,0 @@ -from fastapi import APIRouter, HTTPException -from postgrest.exceptions import APIError - -from app.services.errors import database_http_exception -from app.services.schema import schema_status - -router = APIRouter() - - -@router.get("/health/schema") -def check_schema(): - try: - status = schema_status() - except APIError as exc: - raise database_http_exception(exc) from exc - if not status["ok"]: - raise HTTPException(status_code=503, detail=status) - return status diff --git a/backend/app/routes/preview.py b/backend/app/routes/preview.py deleted file mode 100644 index e450de25446fcedbf23af5fbb7034a86071517f1..0000000000000000000000000000000000000000 --- a/backend/app/routes/preview.py +++ /dev/null @@ -1,76 +0,0 @@ -from fastapi import APIRouter, HTTPException, Query -from fastapi.responses import HTMLResponse, Response -from weasyprint import HTML - -from app.preview import build_course_preview -from app.rendering import FRONTEND_DIR, templates -from app.services.curriculum import attach_submissions, ordered_courses, selected_curriculum_year -from app.supabase import first_row, supabase - -router = APIRouter() - - -@router.get("/preview/semester/{sem}/courses") -def list_courses(sem: int): - result = supabase.table("refined_submissions").select("id").neq("status", "archived").eq("semester", sem).order("id").execute() - return {"course_ids": [row["id"] for row in result.data]} - - -@router.get("/preview/courses") -def list_all_courses(): - result = supabase.table("refined_submissions").select("id,semester,course_code").neq("status", "archived").execute() - rows = sorted(result.data, key=lambda row: (int(row.get("semester") or 0), str(row.get("course_code") or ""), int(row.get("id") or 0))) - return {"course_ids": [row["id"] for row in rows]} - - -@router.get("/preview/course/{refined_id}") -def preview_course(refined_id: int, curriculum_year: str = Query("")): - row = first_row(supabase.table("refined_submissions").select("*").eq("id", refined_id)) - if not row: - raise HTTPException(status_code=404, detail="Refined submission not found") - row = attach_submissions([row])[0] - html = templates.get_template("jinja_sample.html").render( - course=build_course_preview(row), - curriculum_year=selected_curriculum_year(curriculum_year), - asset_root="/", - ) - return HTMLResponse(html, headers={"Cache-Control": "no-store"}) - - -@router.get("/preview/pdf") -def download_all_pdf(download: bool = Query(False), curriculum_year: str = Query("")): - result = supabase.table("refined_submissions").select("*").neq("status", "archived").execute() - courses = ordered_courses(result.data) - html = templates.get_template("jinja_sample.html").render( - courses=courses, - semester="", - curriculum_year=selected_curriculum_year(curriculum_year), - asset_root="", - show_summaries=True, - ) - pdf = HTML(string=html, base_url=str(FRONTEND_DIR)).write_pdf() - return pdf_response(pdf, "curriculum-preview.pdf", download) - - -@router.get("/preview/semester/{sem}/pdf") -def download_pdf(sem: int, download: bool = Query(False), curriculum_year: str = Query("")): - result = supabase.table("refined_submissions").select("*").neq("status", "archived").eq("semester", sem).order("id").execute() - courses = ordered_courses(result.data) - html = templates.get_template("jinja_sample.html").render( - courses=courses, - semester=sem, - curriculum_year=selected_curriculum_year(curriculum_year), - asset_root="", - show_summaries=True, - ) - pdf = HTML(string=html, base_url=str(FRONTEND_DIR)).write_pdf() - return pdf_response(pdf, f"semester-{sem}.pdf", download) - - -def pdf_response(pdf: bytes, filename: str, download: bool) -> Response: - disposition = "attachment" if download else "inline" - return Response( - content=pdf, - media_type="application/pdf", - headers={"Content-Disposition": f'{disposition}; filename="{filename}"', "Cache-Control": "no-store"}, - ) diff --git a/backend/app/routes/refined.py b/backend/app/routes/refined.py deleted file mode 100644 index 0942f6d01d10de055c68b20d98322f8ea0ddcf3a..0000000000000000000000000000000000000000 --- a/backend/app/routes/refined.py +++ /dev/null @@ -1,24 +0,0 @@ -from fastapi import APIRouter, HTTPException - -from app.preview import build_course_preview -from app.services.curriculum import attach_submissions, update_refined_fields -from app.supabase import first_row, supabase - -router = APIRouter() - - -@router.get("/refined/{refined_id}") -def get_refined(refined_id: int): - row = first_row(supabase.table("refined_submissions").select("*").eq("id", refined_id)) - if not row: - raise HTTPException(status_code=404, detail="Refined submission not found") - row = attach_submissions([row])[0] - return {"id": refined_id, "fields": build_course_preview(row)} - - -@router.patch("/refined/{refined_id}") -def update_refined(refined_id: int, payload: dict): - fields = payload.get("fields") - if not isinstance(fields, dict): - raise HTTPException(status_code=400, detail="fields is required") - return {"message": "Updated", "data": update_refined_fields(refined_id, fields)} diff --git a/backend/app/routes/submissions.py b/backend/app/routes/submissions.py deleted file mode 100644 index 46f91c0d5a2686fc09293b750801dfd1b23438ec..0000000000000000000000000000000000000000 --- a/backend/app/routes/submissions.py +++ /dev/null @@ -1,41 +0,0 @@ -import sentry_sdk -from fastapi import APIRouter, BackgroundTasks, HTTPException -from postgrest.exceptions import APIError - -from app.models.submission import CourseSubmission -from app.services.errors import database_http_exception -from app.services.refinement import refine -from app.supabase import supabase - -router = APIRouter() - - -def refine_later(submission_id: int) -> None: - try: - refine(submission_id) - except Exception as exc: - sentry_sdk.capture_exception(exc) - supabase.table("submissions").update({"status": "refine_failed"}).eq("id", submission_id).execute() - - -@router.post("/submissions") -def receive(data: CourseSubmission, background_tasks: BackgroundTasks): - payload = data.model_dump() - payload["status"] = "pending" - try: - result = supabase.table("submissions").insert(payload).execute() - except APIError as exc: - raise database_http_exception(exc) from exc - submission = result.data[0] - background_tasks.add_task(refine_later, submission["id"]) - return {"message": "Submission Received!", "submission": submission} - - -@router.post("/submissions/{id}/refine") -def refine_submission(id: int): - try: - return {"message": "Refined", "data": refine(id)} - except LookupError as exc: - raise HTTPException(status_code=404, detail=str(exc)) from exc - except APIError as exc: - raise database_http_exception(exc) from exc diff --git a/backend/app/routes/versions.py b/backend/app/routes/versions.py deleted file mode 100644 index 12ff6193b5e8b625c9d6b4b6dce81b0d8e173e57..0000000000000000000000000000000000000000 --- a/backend/app/routes/versions.py +++ /dev/null @@ -1,198 +0,0 @@ -from fastapi import APIRouter, HTTPException -from fastapi.responses import HTMLResponse -from postgrest.exceptions import APIError - -from app.preview import build_course_preview -from app.rendering import templates -from app.services.curriculum import attach_submissions, selected_curriculum_year, update_refined_fields -from app.services.diffing import diff_course -from app.services.errors import database_http_exception -from app.supabase import first_row, supabase - -router = APIRouter() - - -def _version(version_id: int) -> dict: - row = first_row(supabase.table("curriculum_versions").select("*").eq("id", version_id)) - if not row: - raise HTTPException(status_code=404, detail="Version not found") - return row - - -def _snapshot(version_id: int, refined_id: int) -> dict: - row = first_row( - supabase.table("finalized_submissions") - .select("*") - .eq("curriculum_version_id", version_id) - .eq("refined_id", refined_id) - ) - if not row: - raise HTTPException(status_code=404, detail="Version course not found") - return row - - -def _course_summary(row: dict) -> dict: - course = row.get("course_json") or {} - return { - "id": row["id"], - "refined_id": row["refined_id"], - "semester": course.get("semester") or "", - "course_code": course.get("course_code") or "", - "course_title": course.get("course_title") or "", - } - - -@router.get("/versions") -def list_versions(): - try: - rows = supabase.table("curriculum_versions").select("*").order("id", desc=True).execute().data - except APIError as exc: - raise database_http_exception(exc) from exc - return {"versions": rows} - - -@router.post("/versions") -def create_version(payload: dict): - name = str(payload.get("name") or "").strip() - if not name: - raise HTTPException(status_code=400, detail="Version name is required") - - try: - rows = supabase.table("refined_submissions").select("*").execute().data - rows = attach_submissions(rows) - courses = [{"refined_id": row["id"], "course_json": build_course_preview(row)} for row in rows] - version = ( - supabase.table("curriculum_versions") - .insert( - { - "name": name, - "program": str(payload.get("program") or (courses[0]["course_json"].get("program") if courses else "") or "").strip(), - "academic_year": selected_curriculum_year(payload.get("academic_year")), - "status": str(payload.get("status") or "draft").strip(), - } - ) - .execute() - .data[0] - ) - if courses: - records = [{**course, "curriculum_version_id": version["id"]} for course in courses] - supabase.table("finalized_submissions").insert(records).execute() - except APIError as exc: - raise database_http_exception(exc) from exc - return {"version": version, "courses": len(courses)} - - -@router.post("/versions/{version_id}/restore") -def restore_version(version_id: int): - try: - version = _version(version_id) - rows = ( - supabase.table("finalized_submissions") - .select("*") - .eq("curriculum_version_id", version_id) - .execute() - .data - ) - version_refined_ids = [row["refined_id"] for row in rows] - current_rows = supabase.table("refined_submissions").select("*").execute().data - current = {row["id"]: build_course_preview(row) for row in attach_submissions(current_rows)} - current_status = {row["id"]: row.get("status") for row in current_rows} - extra_ids = [row["id"] for row in current_rows if row["id"] not in version_refined_ids] - history = [] - restored = 0 - for row in rows: - refined_id = int(row["refined_id"]) - previous = current.get(refined_id) - restored_course = row["course_json"] - if not previous: - continue - if previous != restored_course: - summary = diff_course(previous, restored_course) - history.append( - { - "refined_id": refined_id, - "source": "version_restore", - "previous_json": previous, - "next_json": restored_course, - "json_patch": summary.pop("json_patch"), - "diff_summary": summary, - "change_reason": f"Restore version: {version['name']}", - } - ) - if previous != restored_course or current_status.get(refined_id) != "refined": - update_refined_fields(refined_id, {**restored_course, "status": "refined"}) - restored += 1 - if history: - supabase.table("course_revision_history").insert(history).execute() - if extra_ids: - supabase.table("refined_submissions").update({"status": "archived"}).in_("id", extra_ids).execute() - except APIError as exc: - raise database_http_exception(exc) from exc - return {"message": "Version restored", "version": version, "courses_restored": restored, "courses_archived": len(extra_ids)} - - -@router.get("/versions/{version_id}") -def get_version(version_id: int): - try: - version = _version(version_id) - rows = ( - supabase.table("finalized_submissions") - .select("*") - .eq("curriculum_version_id", version_id) - .execute() - .data - ) - except APIError as exc: - raise database_http_exception(exc) from exc - courses = [_course_summary(row) for row in rows] - courses.sort(key=lambda course: (int(course.get("semester") or 0), str(course.get("course_code") or ""), str(course.get("course_title") or ""))) - return {"version": version, "courses": courses} - - -@router.get("/versions/{version_id}/courses/{refined_id}") -def get_version_course(version_id: int, refined_id: int): - try: - version = _version(version_id) - snapshot = _snapshot(version_id, refined_id) - except APIError as exc: - raise database_http_exception(exc) from exc - return {"version": version, "refined_id": refined_id, "fields": snapshot["course_json"]} - - -@router.get("/versions/{version_id}/courses/{refined_id}/preview") -def preview_version_course(version_id: int, refined_id: int): - try: - snapshot = _snapshot(version_id, refined_id) - except APIError as exc: - raise database_http_exception(exc) from exc - version = _version(version_id) - html = templates.get_template("jinja_sample.html").render(course=snapshot["course_json"], curriculum_year=selected_curriculum_year(version.get("academic_year")), asset_root="/") - return HTMLResponse(html, headers={"Cache-Control": "no-store"}) - - -@router.get("/versions/{version_id}/preview") -def preview_version(version_id: int): - try: - version = _version(version_id) - rows = ( - supabase.table("finalized_submissions") - .select("*") - .eq("curriculum_version_id", version_id) - .order("refined_id") - .execute() - .data - ) - except APIError as exc: - raise database_http_exception(exc) from exc - courses = sorted( - (row["course_json"] for row in rows), - key=lambda course: (int(course.get("semester") or 0), str(course.get("course_code") or ""), str(course.get("course_title") or "")), - ) - html = templates.get_template("jinja_sample.html").render( - courses=courses, - semester="", - curriculum_year=selected_curriculum_year(version.get("academic_year")), - asset_root="/", - show_summaries=True, - ) - return HTMLResponse(html, headers={"Cache-Control": "no-store"}) diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py deleted file mode 100644 index 6bc40b77fa903fd8eb961647f5a555993b567e46..0000000000000000000000000000000000000000 --- a/backend/app/services/agent_tools.py +++ /dev/null @@ -1,319 +0,0 @@ -from collections.abc import Callable -from dataclasses import dataclass - -import httpx - -from app.services.curriculum import draft_record, load_agent_draft, load_document_draft, ordered_courses, refined_course -from app.services.diffing import diff_course -from app.supabase import supabase - -ToolHandler = Callable[[dict], dict] - - -@dataclass(frozen=True) -class AgentTool: - name: str - description: str - parameters: dict - handler: ToolHandler - - def schema(self) -> dict: - return { - "type": "function", - "function": { - "name": self.name, - "description": self.description, - "parameters": self.parameters, - }, - } - - -def list_tool_schemas() -> list[dict]: - return [tool.schema() for tool in TOOLS.values()] - - -def call_tool(name: str, arguments: dict | None = None) -> dict: - tool = TOOLS.get(name) - if not tool: - raise LookupError("Agent tool not found") - return tool.handler(arguments or {}) - - -def _require_int(arguments: dict, key: str) -> int: - value = arguments.get(key) - if value is None: - raise ValueError(f"{key} is required") - return int(value) - - -def _require_dict(arguments: dict, key: str) -> dict: - value = arguments.get(key) - if not isinstance(value, dict): - raise ValueError(f"{key} must be an object") - return value - - -def _get_current_course(arguments: dict) -> dict: - return {"course": refined_course(_require_int(arguments, "refined_id"))} - - -def _diff_course_json(arguments: dict) -> dict: - return diff_course(_require_dict(arguments, "current"), _require_dict(arguments, "proposed")) - - -def _create_course_draft(arguments: dict) -> dict: - record = draft_record( - _require_int(arguments, "refined_id"), - _require_dict(arguments, "fields"), - str(arguments.get("reason") or ""), - ) - draft = supabase.table("agent_drafts").insert(record).execute().data[0] - return {"draft": draft} - - -def _get_curriculum_json(arguments: dict) -> dict: - query = supabase.table("refined_submissions").select("*").neq("status", "archived") - if arguments.get("semester") is not None: - query = query.eq("semester", int(arguments["semester"])) - return {"courses": ordered_courses(query.execute().data)} - - -def _create_document_draft(arguments: dict) -> dict: - courses = arguments.get("courses") - if not isinstance(courses, list) or not courses: - raise ValueError("courses must be a non-empty array") - - records = [] - for course in courses: - if not isinstance(course, dict): - raise ValueError("each course must be an object") - records.append(draft_record(int(course.get("refined_id")), _require_dict(course, "fields"), str(arguments.get("reason") or ""))) - - summaries = [record["diff_summary"] for record in records] - document_summary = { - "courses_changed": len(records), - "courses_with_removed_topics": sum(1 for summary in summaries if summary.get("topics_removed")), - "courses_with_protected_changes": sum(1 for summary in summaries if summary.get("protected_changes")), - "max_syllabus_change_percent": max((summary.get("syllabus_change_percent") or 0 for summary in summaries), default=0), - } - document = ( - supabase.table("agent_document_drafts") - .insert( - { - "curriculum_version_id": arguments.get("curriculum_version_id"), - "uploaded_document_id": str(arguments.get("uploaded_document_id") or "").strip(), - "diff_summary": document_summary, - "change_reason": str(arguments.get("reason") or "").strip(), - "status": "blocked" if document_summary["courses_with_protected_changes"] else "proposed", - } - ) - .execute() - .data[0] - ) - for record in records: - record["document_draft_id"] = document["id"] - drafts = supabase.table("agent_drafts").insert(records).execute().data - return {"document_draft": document, "drafts": drafts} - - -def _get_course_draft(arguments: dict) -> dict: - return {"draft": load_agent_draft(_require_int(arguments, "draft_id"))} - - -def _get_document_draft(arguments: dict) -> dict: - return load_document_draft(_require_int(arguments, "document_draft_id")) - - -def _get_preview_url(arguments: dict) -> dict: - kind = str(arguments.get("kind") or "") - item_id = _require_int(arguments, "id") - paths = { - "course": f"/api/preview/course/{item_id}", - "draft": f"/api/agent/drafts/{item_id}/preview", - "document_draft": f"/api/agent/document-drafts/{item_id}/preview", - } - if kind not in paths: - raise ValueError("kind must be course, draft, or document_draft") - return {"url": paths[kind]} - - -def _list_courses(arguments: dict) -> dict: - query = supabase.table("refined_submissions").select("id,semester,course_code,course_title").neq("status", "archived") - if arguments.get("semester") is not None: - query = query.eq("semester", int(arguments["semester"])) - rows = query.execute().data - rows.sort(key=lambda row: (int(row.get("semester") or 0), str(row.get("course_code") or ""), int(row.get("id") or 0))) - return {"courses": rows} - - -def _fetch_url(arguments: dict) -> dict: - url = str(arguments.get("url") or "").strip() - if not url: - raise ValueError("url is required") - resp = httpx.get(url, timeout=30, follow_redirects=True) - resp.raise_for_status() - text = resp.text[:15000] - return {"url": url, "text": text, "chars": len(text)} - - -def _create_report(arguments: dict) -> dict: - session_id = _require_int(arguments, "session_id") - content = str(arguments.get("content") or "").strip() - if not content: - raise ValueError("content is required") - filename = str(arguments.get("filename") or "report.md").strip() - row = ( - supabase.table("chat_attachments") - .insert({ - "session_id": session_id, - "filename": filename, - "content_type": "text/markdown", - "size_bytes": len(content.encode()), - "extracted_text": content, - "status": "ready", - }) - .execute() - .data[0] - ) - return {"attachment": {"id": row["id"], "filename": row["filename"], "chars": len(content)}} - - -def _attachment_text(arguments: dict) -> dict: - session_id = _require_int(arguments, "session_id") - ids = [int(value) for value in arguments.get("attachment_ids") or []] - if not ids: - raise ValueError("attachment_ids is required") - rows = ( - supabase.table("chat_attachments") - .select("id,filename,status,error,extracted_text") - .eq("session_id", session_id) - .in_("id", ids) - .execute() - .data - ) - return {"attachments": rows} - - -OBJECT = {"type": "object", "additionalProperties": False} - -TOOLS: dict[str, AgentTool] = { - "get_current_course_json": AgentTool( - "get_current_course_json", - "Read the current template-ready JSON for one refined course.", - {**OBJECT, "properties": {"refined_id": {"type": "integer"}}, "required": ["refined_id"]}, - _get_current_course, - ), - "diff_course_json": AgentTool( - "diff_course_json", - "Compare two course JSON objects and return patch operations, changed percent, and syllabus topic changes.", - {**OBJECT, "properties": {"current": {"type": "object"}, "proposed": {"type": "object"}}, "required": ["current", "proposed"]}, - _diff_course_json, - ), - "create_course_draft": AgentTool( - "create_course_draft", - "Create a human-reviewable draft for one course. This never applies changes to refined_submissions.", - { - **OBJECT, - "properties": { - "refined_id": {"type": "integer"}, - "fields": {"type": "object"}, - "reason": {"type": "string"}, - }, - "required": ["refined_id", "fields"], - }, - _create_course_draft, - ), - "get_curriculum_json": AgentTool( - "get_curriculum_json", - "Read template-ready JSON for the full curriculum, optionally filtered by semester.", - {**OBJECT, "properties": {"semester": {"type": "integer", "minimum": 1, "maximum": 8}}}, - _get_curriculum_json, - ), - "create_document_draft": AgentTool( - "create_document_draft", - "Create one human-reviewable document draft containing proposed changes for multiple courses. This never applies changes.", - { - **OBJECT, - "properties": { - "courses": { - "type": "array", - "items": { - "type": "object", - "properties": {"refined_id": {"type": "integer"}, "fields": {"type": "object"}}, - "required": ["refined_id", "fields"], - "additionalProperties": False, - }, - }, - "reason": {"type": "string"}, - "uploaded_document_id": {"type": "string"}, - "curriculum_version_id": {"type": "integer"}, - }, - "required": ["courses"], - }, - _create_document_draft, - ), - "get_course_draft": AgentTool( - "get_course_draft", - "Read one staged course draft and its diff summary.", - {**OBJECT, "properties": {"draft_id": {"type": "integer"}}, "required": ["draft_id"]}, - _get_course_draft, - ), - "get_document_draft": AgentTool( - "get_document_draft", - "Read one staged document draft and all linked course drafts.", - {**OBJECT, "properties": {"document_draft_id": {"type": "integer"}}, "required": ["document_draft_id"]}, - _get_document_draft, - ), - "get_preview_url": AgentTool( - "get_preview_url", - "Return the preview URL for a course, course draft, or document draft.", - { - **OBJECT, - "properties": { - "kind": {"type": "string", "enum": ["course", "draft", "document_draft"]}, - "id": {"type": "integer"}, - }, - "required": ["kind", "id"], - }, - _get_preview_url, - ), - "list_courses": AgentTool( - "list_courses", - "List refined course IDs and titles, optionally filtered by semester.", - {**OBJECT, "properties": {"semester": {"type": "integer", "minimum": 1, "maximum": 8}}}, - _list_courses, - ), - "get_attachment_text": AgentTool( - "get_attachment_text", - "Read extracted text for uploaded chat attachments within a chat session.", - { - **OBJECT, - "properties": { - "session_id": {"type": "integer"}, - "attachment_ids": {"type": "array", "items": {"type": "integer"}}, - }, - "required": ["session_id", "attachment_ids"], - }, - _attachment_text, - ), - "fetch_url": AgentTool( - "fetch_url", - "Fetch a public URL and return its text content. Use to read web pages, public documents, and online resources.", - {**OBJECT, "properties": {"url": {"type": "string"}}, "required": ["url"]}, - _fetch_url, - ), - "create_report": AgentTool( - "create_report", - "Save a generated document (report, comparison, summary, etc.) as a chat attachment accessible to the user. Use after reading source documents and generating new content.", - { - **OBJECT, - "properties": { - "session_id": {"type": "integer"}, - "content": {"type": "string", "description": "Full report/document content in markdown format"}, - "filename": {"type": "string", "description": "Filename including extension, e.g. comparison-report.md"}, - }, - "required": ["session_id", "content"], - }, - _create_report, - ), -} diff --git a/backend/app/services/attachments.py b/backend/app/services/attachments.py deleted file mode 100644 index ca5e6d7355356cbd309ccfa0fa66153db5cf06a5..0000000000000000000000000000000000000000 --- a/backend/app/services/attachments.py +++ /dev/null @@ -1,91 +0,0 @@ -import re -import subprocess -import tempfile -import zipfile -from pathlib import Path -from xml.etree import ElementTree - -MAX_FILE_BYTES = 8 * 1024 * 1024 -MAX_TEXT_CHARS = 50000 -SPACE = re.compile(r"[ \t]+") -BLANKS = re.compile(r"\n{3,}") - - -def extract_text(filename: str, content_type: str, data: bytes) -> tuple[str, str, str]: - if len(data) > MAX_FILE_BYTES: - return "", "failed", "File is larger than 8 MB" - suffix = Path(filename or "").suffix.lower() - try: - if suffix == ".pdf" or content_type == "application/pdf": - return _clean(_pdf_text(data)), "ready", "" - if suffix == ".docx": - return _clean(_docx_text(data)), "ready", "" - if suffix == ".xlsx": - return _clean(_xlsx_text(data)), "ready", "" - if suffix in {".txt", ".md", ".csv"} or content_type.startswith("text/"): - return _clean(_decode(data)), "ready", "" - if content_type.startswith("image/"): - return "", "unsupported", "Image OCR is not enabled yet" - return "", "unsupported", "File type is not supported yet" - except Exception as exc: - return "", "failed", str(exc) - - -def _pdf_text(data: bytes) -> str: - with tempfile.TemporaryDirectory() as temp_dir: - path = Path(temp_dir) / "upload.pdf" - path.write_bytes(data) - result = subprocess.run( - ["pdftotext", "-layout", str(path), "-"], - check=True, - capture_output=True, - text=True, - timeout=30, - ) - return result.stdout - - -def _docx_text(data: bytes) -> str: - with tempfile.TemporaryDirectory() as temp_dir: - path = Path(temp_dir) / "upload.docx" - path.write_bytes(data) - with zipfile.ZipFile(path) as docx: - xml = docx.read("word/document.xml") - root = ElementTree.fromstring(xml) - ns = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" - lines = [] - for paragraph in root.iter(f"{ns}p"): - text = "".join(node.text or "" for node in paragraph.iter(f"{ns}t")).strip() - if text: - lines.append(text) - return "\n".join(lines) - - -def _xlsx_text(data: bytes) -> str: - import openpyxl - with tempfile.TemporaryDirectory() as temp_dir: - path = Path(temp_dir) / "upload.xlsx" - path.write_bytes(data) - wb = openpyxl.load_workbook(path, read_only=True, data_only=True) - rows = [] - for sheet in wb: - for row in sheet.iter_rows(values_only=True): - line = "\t".join(str(c or "") for c in row) - if line.strip(): - rows.append(line) - return "\n".join(rows) - - -def _decode(data: bytes) -> str: - for encoding in ("utf-8", "utf-16", "latin-1"): - try: - return data.decode(encoding) - except UnicodeDecodeError: - continue - return data.decode("utf-8", errors="ignore") - - -def _clean(text: str) -> str: - text = SPACE.sub(" ", text.replace("\r\n", "\n").replace("\r", "\n")) - text = BLANKS.sub("\n\n", text).strip() - return text[:MAX_TEXT_CHARS] diff --git a/backend/app/services/books.py b/backend/app/services/books.py deleted file mode 100644 index 3e92d310172500b5d546a2d2b299bb3dfe5451aa..0000000000000000000000000000000000000000 --- a/backend/app/services/books.py +++ /dev/null @@ -1,105 +0,0 @@ -import re - -WORD = re.compile(r"\w+") -BOOK_STOP = re.compile( - r"\bCourse\b[\s\S]{0,160}\bOutcome\b|" - r"\b(Course\s+Objectives?|Assignment\s*/|Laboratory|Recommended\s+Materials)\b", - re.IGNORECASE, -) -BOOK_LABEL = re.compile( - r"(?im)^\s*(?:Text\s*)?Book\(s\):\s*|" - r"^\s*Text\s+Book\(s\):\s*|" - r"^\s*Reference\s*(?:Book\(s\):)?\s*" -) -BOOK_NUMBER = re.compile(r"(?:^|\n|\s{2,}|(?<=\.)\s+)\d+\s*[:.)]\s*") -ORDINAL_LINE = re.compile(r"(?im)^\s*(st|nd|rd|th)\s*$") -PAGE_NOISE = re.compile( - r"P\.?\s*E\.?\s*S\.?\s*University|" - r"Curriculum|" - r"\s*:-\s*[A-Za-z]*\s*\d{4}\s*-\s*\d{4}\b|" - r"\b\d+\s*\|\s*Page\b", - re.IGNORECASE, -) -TEXT_BOOK_LINE = re.compile(r"\bText\s+Book\(s\):", re.IGNORECASE) -REFERENCE_LINE = re.compile(r"^\s*Reference\b", re.IGNORECASE) -SECTION_END_LINE = re.compile(r"^\s*(Course\s+Outcome|Assignment\s*/|Course\s+Objectives?|Laboratory)\b", re.IGNORECASE) - - -def _words(text: str) -> int: - return len(WORD.findall(text)) - - -def _flatten(values) -> str: - parts = [] - for value in values: - if not value: - continue - if isinstance(value, list): - parts.extend(str(item) for item in value if str(item).strip()) - else: - parts.append(str(value)) - text = "\n".join(parts) - match = BOOK_STOP.search(text) - if match: - text = text[: match.start()] - text = ORDINAL_LINE.sub("", text) - text = PAGE_NOISE.sub("\n", text) - return BOOK_LABEL.sub("", text) - - -def _clean(value: str) -> str: - value = BOOK_LABEL.sub("", value) - value = re.sub(r"\bBook\(s\):?\s*", "", value, flags=re.IGNORECASE) - value = re.sub(r"-\s+", "-", value) - return re.sub(r"\s+", " ", value).strip(" :") - - -def parse_books(*values) -> list[str]: - text = _flatten(values) - if not text.strip(): - return [] - - numbered = [part for part in BOOK_NUMBER.split(text) if part.strip()] - if numbered and (len(numbered) > 1 or BOOK_NUMBER.search(text)): - parts = numbered - else: - parts = [line for line in text.splitlines() if line.strip()] - - books = [] - seen = set() - for part in parts: - item = _clean(part) - if not item: - continue - if books and (item.startswith("(") or (_words(item) <= 3 and not re.search(r"\b\d{4}\b", item))): - books[-1] = f"{books[-1]} {item}".strip() - continue - key = re.sub(r"[^a-z0-9]+", " ", item.lower()).strip() - if key not in seen: - books.append(item) - seen.add(key) - return books - - -def raw_book_section(raw_content: str, kind: str) -> str: - lines = (raw_content or "").splitlines() - start = None - for index, line in enumerate(lines): - if kind == "text" and TEXT_BOOK_LINE.search(line): - start = index - break - if kind == "reference" and REFERENCE_LINE.search(line): - start = index - break - if start is None: - return "" - - end = len(lines) - for index in range(start + 1, len(lines)): - if kind == "text" and REFERENCE_LINE.search(lines[index]): - end = index - break - if SECTION_END_LINE.search(lines[index]): - end = index - break - return "\n".join(lines[start:end]) diff --git a/backend/app/services/curriculum.py b/backend/app/services/curriculum.py deleted file mode 100644 index c57f2b453998d7d0837f3582f59e676e2b510e5b..0000000000000000000000000000000000000000 --- a/backend/app/services/curriculum.py +++ /dev/null @@ -1,154 +0,0 @@ -from os import environ - -from app.preview import build_course_preview -from app.services.diffing import diff_course, merge_fields, validate_draft -from app.supabase import first_row, supabase - -DEFAULT_CURRICULUM_YEAR = environ.get("CURRICULUM_YEAR", "").strip() -SOURCE_ORDER = { - "UE25CS151A": 1, - "UE25CS151B": 1, - "UE24CS251A": 1, - "UE24CS252A": 2, - "UE24MA242A": 3, - "UE24CS241A": 4, - "UE24CS243A": 5, - "UZ24UZ221A": 6, - "UE25MA201A*": 7, - "UE24CS251B": 1, - "UE24CS252B": 2, - "UE24CS241B": 3, - "UE24CS242B": 4, - "UE24MA241B": 5, - "UZ24UZ221B": 6, - "UE25MA201B*": 7, - "UE23CS351A": 1, - "UE23CS352A": 2, - "UE23CS341A": 3, - "UE23CS342AAX": 4, - "UE23CS343ABX": 5, - "UE23CS320A": 6, - "UE23CS351B": 1, - "UE23CS352B": 2, - "UE23CS341B": 3, - "UE23CS342BAX": 4, - "UE23CS343BBX": 5, - "UE23CS320B": 6, - "UE22CS441A": 1, - "UZ22UZ422A": 2, - "UE22AM421AXX": 3, - "UE22CS421B": 1, - "UE22CS461XB": 2, -} - -REFINED_FIELDS = { - "semester", - "course_code", - "course_title", - "program", - "lecture_hours", - "tutorial_hours", - "practical_hours", - "self_study", - "credits", - "course_type", - "tools_languages", - "desirable_knowledge", - "prelude", - "objectives", - "course_outcomes", - "units", - "lab_experiments", - "text_books", - "reference_books", - "status", -} - - -def attach_submissions(rows: list[dict]) -> list[dict]: - ids = [row["submission_id"] for row in rows if row.get("submission_id")] - if not ids: - return rows - submissions = supabase.table("submissions").select("*").in_("id", ids).execute().data - by_id = {row["id"]: row for row in submissions} - for row in rows: - row["_submission"] = by_id.get(row.get("submission_id"), {}) - return rows - - -def ordered_courses(rows: list[dict]) -> list[dict]: - rows = attach_submissions(rows) - rows.sort(key=course_sort_key) - return [build_course_preview(row) for row in rows] - - -def course_sort_key(row: dict) -> tuple[int, int, int]: - semester = int(row.get("semester") or 0) - code = str(row.get("course_code") or "").replace(" ", "").upper() - order = SOURCE_ORDER.get(code) - if order is None and semester == 5: - order = elective_order(code, "AA", "AB") - if order is None and semester == 6: - order = elective_order(code, "BA", "BB") - return semester, order or 900, int(row.get("id") or 0) - - -def elective_order(code: str, first_group: str, second_group: str) -> int | None: - for offset, group in ((100, first_group), (200, second_group)): - if group in code: - suffix = code.rsplit(group, 1)[-1].rstrip("X") - return offset + int(suffix) if suffix.isdigit() else offset - return None - - -def selected_curriculum_year(value: str | None = None) -> str: - return str(value or "").strip() or DEFAULT_CURRICULUM_YEAR - - -def refined_course(refined_id: int) -> dict: - row = first_row(supabase.table("refined_submissions").select("*").eq("id", refined_id)) - if not row: - raise LookupError("Refined submission not found") - return build_course_preview(attach_submissions([row])[0]) - - -def update_refined_fields(refined_id: int, fields: dict) -> dict | None: - update = {key: fields[key] for key in REFINED_FIELDS if key in fields} - for key in ("semester", "lecture_hours", "tutorial_hours", "practical_hours", "self_study", "credits"): - if key in update: - update[key] = int(update[key] or 0) - result = supabase.table("refined_submissions").update(update).eq("id", refined_id).execute() - return result.data[0] if result.data else None - - -def draft_record(refined_id: int, fields: dict, reason: str = "", document_draft_id: int | None = None) -> dict: - base = refined_course(refined_id) - proposed = merge_fields(base, fields) - summary = diff_course(base, proposed) - issues = validate_draft(base, proposed) - summary["validation_issues"] = issues - return { - "refined_id": refined_id, - "document_draft_id": document_draft_id, - "base_refined_json": base, - "proposed_json": proposed, - "json_patch": summary.pop("json_patch"), - "diff_summary": summary, - "change_reason": reason.strip(), - "status": "blocked" if issues else "proposed", - } - - -def load_agent_draft(draft_id: int) -> dict: - draft = first_row(supabase.table("agent_drafts").select("*").eq("id", draft_id)) - if not draft: - raise LookupError("Agent draft not found") - return draft - - -def load_document_draft(document_draft_id: int) -> dict: - document = first_row(supabase.table("agent_document_drafts").select("*").eq("id", document_draft_id)) - if not document: - raise LookupError("Document draft not found") - drafts = supabase.table("agent_drafts").select("*").eq("document_draft_id", document_draft_id).order("id").execute().data - return {"document_draft": document, "drafts": drafts} diff --git a/backend/app/services/deterministic.py b/backend/app/services/deterministic.py deleted file mode 100644 index e2e15045a33e9e21d74a792f4c1b53e64a534c2a..0000000000000000000000000000000000000000 --- a/backend/app/services/deterministic.py +++ /dev/null @@ -1,27 +0,0 @@ -def compute_hours(cat: str) -> dict: - return { - "5": {"lecture_hours": 4, "tutorial_hours": 0, "practical_hours": 2, "self_study": 5, "credits": 5}, - "4": {"lecture_hours": 4, "tutorial_hours": 0, "practical_hours": 0, "self_study": 4, "credits": 4}, - "2": {"lecture_hours": 2, "tutorial_hours": 0, "practical_hours": 0, "self_study": 2, "credits": 2}, - "0": {"lecture_hours": 0, "tutorial_hours": 0, "practical_hours": 0, "self_study": 0, "credits": 0}, - }[cat] - - -def compute_program(dept: str) -> str: - return { - "CSE": "B. TECH", - "AIML": "B. TECH", - "ECE": "B. TECH", - "ME": "B. TECH", - "EEE": "B. TECH", - "BT": "B. TECH", - }[dept] - - -def compute_course_type(cat: str) -> str: - return { - "0": "Foundation Course", - "5": "Core Course-Lab Integrated", - "4": "Core Course", - "2": "Core Theory", - }[cat] diff --git a/backend/app/services/diffing.py b/backend/app/services/diffing.py deleted file mode 100644 index 2d3ac75dd3bf57419cb180003ff60dd41330cddf..0000000000000000000000000000000000000000 --- a/backend/app/services/diffing.py +++ /dev/null @@ -1,160 +0,0 @@ -import difflib -import json -import re -from copy import deepcopy -from typing import Any - -PROTECTED_FIELDS = { - "program", - "lecture_hours", - "tutorial_hours", - "practical_hours", - "self_study", - "credits", - "course_type", -} - -TOPIC_SPLIT = re.compile(r"[,;\n]+") -SPACE = re.compile(r"\s+") - - -def stable_json(value: Any) -> str: - return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) - - -def build_patch(old: Any, new: Any, path: str = "") -> list[dict]: - if isinstance(old, dict) and isinstance(new, dict): - patch = [] - old_keys = set(old) - new_keys = set(new) - for key in sorted(old_keys - new_keys): - patch.append({"op": "remove", "path": f"{path}/{_escape(key)}"}) - for key in sorted(new_keys - old_keys): - patch.append({"op": "add", "path": f"{path}/{_escape(key)}", "value": new[key]}) - for key in sorted(old_keys & new_keys): - patch.extend(build_patch(old[key], new[key], f"{path}/{_escape(key)}")) - return patch - - if old != new: - return [{"op": "replace", "path": path or "/", "value": new}] - return [] - - -def apply_patch(value: Any, patch: list[dict]) -> Any: - result = deepcopy(value) - for op in patch: - name = op.get("op") - path = op.get("path") - if name not in {"add", "remove", "replace"} or not isinstance(path, str): - raise ValueError("Unsupported patch operation") - parent, key = _resolve_parent(result, path) - if isinstance(parent, list): - index = int(key) - if name == "remove": - parent.pop(index) - elif name == "replace": - parent[index] = op.get("value") - else: - parent.insert(index, op.get("value")) - continue - if name == "remove": - parent.pop(key, None) - else: - parent[key] = op.get("value") - return result - - -def diff_course(old: dict, new: dict) -> dict: - old_text = stable_json(old) - new_text = stable_json(new) - patch = build_patch(old, new) - syllabus_old = _syllabus_text(old) - syllabus_new = _syllabus_text(new) - topics_old = _topics(syllabus_old) - topics_new = _topics(syllabus_new) - - return { - "json_patch": patch, - "patch_operations": len(patch), - "change_percent": _change_percent(old_text, new_text), - "syllabus_change_percent": _change_percent(syllabus_old, syllabus_new), - "topics_added": sorted(topics_new - topics_old), - "topics_removed": sorted(topics_old - topics_new), - "protected_changes": sorted(field for field in PROTECTED_FIELDS if _field_value(old, field) != _field_value(new, field)), - "unified_diff": "\n".join( - difflib.unified_diff( - old_text.splitlines(), - new_text.splitlines(), - fromfile="current", - tofile="proposed", - lineterm="", - ) - ), - } - - -def validate_draft(old: dict, new: dict) -> list[str]: - issues = [] - for field in PROTECTED_FIELDS: - if _field_value(old, field) != _field_value(new, field): - issues.append(f"{field} is deterministic and cannot be changed by an agent draft") - return issues - - -def merge_fields(base: dict, fields: dict) -> dict: - merged = deepcopy(base) - for key, value in fields.items(): - if key in PROTECTED_FIELDS and str(base.get(key) or "") == str(value or ""): - value = base.get(key) - merged[key] = value - return merged - - -def _field_value(value: dict, field: str) -> str: - return str(value.get(field) or "") - - -def _escape(value: str) -> str: - return value.replace("~", "~0").replace("/", "~1") - - -def _unescape(value: str) -> str: - return value.replace("~1", "/").replace("~0", "~") - - -def _resolve_parent(value: Any, path: str) -> tuple[Any, str]: - if path in {"", "/"}: - raise ValueError("Root patch operations are not supported") - parts = [_unescape(part) for part in path.strip("/").split("/")] - current = value - for part in parts[:-1]: - current = current[int(part)] if isinstance(current, list) else current[part] - return current, parts[-1] - - -def _change_percent(old: str, new: str) -> float: - if not old and not new: - return 0.0 - ratio = difflib.SequenceMatcher(None, old, new).ratio() - return round((1 - ratio) * 100, 2) - - -def _syllabus_text(value: dict) -> str: - units = value.get("units") or [] - if not isinstance(units, list): - return "" - parts = [] - for unit in units: - if isinstance(unit, dict): - parts.append(str(unit.get("title") or "")) - parts.append(str(unit.get("content") or "")) - return SPACE.sub(" ", " ".join(parts)).strip() - - -def _topics(text: str) -> set[str]: - topics = set() - for item in TOPIC_SPLIT.split(text): - topic = SPACE.sub(" ", item).strip(" .:-").lower() - if len(topic) >= 4: - topics.add(topic) - return topics diff --git a/backend/app/services/errors.py b/backend/app/services/errors.py deleted file mode 100644 index bce35d152826aa032d2ee459fe8adca33cfefa5b..0000000000000000000000000000000000000000 --- a/backend/app/services/errors.py +++ /dev/null @@ -1,11 +0,0 @@ -import sentry_sdk -from fastapi import HTTPException -from postgrest.exceptions import APIError - - -def database_http_exception(exc: APIError) -> HTTPException: - message = str(exc) - if "schema cache" in message and "Could not find the table" in message: - return HTTPException(status_code=503, detail="Required database tables are missing. Run docs/schema.sql in Supabase.") - sentry_sdk.capture_exception(exc) - return HTTPException(status_code=500, detail="Database request failed.") diff --git a/backend/app/services/openrouter.py b/backend/app/services/openrouter.py deleted file mode 100644 index a995c9e0679255352b76d46420a347b980387a5a..0000000000000000000000000000000000000000 --- a/backend/app/services/openrouter.py +++ /dev/null @@ -1,221 +0,0 @@ -import json -import logging -import os - -from dotenv import load_dotenv -from httpx import Client, HTTPError, HTTPStatusError - -load_dotenv("../.env") - -URL = os.environ["OPENROUTER_URL"].strip() -KEY = os.environ["OPENROUTER_API_KEY"].strip() -MODEL = os.environ["OPENROUTER_MODEL"].strip() -logger = logging.getLogger(__name__) - - -class OpenRouterError(RuntimeError): - def __init__(self, status_code: int, retry_after: str | None = None, message: str | None = None, provider_message: str = ""): - self.status_code = status_code - self.retry_after = retry_after - self._message = message - self.provider_message = provider_message - super().__init__(self.message) - - @property - def message(self) -> str: - if self._message: - return self._message - if self.status_code in {429, 503}: - if self.retry_after: - return f"Model provider is rate limited. Try again in {self.retry_after} seconds." - return "Model provider is rate limited. Please try again shortly." - return "Model provider request failed. Please try again later." - - -def _messages(system: str, messages: list[dict]) -> list[dict]: - return [{"role": "system", "content": system}, *messages] - - -def _headers() -> dict: - return {"Authorization": f"Bearer {KEY}"} - - -def _message_content(data: dict) -> str: - try: - return _assistant_message(data)["content"].strip() - except (KeyError, TypeError, AttributeError) as exc: - raise OpenRouterError(502, message="Model provider returned an invalid response. Please try again later.", provider_message=str(data.get("error") or data)[:500]) from exc - - -def _raise_for_status(response) -> None: - try: - response.raise_for_status() - except HTTPStatusError as exc: - raise OpenRouterError( - exc.response.status_code, - exc.response.headers.get("retry-after"), - provider_message=exc.response.text[:500], - ) from exc - - -def _provider_error(data: dict) -> str: - error = data.get("error") if isinstance(data, dict) else None - if isinstance(error, dict): - return str(error.get("message") or error) - return str(error or data)[:500] - - -def _public_shape_error(provider_message: str) -> str: - lowered = provider_message.lower() - if "tool" in lowered and ("support" in lowered or "unsupported" in lowered): - return "The selected model does not support editor tools. Switch to a tool-calling model and try again." - if "rate" in lowered or "quota" in lowered: - return "Model provider is rate limited. Please try again shortly." - return "Model provider returned an invalid response. Please try again later." - - -def _assistant_message(data: dict) -> dict: - try: - message = data["choices"][0]["message"] - except (KeyError, IndexError, TypeError) as exc: - provider_message = _provider_error(data) - raise OpenRouterError(502, message=_public_shape_error(provider_message), provider_message=provider_message) from exc - if not isinstance(message, dict): - raise OpenRouterError(502, message="Model provider returned an invalid response. Please try again later.", provider_message=str(message)[:500]) - return message - - -def call(system: str, user: str) -> dict: - with Client(timeout=120) as c: - r = c.post( - URL, - headers=_headers(), - json={"model": MODEL, "messages": _messages(system, [{"role": "user", "content": user}])}, - ) - _raise_for_status(r) - data = r.json() - text = _message_content(data) - text = text.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip() - return json.loads(text) - - -def stream_chat(system: str, messages: list[dict], tools: list[dict] | None = None, tool_runner=None, on_tool_result=None): - chat_messages = _messages(system, messages) - if tools and tool_runner: - yield from _chat_with_tools(chat_messages, tools, tool_runner, on_tool_result) - return - - emitted = False - try: - for token in _stream_chat(chat_messages): - emitted = True - yield token - except (HTTPError, json.JSONDecodeError) as exc: - _log_stream_fallback(exc) - - if emitted: - return - - text = _chat_text(chat_messages) - if text: - yield text - - -def _stream_chat(messages: list[dict]): - payload = {"model": MODEL, "messages": messages, "stream": True} - with Client(timeout=120) as client: - with client.stream("POST", URL, headers=_headers(), json=payload) as response: - if response.is_error: - response.read() - response.raise_for_status() - for line in response.iter_lines(): - token = _stream_token(line) - if token: - yield token - - -def _chat_text(messages: list[dict]) -> str: - with Client(timeout=120) as client: - response = client.post(URL, headers=_headers(), json={"model": MODEL, "messages": messages}) - _raise_for_status(response) - return _message_content(response.json()) - - -def _chat_message(messages: list[dict], tools: list[dict]) -> dict: - with Client(timeout=120) as client: - response = client.post(URL, headers=_headers(), json={"model": MODEL, "messages": messages, "tools": tools}) - _raise_for_status(response) - return _assistant_message(response.json()) - - -def _chat_with_tools(messages: list[dict], tools: list[dict], tool_runner, on_tool_result=None): - for _ in range(5): - yield {"$status": f"Thinking... (attempt {_ + 1}/5)"} - message = _chat_message(messages, tools) - tool_calls = message.get("tool_calls") or [] - if not tool_calls: - text = str(message.get("content") or "").strip() - if text: - yield text - return - - messages.append({"role": "assistant", "content": message.get("content") or "", "tool_calls": tool_calls}) - for tool_call in tool_calls: - name = (tool_call.get("function") or {}).get("name") or "" - try: - result = _run_tool(tool_runner, name, _tool_arguments(tool_call)) - except ValueError as exc: - result = {"error": str(exc)} - if on_tool_result: - on_tool_result(name, result) - messages.append( - { - "role": "tool", - "tool_call_id": tool_call.get("id") or name, - "name": name, - "content": json.dumps(result, ensure_ascii=False), - } - ) - yield "I created tool results, but could not finish the response. Please check the Review panel." - - -def _tool_arguments(tool_call: dict) -> dict: - raw = (tool_call.get("function") or {}).get("arguments") or "{}" - arguments = json.loads(raw) if isinstance(raw, str) else raw - if not isinstance(arguments, dict): - raise ValueError("Tool arguments must be an object") - return arguments - - -def _run_tool(tool_runner, name: str, arguments: dict) -> dict: - try: - return tool_runner(name, arguments) - except Exception as exc: - return {"error": str(exc)} - - -def _log_stream_fallback(exc: Exception) -> None: - if isinstance(exc, HTTPStatusError): - response = exc.response - logger.warning( - "OpenRouter streaming failed; retrying without stream. status=%s model=%s body=%s", - response.status_code, - MODEL, - response.text[:500], - ) - return - logger.warning("OpenRouter streaming failed; retrying without stream. error=%s model=%s", exc.__class__.__name__, MODEL) - - -def _stream_token(line: str) -> str: - if not line or line.startswith(":"): - return "" - if not line.startswith("data:"): - return "" - data = line.removeprefix("data:").strip() - if data == "[DONE]": - return "" - chunk = json.loads(data) - choice = (chunk.get("choices") or [{}])[0] - delta = choice.get("delta") or {} - return delta.get("content") or "" diff --git a/backend/app/services/refinement.py b/backend/app/services/refinement.py deleted file mode 100644 index 5aad26b17035a357ddbe23429fe105c6ee6356e8..0000000000000000000000000000000000000000 --- a/backend/app/services/refinement.py +++ /dev/null @@ -1,491 +0,0 @@ -import re - -from app.supabase import first_row, supabase -from app.services.books import parse_books, raw_book_section -from app.services.deterministic import compute_hours, compute_program, compute_course_type -from app.services.openrouter import call as llm - -WORD = re.compile(r"\w+") -UNIT_LINE = re.compile(r"\b(Unit\s+\d+|Module\s+(?:\d+|[IVX]+))\s*[:.-]?\s*(.*)$", re.IGNORECASE) -HOURS_SUFFIX = re.compile(r"\s*\b\d+\s+Hours?\b\s*$", re.IGNORECASE) -DESIRABLE_LINE = re.compile(r"\bDesirable\s+([^\n]+)", re.IGNORECASE) -COURSE_CODE = re.compile(r"\bCourse\s+Code\s*:?\s*([A-Z0-9]+)", re.IGNORECASE) -PAGE_NOISE = re.compile( - r"P\.?\s*E\.?\s*S\.?\s*University|" - r"Curriculum|" - r"\s*:-\s*[A-Za-z]*\s*\d{4}\s*-\s*\d{4}\b|" - r"\b\d+\s*\|\s*Page\b", - re.IGNORECASE, -) - -SYS = """You refine PES University course submissions for the UG curriculum template. -Return only valid JSON. No markdown. No commentary. -Priority order: -1. Preserve the submitted academic content. -2. Correct spelling, grammar, casing, and formatting. -3. Fill missing template fields from the submitted course scope. -4. Return the required JSON shape. - -Editing rules: -- Refine, do not rewrite. If the input is already curriculum-ready, keep its wording and structure except for small correctness fixes. -- If prelude, objectives, outcomes, units, tools, or books are already present, clean them instead of replacing them. -- Generate a field only when that field is missing, incomplete, or too rough for the template. -- Preserve every syllabus topic and subtopic from Raw Course Content. -- Do not summarize away, omit, replace, merge away, or simplify syllabus topics. -- Do not add advanced, fashionable, or unrelated topics. -- Do not invent course codes, books, references, departments, credits, or external prerequisites. -- Correct course title typos, casing, spacing, and grammar. -- Keep objectives and outcomes direct, concise, and aligned with the submitted scope. -- Return exactly 4 units. If the input already has 4 units, keep those boundaries. If it has a different structure, redistribute topics in order without dropping content. -- Unit hours must sum to the supplied total unit hours. -- For tools/languages, use Preferred Tools / Languages when provided; otherwise identify course-specific tools, languages, platforms, or AI tools from Raw Course Content. -- Do not use canned defaults for tools/languages. -- For desirable knowledge, use only relevant knowledge from Previously Completed Courses. Return an empty string when none apply. -- Generate laboratory experiments only when practical hours are non-zero. Keep them tied to submitted topics. -- Copy and clean books from the submitted book fields only. -- Use empty strings or empty arrays for truly unavailable optional content. Do not output "-". -""" - -SCHEMA = """{ - "course_title": "corrected course title", - "prelude": "one short paragraph", - "objectives": ["3 to 4 objectives"], - "course_outcomes": ["3 to 4 measurable outcomes"], - "units": [{"title": "Unit 1: Title", "content": "compact topic list", "hours": 14}], - "lab_experiments": ["concise lab item"], - "tools_languages": "course-specific tools, languages, platforms, or AI tools", - "desirable_knowledge": "short text based only on previously completed courses, or empty string", - "text_books": ["submitted text books only"], - "reference_books": ["submitted reference books only"] -}""" - -EXAMPLES = """Behavior examples calibrated to PESU curriculum style. -Use them only to learn fidelity, field shape, and level of detail. Do not copy example facts into the real answer. - -Example 1: already refined input -Input: -Course Title: Web Technologies -Weekly Hours: L 4, T 0, P 0, S 4, C 4 -Total Unit Hours: 56 -Raw Course Content: -Unit 1: HTML, CSS and Client-Side Scripting - web architecture, HTTP request and response formats, URLs, HTML elements and attributes, web forms, HTML5 tags and controls, CSS selectors, style properties, box model, JavaScript objects, DOM manipulation, events and event handling. 14 Hours -Unit 2: HTML5 and ReactJS - HTML5 APIs, audio, video, progress, geolocation, callbacks, promises, single page applications, XML vs JSON, async/await, JSX, rendering elements, React setup, components, styling, props, state and context. 14 Hours -Unit 3: ReactJS and NodeJS - complex state management, keys, event handling, forms, hooks including useState, useRef, useEffect, useContext and useReducer, React Router, introduction to NextJS, NodeJS architecture, callbacks, modules, buffers, streams, file system and Axios API. 14 Hours -Unit 4: MongoDB and ExpressJS - documents, collections, reading and writing to MongoDB, MongoDB NodeJS driver, running a React application on NodeJS, React Router, web services, REST APIs, Express routing, URL building, error handling, middleware, form data and file upload. 14 Hours -Preferred Tools / Languages: HTML, CSS, JavaScript, MERN Technologies, GitHub, AI tools: Copilot and Tabnine -Previously Completed Courses: None -Text Books: -1. Robin Nixon, Learning PHP, MySQL and JavaScript, 5th Edition, O'Reilly Media, 2018. -2. Vasan Subramanian, Pro MERN Stack: Full Stack Web App Development with Mongo, Express, React, and Node, Apress, 2017. -Output: -{ - "course_title": "Web Technologies", - "prelude": "This course develops an in-depth understanding of technologies required to design and develop rich web applications using client-side scripting, ReactJS, NodeJS, ExpressJS, and MongoDB.", - "objectives": ["Build web pages using HTML, CSS, JavaScript, and advanced JavaScript concepts.", "Use HTML5, AJAX, JSON, and ReactJS to develop interactive user interfaces.", "Build multi-tier applications by connecting ReactJS interfaces to NodeJS services.", "Integrate MongoDB through ExpressJS and RESTful web services."], - "course_outcomes": ["Develop web pages using HTML, CSS, JavaScript, DOM manipulation, and event handling.", "Create interactive user interfaces using HTML5 APIs and ReactJS components.", "Implement server-side functionality using NodeJS, modules, streams, file system APIs, and routing.", "Create RESTful services with ExpressJS and integrate MongoDB for persistent storage."], - "units": [ - {"title": "Unit 1: HTML, CSS and Client-Side Scripting", "content": "Web architecture, HTTP request and response formats, URLs, HTML elements and attributes, web forms, HTML5 tags and controls, CSS selectors, style properties, box model, JavaScript objects, DOM manipulation, events and event handling.", "hours": 14}, - {"title": "Unit 2: HTML5 and ReactJS", "content": "HTML5 APIs, audio, video, progress, geolocation, callbacks, promises, single page applications, XML vs JSON, async/await, JSX, rendering elements, React setup, components, styling, props, state and context.", "hours": 14}, - {"title": "Unit 3: ReactJS and NodeJS", "content": "Complex state management, keys, event handling, forms, hooks including useState, useRef, useEffect, useContext and useReducer, React Router, introduction to NextJS, NodeJS architecture, callbacks, modules, buffers, streams, file system and Axios API.", "hours": 14}, - {"title": "Unit 4: MongoDB and ExpressJS", "content": "Documents, collections, reading and writing to MongoDB, MongoDB NodeJS driver, running a React application on NodeJS, React Router, web services, REST APIs, Express routing, URL building, error handling, middleware, form data and file upload.", "hours": 14} - ], - "lab_experiments": [], - "tools_languages": "HTML, CSS, JavaScript, MERN Technologies, GitHub; AI tools: Copilot, Tabnine", - "desirable_knowledge": "", - "text_books": ["Robin Nixon, Learning PHP, MySQL and JavaScript, 5th Edition, O'Reilly Media, 2018.", "Vasan Subramanian, Pro MERN Stack: Full Stack Web App Development with Mongo, Express, React, and Node, Apress, 2017."], - "reference_books": [] -} - -Example 2: detailed lab-integrated input with title and formatting issues -Input: -Course Title: data structures & its aplications -Weekly Hours: L 4, T 0, P 2, S 5, C 5 -Total Unit Hours: 56 -Raw Course Content: -Unit 1: Linked List and Stacks - Review of C, static and dynamic memory allocation, doubly linked list, circular linked list, multilist and sparse matrix, skip list dictionary case study, stack using arrays and linked list, function execution, nested functions, recursion, Tower of Hanoi, infix to postfix, infix to prefix, expression evaluation, matching parenthesis. 14 Hours -Unit 2: Queues and Trees - simple queue, circular queue, priority queue, dequeue using arrays and linked list, Josephus problem, CPU scheduling using queues, N-ary trees, binary trees, binary search trees, forests, conversion to binary tree, preorder, inorder and postorder traversal. 14 Hours -Unit 3: Application of Trees and Introduction to Graphs - BST insertion and deletion using arrays and dynamic allocation, binary expression tree, threaded binary search tree, heaps, priority queue using min heap and max heap, dictionary and decision tree applications, AVL trees, rotations, splay tree, graph properties, adjacency matrix, adjacency list, DFS, BFS, network topology representation. 14 Hours -Unit 4: Applications of Graphs, B-Trees, Suffix Tree and Hashing - BFS and DFS applications, connectivity, path finding in a network, suffix trees, trie trees, insert, delete and search operations, hashing, hash functions, hash tables, separate chaining, open addressing, double hashing, rehashing, URL decoding and word prediction using trie trees and suffix trees. 14 Hours -Laboratory: linked list operations; stack applications; queue applications; binary tree and BST applications; graph data structure applications; hashing techniques. -Preferred Tools / Languages: C-Programming language; AI tools: VisuAlgo (Interactive Visualizations), Algorithm Visualizer (AI Explanations) -Previously Completed Courses: Problem Solving with C -Text Books: 1. Langsam Yedidyah, Moshe J. Augenstein, Aaron M. Tenenbaum, Data Structures using C / C++, Pearson Education Inc., 2nd Edition, 2015. -Output: -{ - "course_title": "Data Structures and Applications", - "prelude": "This course introduces fundamental data structure concepts with emphasis on their theoretical foundations, implementation techniques, and practical applications using a programming language.", - "objectives": ["Analyze and design data structures for efficient storage, retrieval, and manipulation.", "Use linked lists, stacks, queues, trees, heaps, and graphs for suitable computational tasks.", "Implement insertion, deletion, searching, and modification operations across linear and non-linear data structures.", "Select and apply appropriate data structures to solve application-oriented problems."], - "course_outcomes": ["Select and apply appropriate data structures for solving problems across application domains.", "Implement fundamental data structures and their operations using suitable programming constructs.", "Use data structures effectively to design efficient solutions for computational problems.", "Develop software components by applying data structure principles and their applications."], - "units": [ - {"title": "Unit 1: Linked List and Stacks", "content": "Review of C, static and dynamic memory allocation, doubly linked list, circular linked list, multilist and sparse matrix, skip list dictionary case study, stack using arrays and linked list, function execution, nested functions, recursion, Tower of Hanoi, infix to postfix, infix to prefix, expression evaluation, matching parenthesis.", "hours": 14}, - {"title": "Unit 2: Queues and Trees", "content": "Simple queue, circular queue, priority queue, dequeue using arrays and linked list, Josephus problem, CPU scheduling using queues, N-ary trees, binary trees, binary search trees, forests, conversion to binary tree, preorder, inorder and postorder traversal.", "hours": 14}, - {"title": "Unit 3: Application of Trees and Introduction to Graphs", "content": "BST insertion and deletion using arrays and dynamic allocation, binary expression tree, threaded binary search tree, heaps, priority queue using min heap and max heap, dictionary and decision tree applications, AVL trees, rotations, splay tree, graph properties, adjacency matrix, adjacency list, DFS, BFS, network topology representation.", "hours": 14}, - {"title": "Unit 4: Applications of Graphs, B-Trees, Suffix Tree and Hashing", "content": "BFS and DFS applications, connectivity, path finding in a network, suffix trees, trie trees, insert, delete and search operations, hashing, hash functions, hash tables, separate chaining, open addressing, double hashing, rehashing, URL decoding and word prediction using trie trees and suffix trees.", "hours": 14} - ], - "lab_experiments": ["Linked list and advanced operations.", "Stack applications.", "Queue applications.", "Binary tree and binary search tree applications.", "Graph data structure applications.", "Hashing techniques."], - "tools_languages": "C-Programming language; AI tools: VisuAlgo (Interactive Visualizations), Algorithm Visualizer (AI Explanations)", - "desirable_knowledge": "Problem Solving with C", - "text_books": ["Langsam Yedidyah, Moshe J. Augenstein, Aaron M. Tenenbaum, Data Structures using C / C++, Pearson Education Inc., 2nd Edition, 2015."], - "reference_books": [] -} -""" - - -def _lines(value) -> list[str]: - if not value: - return [] - if isinstance(value, list): - return [str(item).strip() for item in value if str(item).strip()] - return [line.strip() for line in str(value).splitlines() if line.strip()] - - -def _clean_noise(value: str) -> str: - return re.sub(r"\s+", " ", PAGE_NOISE.sub(" ", value)).strip() - - -def _books(*values) -> list[str]: - return parse_books(*values) - - -def _raw_book_section(raw_content: str, kind: str) -> str: - return raw_book_section(raw_content, kind) - - -def _words(text: str) -> int: - return len(WORD.findall(text)) - - -def _clean_part(value: str) -> str: - return _clean_noise(value.strip(" \t-*\u2022")) - - -def _split_parts(text: str) -> list[str]: - lines = [_clean_part(line) for line in text.splitlines() if _clean_part(line)] - if len(lines) >= 4: - return lines - compact = _clean_part(text) - parts = [_clean_part(part) for part in re.split(r"(?<=[.!?])\s+", compact) if _clean_part(part)] - if len(parts) >= 4: - return parts - words = compact.split() - if not words: - return [] - return [ - " ".join(words[(index * len(words)) // 4 : ((index + 1) * len(words)) // 4]) - for index in range(4) - ] - - -def _four_units_from_raw(raw_content: str) -> list[dict]: - parts = _split_parts(raw_content) - if not parts: - return [] - buckets = [[] for _ in range(4)] - for index, part in enumerate(parts): - buckets[min(index * 4 // len(parts), 3)].append(part) - return [ - {"title": f"Unit {index + 1}", "content": " ".join(bucket).strip(), "hours": 0} - for index, bucket in enumerate(buckets) - if bucket - ] - - -def _course_contents(raw_content: str) -> str: - lines = raw_content.splitlines() - start = None - for index, line in enumerate(lines): - if "Course" in line and "Contents" in line: - start = index - break - if "Course" in line and any("Contents" in item for item in lines[index + 1 : index + 3]): - start = index - break - if start is None: - return raw_content - - end = len(lines) - for index in range(start + 1, len(lines)): - marker = lines[index].strip() - if not marker: - continue - if any(marker.startswith(label) for label in ("Laboratory", "Text Book", "Reference", "Course Outcome", "Assignment /")): - end = index - break - return "\n".join(lines[start:end]).strip() - - -def _syllabus_line(line: str) -> str: - clean = _clean_part(line) - clean = re.sub(r"^(Course\s+Contents|Contents)\s+", "", clean, flags=re.IGNORECASE) - clean = re.sub(r"^Course\s+(?=Unit\s+\d+|Module\s+(?:\d+|[IVX]+))", "", clean, flags=re.IGNORECASE) - return HOURS_SUFFIX.sub("", clean).strip() - - -def _units_from_course_contents(raw_content: str, fallback: bool = True) -> list[dict]: - content = _course_contents(raw_content) - lines = [line.strip() for line in content.splitlines() if line.strip()] - units = [] - current = None - - for line in lines: - clean = _syllabus_line(line) - if not clean: - continue - match = UNIT_LINE.search(clean) - if match: - if current: - units.append(current) - label = match.group(1).strip().title() - rest = match.group(2).strip(" :-") - if " - " in rest: - title, inline_content = rest.split(" - ", 1) - elif _words(rest) > 12: - title, inline_content = "", rest - else: - title, inline_content = rest, "" - current = { - "title": f"{label}: {title.strip()}" if title.strip() else label, - "content": inline_content.strip(), - "hours": 0, - } - continue - if current: - current["content"] = f"{current['content']} {clean}".strip() - - if current: - units.append(current) - if len(units) == 4: - return units - return _four_units_from_raw(content) if fallback else [] - - -def _unit_text(unit: dict) -> str: - title = str(unit.get("title", "")).strip() - content = str(unit.get("content", "")).strip() - if title and content: - return f"{title}: {content}" - return title or content - - -def _fit_four_units(units: list[dict], raw_content: str) -> list[dict]: - raw = _course_contents(raw_content).strip() - raw_units = _units_from_course_contents(raw_content, fallback=False) - raw_unit_words = _words(" ".join(_unit_text(unit) for unit in raw_units)) - if raw_units and raw_unit_words >= _words(raw) * 0.7: - return raw_units - if raw and _words(" ".join(_unit_text(unit) for unit in units)) < _words(raw) * 0.8: - return raw_units or _units_from_course_contents(raw_content) - if len(units) > 4: - fourth = units[3].copy() - fourth["content"] = " ".join(_unit_text(unit) for unit in units[3:]).strip() - return units[:3] + [fourth] - if len(units) == 4: - return units - if raw: - return raw_units or _units_from_course_contents(raw_content) - return units - - -def _units(value) -> list[dict]: - units = [] - for item in value if isinstance(value, list) else []: - if not isinstance(item, dict): - continue - title = str(item.get("title", "")).strip() - content = str(item.get("content", "")).strip() - hours_raw = str(item.get("hours", "0")) - hours = int("".join(ch for ch in hours_raw if ch.isdigit()) or 0) - if title or content: - units.append({"title": title, "content": content, "hours": hours}) - return units - - -def _assign_hours(units: list[dict], total_hours: int) -> list[dict]: - if not units: - return units - if sum(unit["hours"] for unit in units) == total_hours: - return units - base, extra = divmod(total_hours, len(units)) - for index, unit in enumerate(units): - unit["hours"] = base + (1 if index < extra else 0) - return units - - -def _text(*values) -> str: - for value in values: - if isinstance(value, str): - text = value.strip() - if text and text != "-": - return text - return "" - - -def _course_code(raw_content: str) -> str: - match = COURSE_CODE.search(raw_content or "") - return match.group(1).upper() if match else "" - - -def _prior_course_titles(sub: dict) -> list[str]: - semester = int(sub["semester"]) - if semester <= 1: - return [] - rows = ( - supabase.table("refined_submissions") - .select("submission_id,course_code,course_title,semester") - .lt("semester", semester) - .order("semester") - .execute() - .data - ) - ids = [row["submission_id"] for row in rows if row.get("submission_id")] - submissions = supabase.table("submissions").select("id,target_department,raw_course_content").in_("id", ids).execute().data if ids else [] - departments = {row["id"]: row.get("target_department") for row in submissions} - raw_codes = {row["id"]: _course_code(row.get("raw_course_content") or "") for row in submissions} - titles = [] - for row in rows: - if departments.get(row.get("submission_id")) != sub.get("target_department"): - continue - title = str(row.get("course_title") or "").strip() - code = str(row.get("course_code") or raw_codes.get(row.get("submission_id")) or "").strip() - label = f"{code} - {title}" if code else title - if title and label not in titles: - titles.append(label) - return titles - - -def _normalized(text: str) -> str: - return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip() - - -def _singularized(text: str) -> str: - return " ".join(word[:-1] if len(word) > 3 and word.endswith("s") else word for word in _normalized(text).split()) - - -def _core_key(text: str) -> str: - skip = {"a", "an", "and", "for", "in", "its", "of", "the", "to", "with"} - return " ".join(word for word in _singularized(text).split() if word not in skip) - - -def _prior_keys(course: str) -> list[str]: - match = re.match(r"\s*([A-Z]{2}\d{2}[A-Z0-9]+)\s*[-:]\s*(.+)", course) - code = match.group(1).lower() if match else "" - title = match.group(2) if match else course - return [key for key in (code, _normalized(title), _singularized(title), _core_key(title)) if key] - - -def _prior_matches(text: str, prior_courses: list[str]) -> str: - normalized = _normalized(text) - singularized = _singularized(text) - core = _core_key(text) - matches = [course for course in prior_courses if any(key in normalized or key in singularized or key in core for key in _prior_keys(course))] - return ", ".join(matches) - - -def _submitted_desirable(raw_content: str, prior_courses: list[str]) -> str | None: - match = DESIRABLE_LINE.search(raw_content or "") - if not match: - return None - value = _text(match.group(1)) - if not value: - return "" - return _prior_matches(value, prior_courses) or None - - -def _desirable(value, prior_courses: list[str], raw_content: str = "") -> str: - if not prior_courses: - return "" - submitted = _submitted_desirable(raw_content, prior_courses) - if submitted is not None: - return submitted - return _prior_matches(_text(value), prior_courses) - - -def _courses_text(courses: list[str]) -> str: - return "\n".join(f"- {course}" for course in courses) if courses else "None" - - -def build_refined_payload(sub: dict, out: dict, prior_courses: list[str] | None = None) -> dict: - out = out or {} - prior_courses = prior_courses or [] - det = compute_hours(sub["credit_category"]) - total_unit_hours = det["lecture_hours"] * 14 - raw_content = sub.get("raw_course_content") or "" - text_book_source = _raw_book_section(raw_content, "text") or sub["text_books"] - reference_book_source = _raw_book_section(raw_content, "reference") or sub.get("reference_books") - units = _assign_hours(_fit_four_units(_units(out.get("units")), sub.get("raw_course_content") or ""), total_unit_hours) - - objectives = _lines(out.get("objectives"))[:4] - course_outcomes = _lines(out.get("course_outcomes"))[:4] or objectives - - return { - "submission_id": sub["id"], - "semester": int(sub["semester"]), - "course_code": _course_code(raw_content), - "course_title": _text(out.get("course_title"), sub["course_title"]), - "program": compute_program(sub["target_department"]), - "course_type": compute_course_type(sub["credit_category"]), - **det, - "prelude": _text(out.get("prelude"), f"This course covers {sub['course_title'].strip()}."), - "objectives": objectives, - "course_outcomes": course_outcomes, - "units": units, - "lab_experiments": _lines(out.get("lab_experiments"))[:10] if det["practical_hours"] else [], - "tools_languages": _text(out.get("tools_languages"), sub.get("preferred_tools")), - "desirable_knowledge": _desirable(out.get("desirable_knowledge"), prior_courses, raw_content), - "text_books": _books(text_book_source), - "reference_books": _books(reference_book_source), - "status": "refined", - } - - -def refine(submission_id: int): - sub = first_row(supabase.table("submissions").select("*").eq("id", submission_id)) - if not sub: - raise LookupError("Submission not found") - det = compute_hours(sub["credit_category"]) - ctype = compute_course_type(sub["credit_category"]) - total_unit_hours = det["lecture_hours"] * 14 - prior_courses = _prior_course_titles(sub) - - prompt = f"""Return JSON matching this schema. Include every key: -{SCHEMA} - -{EXAMPLES} - -Now refine the real submission below. - -Course Title: {sub["course_title"]} -Offering Department: {sub["offering_department"]} -Target Department: {sub["target_department"]} -Semester: {sub["semester"]} -Credit Category: {sub["credit_category"]} -Course Type: {ctype} -Weekly Hours: L {det["lecture_hours"]}, T {det["tutorial_hours"]}, P {det["practical_hours"]}, S {det["self_study"]}, C {det["credits"]} -Total Unit Hours: {total_unit_hours} -Raw Course Content: -{sub["raw_course_content"]} - -Previously Completed Courses: -{_courses_text(prior_courses)} - -Text Books: -{sub["text_books"]} - -Reference Books: -{sub.get("reference_books") or "-"} - -Preferred Tools / Languages: -{sub.get("preferred_tools") or "-"}""" - - out = llm(SYS, prompt) - merged = build_refined_payload(sub, out, prior_courses) - - existing = supabase.table("refined_submissions").select("id").eq("submission_id", submission_id).execute().data - if existing: - supabase.table("refined_submissions").update(merged).eq("submission_id", submission_id).execute() - else: - supabase.table("refined_submissions").insert(merged).execute() - - supabase.table("submissions").update({"status": "refined"}).eq("id", submission_id).execute() - - return merged diff --git a/backend/app/services/schema.py b/backend/app/services/schema.py deleted file mode 100644 index f31dcbd8fa1b776b8a5c2a1a20b9a9dc5e354e70..0000000000000000000000000000000000000000 --- a/backend/app/services/schema.py +++ /dev/null @@ -1,28 +0,0 @@ -from postgrest.exceptions import APIError - -from app.supabase import supabase - -REQUIRED_TABLES = ( - "submissions", - "refined_submissions", - "curriculum_versions", - "finalized_submissions", - "agent_document_drafts", - "agent_drafts", - "course_revision_history", - "chat_sessions", - "chat_messages", - "chat_attachments", -) - - -def schema_status() -> dict: - missing = [] - for table in REQUIRED_TABLES: - try: - supabase.table(table).select("id").limit(1).execute() - except APIError as exc: - if "schema cache" not in str(exc): - raise - missing.append(table) - return {"ok": not missing, "missing_tables": missing, "required_tables": list(REQUIRED_TABLES)} diff --git a/backend/app/supabase.py b/backend/app/supabase.py index bb56c8315b23093e14ae754a6b02841bde82ea39..0cdba28a0e3deee72cfdee4f04192ed92b240dd0 100644 --- a/backend/app/supabase.py +++ b/backend/app/supabase.py @@ -5,17 +5,12 @@ import httpx load_dotenv("../.env") -url = environ["SUPABASE_URL"].strip() -key = environ["SUPABASE_KEY"].strip() -supabase = create_client( +url=environ["SUPABASE_URL"] +key=environ["SUPABASE_KEY"] +supabase=create_client( supabase_url=url, supabase_key=key, options=ClientOptions( httpx_client=httpx.Client(http2=False), ), -) - - -def first_row(query) -> dict | None: - rows = query.limit(1).execute().data - return rows[0] if rows else None +) \ No newline at end of file diff --git a/frontend/images/image1.png b/backend/app/templates/images/image1.png similarity index 100% rename from frontend/images/image1.png rename to backend/app/templates/images/image1.png diff --git a/frontend/images/image2.png b/backend/app/templates/images/image2.png similarity index 100% rename from frontend/images/image2.png rename to backend/app/templates/images/image2.png diff --git a/backend/app/templates/jinja_1_to_8.html b/backend/app/templates/jinja_1_to_8.html deleted file mode 100644 index b9f845e3b3a19a99edf0fa6e89840079629b5f13..0000000000000000000000000000000000000000 --- a/backend/app/templates/jinja_1_to_8.html +++ /dev/null @@ -1,240 +0,0 @@ -{% macro semester_name(value) -%} - {{ {"1": "I", "2": "II", "3": "III", "4": "IV", "7": "VII", "8": "VIII"}.get(value|string, value) }} -{%- endmacro %} - -{% macro course_row(course, number) -%} - - {{ number }} - {{ course.course_code | course_code_for_year(course.semester, curriculum_year) }} - {{ course.course_title }} - {{ course.lecture_hours }} - {{ course.tutorial_hours }} - {{ course.practical_hours }} - {{ course.self_study }} - {{ course.credits }} - {{ course.tools_languages }} - {{ course.course_type }} - -{%- endmacro %} - -{% macro final_year_row(course, number) -%} - - {{ number }} - {{ course.course_code | course_code_for_year(course.semester, curriculum_year) }} - {{ course.course_title }} - {{ course.lecture_hours }} - {{ course.tutorial_hours }} - {{ course.practical_hours }} - {{ course.self_study }} - {{ course.credits }} - {{ course.course_type }} - -{%- endmacro %} - -{% macro total_cell(total, credit_total) -%} - {% if total != credit_total %}{{ credit_total }}/{{ total }}{% else %}{{ total }}{% endif %} -{%- endmacro %} - -{% macro regular_table(summary_semester, show_total) -%} - {% set totals = namespace(count=0, lecture=0, lecture_credit=0, tutorial=0, practical=0, self_study=0, credits=0) %} - {% for summary_course in course_list %} - {% if summary_course.semester|string == summary_semester|string %} - {% set credit_value = summary_course.credits|int %} - {% set totals.count = totals.count + 1 %} - {% set totals.lecture = totals.lecture + summary_course.lecture_hours|int %} - {% set totals.tutorial = totals.tutorial + summary_course.tutorial_hours|int %} - {% set totals.practical = totals.practical + summary_course.practical_hours|int %} - {% set totals.self_study = totals.self_study + summary_course.self_study|int %} - {% set totals.credits = totals.credits + credit_value %} - {% if credit_value > 0 %} - {% set totals.lecture_credit = totals.lecture_credit + summary_course.lecture_hours|int %} - {% endif %} - {% endif %} - {% endfor %} - - {% if totals.count %} -

{{ semester_name(summary_semester) }} SEMESTER {{ batch_label(summary_semester, curriculum_year) }}

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

{{ semester_name(summary_semester) }} SEMESTER {{ batch_label(summary_semester, curriculum_year) }}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% set row = namespace(number=1) %} - {% for summary_course in course_list %} - {% if summary_course.semester|string == summary_semester|string %} - {{ final_year_row(summary_course, row.number) }} - {% set row.number = row.number + 1 %} - {% endif %} - {% endfor %} - - - - - - - - - - -
Sl. No.Course CodeCourse TitleHours/weekCreditsCourse Type
LTPSC
Total{{ totals.lecture }}{{ totals.tutorial }}{{ totals.practical }}{{ totals.self_study }}{{ totals.credits }}
- {% endif %} - {% endif %} - {% endfor %} -
- {% endif %} -{% endif %} diff --git a/backend/app/templates/jinja_sample.html b/backend/app/templates/jinja_sample.html index 0d237e3e9770d4b62bbb3033a1c4d7853c6c3ba3..3c530d4ab37a3cebff8f5d5a966fceb2edf77a50 100644 --- a/backend/app/templates/jinja_sample.html +++ b/backend/app/templates/jinja_sample.html @@ -1,427 +1,1478 @@ - - + - - + - - - {% set course_list = courses if courses is defined else [course] %} - {% if course_list %} - {% if show_summaries | default(false) %} - {% set summary_range = "early" %} - {% include "jinja_1_to_8.html" %} - {% include "jinja_sem_5_6.html" %} - {% set summary_range = "final" %} - {% include "jinja_1_to_8.html" %} - {% endif %} - {% for course in course_list %} - {% if not show_summaries | default(false) or course.render_detail | default(true) %} -
-
- PES University logo - PES University -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Course Code{{ course.course_code | course_code_for_year(course.semester, curriculum_year) }}Course Title{{ course.course_title }}
Program{{ course.program }}Hours per week/ Credit AssignedLTPSC
{{ course.lecture_hours }}{{ course.tutorial_hours }}{{ course.practical_hours }}{{ course.self_study }}{{ course.credits }}
Semester{{ course.semester }}Type of Course{{ course.course_type }}
AI Tools / Tools / Languages{{ course.tools_languages }}Desirable Knowledge{{ course.desirable_knowledge }}
- - - - - - - - - - - - - - - - - - - - {% if course.lab_experiments %} - - - - - {% endif %} - - - - - {% if course.reference_books %} - - - - - {% endif %} - {% if course.course_outcomes %} - - - - - {% endif %} - -
Prelude{{ course.prelude }}
Course Objectives: -
    - {% for objective in course.objectives %} -
  • {{ objective }}
  • - {% endfor %} -
-
Course Contents - {% if course.units %} - {% for unit in course.units %} -
-

{{ unit.title }}

-

{{ unit.content }}

-

{{ unit.hours }} Hours

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

+ +

+
+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+
+

Course Code

+
+

{{ course.course_code }}

+
+

Course Title

+
+

+ {{ course.course_title }} +

+
+

+
+

Program

+
+

{{ course.program }}

+
+

+ Hours per week/ Credit Assigned +

+
+

L

+
+

T

+
+

P

+
+

S

+
+

C

+
+

{{ course.lecture_hours }}

+
+

{{ course.tutorial_hours }}

+
+

{{ course.practical_hours }}

+
+

{{ course.self_study }}

+
+

{{ course.credits }}

+
+

Semester

+
+

{{ course.semester }}

+
+

Type of Course

+
+

{{ course.course_type }}

+
+

AI Tools

+

/Tools/Languag es

+
+

+ {{ course.tools_languages }} +

+
+

Desirable Knowledge

+
+

{{ course.desirable_knowledge }}

+
+

Prelude

+
+

+ {{ course.prelude }} +

+
+

Course Objectives:

+
+
    + {% for obj in course.objectives %} +
  • + {{ obj }} +
  • + {% endfor %} +
+
+

Course Contents

+
+ {% for unit in course.units %} +

{{ unit.title }}

+

+ {{ unit.content }} +

+

{{ unit.hours }} Hours

+ {% endfor %} +
+

Laboratory

+
+
    + {% for exp in course.lab_experiments %} +
  1. + {{ exp }} +
  2. + {% endfor %} +
+
+ diff --git a/backend/app/templates/jinja_sem_5_6.html b/backend/app/templates/jinja_sem_5_6.html deleted file mode 100644 index 5b31d2da913ba886d9880cc0af6353f3ecb5b55b..0000000000000000000000000000000000000000 --- a/backend/app/templates/jinja_sem_5_6.html +++ /dev/null @@ -1,238 +0,0 @@ -{% macro semester_name(value) -%} - {{ {"5": "V", "6": "VI"}.get(value|string, value) }} -{%- endmacro %} - -{% macro course_row(course, number) -%} - {% set code = course.course_code|string|upper|replace(" ", "") %} - {% set title = course.course_title|string|lower %} - {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %} - {% set is_no_course = "no course offered" in title %} - {% set is_elective = ("AA" in code or "AB" in code or "BA" in code or "BB" in code) and not is_placeholder and not is_no_course %} - - {{ number }} - {{ course.course_code | course_code_for_year(course.semester, curriculum_year) }} - {{ course.course_title }} - {% if is_no_course %}{% elif is_elective %}4{% else %}{{ course.lecture_hours }}{% endif %} - {% if is_no_course %}{% elif is_elective %}0{% else %}{{ course.tutorial_hours }}{% endif %} - {% if is_no_course %}{% elif is_elective %}0{% else %}{{ course.practical_hours }}{% endif %} - {% if is_no_course %}{% elif is_elective %}4{% else %}{{ course.self_study }}{% endif %} - {% if is_no_course %}{% elif is_elective %}4{% else %}{{ course.credits }}{% endif %} - {{ course.tools_languages }} - {% if is_no_course %}{% elif is_elective or is_placeholder %}Elective Course{% else %}{{ course.course_type }}{% endif %} - -{%- endmacro %} - -{% macro total_cell(total, credit_total) -%} - {% if total != credit_total %}{{ credit_total }}/{{ total }}{% else %}{{ total }}{% endif %} -{%- endmacro %} - -{% macro specialization_codes(codes, summary_semester, curriculum_year) -%} - {% for code in codes %} -

{{ ("UE00CS" ~ code) | course_code_for_year(summary_semester, curriculum_year) }}

- {% endfor %} -{%- endmacro %} - -{% set requested_semester = semester|string if semester is defined and semester else "" %} -{% for summary_semester in [5, 6] %} - {% if not requested_semester or requested_semester == summary_semester|string %} - {% set totals = namespace(count=0, lecture=0, lecture_credit=0, tutorial=0, practical=0, self_study=0, credits=0) %} - {% for summary_course in course_list %} - {% set code = summary_course.course_code|string|upper|replace(" ", "") %} - {% set title = summary_course.course_title|string|lower %} - {% set course_type = summary_course.course_type|string|lower %} - {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %} - {% set has_elective_code = "AA" in code or "AB" in code or "BA" in code or "BB" in code %} - {% set is_actual_elective = has_elective_code and not is_placeholder %} - {% if summary_course.semester|string == summary_semester|string and not is_actual_elective %} - {% set credit_value = summary_course.credits|int %} - {% set totals.count = totals.count + 1 %} - {% set totals.lecture = totals.lecture + summary_course.lecture_hours|int %} - {% set totals.tutorial = totals.tutorial + summary_course.tutorial_hours|int %} - {% set totals.practical = totals.practical + summary_course.practical_hours|int %} - {% set totals.self_study = totals.self_study + summary_course.self_study|int %} - {% set totals.credits = totals.credits + credit_value %} - {% if credit_value > 0 %} - {% set totals.lecture_credit = totals.lecture_credit + summary_course.lecture_hours|int %} - {% endif %} - {% endif %} - {% endfor %} - - {% set elective_a = namespace(count=0) %} - {% set elective_b = namespace(count=0) %} - {% for summary_course in course_list %} - {% set code = summary_course.course_code|string|upper|replace(" ", "") %} - {% set title = summary_course.course_title|string|lower %} - {% set course_type = summary_course.course_type|string|lower %} - {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %} - {% set is_elective = not is_placeholder %} - {% if summary_course.semester|string == summary_semester|string and is_elective %} - {% if summary_semester == 5 and "AA" in code %} - {% set elective_a.count = elective_a.count + 1 %} - {% elif summary_semester == 5 and "AB" in code %} - {% set elective_b.count = elective_b.count + 1 %} - {% elif summary_semester == 6 and "BA" in code %} - {% set elective_a.count = elective_a.count + 1 %} - {% elif summary_semester == 6 and "BB" in code %} - {% set elective_b.count = elective_b.count + 1 %} - {% endif %} - {% endif %} - {% endfor %} - - {% if totals.count or elective_a.count or elective_b.count %} -
-
- PES University logo - PES University -
-

{{ semester_name(summary_semester) }} SEMESTER {{ batch_label(summary_semester, curriculum_year) }}

- - {% if totals.count %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% set row = namespace(number=1) %} - {% for summary_course in course_list %} - {% set code = summary_course.course_code|string|upper|replace(" ", "") %} - {% set title = summary_course.course_title|string|lower %} - {% set course_type = summary_course.course_type|string|lower %} - {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %} - {% set has_elective_code = "AA" in code or "AB" in code or "BA" in code or "BB" in code %} - {% set is_actual_elective = has_elective_code and not is_placeholder %} - {% if summary_course.semester|string == summary_semester|string and not is_actual_elective %} - {{ course_row(summary_course, row.number) }} - {% set row.number = row.number + 1 %} - {% endif %} - {% endfor %} - - - - - - - - - - - -
Sl. No.Course CodeCourse TitleHours per weekCreditsAI Tools / Tools /
Languages
Course Type
LTPSC
Total{{ total_cell(totals.lecture, totals.lecture_credit) }}{{ totals.tutorial }}{{ totals.practical }}{{ totals.self_study }}{{ totals.credits }}
- {% endif %} - - {% for group in ["a", "b"] %} - - - - - - - - - - - - - - - - - - {% set start_number = totals.count + 1 if group == "a" else totals.count + elective_a.count + 1 %} - {% set row = namespace(number=start_number) %} - {% for summary_course in course_list %} - {% set code = summary_course.course_code|string|upper|replace(" ", "") %} - {% set title = summary_course.course_title|string|lower %} - {% set course_type = summary_course.course_type|string|lower %} - {% set is_placeholder = code.endswith("X") or title in ["elective i", "elective ii", "elective iii", "elective iv"] %} - {% set is_elective = not is_placeholder %} - {% set is_group_a = (summary_semester == 5 and "AA" in code) or (summary_semester == 6 and "BA" in code) %} - {% set is_group_b = (summary_semester == 5 and "AB" in code) or (summary_semester == 6 and "BB" in code) %} - {% if summary_course.semester|string == summary_semester|string and is_elective and ((group == "a" and is_group_a) or (group == "b" and is_group_b)) %} - {{ course_row(summary_course, row.number) }} - {% set row.number = row.number + 1 %} - {% endif %} - {% endfor %} - -
- {% if summary_semester == 5 and group == "a" %}Elective-I{% endif %} - {% if summary_semester == 5 and group == "b" %}Elective-II{% endif %} - {% if summary_semester == 6 and group == "a" %}Elective-III{% endif %} - {% if summary_semester == 6 and group == "b" %}Elective-IV{% endif %} -
- {% endfor %} - - {% if summary_semester in [5, 6] %} - {% if summary_semester == 5 %} - {% set first_label = "Elective-I" %} - {% set second_label = "Elective-II" %} - {% set specializations = [ - {"letter": "A", "name": "System and Core Computing (SCC)", "first": ["342AA1", "342AA8", "342AA9"], "second": ["343AB1", "343AB2", "343AB3", "343AB4", "343AB8"]}, - {"letter": "B", "name": "Machine Intelligence and Data Science (MIDS)", "first": ["342AA2", "342AA5", "342AA6", "342AA8"], "second": ["343AB1", "342AB2", "342AB7"]}, - {"letter": "C", "name": "Cyber Security and Connected Systems (CSCS)", "first": ["342AA3", "342AA4", "342AA5", "342AA7"], "second": ["343AB5", "342AB6", "342AB7"]}, - ] %} - {% else %} - {% set first_label = "Elective-III" %} - {% set second_label = "Elective-IV" %} - {% set specializations = [ - {"letter": "A", "name": "System and Core Computing (SCC)", "first": ["342BA1", "342BA2", "342BA3"], "second": ["343BB1", "343BB3", "343BB5", "343BB10"]}, - {"letter": "B", "name": "Machine Intelligence and Data Science (MIDS)", "first": ["342BA2", "342BA3", "342BA4", "342BA7", "342BA10"], "second": ["343BB2", "343BB4", "343BB7", "343BB9", "343BB10"]}, - {"letter": "C", "name": "Cyber Security and Connected Systems (CSCS)", "first": ["342BA5", "342BA6", "342BA7", "342BA8"], "second": ["343BB6", "343BB7", "343BB8", "343BB9", "343BB11", "343BB12"]}, - ] %} - {% endif %} - - - - - - - - - - - - - - - - - - {% for specialization in specializations %} - - - - - - - {% endfor %} - -
ELECTIVES TO BE OPTED FOR SPECIALIZATION
Sl. No.Specialization{{ first_label }}{{ second_label }}
{{ specialization.letter }}{{ specialization.name }}{{ specialization_codes(specialization.first, summary_semester, curriculum_year) }}{{ specialization_codes(specialization.second, summary_semester, curriculum_year) }}
- {% endif %} -
- {% endif %} - {% endif %} -{% endfor %} diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 6a88d0c51b721cf91728500abe20d83d3f184eb3..0000000000000000000000000000000000000000 --- a/docs/index.md +++ /dev/null @@ -1,184 +0,0 @@ ---- -title: PESU Curriculum Automation -permalink: / ---- - -# PESU Curriculum Automation - -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. - -## System Overview - -| Layer | Location | Responsibility | -| --- | --- | --- | -| Static frontend | `frontend/` | Course entry, course management, PDF preview, live editor, and version history | -| API backend | `backend/app/` | FastAPI routes, validation, refinement, previews, drafts, chat, and snapshots | -| Persistence | Supabase | Raw submissions, refined courses, agent drafts, chat history, attachments, and curriculum versions | -| Rendering | Jinja2 and WeasyPrint | Curriculum summary pages, course detail pages, and PDF exports | -| Model provider | OpenRouter | Submission refinement and live editor chat with tool calls | - -## Runtime Flow - -1. Faculty submit raw course data through `frontend/form/`. -2. `POST /api/submissions` validates the payload and stores it in `submissions`. -3. A background refinement task builds deterministic academic fields, calls the model for prose fields, and writes `refined_submissions`. -4. Course management and preview pages read refined records through `/api/courses` and `/api/preview/*`. -5. The live editor can edit JSON fields directly or create draft changes through agent tools. -6. Drafts are reviewed with generated diffs before being applied to refined records. -7. Version snapshots store complete curriculum states and can restore a prior snapshot. - -## Frontend Surfaces - -| Route | Purpose | -| --- | --- | -| `/` | Dashboard for the available application surfaces | -| `/form/` | Raw course submission form | -| `/courses/` | Refined course list with filtering and soft delete | -| `/preview/` | Overall or semester PDF preview and download | -| `/live-editor/` | Course preview, chat assistant, JSON editor, draft review, and version restore | -| `/versions/` | Snapshot list, snapshot preview, and editor handoff | - -The frontend is plain HTML, CSS, and JavaScript. There is no Node build step. - -## API Surface - -| Endpoint | Method | Purpose | -| --- | --- | --- | -| `/api/submissions` | `POST` | Store a raw submission and queue refinement | -| `/api/submissions/{id}/refine` | `POST` | Manually refine a submission | -| `/api/refined/{refined_id}` | `GET` | Read template-ready course fields | -| `/api/refined/{refined_id}` | `PATCH` | Update editable refined fields | -| `/api/courses` | `GET` | List active refined courses | -| `/api/courses/{refined_id}` | `DELETE` | Archive a refined course | -| `/api/preview/course/{refined_id}` | `GET` | Render one course as HTML | -| `/api/preview/pdf` | `GET` | Render the full curriculum as PDF | -| `/api/preview/semester/{sem}/pdf` | `GET` | Render one semester as PDF | -| `/api/versions` | `GET`, `POST` | List or create curriculum snapshots | -| `/api/versions/{version_id}/restore` | `POST` | Restore a saved curriculum snapshot | -| `/api/agent/drafts` | `GET`, `POST` | List or create reviewable course drafts | -| `/api/agent/drafts/{draft_id}/apply` | `POST` | Apply a proposed draft after review | -| `/api/agent/document-drafts` | `GET`, `POST` | List or create multi-course drafts | -| `/api/chat/sessions` | `GET`, `POST` | Manage live editor chat sessions | -| `/api/chat/sessions/{session_id}/messages` | `GET`, `POST` | Read or stream chat messages | -| `/api/chat/sessions/{session_id}/attachments` | `POST` | Upload document context for chat | -| `/api/health/schema` | `GET` | Check required Supabase tables | - -## Submission Contract - -`CourseSubmission` is defined in `backend/app/models/submission.py`. - -Required fields: - -- `faculty_email` -- `course_title` -- `offering_department`: `MA`, `CS`, or `UZ` -- `target_department`: `CSE`, `ECE`, `ME`, `BT`, `EEE`, or `AIML` -- `semester`: string value from `1` to `8` -- `credit_category`: `0`, `2`, `4`, or `5` -- `raw_course_content`: at least 50 characters -- `text_books`: at least 5 characters - -Optional fields: - -- `reference_books` -- `preferred_tools` - -Successful submissions return `message` and the inserted `submission` row. - -Validation errors use FastAPI's standard `422` response with a `detail` array. - -## Deterministic Fields - -The backend computes these fields in `backend/app/services/deterministic.py`: - -| Credit category | L | T | P | S | C | Course type | -| --- | ---: | ---: | ---: | ---: | ---: | --- | -| `5` | 4 | 0 | 2 | 5 | 5 | Core Course-Lab Integrated | -| `4` | 4 | 0 | 0 | 4 | 4 | Core Course | -| `2` | 2 | 0 | 0 | 2 | 2 | Core Theory | -| `0` | 0 | 0 | 0 | 0 | 0 | Foundation Course | - -All configured target departments currently map to `B. TECH`. - -Agent drafts are not allowed to change deterministic fields such as program, hours, credits, or course type. Drafts that attempt these changes are blocked. - -## Database - -Run `docs/schema.sql` in the Supabase SQL editor before using the application. The schema creates: - -- `submissions` -- `refined_submissions` -- `curriculum_versions` -- `finalized_submissions` -- `agent_document_drafts` -- `agent_drafts` -- `course_revision_history` -- `chat_sessions` -- `chat_messages` -- `chat_attachments` - -Use `GET /api/health/schema` to verify that the required tables exist. - -## Environment - -Required backend variables: - -- `SUPABASE_URL` -- `SUPABASE_KEY` -- `OPENROUTER_URL` -- `OPENROUTER_API_KEY` -- `OPENROUTER_MODEL` - -Optional backend variables: - -- `CURRICULUM_YEAR` -- `SENTRY_DSN` -- `SENTRY_ENVIRONMENT` -- `SENTRY_RELEASE` - -Keep environment values in `.env` or platform secrets. Do not expose backend credentials in frontend code. - -## Local Development - -```bash -python3 -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -cd backend -fastapi dev app/main.py -``` - -The backend serves the static frontend at `http://127.0.0.1:8000/` and mounts the API under `/api`. - -Run checks from the repository root: - -```bash -source .venv/bin/activate -pytest -python3 -m compileall backend/app -``` - -## Deployment - -The Docker image installs the Python dependencies, copies `backend/` and `frontend/`, and runs: - -```bash -uvicorn app.main:app --host 0.0.0.0 --port 7860 -``` - -The included GitHub Actions workflow syncs `main` to the Hugging Face Space configured in `.github/workflows/sync-to-hub.yml`. - -The static frontend can also be deployed from `frontend/`. The included `frontend/vercel.json` rewrites `/api/*` requests to the deployed backend. - -## Current Checks - -The test suite covers: - -- Deterministic credit, hour, program, and course type mapping -- Submission refinement helpers -- Preview rendering and curriculum summary behavior -- Agent diffing, protected field checks, and draft tooling -- OpenRouter streaming and error handling -- Static frontend route structure -- Supabase schema status checks -- Attachment extraction safeguards and text/DOCX parsing diff --git a/docs/remote-db.md b/docs/remote-db.md deleted file mode 100644 index b9b4521f2432b3367b429f8b921e6b27f3fda078..0000000000000000000000000000000000000000 --- a/docs/remote-db.md +++ /dev/null @@ -1,44 +0,0 @@ -# Remote Database Access - -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. - -## Required Access - -Ask the project owner for: - -- Supabase dashboard access. -- The backend `SUPABASE_URL`. -- The backend `SUPABASE_KEY`. - -Keep these values in `.env` only. Do not commit credentials or paste them into frontend code. - -## Current Data Flow - -- `submissions` stores the raw form input. -- `refined_submissions` stores the template-ready refined fields used by the preview template. -- `agent_drafts` and `agent_document_drafts` store proposed AI changes before human approval. -- `finalized_submissions` and `curriculum_versions` store approved curriculum snapshots. - -## Schema - -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. - -## Read Data From Code - -Run these commands from the repo root after `.env` is configured: - -```bash -source .venv/bin/activate -cd backend -python3 - <<'PY' -from app.supabase import supabase - -raw = supabase.table("submissions").select("id,course_title,status,semester").limit(5).execute() -refined = supabase.table("refined_submissions").select("id,submission_id,course_title,semester").limit(5).execute() - -print("submissions") -print(raw.data) -print("refined_submissions") -print(refined.data) -PY -``` diff --git a/docs/schema.sql b/docs/schema.sql deleted file mode 100644 index 83b7c30c69f756581d352139bfe3dc2318924d1d..0000000000000000000000000000000000000000 --- a/docs/schema.sql +++ /dev/null @@ -1,149 +0,0 @@ -create table if not exists submissions ( - id bigint generated by default as identity primary key, - faculty_email text not null, - course_title text not null, - offering_department text not null check (offering_department in ('MA', 'CS', 'UZ')), - target_department text not null check (target_department in ('CSE', 'ECE', 'ME', 'BT', 'EEE', 'AIML')), - semester integer not null check (semester between 1 and 8), - credit_category text not null check (credit_category in ('0', '2', '4', '5')), - raw_course_content text not null, - text_books text not null, - reference_books text not null default '', - preferred_tools text not null default '', - status text not null default 'pending', - created_at timestamptz not null default now(), - updated_at timestamptz not null default now() -); - -create table if not exists refined_submissions ( - id bigint generated by default as identity primary key, - submission_id bigint not null unique references submissions(id) on delete cascade, - semester integer not null check (semester between 1 and 8), - course_code text not null default '', - course_title text not null, - program text not null, - lecture_hours integer not null default 0, - tutorial_hours integer not null default 0, - practical_hours integer not null default 0, - self_study integer not null default 0, - credits integer not null default 0, - course_type text not null, - tools_languages text not null default '', - desirable_knowledge text not null default '', - prelude text not null default '', - objectives text[] not null default '{}', - course_outcomes text[] not null default '{}', - units jsonb not null default '[]'::jsonb check (jsonb_typeof(units) = 'array'), - lab_experiments text[] not null default '{}', - text_books text[] not null default '{}', - reference_books text[] not null default '{}', - status text not null default 'refined', - created_at timestamptz not null default now(), - updated_at timestamptz not null default now() -); - -create table if not exists curriculum_versions ( - id bigint generated by default as identity primary key, - name text not null, - program text not null, - academic_year text not null, - status text not null default 'draft' check (status in ('draft', 'published', 'archived')), - created_at timestamptz not null default now(), - updated_at timestamptz not null default now() -); - -create table if not exists finalized_submissions ( - id bigint generated by default as identity primary key, - curriculum_version_id bigint not null references curriculum_versions(id) on delete cascade, - refined_id bigint not null references refined_submissions(id) on delete restrict, - course_json jsonb not null check (jsonb_typeof(course_json) = 'object'), - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - unique (curriculum_version_id, refined_id) -); - -create table if not exists agent_document_drafts ( - id bigint generated by default as identity primary key, - curriculum_version_id bigint references curriculum_versions(id) on delete set null, - uploaded_document_id text not null default '', - diff_summary jsonb not null default '{}'::jsonb check (jsonb_typeof(diff_summary) = 'object'), - change_reason text not null default '', - status text not null default 'proposed' check (status in ('proposed', 'blocked', 'approved', 'rejected', 'applied')), - created_at timestamptz not null default now(), - updated_at timestamptz not null default now() -); - -create table if not exists agent_drafts ( - id bigint generated by default as identity primary key, - refined_id bigint not null references refined_submissions(id) on delete cascade, - document_draft_id bigint references agent_document_drafts(id) on delete cascade, - base_refined_json jsonb not null check (jsonb_typeof(base_refined_json) = 'object'), - proposed_json jsonb not null check (jsonb_typeof(proposed_json) = 'object'), - json_patch jsonb not null default '[]'::jsonb check (jsonb_typeof(json_patch) = 'array'), - diff_summary jsonb not null default '{}'::jsonb check (jsonb_typeof(diff_summary) = 'object'), - change_reason text not null default '', - status text not null default 'proposed' check (status in ('proposed', 'blocked', 'approved', 'rejected', 'applied')), - created_at timestamptz not null default now(), - updated_at timestamptz not null default now() -); - -create table if not exists course_revision_history ( - id bigint generated by default as identity primary key, - refined_id bigint not null references refined_submissions(id) on delete cascade, - agent_draft_id bigint references agent_drafts(id) on delete set null, - source text not null default 'agent_draft', - previous_json jsonb not null check (jsonb_typeof(previous_json) = 'object'), - next_json jsonb not null check (jsonb_typeof(next_json) = 'object'), - json_patch jsonb not null default '[]'::jsonb check (jsonb_typeof(json_patch) = 'array'), - diff_summary jsonb not null default '{}'::jsonb check (jsonb_typeof(diff_summary) = 'object'), - change_reason text not null default '', - changed_by text not null default '', - created_at timestamptz not null default now() -); - -create table if not exists chat_sessions ( - id bigint generated by default as identity primary key, - refined_id bigint references refined_submissions(id) on delete set null, - document_draft_id bigint references agent_document_drafts(id) on delete set null, - title text not null default '', - status text not null default 'active' check (status in ('active', 'archived')), - created_at timestamptz not null default now(), - updated_at timestamptz not null default now() -); - -create table if not exists chat_messages ( - id bigint generated by default as identity primary key, - session_id bigint not null references chat_sessions(id) on delete cascade, - role text not null check (role in ('user', 'assistant', 'system', 'tool')), - content text not null default '', - metadata jsonb not null default '{}'::jsonb check (jsonb_typeof(metadata) = 'object'), - created_at timestamptz not null default now() -); - -create table if not exists chat_attachments ( - id bigint generated by default as identity primary key, - session_id bigint not null references chat_sessions(id) on delete cascade, - message_id bigint references chat_messages(id) on delete set null, - filename text not null, - content_type text not null default '', - size_bytes integer not null default 0, - extracted_text text not null default '', - status text not null default 'ready' check (status in ('ready', 'unsupported', 'failed')), - error text not null default '', - created_at timestamptz not null default now() -); - -create index if not exists submissions_semester_idx on submissions (semester); -create index if not exists submissions_status_idx on submissions (status); -create index if not exists refined_submissions_semester_idx on refined_submissions (semester); -create index if not exists agent_drafts_refined_id_idx on agent_drafts (refined_id); -create index if not exists agent_drafts_document_draft_id_idx on agent_drafts (document_draft_id); -create index if not exists agent_drafts_status_idx on agent_drafts (status); -create index if not exists agent_document_drafts_status_idx on agent_document_drafts (status); -create index if not exists finalized_submissions_curriculum_version_idx on finalized_submissions (curriculum_version_id); -create index if not exists course_revision_history_refined_id_idx on course_revision_history (refined_id); -create index if not exists chat_sessions_refined_id_idx on chat_sessions (refined_id); -create index if not exists chat_sessions_document_draft_id_idx on chat_sessions (document_draft_id); -create index if not exists chat_messages_session_id_idx on chat_messages (session_id, created_at); -create index if not exists chat_attachments_session_id_idx on chat_attachments (session_id); -create index if not exists chat_attachments_message_id_idx on chat_attachments (message_id); diff --git a/frontend/courses.html b/frontend/courses.html deleted file mode 100644 index 968434d14311400b9fa26a467406d0d3d0016450..0000000000000000000000000000000000000000 --- a/frontend/courses.html +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/frontend/courses/courses.css b/frontend/courses/courses.css deleted file mode 100644 index b503536e551bc13203c569c77351b6090c3c3b1c..0000000000000000000000000000000000000000 --- a/frontend/courses/courses.css +++ /dev/null @@ -1,130 +0,0 @@ -@import "../shared.css"; - -* { - box-sizing: border-box; -} - -body { - margin: 0; - min-height: 100dvh; - background: var(--page-bg); - color: var(--text); - font-family: Arial, sans-serif; -} - -.shell { - display: grid; - gap: 12px; - padding: 16px; -} - -.topbar, -.panel { - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface); -} - -.topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 14px; -} - -h1 { - margin: 0; - color: var(--pesu-blue); - font-size: 22px; -} - -p { - margin: 4px 0 0; - color: var(--muted); - font-size: 13px; -} - -.actions, -.filters { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} - -.filters { - padding: 12px; - border-bottom: 1px solid var(--border); -} - -.filters input { - flex: 1; -} - -#status { - margin-left: auto; -} - -.table-wrap { - overflow: auto; -} - -table { - width: 100%; - border-collapse: collapse; -} - -th, -td { - padding: 10px 12px; - border-bottom: 1px solid var(--border); - text-align: left; - vertical-align: top; - font-size: 14px; -} - -th { - color: var(--pesu-blue); - background: #f8fafc; - font-size: 12px; - text-transform: uppercase; -} - -.course-row { - cursor: pointer; -} - -.course-row:hover { - background: #f8fafc; -} - -.details { - display: grid; - gap: 8px; - color: #374151; - line-height: 1.45; -} - -.details strong { - color: var(--pesu-blue); -} - -.empty { - padding: 20px; - color: var(--muted); - text-align: center; -} - -@media (max-width: 760px) { - .topbar, - .actions, - .filters { - align-items: stretch; - flex-direction: column; - } - - #status { - margin-left: 0; - } -} diff --git a/frontend/courses/courses.js b/frontend/courses/courses.js deleted file mode 100644 index 1b19e155b9b0311b16820412d485afb1e84112c8..0000000000000000000000000000000000000000 --- a/frontend/courses/courses.js +++ /dev/null @@ -1,117 +0,0 @@ -const semester = document.getElementById("semester"); -const search = document.getElementById("search"); -const statusText = document.getElementById("status"); -const table = document.getElementById("course-table"); -let courses = []; -let openId = ""; - -function setStatus(text, kind = "") { - statusText.textContent = text; - statusText.className = `status-line ${kind}`.trim(); -} - -function cell(text) { - const td = document.createElement("td"); - td.textContent = text || ""; - return td; -} - -function courseMatches(course) { - const query = search.value.trim().toLowerCase(); - if (semester.value && String(course.semester) !== semester.value) return false; - if (!query) return true; - return `${course.course_code} ${course.course_title}`.toLowerCase().includes(query); -} - -function detailsRow(course) { - const row = document.createElement("tr"); - const td = document.createElement("td"); - td.colSpan = 5; - const details = document.createElement("div"); - details.className = "details"; - details.append( - line("Type", course.course_type), - line("Tools", course.tools_languages || "Not specified"), - line("Desirable knowledge", course.desirable_knowledge || "None"), - line("Prelude", course.prelude || "Not specified"), - ); - td.appendChild(details); - row.appendChild(td); - return row; -} - -function line(label, value) { - const div = document.createElement("div"); - const strong = document.createElement("strong"); - strong.textContent = `${label}: `; - div.append(strong, document.createTextNode(value)); - return div; -} - -function render() { - table.replaceChildren(); - const visible = courses.filter(courseMatches); - if (!visible.length) { - const row = document.createElement("tr"); - const td = document.createElement("td"); - td.colSpan = 5; - td.className = "empty"; - td.textContent = "No courses found."; - row.appendChild(td); - table.appendChild(row); - setStatus("No courses found."); - return; - } - - visible.forEach((course) => { - const row = document.createElement("tr"); - row.className = "course-row"; - row.append(cell(course.semester), cell(course.course_code), cell(course.course_title), cell(course.credits)); - - const action = document.createElement("td"); - const remove = document.createElement("button"); - remove.type = "button"; - remove.className = "danger"; - remove.textContent = "Delete"; - remove.addEventListener("click", (event) => { - event.stopPropagation(); - deleteCourse(course); - }); - action.appendChild(remove); - row.appendChild(action); - - row.addEventListener("click", () => { - openId = openId === String(course.id) ? "" : String(course.id); - render(); - }); - table.appendChild(row); - if (openId === String(course.id)) table.appendChild(detailsRow(course)); - }); - setStatus(`${visible.length} course${visible.length === 1 ? "" : "s"}.`, "ready"); -} - -async function deleteCourse(course) { - if (!confirm(`Delete ${course.course_code || course.course_title}?`)) return; - setStatus("Deleting course..."); - const response = await fetch(`/api/courses/${course.id}`, { method: "DELETE" }); - if (!response.ok) throw new Error("Delete failed"); - courses = courses.filter((item) => item.id !== course.id); - if (openId === String(course.id)) openId = ""; - render(); -} - -async function loadCourses() { - setStatus("Loading courses..."); - const response = await fetch("/api/courses"); - if (!response.ok) throw new Error("Unable to load courses"); - const body = await response.json(); - courses = body.courses || []; - render(); -} - -semester.addEventListener("change", render); -search.addEventListener("input", render); - -loadCourses().catch((error) => { - setStatus(error instanceof Error ? error.message : "Unable to load courses.", "error"); -}); diff --git a/frontend/courses/index.html b/frontend/courses/index.html deleted file mode 100644 index 93de4e132277f5e67cb6cd6f5694b74017a374e3..0000000000000000000000000000000000000000 --- a/frontend/courses/index.html +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - Course Management - - - - -
-
-
-

Course Management

-

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

-
- -
- -
-
- - - -
- -
- - - - - - - - - - - -
SemesterCodeCourseCredits
-
-
-
- - diff --git a/frontend/form.html b/frontend/form.html deleted file mode 100644 index aed38b04847c1859187641560ab2009000829571..0000000000000000000000000000000000000000 --- a/frontend/form.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - Course Submission - PESU - - - Course Submission - - diff --git a/frontend/form/index.html b/frontend/form/index.html deleted file mode 100644 index 61baca42be85cba81dd5b72c09207ebd39de99fb..0000000000000000000000000000000000000000 --- a/frontend/form/index.html +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - Course Submission - PESU - - - - -
-
-

Course Submission

-

Fill in the course details below.

- -
-
- Faculty Details - - - - - - - - - e.g. Mathematics, Computer Science (MA, CS) -
- -
- Course Details - - - - - - - - - - - - -
- -
- Academic Content - - - Topics, units, or rough syllabus. - - - - - - - - - -
- - - Manage Courses - View Preview -
- - -
-
- - diff --git a/frontend/home/home.css b/frontend/home/home.css deleted file mode 100644 index 547085d2dc0b471a0f7e4769fdc8b64499d8b69c..0000000000000000000000000000000000000000 --- a/frontend/home/home.css +++ /dev/null @@ -1,70 +0,0 @@ -@import "../shared.css"; - -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -:root { - --bg: var(--page-bg); - --text: var(--pesu-blue); - --accent: var(--pesu-orange); -} - -body { - min-height: 100dvh; - display: flex; - align-items: center; - justify-content: center; - padding: 1rem; - background: var(--bg); - color: var(--text); - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; -} - -.home-shell { - width: 100%; - max-width: 760px; -} - -.home-panel { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 12px; - padding: 2rem; -} - -h1 { - font-size: 1.5rem; -} - -.surface-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 1rem; - margin-top: 1.5rem; -} - -.surface-link { - display: flex; - align-items: center; - justify-content: center; - min-height: 96px; - border: 1px solid var(--border); - border-radius: 8px; - color: var(--text); - background: var(--surface); - text-decoration: none; - font-weight: 700; -} - -.surface-link:hover { - border-color: var(--accent); -} - -@media (max-width: 640px) { - .surface-grid { - grid-template-columns: 1fr; - } -} diff --git a/frontend/index.html b/frontend/index.html index 7bc5ed871c4f5c5def522177388147172f561efd..92b31d885bae9a5a9a38cf142ee1dece9967b504 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,21 +3,97 @@ - PESU Curriculum Automation - + Course Submission — PESU + + -
-
-

PESU Curriculum Automation

- -
-
+
+
+

Course Submission

+

Fill in the course details below.

+ +
+
+ Faculty Details + + + + + + + + + e.g. Mathematics, Computer Science (MA, CS) + +
+ +
+ Course Details + + + + + + + + + + + + +
+ +
+ Academic Content + + + Topics, units, or rough syllabus. + + + + + + + + + +
+ + +
+ + +
+
diff --git a/frontend/live-editor.html b/frontend/live-editor.html deleted file mode 100644 index ae3898766f181eea2ab19a4400cfb97cbb81f670..0000000000000000000000000000000000000000 --- a/frontend/live-editor.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - Live Editor - PESU - - - Live Editor - - diff --git a/frontend/live-editor/index.html b/frontend/live-editor/index.html deleted file mode 100644 index 446605e16f0f768d7ea48dae1ae96f937dbbc3d7..0000000000000000000000000000000000000000 --- a/frontend/live-editor/index.html +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - Live Editor - PESU - - - -
-
- - - - Open -
- -
- - - - -
- - -
- -
-
- -
Loading...
-
- - -
- - - - diff --git a/frontend/live-editor/live-editor.css b/frontend/live-editor/live-editor.css deleted file mode 100644 index bf5d0bf739c7209d2da648d26477828b7ccc0174..0000000000000000000000000000000000000000 --- a/frontend/live-editor/live-editor.css +++ /dev/null @@ -1,612 +0,0 @@ -@import "../shared.css"; - -* { - box-sizing: border-box; -} - -body { - margin: 0; - height: 100dvh; - display: grid; - grid-template-rows: auto 1fr; - background: #f0f2f5; - color: #111827; - font-family: Arial, sans-serif; - overflow: hidden; -} - -.toolbar { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - min-height: 56px; - padding: 10px 12px; - background: #fff; - border-bottom: 1px solid #d1d5db; -} - -.toolbar-group { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; - padding-right: 8px; - border-right: 1px solid #e5e7eb; -} - -.toolbar-group:last-of-type { - border-right: 0; -} - -select, -input, -button, -a { - height: 34px; - border: 1px solid #9ca3af; - border-radius: 4px; - background-color: #fff; - color: #111827; - font-size: 14px; -} - -select { - min-width: 150px; - max-width: 280px; - background-color: #f8fafc; - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 8px center; - background-size: 14px; - padding-right: 28px; - cursor: pointer; -} - -button { - background: #e5e7eb; -} - -.danger { - color: #dc2626; - background: #fef2f2; - border-color: #fecaca; -} - -input { - min-width: 0; - padding: 0 8px; -} - -.course-controls { - flex: 1 1 520px; -} - -.course-controls #course { - flex: 1 1 240px; -} - -.version-controls { - flex: 0 1 620px; -} - -.version-controls input { - flex: 1 1 160px; -} - -.version-controls select { - flex: 1 1 200px; -} - -button, -a { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0 12px; - text-decoration: none; - cursor: pointer; -} - -button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.primary { - background: #00377b; - color: #fff; - border-color: #00377b; -} - -#status { - margin-left: auto; - max-width: 360px; - min-height: 28px; - display: inline-flex; - align-items: center; - padding: 4px 8px; - border: 1px solid #d1d5db; - border-radius: 6px; - background: #f8fafc; - color: #374151; - font-size: 13px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -#status[hidden] { - display: none; -} - -#status.error { - border-color: #fecaca; - background: #fef2f2; - color: #991b1b; -} - -#status.ready { - border-color: #bbf7d0; - background: #f0fdf4; - color: #166534; -} - -.chat-status { - padding: 4px 12px; - font-size: 12px; - color: #6b7280; - border-bottom: 1px solid #e5e7eb; - background: #fff; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - min-height: 0; - line-height: 20px; -} - -.workspace { - display: grid; - grid-template-columns: minmax(0, 1fr) 460px; - gap: 12px; - min-height: 0; - padding: 12px; -} - -.preview-pane, -.side-pane { - min-height: 0; - border: 1px solid #d1d5db; - border-radius: 6px; - background: #fff; - overflow: hidden; -} - -.preview-pane { - position: relative; -} - -iframe { - display: block; - width: 100%; - height: 100%; - border: 0; -} - -.loading { - position: absolute; - inset: 0; - display: none; - place-items: center; - background: rgba(255, 255, 255, 0.82); - color: #00377b; - font-weight: 700; -} - -.loading.active { - display: grid; -} - -.side-pane { - display: grid; - grid-template-rows: auto 1fr; - min-width: 0; -} - -.tabs { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - border-bottom: 1px solid #d1d5db; -} - -.tab { - width: 100%; - border: 0; - border-radius: 0; - margin: 0; - color: #00377b; - font-weight: 700; -} - -.tab.active { - background: #00377b; - color: #fff; -} - -.panel { - display: none; - min-height: 0; -} - -.panel.active { - display: grid; - min-height: 0; -} - -.chat-panel { - grid-template-rows: auto 1fr auto; -} - -.thread-bar { - display: grid; - grid-template-columns: 1fr auto auto; - gap: 8px; - padding: 10px; - border-bottom: 1px solid #d1d5db; - background: #fff; -} - -.thread-selector { - display: flex; - align-items: center; - gap: 4px; - min-width: 0; -} - -.thread-selector select { - flex: 1; - min-width: 0; - max-width: none; - width: 100%; -} - -.icon-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 34px; - height: 34px; - padding: 0; - border: 1px solid #9ca3af; - border-radius: 4px; - background: #fff; - cursor: pointer; - color: #6b7280; - flex-shrink: 0; -} - -.icon-btn:hover { - background: #f3f4f6; - color: #00377b; -} - -.chat-log { - min-height: 0; - overflow: auto; - padding: 12px; - background: #f8fafc; -} - -.message { - max-width: 92%; - margin-bottom: 10px; - padding: 9px 10px; - border: 1px solid #d1d5db; - border-radius: 6px; - background: #fff; - color: #111827; - font-size: 14px; - line-height: 1.4; - white-space: pre-wrap; - overflow: hidden; - overflow-wrap: break-word; -} - -.message.error { - border-color: #fecaca; - background: #fef2f2; -} - -.message.user { - margin-left: auto; - background: #eaf2ff; - border-color: #b9d1f5; -} - -.message-time { - margin-top: 4px; - color: #6b7280; - font-size: 11px; - white-space: normal; -} - -.message-body { - white-space: pre-wrap; - overflow-wrap: break-word; - word-break: break-word; -} - -.message-body hr { - border: 0; - border-top: 1px solid #d1d5db; - margin: 8px 0; -} - -.message-body ul { - margin: 4px 0; - padding-left: 20px; -} - -.message-body li { - list-style: disc; -} - -.message-body code { - padding: 1px 4px; - border: 1px solid #d1d5db; - border-radius: 3px; - background: #f3f4f6; - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; - font-size: 12px; -} - -.message-body a { - color: #00377b; - text-decoration: underline; -} - -.attachments { - display: grid; - gap: 6px; - margin-top: 8px; -} - -.attachment { - display: grid; - grid-template-columns: 42px 1fr auto; - align-items: center; - gap: 8px; - padding: 6px; - border: 1px solid #d1d5db; - border-radius: 4px; - background: #fff; - font-size: 12px; -} - -.attachment img { - width: 42px; - height: 42px; - object-fit: cover; - border: 1px solid #d1d5db; -} - -.attachment-icon { - display: grid; - place-items: center; - width: 42px; - height: 42px; - border: 1px solid #d1d5db; - color: #00377b; - font-weight: 700; -} - -.attachment-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.attachment-meta { - color: #6b7280; - font-size: 11px; -} - -.attachment-meta.ready { - color: #166534; -} - -.attachment-meta.failed, -.attachment-meta.unsupported { - color: #991b1b; -} - -.attachment-remove { - height: 26px; - padding: 0 8px; -} - -.composer { - display: grid; - gap: 8px; - padding: 10px; - border-top: 1px solid #d1d5db; - background: #fff; -} - -.composer textarea, -.fields-panel textarea { - width: 100%; - border: 1px solid #d1d5db; - border-radius: 4px; - color: #111827; - font: 13px/1.45 Arial, sans-serif; -} - -.composer textarea { - min-height: 84px; - resize: vertical; - padding: 9px; -} - -.composer-actions { - display: grid; - grid-template-columns: auto 1fr auto; - gap: 8px; - align-items: center; -} - -.fields-panel { - grid-template-rows: 1fr auto; -} - -.review-panel { - grid-template-rows: auto 1fr auto; - min-height: 0; -} - -.review-summary { - display: grid; - gap: 6px; - padding: 12px; - border-bottom: 1px solid #d1d5db; - font-size: 13px; -} - -.review-summary strong { - color: #00377b; -} - -.document-draft { - display: grid; - gap: 8px; - padding: 10px; - border-bottom: 1px solid #d1d5db; -} - -.draft-loader { - display: grid; - grid-template-columns: minmax(0, 1fr) 118px; - gap: 8px; -} - -.diff-view { - min-height: 0; - overflow: auto; - margin: 0; - background: #fff; - color: #24292f; - font: 12px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; - border-top: 1px solid #d1d5db; - border-bottom: 1px solid #d1d5db; -} - -.diff-empty { - padding: 12px; - color: #6b7280; -} - -.diff-row { - display: grid; - grid-template-columns: 34px minmax(0, 1fr); - min-height: 25px; - border-bottom: 1px solid rgba(31, 35, 40, 0.06); -} - -.diff-row.added { - background: #dafbe1; -} - -.diff-row.removed { - background: #ffebe9; -} - -.diff-row.meta, -.diff-row.header, -.diff-row.title { - background: #f6f8fa; - color: #57606a; - font-weight: 700; -} - -.diff-row.header { - color: #0969da; -} - -.diff-sign { - display: flex; - align-items: flex-start; - justify-content: center; - padding-top: 3px; - color: #57606a; - user-select: none; - border-right: 1px solid rgba(31, 35, 40, 0.08); -} - -.diff-code { - padding: 3px 8px; - white-space: pre-wrap; - overflow-wrap: anywhere; -} - -.fields-panel textarea { - min-height: 0; - resize: none; - border: 0; - border-radius: 0; - padding: 12px; - font-family: ui-monospace, SFMono-Regular, Consolas, monospace; -} - -.field-actions { - display: flex; - gap: 8px; - justify-content: flex-end; - padding: 10px; - border-top: 1px solid #d1d5db; -} - -@media (max-width: 980px) { - .toolbar { - flex-wrap: wrap; - } - - .toolbar-group { - width: 100%; - border-right: 0; - padding-right: 0; - } - - .course-controls, - .version-controls { - flex-wrap: wrap; - } - - #status { - width: 100%; - max-width: none; - margin-left: 0; - } - - .workspace { - grid-template-columns: 1fr; - height: auto; - } - - .preview-pane, - .side-pane { - height: 72dvh; - } - - .thread-bar { - grid-template-columns: 1fr; - } - - .thread-selector { - width: 100%; - } - - #chat-title { - width: 100%; - } -} diff --git a/frontend/live-editor/live-editor.js b/frontend/live-editor/live-editor.js deleted file mode 100644 index 9aaad1c7c9846a83b3ab59da59111d313d274361..0000000000000000000000000000000000000000 --- a/frontend/live-editor/live-editor.js +++ /dev/null @@ -1,933 +0,0 @@ -const semester = document.getElementById("semester"); -const course = document.getElementById("course"); -const viewMode = document.getElementById("view-mode"); -const versionName = document.getElementById("version-name"); -const saveVersion = document.getElementById("save-version"); -const versionSelect = document.getElementById("version-select"); -const restoreVersion = document.getElementById("restore-version"); -const preview = document.getElementById("preview"); -const viewer = document.getElementById("viewer"); -const loading = document.getElementById("loading"); -const statusText = document.getElementById("status"); -const chatTab = document.getElementById("chat-tab"); -const fieldsTab = document.getElementById("fields-tab"); -const reviewTab = document.getElementById("review-tab"); -const chatPanel = document.getElementById("chat-panel"); -const fieldsPanel = document.getElementById("fields-panel"); -const reviewPanel = document.getElementById("review-panel"); -const chatSession = document.getElementById("chat-session"); -const chatTitle = document.getElementById("chat-title"); -const renameChat = document.getElementById("rename-chat"); -const deleteChat = document.getElementById("delete-chat"); -const newChat = document.getElementById("new-chat"); -const chatLog = document.getElementById("chat-log"); -const chatStatus = document.getElementById("chat-status"); -const message = document.getElementById("message"); -const attach = document.getElementById("attach"); -const files = document.getElementById("files"); -const draftAttachments = document.getElementById("draft-attachments"); -const send = document.getElementById("send"); -const editor = document.getElementById("editor"); -const draft = document.getElementById("draft"); -const save = document.getElementById("save"); -const courseDraftSelect = document.getElementById("course-draft-select"); -const loadCourseDraft = document.getElementById("load-course-draft"); -const documentDraftSelect = document.getElementById("document-draft-select"); -const loadDocumentDraft = document.getElementById("load-document-draft"); -const reviewSummary = document.getElementById("review-summary"); -const diffView = document.getElementById("diff-view"); -const previewDraft = document.getElementById("preview-draft"); -const applyDraft = document.getElementById("apply-draft"); -let activeCourseId = ""; -let activeDraftId = ""; -let activeSessionId = ""; -let queuedFiles = []; -let versionMode = false; -const initialParams = new URLSearchParams(location.search); - -function setStatus(text, kind = "") { - statusText.textContent = text || ""; - statusText.className = kind; -} - -function showError(error, fallback = "Action failed.") { - loading.classList.remove("active"); - const text = error instanceof Error ? error.message : fallback; - setStatus(text || fallback, "error"); -} - -async function errorMessage(response, fallback) { - try { - const body = await response.json(); - return body.detail || body.message || fallback; - } catch { - return fallback; - } -} - -async function courseIds(sem) { - const response = await fetch(`/api/preview/semester/${sem}/courses`); - if (!response.ok) return []; - const body = await response.json(); - return body.course_ids || []; -} - -async function firstAvailableSemester() { - for (let sem = 1; sem <= 8; sem += 1) { - if ((await courseIds(sem)).length) return String(sem); - } - return "1"; -} - -function setTab(name) { - const chat = name === "chat"; - const fields = name === "fields"; - const review = name === "review"; - chatTab.classList.toggle("active", chat); - fieldsTab.classList.toggle("active", fields); - reviewTab.classList.toggle("active", review); - chatTab.setAttribute("aria-selected", String(chat)); - fieldsTab.setAttribute("aria-selected", String(fields)); - reviewTab.setAttribute("aria-selected", String(review)); - chatPanel.classList.toggle("active", chat); - fieldsPanel.classList.toggle("active", fields); - reviewPanel.classList.toggle("active", review); - if (review) refreshDraftSelectors().catch(() => {}); -} - -function chatKey() { - return `pesu-live-editor-session:${activeCourseId || "document"}`; -} - -function option(value, text) { - const item = document.createElement("option"); - item.value = value; - item.textContent = text; - return item; -} - -function versionLabel(item) { - const year = item.academic_year ? ` ${item.academic_year}` : ""; - return `${item.name}${year}`; -} - -async function refreshVersions() { - const response = await fetch("/api/versions"); - if (!response.ok) return; - const body = await response.json(); - versionSelect.replaceChildren(...(body.versions || []).map((item) => option(String(item.id), versionLabel(item)))); -} - -async function saveCurrentVersion() { - const name = versionName.value.trim(); - if (!name) { - setStatus("Version name is required.", "error"); - return; - } - saveVersion.disabled = true; - try { - setStatus("Saving full version..."); - const response = await fetch("/api/versions", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name }), - }); - if (!response.ok) throw new Error(await errorMessage(response, "Version save failed")); - const body = await response.json(); - versionName.value = ""; - await refreshVersions(); - versionSelect.value = String(body.version.id); - setStatus(`Saved ${body.courses} courses.`, "ready"); - } finally { - saveVersion.disabled = false; - } -} - -async function restoreSelectedVersion() { - if (!versionSelect.value) return; - restoreVersion.disabled = true; - try { - setStatus("Restoring full version..."); - const response = await fetch(`/api/versions/${versionSelect.value}/restore`, { method: "POST" }); - if (!response.ok) throw new Error(await errorMessage(response, "Version restore failed")); - const body = await response.json(); - if (activeCourseId) await loadCourse(activeCourseId); - setStatus(`Restored ${body.courses_restored} courses. Archived ${body.courses_archived || 0}.`, "ready"); - } finally { - restoreVersion.disabled = false; - } -} - -function chatScopeQuery() { - return activeCourseId ? `?refined_id=${encodeURIComponent(activeCourseId)}` : ""; -} - -function sessionTitle(item) { - if (item.title) return item.title; - const date = item.created_at ? new Date(item.created_at).toLocaleDateString() : ""; - return date ? `Thread ${item.id} - ${date}` : `Thread ${item.id}`; -} - -async function refreshChatSessions() { - const response = await fetch(`/api/chat/sessions${chatScopeQuery()}`); - if (!response.ok) return; - const body = await response.json(); - const sessions = body.sessions || []; - chatSession.replaceChildren(...sessions.map((item) => option(String(item.id), sessionTitle(item)))); - if (activeSessionId) chatSession.value = activeSessionId; - const selected = sessions.find((item) => String(item.id) === chatSession.value); - if (chatTitle.hidden) chatTitle.value = selected?.title || ""; -} - -async function createChatSession() { - const response = await fetch("/api/chat/sessions", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(activeCourseId ? { refined_id: Number(activeCourseId), title: statusText.textContent } : { title: "Full Document" }), - }); - if (!response.ok) throw new Error("Unable to create chat session"); - const body = await response.json(); - activeSessionId = String(body.session.id); - localStorage.setItem(chatKey(), activeSessionId); - await refreshChatSessions(); - chatSession.value = activeSessionId; - return activeSessionId; -} - -async function renameActiveChat() { - if (!activeSessionId || !chatTitle.value.trim()) { - exitRenameMode(); - return; - } - const response = await fetch(`/api/chat/sessions/${activeSessionId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title: chatTitle.value.trim() }), - }); - if (!response.ok) throw new Error(await errorMessage(response, "Rename failed")); - await refreshChatSessions(); - setStatus("Thread renamed.", "ready"); - exitRenameMode(); -} - -function enterRenameMode() { - if (!activeSessionId) return; - const currentTitle = chatSession.options[chatSession.selectedIndex]?.text || ""; - chatTitle.value = currentTitle; - chatTitle.hidden = false; - document.querySelector(".thread-selector").hidden = true; - chatTitle.focus(); - chatTitle.select(); -} - -function exitRenameMode() { - chatTitle.hidden = true; - const selector = document.querySelector(".thread-selector"); - if (selector) selector.hidden = false; -} - -async function deleteActiveChat() { - if (!activeSessionId) return; - if (!confirm("Delete this chat thread permanently?")) return; - const deleted = activeSessionId; - const response = await fetch(`/api/chat/sessions/${deleted}`, { method: "DELETE" }); - if (!response.ok) throw new Error(await errorMessage(response, "Delete failed")); - localStorage.removeItem(chatKey()); - activeSessionId = ""; - chatLog.replaceChildren(); - await ensureChatSession(); - await renderMessages(); - setStatus("Thread deleted.", "ready"); -} - -async function ensureChatSession() { - const key = chatKey(); - const existing = localStorage.getItem(key); - if (existing) { - activeSessionId = existing; - await refreshChatSessions(); - if ([...chatSession.options].some((item) => item.value === existing)) return existing; - localStorage.removeItem(key); - activeSessionId = ""; - } - await refreshChatSessions(); - if (chatSession.value) { - activeSessionId = chatSession.value; - localStorage.setItem(key, activeSessionId); - return activeSessionId; - } - return createChatSession(); -} - -async function loadMessages() { - if (!activeSessionId) return []; - const response = await fetch(`/api/chat/sessions/${activeSessionId}/messages`); - if (!response.ok) { - localStorage.removeItem(chatKey()); - activeSessionId = ""; - await ensureChatSession(); - return []; - } - const body = await response.json(); - return body.messages || []; -} - -function formatSize(bytes) { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - -function attachmentNode(file, index, removable) { - const item = document.createElement("div"); - item.className = "attachment"; - const visual = file.preview ? document.createElement("img") : document.createElement("div"); - if (file.preview) { - visual.src = file.preview; - visual.alt = ""; - } else { - visual.className = "attachment-icon"; - visual.textContent = "FILE"; - } - const text = document.createElement("div"); - const name = document.createElement("div"); - name.className = "attachment-name"; - name.textContent = file.name; - const meta = document.createElement("div"); - meta.className = "attachment-meta"; - if (file.status) meta.classList.add(file.status); - const status = file.status ? ` - ${file.status}` : ""; - const extracted = file.extracted_chars ? ` - ${file.extracted_chars} chars` : ""; - meta.textContent = `${file.type || "file"} - ${formatSize(file.size || 0)}${status}${extracted}`; - text.append(name, meta); - item.append(visual, text); - if (removable) { - const remove = document.createElement("button"); - remove.className = "attachment-remove"; - remove.type = "button"; - remove.textContent = "Remove"; - remove.addEventListener("click", () => { - queuedFiles.splice(index, 1); - renderDraftAttachments(); - }); - item.appendChild(remove); - } else { - item.appendChild(document.createElement("span")); - } - return item; -} - -function renderDraftAttachments() { - draftAttachments.replaceChildren(...queuedFiles.map((file, index) => attachmentNode(file, index, true))); -} - -function messageNode(item) { - const bubble = document.createElement("div"); - bubble.className = `message ${item.role}`; - const content = document.createElement("div"); - content.className = "message-body"; - renderMessageContent(content, item.content || ""); - bubble.appendChild(content); - const attachments = item.attachments || item.metadata?.attachments || []; - if (attachments.length) { - const list = document.createElement("div"); - list.className = "attachments"; - attachments.forEach((file) => list.appendChild(attachmentNode(file, 0, false))); - bubble.appendChild(list); - } - const time = document.createElement("div"); - time.className = "message-time"; - time.textContent = new Date(item.created_at || Date.now()).toLocaleString(); - bubble.appendChild(time); - return { bubble, content }; -} - -function renderInline(parent, text) { - const pattern = /(\*\*[^*]+\*\*|`[^`]+`|https?:\/\/[^\s<>"']+)/g; - let last = 0; - for (const match of text.matchAll(pattern)) { - if (match.index > last) parent.appendChild(document.createTextNode(text.slice(last, match.index))); - const token = match[0]; - if (token.startsWith("**")) { - const strong = document.createElement("strong"); - strong.textContent = token.slice(2, -2); - parent.appendChild(strong); - } else if (token.startsWith("`")) { - const code = document.createElement("code"); - code.textContent = token.slice(1, -1); - parent.appendChild(code); - } else { - const link = document.createElement("a"); - const url = token.replace(/[.,;:)}!?]+$/, "").replace(/[<>]+/g, ""); - const trailing = token.slice(url.length); - link.href = url; - link.target = "_blank"; - link.rel = "noreferrer"; - link.textContent = url; - parent.appendChild(link); - if (trailing) parent.appendChild(document.createTextNode(trailing)); - } - last = match.index + token.length; - } - if (last < text.length) parent.appendChild(document.createTextNode(text.slice(last))); -} - -function renderMessageContent(target, value) { - target.replaceChildren(); - const text = String(value || ""); - const lines = text.split("\n"); - let inList = false; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const trimmed = line.trim(); - if (!trimmed) { - inList = false; - continue; - } - if (/^---+$/.test(trimmed)) { - if (inList) inList = false; - target.appendChild(document.createElement("hr")); - continue; - } - if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) { - if (!inList) { - inList = true; - target.appendChild(document.createElement("ul")); - } - const li = document.createElement("li"); - renderInline(li, trimmed.slice(2)); - target.lastElementChild.appendChild(li); - continue; - } - if (inList) inList = false; - const block = document.createElement("div"); - renderInline(block, line); - target.appendChild(block); - } -} - -function appendMessage(item) { - const node = messageNode(item); - chatLog.appendChild(node.bubble); - chatLog.scrollTop = chatLog.scrollHeight; - return node; -} - -async function renderMessages() { - const messages = await loadMessages(); - chatLog.replaceChildren(); - messages.forEach((item) => appendMessage(item)); -} - -function parseEvent(raw) { - let event = "message"; - const data = []; - raw.split("\n").forEach((line) => { - if (line.startsWith("event:")) event = line.slice(6).trim(); - if (line.startsWith("data:")) data.push(line.slice(5).trim()); - }); - return { event, data: data.length ? JSON.parse(data.join("\n")) : {} }; -} - -async function readEventStream(response, onEvent) { - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - let index = buffer.indexOf("\n\n"); - while (index >= 0) { - const raw = buffer.slice(0, index).trim(); - buffer = buffer.slice(index + 2); - if (raw) onEvent(parseEvent(raw)); - index = buffer.indexOf("\n\n"); - } - } -} - -function resetReview() { - activeDraftId = ""; - reviewSummary.replaceChildren(document.createTextNode("No draft loaded.")); - renderDiff(""); - previewDraft.disabled = true; - applyDraft.disabled = true; -} - -function renderDiff(text) { - diffView.replaceChildren(); - if (!text) { - const empty = document.createElement("div"); - empty.className = "diff-empty"; - empty.textContent = "No diff loaded."; - diffView.appendChild(empty); - return; - } - - text.split("\n").forEach((line) => { - let kind = "context"; - let sign = ""; - let code = line; - - if (line.startsWith("Course draft ")) { - kind = "title"; - } else if (line.startsWith("@@")) { - kind = "header"; - } else if (line.startsWith("---") || line.startsWith("+++")) { - kind = "meta"; - } else if (line.startsWith("-")) { - kind = "removed"; - sign = "-"; - code = line.slice(1).replace(/^ /, ""); - } else if (line.startsWith("+")) { - kind = "added"; - sign = "+"; - code = line.slice(1).replace(/^ /, ""); - } - - const row = document.createElement("div"); - row.className = `diff-row ${kind}`; - const signCell = document.createElement("div"); - signCell.className = "diff-sign"; - signCell.textContent = sign; - const codeCell = document.createElement("div"); - codeCell.className = "diff-code"; - codeCell.textContent = code; - row.append(signCell, codeCell); - diffView.appendChild(row); - }); -} - -function summaryLine(label, value) { - const line = document.createElement("div"); - const strong = document.createElement("strong"); - strong.textContent = `${label}: `; - line.append(strong, document.createTextNode(value)); - return line; -} - -function renderDraftReview(draftRow) { - const summary = draftRow.diff_summary || {}; - activeDraftId = String(draftRow.id || ""); - reviewSummary.replaceChildren( - summaryLine("Draft", activeDraftId || "unsaved"), - summaryLine("Status", draftRow.status || ""), - summaryLine("Change", `${summary.change_percent || 0}%`), - summaryLine("Syllabus change", `${summary.syllabus_change_percent || 0}%`), - summaryLine("Topics added", (summary.topics_added || []).join(", ") || "None"), - summaryLine("Topics removed", (summary.topics_removed || []).join(", ") || "None"), - summaryLine("Protected changes", (summary.protected_changes || []).join(", ") || "None"), - ); - renderDiff(summary.unified_diff || "No changes."); - previewDraft.disabled = !activeDraftId; - applyDraft.disabled = draftRow.status !== "proposed" || Boolean((summary.protected_changes || []).length); -} - -function showCourseDraft(draftRow) { - renderDraftReview(draftRow); - viewer.src = `/api/agent/drafts/${draftRow.id}/preview`; - preview.href = `/api/agent/drafts/${draftRow.id}/preview`; - setStatus("Draft ready for review.", "ready"); - refreshDraftSelectors().catch(showError); - setTab("review"); -} - -async function loadCourseDraftById(id) { - if (!id) return; - setStatus("Loading course draft..."); - const response = await fetch(`/api/agent/drafts/${id}`); - if (!response.ok) throw new Error(await errorMessage(response, "Course draft not found")); - const body = await response.json(); - showCourseDraft(body.draft); - setStatus("Course draft loaded.", "ready"); -} - -async function loadDocumentDraftById(id) { - if (!id) return; - setStatus("Loading document draft..."); - const response = await fetch(`/api/agent/document-drafts/${id}`); - if (!response.ok) throw new Error(await errorMessage(response, "Document draft not found")); - const body = await response.json(); - const summary = body.document_draft.diff_summary || {}; - activeDraftId = ""; - reviewSummary.replaceChildren( - summaryLine("Document draft", id), - summaryLine("Status", body.document_draft.status || ""), - summaryLine("Courses changed", String(summary.courses_changed || 0)), - summaryLine("Removed-topic courses", String(summary.courses_with_removed_topics || 0)), - summaryLine("Protected-change courses", String(summary.courses_with_protected_changes || 0)), - summaryLine("Max syllabus change", `${summary.max_syllabus_change_percent || 0}%`), - ); - renderDiff( - (body.drafts || []) - .map((item) => `Course draft ${item.id}\n${item.diff_summary?.unified_diff || ""}`) - .join("\n\n"), - ); - previewDraft.disabled = true; - applyDraft.disabled = true; - viewer.src = `/api/agent/document-drafts/${id}/preview`; - preview.href = `/api/agent/document-drafts/${id}/preview`; - await refreshDraftSelectors(); - documentDraftSelect.value = String(id); - setTab("review"); - setStatus("Document draft loaded.", "ready"); -} - -function courseDraftLabel(item) { - const title = item.course_title || `Course ${item.refined_id}`; - const code = item.course_code ? `${item.course_code} - ` : ""; - return `${item.id}: ${code}${title} (${item.status})`; -} - -function documentDraftLabel(item) { - const name = item.uploaded_document_id || item.change_reason || `Document draft ${item.id}`; - return `${item.id}: ${name} (${item.status})`; -} - -async function refreshDraftSelectors() { - const [courseResponse, documentResponse] = await Promise.all([ - fetch("/api/agent/drafts"), - fetch("/api/agent/document-drafts"), - ]); - if (courseResponse.ok) { - const body = await courseResponse.json(); - courseDraftSelect.replaceChildren(...(body.drafts || []).map((item) => option(String(item.id), courseDraftLabel(item)))); - } - if (documentResponse.ok) { - const body = await documentResponse.json(); - documentDraftSelect.replaceChildren(...(body.document_drafts || []).map((item) => option(String(item.id), documentDraftLabel(item)))); - } - if (activeDraftId) courseDraftSelect.value = activeDraftId; -} - -function filePreview(file) { - return new Promise((resolve) => { - if (!file.type.startsWith("image/") || file.size > 900000) { - resolve(""); - return; - } - const reader = new FileReader(); - reader.onload = () => resolve(String(reader.result || "")); - reader.onerror = () => resolve(""); - reader.readAsDataURL(file); - }); -} - -async function queueFiles(fileList) { - await ensureChatSession(); - setStatus("Uploading attachments..."); - const pending = await Promise.all( - Array.from(fileList).map(async (file) => ({ - name: file.name, - type: file.type, - size: file.size, - status: "uploading", - preview: await filePreview(file), - })), - ); - queuedFiles = queuedFiles.concat(pending); - renderDraftAttachments(); - const form = new FormData(); - Array.from(fileList).forEach((file) => form.append("files", file)); - const response = await fetch(`/api/chat/sessions/${activeSessionId}/attachments`, { - method: "POST", - body: form, - }); - if (!response.ok) { - throw new Error(await errorMessage(response, "Attachment upload failed")); - } - const body = await response.json(); - const uploaded = (body.attachments || []).map((file, index) => ({ - ...file, - preview: pending[index]?.preview || "", - })); - queuedFiles.splice(queuedFiles.length - pending.length, pending.length, ...uploaded); - renderDraftAttachments(); - setStatus("Attachments ready.", "ready"); - files.value = ""; -} - -async function loadCourse(id) { - activeCourseId = String(id); - versionMode = false; - save.disabled = false; - course.disabled = false; - semester.disabled = false; - resetReview(); - loading.classList.add("active"); - const response = await fetch(`/api/refined/${id}`); - if (!response.ok) throw new Error("Unable to load course"); - const row = await response.json(); - editor.value = JSON.stringify(row.fields || {}, null, 2); - viewer.src = `/api/preview/course/${id}`; - preview.href = `/api/preview/course/${id}`; - const title = row.fields?.course_title || `Course ${id}`; - setStatus(title); - const selected = course.querySelector(`option[value="${id}"]`); - if (selected) selected.textContent = title; - queuedFiles = []; - renderDraftAttachments(); - await ensureChatSession(); - await renderMessages(); -} - -async function loadVersionCourse(versionId, refinedId) { - activeCourseId = String(refinedId); - versionMode = true; - save.disabled = true; - course.disabled = true; - semester.disabled = true; - resetReview(); - loading.classList.add("active"); - const response = await fetch(`/api/versions/${versionId}/courses/${refinedId}`); - if (!response.ok) throw new Error("Unable to load version course"); - const body = await response.json(); - editor.value = JSON.stringify(body.fields || {}, null, 2); - viewer.src = `/api/versions/${versionId}/courses/${refinedId}/preview`; - preview.href = viewer.src; - setStatus(`${body.version.name}: ${body.fields?.course_title || `Course ${refinedId}`}`); - queuedFiles = []; - renderDraftAttachments(); - await ensureChatSession(); - await renderMessages(); -} - -async function loadDocumentPreview() { - activeCourseId = ""; - activeDraftId = ""; - versionMode = false; - viewMode.value = "document"; - save.disabled = true; - course.disabled = true; - semester.disabled = true; - editor.value = ""; - resetReview(); - chatStatus.textContent = ""; - loading.classList.add("active"); - viewer.src = "/api/preview/pdf"; - preview.href = "/api/preview/pdf"; - setStatus("Full Document"); - await ensureChatSession(); - await renderMessages(); -} - -async function loadSemester(sem) { - versionMode = false; - save.disabled = false; - semester.value = sem; - course.replaceChildren(); - editor.value = ""; - viewer.removeAttribute("src"); - preview.removeAttribute("href"); - loading.classList.add("active"); - setStatus("Loading..."); - - const ids = await courseIds(sem); - if (!ids.length) { - loading.classList.remove("active"); - setStatus(`No refined courses found for Semester ${sem}.`); - return; - } - - course.replaceChildren(...ids.map((id) => option(id, `Course ${id}`))); - await loadCourse(ids[0]); -} - -chatTab.addEventListener("click", () => setTab("chat")); -fieldsTab.addEventListener("click", () => setTab("fields")); -reviewTab.addEventListener("click", () => setTab("review")); -viewer.addEventListener("load", () => loading.classList.remove("active")); -semester.addEventListener("change", () => loadSemester(semester.value).catch(showError)); -course.addEventListener("change", () => loadCourse(course.value).catch(showError)); -viewMode.addEventListener("change", async () => { - try { - if (viewMode.value === "document") { - await loadDocumentPreview(); - return; - } - course.disabled = false; - semester.disabled = false; - await loadSemester(semester.value); - } catch (error) { - showError(error); - } -}); -attach.addEventListener("click", () => files.click()); -files.addEventListener("change", () => queueFiles(files.files).catch(showError)); -saveVersion.addEventListener("click", () => saveCurrentVersion().catch((error) => { - showError(error, "Version save failed."); -})); -restoreVersion.addEventListener("click", () => restoreSelectedVersion().catch((error) => { - showError(error, "Version restore failed."); -})); - -chatSession.addEventListener("change", async () => { - activeSessionId = chatSession.value; - localStorage.setItem(chatKey(), activeSessionId); - await refreshChatSessions(); - await renderMessages(); -}); - -renameChat.addEventListener("click", () => enterRenameMode()); -chatTitle.addEventListener("keydown", async (e) => { - if (e.key === "Enter") { - e.preventDefault(); - try { - await renameActiveChat(); - } catch (error) { - showError(error); - } - } - if (e.key === "Escape") { - exitRenameMode(); - } -}); -chatTitle.addEventListener("blur", () => { - setTimeout(() => { - if (!chatTitle.hidden) exitRenameMode(); - }, 150); -}); -deleteChat.addEventListener("click", () => deleteActiveChat().catch(showError)); - -newChat.addEventListener("click", async () => { - localStorage.removeItem(chatKey()); - activeSessionId = ""; - chatLog.replaceChildren(); - await createChatSession(); - await renderMessages(); -}); - -send.addEventListener("click", async () => { - const content = message.value.trim(); - if (!content && !queuedFiles.length) return; - send.disabled = true; - let assistant = null; - try { - await ensureChatSession(); - const attachments = queuedFiles; - appendMessage({ - role: "user", - content, - attachments, - created_at: new Date().toISOString(), - }); - message.value = ""; - queuedFiles = []; - renderDraftAttachments(); - - const response = await fetch(`/api/chat/sessions/${activeSessionId}/messages`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ content, metadata: { attachments } }), - }); - if (!response.ok) { - throw new Error(await errorMessage(response, "Chat failed")); - } - - let answer = ""; - await readEventStream(response, ({ event, data }) => { - if (event === "status") { chatStatus.textContent = data.message || ""; } - if (event === "token") { - if (!assistant) assistant = appendMessage({ role: "assistant", content: "", created_at: new Date().toISOString() }); - answer += data.text || ""; - renderMessageContent(assistant.content, answer); - chatLog.scrollTop = chatLog.scrollHeight; - } - if (event === "draft" && data.draft) { - if (!assistant) assistant = appendMessage({ role: "assistant", content: "", created_at: new Date().toISOString() }); - if (!answer) renderMessageContent(assistant.content, "Draft ready for review."); - showCourseDraft(data.draft); - } - if (event === "document_draft" && data.document_draft) { - if (!assistant) assistant = appendMessage({ role: "assistant", content: "", created_at: new Date().toISOString() }); - if (!answer) renderMessageContent(assistant.content, "Document draft ready for review."); - loadDocumentDraftById(data.document_draft.id).catch(showError); - } - if (event === "error") throw new Error(data.message || "Chat failed"); - if (event === "done") { chatStatus.textContent = ""; setStatus("Response saved.", "ready"); } - }); - } catch (error) { - const text = error instanceof Error ? error.message : "Chat failed"; - chatStatus.textContent = text; - setStatus(text, "error"); - if (assistant) { - assistant.bubble.classList.add("error"); - renderMessageContent(assistant.content, text); - } - } finally { - send.disabled = false; - } -}); - -draft.addEventListener("click", async () => { - const refinedId = activeCourseId || course.value; - if (!refinedId || viewMode.value !== "course") return; - setStatus("Creating draft..."); - const parsed = JSON.parse(editor.value); - const response = await fetch("/api/agent/drafts", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refined_id: Number(refinedId), fields: parsed, reason: versionMode ? "Version rollback draft" : "Live editor draft" }), - }); - if (!response.ok) { - throw new Error(await errorMessage(response, "Draft failed")); - } - const body = await response.json(); - showCourseDraft(body.draft); -}); - -previewDraft.addEventListener("click", () => { - if (!activeDraftId) return; - viewer.src = `/api/agent/drafts/${activeDraftId}/preview`; - preview.href = `/api/agent/drafts/${activeDraftId}/preview`; -}); - -applyDraft.addEventListener("click", async () => { - if (!activeDraftId) return; - setStatus("Applying draft..."); - const response = await fetch(`/api/agent/drafts/${activeDraftId}/apply`, { method: "POST" }); - if (!response.ok) { - throw new Error(await errorMessage(response, "Apply failed")); - } - await loadCourse(activeCourseId || course.value); - setStatus("Draft applied.", "ready"); -}); - -loadDocumentDraft.addEventListener("click", () => loadDocumentDraftById(documentDraftSelect.value).catch(showError)); - -loadCourseDraft.addEventListener("click", () => loadCourseDraftById(courseDraftSelect.value).catch(showError)); - -save.addEventListener("click", async () => { - if (versionMode) return; - setStatus("Saving..."); - const parsed = JSON.parse(editor.value); - const response = await fetch(`/api/refined/${activeCourseId || course.value}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fields: parsed }), - }); - if (!response.ok) { - throw new Error(await errorMessage(response, "Save failed")); - } - await loadCourse(course.value); - setTab("chat"); -}); - -window.addEventListener("error", (event) => { - showError(new Error(event.message)); -}); - -window.addEventListener("unhandledrejection", (event) => { - event.preventDefault(); - showError(event.reason); -}); - -const initialVersion = initialParams.get("version"); -const initialCourse = initialParams.get("course"); -const initialLoad = initialVersion && initialCourse ? loadVersionCourse(initialVersion, initialCourse) : loadDocumentPreview(); - -Promise.all([refreshVersions(), initialLoad]).catch(() => { - loading.classList.remove("active"); - setStatus("Backend unavailable.", "error"); -}); diff --git a/frontend/preview.html b/frontend/preview.html index badb9720df132c64449b7ae4e448dfc50d406186..be4ef6a21d49642d35216afb7cb8be96ab872eef 100644 --- a/frontend/preview.html +++ b/frontend/preview.html @@ -1,11 +1,28 @@ - + - - - - Curriculum Preview - - - Curriculum Preview - - + + + +Curriculum Preview + + + +
+ + + \ No newline at end of file diff --git a/frontend/preview/index.html b/frontend/preview/index.html deleted file mode 100644 index 467860c82d744de51d35d64d6750ee223c67efbd..0000000000000000000000000000000000000000 --- a/frontend/preview/index.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - Curriculum Preview - - - -
- - - Open - Download - -
- - - - - diff --git a/frontend/preview/preview.css b/frontend/preview/preview.css deleted file mode 100644 index b717ce6b224ee88fa76bd95a310afe02739d4c46..0000000000000000000000000000000000000000 --- a/frontend/preview/preview.css +++ /dev/null @@ -1,69 +0,0 @@ -@import "../shared.css"; - -* { - box-sizing: border-box; -} - -body { - margin: 0; - min-height: 100dvh; - background: #4d5259; - color: #111827; - font-family: Arial, sans-serif; -} - -.toolbar { - display: flex; - align-items: center; - gap: 10px; - min-height: 52px; - padding: 8px 12px; - background: #fff; - border-bottom: 1px solid #d1d5db; -} - -select, -input, -a { - height: 34px; - border: 1px solid #9ca3af; - border-radius: 4px; - background-color: #fff; - color: #111827; - font-size: 14px; -} - -select { - min-width: 150px; -} - -input { - width: 120px; - padding: 0 10px; -} - -.year-control { - display: inline-flex; - align-items: center; - gap: 8px; - color: #374151; - font-size: 14px; -} - -a { - display: inline-flex; - align-items: center; - padding: 0 12px; - text-decoration: none; -} - -#status { - margin-left: auto; -} - -iframe { - display: block; - width: 100%; - height: calc(100dvh - 52px); - border: 0; -} diff --git a/frontend/preview/preview.js b/frontend/preview/preview.js deleted file mode 100644 index c2391eabd97a41f426d5d18d65d992b3e645e382..0000000000000000000000000000000000000000 --- a/frontend/preview/preview.js +++ /dev/null @@ -1,84 +0,0 @@ -const params = new URLSearchParams(location.search); -const requestedSemester = params.get("sem"); -const storedYear = localStorage.getItem("curriculumYear") || ""; -const requestedYear = params.get("curriculum_year") || params.get("year") || storedYear; -const semester = document.getElementById("semester"); -const curriculumYear = document.getElementById("curriculum-year"); -const viewer = document.getElementById("viewer"); -const openLink = document.getElementById("open"); -const downloadLink = document.getElementById("download"); -const statusText = document.getElementById("status"); -curriculumYear.value = requestedYear; -if (requestedYear) localStorage.setItem("curriculumYear", requestedYear); - -function clearPreview(message) { - viewer.removeAttribute("src"); - openLink.removeAttribute("href"); - downloadLink.removeAttribute("href"); - statusText.textContent = message; -} - -function yearValue() { - return curriculumYear.value.trim(); -} - -function saveYear() { - if (yearValue()) localStorage.setItem("curriculumYear", yearValue()); -} - -function pdfUrl(sem, download = false) { - const path = sem === "all" ? "/api/preview/pdf" : `/api/preview/semester/${sem}/pdf`; - const query = new URLSearchParams({ curriculum_year: yearValue() }); - if (download) query.set("download", "true"); - return `${path}?${query}`; -} - -async function courseIds(sem) { - const url = sem === "all" ? "/api/preview/courses" : `/api/preview/semester/${sem}/courses`; - const response = await fetch(url); - if (!response.ok) return []; - const body = await response.json(); - return body.course_ids || []; -} - -async function firstAvailableSemester() { - if ((await courseIds("all")).length) return "all"; - for (let sem = 1; sem <= 8; sem += 1) { - if ((await courseIds(sem)).length) return String(sem); - } - return "all"; -} - -async function loadSemester(sem) { - semester.value = sem; - statusText.textContent = "Loading..."; - - if (!/^\d{4}-\d{4}$/.test(yearValue())) { - clearPreview("Enter academic year as YYYY-YYYY."); - return; - } - - if (!(await courseIds(sem)).length) { - clearPreview(sem === "all" ? "No refined courses found." : `No refined courses found for Semester ${sem}.`); - return; - } - - const pdf = pdfUrl(sem); - viewer.src = pdf; - openLink.href = pdf; - downloadLink.href = pdfUrl(sem, true); - statusText.textContent = sem === "all" ? "Overall" : `Semester ${sem}`; -} - -semester.addEventListener("change", () => { - loadSemester(semester.value); -}); - -curriculumYear.addEventListener("change", () => { - saveYear(); - loadSemester(semester.value); -}); - -(requestedSemester ? Promise.resolve(requestedSemester) : firstAvailableSemester()).then(loadSemester).catch(() => { - statusText.textContent = "Backend unavailable."; -}); diff --git a/frontend/form/form.js b/frontend/script.js similarity index 90% rename from frontend/form/form.js rename to frontend/script.js index 7ceb553b46fdafec93fd854c224b2e503001e850..687a2c35501791f11fd45cccf94bad60b86121c6 100644 --- a/frontend/form/form.js +++ b/frontend/script.js @@ -26,7 +26,7 @@ document.addEventListener("DOMContentLoaded", () => { result.textContent = msgs.join("\n"); result.className = "error"; } else { - result.textContent = "Submission received. It will appear in Course Management after refinement completes."; + result.textContent = "Submission received."; result.className = "success"; } } catch { diff --git a/frontend/shared.css b/frontend/shared.css deleted file mode 100644 index f12e232ab859f86b6c44e40491a6558b0722f51e..0000000000000000000000000000000000000000 --- a/frontend/shared.css +++ /dev/null @@ -1,122 +0,0 @@ -:root { - --pesu-blue: #00377b; - --pesu-orange: #ff6c00; - --page-bg: #f0f2f5; - --surface: #ffffff; - --border: #d4d8dd; - --border-strong: #9ca3af; - --text: #111827; - --muted: #5a6b8a; - --success: #166534; - --success-bg: #f0fdf4; - --error: #991b1b; - --error-bg: #fef2f2; -} - -select, -input, -textarea, -button, -a.button-link { - min-width: 0; - border-radius: 6px; - font: 14px/1.3 Arial, sans-serif; -} - -select, -input, -textarea { - border: 1px solid var(--border-strong); - background-color: var(--surface); - color: var(--text); -} - -select, -input, -button, -a.button-link { - min-height: 36px; -} - -select { - appearance: none; - padding: 0 34px 0 10px; - background-image: - linear-gradient(45deg, transparent 50%, var(--pesu-blue) 50%), - linear-gradient(135deg, var(--pesu-blue) 50%, transparent 50%); - background-position: - calc(100% - 17px) 15px, - calc(100% - 12px) 15px; - background-size: 5px 5px; - background-repeat: no-repeat; -} - -input, -textarea { - padding: 8px 10px; -} - -button, -a.button-link { - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid var(--border-strong); - background: var(--surface); - color: var(--text); - padding: 0 12px; - text-decoration: none; - cursor: pointer; -} - -button.primary, -a.button-link.primary { - border-color: var(--pesu-blue); - background: var(--pesu-blue); - color: #ffffff; -} - -button.danger { - border-color: #fecaca; - background: #ffffff; - color: var(--error); -} - -button:disabled, -a.button-link.disabled { - opacity: 0.55; - cursor: not-allowed; -} - -select:focus, -input:focus, -textarea:focus, -button:focus-visible, -a.button-link:focus-visible { - outline: 2px solid rgba(255, 108, 0, 0.35); - outline-offset: 1px; -} - -.status-line { - min-height: 28px; - display: inline-flex; - align-items: center; - padding: 4px 8px; - border: 1px solid var(--border); - border-radius: 6px; - background: #f8fafc; - color: #374151; - font-size: 13px; -} - -.status-line.ready { - border-color: #bbf7d0; - background: var(--success-bg); - color: var(--success); -} - -.status-line.error { - border-color: #fecaca; - background: var(--error-bg); - color: var(--error); -} diff --git a/frontend/form/form.css b/frontend/styles.css similarity index 87% rename from frontend/form/form.css rename to frontend/styles.css index 28aad299c1f8ca409d79ec4939e1db01926a2f19..c4a4d1c30b8761454c445276a3db73d1a07baa3e 100644 --- a/frontend/form/form.css +++ b/frontend/styles.css @@ -1,5 +1,3 @@ -@import "../shared.css"; - *, *::before, *::after { box-sizing: border-box; margin: 0; @@ -7,12 +5,18 @@ } :root { - --bg: var(--page-bg); - --text: var(--pesu-blue); + --bg: #f0f2f5; + --surface: #ffffff; + --border: #d4d8dd; + --text: #00377b; --text-secondary: #5a6b8a; - --accent: var(--pesu-orange); + --accent: #ff6c00; --accent-hover: #e05e00; --optional: #8a9bb5; + --success: #166534; + --success-bg: #f0fdf4; + --error: #991b1b; + --error-bg: #fef2f2; } body { @@ -75,8 +79,13 @@ label { input, select, textarea { width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.875rem; font-family: inherit; - background-color: var(--surface); + background: var(--surface); + color: var(--text); transition: border-color 0.15s; } @@ -148,17 +157,3 @@ button:disabled { .hidden { display: none; } - -.form-link { - display: flex; - align-items: center; - justify-content: center; - margin-top: 8px; - color: var(--text); - font-size: 13px; - text-decoration: none; -} - -.form-link:hover { - color: var(--accent); -} diff --git a/frontend/versions.html b/frontend/versions.html deleted file mode 100644 index 6fcfe7cce33299478ccfe21c476bd43333d37616..0000000000000000000000000000000000000000 --- a/frontend/versions.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - Curriculum Versions - - - Curriculum Versions - - diff --git a/frontend/versions/index.html b/frontend/versions/index.html deleted file mode 100644 index 69814f8adac0c5c3e25cb588fc892781ac9c5b5e..0000000000000000000000000000000000000000 --- a/frontend/versions/index.html +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - Curriculum Versions - - - - -
-
-
- - - - - Open Preview -
-
- - -
-
-
- -
- - diff --git a/frontend/versions/versions.css b/frontend/versions/versions.css deleted file mode 100644 index 381940d8e6bdde44a576c7e38d2491a0116a90aa..0000000000000000000000000000000000000000 --- a/frontend/versions/versions.css +++ /dev/null @@ -1,98 +0,0 @@ -@import "../shared.css"; - -* { - box-sizing: border-box; -} - -body { - margin: 0; - min-height: 100dvh; - background: #f0f2f5; - color: #111827; - font-family: Arial, sans-serif; -} - -.shell { - display: grid; - grid-template-rows: auto 1fr; - min-height: 100dvh; -} - -.panel { - display: grid; - gap: 10px; - padding: 12px; - background: #fff; - border-bottom: 1px solid #d1d5db; -} - -.toolbar, -.snapshot { - display: grid; - grid-template-columns: minmax(180px, 1fr) minmax(220px, 1fr) auto auto auto; - gap: 10px; - align-items: center; -} - -.snapshot { - grid-template-columns: minmax(220px, 1fr) auto; -} - -select, -input, -button, -a { - height: 34px; - border: 1px solid #9ca3af; - border-radius: 4px; - background-color: #fff; - color: #111827; - font-size: 14px; -} - -label { - display: grid; - gap: 4px; - color: #00377b; - font-size: 12px; - font-weight: 700; -} - -select, -input { - min-width: 0; -} - -button, -a { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0 12px; - text-decoration: none; - cursor: pointer; -} - -button { - background-color: #00377b; - border-color: #00377b; - color: #ffffff; -} - -#status { - width: fit-content; -} - -iframe { - width: 100%; - height: 100%; - border: 0; - background: #4d5259; -} - -@media (max-width: 760px) { - .toolbar, - .snapshot { - grid-template-columns: 1fr; - } -} diff --git a/frontend/versions/versions.js b/frontend/versions/versions.js deleted file mode 100644 index e65179ea74f0dc416a6aa6e10dc9c60ee580451a..0000000000000000000000000000000000000000 --- a/frontend/versions/versions.js +++ /dev/null @@ -1,96 +0,0 @@ -const version = document.getElementById("version"); -const course = document.getElementById("course"); -const previewVersion = document.getElementById("preview-version"); -const openEditor = document.getElementById("open-editor"); -const previewLink = document.getElementById("preview-link"); -const snapshotForm = document.getElementById("snapshot-form"); -const versionName = document.getElementById("version-name"); -const statusText = document.getElementById("status"); -const viewer = document.getElementById("viewer"); - -function setStatus(text, kind = "") { - statusText.textContent = text || ""; - statusText.className = `status-line ${kind}`.trim(); -} - -function option(value, text) { - const item = document.createElement("option"); - item.value = value; - item.textContent = text; - return item; -} - -async function json(url, options) { - const response = await fetch(url, options); - const body = await response.json(); - if (!response.ok) throw new Error(body.detail || "Request failed"); - return body; -} - -function versionLabel(item) { - return item.name || `Snapshot ${item.id}`; -} - -function courseLabel(item) { - const code = item.course_code ? `${item.course_code} - ` : ""; - const sem = item.semester ? `S${item.semester} ` : ""; - return `${sem}${code}${item.course_title || `Course ${item.refined_id}`}`; -} - -async function loadVersions() { - const body = await json("/api/versions"); - version.replaceChildren(...(body.versions || []).map((item) => option(item.id, versionLabel(item)))); - if (!version.value) { - setStatus("No snapshots saved."); - return; - } - await loadVersion(); -} - -async function loadVersion() { - const body = await json(`/api/versions/${version.value}`); - course.replaceChildren(...(body.courses || []).map((item) => option(item.refined_id, courseLabel(item)))); - showVersionPreview(); - setStatus(body.courses?.length ? `${body.courses.length} courses in snapshot.` : "Snapshot has no courses.", body.courses?.length ? "ready" : ""); -} - -function showVersionPreview() { - if (!version.value) return; - previewLink.href = `/api/versions/${version.value}/preview`; - viewer.src = previewLink.href; -} - -function showCoursePreview() { - if (!version.value || !course.value) return; - previewLink.href = `/api/versions/${version.value}/courses/${course.value}/preview`; - viewer.src = previewLink.href; -} - -version.addEventListener("change", loadVersion); -previewVersion.addEventListener("click", showVersionPreview); - -course.addEventListener("change", () => { - showCoursePreview(); -}); - -openEditor.addEventListener("click", () => { - if (!version.value || !course.value) return; - location.href = `../live-editor/?version=${encodeURIComponent(version.value)}&course=${encodeURIComponent(course.value)}`; -}); - -snapshotForm.addEventListener("submit", async (event) => { - event.preventDefault(); - setStatus("Saving snapshot..."); - await json("/api/versions", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: versionName.value }), - }); - versionName.value = ""; - setStatus("Snapshot saved.", "ready"); - await loadVersions(); -}); - -loadVersions().catch((error) => { - setStatus(error instanceof Error ? error.message : "Unable to load versions.", "error"); -}); diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 8deee13134b3570473a73be0a854e7c4848bd103..0000000000000000000000000000000000000000 --- a/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -pythonpath = backend diff --git a/requirements.txt b/requirements.txt index e09fdd78f9240c1d08fbf1497d1171fe9b70e99a..35ab803c95027eec1cf4bd160e84cb0aaa5fe313 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,66 @@ +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.14.1 +certifi==2026.6.17 +cffi==2.0.0 +click==8.4.2 +cryptography==49.0.0 +deprecation==2.1.0 +detect-installer==0.1.0 +dnspython==2.8.0 +email-validator==2.3.0 fastapi==0.138.0 -openpyxl==3.1.5 +fastapi-cli==0.0.27 +fastapi-cloud-cli==0.21.0 +fastar==0.11.0 +h11==0.16.0 +h2==4.3.0 +hpack==4.2.0 +httpcore==1.0.9 +httptools==0.8.0 httpx==0.28.1 +hyperframe==6.1.0 +idna==3.18 +iniconfig==2.3.0 Jinja2==3.1.6 +markdown-it-py==4.2.0 MarkupSafe==3.0.3 -pydantic==2.13.4 +mdurl==0.1.2 +multidict==6.7.1 +packaging==26.2 +pluggy==1.6.0 postgrest==2.31.0 +propcache==0.5.2 +pycparser==3.0 +pydantic==2.13.4 +pydantic-extra-types==2.11.1 +pydantic-settings==2.14.2 +pydantic_core==2.46.4 +Pygments==2.20.0 +PyJWT==2.13.0 pytest==9.1.1 python-dotenv==1.2.2 python-multipart==0.0.32 +PyYAML==6.0.3 +realtime==2.31.0 +rich==15.0.0 +rich-toolkit==0.20.1 +rignore==0.7.6 +ruff==0.15.19 sentry-sdk==2.63.0 +shellingham==1.5.4 +starlette==1.3.1 +storage3==2.31.0 +StrEnum==0.4.15 supabase==2.31.0 -uvicorn[standard]==0.49.0 -weasyprint==69.0 +supabase-auth==2.31.0 +supabase-functions==2.31.0 +typer==0.26.7 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +urllib3==2.7.0 +uvicorn==0.49.0 +uvloop==0.22.1 +watchfiles==1.2.0 +websockets==15.0.1 +yarl==1.24.2 diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 8dfd68fc6f96811cbd80e646cfdae040cfb1b0c0..0000000000000000000000000000000000000000 --- a/tests/conftest.py +++ /dev/null @@ -1,8 +0,0 @@ -import os - - -os.environ.setdefault("SUPABASE_URL", "https://example.supabase.co") -os.environ.setdefault("SUPABASE_KEY", "test") -os.environ.setdefault("OPENROUTER_URL", "https://openrouter.test") -os.environ.setdefault("OPENROUTER_API_KEY", "test") -os.environ.setdefault("OPENROUTER_MODEL", "test-model") diff --git a/tests/test_agent_tools.py b/tests/test_agent_tools.py deleted file mode 100644 index 156d0cb411673ae621ebb0c1bea6f213b5b77f0f..0000000000000000000000000000000000000000 --- a/tests/test_agent_tools.py +++ /dev/null @@ -1,47 +0,0 @@ -import pytest - -from app.services.agent_tools import call_tool, list_tool_schemas - - -def tool_names(): - return {tool["function"]["name"] for tool in list_tool_schemas()} - - -def test_agent_tool_schemas_are_function_tools(): - tools = list_tool_schemas() - - assert tools - assert all(tool["type"] == "function" for tool in tools) - assert "get_current_course_json" in tool_names() - assert "get_curriculum_json" in tool_names() - assert "diff_course_json" in tool_names() - assert "create_document_draft" in tool_names() - assert "apply_course_draft" not in tool_names() - - -def test_diff_tool_returns_syllabus_changes(): - result = call_tool( - "diff_course_json", - { - "current": {"units": [{"title": "Unit 1", "content": "Stacks, Queues"}]}, - "proposed": {"units": [{"title": "Unit 1", "content": "Stacks, Graphs"}]}, - }, - ) - - assert "graphs" in result["topics_added"] - assert "queues" in result["topics_removed"] - - -def test_preview_url_tool(): - assert call_tool("get_preview_url", {"kind": "course", "id": 12}) == {"url": "/api/preview/course/12"} - assert call_tool("get_preview_url", {"kind": "draft", "id": 3}) == {"url": "/api/agent/drafts/3/preview"} - - -def test_unknown_tool_fails(): - with pytest.raises(LookupError): - call_tool("missing_tool", {}) - - -def test_invalid_preview_kind_fails(): - with pytest.raises(ValueError): - call_tool("get_preview_url", {"kind": "refined", "id": 1}) diff --git a/tests/test_api_errors.py b/tests/test_api_errors.py deleted file mode 100644 index 86cedd6848fb3079709f08c1fd82e6b86f1c3f8a..0000000000000000000000000000000000000000 --- a/tests/test_api_errors.py +++ /dev/null @@ -1,17 +0,0 @@ -from postgrest.exceptions import APIError - -from app.services.errors import database_http_exception - - -def test_missing_schema_error_is_service_unavailable(): - error = APIError( - { - "message": "Could not find the table 'public.agent_document_drafts' in the schema cache", - "code": "PGRST205", - } - ) - - response = database_http_exception(error) - - assert response.status_code == 503 - assert response.detail == "Required database tables are missing. Run docs/schema.sql in Supabase." diff --git a/tests/test_attachments.py b/tests/test_attachments.py deleted file mode 100644 index 2148f78403ef7ea3e518dece885bce631224cb8d..0000000000000000000000000000000000000000 --- a/tests/test_attachments.py +++ /dev/null @@ -1,38 +0,0 @@ -import io -import zipfile - -from app.services.attachments import MAX_FILE_BYTES, extract_text - - -def test_extract_text_file(): - text, status, error = extract_text("notes.txt", "text/plain", b"hello\n\nworld") - - assert text == "hello\n\nworld" - assert status == "ready" - assert error == "" - - -def test_extract_docx_file(): - data = io.BytesIO() - xml = ( - '' - '' - "firstsecond" - "" - ) - with zipfile.ZipFile(data, "w") as docx: - docx.writestr("word/document.xml", xml) - - text, status, error = extract_text("notes.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", data.getvalue()) - - assert text == "first\nsecond" - assert status == "ready" - assert error == "" - - -def test_reject_large_file(): - text, status, error = extract_text("large.txt", "text/plain", b"x" * (MAX_FILE_BYTES + 1)) - - assert text == "" - assert status == "failed" - assert "8 MB" in error diff --git a/tests/test_chat_prompt.py b/tests/test_chat_prompt.py deleted file mode 100644 index 8133c3af917d22ccf1ab0d79dc4f0383db67e771..0000000000000000000000000000000000000000 --- a/tests/test_chat_prompt.py +++ /dev/null @@ -1,15 +0,0 @@ -from app.routes import chat - - -def test_chat_prompt_allows_reviewable_drafts(monkeypatch): - monkeypatch.setattr(chat, "refined_course", lambda refined_id: {"course_title": "Algorithms"}) - - prompt = chat.chat_system_prompt({"refined_id": 7}) - - assert "create_course_draft" in prompt - assert "create_document_draft" in prompt - assert "active_session_id" in prompt - assert "active_refined_id" in prompt - assert "review the diff" in prompt - assert "explain the exact fields" not in prompt - assert "cannot edit" not in prompt diff --git a/tests/test_deterministic.py b/tests/test_deterministic.py deleted file mode 100644 index 471363e78e44277fb21bd48f1ec1c0d37e5fc283..0000000000000000000000000000000000000000 --- a/tests/test_deterministic.py +++ /dev/null @@ -1,23 +0,0 @@ -import pytest - -from app.services.deterministic import compute_course_type, compute_hours, compute_program - - -def test_credit_category_hours(): - assert compute_hours("5") == {"lecture_hours": 4, "tutorial_hours": 0, "practical_hours": 2, "self_study": 5, "credits": 5} - assert compute_hours("0") == {"lecture_hours": 0, "tutorial_hours": 0, "practical_hours": 0, "self_study": 0, "credits": 0} - - -def test_program_mapping(): - assert compute_program("CSE") == "B. TECH" - assert compute_program("AIML") == "B. TECH" - - -def test_course_type_mapping(): - assert compute_course_type("5") == "Core Course-Lab Integrated" - assert compute_course_type("2") == "Core Theory" - - -def test_unknown_values_fail_fast(): - with pytest.raises(KeyError): - compute_hours("9") diff --git a/tests/test_diffing.py b/tests/test_diffing.py deleted file mode 100644 index df5c8474df8efd83c7d379a9cb0c3a427277603e..0000000000000000000000000000000000000000 --- a/tests/test_diffing.py +++ /dev/null @@ -1,29 +0,0 @@ -from app.services.diffing import apply_patch, build_patch, diff_course, merge_fields, validate_draft - - -def test_build_and_apply_patch(): - old = {"title": "A", "items": ["one"]} - new = {"title": "B", "items": ["one", "two"]} - - patch = build_patch(old, new) - - assert apply_patch(old, patch) == new - - -def test_equivalent_protected_numbers_are_not_blocked(): - base = {"credits": "4", "course_type": "Core Course"} - proposed = merge_fields(base, {"credits": 4, "course_type": "Core Course"}) - - assert validate_draft(base, proposed) == [] - assert proposed["credits"] == "4" - - -def test_syllabus_topics_added_and_removed_are_reported(): - old = {"units": [{"title": "Unit 1", "content": "Stacks, Queues, Trees"}]} - new = {"units": [{"title": "Unit 1", "content": "Stacks, Graphs, Trees"}]} - - summary = diff_course(old, new) - - assert "graphs" in summary["topics_added"] - assert "queues" in summary["topics_removed"] - assert summary["syllabus_change_percent"] > 0 diff --git a/tests/test_frontend_static.py b/tests/test_frontend_static.py deleted file mode 100644 index 736d002b202c4f569318c02b1e7f11271e162d8d..0000000000000000000000000000000000000000 --- a/tests/test_frontend_static.py +++ /dev/null @@ -1,127 +0,0 @@ -import shutil -import subprocess -from pathlib import Path - -import pytest -from fastapi.testclient import TestClient - - -LIVE_EDITOR = Path("frontend/live-editor/index.html") -LIVE_EDITOR_JS = Path("frontend/live-editor/live-editor.js") - - -def test_live_editor_script_parses(): - node = shutil.which("node") - if not node: - pytest.skip("node is not installed") - script = "new Function(require('fs').readFileSync('frontend/live-editor/live-editor.js', 'utf8'));" - subprocess.run([node, "-e", script], check=True) - - -def test_versions_script_parses(): - node = shutil.which("node") - if not node: - pytest.skip("node is not installed") - script = "new Function(require('fs').readFileSync('frontend/versions/versions.js', 'utf8'));" - subprocess.run([node, "-e", script], check=True) - - -def test_courses_script_parses(): - node = shutil.which("node") - if not node: - pytest.skip("node is not installed") - script = "new Function(require('fs').readFileSync('frontend/courses/courses.js', 'utf8'));" - subprocess.run([node, "-e", script], check=True) - - -def test_live_editor_uses_safe_message_rendering(): - text = LIVE_EDITOR_JS.read_text() - - assert "function renderMessageContent" in text - assert ".innerHTML" not in text - assert 'content: "Working..."' not in text - - -def test_live_editor_uses_external_assets(): - text = LIVE_EDITOR.read_text() - - assert '' in text - assert '' in text - assert 'id="save-version"' in text - assert 'id="restore-version"' in text - assert "