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
Empty file added chatbot/__init__.py
Empty file.
141 changes: 141 additions & 0 deletions chatbot/chatbot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import json
import requests
from semesterly.settings import get_secret
from timetable.models import Semester
from searches.utils import search
from courses.serializers import CourseSearchSerializer
from django.core.paginator import Paginator, EmptyPage
from datetime import datetime
from searches.views import CourseSearchList
from .config import get_system_prompt, get_tool_schema


def advanced_course_search_from_view(
query, sem_name, year, filters, school, page=1, limit=10
):
sem_name = sem_name.capitalize().strip() if sem_name else None

if not sem_name or not year:
latest = get_latest_semester()
if not latest:
raise ValueError("No semesters available.")
sem_name = sem_name or latest.name
year = year or latest.year

sem = Semester.objects.filter(name=sem_name, year=year).first()
if not sem:
raise ValueError(f"Semester '{sem_name} {year}' not found")

if not school:
raise ValueError("Missing school/subdomain for search")

# Call the same search engine as the view
course_matches = search(school, query, sem)

# Instantiate the view just to reuse filter logic
view = CourseSearchList()
course_matches = view.filter_course_matches(course_matches, filters, sem)
course_matches = course_matches[:100]

# Pagination
paginator = Paginator(course_matches, limit)
try:
paginated_data = paginator.page(page)
except EmptyPage:
return {"data": [], "page": page}

# Serialization (you can switch to CourseSerializer if needed)
serialized = CourseSearchSerializer(
paginated_data, context={"semester": sem, "school": school}, many=True
).data

return {
"data": serialized,
"page": page,
"total_courses": paginator.count,
"total_pages": paginator.num_pages,
}


def get_chatbot_response(query, subdomain, default_sem_name=None, default_year=None):

API_KEY = get_secret("OPENAI_API_KEY")

URL = "https://api.openai.com/v1/chat/completions"

prompt = {"role": "system", "content": get_system_prompt()}

headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}

tools = [get_tool_schema()]

data = {
"model": "gpt-3.5-turbo",
"messages": [prompt, {"role": "user", "content": query}],
"tools": tools,
"tool_choice": "auto",
}

try:
initial_response = requests.post(URL, headers=headers, json=data)
initial_response.raise_for_status()

# Extract chatbot response from the JSON
data = initial_response.json()
gpt_message = data["choices"][0]["message"]
print("Initial response: ", gpt_message)

if "tool_calls" in gpt_message:
tool_call = gpt_message["tool_calls"][0]
tool_name = tool_call["function"]["name"]
tool_args = json.loads(tool_call["function"]["arguments"])

if tool_name == "advanced_course_search":
print("Query: ", query)
tool_output = advanced_course_search_from_view(
query=tool_args["query"],
sem_name=tool_args.get("sem_name"),
year=tool_args.get("year"),
filters=tool_args.get("filters", {}),
page=tool_args.get("page", 1),
limit=tool_args.get("limit", 10),
school=subdomain,
)
print("Tool output: ", tool_output)

# Add tool output and send it back to GPT
followup_response = requests.post(
URL,
headers=headers,
json={
"model": "gpt-4-0613",
"messages": [
prompt,
gpt_message,
{
"role": "tool",
"tool_call_id": tool_call["id"],
"name": tool_name,
"content": json.dumps(tool_output),
},
],
},
)

followup_response.raise_for_status()
final = followup_response.json()
return {
"response": final["choices"][0]["message"]["content"],
"tool_output": tool_output,
"used_sem_name": tool_args.get("sem_name"),
"used_year": tool_args.get("year"),
}

return {"response": gpt_message.get("content", ""), "tool_output": None}

