Spaces:
Running
Running
| """One syllabus -> one structured record, in one model call. | |
| The only place in this feature that talks to a model. Everything model-facing is | |
| here and narrow on purpose: one function, one model constant, one schema. | |
| Three decisions worth knowing: | |
| * **`gpt-5.6-sol`, not the nano tier.** `collectors/anchorlink/classify.py` runs on | |
| `gpt-5.4-nano` because it picks one of four category labels. This is a long, | |
| badly-structured document reduced to a nested schema, with relative dates | |
| ("Friday of week 6") that have to resolve to real days. A handful of calls per | |
| student per term — accuracy beats cost by a wide margin. | |
| * **Strict JSON-schema output.** The Responses API is told the exact shape, so the | |
| model cannot return anything else. Schema-constrained output is the difference | |
| between a parser and a guessing machine. | |
| * **The term's anchors go in the prompt.** A syllabus says "Week 6"; the calendar, | |
| map and Today feed need `2026-10-02`. `terms.prompt_context()` supplies the | |
| first day of classes, the exam period and the breaks so the model can do that | |
| arithmetic. When those anchors aren't published yet, the model is told to leave | |
| the date null instead of inventing one — see `terms.complete()`. | |
| Nothing here logs document text: a failure records the filename, the route and the | |
| error, never the syllabus contents. | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import json | |
| import logging | |
| import os | |
| from datetime import datetime, timezone | |
| from . import extract, schema, terms | |
| log = logging.getLogger("foresight.syllabus") | |
| # One tier, one place. Keep this the accurate model for the job — see above. | |
| DEFAULT_MODEL = "gpt-5.6-sol" | |
| MAX_OUTPUT_TOKENS = int(os.environ.get("FORESIGHT_SYLLABUS_MAX_TOKENS", 8000)) | |
| SYSTEM_PROMPT = """\ | |
| You extract structured data from a university course syllabus. You are reading it \ | |
| on behalf of a first-generation student at Vanderbilt who will use it to know when \ | |
| their classes meet, where to go, and what is due. | |
| Rules, in order of importance: | |
| 1. **Only what the document says.** Never infer a meeting time, an exam date, or an \ | |
| instructor from what is typical. If the syllabus doesn't say it, the field is null \ | |
| and the list is empty. A missing exam date is recoverable; an invented one is not. | |
| 2. **Resolve every date to a real calendar date** in YYYY-MM-DD form, using the \ | |
| term information below. Syllabi say "Week 6", "Friday of week 4", "Sept 24", or \ | |
| "the Monday after fall break" — all of those must come out as ISO dates. If a date \ | |
| cannot be resolved to a specific day, leave it null rather than guessing a plausible \ | |
| one. | |
| 3. **Times in 24-hour HH:MM.** "2:15pm" is "14:15". A time range belongs in start \ | |
| and end. | |
| 4. **Copy locations verbatim into location_raw** — "Stevenson Center 4327", "MRB III \ | |
| 100", "Zoom". Do not normalize, expand, abbreviate or correct them; something else \ | |
| resolves them to campus buildings. | |
| 5. **Every graded item with a due date is an assignment**, including problem sets, \ | |
| papers, quizzes and labs. Exams (midterms, finals) go in exams, not assignments. \ | |
| Keep weights as written ("15%", "one letter grade") — on the exam or assignment row \ | |
| itself, whenever the syllabus states one. | |
| 6. **Put the whole grade breakdown in `grading` as well**, one row per component, \ | |
| components and weights verbatim. Include the parts that are neither exams nor dated \ | |
| assignments, like "Class participation 10%". Copy what is written even if the numbers \ | |
| don't add to 100 — do not adjust them to fit. | |
| 7. **Office hours go in twice.** `office_hours` is the verbatim text, including things \ | |
| like "or by appointment". `office_hours_slots` is the same information as recurring \ | |
| slots a calendar can place — days, start and end in 24-hour HH:MM. "Wednesdays 1-3pm" \ | |
| is one slot: days ["Wed"], start "13:00", end "15:00". "MW 10-11" is one slot with two \ | |
| days. Leave the list empty if the hours are only "by appointment" or have no stated \ | |
| time, and set `location_raw` on a slot only when it names somewhere other than the \ | |
| instructor's office. | |
| 8. **Report honest per-section confidence** from 0 to 1: how sure are you that the \ | |
| meetings, the exams, and the assignments you extracted are complete and correct? A \ | |
| clean weekly schedule table deserves a high number; a prose paragraph you had to \ | |
| interpret deserves a low one. Do not inflate — a low number tells the student where \ | |
| to check, which is more useful than false certainty.""" | |
| VISION_NOTE = """\ | |
| This syllabus is a photo or a scan, so you are reading pixels rather than text. Be \ | |
| more conservative: if a date, room or time is not legible with certainty, leave it \ | |
| null and lower the confidence for that section. Do not fill a gap with something \ | |
| that looks likely.""" | |
| NO_ANCHORS_NOTE = """\ | |
| The academic calendar for this term has not been published yet, so relative dates \ | |
| cannot be resolved. Leave any date given only as a relative reference ("Week 6", \ | |
| "the second Monday") null. Keep dates the syllabus states outright (an explicit \ | |
| month and day), using the year from the term name.""" | |
| class ParseFailed(Exception): | |
| """The model call didn't produce a usable record. | |
| Like `extract.Unsupported`, the message is written to be shown to the student — | |
| a failed parse must offer a reason and a retry, never a silent drop. | |
| """ | |
| def model_name() -> str: | |
| return os.environ.get("FORESIGHT_SYLLABUS_MODEL", DEFAULT_MODEL) | |
| def configured() -> bool: | |
| """Whether parsing can run at all. False means no API key, and the upload UI | |
| should say so rather than accepting a file it can't process.""" | |
| return bool(os.environ.get("OPENAI_API_KEY")) | |
| def describe() -> dict: | |
| return {"enabled": configured(), "model": model_name() if configured() else None} | |
| # --- prompt assembly -------------------------------------------------------- | |
| def build_input(read: dict, bounds: dict, *, filename: str) -> list[dict]: | |
| """The `input` list for the Responses API. | |
| Two routes converge here (see `extract.py`): extracted text becomes an | |
| `input_text` part, while a photo or a text-less PDF is attached as an | |
| `input_image` / `input_file` part and read by the model directly. | |
| """ | |
| system = SYSTEM_PROMPT | |
| if read["mode"] == extract.VISION: | |
| system += "\n\n" + VISION_NOTE | |
| context = terms.prompt_context(bounds) | |
| system += "\n\n" + (context if context else NO_ANCHORS_NOTE) | |
| parts: list[dict] = [{ | |
| "type": "input_text", | |
| "text": (f"Syllabus file: {filename}\n" | |
| f"Extract the course, instructor, meetings, exams and assignments."), | |
| }] | |
| if read["mode"] == extract.VISION: | |
| payload = base64.b64encode(read["_bytes"]).decode("ascii") | |
| if read["media_type"] == extract.PDF_MEDIA: | |
| parts.append({ | |
| "type": "input_file", | |
| "filename": filename or "syllabus.pdf", | |
| "file_data": f"data:{extract.PDF_MEDIA};base64,{payload}", | |
| }) | |
| else: | |
| parts.append({ | |
| "type": "input_image", | |
| "image_url": f"data:{read['media_type']};base64,{payload}", | |
| }) | |
| else: | |
| note = ("\n\n(The document was longer than we read; later pages may be " | |
| "missing.)" if read["truncated"] else "") | |
| parts.append({"type": "input_text", | |
| "text": f"--- syllabus text ---\n{read['text']}{note}"}) | |
| return [{"role": "system", "content": system}, | |
| {"role": "user", "content": parts}] | |
| # --- the call --------------------------------------------------------------- | |
| def parse(data: bytes, filename: str, *, term: str | None = None, | |
| calendar: list[dict] | None = None, client=None, gaz=None, | |
| now: datetime | None = None) -> dict: | |
| """Bytes in, one stored-shape record out. | |
| Raises `extract.Unsupported` for a file the student needs to swap, and | |
| `ParseFailed` when the model call itself doesn't yield a record. Both carry a | |
| message meant for the student. | |
| `client` and `gaz` are injectable so tests never touch the network. | |
| """ | |
| read = extract.extract(data, filename) # may raise Unsupported | |
| if read["mode"] == extract.VISION: | |
| read = {**read, "_bytes": data} | |
| cal = calendar if calendar is not None else terms.load_calendar() | |
| term = term or terms.current_or_next(cal) | |
| bounds = terms.bounds(term, cal) if term else terms.bounds("", []) | |
| if client is None: | |
| client = _client() | |
| payload = build_input(read, bounds, filename=filename) | |
| try: | |
| response = client.responses.create( | |
| model=model_name(), | |
| input=payload, | |
| text={"format": {"type": "json_schema", "name": schema.SCHEMA_NAME, | |
| "schema": schema.EXTRACTION_SCHEMA, "strict": True}}, | |
| max_output_tokens=MAX_OUTPUT_TOKENS, | |
| ) | |
| except Exception as err: | |
| # Deliberately no document text in the log — just the route and the error. | |
| log.warning("syllabus parse failed: file=%s route=%s err=%s", | |
| filename, read["mode"], type(err).__name__) | |
| raise ParseFailed( | |
| "We couldn't read that syllabus just now. Try again in a moment.") from err | |
| if getattr(response, "status", "completed") == "incomplete": | |
| raise ParseFailed( | |
| "That syllabus was too long to finish reading. Upload just the pages " | |
| "with the schedule, or try again.") | |
| text = getattr(response, "output_text", None) | |
| try: | |
| parsed = json.loads(text) | |
| except (TypeError, json.JSONDecodeError) as err: | |
| log.warning("syllabus parse returned no JSON: file=%s route=%s", | |
| filename, read["mode"]) | |
| raise ParseFailed( | |
| "We couldn't make sense of that syllabus. Try again, or upload a " | |
| "different version of the file.") from err | |
| if not isinstance(parsed, dict): | |
| raise ParseFailed("We couldn't make sense of that syllabus. Try again.") | |
| stamp = (now or datetime.now(timezone.utc)).isoformat() | |
| record = schema.build_record( | |
| parsed, | |
| source_file=filename, | |
| term=term, | |
| parsed_at=stamp, | |
| bounds=bounds, | |
| gaz=gaz, | |
| text_source=(schema.VISION_SOURCE if read["mode"] == extract.VISION | |
| else schema.EXTRACTED_SOURCE), | |
| ) | |
| log.info("syllabus parsed: file=%s route=%s course=%s meetings=%d exams=%d " | |
| "assignments=%d flags=%d", filename, read["mode"], | |
| record["course_code"], len(record["meetings"]), len(record["exams"]), | |
| len(record["assignments"]), len(record["flags"])) | |
| return record | |
| def _client(): | |
| if not configured(): | |
| raise ParseFailed( | |
| "Syllabus reading isn't set up on this server yet. Your file wasn't " | |
| "saved — nothing to fix on your end.") | |
| try: | |
| from openai import OpenAI | |
| except ImportError as err: # pragma: no cover | |
| raise ParseFailed("Syllabus reading isn't available on this server.") from err | |
| return OpenAI(api_key=os.environ["OPENAI_API_KEY"]) | |