foresight / server /syllabus /parse.py
umangchaudhry's picture
Deploy 724d0d7 from GitHub
c82849f verified
Raw
History Blame Contribute Delete
11.2 kB
"""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"])