except requests.exceptions.RequestException as e:
print("Error:", e)
return {
"response": "Sorry, I couldn't process your request right now.",
"tool_output": None,
}
99 changes: 99 additions & 0 deletions chatbot/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
def get_system_prompt():
return (
"You are a helpful university course assistant. When a user asks about courses, your job is to extract the key topic (e.g., 'AI', 'history', 'creative writing'), "
"the semester (Fall, Spring, Summer, or Intersession), and the year, and pass them to the `advanced_course_search` function. "
"Only use this tool if a course-related search is clearly requested.\n\n"
"If the user includes filters, capture them:\n"
"- If a department is mentioned (e.g., 'CS', 'biology'), use the `departments` filter.\n"
"- If a course level is mentioned (e.g., 'intro', 'upper-level', 'graduate'), use the `levels` filter.\n"
"- If days and times are mentioned (e.g., 'Monday mornings', 'classes after 3pm'), map to `times` using:\n"
" - 'day': full day name (e.g., 'Tuesday')\n"
" - 'min' and 'max': times in minutes after midnight (e.g., 540 = 9:00am, 1020 = 5:00pm)\n"
"- If a distribution area is mentioned (e.g., 'Humanities', 'Sciences'), use the `areas` filter.\n\n"
"Avoid using generic terms like 'courses', 'classes', or 'subjects' in the `query` field—extract the real topic of interest.\n"
"If the query is vague or too broad (e.g., 'good classes', 'easy A'), ask a clarifying follow-up question instead of using the tool.\n\n"
"If the tool call returns more than 5 courses, summarize the results and ask the user how they want to narrow it down (e.g., department, time, level).\n"
"If no results are found, respond politely and suggest trying another query.\n"
"Do not list courses in your reply — the UI will show them. Keep your responses short, relevant, and polite."
)


def get_tool_schema():
return {
"type": "function",
"function": {
"name": "advanced_course_search",
"description": "Query-based search for courses with optional filters like department, level, or meeting time.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Main keyword or topic to search courses by (e.g., 'AI', 'economics', 'machine learning').",
},
"sem_name": {
"type": "string",
"description": "Semester name, such as Fall, Spring, Summer, or Intersession.",
},
"year": {
"type": "string",
"description": "Year of the semester, e.g., 2025.",
},
"filters": {
"type": "object",
"description": "Optional filters to narrow the course search.",
"properties": {
"departments": {
"type": "array",
"items": {"type": "string"},
"description": "Department codes, e.g., ['CS', 'MATH', 'BIO']",
},
"levels": {
"type": "array",
"items": {"type": "string"},
"description": "Course levels, e.g., ['100', '200', 'graduate']",
},
"areas": {
"type": "array",
"items": {"type": "string"},
"description": "Distribution areas or categories, e.g., ['Humanities', 'Natural Sciences']",
},
"times": {
"type": "array",
"items": {
"type": "object",
"properties": {
"day": {
"type": "string",
"description": "Day of the week, e.g., 'Monday', 'Tuesday'",
},
"min": {
"type": "integer",
"description": "Earliest start time in minutes after midnight (e.g., 540 = 9:00am)",
},
"max": {
"type": "integer",
"description": "Latest end time in minutes after midnight (e.g., 1020 = 5:00pm)",
},
},
"required": ["day", "min", "max"],
},
"description": "List of preferred class meeting times",
},
},
},
"page": {
"type": "integer",
"default": 1,
"description": "Pagination: which page of results to return",
},
"limit": {
"type": "integer",
"default": 10,
"description": "Number of results per page",
},
},
"required": ["query"],
},
},
}
7 changes: 7 additions & 0 deletions chatbot/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.urls import path
from .views import ChatbotQueryView, ChatbotSearchCoursesView

urlpatterns = [
path("query/", ChatbotQueryView.as_view(), name="chatbot-query"),
path("search/", ChatbotSearchCoursesView.as_view(), name="chatbot-search"),
]
58 changes: 58 additions & 0 deletions chatbot/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from .chatbot import get_chatbot_response, advanced_course_search_from_view
from searches.utils import search


