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
12 changes: 9 additions & 3 deletions app/controllers/api/v1/catalog/instructors_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def index
scope = apply_search(scope)

total = scope.count
people = scope.order(:last_name, :first_name).page(page).per(per_page)
people = scope.page(page).per(per_page)

render_collection(
people.map { |faculty| ::Catalog::InstructorSerializer.new(faculty).as_json },
Expand Down Expand Up @@ -44,14 +44,20 @@ def faculty_ids_for_term
.select("faculties.id")
end

# Ranks by meaning when the caller asks for it, and by the literal
# words otherwise. A semantic search that cannot reach the API falls
# back to the name match rather than failing.
def apply_search(scope)
return scope if params[:q].blank?
return scope.order(:last_name, :first_name) if params[:q].blank?

ranked = ::Catalog::SemanticSearch.ranked_scope(scope, params[:q]) if boolean_param(:semantic)
return ranked if ranked

query = "%#{ActiveRecord::Base.sanitize_sql_like(params[:q].to_s.strip)}%"
scope.where(
"faculties.first_name ILIKE :q OR faculties.last_name ILIKE :q OR faculties.display_name ILIKE :q",
q: query
)
).order(:last_name, :first_name)
end
end
end
Expand Down
1 change: 1 addition & 0 deletions app/controllers/api/v1/catalog/sections_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def filters
crns: array_param(:crn),
pub_ids: array_param(:pub_id),
q: params[:q],
semantic: params[:semantic],
schedule_types: array_param(:schedule_type),
meets_on: array_param(:meets_on),
free_days: array_param(:free_days),
Expand Down
4 changes: 4 additions & 0 deletions app/controllers/api/v1/public_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ def pagination
[ page, per_page ]
end

def boolean_param(key)
ActiveModel::Type::Boolean.new.cast(params[key]).present?
end

def array_param(key)
value = params[key]
return nil if value.blank?
Expand Down
21 changes: 12 additions & 9 deletions app/graphql/types/query_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ class QueryType < BaseObject
description: "Faculty who teach at least one section" do
argument :term_uid, Integer, required: false
argument :q, String, required: false
argument :semantic, Boolean, required: false, default_value: false,
description: "Rank q by meaning instead of by the literal name"
directive Directives::ListSize, **CONNECTION_LIST_SIZE
end

Expand Down Expand Up @@ -80,7 +82,7 @@ def section(crn:, term_uid: nil)
::Catalog::SectionQuery.with_associations(relation).first
end

def instructors(term_uid: nil, q: nil)
def instructors(term_uid: nil, q: nil, semantic: false)
scope = Faculty.where(id: Faculty.joins(:courses).select("faculties.id"))
.includes(:rating_distribution)

Expand All @@ -92,15 +94,16 @@ def instructors(term_uid: nil, q: nil)
)
end

if q.present?
query = "%#{ActiveRecord::Base.sanitize_sql_like(q.strip)}%"
scope = scope.where(
"faculties.first_name ILIKE :q OR faculties.last_name ILIKE :q OR faculties.display_name ILIKE :q",
q: query
)
end
return scope.order(:last_name, :first_name) if q.blank?

ranked = semantic ? ::Catalog::SemanticSearch.ranked_scope(scope, q) : nil
return ranked if ranked

scope.order(:last_name, :first_name)
query = "%#{ActiveRecord::Base.sanitize_sql_like(q.strip)}%"
scope.where(
"faculties.first_name ILIKE :q OR faculties.last_name ILIKE :q OR faculties.display_name ILIKE :q",
q: query
).order(:last_name, :first_name)
end

