diff options
author | JJ <nicetry@noemail.com> | 2025-03-31 21:08:41 +0100 |
---|---|---|
committer | JJ <nicetry@noemail.com> | 2025-03-31 21:08:41 +0100 |
commit | 2e0b9c97af457da5c6afda611d48e59047d4cdb8 (patch) | |
tree | 199d10037f790bcade2ac86f478c10da77bbc771 | |
parent | 17529f38f4b4edf7249e18418ddfcc3f818a006d (diff) |
Basic AI functionality
-rw-r--r-- | .Constants.py.swp | bin | 0 -> 12288 bytes | |||
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | AIParams.py | 25 | ||||
-rw-r--r-- | scripts/database/mongo.py | 11 | ||||
-rw-r--r-- | scripts/recipes/handle_recipes.py | 2 | ||||
-rw-r--r-- | scripts/scraping/ai_scraping.py | 19 | ||||
-rw-r--r-- | scripts/scraping/scraper.py | 3 | ||||
-rw-r--r-- | scripts/users/handle_users.py | 10 | ||||
-rw-r--r-- | server.py | 28 | ||||
-rw-r--r-- | static/images/bars.svg | 2 | ||||
-rw-r--r-- | static/scripts/index.js | 57 | ||||
-rw-r--r-- | static/style/style.css | 8 | ||||
-rw-r--r-- | templates/base.html | 1 | ||||
-rw-r--r-- | templates/pages/about.html | 9 | ||||
-rw-r--r-- | templates/pages/account.html | 21 | ||||
-rw-r--r-- | templates/pages/home.html | 2 | ||||
-rw-r--r-- | templates/pages/single-recipe.html | 14 |
17 files changed, 186 insertions, 27 deletions
diff --git a/.Constants.py.swp b/.Constants.py.swp Binary files differnew file mode 100644 index 0000000..ffb1f96 --- /dev/null +++ b/.Constants.py.swp @@ -9,3 +9,4 @@ htmlcov/ .tox/ docs/_build/ Constants.py +AIParams.py diff --git a/AIParams.py b/AIParams.py new file mode 100644 index 0000000..aab8ac3 --- /dev/null +++ b/AIParams.py @@ -0,0 +1,25 @@ +RECIPE_SCHEMA = { + 'title': str, + 'slug': str, + 'image': str, + 'url': str, + 'tags': list[str], + 'ingredients': list[str], + 'instructions': list[str] +} + +PROMPT = """ +Extract data from the following text in the schema format provided. + +SCHEMA: + +{schema} + +DATA: + +{markdown} +""" + +SYSTEM_INSTRUCTIONS="You are responsible for taking the inputted markdown text and generating a structure response from it. See the Schema provided." + + diff --git a/scripts/database/mongo.py b/scripts/database/mongo.py index 3e97b0b..eaa2d97 100644 --- a/scripts/database/mongo.py +++ b/scripts/database/mongo.py @@ -3,7 +3,12 @@ from pymongo.server_api import ServerApi import Constants import certifi -uri = Constants.MONGO_CONNECTION_STRING +uri = Constants.MONGO_CONNECTION_STRING if Constants.MODE == "production" else Constants.MONGO_CONNECTION_STRING_DEV +database_str = "index-cooking" if Constants.MODE == "production" else "recipedb" -client = MongoClient(uri, server_api=ServerApi('1'), tlsCAFile=certifi.where()) -mongo_database = client["index-cooking"]
\ No newline at end of file +if Constants.MODE == "production": + client = MongoClient(uri, server_api=ServerApi('1'), tlsCAFile=certifi.where()) +else: + client = MongoClient(uri) + +mongo_database = client["index-cooking"] diff --git a/scripts/recipes/handle_recipes.py b/scripts/recipes/handle_recipes.py index 22ba0a4..73d19be 100644 --- a/scripts/recipes/handle_recipes.py +++ b/scripts/recipes/handle_recipes.py @@ -1,10 +1,12 @@ from bson.objectid import ObjectId +from flask import session from scripts.database.mongo import mongo_database from scripts.scraping.scraper import scrape mongo_collection = mongo_database.get_collection("recipes") def add_single_recipe(recipe): + recipe["user"] = session["username"] added_recipe = mongo_collection.insert_one(recipe) new_recipe = mongo_collection.find_one() return added_recipe diff --git a/scripts/scraping/ai_scraping.py b/scripts/scraping/ai_scraping.py new file mode 100644 index 0000000..b11a12b --- /dev/null +++ b/scripts/scraping/ai_scraping.py @@ -0,0 +1,19 @@ +from google import genai +import json +import requests +import Constants +import AIParams + +client = genai.Client(api_key="AIzaSyAdB7yo0qcnwHeC4T2rRaSXD588JRw94oQ") + +def run_ai_query(url): + req_url = f"https://r.jina.ai/{url}" + res = requests.get(req_url) + markdown_content = res.text + + prompt = AIParams.PROMPT.format(schema=AIParams.RECIPE_SCHEMA, markdown=markdown_content) + + ai_res = client.models.generate_content(model="gemini-2.0-flash", contents=prompt) + cleaned_text = ai_res.text.strip("```").strip("```json") + recipe_json = json.loads(cleaned_text) + return {"success": True, "data": recipe_json} diff --git a/scripts/scraping/scraper.py b/scripts/scraping/scraper.py index 8919d46..0e0b9c8 100644 --- a/scripts/scraping/scraper.py +++ b/scripts/scraping/scraper.py @@ -25,7 +25,7 @@ def extractInstructions(instructions): return returnedInstructions -def scrape(url, user_name): +def scrape(url): try: data = requests.get(url, headers= {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}) html = BeautifulSoup(data.text, 'html.parser') @@ -43,7 +43,6 @@ def scrape(url, user_name): slug = parse.quote(i["name"]).lower() # The recipe - recipe["user"] = user_name recipe["slug"] = slug recipe["title"] = i["name"] recipe["image"] = i["image"][0] diff --git a/scripts/users/handle_users.py b/scripts/users/handle_users.py index 250c716..7a315f1 100644 --- a/scripts/users/handle_users.py +++ b/scripts/users/handle_users.py @@ -23,4 +23,12 @@ def delete_single_user(user): mongo_collection.delete_one({"name": user}) return {"success": True, "user": user} except: - return {"success": False, "user": user}
\ No newline at end of file + return {"success": False, "user": user} + +def update_user(user, user_prefs): + print(user, user_prefs, "in user handler") + try: + res = mongo_collection.update_one({"name": user}, {"$set": user_prefs}) + return {"success": True, "message": "user preferences successfully updated"} + except Exception as e: + return {"success": False, "message": str(e)} @@ -3,9 +3,11 @@ from flask_bcrypt import Bcrypt import functools import re import Constants +from urllib.parse import urlparse from scripts.scraping.scraper import scrape +from scripts.scraping.ai_scraping import run_ai_query from scripts.recipes.handle_recipes import recipe_exists, add_single_recipe, get_all_recipes, get_single_recipe, get_facets, delete_single_recipe, search_recipes, delete_multiple_recipes -from scripts.users.handle_users import user_exists, add_user, delete_single_user +from scripts.users.handle_users import user_exists, add_user, delete_single_user, update_user app = Flask(__name__) bcrypt = Bcrypt(app) @@ -25,7 +27,6 @@ def login_required(func): @app.route("/home") @login_required def home_page(): - print(session["username"]) tags = get_facets(session["username"]) all_recipes = get_all_recipes(session["username"]) return render_template("/pages/home.html",recipes=all_recipes, facets=tags) @@ -79,15 +80,22 @@ def add_recipe(): if does_recipe_exist: return "<span class='error'>Recipe already exists!</span>", 422 else: - response = scrape(submitted_url, session["username"]) + response = scrape(submitted_url) if response["success"]: add_single_recipe(response["data"]) all_recipes = get_all_recipes(session["username"]) all_facets = get_facets(session["username"]) return render_template("/components/app.html", recipes=all_recipes, facets=all_facets) + + elif not response["success"]: + ai_res = run_ai_query(submitted_url) + add_single_recipe(ai_res["data"]) + all_recipes = get_all_recipes(session["username"]) + all_facets = get_facets(session["username"]) + return render_template("/components/app.html", recipes=all_recipes, facets=all_facets) else: - return f"<span class='error'>Error processing recipe metadata, {response['error']} </span>", 422 + return "something went wrong", 400 @app.post("/recipes/search") @@ -168,6 +176,18 @@ def delete_user(): return Response(headers={"HX-Redirect": "/"}) else: return "Something went wrong deleting the user and recipes" + +@app.post("/update-account") +def update_account(): + user_prefs = request.form.to_dict() + response = update_user(session["username"], user_prefs) + + if response["success"]: + session["isAiSubscriber"] = True + return Response(headers={"HX-Redirect": "/account"}) + else: + return f"<span class='error'>{response["message"]}</span>", 422 + if __name__ == "__main__": app.run(host="0.0.0.0") diff --git a/static/images/bars.svg b/static/images/bars.svg index 64dd5a8..8ce0f06 100644 --- a/static/images/bars.svg +++ b/static/images/bars.svg @@ -1,4 +1,4 @@ -<svg width="16" height="16" viewBox="0 0 135 140" xmlns="http://www.w3.org/2000/svg" fill="#fff"> +<svg width="16" height="16" viewBox="0 0 135 140" xmlns="http://www.w3.org/2000/svg" fill="#1d1f27"> <rect y="10" width="15" height="120" rx="6"> <animate attributeName="height" begin="0.5s" dur="1s" diff --git a/static/scripts/index.js b/static/scripts/index.js index 65c3c12..b1b006d 100644 --- a/static/scripts/index.js +++ b/static/scripts/index.js @@ -1,11 +1,50 @@ -const modal = document.querySelector("dialog"); -const showModalBtn = document.querySelector("#show-modal"); -const closeModalBtn = document.querySelector("#close-modal"); +// const modal = document.querySelector("dialog"); +const showModalBtns = document.querySelectorAll(".show-modal"); +const closeModalBtns = document.querySelectorAll(".close-modal"); +const clipboard = document.querySelector("#copy-ingredients"); -showModalBtn.addEventListener("click", () => { - modal.showModal(); -}); +if(clipboard) { + clipboard.addEventListener("click", () => { + + // Copy data to clipboard + const ingredientsContent = document.querySelector("#ingredients").textContent.trim(); + const contentArr = ingredientsContent.split("\n"); + const ingredientsString = contentArr.map((el) => { + if(el.trim() == "") return; + else return el.trim(); + }).join("\n") + navigator.clipboard.writeText(ingredientsString); + + // Show success message momentarily + clipboard.parentElement.insertAdjacentHTML("afterend", "<span id='copied-message'> ingredients copied!</span>"); + + setTimeout(() => { + document.querySelector("#copied-message").remove(); + }, 2000) + }) +} + + +// Open and close modals on /account page +if(showModalBtns){ + showModalBtns.forEach((btn) => { + btn.addEventListener("click", () => { + + // Open dialog box + btn.nextElementSibling.showModal(); + + }) + }) +} + +if(closeModalBtns){ + closeModalBtns.forEach((btn) => { + btn.addEventListener("click", () => { + + // Open dialog box + btn.parentNode.close(); + + }) + }) +} -closeModalBtn.addEventListener("click", () => { - modal.close(); -}); diff --git a/static/style/style.css b/static/style/style.css index 748e8dd..9f92d77 100644 --- a/static/style/style.css +++ b/static/style/style.css @@ -16,6 +16,14 @@ color: var(--success); } +.cursor { + cursor: pointer; +} + +.inline-block { + display: inline-block; +} + body, html { min-height: 100%; diff --git a/templates/base.html b/templates/base.html index 523e7ac..3181a2f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -15,7 +15,6 @@ /> </head> <body hx-ext="response-targets"> - <h1>Dickhead</h1> {% include "/components/header.html" %} <h1>{% block heading %}Some heading!{% endblock %}</h1> {% block content %}{% endblock %} diff --git a/templates/pages/about.html b/templates/pages/about.html index d719941..7aefc54 100644 --- a/templates/pages/about.html +++ b/templates/pages/about.html @@ -17,4 +17,13 @@ heading %} Yoast SEO Schema. If you have other websites that you would like us to index, then reach out <a href="/contact">here.</a> </p> + +<h2>Instructions</h2> + +<p>Using the website is very simple. Once you login go to <a href="/home>recipes</a>, this is your homepage. In the input box add URLs for your favourite recipes.</p> + +<p>URLs must be valid. E.g:</p> + +<p><strong class="error">bad: </strong>www.example.com/awesome-recipe</p> +<p><strong class="success">good: </strong>https://www.example.com/awesome-recipes</p> {% endblock %} diff --git a/templates/pages/account.html b/templates/pages/account.html index 10a3789..063d924 100644 --- a/templates/pages/account.html +++ b/templates/pages/account.html @@ -1,6 +1,4 @@ {% extends "base.html" %} {% block scripts %} - -<!-- Conditionally load index.js, as it's only needed on this page --> <script src="{{ url_for('static', filename='scripts/index.js') }}" defer @@ -28,4 +26,23 @@ <button hx-delete="/delete-account">yes</button> <button id="close-modal">cancel</button> </dialog> + +<h2>AI mode</h2> +{% if session.isAiSubscriber %} + <p>Thanks for actvating AI mode. You can check your quota of AI requests remaining below</p> +{% else %} + <p>The HTML markup for recipe websites differs greatly. Therefore, it's hard to extract all the HTML from all websites. You may find there are certain websites that we cannot extract recipe data from. To get around this, you have the option to use <strong>AI mode</strong>.</p> + <p>AI mode works by running the page's HTML through an LLM to extract the relevant recipe information.</p> + <p>As this process is not cheap, you can buy credits that will allow you to perform these extraction operations. $5 will get you 1,000 AI operations. Probably more than you will ever need</p> + <p>We will always try and extract website data using normal metadata before using AI, so it's unlikely you will get close to 1,000 operations.</p> + <button id="show-modal">Enable AI mode</button> + <dialog> + <p> + Are you sure you want to delete your account? This will erase all your data + including your recipes. + </p> + <button hx-delete="/delete-account">yes</button> + <button id="close-modal">cancel</button> + </dialog> + {% endif %} {% endblock %} diff --git a/templates/pages/home.html b/templates/pages/home.html index c44a017..6cb5935 100644 --- a/templates/pages/home.html +++ b/templates/pages/home.html @@ -11,7 +11,7 @@ hx-target-5*="#form-error" > <div style="display: flex; align-items: center; margin-bottom: 15px;"}> - <input type="text" name="url" class="border-2 border-black" style="margin-bottom: 0px; margin-right: 10px;"/> + <input type="url" name="url" class="border-2 border-black" style="margin-bottom: 0px; margin-right: 10px;"/> <img src={{url_for("static", filename="images/bars.svg")}} class="htmx-indicator" id="spinner" alt="loading indicator "/> </div> <button type="submit" class="cursor-pointer">Submit</button> diff --git a/templates/pages/single-recipe.html b/templates/pages/single-recipe.html index d8c334d..ec2b4dd 100644 --- a/templates/pages/single-recipe.html +++ b/templates/pages/single-recipe.html @@ -1,12 +1,20 @@ -{% extends "base.html" %} {% block heading %} {{ single_recipe.title }} {% +{% extends "base.html" %} + +{% block scripts %} + +<script src={{ url_for("static", filename="scripts/index.js") }} defer></script> + +{% endblock %} + +{% block heading %} {{ single_recipe.title }} {% endblock %} {% block content %} <article> <figure> <img src="{{ single_recipe.image }}" alt="{{ single_recipe.title }}" /> <figcaption><a href="{{ single_recipe.url }}">Source</a></figcaption> </figure> - <h2>Ingredients</h2> - <ul> + <h2 class="inline-block">Ingredients<span id="copy-ingredients" class="cursor">📋</span></h2> + <ul id="ingredients"> {% for ingredient in single_recipe.ingredients %} <li>{{ ingredient }}</li> {% endfor %} |