class ChatbotQueryView(APIView):
def post(self, request):
query = request.data.get("query")
if not query:
return Response(
{"error": "No query provided"}, status=status.HTTP_400_BAD_REQUEST
)

subdomain = request.subdomain
sem_name = request.data.get("sem_name") or None
year = request.data.get("year") or None

response = get_chatbot_response(query, subdomain, sem_name, year)

return Response(
{"response": response["response"], "tool_output": response["tool_output"]},
status=status.HTTP_200_OK,
)


class ChatbotSearchCoursesView(APIView):
def post(self, request):
query = request.data.get("query", "").strip()
page = int(request.data.get("page", 1))
limit = int(request.data.get("limit", 5))
sem_name = request.data.get("sem_name", "").strip() or None
year = request.data.get("year") or None
school = request.subdomain

if len(query) <= 1:
return Response({"data": [], "page": page}, status=status.HTTP_200_OK)

try:
result = advanced_course_search_from_view(
query=query,
sem_name=sem_name,
year=year,
filters={},
school=school,
page=page,
limit=limit,
)

return Response(
{"data": result["data"], "page": page}, status=status.HTTP_200_OK
)

except Exception as e:
return Response(
{"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
5 changes: 5 additions & 0 deletions manifests/dev/cronjob.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,9 @@ spec:
secretKeyRef:
name: semesterly-secrets
key: JHU_API_KEY
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: semesterly-secrets
key: OPENAI_API_KEY
restartPolicy: OnFailure
5 changes: 5 additions & 0 deletions manifests/dev/deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ spec:
secretKeyRef:
name: semesterly-secrets
key: JHU_API_KEY
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: semesterly-secrets
key: OPENAI_API_KEY
- name: SOCIAL_AUTH_GOOGLE_OAUTH2_KEY
valueFrom:
secretKeyRef:
Expand Down
5 changes: 5 additions & 0 deletions manifests/prod/cronjob.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,9 @@ spec:
secretKeyRef:
name: semesterly-secrets
key: JHU_API_KEY
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: semesterly-secrets
key: OPENAI_API_KEY
restartPolicy: OnFailure
5 changes: 5 additions & 0 deletions manifests/prod/deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,11 @@ spec:
secretKeyRef:
name: semesterly-secrets
key: JHU_API_KEY
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: semesterly-secrets
key: OPENAI_API_KEY
- name: SOCIAL_AUTH_GOOGLE_OAUTH2_KEY
valueFrom:
secretKeyRef:
Expand Down
5 changes: 5 additions & 0 deletions manifests/stage/cronjob.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,9 @@ spec:
secretKeyRef:
name: semesterly-secrets
key: JHU_API_KEY
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: semesterly-secrets
key: OPENAI_API_KEY
restartPolicy: OnFailure
5 changes: 5 additions & 0 deletions manifests/stage/deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ spec:
secretKeyRef:
name: semesterly-secrets
key: JHU_API_KEY
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: semesterly-secrets
key: OPENAI_API_KEY
- name: SOCIAL_AUTH_GOOGLE_OAUTH2_KEY
valueFrom:
secretKeyRef:
Expand Down
1 change: 1 addition & 0 deletions semesterly/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ def get_secret(key):
"timetable",
"ckeditor",
"friends",
"chatbot",
)

REST_FRAMEWORK = {"UNICODE_JSON": False}
Expand Down
1 change: 1 addition & 0 deletions semesterly/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
re_path("", include("agreement.urls")),
re_path("", include("notifications.urls")),
re_path("", include("friends.urls")),
re_path(r"^chatbot/", include("chatbot.urls")),
re_path(r"admin/?", admin.site.urls),
# Automatic deployment endpoint
re_path(r"deploy_staging/?", semesterly.views.deploy_staging),
Expand Down
1 change: 1 addition & 0 deletions static/css/timetable/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,4 @@
@import "partials/user_acquisition_modal";
@import "partials/user_settings_modal";
@import "partials/fallback";
@import "partials/chatbot";
Loading
Loading