diff --git a/app/controllers/api/v1/catalog/instructors_controller.rb b/app/controllers/api/v1/catalog/instructors_controller.rb index 23bb6bec..5daf9f20 100644 --- a/app/controllers/api/v1/catalog/instructors_controller.rb +++ b/app/controllers/api/v1/catalog/instructors_controller.rb @@ -4,6 +4,8 @@ module Api module V1 module Catalog # GET /api/v1/catalog/instructors + # GET /api/v1/catalog/instructors/:pub_id + # GET /api/v1/catalog/instructors/:pub_id/similar class InstructorsController < Api::V1::PublicController def index page, per_page = pagination @@ -30,13 +32,35 @@ def index end def show + render_resource(::Catalog::InstructorSerializer.new(find_instructor).as_json) + end + + # Instructors who teach something close to what this one teaches. The + # list is empty until the instructor has a vector, which the nightly + # backfill writes. + def similar + faculty = find_instructor + people = faculty.similar_instructors(limit: similar_limit).includes(:rating_distribution) + + render_collection( + people.map { |person| ::Catalog::InstructorSerializer.new(person).as_json }, + meta: { pub_id: faculty.public_id, limit: similar_limit } + ) + end + + private + + def find_instructor faculty = Faculty.includes(:rating_distribution).find_by_public_id(params[:pub_id]) raise ActiveRecord::RecordNotFound, "No instructor #{params[:pub_id]}" if faculty.nil? - render_resource(::Catalog::InstructorSerializer.new(faculty).as_json) + faculty end - private + def similar_limit + @similar_limit ||= (params[:limit].presence&.to_i || Embeddable::DEFAULT_SIMILAR_LIMIT) + .clamp(1, Embeddable::MAX_SIMILAR_LIMIT) + end def faculty_ids_for_term Faculty.joins(:courses) diff --git a/app/controllers/api/v1/catalog/sections_controller.rb b/app/controllers/api/v1/catalog/sections_controller.rb index edb71929..5126e003 100644 --- a/app/controllers/api/v1/catalog/sections_controller.rb +++ b/app/controllers/api/v1/catalog/sections_controller.rb @@ -5,6 +5,7 @@ module V1 module Catalog # GET /api/v1/catalog/sections # GET /api/v1/catalog/sections/:crn + # GET /api/v1/catalog/sections/:crn/similar class SectionsController < Api::V1::PublicController def index page, per_page = pagination @@ -28,18 +29,40 @@ def index end def show + course = find_section(params[:crn], params[:term_uid], with_associations: true) + + render_resource(::Catalog::SectionSerializer.new(course).as_json) + end + + # Sections that teach something close to this one. The list is empty + # until the section has a vector, which the nightly backfill writes. + def similar + course = find_section(params[:crn], params[:term_uid]) + relation = ::Catalog::SectionQuery.with_associations(course.similar_sections(limit: similar_limit)) + + render_collection( + relation.map { |section| ::Catalog::SectionSerializer.new(section).as_json }, + meta: { crn: course.crn, limit: similar_limit } + ) + end + + private + + def find_section(crn, term_uid, with_associations: false) relation = ::Catalog::SectionQuery.new.call( - crns: [ params[:crn] ], - term_uid: params[:term_uid], + crns: [ crn ], + term_uid: term_uid, include_cancelled: true ) - course = ::Catalog::SectionQuery.with_associations(relation).first - raise ActiveRecord::RecordNotFound, "No section with CRN #{params[:crn]}" if course.nil? + relation = ::Catalog::SectionQuery.with_associations(relation) if with_associations - render_resource(::Catalog::SectionSerializer.new(course).as_json) + relation.first || raise(ActiveRecord::RecordNotFound, "No section with CRN #{crn}") end - private + def similar_limit + @similar_limit ||= (params[:limit].presence&.to_i || Embeddable::DEFAULT_SIMILAR_LIMIT) + .clamp(1, Embeddable::MAX_SIMILAR_LIMIT) + end def filters { diff --git a/app/graphql/types/instructor_type.rb b/app/graphql/types/instructor_type.rb index 9be4a61b..c7c7ea17 100644 --- a/app/graphql/types/instructor_type.rb +++ b/app/graphql/types/instructor_type.rb @@ -16,6 +16,19 @@ class InstructorType < BaseObject field :school, String, null: true field :rmp, RmpRatingType, null: true + # Each call runs its own vector query, so the field costs more than a + # column and says so. It is empty until the instructor has been embedded. + field :similar, [ InstructorType ], null: false, complexity: 10, + description: "Instructors who teach something close to what this one teaches" do + argument :limit, Integer, required: false, default_value: Embeddable::DEFAULT_SIMILAR_LIMIT + directive Directives::ListSize, slicing_arguments: [ "limit" ], + assumed_size: Embeddable::MAX_SIMILAR_LIMIT, require_one_slicing_argument: false + end + + def similar(limit:) + object.similar_instructors(limit: limit.clamp(1, Embeddable::MAX_SIMILAR_LIMIT)) + end + # Email and phone are intentionally absent: this schema is unauthenticated. # # Returns a plain hash: graphql-ruby resolves object fields from symbol diff --git a/app/graphql/types/section_type.rb b/app/graphql/types/section_type.rb index 6ab4959e..cb8f02ed 100644 --- a/app/graphql/types/section_type.rb +++ b/app/graphql/types/section_type.rb @@ -29,6 +29,19 @@ class SectionType < BaseObject field :meeting_times, [ MeetingTimeType ], null: false field :final_exam, FinalExamType, null: true + # Each call runs its own vector query, so the field costs more than a + # column and says so. It is empty until the section has been embedded. + field :similar, [ SectionType ], null: false, complexity: 10, + description: "Sections in the same term that teach something close to this one" do + argument :limit, Integer, required: false, default_value: Embeddable::DEFAULT_SIMILAR_LIMIT + directive Directives::ListSize, slicing_arguments: [ "limit" ], + assumed_size: Embeddable::MAX_SIMILAR_LIMIT, require_one_slicing_argument: false + end + + def similar(limit:) + object.similar_sections(limit: limit.clamp(1, Embeddable::MAX_SIMILAR_LIMIT)) + end + def course_code ::Catalog::SectionSerializer.course_code_for(object) end diff --git a/app/models/concerns/embeddable.rb b/app/models/concerns/embeddable.rb index d4be0770..f6397067 100644 --- a/app/models/concerns/embeddable.rb +++ b/app/models/concerns/embeddable.rb @@ -13,6 +13,11 @@ module Embeddable extend ActiveSupport::Concern + # How many neighbours a "what is like this?" request returns, and the most + # it may ask for. Both the API and the GraphQL schema read these. + DEFAULT_SIMILAR_LIMIT = 10 + MAX_SIMILAR_LIMIT = 50 + included do has_neighbors :embedding @@ -59,7 +64,7 @@ def store_embedding(vector, text: embedding_text) end # The records closest to this one, itself excluded. - def similar(limit: 10) + def similar(limit: DEFAULT_SIMILAR_LIMIT) return self.class.none if embedding.nil? self.class.nearest_to(embedding, limit: limit).where.not(id: id) diff --git a/app/models/course.rb b/app/models/course.rb index f11f2fea..517dec64 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -135,6 +135,20 @@ def linked_sections .where("LEFT(link_identifier, 1) <> ?", link_slot) end + # Sections that teach something close to this one, nearest first. + # + # Same term, because a student picks from what is offered now. The other + # sections of this same course are left out: they carry the same words, so + # they would fill the list with what the student is already looking at. + def similar_sections(limit: Embeddable::DEFAULT_SIMILAR_LIMIT) + return Course.none if embedding.nil? + + Course.nearest_to(embedding, limit: limit) + .active + .where(term_id: term_id) + .where.not(subject: subject, course_number: course_number) + end + # Returns deduplicated meeting times, preferring non-TBD locations when there are duplicates. def filtered_meeting_times mts = meeting_times.loaded? ? meeting_times : meeting_times.includes(rooms: :building) diff --git a/app/models/faculty.rb b/app/models/faculty.rb index 12bde289..9c13b70d 100644 --- a/app/models/faculty.rb +++ b/app/models/faculty.rb @@ -93,6 +93,16 @@ def embedding_text [ full_name, title, department, school ].compact_blank.join(". ") end + # Instructors whose subject looks like this one's, nearest first. Only + # people who teach are offered, because the catalog only shows those. + def similar_instructors(limit: Embeddable::DEFAULT_SIMILAR_LIMIT) + return Faculty.none if embedding.nil? + + Faculty.nearest_to(embedding, limit: limit) + .where(id: Faculty.joins(:courses).select("faculties.id")) + .where.not(id: id) + end + def rmp_stats return nil unless rating_distribution diff --git a/config/routes.rb b/config/routes.rb index 86341e5a..38b6f0e0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -77,8 +77,10 @@ get "subjects", to: "subjects#index" get "sections", to: "sections#index" get "sections/:crn", to: "sections#show", as: :section, constraints: { crn: /\d+/ } + get "sections/:crn/similar", to: "sections#similar", as: :similar_sections, constraints: { crn: /\d+/ } get "instructors", to: "instructors#index" get "instructors/:pub_id", to: "instructors#show", as: :instructor + get "instructors/:pub_id/similar", to: "instructors#similar", as: :similar_instructors end end end diff --git a/docs/public-catalog-api.md b/docs/public-catalog-api.md index 8e4899eb..80b1a196 100644 --- a/docs/public-catalog-api.md +++ b/docs/public-catalog-api.md @@ -203,7 +203,9 @@ message: | `GET /api/v1/catalog/sections` | Sections, with filters | | `GET /api/v1/catalog/sections/:crn` | One section by CRN | | `GET /api/v1/catalog/instructors` | Faculty who teach at least one section | +| `GET /api/v1/catalog/sections/:crn/similar` | Sections like this one | | `GET /api/v1/catalog/instructors/:pub_id` | One instructor | +| `GET /api/v1/catalog/instructors/:pub_id/similar` | Instructors like this one | `GET /api/v1/catalog/subjects` accepts `term_uid`. @@ -267,6 +269,23 @@ Points to know: - The server falls back to the keyword search when semantic search is off. The request never fails because of it. +### What is like this one? + +`/similar` ranks the records closest in meaning to one record, nearest first. + +```bash +curl "https://calendar.witcc.dev/api/v1/catalog/sections/17294/similar?limit=5" +curl "https://calendar.witcc.dev/api/v1/catalog/instructors/fac_kw7coe30/similar" +``` + +Points to know: + +- `limit` is 10 by default and 50 at most. +- Similar sections stay inside the section's own term, and the other sections + of the same course are left out. +- Similar instructors are people who teach at least one section. +- The list is empty until the record has been embedded, which happens nightly. + ### Example Find Computer Science sections in Fall 2026 that keep Friday free and do not @@ -300,6 +319,10 @@ value into a string, and GraphQL then rejects booleans and numbers. | `section` | `crn`, `termUid` | One section, cancelled ones included | | `instructors` | `termUid`, `q`, `semantic`, plus Relay arguments | A connection of faculty | +`SectionType.similar(limit:)` and `InstructorType.similar(limit:)` return the +records closest in meaning to that record. Both cost more than a plain field, +because each one runs its own search. + `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 c022b78e..785792f2 100644 --- a/docs/public-catalog-api.openapi.yml +++ b/docs/public-catalog-api.openapi.yml @@ -283,6 +283,46 @@ paths: "429": { $ref: "#/components/responses/TooManyRequests" } "500": { $ref: "#/components/responses/InternalError" } + /api/v1/catalog/sections/{crn}/similar: + get: + tags: [sections] + operationId: listSimilarSections + summary: List sections that teach something close to this one + description: | + Ranked by meaning, nearest first. Results stay inside the section's own + term, and the other sections of the same course are left out. The list + is empty until the section has been embedded, which happens nightly. + parameters: + - name: crn + in: path + required: true + schema: { type: integer, examples: [17294] } + - $ref: "#/components/parameters/TermUid" + - $ref: "#/components/parameters/SimilarLimit" + responses: + "200": + description: Sections like this one, nearest first + headers: + RateLimit: { $ref: "#/components/headers/RateLimit" } + RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" } + content: + application/json: + schema: + type: object + required: [data, meta] + properties: + data: + type: array + items: { $ref: "#/components/schemas/Section" } + meta: + type: object + properties: + crn: { type: integer } + limit: { type: integer } + "404": { $ref: "#/components/responses/NotFound" } + "429": { $ref: "#/components/responses/TooManyRequests" } + "500": { $ref: "#/components/responses/InternalError" } + /api/v1/catalog/instructors: get: tags: [instructors] @@ -348,8 +388,51 @@ paths: "429": { $ref: "#/components/responses/TooManyRequests" } "500": { $ref: "#/components/responses/InternalError" } + /api/v1/catalog/instructors/{pub_id}/similar: + get: + tags: [instructors] + operationId: listSimilarInstructors + summary: List instructors who teach something close to what this one teaches + description: | + Ranked by meaning, nearest first. The list is empty until the + instructor has been embedded, which happens nightly. + parameters: + - name: pub_id + in: path + required: true + schema: { type: string } + - $ref: "#/components/parameters/SimilarLimit" + responses: + "200": + description: Instructors like this one, nearest first + headers: + RateLimit: { $ref: "#/components/headers/RateLimit" } + RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" } + content: + application/json: + schema: + type: object + required: [data, meta] + properties: + data: + type: array + items: { $ref: "#/components/schemas/Instructor" } + meta: + type: object + properties: + pub_id: { type: string } + limit: { type: integer } + "404": { $ref: "#/components/responses/NotFound" } + "429": { $ref: "#/components/responses/TooManyRequests" } + "500": { $ref: "#/components/responses/InternalError" } + components: parameters: + SimilarLimit: + name: limit + in: query + description: How many neighbours to return, 1 to 50. + schema: { type: integer, default: 10, minimum: 1, maximum: 50 } TermUid: name: term_uid in: query diff --git a/spec/models/course_spec.rb b/spec/models/course_spec.rb index d45d1778..4ed4fb07 100644 --- a/spec/models/course_spec.rb +++ b/spec/models/course_spec.rb @@ -110,6 +110,55 @@ def course(section_number:, schedule_type: "LEC", link_identifier: nil, subject: end end + describe "#similar_sections" do + let(:spring_term) { create(:term, uid: 202_620, year: 2026, season: :spring) } + + def section(subject: "COMP", number: 1000, term_for: term, angle: 0.5) + give_embedding(create(:course, term: term_for, subject: subject, course_number: number), angle) + end + + it "returns the closest sections first" do + source = section(number: 1000, angle: 0.0) + near = section(number: 2000, angle: 0.10) + far = section(number: 3000, angle: 0.90) + + expect(source.similar_sections).to eq([ near, far ]) + end + + it "leaves out the other sections of the same course" do + source = section(number: 1000, angle: 0.0) + section(number: 1000, angle: 0.01) + + expect(source.similar_sections).to be_empty + end + + it "stays inside the term the student is looking at" do + source = section(number: 1000, angle: 0.0) + section(number: 2000, term_for: spring_term, angle: 0.01) + + expect(source.similar_sections).to be_empty + end + + it "leaves out cancelled sections" do + source = section(number: 1000, angle: 0.0) + section(number: 2000, angle: 0.01).update!(status: :cancelled) + + expect(source.similar_sections).to be_empty + end + + it "stops at the limit" do + source = section(number: 1000, angle: 0.0) + section(number: 2000, angle: 0.10) + section(number: 3000, angle: 0.20) + + expect(source.similar_sections(limit: 1).length).to eq(1) + end + + it "returns nothing until the section has a vector" do + expect(create(:course, term: term).similar_sections).to be_empty + end + end + describe "#link_slot and #link_key" do it "splits the Banner identifier into the slot and the key" do lecture = course(section_number: "1A", link_identifier: "A1") diff --git a/spec/models/faculty_spec.rb b/spec/models/faculty_spec.rb index b636693e..7c05b0ae 100644 --- a/spec/models/faculty_spec.rb +++ b/spec/models/faculty_spec.rb @@ -57,6 +57,43 @@ it { is_expected.to validate_presence_of(:last_name) } it { is_expected.to validate_uniqueness_of(:rmp_id).allow_nil } + describe "#similar_instructors" do + let(:term) { create(:term) } + + def teaching_faculty(angle) + person = give_embedding(create(:faculty), angle) + create(:course, term: term).faculties << person + person + end + + it "returns the closest instructors who teach, nearest first" do + source = teaching_faculty(0.0) + near = teaching_faculty(0.10) + far = teaching_faculty(0.90) + + expect(source.similar_instructors).to eq([ near, far ]) + end + + it "leaves out people who teach nothing" do + source = teaching_faculty(0.0) + give_embedding(create(:faculty), 0.01) + + expect(source.similar_instructors).to be_empty + end + + it "stops at the limit" do + source = teaching_faculty(0.0) + teaching_faculty(0.10) + teaching_faculty(0.20) + + expect(source.similar_instructors(limit: 1).length).to eq(1) + end + + it "returns nothing until the instructor has a vector" do + expect(create(:faculty).similar_instructors).to be_empty + end + end + describe "#embedding_text" do it "reads the directory facts a student would search by" do faculty = create(:faculty, first_name: "Ada", last_name: "Lovelace", display_name: nil, diff --git a/spec/requests/api/graphql_spec.rb b/spec/requests/api/graphql_spec.rb index a0b98a50..e06f4ad9 100644 --- a/spec/requests/api/graphql_spec.rb +++ b/spec/requests/api/graphql_spec.rb @@ -204,6 +204,34 @@ def gql(query, variables: nil) end end + describe "similar" do + it "returns the sections closest to one section" do + give_embedding(comp1000, 0.00) + give_embedding(comp2000, 0.10) + give_embedding(math1750, 0.90) + + result = gql("{ section(crn: 10001) { similar(limit: 5) { crn } } }") + + expect(result["errors"]).to be_nil + expect(result["data"]["section"]["similar"].map { |s| s["crn"] }).to eq([ 10_002 ]) + end + + it "returns the instructors closest to one instructor" do + give_embedding(ada, 0.00) + give_embedding(grace, 0.10) + + result = gql("{ instructors(q: \"byron\", first: 1) { nodes { similar { name } } } }") + + expect(result["data"]["instructors"]["nodes"].first["similar"].map { |i| i["name"] }).to eq([ "Grace Hop" ]) + end + + it "is empty for a section with no vector" do + result = gql("{ section(crn: 10001) { similar { crn } } }") + + expect(result["data"]["section"]["similar"]).to be_empty + end + end + describe "semantic search", :semantic_search do before { stub_openai_embeddings([ embedding_vector(0.0) ]) } diff --git a/spec/requests/api/v1/catalog/instructors_spec.rb b/spec/requests/api/v1/catalog/instructors_spec.rb index 23bf7862..fd5642fd 100644 --- a/spec/requests/api/v1/catalog/instructors_spec.rb +++ b/spec/requests/api/v1/catalog/instructors_spec.rb @@ -31,6 +31,35 @@ def json = JSON.parse(response.body) end end + describe "GET /api/v1/catalog/instructors/:pub_id/similar" do + before do + give_embedding(ada, 0.00) + give_embedding(grace, 0.10) + end + + it "returns the closest instructors who teach, nearest first" do + get "/api/v1/catalog/instructors/#{ada.public_id}/similar" + + expect(response).to have_http_status(:ok) + expect(json["data"].map { |i| i["name"] }).to eq([ "Grace Hop" ]) + expect(json["meta"]).to eq("pub_id" => ada.public_id, "limit" => 10) + end + + it "returns an empty list for an instructor with no vector" do + ada.update_columns(embedding: nil, embedding_digest: nil) # rubocop:disable Rails/SkipsModelValidations + + get "/api/v1/catalog/instructors/#{ada.public_id}/similar" + + expect(json["data"]).to be_empty + end + + it "returns 404 for an instructor that does not exist" do + get "/api/v1/catalog/instructors/fac_missing/similar" + + expect(response).to have_http_status(:not_found) + end + end + describe "semantic search", :semantic_search do before do give_embedding(ada, 0.05) diff --git a/spec/requests/api/v1/catalog/sections_spec.rb b/spec/requests/api/v1/catalog/sections_spec.rb index 723bcb6c..7e51344b 100644 --- a/spec/requests/api/v1/catalog/sections_spec.rb +++ b/spec/requests/api/v1/catalog/sections_spec.rb @@ -170,6 +170,52 @@ def crns = json["data"].map { |s| s["crn"] } end end + describe "GET /api/v1/catalog/sections/:crn/similar" do + before do + give_embedding(comp1000, 0.00) + give_embedding(comp2000, 0.10) + give_embedding(comp2000b, 0.11) + give_embedding(math1750, 0.90) + end + + it "returns the closest sections of the same term, nearest first" do + get "/api/v1/catalog/sections/10001/similar" + + expect(response).to have_http_status(:ok) + expect(crns).to eq([ 10_002, 10_003 ]) + expect(json["meta"]).to eq("crn" => 10_001, "limit" => 10) + end + + it "honours the limit" do + get "/api/v1/catalog/sections/10001/similar", params: { limit: 1 } + + expect(crns).to eq([ 10_002 ]) + expect(json["meta"]["limit"]).to eq(1) + end + + it "caps the limit" do + get "/api/v1/catalog/sections/10001/similar", params: { limit: 5000 } + + expect(json["meta"]["limit"]).to eq(50) + end + + it "returns an empty list for a section with no vector" do + comp1000.update_columns(embedding: nil, embedding_digest: nil) # rubocop:disable Rails/SkipsModelValidations + + get "/api/v1/catalog/sections/10001/similar" + + expect(response).to have_http_status(:ok) + expect(json["data"]).to be_empty + end + + it "returns 404 for a CRN that does not exist" do + get "/api/v1/catalog/sections/99999/similar" + + expect(response).to have_http_status(:not_found) + expect(json["code"]).to eq("NOT_FOUND") + end + end + describe "semantic search", :semantic_search do before do give_embedding(comp1000, 0.05)