From eb16ba85b6f97111c68f0f02f2a3286a0403ccbe Mon Sep 17 00:00:00 2001 From: DIodide Date: Thu, 26 Mar 2026 17:10:43 -0400 Subject: [PATCH 1/2] Add junction MCP scope with Supabase-backed schedule tools for PrincetonCourses integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enables PrincetonCourses users to access and manage their TigerJunction schedules via the chat AI, using their Princeton NetID as the identity bridge. Engine changes: - Add Supabase plugin (SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY) - New /junction/mcp endpoint with "junction" scope - junction-schedules.ts: read tools (get_user_schedules, get_schedule_details, verify_schedule) + write tools (create_schedule, add_course_to_schedule, remove_course_from_schedule, rename_schedule, delete_schedule) - Identity: NetID → Supabase RPC get_user_id_by_netid → UUID - search_courses: add scheduleId filter for conflict-free course search - All tools enforce ownership (schedule.user_id === resolved UUID) Ask-gateway changes: - Accept netid in request body, route to /junction/mcp when present - Send x-user-netid header to engine MCP - Augment system prompt with schedule tool guidance - Default to Fall 2026 (term 1272) in system prompt Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/ask-gateway/app/chat_service.py | 33 +- apps/ask-gateway/app/config.py | 1 + apps/ask-gateway/app/mcp_client.py | 18 +- apps/ask-gateway/app/models.py | 1 + apps/engine/bun.lockb | Bin 168867 -> 172461 bytes apps/engine/package.json | 1 + apps/engine/src/app.ts | 3 + apps/engine/src/mcp/index.ts | 14 +- apps/engine/src/mcp/tools/courses.ts | 127 ++- .../src/mcp/tools/junction-schedules.ts | 777 ++++++++++++++++++ apps/engine/src/plugins/supabase.ts | 32 + apps/engine/src/routes/mcp.ts | 3 +- 12 files changed, 995 insertions(+), 15 deletions(-) create mode 100644 apps/engine/src/mcp/tools/junction-schedules.ts create mode 100644 apps/engine/src/plugins/supabase.ts diff --git a/apps/ask-gateway/app/chat_service.py b/apps/ask-gateway/app/chat_service.py index a7aef650..5e8091d7 100644 --- a/apps/ask-gateway/app/chat_service.py +++ b/apps/ask-gateway/app/chat_service.py @@ -24,6 +24,9 @@ You have access to tools that search courses, get course details, evaluations, \ instructor info, and more. Use them to answer accurately. +The upcoming term is Fall 2026 (term code 1272). Unless the user specifies otherwise, \ +default to searching and discussing courses for Fall 2026. The current term is Spring 2026 (1264). + Guidelines: - Always use tools to look up real data. Do not fabricate course information. - After receiving tool results, synthesize a helpful, conversational response. @@ -31,6 +34,24 @@ - Format course codes as "DEPT NNN" (e.g., COS 226, not COS226). - Keep responses concise but thorough. Use bullet points and bold for readability. - If a course is not found, say so honestly and suggest alternatives. +- When searching for courses, prefer term 1272 (Fall 2026) unless the user asks about a different term. +""" + +_SCHEDULE_PROMPT_ADDENDUM = """ + +You also have access to the user's TigerJunction schedule. You can: +- Get their schedules with get_user_schedules (no userId needed — you are already authenticated) +- Get full details of a schedule with get_schedule_details +- Search for courses that don't conflict with their schedule by passing scheduleId to search_courses +- Verify a proposed schedule with verify_schedule +- Create a new schedule with create_schedule +- Add a course with add_course_to_schedule (needs scheduleId and courseCode like "COS 226") +- Remove a course with remove_course_from_schedule +- Rename a schedule with rename_schedule +- Delete a schedule with delete_schedule +When the user asks about "my schedule", "my courses", or wants to add/remove/manage courses, use these tools. +Always get the user's schedules first to find the right scheduleId before adding/removing courses or filtering by conflicts. +When the user wants to find courses that fit their schedule, use search_courses with the scheduleId parameter — this combines all search filters (department, text, days, time, instructor, distribution) with schedule conflict checking. """ @@ -120,15 +141,20 @@ async def _stream_agentic( request_id = request_id or str(uuid.uuid4()) conversation_id = payload.conversationId or str(uuid.uuid4()) prompt = payload.messages[-1].content - mcp_client = McpHttpClient(self._settings) + mcp_url = self._settings.junction_mcp_url if payload.netid else None + mcp_client = McpHttpClient(self._settings, netid=payload.netid, mcp_url=mcp_url) llm_client = OpenAiLlmClient(self._settings) session_id: str | None = None try: yield sse_event("status", {"phase": "starting", "requestId": request_id}) + system_prompt = _SYSTEM_PROMPT + if payload.netid: + system_prompt += _SCHEDULE_PROMPT_ADDENDUM + messages: list[dict[str, Any]] = [ - {"role": "system", "content": _SYSTEM_PROMPT}, + {"role": "system", "content": system_prompt}, *[m.model_dump() for m in payload.messages], ] @@ -354,7 +380,8 @@ async def _stream_deterministic( request_id = request_id or str(uuid.uuid4()) conversation_id = payload.conversationId or str(uuid.uuid4()) prompt = payload.messages[-1].content - mcp_client = McpHttpClient(self._settings) + mcp_url = self._settings.junction_mcp_url if payload.netid else None + mcp_client = McpHttpClient(self._settings, netid=payload.netid, mcp_url=mcp_url) session_id: str | None = None try: yield sse_event("status", {"phase": "starting", "requestId": request_id}) diff --git a/apps/ask-gateway/app/config.py b/apps/ask-gateway/app/config.py index 23e20733..cbd9c960 100644 --- a/apps/ask-gateway/app/config.py +++ b/apps/ask-gateway/app/config.py @@ -20,6 +20,7 @@ def _env_bool(name: str, default: bool) -> bool: class Settings: gateway_api_token: str = os.getenv("ASK_GATEWAY_API_TOKEN", "") mcp_url: str = os.getenv("JUNCTION_MCP_URL", "http://localhost:3000/mcp") + junction_mcp_url: str = os.getenv("JUNCTION_MCP_URL_SCHEDULE", "http://localhost:3000/junction/mcp") mcp_token: str = os.getenv("JUNCTION_MCP_TOKEN", "") mcp_protocol_version: str = os.getenv("MCP_PROTOCOL_VERSION", "2025-03-26") tool_timeout_seconds: float = float(os.getenv("ASK_TOOL_TIMEOUT_SECONDS", "10")) diff --git a/apps/ask-gateway/app/mcp_client.py b/apps/ask-gateway/app/mcp_client.py index 4ee57e0f..f816eb3c 100644 --- a/apps/ask-gateway/app/mcp_client.py +++ b/apps/ask-gateway/app/mcp_client.py @@ -22,8 +22,16 @@ class McpClientError(Exception): class McpHttpClient: - def __init__(self, settings: Settings) -> None: + def __init__( + self, + settings: Settings, + *, + netid: str | None = None, + mcp_url: str | None = None, + ) -> None: self._settings = settings + self._netid = netid + self._mcp_url = mcp_url or settings.mcp_url self._session_id: str | None = None self._client = httpx.AsyncClient( timeout=httpx.Timeout(settings.tool_timeout_seconds, connect=settings.connect_timeout_seconds) @@ -58,7 +66,7 @@ async def list_tools(self) -> list[dict[str, Any]]: if self._session_id is None: await self.initialize() - cache_key = self._settings.mcp_url + cache_key = self._mcp_url cached = _tools_cache.get(cache_key) if cached is not None: openai_tools, _, ts = cached @@ -103,7 +111,7 @@ async def close(self) -> None: try: if self._session_id: await self._client.delete( - self._settings.mcp_url, + self._mcp_url, headers=self._headers(include_session=True), ) finally: @@ -116,7 +124,7 @@ def _next(self) -> int: async def _post(self, payload: dict[str, Any]) -> httpx.Response: response = await self._client.post( - self._settings.mcp_url, + self._mcp_url, headers=self._headers(include_session=True), json=payload, ) @@ -131,6 +139,8 @@ def _headers(self, include_session: bool) -> dict[str, str]: } if self._settings.mcp_token: headers["authorization"] = f"Bearer {self._settings.mcp_token}" + if self._netid: + headers["x-user-netid"] = self._netid if include_session and self._session_id: headers["mcp-session-id"] = self._session_id headers["mcp-protocol-version"] = self._settings.mcp_protocol_version diff --git a/apps/ask-gateway/app/models.py b/apps/ask-gateway/app/models.py index 9a0a572b..db1059a2 100644 --- a/apps/ask-gateway/app/models.py +++ b/apps/ask-gateway/app/models.py @@ -15,6 +15,7 @@ class AskStreamRequest(BaseModel): term: int | None = None model: str | None = None messages: list[ChatMessage] = Field(min_length=1) + netid: str | None = None class ToolCall(BaseModel): diff --git a/apps/engine/bun.lockb b/apps/engine/bun.lockb index 0f6cf4e59800a231a4cd6aa7bef03f344300d7fc..048d12ced38e08aa8bf403010c9dc33e6269a1b3 100755 GIT binary patch delta 32630 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 CQ-`_$ delta 29366 zcmeHwd3;XC+xLBr+>#T5#F9i35fMvB$Rc7*V#yt1ONqoTAtVIZs6EkAx~RsKL2c2h zwTrf@yP}GgqOBH1?L|?krFQT4dlvDu+UNIt-skowc_i_iNP#@cN*cL>K9WQ-mPc?5V?9t2tm)YGIn!$Y!07ew2tIa!s2{H&bp+ne*j^I&IQBKzAG30Y<7|!kpN~a71W%(m8MISlv zw5kax{x9;uiOzNcg%u-;t|FHWoC9TNn<1CZk=};fYH-MyF(b1wY_?$JQ*RggM4FdT zKs#(7f@ir|J~kVQ7Oe$OLp`Bi6?CJ>E$11q*4HSNHe#G!%A=N%d*9D6YmQh`&cARA z3eYLerY+aNvyR!d4VO;>r7QPB7zDZ=l%3pzI%(6;F$hI8@E&;D(z&joA8pc%@i}=J z`T4d}D9n1l1*H$Rf}#&Si{3|q-pe;4+Lm9CRxma{BY&+a7ocn?BO_#3R$hL=K=AbX zsH{;LAw$z`v&{Uf4UBruG!U-#dP!4EaWW`F#|qG#w7mR`v3c1`A!oA}0u7pH>K9DN z$q32Hw*@vfeV+DYNcxze6W$cl?EZ!MA%^xJpm12xFQ6QWouKq$MyL@YbDJ1}mkjC# z`DWzPiX!mzW-e%D&~%e_24yGXK&kh2n9(2UJKm zX2NXp)^N3nFe2ZdB3MdVk1UUzyQ#*rJH0681(?H+$hWyX<9D%%2Ar$_*il)FIhT4dZMuXOb zY63YMu;xL_c%vcC4;zl^$ghd~Pv9BqO-LJ+O*z`s>-*J#G-G)*1RQf~2K;%VSuXec8;8+#4bx2%FJWZCk%7;@jPhQ~ZW=@}Q3W@n6JQ?{GXV~2)jWEAFb$_;^l zV{MEHHlywap9RVa`hha|QlM86G#oStG%G(uAMT%e7<%;~=a6I#A3Y`yKJYd9VOgVx zhK$Xzc|2j{JDW5oq%eETkhFq~oRI9SQCS5x=bnZ_MHHlk3&7I=1hBr8R_bNs56ey~ zD99LU%g7&Fkd>VenmaZlZ-VU>@~NMZkF^g5+)6Zpvmn{60NGJ?W5y7st3au5}JJlG<9Z=(B`3RIl`np+c7AO|^qDg;}5AAE{WkJqz zT|wC{TF~w61D^f7(9f`QM1F`KC24uXxm*@z976^RRKwVlI)k#|6`-6HbblHgU~M&) z!Oc-ICsQCO`?X=9v3aX*j$FgyPo5>Ft54Nt=) zr>A#-l26Y|8FIiCmOp&%YXK^t=v6iya&d%1}&W zK4)hH8uSO9IM}ez1AGAZE8y#Z_64mCdK#9qXJ3Glw_>G1*PJC zP&V`_DDzjD`NHIk-;&aStyO$%*I>n|@PE&BM?uNO>MHsaf?kKJBrn6mR^d$G@FuyR&}`8t$p zVgk`&5nL-?2^P7v9QGmQY_=96q*i-nu~7US z%6Fm@&do$I&QFR%IIj}{wH?~ePBt6%_aaq<)rwJ)#0JP-vD<9nkU5FCDKW|(5f|Xl zDwns}IzXlfB{)XuDfS0A6e+yxIFO%)b49TR=k}r$XCYbyIuu-N1v<1Em@rT1b+KHg zaIfo7J`jCyt|Ch6I_y2*sV?GPL)sY@0 z99ky4i%k+XvfejEZheP(v!eK@empv`+dMX^qig#1$P{UIf&MQMw8D5Qp|OWOQhG-PnzyB*dX!@(>q7;+1d_7V1#O zi3vE55nFKnKxj=I+C_w4XB5%HU*~$cQiUooPfr9Q>KX$zZhkT$ZhJ-HX|-$ zp=YD<+5>QiT+~6X6;=l386!AJ6o)yqJ&^U%H3AyNC;=iM+@a-S@$L*;k(FVk6^fNTn6QMw-Mc~|7NKy*Ns z0GXs&I<)zn z3KSP&;@xxW+HAN=D#8TR4{tH8R ^q+K9&5xEUxH2)yO47&(x7h}HxuAA_y(_ZUP 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 { + async ({ term, department, query, dist, days, daysMatch, startAfter, startBefore, instructor, scheduleId, limit: maxResults, offset }) => { const resultLimit = Math.min(maxResults ?? 50, 200); const resultOffset = offset ?? 0; const conditions = []; @@ -73,6 +81,9 @@ export function registerCourseTools(server: McpServer, db: NodePgDatabase) { ); } + // Fetch more results when schedule filtering is active (some will be filtered out) + const fetchLimit = scheduleId ? resultLimit * 3 : resultLimit; + const courses = await db .select({ id: schema.courses.id, @@ -89,7 +100,115 @@ export function registerCourseTools(server: McpServer, db: NodePgDatabase) { .where(conditions.length > 0 ? and(...conditions) : undefined) .orderBy(asc(schema.courses.code)) .offset(resultOffset) - .limit(resultLimit); + .limit(fetchLimit); + + // If scheduleId is provided and junction context available, post-filter for conflicts + if (scheduleId != null && junctionCtx) { + const { supabase, authContext } = junctionCtx; + + // Resolve user + if (!authContext?.netid) { + return { + content: [{ type: "text" as const, text: "scheduleId filter requires authenticated user (x-user-netid header)." }], + isError: true, + }; + } + + const { data: userId } = await supabase.rpc("get_user_id_by_netid", { netid: authContext.netid }); + if (!userId) { + return { + content: [{ type: "text" as const, text: `No TigerJunction account found for NetID '${authContext.netid}'.` }], + isError: true, + }; + } + + // Verify schedule ownership + const { data: sched } = await supabase + .from("schedules") + .select("id, user_id") + .eq("id", scheduleId) + .single(); + + if (!sched || sched.user_id !== userId) { + return { + content: [{ type: "text" as const, text: "Schedule not found or does not belong to authenticated user." }], + isError: true, + }; + } + + // Get existing courses in schedule + const { data: existingAssocs } = await supabase + .from("course_schedule_associations") + .select("course_id") + .eq("schedule_id", scheduleId); + + const existingCourseIds = new Set((existingAssocs ?? []).map((a: { course_id: number }) => a.course_id)); + + // Get occupied time slots from schedule's sections (via engine DB for consistency) + const occupiedSlots: { days: number; startTime: number; endTime: number }[] = []; + if (existingCourseIds.size > 0) { + // Map Supabase course IDs to engine course IDs via listing_id + term + const { data: supabaseCourses } = await supabase + .from("courses") + .select("listing_id, term") + .in("id", [...existingCourseIds]); + + for (const sc of supabaseCourses ?? []) { + const engineCourseId = `${sc.listing_id}-${sc.term}`; + const sections = await db + .select({ days: schema.sections.days, startTime: schema.sections.startTime, endTime: schema.sections.endTime }) + .from(schema.sections) + .where(eq(schema.sections.courseId, engineCourseId)); + occupiedSlots.push(...sections); + } + } + + // Filter out courses already in schedule and courses that conflict + const filtered = []; + for (const course of courses) { + // Skip courses already in schedule (match by listing_id + term) + const sections = await db + .select({ + title: schema.sections.title, + days: schema.sections.days, + startTime: schema.sections.startTime, + endTime: schema.sections.endTime, + }) + .from(schema.sections) + .where(eq(schema.sections.courseId, course.id)); + + if (sections.length === 0) { filtered.push(course); continue; } + + // Group by section type + const byType = new Map(); + for (const s of sections) { + const type = s.title.match(/^([A-Z]+)/)?.[1] ?? s.title; + if (!byType.has(type)) byType.set(type, []); + byType.get(type)!.push(s); + } + + // Course fits if each section type has at least one non-conflicting option + const fits = [...byType.values()].every((group) => + group.some((s) => + !occupiedSlots.some((o) => + (s.days & o.days) !== 0 && s.startTime < o.endTime && o.startTime < s.endTime + ) + ) + ); + + if (fits) filtered.push(course); + if (filtered.length >= resultLimit) break; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ count: filtered.length, scheduleFiltered: true, scheduleId, courses: filtered }, null, 2), + }, + ], + }; + } return { content: [ diff --git a/apps/engine/src/mcp/tools/junction-schedules.ts b/apps/engine/src/mcp/tools/junction-schedules.ts new file mode 100644 index 00000000..afb4ab31 --- /dev/null +++ b/apps/engine/src/mcp/tools/junction-schedules.ts @@ -0,0 +1,777 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import { z } from "zod"; +import { termCodeToName, valueToDays, valueToTime } from "../helpers.js"; +import type { AuthContext } from "../context.js"; + +// Supabase status encoding: 0=open, 1=closed, 2=canceled +const STATUS_MAP: Record = { 0: "open", 1: "closed", 2: "canceled" }; +function statusName(code: number | null): string { + return code != null ? (STATUS_MAP[code] ?? "unknown") : "unknown"; +} + +interface TimeSlot { + days: number; + startTime: number; + endTime: number; +} + +function timeSlotsOverlap(a: TimeSlot, b: TimeSlot): boolean { + if ((a.days & b.days) === 0) return false; + return a.startTime < b.endTime && b.startTime < a.endTime; +} + +function sectionTypePrefix(title: string): string { + const match = title.match(/^([A-Z]+)/); + return match ? match[1] : title; +} + +function formatSectionForDisplay(s: { + title: string; + days: number; + start_time: number; + end_time: number; + room?: string | null; + status: number | null; +}) { + return { + sectionTitle: s.title, + days: valueToDays(s.days), + startTime: valueToTime(s.start_time), + endTime: valueToTime(s.end_time), + room: s.room ?? null, + status: statusName(s.status), + }; +} + +/** + * Resolve the authenticated user's Supabase UUID via: + * NetID → Supabase RPC get_user_id_by_netid → Supabase UUID + * + * Uses a Supabase database function that looks up auth.users by email + * ({netid}@princeton.edu), avoiding dependency on the engine's local users table. + */ +async function resolveSupabaseUserId( + supabase: SupabaseClient, + authContext?: AuthContext +): Promise<{ supabaseUuid?: string; error?: string }> { + if (!authContext?.netid) { + return { error: "Missing user context. Provide x-user-netid header." }; + } + + const { data, error } = await supabase.rpc("get_user_id_by_netid", { + netid: authContext.netid, + }); + + if (error) { + return { error: `Failed to resolve NetID '${authContext.netid}': ${error.message}` }; + } + + if (!data) { + return { + error: `No TigerJunction account found for NetID '${authContext.netid}'. Create a TigerJunction account first to access schedule features.`, + }; + } + + return { supabaseUuid: data as string }; +} + +export function registerJunctionScheduleTools( + server: McpServer, + supabase: SupabaseClient, + authContext?: AuthContext +) { + // ── get_user_schedules ────────────────────────────────────────────── + server.tool( + "get_user_schedules", + "Get all schedules for the authenticated user, optionally filtered by term.", + { + term: z + .number() + .optional() + .describe( + "Term code to filter by. Mapping: 1232=Fall 2022, 1234=Spring 2023, 1242=Fall 2023, 1244=Spring 2024, 1252=Fall 2024, 1254=Spring 2025, 1262=Fall 2025, 1264=Spring 2026 (current). Codes ending in 2=Fall, ending in 4=Spring." + ), + }, + async ({ term }) => { + const auth = await resolveSupabaseUserId(supabase, authContext); + if (!auth.supabaseUuid) { + return { content: [{ type: "text" as const, text: auth.error ?? "Unauthorized." }], isError: true }; + } + + let query = supabase + .from("schedules") + .select("id, title, term, is_public") + .eq("user_id", auth.supabaseUuid) + .order("term", { ascending: true }); + + if (term != null) { + query = query.eq("term", term); + } + + const { data: schedules, error } = await query; + + if (error) { + return { content: [{ type: "text" as const, text: `Failed to fetch schedules: ${error.message}` }], isError: true }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + count: schedules?.length ?? 0, + schedules: (schedules ?? []).map((s) => ({ + ...s, + termName: termCodeToName(s.term), + })), + }, + null, + 2 + ), + }, + ], + }; + } + ); + + // ── get_schedule_details ──────────────────────────────────────────── + server.tool( + "get_schedule_details", + "Get full details of a schedule including its courses, sections, meeting times, and any time conflicts.", + { + scheduleId: z.number().describe("Schedule ID"), + }, + async ({ scheduleId }) => { + const auth = await resolveSupabaseUserId(supabase, authContext); + if (!auth.supabaseUuid) { + return { content: [{ type: "text" as const, text: auth.error ?? "Unauthorized." }], isError: true }; + } + + // Fetch the schedule and verify ownership + const { data: schedule, error: schedError } = await supabase + .from("schedules") + .select("id, title, term, is_public, user_id") + .eq("id", scheduleId) + .single(); + + if (schedError || !schedule) { + return { content: [{ type: "text" as const, text: "Schedule not found." }], isError: true }; + } + if (schedule.user_id !== auth.supabaseUuid) { + return { + content: [{ type: "text" as const, text: "Forbidden: schedule does not belong to authenticated user." }], + isError: true, + }; + } + + // Fetch course associations + const { data: associations } = await supabase + .from("course_schedule_associations") + .select("course_id, metadata") + .eq("schedule_id", scheduleId); + + if (!associations || associations.length === 0) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + schedule: { id: schedule.id, title: schedule.title, term: schedule.term, termName: termCodeToName(schedule.term) }, + courses: [], + sections: [], + conflicts: "No courses in this schedule", + }, + null, + 2 + ), + }, + ], + }; + } + + const courseIds = associations.map((a) => a.course_id); + + // Fetch courses + const { data: courses } = await supabase + .from("courses") + .select("id, code, title, status") + .in("id", courseIds); + + const courseMap = new Map((courses ?? []).map((c) => [c.id, c])); + + // Fetch sections for all courses + const { data: sections } = await supabase + .from("sections") + .select("id, course_id, title, days, start_time, end_time, room, status, cap, tot") + .in("course_id", courseIds); + + // Build section list with course codes and detect conflicts + const allSections: (TimeSlot & { courseCode: string; sectionTitle: string; room: string | null; status: string })[] = []; + + for (const s of sections ?? []) { + const course = courseMap.get(s.course_id); + if (!course) continue; + allSections.push({ + courseCode: course.code, + sectionTitle: s.title, + days: s.days, + startTime: s.start_time, + endTime: s.end_time, + room: s.room, + status: statusName(s.status), + }); + } + + const conflicts: string[] = []; + for (let i = 0; i < allSections.length; i++) { + for (let j = i + 1; j < allSections.length; j++) { + if (allSections[i].courseCode === allSections[j].courseCode) continue; + if (timeSlotsOverlap(allSections[i], allSections[j])) { + conflicts.push( + `${allSections[i].courseCode} (${allSections[i].sectionTitle}) overlaps with ${allSections[j].courseCode} (${allSections[j].sectionTitle})` + ); + } + } + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + schedule: { id: schedule.id, title: schedule.title, term: schedule.term, termName: termCodeToName(schedule.term) }, + courses: (courses ?? []).map((c) => ({ + id: c.id, + code: c.code, + title: c.title, + status: statusName(c.status), + })), + sections: allSections.map((s) => ({ + courseCode: s.courseCode, + sectionTitle: s.sectionTitle, + days: valueToDays(s.days), + startTime: valueToTime(s.startTime), + endTime: valueToTime(s.endTime), + room: s.room, + status: s.status, + })), + conflicts: conflicts.length > 0 ? conflicts : "No conflicts detected", + }, + null, + 2 + ), + }, + ], + }; + } + ); + + // ── verify_schedule ───────────────────────────────────────────────── + server.tool( + "verify_schedule", + "Validate a proposed schedule of courses. Checks for: time conflicts between sections, mixed terms, missing section types (e.g., no precept selected when required), closed/canceled sections, duplicate courses, and exceeding 7 courses. Returns valid=true or a list of issues.", + { + courseCodes: z + .array(z.string()) + .min(1) + .max(10) + .describe("Array of course codes to validate together (e.g., ['COS 226', 'MAT 202', 'ECO 100'])"), + term: z + .number() + .optional() + .describe("Term code. If omitted, uses the most recent term each course is offered."), + }, + async ({ courseCodes, term }) => { + const issues: string[] = []; + const resolvedCourses: { + code: string; + courseId: number; + term: number; + status: string; + sections: { title: string; type: string; days: number; startTime: number; endTime: number; status: string; cap: number; tot: number }[]; + }[] = []; + + for (const code of courseCodes) { + // Query Supabase courses by code + let courseQuery = supabase + .from("courses") + .select("id, code, term, status") + .ilike("code", code); + + if (term != null) { + courseQuery = courseQuery.eq("term", term); + } + + const { data: matchedCourses } = await courseQuery.order("term", { ascending: false }).limit(1); + + if (!matchedCourses || matchedCourses.length === 0) { + issues.push(`Course not found: "${code}"`); + continue; + } + + const course = matchedCourses[0]; + const courseStatus = statusName(course.status); + + // Check for duplicates + if (resolvedCourses.some((c) => c.courseId === course.id)) { + issues.push(`Duplicate course: ${course.code}`); + continue; + } + + // Fetch sections + const { data: sections } = await supabase + .from("sections") + .select("title, days, start_time, end_time, status, cap, tot") + .eq("course_id", course.id); + + resolvedCourses.push({ + code: course.code, + courseId: course.id, + term: course.term, + status: courseStatus, + sections: (sections ?? []).map((s) => ({ + title: s.title, + type: sectionTypePrefix(s.title), + days: s.days, + startTime: s.start_time, + endTime: s.end_time, + status: statusName(s.status), + cap: s.cap ?? 0, + tot: s.tot ?? 0, + })), + }); + } + + // Check: too many courses + if (resolvedCourses.length > 7) { + issues.push(`Too many courses: ${resolvedCourses.length} courses selected (max recommended is 7)`); + } + + // Check: mixed terms + const terms = [...new Set(resolvedCourses.map((c) => c.term))]; + if (terms.length > 1) { + const termNames = terms.map((t) => `${termCodeToName(t)} (${t})`).join(", "); + issues.push(`Mixed terms: courses span multiple semesters: ${termNames}`); + } + + // Check: canceled courses + for (const course of resolvedCourses) { + if (course.status === "canceled") { + issues.push(`Canceled course: ${course.code} is canceled`); + } + } + + // Check: section completeness + for (const course of resolvedCourses) { + const byType = new Map(); + for (const s of course.sections) { + if (!byType.has(s.type)) byType.set(s.type, []); + byType.get(s.type)!.push(s); + } + + for (const [type, sections] of byType) { + const allClosed = sections.every((s) => s.status === "closed"); + const allCanceled = sections.every((s) => s.status === "canceled"); + if (allCanceled) { + issues.push(`No available ${type} sections: all ${type} sections for ${course.code} are canceled`); + } else if (allClosed) { + issues.push( + `All ${type} sections full: all ${type} sections for ${course.code} are closed (${sections[0].tot}/${sections[0].cap} enrolled)` + ); + } + } + + if (course.sections.length === 0) { + issues.push(`No sections: ${course.code} has no sections listed`); + } + } + + // Check: time conflicts between courses + for (let i = 0; i < resolvedCourses.length; i++) { + for (let j = i + 1; j < resolvedCourses.length; j++) { + const a = resolvedCourses[i]; + const b = resolvedCourses[j]; + + const aByType = new Map(); + for (const s of a.sections) { + if (!aByType.has(s.type)) aByType.set(s.type, []); + aByType.get(s.type)!.push(s); + } + + const bByType = new Map(); + for (const s of b.sections) { + if (!bByType.has(s.type)) bByType.set(s.type, []); + bByType.get(s.type)!.push(s); + } + + for (const [aType, aSections] of aByType) { + for (const [bType, bSections] of bByType) { + const allConflict = aSections.every((aS) => + bSections.every((bS) => timeSlotsOverlap(aS, bS)) + ); + if (allConflict && aSections[0].days !== 0 && bSections[0].days !== 0) { + issues.push( + `Time conflict: ${a.code} ${aType} sections all conflict with ${b.code} ${bType} sections` + ); + } + } + } + } + } + + const valid = issues.length === 0; + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + valid, + courseCount: resolvedCourses.length, + term: terms.length === 1 ? { code: terms[0], name: termCodeToName(terms[0]) } : null, + courses: resolvedCourses.map((c) => ({ + code: c.code, + status: c.status, + sectionTypes: [...new Set(c.sections.map((s) => s.type))], + })), + issues: valid ? "Schedule is valid — no issues detected" : issues, + }, + null, + 2 + ), + }, + ], + }; + } + ); + + // ── WRITE TOOLS ───────────────────────────────────────────────────── + + // Helper: verify schedule ownership and return the schedule row + async function verifyScheduleOwnership( + scheduleId: number, + supabaseUuid: string + ): Promise<{ schedule?: { id: number; term: number; title: string; user_id: string }; error?: string }> { + const { data, error } = await supabase + .from("schedules") + .select("id, term, title, user_id") + .eq("id", scheduleId) + .single(); + + if (error || !data) return { error: "Schedule not found." }; + if (data.user_id !== supabaseUuid) return { error: "Forbidden: schedule does not belong to authenticated user." }; + return { schedule: data }; + } + + // Helper: resolve course by code within a term + async function resolveCourseByCode( + code: string, + term: number + ): Promise<{ course?: { id: number; code: string; title: string }; error?: string }> { + const { data } = await supabase + .from("courses") + .select("id, code, title") + .ilike("code", code) + .eq("term", term) + .limit(1); + + if (!data || data.length === 0) { + return { error: `Course "${code}" not found for term ${termCodeToName(term)} (${term}).` }; + } + return { course: data[0] }; + } + + // Helper: generate metadata for a course being added to a schedule + async function generateCourseMetadata( + scheduleId: number, + courseId: number + ): Promise<{ complete: boolean; color: number; sections: string[]; confirms: Record }> { + // Pick a color: find unused 0-6 among existing courses in this schedule + const { data: existingAssocs } = await supabase + .from("course_schedule_associations") + .select("metadata") + .eq("schedule_id", scheduleId); + + const usedColors = new Map(); + for (const a of existingAssocs ?? []) { + const meta = a.metadata as { color?: number } | null; + if (meta?.color != null) { + usedColors.set(meta.color, (usedColors.get(meta.color) ?? 0) + 1); + } + } + + let color = 0; + for (let c = 0; c <= 6; c++) { + if (!usedColors.has(c)) { color = c; break; } + if (c === 6) { + // All used — pick least-used + let minCount = Infinity; + for (const [col, count] of usedColors) { + if (count < minCount) { minCount = count; color = col; } + } + } + } + + // Fetch sections for the course and extract categories + const { data: sections } = await supabase + .from("sections") + .select("title, category") + .eq("course_id", courseId); + + const categoryMap = new Map(); + for (const s of sections ?? []) { + const cat = s.category; + if (!categoryMap.has(cat)) categoryMap.set(cat, []); + categoryMap.get(cat)!.push(s.title); + } + + const sectionCategories = [...categoryMap.keys()].sort(); + + // Auto-confirm categories with exactly 1 section + const confirms: Record = {}; + for (const [cat, titles] of categoryMap) { + if (titles.length === 1) { + confirms[cat] = titles[0]; + } + } + + const complete = sectionCategories.every((cat) => confirms[cat] != null); + + return { complete, color, sections: sectionCategories, confirms }; + } + + // ── create_schedule ───────────────────────────────────────────────── + server.tool( + "create_schedule", + "Create a new schedule for the authenticated user.", + { + term: z.number().describe("Term code for the schedule (e.g., 1272 for Fall 2026)."), + title: z.string().optional().describe("Schedule title (default: 'My Schedule')."), + }, + async ({ term, title }) => { + const auth = await resolveSupabaseUserId(supabase, authContext); + if (!auth.supabaseUuid) { + return { content: [{ type: "text" as const, text: auth.error ?? "Unauthorized." }], isError: true }; + } + + const { data, error } = await supabase + .from("schedules") + .insert({ user_id: auth.supabaseUuid, term, title: title ?? "My Schedule" }) + .select("id, title, term") + .single(); + + if (error) { + return { content: [{ type: "text" as const, text: `Failed to create schedule: ${error.message}` }], isError: true }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ created: true, schedule: { ...data, termName: termCodeToName(data.term) } }, null, 2), + }, + ], + }; + } + ); + + // ── add_course_to_schedule ────────────────────────────────────────── + server.tool( + "add_course_to_schedule", + "Add a course to an existing schedule. Automatically generates metadata (color, section categories). The course must exist for the schedule's term.", + { + scheduleId: z.number().describe("Schedule ID to add the course to."), + courseCode: z.string().describe("Course code (e.g., 'COS 226')."), + }, + async ({ scheduleId, courseCode }) => { + const auth = await resolveSupabaseUserId(supabase, authContext); + if (!auth.supabaseUuid) { + return { content: [{ type: "text" as const, text: auth.error ?? "Unauthorized." }], isError: true }; + } + + const ownership = await verifyScheduleOwnership(scheduleId, auth.supabaseUuid); + if (!ownership.schedule) { + return { content: [{ type: "text" as const, text: ownership.error ?? "Schedule error." }], isError: true }; + } + + const resolved = await resolveCourseByCode(courseCode, ownership.schedule.term); + if (!resolved.course) { + return { content: [{ type: "text" as const, text: resolved.error ?? "Course not found." }], isError: true }; + } + + // Check if already in schedule + const { data: existing } = await supabase + .from("course_schedule_associations") + .select("course_id") + .eq("schedule_id", scheduleId) + .eq("course_id", resolved.course.id) + .limit(1); + + if (existing && existing.length > 0) { + return { + content: [{ type: "text" as const, text: `${resolved.course.code} is already in this schedule.` }], + isError: true, + }; + } + + const metadata = await generateCourseMetadata(scheduleId, resolved.course.id); + + const { error } = await supabase + .from("course_schedule_associations") + .insert({ course_id: resolved.course.id, schedule_id: scheduleId, metadata }); + + if (error) { + return { content: [{ type: "text" as const, text: `Failed to add course: ${error.message}` }], isError: true }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + added: true, + course: resolved.course, + scheduleId, + metadata, + }, + null, + 2 + ), + }, + ], + }; + } + ); + + // ── remove_course_from_schedule ───────────────────────────────────── + server.tool( + "remove_course_from_schedule", + "Remove a course from a schedule.", + { + scheduleId: z.number().describe("Schedule ID."), + courseCode: z.string().describe("Course code to remove (e.g., 'COS 226')."), + }, + async ({ scheduleId, courseCode }) => { + const auth = await resolveSupabaseUserId(supabase, authContext); + if (!auth.supabaseUuid) { + return { content: [{ type: "text" as const, text: auth.error ?? "Unauthorized." }], isError: true }; + } + + const ownership = await verifyScheduleOwnership(scheduleId, auth.supabaseUuid); + if (!ownership.schedule) { + return { content: [{ type: "text" as const, text: ownership.error ?? "Schedule error." }], isError: true }; + } + + const resolved = await resolveCourseByCode(courseCode, ownership.schedule.term); + if (!resolved.course) { + return { content: [{ type: "text" as const, text: resolved.error ?? "Course not found." }], isError: true }; + } + + const { error, count } = await supabase + .from("course_schedule_associations") + .delete() + .eq("schedule_id", scheduleId) + .eq("course_id", resolved.course.id); + + if (error) { + return { content: [{ type: "text" as const, text: `Failed to remove course: ${error.message}` }], isError: true }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ removed: true, course: resolved.course, scheduleId }, null, 2), + }, + ], + }; + } + ); + + // ── rename_schedule ───────────────────────────────────────────────── + server.tool( + "rename_schedule", + "Rename a schedule.", + { + scheduleId: z.number().describe("Schedule ID."), + title: z.string().describe("New title for the schedule."), + }, + async ({ scheduleId, title }) => { + const auth = await resolveSupabaseUserId(supabase, authContext); + if (!auth.supabaseUuid) { + return { content: [{ type: "text" as const, text: auth.error ?? "Unauthorized." }], isError: true }; + } + + const ownership = await verifyScheduleOwnership(scheduleId, auth.supabaseUuid); + if (!ownership.schedule) { + return { content: [{ type: "text" as const, text: ownership.error ?? "Schedule error." }], isError: true }; + } + + const { error } = await supabase + .from("schedules") + .update({ title }) + .eq("id", scheduleId); + + if (error) { + return { content: [{ type: "text" as const, text: `Failed to rename schedule: ${error.message}` }], isError: true }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ renamed: true, scheduleId, newTitle: title }, null, 2), + }, + ], + }; + } + ); + + // ── delete_schedule ───────────────────────────────────────────────── + server.tool( + "delete_schedule", + "Delete a schedule and all its course associations. This cannot be undone.", + { + scheduleId: z.number().describe("Schedule ID to delete."), + }, + async ({ scheduleId }) => { + const auth = await resolveSupabaseUserId(supabase, authContext); + if (!auth.supabaseUuid) { + return { content: [{ type: "text" as const, text: auth.error ?? "Unauthorized." }], isError: true }; + } + + const ownership = await verifyScheduleOwnership(scheduleId, auth.supabaseUuid); + if (!ownership.schedule) { + return { content: [{ type: "text" as const, text: ownership.error ?? "Schedule error." }], isError: true }; + } + + const { error } = await supabase + .from("schedules") + .delete() + .eq("id", scheduleId); + + if (error) { + return { content: [{ type: "text" as const, text: `Failed to delete schedule: ${error.message}` }], isError: true }; + } + + return { + content: [ + { + type: "text" as const, + text: JSON.stringify({ + deleted: true, + scheduleId, + title: ownership.schedule.title, + }, null, 2), + }, + ], + }; + } + ); +} diff --git a/apps/engine/src/plugins/supabase.ts b/apps/engine/src/plugins/supabase.ts new file mode 100644 index 00000000..53b0cdf2 --- /dev/null +++ b/apps/engine/src/plugins/supabase.ts @@ -0,0 +1,32 @@ +import fp from "fastify-plugin"; +import type { FastifyPluginAsync } from "fastify"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; + +declare module "fastify" { + interface FastifyInstance { + supabase: SupabaseClient; + } +} + +const supabasePlugin: FastifyPluginAsync = async (app) => { + const url = process.env.SUPABASE_URL?.trim(); + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY?.trim(); + + if (!url || !serviceRoleKey) { + app.log.warn("SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY not set — Supabase plugin disabled. /junction/mcp schedule tools will not work."); + // Decorate with a dummy so Fastify doesn't throw on access + app.decorate("supabase", null as unknown as SupabaseClient); + return; + } + + const supabase = createClient(url, serviceRoleKey, { + auth: { persistSession: false, autoRefreshToken: false }, + }); + + app.decorate("supabase", supabase); + app.log.info("Supabase client initialized"); +}; + +export default fp(supabasePlugin, { + name: "supabase-plugin", +}); diff --git a/apps/engine/src/routes/mcp.ts b/apps/engine/src/routes/mcp.ts index 88c968ac..aa78eee9 100644 --- a/apps/engine/src/routes/mcp.ts +++ b/apps/engine/src/routes/mcp.ts @@ -135,7 +135,8 @@ const mcpRoutes: FastifyPluginAsync = async (app, opts) => { .send(rpcError(-32009, "Too many active MCP sessions for this client. Close a session and retry.")); } - const mcpServer = createMcpServer(app.db.db, auth.authContext, scope); + const supabase = scope === "junction" ? app.supabase : undefined; + const mcpServer = createMcpServer(app.db.db, auth.authContext, scope, supabase); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), }); From 6db04d328445573237690faa959e87c08b1dafc8 Mon Sep 17 00:00:00 2001 From: DIodide Date: Fri, 27 Mar 2026 03:56:49 -0400 Subject: [PATCH 2/2] Significant changes + connection to junction --- apps/ask-gateway/app/chat_service.py | 185 +++++-- apps/ask-gateway/app/config.py | 2 + apps/ask-gateway/app/llm_client.py | 1 + apps/ask-gateway/app/main.py | 42 ++ apps/ask-gateway/app/supabase_store.py | 185 +++++++ apps/ask-gateway/app/usage_tracker.py | 125 +++++ apps/engine/.mcp.json | 8 + apps/engine/bun.lockb | Bin 172461 -> 175828 bytes apps/engine/package.json | 1 + apps/engine/src/app.ts | 3 + apps/engine/src/mcp/index.ts | 10 +- .../src/mcp/tools/junction-schedules.ts | 13 +- apps/engine/src/mcp/tools/snatch.ts | 516 ++++++++++++++++++ apps/engine/src/plugins/snatch-db.ts | 34 ++ apps/engine/src/routes/mcp.ts | 3 +- 15 files changed, 1078 insertions(+), 50 deletions(-) create mode 100644 apps/ask-gateway/app/supabase_store.py create mode 100644 apps/ask-gateway/app/usage_tracker.py create mode 100644 apps/engine/.mcp.json create mode 100644 apps/engine/src/mcp/tools/snatch.ts create mode 100644 apps/engine/src/plugins/snatch-db.ts diff --git a/apps/ask-gateway/app/chat_service.py b/apps/ask-gateway/app/chat_service.py index 5e8091d7..5a96be26 100644 --- a/apps/ask-gateway/app/chat_service.py +++ b/apps/ask-gateway/app/chat_service.py @@ -14,16 +14,19 @@ from .mcp_client import McpClientError, McpHttpClient from .models import AskStreamRequest, ToolCall from .response_synthesizer import synthesize_final_response +from .usage_tracker import ( + resolve_model_for_user_async, + record_usage_async, + get_user_usage_async, +) +from . import supabase_store -MAX_TOOL_ITERATIONS = 30 +MAX_TOOL_ITERATIONS = 20 _SYSTEM_PROMPT = """\ You are an AI course assistant for Princeton University students. You help students \ find courses, understand workload, compare options, and make informed decisions. -You have access to tools that search courses, get course details, evaluations, \ -instructor info, and more. Use them to answer accurately. - The upcoming term is Fall 2026 (term code 1272). Unless the user specifies otherwise, \ default to searching and discussing courses for Fall 2026. The current term is Spring 2026 (1264). @@ -31,26 +34,15 @@ - Always use tools to look up real data. Do not fabricate course information. - After receiving tool results, synthesize a helpful, conversational response. - When comparing courses, highlight key differences (rating, workload, schedule). -- Format course codes as "DEPT NNN" (e.g., COS 226, not COS226). - Keep responses concise but thorough. Use bullet points and bold for readability. -- If a course is not found, say so honestly and suggest alternatives. - When searching for courses, prefer term 1272 (Fall 2026) unless the user asks about a different term. """ _SCHEDULE_PROMPT_ADDENDUM = """ -You also have access to the user's TigerJunction schedule. You can: +You also have access to the user's TigerJunction (junction.tigerapps.org) schedule. - Get their schedules with get_user_schedules (no userId needed — you are already authenticated) -- Get full details of a schedule with get_schedule_details -- Search for courses that don't conflict with their schedule by passing scheduleId to search_courses -- Verify a proposed schedule with verify_schedule -- Create a new schedule with create_schedule -- Add a course with add_course_to_schedule (needs scheduleId and courseCode like "COS 226") -- Remove a course with remove_course_from_schedule -- Rename a schedule with rename_schedule -- Delete a schedule with delete_schedule -When the user asks about "my schedule", "my courses", or wants to add/remove/manage courses, use these tools. -Always get the user's schedules first to find the right scheduleId before adding/removing courses or filtering by conflicts. +When the user asks about "my schedule", "my courses", or wants to add/remove/manage courses, use tools. When the user wants to find courses that fit their schedule, use search_courses with the scheduleId parameter — this combines all search filters (department, text, days, time, instructor, distribution) with schedule conflict checking. """ @@ -146,6 +138,23 @@ async def _stream_agentic( llm_client = OpenAiLlmClient(self._settings) session_id: str | None = None + # Quota enforcement + quota_before: dict | None = None + effective_model: str | None = payload.model + if payload.netid: + quota_before = await resolve_model_for_user_async(payload.netid) + if quota_before["blocked"]: + yield sse_event( + "quota_exhausted", + { + "percentUsed": 100, + "resetSeconds": quota_before["resetSeconds"], + "requestId": request_id, + }, + ) + return + effective_model = quota_before["model"] + try: yield sse_event("status", {"phase": "starting", "requestId": request_id}) @@ -165,6 +174,12 @@ async def _stream_agentic( # list_tools initializes the session, so capture it session_id = mcp_client._session_id collected_usage: dict[str, Any] | None = None + # Accumulate usage across all LLM iterations (tool-calling loop) + total_cost = 0.0 + total_input_tokens = 0 + total_output_tokens = 0 + # Track tool calls for persistence + persisted_tool_events: list[dict[str, Any]] = [] for iteration in range(MAX_TOOL_ITERATIONS): if is_disconnected(): @@ -176,13 +191,18 @@ async def _stream_agentic( finish_reason: str | None = None async for chunk in llm_client.stream_chat( - messages=messages, tools=llm_tools, model=payload.model + messages=messages, tools=llm_tools, model=effective_model ): if chunk.get("type") == "done": break if chunk.get("usage"): collected_usage = chunk["usage"] + total_cost += collected_usage.get("cost") or 0 + total_input_tokens += collected_usage.get("prompt_tokens") or 0 + total_output_tokens += ( + collected_usage.get("completion_tokens") or 0 + ) choices = chunk.get("choices", []) if not choices: @@ -226,11 +246,47 @@ async def _stream_agentic( # since some models like Gemini use "stop" even with tool calls). if not collected_tool_calls: usage = { - "inputTokens": (collected_usage or {}).get("prompt_tokens", 0), - "outputTokens": (collected_usage or {}).get( - "completion_tokens", 0 - ), + "inputTokens": total_input_tokens, + "outputTokens": total_output_tokens, } + + # Record cost and get updated quota + quota_after: dict | None = None + if payload.netid: + if total_cost > 0: + await record_usage_async(payload.netid, total_cost) + quota_after = await get_user_usage_async(payload.netid) + + # Save conversation to Supabase + conv_title = ( + payload.messages[0].content[:80] + if payload.messages + else "New chat" + ) + await supabase_store.save_message( + conversation_id, payload.netid, conv_title, "user", prompt + ) + # Save tool calls/results + for te in persisted_tool_events: + await supabase_store.save_message( + conversation_id, + payload.netid, + conv_title, + te["type"], + json.dumps(te, default=str), + ) + await supabase_store.save_message( + conversation_id, + payload.netid, + conv_title, + "assistant", + collected_content, + cost=total_cost if total_cost > 0 else None, + input_tokens=total_input_tokens or None, + output_tokens=total_output_tokens or None, + model=effective_model, + ) + yield sse_event( "status", { @@ -239,15 +295,22 @@ async def _stream_agentic( **({"sessionId": session_id} if session_id else {}), }, ) - yield sse_event( - "done", - { - "conversationId": conversation_id, - "requestId": request_id, - **({"sessionId": session_id} if session_id else {}), - "usage": usage, - }, - ) + + done_data: dict[str, Any] = { + "conversationId": conversation_id, + "requestId": request_id, + **({"sessionId": session_id} if session_id else {}), + "usage": usage, + } + if quota_after is not None: + done_data["quota"] = { + "percentUsed": quota_after["percentUsed"], + "tier": quota_after["tier"], + "tierChanged": quota_before is not None + and quota_before["tier"] != quota_after["tier"], + "resetSeconds": quota_after["resetSeconds"], + } + yield sse_event("done", done_data) return yield sse_event( @@ -293,10 +356,25 @@ async def _stream_agentic( "sessionId": session_id, }, ) + persisted_tool_events.append( + { + "type": "tool_call", + "name": tool_name, + "arguments": tool_args, + } + ) result = await asyncio.wait_for( mcp_client.call_tool(tool_name, tool_args), timeout=self._settings.tool_timeout_seconds, ) + persisted_tool_events.append( + { + "type": "tool_result", + "name": tool_name, + "ok": True, + "result": result, + } + ) yield sse_event( "tool_result", { @@ -383,6 +461,21 @@ async def _stream_deterministic( mcp_url = self._settings.junction_mcp_url if payload.netid else None mcp_client = McpHttpClient(self._settings, netid=payload.netid, mcp_url=mcp_url) session_id: str | None = None + + # Quota enforcement (deterministic doesn't call LLM, but still check) + if payload.netid: + det_quota = await resolve_model_for_user_async(payload.netid) + if det_quota["blocked"]: + yield sse_event( + "quota_exhausted", + { + "percentUsed": 100, + "resetSeconds": det_quota["resetSeconds"], + "requestId": request_id, + }, + ) + return + try: yield sse_event("status", {"phase": "starting", "requestId": request_id}) tool_calls = _plan_tools(prompt, payload.term) @@ -459,18 +552,24 @@ async def _stream_deterministic( **({"sessionId": session_id} if session_id else {}), }, ) - yield sse_event( - "done", - { - "conversationId": conversation_id, - "requestId": request_id, - **({"sessionId": session_id} if session_id else {}), - "usage": { - "inputTokens": 0, - "outputTokens": len(response_text.split()), - }, + det_done_data: dict[str, Any] = { + "conversationId": conversation_id, + "requestId": request_id, + **({"sessionId": session_id} if session_id else {}), + "usage": { + "inputTokens": 0, + "outputTokens": len(response_text.split()), }, - ) + } + if payload.netid: + det_q = await get_user_usage_async(payload.netid) + det_done_data["quota"] = { + "percentUsed": det_q["percentUsed"], + "tier": det_q["tier"], + "tierChanged": False, + "resetSeconds": det_q["resetSeconds"], + } + yield sse_event("done", det_done_data) except asyncio.CancelledError: yield sse_event( "error", @@ -532,5 +631,3 @@ def _extract_reasoning(delta: dict[str, Any]) -> str: if isinstance(reasoning, str): return reasoning return "" - - diff --git a/apps/ask-gateway/app/config.py b/apps/ask-gateway/app/config.py index cbd9c960..2897a2e2 100644 --- a/apps/ask-gateway/app/config.py +++ b/apps/ask-gateway/app/config.py @@ -34,3 +34,5 @@ class Settings: ask_llm_timeout_seconds: float = float(os.getenv("ASK_LLM_TIMEOUT_SECONDS", "12")) ask_llm_planner_enabled: bool = _env_bool("ASK_LLM_PLANNER_ENABLED", False) ask_llm_synthesis_enabled: bool = _env_bool("ASK_LLM_SYNTHESIS_ENABLED", False) + supabase_url: str = os.getenv("SUPABASE_URL", "") + supabase_service_role_key: str = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "") diff --git a/apps/ask-gateway/app/llm_client.py b/apps/ask-gateway/app/llm_client.py index ec408ec9..46d22bd5 100644 --- a/apps/ask-gateway/app/llm_client.py +++ b/apps/ask-gateway/app/llm_client.py @@ -48,6 +48,7 @@ async def stream_chat( "model": model or self._settings.ask_llm_model, "messages": messages, "stream": True, + "stream_options": {"include_usage": True}, } if tools: request["tools"] = tools diff --git a/apps/ask-gateway/app/main.py b/apps/ask-gateway/app/main.py index 1415cfd4..d22aec8f 100644 --- a/apps/ask-gateway/app/main.py +++ b/apps/ask-gateway/app/main.py @@ -10,6 +10,8 @@ from .chat_service import ChatService from .config import Settings from .models import AskStreamRequest +from .usage_tracker import get_user_usage_async +from . import supabase_store app = FastAPI(title="Ask Gateway", version="1.0.0") logger = logging.getLogger("ask-gateway") @@ -36,6 +38,46 @@ async def health() -> dict[str, str]: return {"status": "ok"} +@app.get("/ask/quota") +async def get_quota( + netid: str, + authorization: str | None = Header(default=None), + settings: Settings = Depends(get_settings), +) -> dict: + _validate_gateway_auth(settings, authorization) + if not netid: + raise HTTPException(status_code=400, detail="netid is required") + return await get_user_usage_async(netid) + + +@app.get("/ask/conversations") +async def list_conversations( + netid: str, + authorization: str | None = Header(default=None), + settings: Settings = Depends(get_settings), +) -> list: + _validate_gateway_auth(settings, authorization) + if not netid: + raise HTTPException(status_code=400, detail="netid is required") + return await supabase_store.list_conversations(netid) + + +@app.get("/ask/conversations/{conv_id}/messages") +async def get_conversation_messages( + conv_id: str, + netid: str, + authorization: str | None = Header(default=None), + settings: Settings = Depends(get_settings), +) -> list: + _validate_gateway_auth(settings, authorization) + if not netid: + raise HTTPException(status_code=400, detail="netid is required") + messages = await supabase_store.get_conversation_messages(conv_id, netid) + if messages is None: + raise HTTPException(status_code=404, detail="Conversation not found") + return messages + + @app.post("/ask/stream") async def ask_stream( payload: AskStreamRequest, diff --git a/apps/ask-gateway/app/supabase_store.py b/apps/ask-gateway/app/supabase_store.py new file mode 100644 index 00000000..941d6cd6 --- /dev/null +++ b/apps/ask-gateway/app/supabase_store.py @@ -0,0 +1,185 @@ +"""Supabase persistence for quotas, conversations, and messages. + +Uses httpx to call Supabase PostgREST API directly — no extra dependencies. +""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +import httpx + +logger = logging.getLogger("ask-gateway.store") + +_SUPABASE_URL = os.getenv("SUPABASE_URL", "").rstrip("/") +_SUPABASE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "") + + +def _headers() -> dict[str, str]: + return { + "apikey": _SUPABASE_KEY, + "Authorization": f"Bearer {_SUPABASE_KEY}", + "Content-Type": "application/json", + "Prefer": "return=representation", + } + + +def _rest_url(table: str) -> str: + return f"{_SUPABASE_URL}/rest/v1/{table}" + + +def _enabled() -> bool: + return bool(_SUPABASE_URL and _SUPABASE_KEY) + + +# ── Quotas ───────────────────────────────────────────────────────────── + +async def get_quota_spent(netid: str, time_window: str) -> float: + if not _enabled(): + return 0.0 + async with httpx.AsyncClient() as client: + r = await client.get( + _rest_url("ask_quotas"), + headers={**_headers(), "Accept": "application/json"}, + params={"netid": f"eq.{netid}", "time_window": f"eq.{time_window}", "select": "spent"}, + ) + if r.status_code == 200: + rows = r.json() + if rows: + return float(rows[0].get("spent", 0)) + return 0.0 + + +async def upsert_quota(netid: str, time_window: str, spent: float) -> None: + if not _enabled(): + return + async with httpx.AsyncClient() as client: + await client.post( + _rest_url("ask_quotas"), + headers={**_headers(), "Prefer": "resolution=merge-duplicates"}, + json={"netid": netid, "time_window": time_window, "spent": round(spent, 6)}, + ) + + +# ── Conversations ────────────────────────────────────────────────────── + +async def upsert_conversation(conv_id: str, netid: str, title: str) -> None: + if not _enabled(): + return + async with httpx.AsyncClient() as client: + await client.post( + _rest_url("ask_conversations"), + headers={**_headers(), "Prefer": "resolution=merge-duplicates"}, + json={"id": conv_id, "netid": netid, "title": title[:100], "updated_at": "now()"}, + ) + + +async def update_conversation_timestamp(conv_id: str) -> None: + if not _enabled(): + return + async with httpx.AsyncClient() as client: + await client.patch( + _rest_url("ask_conversations"), + headers=_headers(), + params={"id": f"eq.{conv_id}"}, + json={"updated_at": "now()"}, + ) + + +async def save_message( + conv_id: str, + netid: str, + title: str, + role: str, + content: str, + cost: float | None = None, + input_tokens: int | None = None, + output_tokens: int | None = None, + model: str | None = None, +) -> None: + """Save a single message and ensure the conversation exists.""" + if not _enabled(): + return + try: + # Ensure conversation exists + await upsert_conversation(conv_id, netid, title) + + # Insert message + msg: dict[str, Any] = { + "conversation_id": conv_id, + "role": role, + "content": content, + } + if cost is not None: + msg["cost"] = round(cost, 6) + if input_tokens is not None: + msg["input_tokens"] = input_tokens + if output_tokens is not None: + msg["output_tokens"] = output_tokens + if model is not None: + msg["model"] = model + + async with httpx.AsyncClient() as client: + await client.post( + _rest_url("ask_messages"), + headers=_headers(), + json=msg, + ) + + # Update conversation timestamp + await update_conversation_timestamp(conv_id) + except Exception as e: + logger.error("Failed to save message: %s", e) + + +async def list_conversations(netid: str, limit: int = 20) -> list[dict[str, Any]]: + if not _enabled(): + return [] + async with httpx.AsyncClient() as client: + r = await client.get( + _rest_url("ask_conversations"), + headers={**_headers(), "Accept": "application/json"}, + params={ + "netid": f"eq.{netid}", + "select": "id,title,created_at,updated_at", + "order": "updated_at.desc", + "limit": str(limit), + }, + ) + if r.status_code == 200: + return r.json() + return [] + + +async def get_conversation_messages( + conv_id: str, netid: str +) -> list[dict[str, Any]] | None: + """Get messages for a conversation, verifying ownership.""" + if not _enabled(): + return None + + async with httpx.AsyncClient() as client: + # Verify ownership + r = await client.get( + _rest_url("ask_conversations"), + headers={**_headers(), "Accept": "application/json"}, + params={"id": f"eq.{conv_id}", "netid": f"eq.{netid}", "select": "id"}, + ) + if r.status_code != 200 or not r.json(): + return None + + # Fetch messages + r = await client.get( + _rest_url("ask_messages"), + headers={**_headers(), "Accept": "application/json"}, + params={ + "conversation_id": f"eq.{conv_id}", + "select": "role,content,cost,input_tokens,output_tokens,model,created_at", + "order": "created_at.asc", + }, + ) + if r.status_code == 200: + return r.json() + return None diff --git a/apps/ask-gateway/app/usage_tracker.py b/apps/ask-gateway/app/usage_tracker.py new file mode 100644 index 00000000..8fcdf136 --- /dev/null +++ b/apps/ask-gateway/app/usage_tracker.py @@ -0,0 +1,125 @@ +"""Per-user usage tracking with tiered quota system, persisted to Supabase. + +Tier 1: Claude Sonnet 4.6 — $0.50 budget +Tier 2: Claude Haiku 4.5 — $0.25 budget (auto-downgrade when Tier 1 exhausted) +Exhausted: No more requests until next 8-hour window + +Resets at 12:00 AM, 8:00 AM, 4:00 PM US Eastern. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone, timedelta +from typing import Any + +from . import supabase_store + +logger = logging.getLogger("ask-gateway.usage") + +TIER1_MODEL = "anthropic/claude-sonnet-4.6" +TIER2_MODEL = "anthropic/claude-haiku-4.5" +TIER1_BUDGET = 0.50 +TIER2_BUDGET = 0.25 +TOTAL_BUDGET = TIER1_BUDGET + TIER2_BUDGET # $0.75 +WINDOW_HOURS = 8 + +_ET_OFFSET = timezone(timedelta(hours=-5)) + + +def _now_et() -> datetime: + return datetime.now(_ET_OFFSET) + + +def _get_window_id() -> str: + now = _now_et() + window_index = now.hour // WINDOW_HOURS + return f"{now.strftime('%Y-%m-%d')}-{window_index}" + + +def _seconds_until_next_window() -> int: + now = _now_et() + window_index = now.hour // WINDOW_HOURS + next_hour = (window_index + 1) * WINDOW_HOURS + if next_hour >= 24: + tomorrow = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) + return max(1, int((tomorrow - now).total_seconds())) + next_boundary = now.replace(hour=next_hour, minute=0, second=0, microsecond=0) + return max(1, int((next_boundary - now).total_seconds())) + + +def _build_status(spent: float) -> dict[str, Any]: + if spent < TIER1_BUDGET: + tier = 1 + model = TIER1_MODEL + blocked = False + elif spent < TOTAL_BUDGET: + tier = 2 + model = TIER2_MODEL + blocked = False + else: + tier = "exhausted" + model = TIER2_MODEL + blocked = True + + percent_used = min(100, round((spent / TOTAL_BUDGET) * 100, 1)) + reset_seconds = _seconds_until_next_window() + + return { + "spent": round(spent, 4), + "tier": tier, + "model": model, + "blocked": blocked, + "percentUsed": percent_used, + "resetSeconds": reset_seconds, + "window": _get_window_id(), + } + + +def get_user_usage(netid: str) -> dict[str, Any]: + """Synchronous wrapper — reads from Supabase.""" + window = _get_window_id() + try: + spent = asyncio.get_event_loop().run_until_complete( + supabase_store.get_quota_spent(netid, window) + ) + except RuntimeError: + # If no event loop, create one (shouldn't happen in FastAPI) + spent = asyncio.run(supabase_store.get_quota_spent(netid, window)) + return _build_status(spent) + + +async def get_user_usage_async(netid: str) -> dict[str, Any]: + """Async version for use in async handlers.""" + window = _get_window_id() + spent = await supabase_store.get_quota_spent(netid, window) + return _build_status(spent) + + +def resolve_model_for_user(netid: str) -> dict[str, Any]: + return get_user_usage(netid) + + +async def resolve_model_for_user_async(netid: str) -> dict[str, Any]: + return await get_user_usage_async(netid) + + +async def record_usage_async(netid: str, cost: float) -> None: + if cost <= 0: + return + window = _get_window_id() + current = await supabase_store.get_quota_spent(netid, window) + new_spent = current + cost + await supabase_store.upsert_quota(netid, window, new_spent) + logger.info("usage.record netid=%s cost=%.4f total=%.4f window=%s", netid, cost, new_spent, window) + + +def record_usage(netid: str, cost: float) -> None: + """Synchronous wrapper.""" + if cost <= 0: + return + try: + asyncio.get_event_loop().run_until_complete(record_usage_async(netid, cost)) + except RuntimeError: + asyncio.run(record_usage_async(netid, cost)) diff --git a/apps/engine/.mcp.json b/apps/engine/.mcp.json new file mode 100644 index 00000000..42694d08 --- /dev/null +++ b/apps/engine/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "supabase": { + "type": "http", + "url": "https://mcp.supabase.com/mcp" + } + } +} \ No newline at end of file diff --git a/apps/engine/bun.lockb b/apps/engine/bun.lockb index 048d12ced38e08aa8bf403010c9dc33e6269a1b3..85625db7cc97918b5d21be79f8787e297532cc24 100755 GIT binary patch delta 32915 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 diff --git a/apps/engine/package.json b/apps/engine/package.json index 92eb8842..a82a43e1 100644 --- a/apps/engine/package.json +++ b/apps/engine/package.json @@ -33,6 +33,7 @@ "fastify": "^5.5.0", "fastify-plugin": "^5.0.1", "jsdom": "^26.1.0", + "mongodb": "^7.1.1", "pg": "^8.16.3", "redis": "^4.6.0", "zod": "^4.3.6" diff --git a/apps/engine/src/app.ts b/apps/engine/src/app.ts index 23c5bc3c..7c54e0be 100644 --- a/apps/engine/src/app.ts +++ b/apps/engine/src/app.ts @@ -20,6 +20,7 @@ import evaluationsRoutes from "./routes/api/evaluations.ts"; import redisPlugin from "./plugins/redis.ts"; import dbPlugin from "./plugins/db.ts"; import supabasePlugin from "./plugins/supabase.ts"; +import snatchDbPlugin from "./plugins/snatch-db.ts"; import snatchRoutes from "./routes/snatch.ts"; import mcpRoutes from "./routes/mcp.ts"; @@ -77,6 +78,7 @@ export async function build(opts?: { logger?: boolean }): Promise { + // Supabase stores codes without spaces (e.g., "COS330"), but users type "COS 330". + // Try both the original and a no-space version. + const noSpace = code.replace(/\s+/g, ""); + const withSpace = code.replace(/([A-Za-z])(\d)/, "$1 $2"); + const { data } = await supabase .from("courses") .select("id, code, title") - .ilike("code", code) + .or(`code.ilike.${noSpace},code.ilike.${withSpace}`) .eq("term", term) .limit(1); diff --git a/apps/engine/src/mcp/tools/snatch.ts b/apps/engine/src/mcp/tools/snatch.ts new file mode 100644 index 00000000..9260f301 --- /dev/null +++ b/apps/engine/src/mcp/tools/snatch.ts @@ -0,0 +1,516 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; +import type { Db } from "mongodb"; +import { z } from "zod"; +import { eq, ilike, asc, and } from "drizzle-orm"; +import * as schema from "../../db/schema.js"; +import { formatSection, termCodeToName } from "../helpers.js"; +import type { AuthContext } from "../context.js"; + +function getSnatchConfig(): { url: string; token: string } | null { + const url = process.env.SNATCH_URL?.trim(); + const token = process.env.SNATCH_TOKEN?.trim(); + if (!url || !token) return null; + return { url: url.replace(/\/$/, ""), token }; +} + +async function snatchFetch( + path: string, + method: "GET" | "POST" = "POST" +): Promise<{ ok: boolean; data?: any; error?: string }> { + const config = getSnatchConfig(); + if (!config) return { ok: false, error: "TigerSnatch is not configured (missing SNATCH_URL or SNATCH_TOKEN)." }; + + try { + const response = await fetch(`${config.url}${path}`, { + method, + headers: { Authorization: config.token }, + }); + const data = await response.json(); + if (!response.ok) { + return { ok: false, error: data?.message ?? `TigerSnatch returned status ${response.status}` }; + } + return { ok: true, data }; + } catch (err) { + return { ok: false, error: `Failed to reach TigerSnatch: ${err instanceof Error ? err.message : String(err)}` }; + } +} + +async function resolveClassId( + db: NodePgDatabase, + courseCode: string, + term?: number, + sectionTitle?: string +): Promise<{ classId?: number; section?: { title: string; days: number; startTime: number; endTime: number }; courseName?: string; error?: string }> { + const conditions = [ilike(schema.courses.code, courseCode)]; + if (term) conditions.push(eq(schema.courses.term, term)); + + const courses = await db + .select({ id: schema.courses.id, code: schema.courses.code, title: schema.courses.title, term: schema.courses.term }) + .from(schema.courses) + .where(and(...conditions)) + .orderBy(asc(schema.courses.term)) + .limit(1); + + if (courses.length === 0) { + return { error: `Course "${courseCode}" not found${term ? ` for ${termCodeToName(term)}` : ""}.` }; + } + + const course = courses[0]; + const sectionConditions = [eq(schema.sections.courseId, course.id)]; + if (sectionTitle) { + sectionConditions.push(ilike(schema.sections.title, sectionTitle)); + } + + const sections = await db + .select({ + id: schema.sections.id, + title: schema.sections.title, + days: schema.sections.days, + startTime: schema.sections.startTime, + endTime: schema.sections.endTime, + status: schema.sections.status, + cap: schema.sections.cap, + tot: schema.sections.tot, + }) + .from(schema.sections) + .where(and(...sectionConditions)) + .orderBy(asc(schema.sections.id)); + + if (sections.length === 0) { + return { error: `No sections found for ${course.code}${sectionTitle ? ` section ${sectionTitle}` : ""}.` }; + } + + if (sections.length === 1 || sectionTitle) { + const s = sections[0]; + return { + classId: s.id, + section: { title: s.title, days: s.days, startTime: s.startTime, endTime: s.endTime }, + courseName: `${course.code} — ${course.title}`, + }; + } + + const sectionList = sections.map((s) => { + const formatted = formatSection(s); + return `${s.title} (${formatted.days.join("")} ${formatted.startTime}–${formatted.endTime}, ${s.status}, ${s.tot}/${s.cap} enrolled)`; + }); + + return { + error: `${course.code} has ${sections.length} sections. Please specify which one:\n${sectionList.join("\n")}`, + }; +} + +export function registerSnatchTools( + server: McpServer, + db: NodePgDatabase, + authContext?: AuthContext, + snatchDb?: Db | null +) { + // ── get_snatch_subscriptions ──────────────────────────────────────── + server.tool( + "get_snatch_subscriptions", + "Get the user's current TigerSnatch notification subscriptions — classes they'll be notified about when a seat opens.", + {}, + async () => { + if (!authContext?.netid) { + return { content: [{ type: "text" as const, text: "Requires authenticated user (x-user-netid header)." }], isError: true }; + } + + const result = await snatchFetch(`/junction/get_user_data/${authContext.netid}`); + if (!result.ok) { + return { content: [{ type: "text" as const, text: result.error ?? "Failed to get subscriptions." }], isError: true }; + } + + const userData = result.data?.data; + if (userData === "missing") { + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ subscriptions: [], message: "No TigerSnatch account found. Visit tigersnatch.com to get started." }, null, 2), + }], + }; + } + + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + netid: authContext.netid, + subscriptions: userData?.waitlists ?? [], + autoResubscribe: userData?.auto_resub ?? null, + }, null, 2), + }], + }; + } + ); + + // ── subscribe_to_snatch ───────────────────────────────────────────── + server.tool( + "subscribe_to_snatch", + "Subscribe to TigerSnatch notifications for a class section. You'll get notified when a seat opens. Provide a course code and optionally a specific section (e.g., 'L01'). If the course has multiple sections and none is specified, you'll be shown the available sections to choose from.", + { + courseCode: z.string().describe("Course code (e.g., 'COS 226')."), + section: z.string().optional().describe("Section title (e.g., 'L01', 'P01'). If omitted and the course has multiple sections, available sections will be listed."), + term: z.number().optional().describe("Term code (e.g., 1272 for Fall 2026). Defaults to most recent term."), + }, + async ({ courseCode, section, term }) => { + if (!authContext?.netid) { + return { content: [{ type: "text" as const, text: "Requires authenticated user (x-user-netid header)." }], isError: true }; + } + + const resolved = await resolveClassId(db, courseCode, term, section); + if (!resolved.classId) { + return { content: [{ type: "text" as const, text: resolved.error ?? "Could not resolve class." }], isError: true }; + } + + const result = await snatchFetch(`/junction/add_to_waitlist/${authContext.netid}/${resolved.classId}`); + if (!result.ok) { + return { content: [{ type: "text" as const, text: result.error ?? "Failed to subscribe." }], isError: true }; + } + + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + subscribed: true, + course: resolved.courseName, + section: resolved.section?.title, + classId: resolved.classId, + message: `You'll be notified when a seat opens in ${resolved.courseName} (${resolved.section?.title}).`, + }, null, 2), + }], + }; + } + ); + + // ── unsubscribe_from_snatch ───────────────────────────────────────── + server.tool( + "unsubscribe_from_snatch", + "Unsubscribe from TigerSnatch notifications for a class section.", + { + courseCode: z.string().describe("Course code (e.g., 'COS 226')."), + section: z.string().optional().describe("Section title (e.g., 'L01', 'P01')."), + term: z.number().optional().describe("Term code (e.g., 1272 for Fall 2026). Defaults to most recent term."), + }, + async ({ courseCode, section, term }) => { + if (!authContext?.netid) { + return { content: [{ type: "text" as const, text: "Requires authenticated user (x-user-netid header)." }], isError: true }; + } + + const resolved = await resolveClassId(db, courseCode, term, section); + if (!resolved.classId) { + return { content: [{ type: "text" as const, text: resolved.error ?? "Could not resolve class." }], isError: true }; + } + + const result = await snatchFetch(`/junction/remove_from_waitlist/${authContext.netid}/${resolved.classId}`); + if (!result.ok) { + return { content: [{ type: "text" as const, text: result.error ?? "Failed to unsubscribe." }], isError: true }; + } + + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + unsubscribed: true, + course: resolved.courseName, + section: resolved.section?.title, + classId: resolved.classId, + }, null, 2), + }], + }; + } + ); + + // ── DEMAND / ANALYTICS TOOLS (powered by TigerSnatch MongoDB) ────── + + if (!snatchDb) return; + + // ── get_course_demand ─────────────────────────────────────────────── + server.tool( + "get_course_demand", + "Get demand signals for a course from TigerSnatch: real-time enrollment vs capacity for every section, how many students are subscribed for seat-open notifications (during add/drop), and whether the course has reserved seats. Useful for gauging how competitive a class is.", + { + courseCode: z.string().describe("Course code (e.g., 'COS 226')."), + }, + async ({ courseCode }) => { + // Search TigerSnatch mappings by displayname + const normalizedCode = courseCode.replace(/\s+/g, "").toUpperCase(); + const course = await snatchDb.collection("mappings").findOne({ + displayname: { $regex: new RegExp(`^${normalizedCode.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, "i") }, + }); + + if (!course) { + // Try whitespace version + const wsMatch = await snatchDb.collection("mappings").findOne({ + displayname_whitespace: { $regex: new RegExp(courseCode.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), "i") }, + }); + if (!wsMatch) { + return { content: [{ type: "text" as const, text: `Course "${courseCode}" not found in TigerSnatch.` }], isError: true }; + } + return await buildDemandResponse(snatchDb, wsMatch.courseid, wsMatch.displayname_whitespace, wsMatch.title); + } + + return await buildDemandResponse(snatchDb, course.courseid, course.displayname_whitespace, course.title); + } + ); + + // ── get_trending_courses ──────────────────────────────────────────── + server.tool( + "get_trending_courses", + "Get the most in-demand courses on TigerSnatch. During add/drop, shows courses with the most notification subscribers (students waiting for seats). Between semesters, shows platform-wide stats and historical data.", + { + limit: z.number().optional().describe("Max courses to return (default 15)."), + }, + async ({ limit: maxResults }) => { + const resultLimit = maxResults ?? 15; + + const admin = await snatchDb.collection("admin").findOne({}); + if (!admin) { + return { content: [{ type: "text" as const, text: "TigerSnatch admin data not available." }], isError: true }; + } + + const topSubs = (admin.stats_top_subs ?? []).slice(0, resultLimit); + const hasTrendingData = topSubs.length > 0; + + if (hasTrendingData) { + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + term: admin.current_term_name, + termCode: admin.current_term_code, + trendingCourses: topSubs, + platformStats: { + totalSubscriptions: admin.stats_total_subs, + subscribedUsers: admin.stats_subbed_users, + subscribedCourses: admin.stats_subbed_courses, + subscribedSections: admin.stats_subbed_sections, + }, + lastUpdated: admin.stats_update_time, + }, null, 2), + }], + }; + } + + // Between semesters — show aggregate stats and most-enrolled courses + const topEnrolled = await snatchDb.collection("enrollments") + .find({ capacity: { $gt: 5 } }) + .sort({ enrollment: -1 }) + .limit(resultLimit) + .toArray(); + + const enriched = []; + for (const e of topEnrolled) { + const course = await snatchDb.collection("mappings").findOne({ courseid: e.courseid }); + enriched.push({ + course: course?.displayname_whitespace ?? e.courseid, + title: course?.title ?? "", + section: e.section, + enrollment: e.enrollment, + capacity: e.capacity, + fillPercent: e.capacity > 0 ? Math.round((e.enrollment / e.capacity) * 100) : 0, + }); + } + + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + term: admin.current_term_name, + termCode: admin.current_term_code, + status: "Between semesters — no active subscriptions. Showing enrollment data.", + topEnrolled: enriched, + platformStats: { + totalUsersAllTime: admin.stats_total_users, + totalNotificationsAllTime: admin.stats_total_notifs, + }, + lastUpdated: admin.stats_update_time, + }, null, 2), + }], + }; + } + ); + + // ── get_course_historical_demand ────────────────────────────────────── + server.tool( + "get_course_historical_demand", + "Get historical demand trends for a course across past semesters. Shows enrollment fill rates, closed section counts, and capacity changes over time. Answers questions like 'Is this class hard to get into?' and 'Does this course usually fill up?'. Uses engine DB data across all available terms.", + { + courseCode: z.string().describe("Course code (e.g., 'COS 226')."), + }, + async ({ courseCode }) => { + // Resolve course to listing_id via engine DB + const courseRows = await db + .select({ + listingId: schema.courses.listingId, + code: schema.courses.code, + title: schema.courses.title, + }) + .from(schema.courses) + .where(ilike(schema.courses.code, courseCode)) + .orderBy(asc(schema.courses.term)) + .limit(1); + + if (courseRows.length === 0) { + return { content: [{ type: "text" as const, text: `Course "${courseCode}" not found.` }], isError: true }; + } + + const { listingId, code, title } = courseRows[0]; + + // Get all offerings of this course across terms + const offerings = await db + .select({ + term: schema.courses.term, + courseId: schema.courses.id, + status: schema.courses.status, + }) + .from(schema.courses) + .where(eq(schema.courses.listingId, listingId)) + .orderBy(asc(schema.courses.term)); + + const termStats = []; + let totalFillRateSum = 0; + let termsWithEnrollment = 0; + let termsFullyClosed = 0; + let termsWithClosedSections = 0; + + for (const offering of offerings) { + const sections = await db + .select({ + title: schema.sections.title, + status: schema.sections.status, + cap: schema.sections.cap, + tot: schema.sections.tot, + }) + .from(schema.sections) + .where(eq(schema.sections.courseId, offering.courseId)); + + const totalCap = sections.reduce((s, sec) => s + (sec.cap ?? 0), 0); + const totalEnrolled = sections.reduce((s, sec) => s + (sec.tot ?? 0), 0); + const closedCount = sections.filter((s) => s.status === "closed").length; + const canceledCount = sections.filter((s) => s.status === "canceled").length; + const fillRate = totalCap > 0 ? Math.round((totalEnrolled / totalCap) * 100) : 0; + + // Only count terms with actual enrollment data (not future terms) + if (totalEnrolled > 0) { + totalFillRateSum += fillRate; + termsWithEnrollment++; + if (offering.status === "closed") termsFullyClosed++; + if (closedCount > 0) termsWithClosedSections++; + } + + termStats.push({ + term: offering.term, + termName: termCodeToName(offering.term), + courseStatus: offering.status, + totalEnrolled, + totalCapacity: totalCap, + fillRate: `${fillRate}%`, + sections: sections.length, + closedSections: closedCount, + canceledSections: canceledCount, + }); + } + + const avgFillRate = termsWithEnrollment > 0 + ? Math.round(totalFillRateSum / termsWithEnrollment) + : 0; + + // Determine competitiveness + let competitiveness: string; + if (avgFillRate >= 95 || termsFullyClosed >= termsWithEnrollment * 0.5) { + competitiveness = "Very Competitive — this course consistently fills up and often closes. Plan to enroll early or use TigerSnatch."; + } else if (avgFillRate >= 80) { + competitiveness = "Competitive — this course typically fills most of its seats. Early enrollment recommended."; + } else if (avgFillRate >= 50) { + competitiveness = "Moderate — this course usually has available seats but does fill up in popular sections."; + } else { + competitiveness = "Low — this course generally has plenty of available seats."; + } + + // Capacity trend + const capsOverTime = termStats + .filter((t) => t.totalCapacity > 0) + .map((t) => t.totalCapacity); + let capacityTrend = "Stable"; + if (capsOverTime.length >= 2) { + const first = capsOverTime[0]; + const last = capsOverTime[capsOverTime.length - 1]; + const change = ((last - first) / first) * 100; + if (change > 15) capacityTrend = `Growing (+${Math.round(change)}% capacity since ${termStats[0].termName})`; + else if (change < -15) capacityTrend = `Shrinking (${Math.round(change)}% capacity since ${termStats[0].termName})`; + } + + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + course: code, + title, + termsOffered: offerings.length, + termsWithEnrollmentData: termsWithEnrollment, + averageFillRate: `${avgFillRate}%`, + timesFullyClosed: `${termsFullyClosed}/${termsWithEnrollment} terms`, + timesWithClosedSections: `${termsWithClosedSections}/${termsWithEnrollment} terms`, + capacityTrend, + competitiveness, + history: termStats, + }, null, 2), + }], + }; + } + ); +} + +async function buildDemandResponse(snatchDb: Db, courseid: string, displayName: string, title: string) { + // Get enrollment data for all sections + const enrollments = await snatchDb.collection("enrollments") + .find({ courseid }) + .toArray(); + + // Get course doc for reserved seats and waitlist info + const courseDoc = await snatchDb.collection("courses").findOne({ courseid }); + + // Get waitlist sizes for each section + const sectionDemand = []; + for (const e of enrollments) { + const classKey = `class_${e.classid}`; + const classInfo = courseDoc?.[classKey] as Record | undefined; + + // Check waitlist for this class + const waitlistDoc = await snatchDb.collection("waitlists").findOne({ classid: String(e.classid) }); + const subscriberCount = Array.isArray(waitlistDoc?.netids) ? waitlistDoc.netids.length : 0; + + sectionDemand.push({ + section: e.section, + classId: e.classid, + enrollment: e.enrollment, + capacity: e.capacity, + fillPercent: e.capacity > 0 ? Math.round((e.enrollment / e.capacity) * 100) : 0, + isOpen: classInfo?.status_is_open ?? (e.enrollment < e.capacity), + subscribers: subscriberCount, + days: classInfo?.days ?? null, + startTime: classInfo?.start_time ?? null, + endTime: classInfo?.end_time ?? null, + }); + } + + const totalEnrollment = enrollments.reduce((s, e) => s + e.enrollment, 0); + const totalCapacity = enrollments.reduce((s, e) => s + e.capacity, 0); + const totalSubscribers = sectionDemand.reduce((s, d) => s + d.subscribers, 0); + + return { + content: [{ + type: "text" as const, + text: JSON.stringify({ + course: displayName, + title, + hasReservedSeats: courseDoc?.has_reserved_seats ?? false, + overallFill: totalCapacity > 0 ? `${totalEnrollment}/${totalCapacity} (${Math.round((totalEnrollment / totalCapacity) * 100)}%)` : "N/A", + totalSubscribers, + sections: sectionDemand, + demandLevel: totalSubscribers > 50 ? "Very High" : totalSubscribers > 20 ? "High" : totalSubscribers > 5 ? "Moderate" : totalSubscribers > 0 ? "Low" : "None (no active watchers)", + }, null, 2), + }], + }; +} diff --git a/apps/engine/src/plugins/snatch-db.ts b/apps/engine/src/plugins/snatch-db.ts new file mode 100644 index 00000000..d2d0b606 --- /dev/null +++ b/apps/engine/src/plugins/snatch-db.ts @@ -0,0 +1,34 @@ +import fp from "fastify-plugin"; +import type { FastifyPluginAsync } from "fastify"; +import { MongoClient, type Db } from "mongodb"; + +declare module "fastify" { + interface FastifyInstance { + snatchDb: Db | null; + } +} + +const snatchDbPlugin: FastifyPluginAsync = async (app) => { + const uri = process.env.SNATCH_DB_URI?.trim(); + + if (!uri) { + app.log.warn("SNATCH_DB_URI not set — TigerSnatch demand tools disabled."); + app.decorate("snatchDb", null); + return; + } + + const client = new MongoClient(uri, { serverSelectionTimeoutMS: 5000 }); + await client.connect(); + const db = client.db("tigersnatch"); + + app.decorate("snatchDb", db); + app.log.info("TigerSnatch MongoDB connected"); + + app.addHook("onClose", async () => { + await client.close(); + }); +}; + +export default fp(snatchDbPlugin, { + name: "snatch-db-plugin", +}); diff --git a/apps/engine/src/routes/mcp.ts b/apps/engine/src/routes/mcp.ts index aa78eee9..462579d3 100644 --- a/apps/engine/src/routes/mcp.ts +++ b/apps/engine/src/routes/mcp.ts @@ -136,7 +136,8 @@ const mcpRoutes: FastifyPluginAsync = async (app, opts) => { } const supabase = scope === "junction" ? app.supabase : undefined; - const mcpServer = createMcpServer(app.db.db, auth.authContext, scope, supabase); + const snatchDb = (scope === "junction" || scope === "snatch") ? app.snatchDb : undefined; + const mcpServer = createMcpServer(app.db.db, auth.authContext, scope, supabase, snatchDb); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), });