UBCHelper / src /config.py
Devdan Schretlen
Claude Opus 5 (1M context)
Expand the calendar corpus to every faculty, and rewrite the UI copy
fcdba1d
Raw History Blame Contribute Delete
11.5 kB
"""
Central configuration — every tunable lives here, nothing is hard-coded elsewhere.
Ported from RAGChef. The retrieval / generation / eval knobs are domain-agnostic and kept
as-is (just re-prefixed UBCAL_); the recipe-CSV corpus block is replaced by the calendar
scrape/chunk block below. Environment overrides can live in a `.env` at the project root
(copy `.env.example`).
"""
from __future__ import annotations
import os
from pathlib import Path
from dotenv import load_dotenv
# --------------------------------------------------------------------------------------
# Paths
# --------------------------------------------------------------------------------------
# PROJECT_ROOT is this file's grandparent: <root>/src/config.py -> <root>
PROJECT_ROOT = Path(__file__).resolve().parent.parent
# Load .env before reading settings (real env vars still win — load_dotenv won't override).
load_dotenv(PROJECT_ROOT / ".env")
DATA_DIR = PROJECT_ROOT / "data"
PAGES_DIR = DATA_DIR / "pages" # raw crawl (gitignored): one JSON record per page
MANIFEST_PATH = PAGES_DIR / "manifest.json" # url -> current snapshot (conditional-GET state)
COURSES_PATH = DATA_DIR / "courses.jsonl" # optional structured course/prereq table
INDEX_DIR = PROJECT_ROOT / "index" # built by ingest.py; gitignored
# Where ingest.py writes the index (the "vector store"). Two small files:
EMBEDDINGS_PATH = INDEX_DIR / "embeddings.npy" # float32 matrix, shape (N, dim)
METADATA_PATH = INDEX_DIR / "metadata.json" # list of N chunk records (id-aligned)
# ======================================================================================
# Data layer: scraping the UBC Vancouver Academic Calendar
# ======================================================================================
# The corpus is built to be hard for naive top-k RAG:
# - concurrent editions (2026/27 live + a 2025/26 archive slice), and
# - cohort-split requirement pages disambiguated only by page title.
# See docs / the scrape plan for the reasoning behind the scope below.
CALENDAR_BASE_URL = os.getenv("UBCAL_BASE_URL", "https://vancouver.calendar.ubc.ca")
ARCHIVE_BASE_URL = os.getenv("UBCAL_ARCHIVE_BASE_URL", "https://archive.calendar.ubc.ca")
# Sitemap index (6 sub-sitemaps of readable aliases) — primary URL discovery for the live site.
SITEMAP_URL = CALENDAR_BASE_URL + "/sitemap.xml"
# Editions. The live site serves 2026/27; the 2025/26 edition is a frozen static mirror of the
# same Drupal theme under this archive subdirectory.
LIVE_EDITION_YEAR = "2026/27"
ARCHIVE_EDITION_YEAR = "2025/26"
ARCHIVE_EDITION_DIR = "vancouver/2526" # archive.calendar.ubc.ca/vancouver/2526/<path>/index.html
# Which editions to crawl (comma-separated): "live", "archive", or both.
EDITIONS = os.getenv("UBCAL_EDITIONS", "live,archive").split(",")
# --- Scope: LIVE 2026/27 = every faculty/college/school + campus-wide policies (+ the course
# subjects below). A live URL is in scope if its path starts with one of these prefixes (or
# exactly matches a course-subject path).
#
# This used to name the Faculty of Science subtree plus the five cohort-split programs
# individually. Widening to the whole chapter picks up the remaining faculties (Applied Science,
# Medicine, Education, Land & Food Systems, and the rest) without having to enumerate them, and
# the cohort-split programs it used to list are all children of it anyway.
LIVE_SCOPE_PREFIXES = [
"faculties-colleges-and-schools",
"campus-wide-policies-and-regulations",
]
# --- Scope: ARCHIVE 2025/26 = NARROW collision slice. Only the requirement-bearing subtrees
# where editions collide (general B.Sc. + CS + the cohort programs + course subjects). Derived
# from the live path_keys that match these prefixes (the archive has no sitemap).
ARCHIVE_SCOPE_PREFIXES = [
"faculties-colleges-and-schools/faculty-science/bachelor-science",
"faculties-colleges-and-schools/faculty-science/bachelor-computer-science",
"faculties-colleges-and-schools/faculty-arts/bachelor-arts",
"faculties-colleges-and-schools/faculty-arts/bachelor-media-studies",
"faculties-colleges-and-schools/faculty-commerce-and-business-administration/bachelor-commerce",
"faculties-colleges-and-schools/vancouver-school-economics/bachelor-international-economics",
"faculties-colleges-and-schools/faculty-forestry-and-environmental-stewardship/bsc-natural-resources-students-starting-september-2024",
"faculties-colleges-and-schools/faculty-forestry-and-environmental-stewardship/bsc-degrees-students-who-started-prior-september-2024",
]
# The index page that lists every subject, which enumerate_subjects() reads. Note this is the
# "Courses by Subject" sub-page, not the /course-descriptions chapter landing page, which links
# only to its own table of contents.
COURSE_SUBJECT_INDEX_PATH = "course-descriptions/courses-subject"
# Course descriptions: one page per subject (exact-path match). These are taxonomy-term pages
# the sitemap omits, so scrape.enumerate_subjects() discovers the full live set from the
# course-descriptions index at crawl time; this list is the fallback if that fetch fails.
COURSE_SUBJECT_PATHS = [
"course-descriptions/subject/cpscv",
"course-descriptions/subject/mathv",
"course-descriptions/subject/statv",
]
# The ARCHIVE edition deliberately mirrors only these three subjects, not every subject the live
# crawl finds. The archive exists to create edition collisions on requirement-bearing pages (see
# ARCHIVE_SCOPE_PREFIXES below); mirroring ~200 more subject pages would double the crawl and
# blunt that trap into general near-duplicate noise without testing anything new.
ARCHIVE_SUBJECT_PATHS = [
"course-descriptions/subject/cpscv",
"course-descriptions/subject/mathv",
"course-descriptions/subject/statv",
]
# Robots.txt Disallow prefixes we must never fetch (belt-and-suspenders; the sitemap won't
# list these anyway).
ROBOTS_DISALLOW_PREFIXES = [
"admin/", "search", "user/", "node/add", "comment/reply", "filter/tips", "media/oembed",
]
# Path segments excluded from the live crawl even though they sit inside a scope prefix.
# `academic-staff` is a per-faculty roster of people (114 pages; one of them is a single
# 159,000-character list of names). Nothing a student asks about courses, programs or policies
# is answered by a staff directory, so these pages would only add retrieval distractors, and
# the largest of them are individually too big for the embedding model to accept.
EXCLUDE_PATH_SEGMENTS = [
s for s in os.getenv("UBCAL_EXCLUDE_PATH_SEGMENTS", "academic-staff").split(",") if s
]
# A handful of structurally different pages for the extraction smoke test (`scrape --sample`):
# a CS requirements page (15 tables), both B.A. cohort pages, a policy page, and a course page.
SAMPLE_PATHS = [
"faculties-colleges-and-schools/faculty-science/bachelor-science/computer-science",
"faculties-colleges-and-schools/faculty-arts/bachelor-arts/degree-requirements-students-who-enter-program-202324-or-earlier",
"faculties-colleges-and-schools/faculty-arts/bachelor-arts/degree-requirements-students-who-enter-program-202425-or-later",
"campus-wide-policies-and-regulations/academic-standing",
"course-descriptions/subject/cpscv",
]
# --- Politeness (the site's robots.txt asks for Crawl-delay: 10) ---
CRAWL_DELAY = float(os.getenv("UBCAL_CRAWL_DELAY", "10")) # seconds between requests
REQUEST_TIMEOUT = float(os.getenv("UBCAL_REQUEST_TIMEOUT", "30"))
MAX_RETRIES = int(os.getenv("UBCAL_MAX_RETRIES", "3")) # on 429 / 5xx
BACKOFF_BASE = float(os.getenv("UBCAL_BACKOFF_BASE", "10")) # exponential: base * 2**attempt
USER_AGENT = os.getenv(
"UBCAL_USER_AGENT",
"UBCHelper-RAG-demo/0.1 (educational, non-commercial; respects robots.txt)",
)
# --- Chunking (calendar pages are long/heterogeneous, unlike one-recipe-one-doc) ---
CHUNK_MAX_CHARS = int(os.getenv("UBCAL_CHUNK_MAX_CHARS", "1800"))
CHUNK_OVERLAP = int(os.getenv("UBCAL_CHUNK_OVERLAP", "150"))
CHUNK_MIN_CHARS = int(os.getenv("UBCAL_CHUNK_MIN_CHARS", "120")) # merge tiny trailing chunks
# Hard ceiling, enforced even on blocks that are otherwise kept whole (requirement tables).
# text-embedding-3-small rejects any input over 8192 tokens, and a single oversized chunk kills
# the whole ingest mid-batch with nothing saved. Calendar text tokenizes densely (tables, codes,
# names), so this is set well under 8192 * 4 chars to stay safe on the worst-tokenizing pages.
CHUNK_HARD_MAX_CHARS = int(os.getenv("UBCAL_CHUNK_HARD_MAX_CHARS", "16000"))
# ======================================================================================
# Retrieval / generation / eval knobs (domain-agnostic; used once the engine is copied in)
# ======================================================================================
TOP_K = int(os.getenv("UBCAL_TOP_K", "4"))
RETRIEVAL_MODE = os.getenv("UBCAL_RETRIEVAL_MODE", "hybrid_rerank") # dense | hybrid | hybrid_rerank
# Stage-1 recall width. These were 20/20/20; measured at 50 on the 12-question test set,
# hit@4 went 0.667 -> 0.833 and MRR 0.542 -> 0.667, at both the 3,770- and 16,576-chunk corpus
# sizes. The win is the reranker getting a deeper shortlist to work with: the golds it was
# missing sat at stage-1 ranks 20-50, where it never saw them. Cost is unchanged in API calls
# (rerank takes the shortlist in one request), only the documents per request go up.
DENSE_K = int(os.getenv("UBCAL_DENSE_K", "50"))
SPARSE_K = int(os.getenv("UBCAL_SPARSE_K", "50"))
RRF_K = int(os.getenv("UBCAL_RRF_K", "60"))
RERANK_CANDIDATES = int(os.getenv("UBCAL_RERANK_CANDIDATES", "50"))
RERANKER_MODEL = os.getenv("UBCAL_RERANKER_MODEL", "rerank-v3.5")
COHERE_API_KEY = os.getenv("COHERE_API_KEY", "")
LLM_BACKEND = os.getenv("UBCAL_LLM_BACKEND", "anthropic") # anthropic | local
ANTHROPIC_MODEL = os.getenv("UBCAL_ANTHROPIC_MODEL", "claude-haiku-4-5")
OPENAI_BASE_URL = os.getenv("UBCAL_BASE_URL_LLM", "http://localhost:1234/v1")
OPENAI_API_KEY = os.getenv("UBCAL_API_KEY", "lm-studio")
CHAT_MODEL = os.getenv("UBCAL_CHAT_MODEL", "google/gemma-4-e4b")
# Embeddings — hosted OpenAI API. The SAME model must embed documents (ingest) and queries.
EMBED_BASE_URL = os.getenv("UBCAL_EMBED_BASE_URL") # None -> real OpenAI endpoint
EMBED_API_KEY = os.getenv("OPENAI_API_KEY", "")
EMBEDDING_MODEL = os.getenv("UBCAL_EMBED_MODEL", "text-embedding-3-small")
# Per-request timeout for the hosted APIs. The SDK defaults are 300-600 s, which on a demo
# serving one run at a time means a single stalled call holds the only slot for ten minutes.
# Real calls take 1-5 s, so a minute is generous.
API_TIMEOUT = float(os.getenv("UBCAL_API_TIMEOUT", "60"))
TEMPERATURE = float(os.getenv("UBCAL_TEMPERATURE", "0.2"))
MAX_TOKENS = int(os.getenv("UBCAL_MAX_TOKENS", "1024"))
AGENT_MAX_SUBQ = int(os.getenv("UBCAL_AGENT_MAX_SUBQ", "3"))
AGENT_SUBQ_TOP_K = int(os.getenv("UBCAL_AGENT_SUBQ_TOP_K", str(TOP_K)))
SYNTH_CONTEXT_K = int(os.getenv("UBCAL_SYNTH_CONTEXT_K", "8"))
EVAL_DIR = PROJECT_ROOT / "eval"
TESTSET_PATH = EVAL_DIR / "testset.json"
GENERAL_TESTSET_PATH = EVAL_DIR / "testset.general.json"
REPORTS_DIR = EVAL_DIR / "reports"
REPORT_PATH = REPORTS_DIR / "report.md"
ABLATION_REPORT_PATH = REPORTS_DIR / "ablation.md"
HISTORY_PATH = REPORTS_DIR / "history.csv"
EVAL_K = int(os.getenv("UBCAL_EVAL_K", str(TOP_K)))
JUDGE_MODEL = os.getenv("UBCAL_JUDGE_MODEL", CHAT_MODEL)