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
28 changes: 26 additions & 2 deletions app/controllers/api/v1/catalog/instructors_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
35 changes: 29 additions & 6 deletions app/controllers/api/v1/catalog/sections_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
{
Expand Down
13 changes: 13 additions & 0 deletions app/graphql/types/instructor_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions app/graphql/types/section_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion app/models/concerns/embeddable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions app/models/course.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions app/models/faculty.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions docs/public-catalog-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
83 changes: 83 additions & 0 deletions docs/public-catalog-api.openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading