diff --git a/.github/workflows/github-actions-demo.yml b/.github/workflows/github-actions-demo.yml deleted file mode 100644 index e5bb16153..000000000 --- a/.github/workflows/github-actions-demo.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: GitHub Actions Demo -run-name: ${{ github.actor }} is testing out GitHub Actions 🚀 -on: [push] -jobs: - Explore-GitHub-Actions: - runs-on: ubuntu-latest - steps: - - run: echo "🎉 The job was automatically triggered by a ${{ github.event_name }} event." - - run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by GitHub!" - - run: echo "🔎 The name of your branch is ${{ github.ref }} and your repository is ${{ github.repository }}." - - name: Check out repository code - uses: actions/checkout@v4 - - run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner." - - run: echo "🖥️ The workflow is now ready to test your code on the runner." - - name: List files in the repository - run: | - ls ${{ github.workspace }} - - run: echo "🍏 This job's status is ${{ job.status }}." \ No newline at end of file diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 000000000..190787836 --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,39 @@ +name: Python application test + +on: + push: + branches: + - "*" + pull_request: + branches: + - "*" + +jobs: + build: + runs-on: ubuntu-latest + env: + OPENAI_API_KEY: "" + MONGO_URI: "" + FLASK_SECRET_KEY: "" + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest flask_testing requests_mock + - name: Run Tests + run: | + cd backend + pytest + - name: Cache Python dependencies + uses: actions/cache@v2 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- diff --git a/.gitignore b/.gitignore index e69de29bb..d88b7632a 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1,48 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz +/bluebook/ + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env +*/.env +*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +/api/__pycache__ +/api/testmongo.py + +# python venv +*/bluebook_env +bluebook_env + +# data +data \ No newline at end of file diff --git a/README.md b/README.md index 85f455b3e..eed1528fe 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,110 @@ -# s24-bluebook-ai -To run frontend or backend code, please add an `.env` file inside that directory and put your API key in it, such as +# BluebookAI + +Course selections play a pivotal role in shaping student learning experiences at Yale. Each semester, Yale has a vast array of course offerings, and it is difficult for students to select specific courses that fit their needs and interests. Beyond the official Yale Course Search, students often turn to CourseTable for additional insights from other students’ reviews and experiences. + +With CourseTable, students retrieve information using keyword search, filtering, and sorting across classes offered in current and previous semesters. However, CourseTable currently uses exact match only, which can be less helpful when the student doesn’t know what specific course(s) they are searching for. For example, a student who searches for “DevOps” may not see CPSC 439/539 Software Engineering in returned search results. + +In this project, we aim to enhance students’ course selection experience by augmenting CourseTable with a natural language interface that can provide customized course recommendations in response to student queries. By supplying more relevant and dynamic results and expanding students’ means of interaction with course data, this will enable students to more easily and effectively determine the best course schedule for themselves. + +## Get Started + +### Frontend + +0. If you don't already have Node.js installed, you can download it [here](https://nodejs.org/en/download/). +1. Enter the `frontend` directory and install the dependencies: + + ```bash + cd frontend + npm install + ``` + +2. Start the Next.js app: + + ```bash + npm run dev + ``` + +### Backend + +1. Enter the `backend` directory, create a virtual environment, activate it, and install the dependencies. Make sure you have Python 3.10+ installed. + + ```bash + cd backend + python -m venv bluebook_env + source bluebook_env/bin/activate + pip install -r requirements.txt + ``` + +2. You will also need to create `.env` in the the `backend` directory that contains your API key to OpenAI and the MongoDB URI. The `.env` file should look like this: + ``` -OPENAI_API_KEY=sk-XXX +MONGO_URI="mongodb+srv://xxx" +OPENAI_API_KEY="sk-xxx" ``` + Don't push your API key to this repo! To run sentiment classification, first create a conda environment for Python 3 using the requirements.txt file: + ``` conda create --name <env_name> --file sentiment_classif_requirements.txt ``` + Activate the conda environment by running: + ``` conda activate <env_name> ``` + where `<env_name>` is your name of choice for the conda environment. + +You can get an OpenAI API key [here](https://platform.openai.com/api-keys). The MongoDB URI is shared by the team. You will need to have your IP address allowlisted by MongoDB to query the database. Contact the team for access. + +3. Start the Flask server: + + ```bash + python app.py + ``` + +## Usage + +1. Enter the `frontend` directory and run + +```bash +npm run dev +``` + +2. Enter the `backend` directory and run + +```bash +python app.py +``` + +3. Ask away! + +![demo](./demo.png) + +4. You can also use your favorite API client (e.g., Postman) to send a POST request to `http://localhost:8000/api/chat` with the following JSON payload: + + ```json + { + "role": "user", + "content": "Tell me some courses about personal finance" + } + ``` + + You should receive a response with the recommended courses like this: + + ```json + { + "courses": [ + { + "course_code": "ECON 436", + "description": "How much should I be saving at age 35? How much of my portfolio should be invested in stocks at age 50? Which mortgage should I choose, and when should I refinance it? How much can I afford to spend per year in retirement? This course covers prescriptive models of personal saving, asset allocation, borrowing, and spending. The course is designed to answer questions facing anybody who manages their own money or is a manager in an organization that is trying to help clients manage their money.", + "title": "Personal Finance" + }, + ... + ], + "response": "To learn more about personal finance, you can start by taking courses or workshops that focus on financial management, budgeting, investing, and retirement planning. Some universities and educational platforms offer online courses on personal finance, such as ECON 436: Personal Finance and ECON 361: Corporate Finance. Additionally, you can explore resources like books, podcasts, and websites dedicated to personal finance advice and tips. It may also be helpful to consult with a financial advisor or planner for personalized guidance on managing your finances effectively." + } + ``` diff --git a/api/app.py b/api/app.py new file mode 100644 index 000000000..d79c59d23 --- /dev/null +++ b/api/app.py @@ -0,0 +1,107 @@ +""" Flask app that uses the OpenAI API to generate responses to user messages.""" + +import os +import certifi +import openai +from flask import Flask, request, jsonify +from flask_cors import CORS +from dotenv import load_dotenv +from pymongo import MongoClient +from pymongo.errors import ConnectionFailure +from lib import chat_completion_request, create_embedding + +load_dotenv() + +ca = certifi.where() +URI = os.getenv("MONGODB_URI") +client = MongoClient(URI, tlsCAFile=ca) +db = client["course_db"] +collection = db["parsed_courses"] + +try: + client.admin.command("ping") + print("Pinged your deployment. You successfully connected to MongoDB!") +except ConnectionFailure as db_error: + print(f"Database connection error: {db_error}") + +app = Flask(__name__) +CORS(app) + + +@app.route("/") +def home(): + """Route handler for the home page ("/"). Returns a welcome message.""" + return "Welcome to the Flask App!" + + +conversation_history = [] + + +@app.route("/chat", methods=["POST"]) +def chat(): + """ + Route handler for "/chat" endpoint. Receives a JSON payload with user messages, + generates a response using the OpenAI API, and returns it as a JSON object. + """ + data = request.get_json() + + if "message" not in data: + return jsonify({"error": "Missing 'message' in request body"}), 400 + + user_message = data["message"] + conversation_history.append({"role": "user", "content": user_message}) + + query_vector = create_embedding(user_message) + + database_response = list( + collection.aggregate( + [ + { + "$vectorSearch": { + "index": "parsed_courses_title_description_index", + "path": "embedding", + "queryVector": query_vector, + "numCandidates": 30, + "limit": 5, + } + } + ] + ) + ) + + recommended_courses = [ + { + "course_code": course["course_code"], + "title": course["title"], + "description": course["description"], + } + for course in database_response + ] + + recommendation_prompt = ( + "Here are some courses that might be relevant to your request:\n\n" + + "\n\n".join( + f'{course["course_code"]}: {course["title"]}\n{course["description"]}' + for course in recommended_courses + ) + + "\nProvide a response to the user. Incorporate this information only if relevant." + ) + + conversation_history.append({"role": "system", "content": recommendation_prompt}) + + try: + response = chat_completion_request(messages=conversation_history) + ai_response_content = response.choices[0].message.content + conversation_history.append( + {"role": "assistant", "content": ai_response_content} + ) + except openai.BadRequestError as chat_error: + print(f"Error during chat completion request: {chat_error}") + ai_response_content = ( + "Sorry, I encountered an error while processing your request." + ) + + return jsonify({"response": ai_response_content, "courses": recommended_courses}) + +if __name__ == "__main__": + app.run(debug=True) diff --git a/api/course_subjects.json b/api/course_subjects.json new file mode 100644 index 000000000..70adbdf22 --- /dev/null +++ b/api/course_subjects.json @@ -0,0 +1,202 @@ +{ + "ACCT": "Accounting", + "USAF": "Aerospace Studies", + "AFAM": "African American Studies", + "AFST": "African Studies", + "AKKD": "Akkadian", + "ASL": "American Sign Language", + "AMST": "American Studies", + "GREK": "Ancient Greek", + "ANTH": "Anthropology", + "AMTH": "Applied Mathematics", + "APHY": "Applied Physics", + "ARBC": "Arabic", + "ARCG": "Archaeological Studies", + "ARCH": "Architecture", + "ARMN": "Armenian", + "ART": "Art", + "ASTR": "Astronomy", + "BNGL": "Bengali", + "B&BS": "Biological & Biomedical Sci", + "BIOL": "Biology", + "BENG": "Biomedical Engineering", + "BIS": "Biostatistics", + "BRST": "British Studies", + "BURM": "Burmese", + "C&MP": "Cell & Molecular Physiology", + "CBIO": "Cell Biology", + "CENG": "Chemical Engineering", + "CHEM": "Chemistry", + "CHER": "Cherokee", + "CHLD": "Child Study", + "CHNS": "Chinese", + "CDE": "Chronic Disease Epidemiology", + "CLCV": "Classical Civilization", + "CLSS": "Classics", + "MEDR": "Clinical Clerkships", + "CGSC": "Cognitive Science", + "CSYC": "Coll Sem: Yale Coll", + "CSBF": "Coll Sem:Ben Franklin Coll", + "CSBK": "Coll Sem:Berkeley Coll", + "CSBR": "Coll Sem:Branford Coll", + "CSDC": "Coll Sem:Davenport Coll", + "CSES": "Coll Sem:Ezra Stiles Coll", + "CSGH": "Coll Sem:Grace Hopper Coll", + "CSJE": "Coll Sem:Jonathan Edwards Coll", + "CSMC": "Coll Sem:Morse Coll", + "CSMY": "Coll Sem:Pauli Murray Coll", + "CSPC": "Coll Sem:Pierson Coll", + "CSSY": "Coll Sem:Saybrook Coll", + "CSSM": "Coll Sem:Silliman Coll", + "CSTD": "Coll Sem:Timothy Dwight Coll", + "CSTC": "Coll Sem:Trumbull Coll", + "CB&B": "Comp Biol & Bioinfomatics", + "CPLT": "Comparative Literature", + "CPSC": "Computer Science", + "CSEC": "Computer Science and Economics", + "CPAR": "Computing and the Arts", + "MEDC": "Courses in School of Medicine", + "CZEC": "Czech", + "DEVN": "DeVane Lecture Course", + "DRST": "Directed Studies", + "DIR": "Directing", + "DISR": "Diss Research - in Residence", + "DRAM": "Drama", + "DRMA": "Drama Summer", + "DUTC": "Dutch", + "EMST": "Early Modern Studies", + "EPS": "Earth and Planetary Sciences", + "EALL": "East Asian Lang and Lit", + "EAST": "East Asian Studies", + "E&EB": "Ecology & Evolutionary Biology", + "ECON": "Economics", + "EDST": "Education Studies", + "EGYP": "Egyptology", + "EENG": "Electrical Engineering", + "ENRG": "Energy Studies", + "ENAS": "Engineering & Applied Science", + "ENGL": "English", + "ELP": "English Language Program", + "ENV": "Environment", + "ENVE": "Environmental Engineering", + "EHS": "Environmental Health Sciences", + "EVST": "Environmental Studies", + "EPH": "Epidemiology & Public Health", + "EMD": "Epidemiology Microbial Disease", + "EP&E": "Ethics, Politics, & Economics", + "ER&M": "Ethnicity, Race, & Migration", + "E&RS": "European & Russian Studies", + "EXCH": "Exchange Scholar Experience", + "EXPA": "Experimental Pathology", + "FILM": "Film & Media Studies", + "FNSH": "Finnish", + "F&ES": "Forestry & Environment Studies", + "FREN": "French", + "GENE": "Genetics", + "G&G": "Geology and Geophysics", + "GMAN": "German", + "GLBL": "Global Affairs", + "HPM": "Health Policy and Management", + "HLTH": "Health Studies", + "HEBR": "Hebrew", + "HNDI": "Hindi", + "HSHM": "Hist of Science, Hist of Med", + "HIST": "History", + "HSAR": "History of Art", + "HMRT": "Human Rights", + "HUMS": "Humanities", + "HGRN": "Hungarian", + "IBIO": "Immunobiology", + "IDRS": "Ind Res in Sciences", + "INDN": "Indonesian", + "INP": "Interdpt Neuroscience Pgm", + "IMED": "Investigative Medicine", + "ITAL": "Italian Studies", + "JAPN": "Japanese", + "JDST": "Jewish Studies", + "KHMR": "Khmer", + "SWAH": "Kiswahili", + "KREN": "Korean", + "LATN": "Latin", + "LAST": "Latin American Studies", + "LAW": "Law", + "LING": "Linguistics", + "LITR": "Literature", + "MGT": "Management", + "MGMT": "Management, PhD", + "MRES": "Master's Thesis Research", + "MHHR": "Material Hist of Human Record", + "MATH": "Mathematics", + "MENG": "Mechanical Engineering", + "MDVL": "Medieval Studies", + "MESO": "Mesopotamia", + "MBIO": "Microbiology", + "MGRK": "Modern Greek", + "MMES": "Modern Middle East Studies", + "MTBT": "Modern Tibetan", + "MB&B": "Molecular Biophysics & Biochem", + "MCDB": "Molecular, Cellular & Dev Biol", + "MUSI": "Music Department", + "NAVY": "Naval Science", + "NELC": "Near Eastern Langs & Civs", + "NPLI": "Nepali", + "NSCI": "Neuroscience", + "NURS": "Nursing", + "OTTM": "Ottoman", + "PATH": "Pathology", + "PERS": "Persian", + "PHAR": "Pharmacology", + "PHIL": "Philosophy", + "OLPA": "Physician Assistant Online Pgm", + "PA": "Physician Associate Program", + "PHYS": "Physics", + "PLSH": "Polish", + "PLSC": "Political Science", + "PORT": "Portuguese", + "PRAC": "Practicum Analysis", + "CAND": "Prep for Adv to Candidacy", + "QUAL": "Preparing for Qualifying Exams", + "PSYC": "Psychology", + "PHUM": "Public Humanities", + "PNJB": "Punjabi", + "REL": "Religion", + "RLST": "Religious Studies", + "RNST": "Renaissance Studies", + "ROMN": "Romanian", + "RUSS": "Russian", + "RSEE": "Russian & East Europe Studies", + "SKRT": "Sanskrit", + "MD": "School of Medicine", + "MUS": "School of Music", + "SCIE": "Science", + "SMTC": "Semitic", + "SBCR": "Serbian & Croatian", + "SNHL": "Sinhala", + "SLAV": "Slavic", + "SBS": "Social and Behavioral Sciences", + "SOCY": "Sociology", + "SAST": "South Asian Studies", + "SPAN": "Spanish", + "SPEC": "Special Divisional Major", + "STRT": "Start Program - Medical School", + "S&DS": "Statistics and Data Sciences", + "STEV": "Studies in the Environment", + "STCY": "Study of the City", + "SUMR": "Summer Term", + "SWED": "Swedish", + "TAML": "Tamil", + "THST": "Theater and Performance Studies", + "TBTN": "Tibetan", + "PTB": "Translational Biomedicine", + "TKSH": "Turkish", + "TWI": "Twi", + "UKRN": "Ukrainian", + "URBN": "Urban Studies", + "VIET": "Vietnamese", + "VAIR": "Visiting Assistant in Research", + "WLOF": "Wolof", + "WGSS": "Women's, Gender & Sexuality Studies", + "YDSH": "Yiddish", + "YORU": "Yoruba", + "ZULU": "Zulu" +} \ No newline at end of file diff --git a/api/lib.py b/api/lib.py new file mode 100644 index 000000000..465b8d838 --- /dev/null +++ b/api/lib.py @@ -0,0 +1,111 @@ +""" +A module for interacting with the OpenAI API, including creating +embeddings and generating chat completions. +""" + +import os +import json +from tenacity import retry, wait_random_exponential, stop_after_attempt +import openai +from openai import OpenAI +from dotenv import load_dotenv + +load_dotenv() + +with open("course_subjects.json", "r", encoding="utf-8") as file: + subjects = json.load(file) + +api_tools = [ + { + "type": "function", + "function": { + "name": "CourseFilter", + "description": "Provide filters for a course based on conditions.", + "parameters": { + "type": "object", + "properties": { + "subject_code": { + "type": "string", + "enum": [str(key) for key in subjects.keys()], + "description": "A code for the subject of instruction", + }, + "rating": { + "type": "number", + "description": """The rating (a number with one significant + digit) for the class (0 - 4). If a number is not provided, + interpret the given opinion to fit the range. A good, or + average, class should be 3.5""", + }, + "comparison_operator_rating": { + "type": "string", + "enum": ["$lt", "$gt", "$gte", "$lte"], + "description": "A comparison operator for the class rating", + }, + "workload": { + "type": "number", + "description": """The workload (a number with one significant + digit) for the class (0 - 4). If a number is not provided, + interpret the given opinion to fit the range.""", + }, + "comparison_operator_workload": { + "type": "string", + "enum": ["$lt", "$gt", "$gte", "$lte"], + "description": "A comparison operator for the class workload", + }, + }, + "required": ["comparison_operator_rating", "rating"], + }, + }, + } +] + +client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) + +@retry(wait=wait_random_exponential(multiplier=1, max=40), stop=stop_after_attempt(3)) +def create_embedding(text, model="text-embedding-3-small"): + """ + Generates an embedding for the given text using the specified model. + + Parameters: + - text (str): The text to generate an embedding for. + - model (str): The model to use for generating the embedding. + + Returns: + - The embedding as a list of floats or an exception if the request fails. + """ + try: + response = client.embeddings.create( + input=text, model=model + ) + return response.data[0].embedding + except openai.InternalServerError as e: + print("Unable to generate embedding") + print(f"Exception: {e}") + return e + +@retry(wait=wait_random_exponential(multiplier=1, max=40), stop=stop_after_attempt(3)) +def chat_completion_request( + messages, tools_param=None, tool_choice=None, model="gpt-3.5-turbo" +): + """ + Requests a chat completion from the OpenAI API using the provided messages and tools. + + Parameters: + - messages (list): A list of message dictionaries for the chat. + - tools_param (list, optional): Tools to use for the chat completion. + - tool_choice (str, optional): The choice of tool to use for the chat completion. + - model (str): The model to use for the chat completion. + + Returns: + - The API response for the chat completion. + """ + try: + response = client.chat.completions.create( + model=model, + messages=messages, + tools=tools_param, + tool_choice=tool_choice, + ) + return response + except Exception as e: + raise e diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 000000000..6b9599587 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,7 @@ +blinker==1.7.0 +click==8.1.7 +Flask==3.0.2 +itsdangerous==2.1.2 +Jinja2==3.1.3 +MarkupSafe==2.1.5 +Werkzeug==3.0.1 diff --git a/backend/.gitignore b/backend/.gitignore index 7e197cba4..4396f42c0 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,3 +1,7 @@ .env - -data/ \ No newline at end of file +data/ +__pycache__/ +bluebook_env/ +bluebook_env_1/ +.coverage +htmlcov \ No newline at end of file diff --git a/backend/__pycache__/lib.cpython-312.pyc b/backend/__pycache__/lib.cpython-312.pyc new file mode 100644 index 000000000..5b6e153fc Binary files /dev/null and b/backend/__pycache__/lib.cpython-312.pyc differ diff --git a/backend/app.py b/backend/app.py index 0f1548ed9..88f157bb1 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,100 +1,298 @@ -from flask import Flask, request, jsonify +from urllib.parse import urljoin +from flask import Flask, request, jsonify, session, redirect, url_for +from flask_cors import CORS +from flask_cas import CAS, login_required import os from dotenv import load_dotenv from lib import chat_completion_request, create_embedding import json - from pymongo.mongo_client import MongoClient +import requests +import xml.etree.ElementTree as ET +import datetime + +COURSE_QUERY_LIMIT = 5 +SAFETY_CHECK_ENABLED = False +DATABASE_RELEVANCY_CHECK_ENABLED = False -uri = "mongodb+srv://bluebookairoot:<password>@bluebookcluster.0hf4pzi.mongodb.net/?retryWrites=true&w=majority&appName=BluebookCluster" +load_dotenv() -# connect to the MongoDB cluster -client = MongoClient(uri) -db = client['bluebookai'] -collection = db['course-info'] -try: - client.admin.command('ping') - print("Pinged your deployment. You successfully connected to MongoDB!") -except Exception as e: - print(e) +# Separate function to load configurations +def load_config(app, test_config=None): + app.secret_key = os.environ.get( + "FLASK_SECRET_KEY", "3d6f45a5fc12445dbac2f59c3b6c7cb1" + ) + app.config["CAS_SERVER"] = "https://secure.its.yale.edu/cas" -app = Flask(__name__) + if test_config: + # Load test configuration + app.config.update(test_config) + if "COURSE_QUERY_LIMIT" in app.config: + global COURSE_QUERY_LIMIT + COURSE_QUERY_LIMIT = app.config["COURSE_QUERY_LIMIT"] + if "SAFETY_CHECK_ENABLED" in app.config: + global SAFETY_CHECK_ENABLED + SAFETY_CHECK_ENABLED = app.config["SAFETY_CHECK_ENABLED"] + if "DATABASE_RELEVANCY_CHECK_ENABLED" in app.config: + global DATABASE_RELEVANCY_CHECK_ENABLED + DATABASE_RELEVANCY_CHECK_ENABLED = app.config[ + "DATABASE_RELEVANCY_CHECK_ENABLED" + ] + if "FLASK_SECRET_KEY" in app.config: + app.secret_key = app.config["FLASK_SECRET_KEY"] + else: + # Load configuration from environment variables + app.config["MONGO_URI"] = os.getenv("MONGO_URI") -load_dotenv() +# Separate function to initialize database +def init_database(app): + if "MONGO_URI" in app.config: + client = MongoClient(app.config["MONGO_URI"]) + db = client["course_db"] + app.config["collection"] = db["parsed_courses"] + # else, set to None or Mock in case of testing + +def create_app(test_config=None): + app = Flask(__name__) + CORS(app) + cas = CAS(app) + app.config["CAS_SERVER"] = "https://secure.its.yale.edu/cas" + + load_config(app, test_config) + init_database(app) + + # Define your routes here + @app.route("/login", methods=["GET"]) + def login(): + return redirect(url_for("cas.login")) + + @app.route("/logout", methods=["GET"]) + def logout(): + session.clear() + return redirect(url_for("cas.logout")) + + @app.route("/route_after_login", methods=["GET"]) + @login_required + def route_after_login(): + # Handle what happens after successful login + return "Logged in as " + session["CAS_USERNAME"] + + @app.route("/validate_ticket", methods=["POST"]) + def validate_cas_ticket(): + data = request.get_json() + ticket = data.get("ticket") + service_url = data.get("service_url") + print(f"Received ticket: {ticket}, service URL: {service_url}") # Log details + + if not ticket or not service_url: + return jsonify({"error": "Ticket or service URL not provided"}), 400 + + cas_validate_url = "https://secure.its.yale.edu/cas/serviceValidate" + params = {"ticket": ticket, "service": service_url} + response = requests.get(cas_validate_url, params=params) + + if response.status_code == 200: + # Parse the XML response + root = ET.fromstring(response.content) -@app.route('/chat', methods=['POST']) -def chat(): - data = request.get_json() - if 'message' not in data: - return jsonify({"error": "Missing 'messages' in request body"}), 400 - - user_messages = data['message'] - response = chat_completion_request(messages=user_messages) - message = response.choices[0].message - print(message) - # if message.tool_calls is None: - # return 'success' - # args = json.loads(message.tool_calls[0].function.arguments) - # query_vector = create_embedding(user_messages[-1]['content']) - # database_response = collection.aggregate([ - # { - # '$vectorSearch': { - # 'index': 'course-rating-index', - # 'path': 'embedding', - # 'filter': { - # 'rating': { - # args['operator']: args['rating'] - # } - # }, - # 'queryVector': query_vector, - # 'numCandidates': 5, - # 'limit': 5 - # } - # } - # ]) - # # print(database_response) - - # top_class = list(database_response)[0] - # json_response = { - # 'title': top_class['title'], - # 'rating': top_class['rating'], - # } - # return jsonify(json_response) - - # "{\"operator\":\"$gt\",\"rating\":4}" - - # ChatCompletionMessage(content=None, role='assistant', function_call=None, tool_calls=[ChatCompletionMessageToolCall(id='call_Ub07GeA6kaC2OZ8b8KlVmtZz', function=Function(arguments='{\n "subject_code": "CPSC",\n "rating": 3.5,\n "comparison_operator_rating": "$gte",\n "workload": 1,\n "comparison_operator_workload": "$lte"\n}', name='CourseFilter'), type='function')]) - - query_vector = create_embedding(user_messages[-1]['content']) - - print(user_messages[-1]) - database_response = collection.aggregate([ + # Namespace in the XML response + ns = {"cas": "http://www.yale.edu/tp/cas"} + + # Check for authentication success + if root.find(".//cas:authenticationSuccess", ns) is not None: + user = root.find(".//cas:user", ns).text + # Optionally handle proxyGrantingTicket if you need it + return jsonify({"isAuthenticated": True, "user": user}) + else: + return jsonify({"isAuthenticated": False}), 401 + else: + print("response status is not 200") + return jsonify({"isAuthenticated": False}), 401 + + @app.route("/api/slug", methods=["POST"]) + def get_chat_history_slug(): + data = request.get_json() + user_messages = data.get("message", None) + user_messages_ = user_messages.copy() + user_messages_.append( { - '$vectorSearch': { - 'index': 'course-rating-index', - 'path': 'embedding', - 'queryVector': query_vector, - 'numCandidates': 5, - 'limit': 5 + "role": "user", + "content": "Write a descriptive title (3-4 words) for the topic of our conversation with no puncutation. Do not include 'discussion' or 'chat' in the title.", } + ) + + response = chat_completion_request(messages=user_messages_) + try: + response = response.choices[0].message.content + return jsonify({"slug": response}) + except: + return jsonify({"slug": "Untitled"}) + + + @app.route("/api/chat", methods=["POST"]) + def chat(): + data = request.get_json() + user_messages = data.get("message", None) + + filter_season_codes = data.get("season_codes", None) # assume it is an array of season code + filter_subject = data.get("subject", None) + filter_areas = data.get("areas", None) + + if not user_messages: + return jsonify({"error": "No message provided"}) + + # remove id before sending to OpenAI + for message in user_messages: + if "id" in message: + del message["id"] + if message["role"] == "ai": + message["role"] = "assistant" + + print(user_messages) + + if SAFETY_CHECK_ENABLED: + # for safety check, not to be included in final response + user_messages_safety_check = user_messages.copy() + user_messages_safety_check.append( + { + "role": "user", + "content": 'Am I asking for help with courses or academics? Answer "yes" or "no".', + } + ) + + response_safety_check = chat_completion_request( + messages=user_messages_safety_check + ) + response_safety_check = response_safety_check.choices[0].message.content + + if "no" in response_safety_check.lower(): + response = "I am sorry, but I can only assist with questions related to courses or academics at this time." + json_response = {"response": response, "courses": []} + print("failed safety check") + return jsonify(json_response) + else: + print("passed safety check") + + # adding system message if user message does not include a system message header + if user_messages[0]["role"] != "system": + user_messages.insert( + 0, + { + "role": "system", + "content": "Your name is Eli. You are a helpful assistant for Yale University students to ask questions about courses and academics.", + }, + ) + + if DATABASE_RELEVANCY_CHECK_ENABLED: + # checking if database query is necessary + user_messages_database_relevancy_check = user_messages.copy() + user_messages_database_relevancy_check.append( + { + "role": "user", + "content": 'Will you be able to better answer my question with access to specific courses at Yale University? If you answer "yes", you will be provided with courses that are semantically similar to my question. Answer "yes" or "no".', + } + ) + + user_messages_database_relevancy_check = chat_completion_request( + messages=user_messages_database_relevancy_check + ) + response_user_messages_database_relevancy_check = ( + user_messages_database_relevancy_check.choices[0].message.content + ) + + if "no" in response_user_messages_database_relevancy_check.lower(): + response = chat_completion_request(messages=user_messages) + response = response.choices[0].message.content + json_response = {"response": response, "courses": []} + print("no need to query database for course information") + return jsonify(json_response) + else: + print("need to query database for course information") + + # create embedding for user message to query against vector index + vector_search_prompt_generation = user_messages.copy() + vector_search_prompt_generation.append( + { + "role": "user", + "content": "Generate a natural language string to query against the Yale courses vector database that will be helpful to you to generate a response.", + } + ) + response = chat_completion_request(messages=vector_search_prompt_generation) + response = response.choices[0].message.content + print(response) + + query_vector = create_embedding(response) + + collection = app.config["collection"] + + aggregate_pipeline = { + "$vectorSearch": { + "index": "parsed_courses_title_description_index", + "path": "embedding", + "queryVector": query_vector, + "numCandidates": 30, + "limit": COURSE_QUERY_LIMIT, + } + } + + if filter_season_codes: + aggregate_pipeline["$vectorSearch"]["filter"] = { + "season_code": { + "$in": filter_season_codes + } + } + + if filter_subject: + aggregate_pipeline["$vectorSearch"]["filter"] = { + "subject": { + "$eq": filter_subject + } + } + + if filter_areas: + aggregate_pipeline["$vectorSearch"]["filter"] = { + "areas": { + "$in": filter_areas + } } - ]) - - classes = list(database_response) - # top_class = classes[0] - print([c['title'] for c in classes]) - top_class = classes[0] - json_response = { - # 'message': [{ - # 'role': response.choices[0].message.role, - # 'content': response.choices[0].message.content, - # }] - 'title': top_class['title'], - # 'rating': top_class['rating'], - } - - return jsonify(json_response) - -if __name__ == '__main__': - app.run(debug=True) + + database_response = collection.aggregate([aggregate_pipeline]) + database_response = list(database_response) + + recommended_courses = [ + { + "season_code": course["season_code"], + "course_code": course["course_code"], + "title": course["title"], + "description": course["description"], + "areas": course["areas"], + "sentiment_label": course["sentiment_info"]["final_label"], + "sentiment_score": course["sentiment_info"]["final_proportion"], + + } + for course in database_response + ] + + recommendation_prompt = ( + "Here are some courses that might be relevant to the user request:\n\n" + ) + for course in recommended_courses: + recommendation_prompt += f'{course["course_code"]}: {course["title"]}\n{course["description"]}\n\n' + recommendation_prompt += "Provide a response to the user. Incorporate specific course information if it is relevant to the user request. If you include any course titles, make sure to wrap it in **double asterisks**. Do not order them in a list." + + user_messages.append({"role": "system", "content": recommendation_prompt}) + + response = chat_completion_request(messages=user_messages) + response = response.choices[0].message.content + + json_response = {"response": response, "courses": recommended_courses} + + return jsonify(json_response) + + return app + + +if __name__ == "__main__": + app = create_app() + app.run(debug=True, port=8000) diff --git a/backend/chroma_demo.ipynb b/backend/archive/chroma_demo.ipynb similarity index 100% rename from backend/chroma_demo.ipynb rename to backend/archive/chroma_demo.ipynb diff --git a/backend/lib.py b/backend/lib.py index 7aa98e8d5..21ca9e4c3 100644 --- a/backend/lib.py +++ b/backend/lib.py @@ -2,6 +2,11 @@ import os from openai import OpenAI import json +from dotenv import load_dotenv + +load_dotenv() + +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") # Open the JSON file with open('course_subjects.json', 'r') as file: @@ -18,7 +23,7 @@ "type": "object", "properties": { "subject_code": { - ~ "type": "string", + "type": "string", "enum": [str(key) for key in subjects.keys()], "description": "A code for the subject of instruction", }, @@ -47,8 +52,7 @@ } ] -# client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) -client = OpenAI(api_key="<OPENAI_API_KEY>") +client = OpenAI(api_key=OPENAI_API_KEY) @retry(wait=wait_random_exponential(multiplier=1, max=40), stop=stop_after_attempt(3)) def create_embedding(text, model='text-embedding-3-small'): @@ -64,7 +68,7 @@ def create_embedding(text, model='text-embedding-3-small'): return e @retry(wait=wait_random_exponential(multiplier=1, max=40), stop=stop_after_attempt(3)) -def chat_completion_request(messages, tools=None, tool_choice=None, model='gpt-3.5-turbo'): +def chat_completion_request(messages, tools=None, tool_choice=None, model='gpt-4'): try: response = client.chat.completions.create( model=model, diff --git a/backend/process_data.ipynb b/backend/process_data.ipynb new file mode 100644 index 000000000..76c06d986 --- /dev/null +++ b/backend/process_data.ipynb @@ -0,0 +1,89 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [], + "source": [ + "from openai import OpenAI\n", + "import json\n", + "from tqdm import tqdm\n", + "import os\n", + "from dotenv import load_dotenv\n", + "load_dotenv()" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "OPENAI_API_KEY = os.getenv(\"OPENAI_API_KEY\")\n", + "client = OpenAI(api_key=OPENAI_API_KEY)" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "def get_embedding(text):\n", + " response = client.embeddings.create(\n", + " model=\"text-embedding-3-small\",\n", + " input=text\n", + " )\n", + " return response.data[0].embedding" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "100%|██████████| 1206/1206 [04:05<00:00, 4.91it/s]\n" + ] + } + ], + "source": [ + "with open('202501.json', 'r') as file:\n", + " courses = json.load(file)\n", + "\n", + "for course in tqdm(courses):\n", + " text_to_embed = f\"{course['short_title']}: {course['description']}\"\n", + " embedding = get_embedding(text_to_embed) \n", + " course['embedding'] = embedding\n", + "\n", + "with open('courses_with_embeddings.json', 'w') as file:\n", + " json.dump(courses, file, indent=4)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.2" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/backend/test_app.py b/backend/test_app.py new file mode 100644 index 000000000..76376d1e2 --- /dev/null +++ b/backend/test_app.py @@ -0,0 +1,298 @@ +import pytest +from unittest.mock import patch, MagicMock +from app import create_app +from flask_testing import TestCase +from requests_mock import Mocker + + +# Define the TestConfig as a dictionary directly +test_config = { + "TESTING": True, + "CAS_SERVER": "https://secure.its.yale.edu/cas", + "FLASK_SECRET_KEY": "test_secret", + "MONGO_URI": "mongodb://localhost:27017/test", +} + + +class TestApp(TestCase): + def create_app(self): + # Create an app using the test configuration dictionary + return create_app(test_config) + + def test_login_route(self): + response = self.client.get("/login") + # Directly specify the expected redirection URL + self.assertEqual(response.headers["Location"], "/login/") + + def test_logout_route(self): + response = self.client.get("/logout") + # Directly specify the expected redirection URL + self.assertEqual(response.headers["Location"], "/logout/") + + def test_route_after_login(self): + with self.client.session_transaction() as sess: + sess["CAS_USERNAME"] = "testuser" + + response = self.client.get("/route_after_login") + self.assertEqual(response.status_code, 200) + self.assertIn("Logged in as testuser", response.data.decode()) + + @Mocker() + def test_validate_ticket_failure_authentication(self, m): + # Mock the CAS server response to simulate authentication failure + cas_response_xml = """ + <cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas"> + <cas:authenticationFailure code="INVALID_TICKET"> + "Ticket ST-12345 not recognized" + </cas:authenticationFailure> + </cas:serviceResponse> + """ + cas_validate_url = "https://secure.its.yale.edu/cas/serviceValidate" + m.get( + cas_validate_url, text=cas_response_xml, status_code=200 + ) # CAS often uses 200 OK even for failed authentication responses + + # Make a POST request to the validate_ticket route with mock data + data = {"ticket": "ST-12345", "service_url": "http://localhost:5000"} + response = self.client.post("/validate_ticket", json=data) + self.assertEqual(response.status_code, 401) + self.assertFalse( + response.json["isAuthenticated"], "Expected authentication to fail" + ) + + @Mocker() + def test_validate_ticket_success(self, m): + m.get( + "https://secure.its.yale.edu/cas/serviceValidate", + text='<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas"><cas:authenticationSuccess><cas:user>testuser</cas:user></cas:authenticationSuccess></cas:serviceResponse>', + ) + data = {"ticket": "ST-12345", "service_url": "http://localhost:5000"} + response = self.client.post("/validate_ticket", json=data) + self.assertEqual(response.status_code, 200) + self.assertTrue( + "isAuthenticated" in response.json and response.json["isAuthenticated"] + ) + + @Mocker() + def test_validate_ticket_failure(self, m): + m.get("https://secure.its.yale.edu/cas/serviceValidate", status_code=401) + data = {"ticket": "ST-12345", "service_url": "http://localhost:5000"} + response = self.client.post("/validate_ticket", json=data) + self.assertEqual(response.status_code, 401) + self.assertFalse( + "isAuthenticated" in response.json and response.json["isAuthenticated"] + ) + + def test_validate_ticket_no_data(self): + response = self.client.post("/validate_ticket", json={}) + self.assertEqual(response.status_code, 400) + self.assertIn("Ticket or service URL not provided", response.json["error"]) + + +@pytest.fixture +def app(): + app = create_app(test_config) + return app + + +def test_load_config_with_test_config(app): + assert app.secret_key == "test_secret" + assert app.config["CAS_SERVER"] == "https://secure.its.yale.edu/cas" + + +def test_init_database_with_config(app): + assert "collection" in app.config + + +@pytest.fixture +def client(): + mock_collection = MagicMock() + mock_collection.aggregate.return_value = iter( + [ + { + "areas": ["Hu"], + "course_code": "CPSC 150", + "description": "Introduction to the basic ideas of computer science (computability, algorithm, virtual machine, symbol processing system), and of several ongoing relationships between computer science and other fields, particularly philosophy of mind.", + "season_code": "202303", + "sentiment_info": { + "final_label": "NEGATIVE", + "final_proportion": 0.9444444444444444, + }, + "title": "Computer Science and the Modern Intellectual Agenda", + }, + ] + ) + + app = create_app( + { + "TESTING": True, + "collection": mock_collection, + "MONGO_URL": "TEST_URL", + "COURSE_QUERY_LIMIT": 5, + "SAFETY_CHECK_ENABLED": True, + "DATABASE_RELEVANCY_CHECK_ENABLED": True, + } + ) + with app.test_client() as client: + yield client + + +@pytest.fixture +def client_all_disabled(): + mock_collection = MagicMock() + mock_collection.aggregate.return_value = iter( + [ + { + "areas": ["Hu"], + "course_code": "CPSC 150", + "description": "Introduction to the basic ideas of computer science (computability, algorithm, virtual machine, symbol processing system), and of several ongoing relationships between computer science and other fields, particularly philosophy of mind.", + "season_code": "202303", + "sentiment_info": { + "final_label": "NEGATIVE", + "final_proportion": 0.9444444444444444, + }, + "title": "Computer Science and the Modern Intellectual Agenda", + }, + ] + ) + + app = create_app( + { + "TESTING": True, + "collection": mock_collection, + } + ) + with app.test_client() as client: + yield client + + +@pytest.fixture +def mock_chat_completion_yes_no(): + with patch("app.chat_completion_request") as mock: + # List of responses, one for each expected call + responses = [ + MagicMock(choices=[MagicMock(message=MagicMock(content="yes"))]), + MagicMock(choices=[MagicMock(message=MagicMock(content="no"))]), + MagicMock( + choices=[MagicMock(message=MagicMock(content="no need for query"))] + ), + ] + mock.side_effect = responses + yield mock + + +@pytest.fixture +def mock_chat_completion_no(): + with patch("app.chat_completion_request") as mock: + # List of responses, one for each expected call + responses = [ + MagicMock(choices=[MagicMock(message=MagicMock(content="no"))]), + MagicMock(choices=[MagicMock(message=MagicMock(content="no"))]), + MagicMock( + choices=[ + MagicMock( + message=MagicMock(content="Mock response based on user message") + ) + ] + ), + ] + mock.side_effect = responses + yield mock + + +@pytest.fixture +def mock_chat_completion_yes_yes(): + with patch("app.chat_completion_request") as mock: + # List of responses, one for each expected call + responses = [ + MagicMock(choices=[MagicMock(message=MagicMock(content="yes"))]), + MagicMock(choices=[MagicMock(message=MagicMock(content="yes"))]), + MagicMock( + choices=[MagicMock(message=MagicMock(content="no need for query"))] + ), + MagicMock( + choices=[ + MagicMock( + message=MagicMock(content="Mock response based on user message") + ) + ] + ), + ] + mock.side_effect = responses + yield mock + + +def test_chat_endpoint(client, mock_chat_completion_yes_yes): + request_data = { + "message": [ + {"id": 123, "role": "user", "content": "msg"}, + {"id": 123, "role": "ai", "content": "msg2"}, + {"id": 123, "role": "user", "content": "Tell me about cs courses"}, + ] + } + response = client.post("/api/chat", json=request_data) + assert response.status_code == 200 + data = response.get_json() + assert "Mock response based on user message" in data["response"] + assert mock_chat_completion_yes_yes.call_count == 4 + + +def test_no_need_for_query(client, mock_chat_completion_yes_no): + request_data = { + "message": [ + {"id": 123, "role": "user", "content": "msg"}, + {"id": 123, "role": "ai", "content": "msg2"}, + {"id": 123, "role": "user", "content": "Tell me about cs courses"}, + ] + } + response = client.post("/api/chat", json=request_data) + assert response.status_code == 200 + data = response.get_json() + assert "no need for query" in data["response"] + assert mock_chat_completion_yes_no.call_count == 3 + + +def test_safty_violation(client, mock_chat_completion_no): + request_data = { + "message": [ + {"id": 123, "role": "user", "content": "msg"}, + {"id": 123, "role": "ai", "content": "msg2"}, + {"id": 123, "role": "user", "content": "Tell me about cs courses"}, + ] + } + response = client.post("/api/chat", json=request_data) + assert response.status_code == 200 + data = response.get_json() + assert "I am sorry" in data["response"] + assert mock_chat_completion_no.call_count == 1 + + +def test_all_disable(client_all_disabled, mock_chat_completion_yes_yes): + client = client_all_disabled + mock_chat_completion_yes_yes.return_value = MagicMock( + choices=[ + MagicMock(message=MagicMock(content="Mock response based on user message")) + ] + ) + request_data = { + "message": [ + {"id": 123, "role": "user", "content": "msg"}, + {"id": 123, "role": "ai", "content": "msg2"}, + {"id": 123, "role": "user", "content": "Tell me about cs courses"}, + ] + } + response = client.post("/api/chat", json=request_data) + assert response.status_code == 200 + data = response.get_json() + assert "Mock response based on user message" in data["response"] + assert mock_chat_completion_yes_yes.call_count == 4 + + +def test_api_error(client, mock_chat_completion_yes_no): + # Simulate an API error by having the mock raise an exception + mock_chat_completion_yes_no.side_effect = Exception("API error simulated") + request_data = { + "message": [{"id": 123, "role": "user", "content": "Error scenario test."}] + } + with pytest.raises(Exception): + client.post("/api/chat", json=request_data) diff --git a/database_scripts/load_season_courses.py b/database_scripts/load_season_courses.py new file mode 100644 index 000000000..6990c6cb5 --- /dev/null +++ b/database_scripts/load_season_courses.py @@ -0,0 +1,29 @@ +# not functional at the moment, reason unknown + +import pymongo +import json +import os +from dotenv import load_dotenv + +load_dotenv() + +# MongoDB connection setup + +client = pymongo.MongoClient(os.getenv("MONGODB_URI")) # Replace with your MongoDB URI +db = client[os.getenv("DB_NAME")] # Replace with your database name +season_courses_collection = db['season_courses'] # Collection for season courses + +# Function to load and insert data +def load_data(directory): + for filename in os.listdir(directory): + if filename.endswith('.json') and filename[:4] in ["2021", "2022", "2023", "2024"] and filename[4:6] in ["01", "02", "03"] and len(filename) == 11: + with open(os.path.join(directory, filename), 'r') as file: + data = json.load(file) + if isinstance(data, list): # Check if the data is a list + season_courses_collection.insert_many(data) # Insert all courses from the file + +# Replace with the path to your 'season_courses' directory +load_data('/Users/buweichen/repos/s24-bluebook-ai/data/season_courses') + +# Close the MongoDB connection +client.close() diff --git a/demo.png b/demo.png new file mode 100644 index 000000000..1570461e2 Binary files /dev/null and b/demo.png differ diff --git a/frontend/.gitignore b/frontend/.gitignore index 5b9407516..51fa2f232 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -35,3 +35,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +DeletedRoute.TsFile.txt diff --git a/frontend/README.md b/frontend/README.md index c4033664f..d703465b0 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,8 +1,6 @@ -This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). - ## Getting Started -First, run the development server: +Run the development server: ```bash npm run dev @@ -16,21 +14,4 @@ bun dev Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +Open [http://127.0.0.1:8000/](http://127.0.0.1:8000/) with your browser to check if the flask server is up and running. diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 4678774e6..61cd5eddd 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -1,4 +1,6 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + reactStrictMode: false, +}; export default nextConfig; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index de99bc839..4e29b996f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "ai": "^2.2.34", + "concurrently": "^8.2.2", "next": "14.1.0", "openai": "^4.27.0", "react": "^18", @@ -61,7 +62,6 @@ "version": "7.23.9", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -907,7 +907,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -916,7 +915,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -1238,7 +1236,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -1263,6 +1260,53 @@ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/code-red": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz", @@ -1280,7 +1324,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -1291,8 +1334,7 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/combined-stream": { "version": "1.0.8", @@ -1311,6 +1353,46 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/concurrently": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz", + "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==", + "dependencies": { + "chalk": "^4.1.2", + "date-fns": "^2.30.0", + "lodash": "^4.17.21", + "rxjs": "^7.8.1", + "shell-quote": "^1.8.1", + "spawn-command": "0.0.2", + "supports-color": "^8.1.1", + "tree-kill": "^1.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": "^14.13.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -1357,6 +1439,21 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, + "node_modules/date-fns": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -1628,6 +1725,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/escalade": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2295,6 +2400,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -2487,7 +2600,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "engines": { "node": ">=8" } @@ -2755,7 +2867,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "engines": { "node": ">=8" } @@ -3132,6 +3243,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3796,8 +3912,7 @@ "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/regexp.prototype.flags": { "version": "1.5.1", @@ -3816,6 +3931,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -3919,6 +4042,14 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/safe-array-concat": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.0.tgz", @@ -4062,6 +4193,14 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.5.tgz", @@ -4132,6 +4271,11 @@ "node": ">=0.10.0" } }, + "node_modules/spawn-command": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz", + "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==" + }, "node_modules/sswr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/sswr/-/sswr-2.0.0.tgz", @@ -4285,7 +4429,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -4353,7 +4496,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -4474,6 +4616,14 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "bin": { + "tree-kill": "cli.js" + } + }, "node_modules/ts-api-utils": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz", @@ -4876,12 +5026,63 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 154da28c1..517bc9c1c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,13 +3,11 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint" - }, + "dev": "next dev" + }, "dependencies": { "ai": "^2.2.34", + "concurrently": "^8.2.2", "next": "14.1.0", "openai": "^4.27.0", "react": "^18", diff --git a/frontend/src/app/api/chat/route.ts b/frontend/src/app/api/chat/route.ts deleted file mode 100644 index b05e548d5..000000000 --- a/frontend/src/app/api/chat/route.ts +++ /dev/null @@ -1,26 +0,0 @@ -import OpenAI from 'openai'; -import { OpenAIStream, StreamingTextResponse } from 'ai'; - -// Create an OpenAI API client (that's edge friendly!) -const openai = new OpenAI({ - apiKey: process.env.OPENAI_API_KEY!, -}); - -// Set the runtime to edge for best performance -export const runtime = 'edge'; - -export async function POST(req: Request) { - const { messages } = await req.json(); - - // Ask OpenAI for a streaming chat completion given the prompt - const response = await openai.chat.completions.create({ - model: 'gpt-3.5-turbo', - stream: true, - messages, - }); - - // Convert the response into a friendly text-stream - const stream = OpenAIStream(response); - // Respond with the stream - return new StreamingTextResponse(stream); -} \ No newline at end of file diff --git a/frontend/src/app/bg.png b/frontend/src/app/bg.png new file mode 100644 index 000000000..bbdfad95b Binary files /dev/null and b/frontend/src/app/bg.png differ diff --git a/frontend/src/app/chaticon.png b/frontend/src/app/chaticon.png new file mode 100644 index 000000000..a5cb538c0 Binary files /dev/null and b/frontend/src/app/chaticon.png differ diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 7f9393633..122408ff6 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -7,4 +7,8 @@ body { align-items: center; min-height: 100vh; flex-direction: column; + background-image: url('bg.png'); + background-size: cover; + background-position: center; + background-repeat: no-repeat; } \ No newline at end of file diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 3314e4780..7bd63f9bf 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,12 +1,13 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; +import ProfilePopup from './profiles'; // Adjust the import path as needed import "./globals.css"; const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "BluebookAI", + description: "BluebookAI Assistant", }; export default function RootLayout({ @@ -16,7 +17,10 @@ export default function RootLayout({ }>) { return ( <html lang="en"> - <body className={inter.className}>{children}</body> + <body className={inter.className}> + <ProfilePopup /> {/* This renders the profile popup across all pages using this layout */} + {children} + </body> </html> ); -} +} \ No newline at end of file diff --git a/frontend/src/app/page.module.css b/frontend/src/app/page.module.css index 45ef3424b..04c45867c 100644 --- a/frontend/src/app/page.module.css +++ b/frontend/src/app/page.module.css @@ -1,98 +1,294 @@ -.chat-container { +/* .chatContainer { display: flex; flex-direction: column; - justify-content: flex-end; /* aligns content to the bottom */ - align-items: center; - width: 100%; - max-width: 100px; /* width of the chat area */ + justify-content: space-between; + align-items: stretch; + width: 400px; margin: auto; - min-height: 70vh; /* height of the chat container */ + height: 70vh; padding: 1rem; - border-radius: 15px; /* rounded corners for the chat container */ - background-color: #63a6d0; /* background color for the chat container */ - box-shadow: 0 4px 6px rgba(0,0,0,0.1); /* might remove*/ - overflow: hidden; /* ensures the content is within border radius */ + border-radius: 20px; + background-color: #f9f9f9; + box-shadow: 0 8px 24px rgba(0,0,0,0.15); + overflow: hidden; +} */ + +.floatingChatButton { + position: fixed; + bottom: 20px; + right: 20px; + width: 50px; + height: 50px; + padding: 10px 15px; + border-radius: 50%; + background-color: #468ff2; + background-image: url('chaticon.png'); + background-repeat: no-repeat; + background-position: center; + background-size: 40%; + color: white; + border: none; + cursor: pointer; + box-shadow: 2px 2px 10px rgb(0 0 0 / 20%); + z-index: 1200; +} + +.chatContainer { + display: none; + position: fixed; + bottom: 20px; + right: 80px; + width: 350px; + height: 450px; + background: var(--color-surface); + background-color: white; + border: 2px solid var(--color-border); + box-shadow: 0 0 10px 10px rgb(0 0 0 / 20%); + overflow-y: auto; + border-radius: 10px; + z-index: 1100; + transform: translateX(200%); + transition: transform 0.5s ease-in-out; +} + +.chatHeader { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + background: #468ff2; + color: white; + font-size: 17px; + padding: 10px 15px; + margin: 0; + border-top-left-radius: 10px; + border-top-right-radius: 10px; + text-align: left; + box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2); +} + +.chatContainer > :first-child { + margin-top: 0; +} + +.chatVisible { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: stretch; + transform: translateX(0); } .messages { - width: 100%; - height: 100%; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; display: flex; flex-direction: column; - overflow-y: auto; /* scrolling */ - border-radius: 15px; /* rounded corners for the chat container */ - background-color: #63a6d0; /* background color for the chat container */ + overflow-y: auto; + padding: 1rem; + margin-bottom: auto; + font-size: 13px; } .message { + display: inline; + flex-direction: column; + word-wrap: break-word; + padding: 0.5rem 1rem; + background-color: #E6E7ED; + align-self: flex-start; + max-width: fit-content; + position: relative; + margin-bottom: 0.5rem; + line-height: 24px; + border-radius: 25px; +} + +.message:before, +.message:after { + position: absolute; + bottom: 0; + height: 25px; /* height of message bubble "tail" */ + content: ''; +} + +.message.user { + background-color: #0b93f6; + color: white; + align-self: flex-end; + max-width: fit-content; +} + +/* tail for AI messages */ +.message.ai:before { + content: ''; + position: absolute; + bottom: 0; + left: -4px; + width: 10px; + height: 20px; + background-color: #E6E7ED; + border-bottom-right-radius: 16px 14px; +} + +.message.ai:after { + content: ''; + position: absolute; + bottom: 0; + left: -10px; + width: 10px; + height: 20px; + background-color: white; + border-bottom-right-radius: 10px; +} + +/* tail for user messages */ +.message.user:before { + content: ''; + position: absolute; + bottom: 0; + right: -4px; + width: 10px; + height: 20px; + background-color: #0b93f6; + border-bottom-left-radius: 16px 14px; +} + +.message.user:after { + content: ''; + position: absolute; + bottom: 0; + right: -10px; + width: 10px; + height: 20px; + background-color: white; + border-bottom-left-radius: 10px; +} + +.inputForm { display: flex; padding: 0.5rem; - margin: 0.5rem; - border-radius: 15px; /* rounded corners message */ - background-color: #e9e9e9; /* background color for messages */ - align-self: flex-start; /* align AI messages to the left */ + background: white; } -.input-form { - width: 100%; +.inputField { + flex-grow: 1; + padding: 0.6rem 1rem; + margin-right: 0.5rem; + background-color: #f9f9f9; + border: 1px solid #ccc; + border-radius: 18px; } -.input-field { - width: calc(100% - 2rem); - padding: 1rem; - margin: 1rem; - border: 1px solid #e0e0e0; - border-radius: 8px; - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); +.sendButton { + padding: 0.6rem 1rem; + border: none; + border-radius: 18px; + background-color: #468ff2; + color: white; + cursor: pointer; } -/* .messages { - width: 100%; - padding: 1rem; -} */ +/* responsive adjustments */ +@media (max-width: 768px) { + .chatContainer { + width: 100%; /* full width for smaller screens */ + bottom: 0; + right: 0; + height: 50%; + } +} -/* .message { - margin: 0.5rem 0; - padding: 0.5rem; - border-radius: 5px; - max-width: 80%; -} */ +.typing-indicator { + padding: 10px; /* Reduced padding */ + display: flex; /* Changed to flex to facilitate alignment */ + align-items: center; /* Align items vertically */ + /* margin-left: 10px; */ + background-color: #E6E7ED; + will-change: transform; + width: auto; + border-radius: 50px; + /* padding: 20px; */ + /* display: table; */ + margin: 0 auto 0 8px; + position: relative; + animation: 2s bulge infinite ease-out; +} -.message.ai { - background-color: #f1f0f0; - align-self: flex-start; +.typing-indicator::before, +.typing-indicator::after { + content: ''; + position: absolute; + bottom: -2px; + left: -2px; + height: 15px; + width: 15px; + border-radius: 50%; + background-color: #E6E7ED; } -.message.user { - background-color: #d1f4ff; /* different background color for user messages */ - align-self: flex-end; /* aligns user messages to the right */ +.typing-indicator::after { + height: 10px; + width: 10px; + left: -10px; + bottom: -10px; } -/* -.message.user { - background-color: #d1f4ff; - align-self: flex-end; -} */ -.role { - font-weight: bold; +.typing-indicator span { + height: 10px; + width: 10px; + margin: 0 2px; + float: left; + background-color: #9E9EA1; + display: block; + border-radius: 50%; + opacity: 0.4; + animation: blink 1s infinite; } -/* .input-form { - width: 100%; -} */ +.typing-indicator span:nth-of-type(1) { + animation-delay: 0.3333s; +} -/* .input-field { - width: calc(100% - 2rem); - padding: 1rem; - margin: 1rem; - border: 1px solid #e0e0e0; - border-radius: 8px; - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); -} */ +.typing-indicator span:nth-of-type(2) { + animation-delay: 0.6666s; +} -/* mobile responsiveness */ -@media (max-width: 768px) { - .chat-container { - width: 90%; +.typing-indicator span:nth-of-type(3) { + animation-delay: 0.9999s; +} + +@keyframes blink { + 50% { + opacity: 1; + } +} + +@keyframes bulge { + 50% { + transform: scale(1.05); } } + +.floatingProfileButton { + position: fixed; + bottom: 20px; + right: 90px; /* Adjust based on chat button placement */ + z-index: 1001; + /* additional styling */ +} + +.profileContainer { + position: fixed; + bottom: 70px; /* Adjust based on your design */ + right: 10px; + width: 300px; /* Adjust size as needed */ + background: white; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); + transition: right 0.3s ease-in-out; + /* More styles */ +} + +.profileVisible { + right: 10px; /* Adjust to make visible */ +} + +.closeButton { + /* Style the close button */ +} \ No newline at end of file diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 5fdf1ccfb..38bf9339a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,27 +1,234 @@ -'use client'; +"use client"; -import { useChat } from 'ai/react'; -import styles from '/Users/tselmegulammandakh/Downloads/cpsc439/s24-bluebook-ai/frontend/src/app/page.module.css'; // change to ur own directory +import React, { useState } from "react"; +import { useEffect } from "react"; +import styles from "./page.module.css"; // change to ur own directory +import { format } from "path"; export default function Chat() { - const { messages, input, handleInputChange, handleSubmit } = useChat(); + const [isTyping, setIsTyping] = useState(false); + const [input, setInput] = useState(""); + const [messages, setMessages] = useState([ + { id: "welcome-msg", content: "How may I help you?", role: "ai" }, + ]); + const [chatVisible, setChatVisible] = useState(false); + const [isAuthenticated, setIsAuthenticated] = useState(false); + + useEffect(() => { + // Check for CAS ticket in URL parameters + const urlParams = new URLSearchParams(window.location.search); + const ticket = urlParams.get("ticket"); + + if (ticket) { + validateTicket(ticket); + } + }, []); + + const handleInputChange = (e: { + target: { value: React.SetStateAction<string> }; + }) => { + setInput(e.target.value); + }; + + // Call this function after your authentication logic or on page load + useEffect(() => { + clearTicketFromUrl(); + }, []); + + const handleSubmit = async (e: { preventDefault: () => void }) => { + e.preventDefault(); + + setInput(""); + + // add the user's message to the chat. + const newUserMessage = { + id: `user-${Date.now()}`, + content: input, + role: "user", + }; + + setMessages((messages) => [...messages, newUserMessage]); + setIsTyping(true); + + const response = await fetch("http://127.0.0.1:8000/api/chat", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + // body: JSON.stringify({ message: [{ content: input, role: 'user' }] }), + body: JSON.stringify({ + message: [...messages, newUserMessage], + }), + }); + + setIsTyping(false); + + if (response.ok) { + const data = await response.json(); + // simulateTypingEffect(data.message[0].content, 'ai', `ai-${Date.now()}`); + simulateTypingEffect(data.response, "ai", `ai-${Date.now()}`); + } else { + console.error("Failed to send message"); + } + }; + + const simulateTypingEffect = ( + message: string, + role: string, + messageId: string + ) => { + let index = 0; + const typingSpeedMs = 20; + + const typeCharacter = () => { + if (index < message.length) { + const updatedMessage = { + id: messageId, + content: message.substring(0, index + 1), + role: role, + }; + setMessages((currentMessages) => { + // is message being typed already in array + const existingIndex = currentMessages.findIndex( + (msg) => msg.id === messageId + ); + let newMessages = [...currentMessages]; + if (existingIndex >= 0) { + // update existing message + newMessages[existingIndex] = updatedMessage; + } else { + // add new message if it doesn't exist + newMessages.push(updatedMessage); + } + return newMessages; + }); + index++; + setTimeout(typeCharacter, typingSpeedMs); + } + }; + + typeCharacter(); + }; + + const toggleChatVisibility = () => { + console.log("Toggling chat visibility. Current state:", chatVisible); + setChatVisible(!chatVisible); + }; + + // Redirect to CAS login page + const redirectToCasLogin = () => { + const casLoginUrl = `https://secure.its.yale.edu/cas/login?service=${encodeURIComponent( + window.location.href + )}`; + window.location.href = casLoginUrl; + }; + + const validateTicket = async (ticket: string) => { + const serviceUrl = window.location.origin + "/"; + + try { + const response = await fetch("http://127.0.0.1:8000/validate_ticket", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ ticket, service_url: serviceUrl }), + }); + + if (response.ok) { + const data = await response.json(); + setIsAuthenticated(data.isAuthenticated); // Update the state based on the response + clearTicketFromUrl(); + } else { + console.error( + "Failed to validate ticket - server responded with an error" + ); + } + } catch (error) { + console.error("Failed to validate ticket:", error); + } + }; + + // Function to clear the ticket from the URL + const clearTicketFromUrl = () => { + const url = new URL(window.location.href); + url.searchParams.delete("ticket"); // Remove the ticket parameter + + window.history.replaceState({}, document.title, url.pathname + url.search); + }; + + const handleButtonClick = () => { + if (isAuthenticated) { + toggleChatVisibility(); + } else { + redirectToCasLogin(); + } + }; + + const formatMessage = (content: string) => { + const boldRegex = /\*\*(.*?)\*\*/g; + return content.split(boldRegex).map((part, index) => { + // Every even index is not bold, odd indices are the bold text between **. + if (index % 2 === 0) { + // Normal text + return part; + } else { + // Bold text + return <strong key={index}>{part}</strong>; + } + }); + }; + return ( - <div className={styles.chatContainer}> - <div className={styles.messages}> - {messages.map(m => ( - <div key={m.id} className={`${styles.message} ${m.role === 'user' ? styles.user : ''}`}> - {m.content} + <> + <button + onClick={handleButtonClick} + className={styles.floatingChatButton} + aria-label="Toggle Chat" + > + {} + </button> + + {chatVisible && ( + <div + className={`${styles.chatContainer} ${ + chatVisible ? styles.chatVisible : "" + }`} + > + <div className={styles.chatHeader}>BluebookAI Assistant</div> + <div className={styles.messages}> + {messages.map((m) => ( + <div + key={m.id} + className={`${styles.message} ${ + m.role === "user" ? styles.user : styles.ai + }`} + > + {formatMessage(m.content)} + </div> + ))} + {isTyping && ( + <div className={styles["typing-indicator"]}> + <span></span> + <span></span> + <span></span> + </div> + )} </div> - ))} - </div> - <form onSubmit={handleSubmit} className={styles.inputForm}> - <input - className={styles.inputField} - value={input} - placeholder="Say something..." - onChange={handleInputChange} - /> - </form> - </div> + <form onSubmit={handleSubmit} className={styles.inputForm}> + <input + type="text" + className={styles.inputField} + value={input} + placeholder="Say something..." + onChange={handleInputChange} + /> + <button type="submit" className={styles.sendButton}> + Send + </button> + </form> + </div> + )} + </> ); } diff --git a/frontend/src/app/profiles.tsx b/frontend/src/app/profiles.tsx new file mode 100644 index 000000000..17b0bc218 --- /dev/null +++ b/frontend/src/app/profiles.tsx @@ -0,0 +1,100 @@ +"use client"; + +import React, { useState } from "react"; +import styles from "./page.module.css"; // Use your specific styles directory + +const ProfilePopup = () => { + const [popupVisible, setPopupVisible] = useState(false); + const [username, setUsername] = useState("JohnDoe"); + const [email, setEmail] = useState("johndoe@example.com"); + const [courses, setCourses] = useState<string[]>([]); + const [search, setSearch] = useState(""); + const [chatHistories, setChatHistories] = useState<{ id: number; summary: string }[]>([]); + + const togglePopupVisibility = () => { + setPopupVisible(!popupVisible); + }; + + const handleSaveProfile = async () => { + const response = await fetch('/api/save_profile', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ username, email }), + }); + if (!response.ok) { + console.error("Failed to save profile"); + } + }; + + const handleAddCourse = async () => { + if (search) { + const response = await fetch('http://127.0.0.1:8000/api/verify_course_code', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ search }), + }); + if (response.ok) { + const data = await response.json(); + setCourses(prevCourses => [...prevCourses, data]); + setSearch(''); + } + } + }; + + const reloadChat = async (chatId: number) => { + console.log(`Reloading chat with ID: ${chatId}`); + // Simulate fetching chat data from a backend + const response = await fetch(`/api/reload_chat/${chatId}`); + if (response.ok) { + const data = await response.json(); + // Assuming you have a state for the current chat display: + // setCurrentChat(data.chatContent); + console.log("Chat reloaded:", data.chatContent); + } else { + console.error("Failed to reload chat"); + } + }; + + const addChatHistory = (summary: string) => { + const newHistory = { id: Date.now(), summary }; + setChatHistories(prevHistories => [...prevHistories, newHistory]); + }; + + return ( + <> + <button onClick={togglePopupVisibility} className={styles.floatingProfileButton}> + <img src="/path/to/profile-icon.png" alt="Profile Icon" /> + </button> + + {popupVisible && ( + <div className={`${styles.profileContainer} ${popupVisible ? styles.profileVisible : ""}`}> + <div className={styles.profileHeader}>User Profile</div> + <div className={styles.profileDetails}> + <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="Username" /> + <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" /> + <button onClick={handleSaveProfile}>Save</button> + </div> + <div className={styles.courseSearch}> + <input type="text" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search courses" /> + <button onClick={handleAddCourse}>Add Course</button> + {courses.map(course => <div key={course}>{course}</div>)} + </div> + <div className={styles.chatHistory}> + <div className={styles.chatHistoryHeader}>Chat History</div> + <div className={styles.chatHistoryList}> + {chatHistories.map(history => ( + <div key={history.id} className={styles.chatTile}> + <span>{history.summary}</span> + <button onClick={() => reloadChat(history.id)}>🔄</button> + </div> + ))} + </div> + </div> + <button onClick={togglePopupVisibility} className={styles.closeButton}>Close</button> + </div> + )} + </> + ); +}; + +export default ProfilePopup; \ No newline at end of file diff --git a/load_courses.js b/load_courses.js new file mode 100644 index 000000000..0989846d9 --- /dev/null +++ b/load_courses.js @@ -0,0 +1,40 @@ +/* global use, db */ +// MongoDB Playground +// To disable this template go to Settings | MongoDB | Use Default Template For Playground. +// Make sure you are connected to enable completions and to be able to run a playground. +// Use Ctrl+Space inside a snippet or a string literal to trigger completions. +// The result of the last command run in a playground is shown on the results panel. +// By default the first 20 documents will be returned with a cursor. +// Use 'console.log()' to print to the debug output. +// For more documentation on playgrounds please refer to +// https://www.mongodb.com/docs/mongodb-vscode/playgrounds/ + +// Select the database to use. +use('course_db'); +const fs = require('fs'); +const path = require('path'); + + +// Directory containing your JSON files +const directoryPath = '/Users/buweichen/repos/s24-bluebook-ai/data/parsed_courses/'; // Replace with your directory path +function loadSeasonCourses() { + const collection = db.getCollection('parsed_courses') + + const files = fs.readdirSync(directoryPath); + + console.log(files) + + for (const file of files) { + if (file.endsWith('.json') && /^(2021|2022|2023|2024)(01|02|03)\.json$/.test(file)) { + const filePath = path.join(directoryPath, file); + const fileContents = fs.readFileSync(filePath); + console.log(fileContents); + const courses = JSON.parse(fileContents); + if (Array.isArray(courses)) { + collection.insertMany(courses); + console.log(`Inserted courses from ${file}`); + } + } + } +} +loadSeasonCourses(); \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..644fbdfe4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,29 @@ +annotated-types==0.6.0 +anyio==4.3.0 +blinker==1.7.0 +certifi==2024.2.2 +click==8.1.7 +distro==1.9.0 +dnspython==2.6.1 +exceptiongroup==1.2.0 +Flask==2.1.3 +Flask-Cors==4.0.0 +h11==0.14.0 +httpcore==1.0.3 +httpx==0.26.0 +idna==3.6 +itsdangerous==2.1.2 +Jinja2==3.1.3 +MarkupSafe==2.1.5 +openai==1.14.3 +pydantic==2.6.1 +pydantic_core==2.16.2 +pymongo==4.6.2 +python-dotenv==1.0.1 +sniffio==1.3.0 +tenacity==8.2.3 +tqdm==4.66.2 +typing_extensions==4.9.0 +Werkzeug==2.2.2 +flask_cas==1.0.2 +requests==2.31.0 \ No newline at end of file