private
Expand Down
3 changes: 3 additions & 0 deletions app/graphql/types/section_filter_input.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ class SectionFilterInput < BaseInputObject
argument :pub_ids, [ String ], required: false,
description: "Public ids, which stay unique across terms"
argument :q, String, required: false, description: "Free text over title, subject, and number"
argument :semantic, Boolean, required: false, default_value: false,
description: "Rank q by meaning instead of by the literal words. " \
"Falls back to the literal match when semantic search is off."
argument :schedule_types, [ ScheduleTypeEnum ], required: false
argument :meets_on, [ DayOfWeekEnum ], required: false,
description: "Keep sections meeting on at least one of these days"
Expand Down
1 change: 1 addition & 0 deletions app/lib/flipper_flags.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module FlipperFlags
BYPASS_RATE_LIMITS = :bypass_rate_limits
MICROSOFT_SIGN_IN = :microsoft_sign_in
MICROSOFT_GRAPH_CALENDAR = :microsoft_graph_calendar
SEMANTIC_SEARCH = :semantic_search

MAP = {
envSwitcher: ENV_SWITCHER,
Expand Down
21 changes: 18 additions & 3 deletions app/queries/catalog/section_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ class FilterError < StandardError; end
DAYS = Course::MeetingTime.day_of_weeks.freeze

FILTERS = %i[
term_uid subject course_number crns pub_ids q schedule_types
term_uid subject course_number crns pub_ids q semantic schedule_types
meets_on free_days begins_after ends_before
credit_hours instructor include_cancelled
].freeze

DEFAULT_ORDER = "courses.subject ASC, courses.course_number ASC, courses.section_number ASC"

def initialize(scope = Course.all)
@scope = scope
end
Expand All @@ -36,7 +38,6 @@ def call(**filters)
relation = apply_course_number(relation, filters[:course_number])
relation = apply_crns(relation, filters[:crns])
relation = apply_pub_ids(relation, filters[:pub_ids])
relation = apply_search(relation, filters[:q])
relation = apply_schedule_types(relation, filters[:schedule_types])
relation = apply_credit_hours(relation, filters[:credit_hours])
relation = apply_instructor(relation, filters[:instructor])
Expand All @@ -45,7 +46,9 @@ def call(**filters)
relation = apply_begins_after(relation, filters[:begins_after])
relation = apply_ends_before(relation, filters[:ends_before])

relation.order("courses.subject ASC, courses.course_number ASC, courses.section_number ASC")
# The text filter comes last, because a semantic search ranks whatever
# the other filters left and replaces the default order.
apply_text(relation, filters[:q], semantic: truthy?(filters[:semantic]))
end

# Eager-loads everything the serializers and GraphQL types read.
Expand Down Expand Up @@ -121,6 +124,18 @@ def apply_pub_ids(relation, pub_ids)
relation.where(id: ids)
end

# Ranks by meaning when the caller asked for it and the vectors are there,
# and by the literal words otherwise. A semantic search that cannot reach
# the API falls back to the keyword search rather than failing.
def apply_text(relation, query, semantic:)
return relation.order(DEFAULT_ORDER) if query.blank?

ranked = semantic ? SemanticSearch.ranked_scope(relation, query) : nil
return ranked if ranked

apply_search(relation, query).order(DEFAULT_ORDER)
end

def apply_search(relation, query)
return relation if query.blank?

Expand Down
80 changes: 80 additions & 0 deletions app/services/catalog/semantic_search.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# frozen_string_literal: true

module Catalog
# Turns a person's words into the vector the catalog searches by.
#
# Keyword search finds the sections that contain the words. Semantic search
# finds the sections that mean the same thing, so "intro to programming"
# reaches "Computer Science I". Both are offered: a CRN or a course number is
# still best matched literally.
#
# Every query costs an API call, so vectors are cached by the text itself.
# Students ask the same few questions during registration, and the cache
# turns the repeats into no call at all.
module SemanticSearch
CACHE_PREFIX = "catalog/semantic_search/v1"
CACHE_TTL = 1.day

# How many rows the vector search returns before the other filters run.
# One full page is 200 rows, so a page is never short because of this cap.
CANDIDATE_LIMIT = ::Catalog::SectionQuery::MAX_PER_PAGE

module_function

# Semantic search needs a key to embed the query and a flag to say it is
# wanted. Without either, callers fall back to keyword search.
def available?
EmbeddingService.configured? && Flipper.enabled?(FlipperFlags::SEMANTIC_SEARCH)
end

# @param query [String]
# @return [Array<Float>, nil] nil when search is off or the API failed
def vector_for(query)
return nil unless available?

text = query.to_s.strip
return nil if text.blank?

Rails.cache.fetch(cache_key(text), expires_in: CACHE_TTL) do
EmbeddingService.new.embed(text)
end
rescue EmbeddingService::Error => e
# A search that returns the keyword results is better than a search that
# returns an error, so the caller gets nil and falls back.
Rails.logger.warn("[SemanticSearch] #{e.class}: #{e.message}")
nil
end

# The rows of `scope` that mean what the query means, nearest first.
#
# @param scope [ActiveRecord::Relation] a relation of an Embeddable model
# @param query [String]
# @return [ActiveRecord::Relation, nil] nil when the caller should fall
# back to keyword search
def ranked_scope(scope, query, limit: CANDIDATE_LIMIT)
vector = vector_for(query)
return nil if vector.blank?

ids = ranked_ids(scope, vector, limit: limit)
return scope.none if ids.empty?

scope.where(id: ids).in_order_of(:id, ids)
end

# The ids of the rows in `scope` closest to the vector, nearest first.
#
# The scope goes inside the vector query, so filters such as the term are
# applied before the cut rather than after it.
#
# @return [Array<Integer>]
def ranked_ids(scope, vector, limit: CANDIDATE_LIMIT)
scope.model.nearest_to(vector, limit: limit)
.where(id: scope.unscope(:order).select(:id))
.pluck(:id)
end

def cache_key(text)
"#{CACHE_PREFIX}/#{Digest::SHA256.hexdigest(text.downcase)}"
end
end
end
3 changes: 2 additions & 1 deletion config/initializers/flipper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
FlipperFlags::FINALS_RETROACTIVE => "Enables retroactive finals schedule processing for past terms",
FlipperFlags::BYPASS_RATE_LIMITS => "Bypasses rate limiting for trusted users and admins",
FlipperFlags::MICROSOFT_SIGN_IN => "Sign in with Microsoft. Global only: enable it fully, not per actor",
FlipperFlags::MICROSOFT_GRAPH_CALENDAR => "Microsoft Graph calendar sync. Needs Entra admin consent first"
FlipperFlags::MICROSOFT_GRAPH_CALENDAR => "Microsoft Graph calendar sync. Needs Entra admin consent first",
FlipperFlags::SEMANTIC_SEARCH => "Catalog search by meaning. Global only: it needs OPENAI_API_KEY too"
}.freeze

