Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
# OpenAI-compatible API configuration.
# Recommended: Alibaba DashScope (Qwen) - one key covers vision + text.
# OpenAI-compatible API configuration. Both models are required, and the
# vision model must accept image inputs.
#
# Example: Alibaba DashScope (Qwen) - one key covers vision + text.
# BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# MODEL_TEXT=qwen-turbo
# MODEL_VISION=qwen-vl-plus
# Alternative for text: DeepSeek (no vision support)
# BASE_URL=https://api.deepseek.com
# MODEL_TEXT=deepseek-chat
API_KEY=
BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_TEXT=qwen-turbo
MODEL_VISION=qwen-vl-plus
MODEL_VISION=qwen-vl-plus
45 changes: 33 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

Turn Brazilian university teaching plans into calendar events.

Give it your teaching-plan PDFs and weekly timetable. It finds tests and
activities, assigns dates and class times, then writes files you can import
into Google Calendar, Apple Calendar, or Outlook.
Give it your teaching-plan PDFs and weekly timetable. It reads that student's
subjects, semester dates, weekdays, and class times; finds tests and activities;
then writes files that can be imported into Google Calendar, Apple Calendar,
or Outlook.

## Quick start

Expand All @@ -28,7 +29,8 @@ The terminal guides you through the rest.

Put these in `files/`:

- `image.png` — your weekly timetable screenshot.
- `image.png` — your weekly timetable screenshot. It must show the semester
start/end dates, subjects, weekdays, and class times.
- One PDF teaching plan for each subject.
- Optional: an academic calendar as PDF, PNG, JPEG, or WebP.

Expand All @@ -41,10 +43,12 @@ enter its path in the terminal.
The optional calendar keeps estimated dates from drifting after holidays,
recesses, or cancelled class days.

When a calendar has different rules for different campuses, the terminal asks
for your campus and, if useful, your unit, course, and shift. A Sorocaba-only
recess is excluded for a São Paulo student. If a row is unclear, the tool asks
you instead of guessing.
When a calendar has different rules for different campuses, the tool derives
the academic context from the teaching-plan text saved under `output/extracted/`.
It does not ask for institution, campus, faculty, program, or shift; a field
that is missing or inconsistent across plans remains blank. If a calendar row
is unclear or has a different scope, the tool asks for a decision instead of
guessing.

