diff --git a/app/controllers/api/v1/catalog/instructors_controller.rb b/app/controllers/api/v1/catalog/instructors_controller.rb index 8b66c6eb..23bb6bec 100644 --- a/app/controllers/api/v1/catalog/instructors_controller.rb +++ b/app/controllers/api/v1/catalog/instructors_controller.rb @@ -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 }, @@ -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 diff --git a/app/controllers/api/v1/catalog/sections_controller.rb b/app/controllers/api/v1/catalog/sections_controller.rb index d3e04d23..edb71929 100644 --- a/app/controllers/api/v1/catalog/sections_controller.rb +++ b/app/controllers/api/v1/catalog/sections_controller.rb @@ -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), diff --git a/app/controllers/api/v1/public_controller.rb b/app/controllers/api/v1/public_controller.rb index b515ae0c..5fc8a128 100644 --- a/app/controllers/api/v1/public_controller.rb +++ b/app/controllers/api/v1/public_controller.rb @@ -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? diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 159e80dc..6a91ed1b 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -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 @@ -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) @@ -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 diff --git a/app/graphql/types/section_filter_input.rb b/app/graphql/types/section_filter_input.rb index 855ae005..f2753520 100644 --- a/app/graphql/types/section_filter_input.rb +++ b/app/graphql/types/section_filter_input.rb @@ -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" diff --git a/app/lib/flipper_flags.rb b/app/lib/flipper_flags.rb index e5307232..e8d8ead0 100644 --- a/app/lib/flipper_flags.rb +++ b/app/lib/flipper_flags.rb @@ -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, diff --git a/app/queries/catalog/section_query.rb b/app/queries/catalog/section_query.rb index a8169bde..ddc35995 100644 --- a/app/queries/catalog/section_query.rb +++ b/app/queries/catalog/section_query.rb @@ -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 @@ -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]) @@ -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. @@ -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? diff --git a/app/services/catalog/semantic_search.rb b/app/services/catalog/semantic_search.rb new file mode 100644 index 00000000..5c8fbdad --- /dev/null +++ b/app/services/catalog/semantic_search.rb @@ -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, 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] + 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 diff --git a/config/initializers/flipper.rb b/config/initializers/flipper.rb index 52df7100..2746fc95 100644 --- a/config/initializers/flipper.rb +++ b/config/initializers/flipper.rb @@ -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 diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index 5c2f9688..cefe80a7 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -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 diff --git a/docs/embeddings.md b/docs/embeddings.md index f13512df..5de7e69e 100644 --- a/docs/embeddings.md +++ b/docs/embeddings.md @@ -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. diff --git a/docs/public-catalog-api.md b/docs/public-catalog-api.md index 720ed448..8e4899eb 100644 --- a/docs/public-catalog-api.md +++ b/docs/public-catalog-api.md @@ -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 @@ -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 | @@ -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 @@ -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. diff --git a/docs/public-catalog-api.openapi.yml b/docs/public-catalog-api.openapi.yml index 819b509b..c022b78e 100644 --- a/docs/public-catalog-api.openapi.yml +++ b/docs/public-catalog-api.openapi.yml @@ -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`. @@ -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: diff --git a/spec/queries/catalog/section_query_spec.rb b/spec/queries/catalog/section_query_spec.rb index 3b96ea55..12d9a086 100644 --- a/spec/queries/catalog/section_query_spec.rb +++ b/spec/queries/catalog/section_query_spec.rb @@ -103,6 +103,53 @@ def crns_for(**filters) end end + describe "semantic" do + context "with search on", :semantic_search do + before do + give_embedding(comp1000, 0.05) + give_embedding(comp2000, 0.50) + give_embedding(math1750, 0.95) + stub_openai_embeddings([ embedding_vector(0.0) ]) + end + + it "ranks by meaning instead of by subject and number" do + expect(crns_for(q: "learn to program", semantic: true)).to eq([ 10_001, 10_002, 20_001 ]) + end + + it "ranks only the sections the other filters left" do + expect(crns_for(q: "learn to program", semantic: true, term_uid: 202_620)).to eq([ 20_001 ]) + end + + it "leaves out sections that have no vector yet" do + expect(crns_for(q: "learn to program", semantic: true)).not_to include(10_003) + end + + it "keeps the keyword search when the caller does not ask for meaning" do + expect(crns_for(q: "Course 1000")).to eq([ 10_001 ]) + expect(a_request(:post, EmbeddingService::API_URL)).not_to have_been_made + end + + it "returns nothing when no section matches the filters" do + expect(crns_for(q: "learn to program", semantic: true, subject: "PHYS")).to be_empty + end + end + + context "with search off", :embeddings do + it "falls back to the keyword search" do + expect(crns_for(q: "Course 1000", semantic: true)).to eq([ 10_001 ]) + expect(a_request(:post, EmbeddingService::API_URL)).not_to have_been_made + end + end + + context "when the API fails", :semantic_search do + it "falls back to the keyword search" do + stub_request(:post, EmbeddingService::API_URL).to_return(status: 500, body: "{}") + + expect(crns_for(q: "Course 1000", semantic: true)).to eq([ 10_001 ]) + end + end + end + describe "schedule_types" do it "accepts the enum key" do expect(crns_for(schedule_types: "lecture").size).to eq(4) diff --git a/spec/requests/api/graphql_spec.rb b/spec/requests/api/graphql_spec.rb index 396c3482..a0b98a50 100644 --- a/spec/requests/api/graphql_spec.rb +++ b/spec/requests/api/graphql_spec.rb @@ -204,6 +204,36 @@ def gql(query, variables: nil) end end + describe "semantic search", :semantic_search do + before { stub_openai_embeddings([ embedding_vector(0.0) ]) } + + it "ranks sections by meaning" do + give_embedding(comp1000, 0.05) + give_embedding(math1750, 0.95) + + result = gql('{ sections(filter: { q: "learn to program", semantic: true }, first: 10) { nodes { crn } } }') + + expect(result["errors"]).to be_nil + expect(result["data"]["sections"]["nodes"].map { |n| n["crn"] }).to eq([ 10_001, 20_001 ]) + end + + it "ranks instructors by meaning" do + give_embedding(ada, 0.05) + give_embedding(grace, 0.95) + + result = gql('{ instructors(q: "teaches computing", semantic: true, first: 10) { nodes { name } } }') + + expect(result["data"]["instructors"]["nodes"].map { |n| n["name"] }).to eq([ "Ada Byron", "Grace Hop" ]) + end + + it "falls back to the literal match when semantic is not asked for" do + result = gql('{ instructors(q: "byron", first: 10) { nodes { name } } }') + + expect(result["data"]["instructors"]["nodes"].map { |n| n["name"] }).to eq([ "Ada Byron" ]) + expect(a_request(:post, EmbeddingService::API_URL)).not_to have_been_made + end + end + describe "errors" do it "rejects an invalid enum value at validation time" do result = gql('{ sections(filter: { meetsOn: [FUNDAY] }) { totalCount } }') diff --git a/spec/requests/api/v1/catalog/instructors_spec.rb b/spec/requests/api/v1/catalog/instructors_spec.rb index 80e060d0..23bf7862 100644 --- a/spec/requests/api/v1/catalog/instructors_spec.rb +++ b/spec/requests/api/v1/catalog/instructors_spec.rb @@ -31,6 +31,28 @@ def json = JSON.parse(response.body) end end + describe "semantic search", :semantic_search do + before do + give_embedding(ada, 0.05) + give_embedding(grace, 0.95) + stub_openai_embeddings([ embedding_vector(0.0) ]) + end + + it "ranks the instructors that mean what the query means" do + get "/api/v1/catalog/instructors", params: { q: "teaches computing", semantic: "true" } + + expect(response).to have_http_status(:ok) + expect(json["data"].map { |i| i["name"] }).to eq([ "Ada Byron", "Grace Hop" ]) + end + + it "matches the literal name when semantic is not asked for" do + get "/api/v1/catalog/instructors", params: { q: "byron" } + + expect(json["data"].map { |i| i["name"] }).to eq([ "Ada Byron" ]) + expect(a_request(:post, EmbeddingService::API_URL)).not_to have_been_made + end + end + describe "GET /api/v1/catalog/instructors/:pub_id" do it "returns the instructor without contact details" do get "/api/v1/catalog/instructors/#{ada.public_id}" diff --git a/spec/requests/api/v1/catalog/sections_spec.rb b/spec/requests/api/v1/catalog/sections_spec.rb index 45297bf9..723bcb6c 100644 --- a/spec/requests/api/v1/catalog/sections_spec.rb +++ b/spec/requests/api/v1/catalog/sections_spec.rb @@ -170,6 +170,35 @@ def crns = json["data"].map { |s| s["crn"] } end end + describe "semantic search", :semantic_search do + before do + give_embedding(comp1000, 0.05) + give_embedding(math1750, 0.95) + stub_openai_embeddings([ embedding_vector(0.0) ]) + end + + it "ranks the sections that mean what the query means" do + get "/api/v1/catalog/sections", params: { q: "learn to program", semantic: "true" } + + expect(response).to have_http_status(:ok) + expect(crns).to eq([ 10_001, 20_001 ]) + expect(json["meta"]["total_count"]).to eq(2) + end + + it "reports the filters it used" do + get "/api/v1/catalog/sections", params: { q: "learn to program", semantic: "true" } + + expect(json["meta"]["filters"]).to include("q" => "learn to program", "semantic" => "true") + end + + it "searches the literal words when semantic is not asked for" do + get "/api/v1/catalog/sections", params: { q: "Course 1000" } + + expect(crns).to eq([ 10_001 ]) + expect(a_request(:post, EmbeddingService::API_URL)).not_to have_been_made + end + end + describe "pagination" do it "honours page and per_page" do get "/api/v1/catalog/sections", params: { page: 2, per_page: 2 } diff --git a/spec/services/catalog/semantic_search_spec.rb b/spec/services/catalog/semantic_search_spec.rb new file mode 100644 index 00000000..f816be52 --- /dev/null +++ b/spec/services/catalog/semantic_search_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Catalog::SemanticSearch do + let(:term) { create(:term) } + + describe ".available?", :embeddings do + it "is false until the flag is on" do + expect(described_class).not_to be_available + end + + it "is true with the key and the flag", :semantic_search do + expect(described_class).to be_available + end + + it "is false without the key", :semantic_search do + ENV.delete("OPENAI_API_KEY") + + expect(described_class).not_to be_available + end + end + + describe ".vector_for", :semantic_search do + it "embeds the query" do + stub_openai_embeddings([ embedding_vector(0.4) ]) + + expect(described_class.vector_for("intro to programming")).to match_vector(embedding_vector(0.4)) + end + + it "asks the API once for a query it has already embedded" do + allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new) + stub_openai_embeddings([ embedding_vector(0.4) ]) + + 2.times { described_class.vector_for("intro to programming") } + + expect(a_request(:post, EmbeddingService::API_URL)).to have_been_made.once + end + + it "reads the same cache entry whatever the case of the query" do + expect(described_class.cache_key("Intro To Programming")).to eq(described_class.cache_key("intro to programming")) + end + + it "returns nil for a blank query, without calling the API" do + expect(described_class.vector_for(" ")).to be_nil + expect(a_request(:post, EmbeddingService::API_URL)).not_to have_been_made + end + + it "returns nil when the API fails, so the caller can fall back" do + stub_request(:post, EmbeddingService::API_URL).to_return(status: 500, body: "{}") + + expect(described_class.vector_for("intro to programming")).to be_nil + end + end + + describe ".vector_for when search is off", :embeddings do + it "returns nil without calling the API" do + expect(described_class.vector_for("intro to programming")).to be_nil + expect(a_request(:post, EmbeddingService::API_URL)).not_to have_been_made + end + end + + describe ".ranked_scope", :semantic_search do + let!(:near) { give_embedding(create(:course, term: term, title: "near"), 0.05) } + let!(:far) { give_embedding(create(:course, term: term, title: "far"), 0.95) } + + before { stub_openai_embeddings([ embedding_vector(0.0) ]) } + + it "returns the closest rows of the scope first" do + expect(described_class.ranked_scope(Course.all, "anything")).to eq([ near, far ]) + end + + it "ranks only what the scope allows" do + expect(described_class.ranked_scope(Course.where(id: far.id), "anything")).to eq([ far ]) + end + + it "returns an empty relation when the scope has no embedded rows" do + expect(described_class.ranked_scope(Course.where(title: "missing"), "anything")).to be_empty + end + + it "stops at the candidate limit" do + expect(described_class.ranked_scope(Course.all, "anything", limit: 1)).to eq([ near ]) + end + + it "returns nil when the query cannot be embedded, so the caller falls back" do + stub_request(:post, EmbeddingService::API_URL).to_return(status: 500, body: "{}") + + expect(described_class.ranked_scope(Course.all, "anything")).to be_nil + end + end +end diff --git a/spec/support/embeddings.rb b/spec/support/embeddings.rb index bc1160c9..b1356dfd 100644 --- a/spec/support/embeddings.rb +++ b/spec/support/embeddings.rb @@ -63,4 +63,15 @@ def give_embedding(record, angle) config.around(:each, :embeddings) do |example| with_openai_configured { example.run } end + + # Semantic search also needs its flag. The flag is a row, so it is written in + # a before hook: an around hook runs outside the example's transaction, and + # the example would not see the row. The after hook clears the Flipper cache, + # which no transaction rolls back. + config.around(:each, :semantic_search) do |example| + with_openai_configured { example.run } + end + + config.before(:each, :semantic_search) { Flipper.enable(FlipperFlags::SEMANTIC_SEARCH) } + config.after(:each, :semantic_search) { Flipper.disable(FlipperFlags::SEMANTIC_SEARCH) } end