Rails.application.configure do
Expand Down
8 changes: 8 additions & 0 deletions config/initializers/rack_attack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ class Rack::Attack
req.ip if PUBLIC_CATALOG_PATH.call(req)
end

# A semantic search embeds the query, which costs an API call whenever the
# words are new. The cache absorbs the repeats; this limit absorbs the rest.
# Only the REST path is read here: GraphQL carries its query in the body, and
# the 300/min catalog limit above already covers it.
throttle("catalog/semantic", limit: 30, period: 1.minute) do |req|
req.ip if req.path.start_with?("/api/v1/catalog") && req.GET["semantic"].present?
end

throttle("api/process-courses", limit: 5, period: 1.minute) do |req|
user_id = extract_user_id_from_jwt(req)
"process-courses:#{user_id}" if req.path == "/api/process_courses" && req.post? && user_id
Expand Down
5 changes: 5 additions & 0 deletions docs/embeddings.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ width and a full re-embed. Old vectors are not comparable to new ones.
is false, the jobs log and return, and search falls back to keyword matching.
The key lives in the `wit-calendar-env` agenix secret on alastor.

Search by meaning needs the key **and** the `semantic_search` Flipper flag. The
flag is global: turn it on for everybody, not per actor. Turn it off to stop
every query embedding at once, for example if the API bill surprises you. The
catalog then answers with keyword results, and no request fails.