The calendar may be normal text, scanned pages, or a mixture of both. Its page
results are saved under `output/academic-calendar-cache/`, so rerunning the
Expand All @@ -66,10 +70,25 @@ MODEL_TEXT=qwen-turbo
MODEL_VISION=qwen-vl-plus
```

The defaults work with Alibaba DashScope's Qwen models. Any compatible provider
with a text model and an image-capable model should work. Your key is read from
The example values use Alibaba DashScope's Qwen models. They are examples, not
university requirements. Another OpenAI-compatible provider works when it has
both a text model and a model that accepts image inputs. Your key is read from
`.env` and is not printed or saved in output files.

## How student-specific data is handled

No semester, subject, campus, or class time is built into the program. Every
run reads those values from the student's timetable and teaching plans.

If the timetable image is incomplete or cannot be parsed, the run stops and
explains what is missing. If a teaching-plan subject or event time cannot be
matched safely, the terminal asks the student to select the correct timetable
entry. Pressing Enter aborts the run, and no new CSV or ICS file is written.

Teaching-plan extraction is designed first for Brazilian Portuguese. It
recognizes common assessment terms such as prova, avaliação, atividade,
trabalho, projeto, seminário, and substitutiva.

## Output

After a successful run, look in `output/`:
Expand All @@ -80,8 +99,10 @@ After a successful run, look in `output/`:
- `academic-calendar-review.json` — present only when you use an academic calendar.

Regular classes are not exported. The output contains tests and activities
found in the teaching plans. Dates written directly in a plan are used when
they look valid; other dates are estimated from the class number and timetable.
found in the teaching plans. Literal dates in a plan are used directly; other
dates are estimated from the class number, timetable, and accepted academic
calendar closures. Events without a trustworthy date or time are not silently
given defaults.

## Project layout

Expand Down
4 changes: 3 additions & 1 deletion files/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Input Files

Place teaching-plan PDFs and `image.png` (the weekly timetable) here.
Place teaching-plan PDFs and `image.png` (the weekly timetable) here. The
timetable image must show semester start/end dates, subjects, weekdays, and
class times. The program does not contain a fallback schedule.

You may also add one optional academic calendar as PDF, PNG, JPEG, or WebP.
Text-readable, scanned, and mixed PDFs are supported. A filename containing
Expand Down
107 changes: 63 additions & 44 deletions src/dates.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@
import unicodedata
from dataclasses import dataclass
from datetime import date, time, timedelta
from typing import Callable

from .grid import Slot
from .llm import Fact

DEFAULT_SLOT = (time(7, 15), time(8, 55))

class EventResolutionError(ValueError):
"""An event is missing information required for a trustworthy calendar entry."""


SubjectResolver = Callable[[Fact, list[Slot]], list[Slot] | None]
SlotResolver = Callable[[Fact, list[Slot], date], Slot | None]


@dataclass
Expand All @@ -34,14 +41,19 @@ def normalize(s: str) -> str:

def _subject_slots(code: str, name: str, slots: list[Slot]) -> list[Slot]:
code = code.strip().upper()
by_code = [s for s in slots if s.code == code]
if by_code:
return sorted(by_code, key=lambda s: s.weekday)
if code:
by_code = [s for s in slots if s.code == code]
if by_code:
return sorted(by_code, key=lambda s: s.weekday)
nname = normalize(name)
if not nname:
return []
by_name = [s for s in slots if nname in normalize(s.name) or normalize(s.name) in nname]
return sorted(by_name, key=lambda s: s.weekday)
exact_name = [s for s in slots if normalize(s.name) == nname]
if exact_name:
return sorted(exact_name, key=lambda s: s.weekday)
partial = [s for s in slots if nname in normalize(s.name) or normalize(s.name) in nname]
identities = {(slot.code, normalize(slot.name)) for slot in partial}
return sorted(partial, key=lambda s: s.weekday) if len(identities) == 1 else []


def nth_meeting(
Expand Down Expand Up @@ -71,67 +83,74 @@ def compute_events(
facts: list[Fact],
slots: list[Slot],
semester_start: date,
semester_end: date | None = None,
non_teaching_dates: set[date] | None = None,
subject_resolver: SubjectResolver | None = None,
slot_resolver: SlotResolver | None = None,
) -> list[Event]:
print(f"[dates] computing events from {len(facts)} fact(s), {len(slots)} grid slot(s), semester start {semester_start}")
events = []
non_teaching_dates = non_teaching_dates or set()
for fact in facts:
subj_slots = _subject_slots(fact.subject_code, fact.subject_name, slots)
if not subj_slots and subject_resolver is not None:
subj_slots = subject_resolver(fact, slots) or []
if not subj_slots:
raise EventResolutionError(
f"Could not match subject {fact.subject_code or fact.subject_name!r} "
f"from {fact.source_pdf!r} to the timetable."
)
print(f"[dates] fact: {fact.title} | code={fact.subject_code} | matched {len(subj_slots)} slot(s)")
# --- date ---
if fact.explicit_date:
# Validate: if the explicit date's weekday doesn't match any grid slot
# for this subject, the date was likely hallucinated. Fall back to class_number.
subj_weekdays = {s.weekday for s in subj_slots}
if subj_weekdays and fact.explicit_date.weekday() not in subj_weekdays:
print(f"[dates] explicit date {fact.explicit_date} (weekday={fact.explicit_date.weekday()}) not in subject weekdays {subj_weekdays} — likely hallucinated, falling back to class_number")
if fact.class_number:
weekdays = sorted(subj_weekdays)
day, estimated = nth_meeting(weekdays, semester_start, fact.class_number, excluded_dates=non_teaching_dates), True
if day:
print(f"[dates] class #{fact.class_number} on weekdays {weekdays} → {day} (estimated)")
else:
print(f"[dates] class #{fact.class_number} on weekdays {weekdays} → could not compute date")
continue
else:
print(f"[dates] no class_number to fall back to — skipping")
continue
else:
day, estimated = fact.explicit_date, False
print(f"[dates] using explicit date: {day}")
day, estimated = fact.explicit_date, False
print(f"[dates] using explicit date: {day}")
elif fact.class_number:
weekdays = sorted({s.weekday for s in subj_slots})
day, estimated = nth_meeting(weekdays, semester_start, fact.class_number, excluded_dates=non_teaching_dates), True
day, estimated = nth_meeting(
weekdays,
semester_start,
fact.class_number,
limit=semester_end,
excluded_dates=non_teaching_dates,
), True
if day:
print(f"[dates] class #{fact.class_number} on weekdays {weekdays} → {day} (estimated)")
else:
print(f"[dates] class #{fact.class_number} on weekdays {weekdays} → could not compute date")
raise EventResolutionError(
f"Could not place class #{fact.class_number} for {fact.title!r} "
"inside the active semester."
)
else:
print(f"[dates] skip (no date info)")
continue
raise EventResolutionError(
f"Event {fact.title!r} from {fact.source_pdf!r} has neither a "
"literal date nor a class number."
)
if day is None:
print(f"[dates] skip (uncomputable date)")
continue
raise EventResolutionError(f"Could not compute a date for {fact.title!r}.")
# --- hours ---
note = ""
if day in non_teaching_dates:
note = "explicit date falls on an accepted non-teaching day; review calendar conflict"
slot = next((s for s in subj_slots if s.weekday == day.weekday()), None)
if slot is None:
slot = subj_slots[0] if subj_slots else None
same_day_slots = [slot for slot in subj_slots if slot.weekday == day.weekday()]
slot = same_day_slots[0] if len(same_day_slots) == 1 else None
if slot is None and not same_day_slots and len(subj_slots) == 1:
slot = subj_slots[0]
note = _append_note(note, "weekday not in timetable; used subject's only slot")
if slot is None and slot_resolver is not None:
slot = slot_resolver(fact, same_day_slots or subj_slots, day)
if slot is not None:
note = _append_note(note, "weekday not in grid; used subject's regular slot")
if slot is not None:
start_t, end_t = slot.start, slot.end
print(f"[dates] slot: {slot.code} {slot.start}-{slot.end} (weekday={slot.weekday})")
else:
start_t, end_t = DEFAULT_SLOT
note = _append_note(note, "subject not in grid; used default slot 07:15-08:55")
print(f"[dates] no slot found; using default {DEFAULT_SLOT[0]}-{DEFAULT_SLOT[1]}")
note = _append_note(note, "timetable slot selected by user")
if slot is None:
reason = "multiple timetable slots match" if same_day_slots else "no timetable slot matches the event weekday"
raise EventResolutionError(
f"Could not assign a time to {fact.title!r}: {reason}."
)
start_t, end_t = slot.start, slot.end
print(f"[dates] slot: {slot.code} {slot.start}-{slot.end} (weekday={slot.weekday})")
events.append(Event(
subject_code=fact.subject_code or (slot.code if slot else ""),
subject_name=fact.subject_name or (slot.name if slot else ""),
subject_code=slot.code or fact.subject_code,
subject_name=slot.name or fact.subject_name,
title=fact.title,
kind=fact.kind,
date=day,
Expand Down
Loading