File size: 11,492 Bytes
46de6a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fcdba1d
 
 
 
 
 
 
 
46de6a9
fcdba1d
46de6a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fcdba1d
 
 
 
 
 
 
 
46de6a9
 
 
 
 
 
fcdba1d
 
 
 
 
 
 
 
 
 
46de6a9
 
 
 
 
 
fcdba1d
 
 
 
 
 
 
 
 
46de6a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fcdba1d
 
 
 
 
 
46de6a9
 
 
 
 
 
fcdba1d
 
 
 
 
 
 
 
46de6a9
fcdba1d
46de6a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9db5d1e
 
 
 
 
46de6a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
"""
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)