## Postgres

The `vector` extension must be installed on the server.
Expand Down
27 changes: 24 additions & 3 deletions docs/public-catalog-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,8 +207,8 @@ message:

`GET /api/v1/catalog/subjects` accepts `term_uid`.

`GET /api/v1/catalog/instructors` accepts `term_uid`, `q`, `page`, and
`per_page`.
`GET /api/v1/catalog/instructors` accepts `term_uid`, `q`, `semantic`, `page`,
and `per_page`.

`GET /api/v1/catalog/sections/:crn` accepts `term_uid`. Use it when one CRN
occurs in more than one term. This endpoint also returns cancelled sections, so
Expand All @@ -227,6 +227,7 @@ All filters are optional. Give a list as a comma-separated value, for example
| `crn` | `10001,10002` | Keep these CRNs |
| `pub_id` | `crs_kw7coe30` | Keep these sections by public id |
| `q` | `algorithms` | Search the title, subject, and number |
| `semantic` | `true` | Rank `q` by meaning instead of by the literal words |
| `schedule_type` | `lecture` or `LEC` | Keep these schedule types |
| `credit_hours` | `4` | Keep these credit hours |
| `instructor` | `byron` | Match the instructor name |
Expand All @@ -246,6 +247,26 @@ student wants: one Friday afternoon lab still ruins a free Friday.

An unknown filter value returns HTTP 400. An unknown query parameter is ignored.

### Search by meaning

`q` matches the literal words. Add `semantic=true` to rank by meaning instead,
so `intro to programming` reaches `Computer Science I`. The same switch works
on `/api/v1/catalog/instructors`.

```bash
curl "https://calendar.witcc.dev/api/v1/catalog/sections?q=learn+to+program&semantic=true&term_uid=202710"
```

Points to know:

- Results come back ranked, nearest first, not sorted by subject and number.
- Every other filter still applies. The ranking covers what the filters left.
- A section stays out until it has been embedded, which happens nightly.
- Semantic requests are limited to 30 per minute per IP. The keyword search
keeps the standard 300 per minute.
- The server falls back to the keyword search when semantic search is off. The
request never fails because of it.

### Example

Find Computer Science sections in Fall 2026 that keep Friday free and do not
Expand Down Expand Up @@ -277,7 +298,7 @@ value into a string, and GraphQL then rejects booleans and numbers.
| `subjects` | `termUid` | Subjects with section counts |
| `sections` | `filter`, plus Relay arguments | A connection of sections |
| `section` | `crn`, `termUid` | One section, cancelled ones included |
| `instructors` | `termUid`, `q`, plus Relay arguments | A connection of faculty |
| `instructors` | `termUid`, `q`, `semantic`, plus Relay arguments | A connection of faculty |

`sections` and `instructors` are Relay connections. Both add `totalCount`, so a
client can show "50 of 1174" without a second request.
Expand Down
13 changes: 13 additions & 0 deletions docs/public-catalog-api.openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,13 @@ paths:
in: query
description: Search the title, subject, and course number.
schema: { type: string }
- name: semantic
in: query
description: >-
Rank `q` by meaning instead of by the literal words. Results come
back nearest first. Falls back to the literal match when semantic
search is off.
schema: { type: boolean, default: false }
- name: schedule_type
in: query
description: Keep these schedule types, by name or by code, for example `lecture` or `LEC`.
Expand Down Expand Up @@ -287,6 +294,12 @@ paths:
in: query
description: Match the first, last, or display name.
schema: { type: string }
- name: semantic
in: query
description: >-
Rank `q` by meaning instead of by the literal name. Falls back to
the literal match when semantic search is off.
schema: { type: boolean, default: false }
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
responses:
Expand Down
Loading
Loading