From af9096bd0fe41db7c6a912e34e5e687014c9baed Mon Sep 17 00:00:00 2001 From: Jasper Mayone Date: Sat, 19 Sep 2026 18:00:17 -0400 Subject: [PATCH] feat: answer what is like this section or instructor Adds /api/v1/catalog/sections/:crn/similar and /api/v1/catalog/instructors/:pub_id/similar, plus a similar field on both GraphQL types. Similar sections stay in the same term and leave out the other sections of the same course, which would otherwise fill the list with what the student is already reading. --- .../api/v1/catalog/instructors_controller.rb | 28 ++++++- .../api/v1/catalog/sections_controller.rb | 35 ++++++-- app/graphql/types/instructor_type.rb | 13 +++ app/graphql/types/section_type.rb | 13 +++ app/models/concerns/embeddable.rb | 7 +- app/models/course.rb | 14 ++++ app/models/faculty.rb | 10 +++ config/routes.rb | 2 + docs/public-catalog-api.md | 23 +++++ docs/public-catalog-api.openapi.yml | 83 +++++++++++++++++++ spec/models/course_spec.rb | 49 +++++++++++ spec/models/faculty_spec.rb | 37 +++++++++ spec/requests/api/graphql_spec.rb | 28 +++++++ .../api/v1/catalog/instructors_spec.rb | 29 +++++++ spec/requests/api/v1/catalog/sections_spec.rb | 46 ++++++++++ 15 files changed, 408 insertions(+), 9 deletions(-) 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)