OASIS
Structured, traceable course data from inconsistent official web pages, with a local LLM kept on a short leash.
Source code is private. This page shows a sanitised architecture, documented decisions and test evidence.
Problem
Problem
Course information is spread across official university pages that are each structured differently and change without notice. Comparing programmes usually means trusting data someone copied by hand, which is often already out of date.
Scraping is the easy part. The hard part is knowing which extracted facts you can trust, and keeping that true as sites change.
Constraints
Constraints
- Local-first
- Runs on my own machine with a local model through Ollama, with no paid API dependency.
- Facts beat fluency
- A model may fill gaps but must never overwrite a value the rules already extracted.
- Traceable
- Official sources only. Raw pages are kept as evidence so every field can be traced back.
- One developer
- Safety has to come from automated tests and gates, not from someone remembering to check.
- Hostile inputs
- Some sites render client-side, some block automated access, and none share a structure.
Architecture
Architecture
Registryconfig per uni
Stored data Per-university settings live in configuration, not code. A university's status (candidate or active) is a field here.
Onboarding runsample pipeline pass
Deterministic step A new candidate gets a sample run through the real pipeline before anything is trusted.
Gap reportP0–P3 issues
Deterministic step Missing or suspect fields are classified by severity, so effort goes to what blocks correctness first.
Config suggestionhuman approves diff
Human decision The system drafts configuration changes. A person approves them; nothing is applied automatically.
Regression gatepromote/hold/reject
Deterministic step Promotion to active is a manual registry edit, made only after a PROMOTE verdict. Current state: 0 promote, 3 hold, the rest reject.
Discoverfind course pages
Deterministic step Finds candidate course pages from listing pages using clustering and scoring heuristics.
Fetchstatic first
Deterministic step Plain HTTP first. A headless browser is a scoped fallback, switched on per configuration, not the default.
Validate pagedrop non-course
Deterministic step Pages that aren't real course pages are skipped before any evidence is saved.
Extractevidence saved
Deterministic step Raw HTML and extracted text are stored as evidence, so every field can be traced back to its source page.
9 field parsersdeterministic
Deterministic step Config-driven parsers for degree, duration, fees, modules, intakes, campus, study mode, language and entry requirements.
LLM fillonly empty fields
Model call One narrowly scoped local-model call, given only the fields the parsers left empty.
Mergedeterministic wins
Deterministic step If a parser found a value, the model's answer is discarded. The LLM can add facts; it can never overwrite them.
Normaliseschema + canonical
Deterministic step Schema validation plus canonical degree names, durations, intakes, campus names and currency.
Change detectiondiff vs last run
Deterministic step Each run is compared with the stored record, so changes on a university's site show up as a report.
Structured recordsone per programme
Stored data One JSON record per programme. Duplicates are quarantined (moved, not deleted) with a manifest.
Matching engineexplainable scoring
Deterministic step Mandatory criteria plus weighted scoring, with every result explained. It never imports the ingestion code.
Document intaketranscript → profile
Deterministic step Turns an uploaded transcript into the same profile shape a manual form produces. Isolated from every other module.
APIFastAPI
Deterministic step Serves universities, courses and recommendations, with OpenAPI docs. It reuses the engine and never duplicates logic.
Review dashboardinternal
Deterministic step Internal UI for testing profiles against real records. It talks to the API over HTTP only.
End-user productnot built yet
Not built No public product runs on OASIS yet. It is shown here so the diagram doesn't overstate what exists.
- Stored data
- Deterministic step
- Human decision
- Model call
- Not built
Simplified from the repository's architecture document. Module names are generalised and internal paths omitted. Select a node, or use the arrow keys, to read what it does.
- Review dashboardtalks to the API over HTTP only
- APIFastAPI; reuses the engine, never duplicates it
- Recommendation · Document intakeisolated peers; neither imports ingestion
- Ingestion pipelinepage → evidence → parsers → LLM gaps → record
- Registry and onboarding gatedecides what is trusted
Key decisions
Key decisions
Deterministic first, the LLM last
Nine config-driven parsers run first. One narrowly scoped local-model call sees only the fields they left empty, and the merge step always keeps the deterministic value.
Show reasoning
- Context
- Field values have to be right, not plausible. LLMs are good at reading messy pages and bad at admitting they don't know.
- Options considered
- LLM extraction for every field · rules only · rules first, with an LLM for what they miss
- Trade-off accepted
- Slower to build, field by field, and some fields stay empty rather than guessed.
Isolation enforced by tests, not convention
Static import-analysis tests fail if, for example, the recommendation engine ever imports ingestion code. The dashboard talks to the API over HTTP only.
Show reasoning
- Context
- Five modules (ingestion, recommendation, document intake, API and dashboard) could easily start reaching into each other.
- Options considered
- One package with good intentions · separate services · one repo with enforced boundaries
- Trade-off accepted
- Interfaces have to be designed explicitly, even where a direct import would be quicker.
A gate between “works on a sample” and “live”
An onboarding run produces a severity-ranked gap report. The system drafts config fixes that a person approves, and a regression gate returns PROMOTE, HOLD or REJECT. Promotion itself is a manual edit.
Show reasoning
- Context
- A new university that parses well on five pages can still be wrong on the fiftieth.
- Options considered
- Add universities once they run · manual review only · an automated verdict plus a human decision
- Trade-off accepted
- Growth is slow on purpose: 4 universities are live, and none of the 46 candidates has been promoted yet.
Revisited: a decision I changed or corrected after the evidence moved.
Revisited: no headless browser, then a scoped one
As the candidate list grew and many returned zero courses, the argument for headless got stronger, so I changed my mind. I added it as a config-gated fallback and then measured it against 18 zero-discovery candidates.
Show reasoning
- Context
- The original design deliberately excluded headless rendering. Pages that only render client-side were out of scope, and one university was left out for exactly that reason.
- Options considered
- Keep the exclusion · switch to headless everywhere · add a fallback behind configuration
- Trade-off accepted
- Result: 5 of 18 newly produced courses (3 because of headless, 2 through static discovery on a fresh run), 4 were confirmed blocked, and 9 still found nothing despite a real browser render. The assumption behind the fix was only partly right, and the measurement showed which part.
Quarantine, don't delete
Confirmed duplicates (12 groups, 17 files) were moved to a quarantine folder with a manifest. Two suspected groups turned out to be an extraction bug, which was fixed instead.
Show reasoning
- Context
- Duplicate programme records appeared after file-naming drift between runs.
- Options considered
- Delete duplicates · ignore them · move them aside with a manifest
- Trade-off accepted
- Permanent deletion is still a pending follow-up, but nothing was lost on a false positive.
Implementation
Implementation
- Python pipeline: discover → fetch (static, with a headless fallback) → validate → extract and save evidence → 9 field parsers → LLM gap-fill → merge → validate and normalise → change detection → store.
- Explainable matching engine with mandatory criteria and weighted scoring, exposed through a FastAPI service with OpenAPI docs.
- Document intake that turns a transcript into the same profile shape a manual form produces, isolated from every other module.
- University registry and onboarding framework: per-university JSON config, gap reports, human-approved config suggestions and the regression gate.
Validation
Validation
- 858 offline tests
- Test methods across 46 files, run with Python's unittest. No real network or LLM calls, and fixtures copied from real fetched pages.
- CI on every change
- GitHub Actions runs the suite on pushes and pull requests. The most recent run, on 17 Aug 2026, passed.
- Boundaries are tests
- Layer isolation is checked by static import analysis, not code review.
- Where it started
- An early internal audit recorded 24 tests in 2 files and no CI. Writing that down is what turned a working demo into a project with a reliability bar.
Results
Results
- 858offline regression tests across 46 files, run in CICodebase
- 5isolated layers, with isolation enforced by static import testsCodebase
- 4 / 50universities live; none of the 46 candidates has passed the promotion gate yetObserved in use
- Codebase 5 isolated modules, 9 deterministic field parsers, and an API with documented endpoints.
- Observed in use 4 universities live. 46 candidates in onboarding, of which the gate currently holds 3 and rejects the rest.
Limitations
Limitations
- No public end-user product runs on OASIS yet.
- Only 4 universities are live, so every “general” heuristic has been proven against a small set of sites.
- 9 candidate sites still yield nothing even with a headless browser; the cause is not yet diagnosed.
- Some sites block automated access outright, and the system respects that rather than working around it.
- Quarantined duplicates are still awaiting permanent deletion.
Lessons
Lessons
- Put the model where rules run out, and make sure it can't overwrite them.
- A gate that says “no” most of the time is doing its job.
- When you reverse a decision, measure the assumption behind the reversal too.
What I'd do differently
- Write decision records as decisions happen. The repository's decisions folder is still empty, and the reasoning lives in design documents instead.
- Test the most heuristic code first. The early audit found that discovery and layout detection, the riskiest parts, had no tests at all.
Where the numbers come from
- 858 offline regression tests across 46 files, run in CI. Test methods at the latest commit; last CI run passed 17 Aug 2026. Source: private repository, verified 18 Oct 2026
- 5 isolated layers, with isolation enforced by static import tests. ingestion · recommendation · API · document intake · dashboard. Source: private repository, verified 18 Oct 2026
- 4 / 50 universities live; none of the 46 candidates has passed the promotion gate yet. Registry status at the latest commit (4 active, 46 candidate). Source: private repository, verified 18 Oct 2026