From dd2c15ba67cdfe458e8d0ecd22806d521d8a5689 Mon Sep 17 00:00:00 2001 From: Rasmus Date: Mon, 29 Dec 2025 06:19:31 +0100 Subject: [PATCH] moved to server Update readme.md Signed-off-by: rasmus Update readme.md Update Dockerfile Update Dockerfile --- .Dockerignore | 6 + .gitea/workflows/deploy.yaml | 29 +++++ .gitignore | 4 + Dockerfile | 21 ++++ app.py | 207 +++++++++++++++++++++++++++++++++++ compose.yml | 16 +++ readme.md | 58 ++++++++++ requirements.txt | Bin 0 -> 1050 bytes system_prompt.txt | 51 +++++++++ templates/base.html | 87 +++++++++++++++ templates/dashboard.html | 204 ++++++++++++++++++++++++++++++++++ templates/index.html | 111 +++++++++++++++++++ templates/login.html | 18 +++ templates/plan_result.html | 144 ++++++++++++++++++++++++ templates/register.html | 66 +++++++++++ 15 files changed, 1022 insertions(+) create mode 100644 .Dockerignore create mode 100644 .gitea/workflows/deploy.yaml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app.py create mode 100644 compose.yml create mode 100644 readme.md create mode 100644 requirements.txt create mode 100644 system_prompt.txt create mode 100644 templates/base.html create mode 100644 templates/dashboard.html create mode 100644 templates/index.html create mode 100644 templates/login.html create mode 100644 templates/plan_result.html create mode 100644 templates/register.html diff --git a/.Dockerignore b/.Dockerignore new file mode 100644 index 0000000..833d80e --- /dev/null +++ b/.Dockerignore @@ -0,0 +1,6 @@ +venv/ +__pycache__/ +*.pyc +.env +instance/ +.git/ \ No newline at end of file diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..5a0d734 --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,29 @@ +name: Build and Push Madplaner +run-name: ${{ gitea.actor }} building image + +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Login to Gitea Registry + uses: docker/login-action@v3 + with: + registry: git.rasmusbendtsen.dk + username: ${{ gitea.actor }} + password: ${{ secrets.GITEATOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + git.rasmusbendtsen.dk/rasmus/madplaner:latest \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe68159 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +venv/ +.env +*.db +.github/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..de1f68d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 5000 + +# Initialize DB, then start Gunicorn +CMD python -c "from app import app, db; app.app_context().push(); db.create_all()" && \ + gunicorn --bind 0.0.0.0:5000 app:app \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..21567e5 --- /dev/null +++ b/app.py @@ -0,0 +1,207 @@ +import os +from dotenv import load_dotenv +from flask import Flask, render_template, redirect, url_for, request, flash +from flask_sqlalchemy import SQLAlchemy +from flask_login import ( + LoginManager, + UserMixin, + login_user, + login_required, + logout_user, + current_user, +) +from werkzeug.security import generate_password_hash, check_password_hash +from openai import OpenAI + +load_dotenv() + +OPENROUTER_API_KEY=os.environ.get("OPENROUTER_API_KEY") +OPENROUTER_MODEL=os.environ.get("OPENROUTER_MODEL") + +app = Flask(__name__) +app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY") +app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///mealplan.db" + +db = SQLAlchemy(app) +login_manager = LoginManager(app) +login_manager.login_view = "login" + +# Anvender Open-router +client = OpenAI( + base_url="https://openrouter.ai/api/v1", + api_key=OPENROUTER_API_KEY, + default_headers={ + "HTTP-Referer": "http://localhost:5000", + "X-Title": "ChefGPT Meal Planner", + }, +) + + +# --- Models --- +class User(UserMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(150), unique=True, nullable=False) + password = db.Column(db.String(150), nullable=False) + plans = db.relationship("MealPlan", backref="creator", lazy=True) + + +class MealPlan(db.Model): + id = db.Column(db.Integer, primary_key=True) + summary = db.Column(db.Text, nullable=False) + shopping = db.Column(db.Text, nullable=False) + recipes = db.Column(db.Text, nullable=False) + description = db.Column(db.Text, nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) + + + +@app.route("/") +def index(): + return render_template("index.html",source_link=os.environ.get("SOURCECODE_LINK")) + +@app.route("/about") +def about(): + return render_template("about.html") + +@app.route("/logout") +@login_required +def logout(): + logout_user() + return redirect(url_for("index")) + + +@app.route("/dashboard") +@login_required +def dashboard(): + # Show the user's previous plans + plans = ( + MealPlan.query.filter_by(user_id=current_user.id) + .order_by(MealPlan.id.desc()) + .limit(3) + .all() + ) + return render_template("dashboard.html", plans=plans) + + +@app.route("/plan/") +@login_required +def view_plan(plan_id): + plan = MealPlan.query.get_or_404(plan_id) + if plan.user_id != current_user.id: + flash("You do not have permission to view this plan.") + return redirect(url_for("dashboard")) + + return render_template("plan_result.html", plan=plan) + + +@app.route("/generate", methods=["POST"]) +@login_required +def generate(): + data = { + "people_count": request.form.get("people_count"), + "budget_level": request.form.get("budget_level"), + "max_cooking_time": request.form.get("max_cooking_time"), + "skill_level": request.form.get("skill_level"), + "dietary_focus": request.form.get("dietary_focus"), + "dietary_preference": request.form.get("dietary_preference"), + "meal_strategy": request.form.get("meal_strategy"), + "fridge_items": request.form.get("fridge_items") or "None (start from scratch)", + "output_language": request.form.get("output_language", "English"), + } + + try: + with open("system_prompt.txt", "r", encoding="utf-8") as f: + prompt_template = f.read() + + # indsætter formular parametre i systemprompt + final_prompt = prompt_template.format(**data) + except FileNotFoundError: + flash("System prompt file missing. Please contact admin.", "error") + return redirect(url_for("dashboard")) + + # Anvender open-router free modeller + try: + response = client.chat.completions.create( + model=OPENROUTER_MODEL, + messages=[{"role": "system", "content": final_prompt}], + temperature=0.7, + ) + ai_output = response.choices[0].message.content + + except Exception as e: + flash(f"AI Service Error: {str(e)}", "error") + return redirect(url_for("dashboard")) + + # begynder parsing af AI svaret. Svaret skal leve op til formatkravet for at det kan indlæses pænt + def extract_section(text, start_tag, end_tag): + start = text.find(start_tag) + end = text.find(end_tag) + + if start == -1 or end == -1 or end <= start: + return None + + return text[start + len(start_tag) : end].strip() + + desc = extract_section(ai_output, "[[DESCRIPTION_START]]", "[[DESCRIPTION_END]]") + summ = extract_section(ai_output, "[[SUMMARY_START]]", "[[SUMMARY_END]]") + shop = extract_section(ai_output, "[[SHOPPING_START]]", "[[SHOPPING_END]]") + reci = extract_section(ai_output, "[[RECIPES_START]]", "[[RECIPES_END]]") + + if not all([desc, summ, shop, reci]): + flash("The plan could not be generated. Please try generating again.", "error") + return redirect(url_for("dashboard")) + # hvis AI svaret levede op til format kravet, gemmes det i db + try: + new_plan = MealPlan( + description=desc, + summary=summ, + shopping=shop, + recipes=reci, + user_id=current_user.id, + ) + db.session.add(new_plan) + db.session.commit() + + return redirect(url_for("view_plan", plan_id=new_plan.id)) + + except Exception as e: + db.session.rollback() + flash(f"Database Error: {str(e)}", "error") + return redirect(url_for("dashboard")) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + user = User.query.filter_by(username=request.form.get("username")).first() + if user and check_password_hash(user.password, request.form.get("password")): + login_user(user) + return redirect(url_for("dashboard")) + flash("Invalid credentials") + return render_template("login.html") + + +@app.route("/register", methods=["GET", "POST"]) +def register(): + if request.method == "POST": + new_user = User( + username=request.form.get("username"), + password=generate_password_hash( + request.form.get("password"), method="pbkdf2:sha256" + ), + ) + db.session.add(new_user) + db.session.commit() + return redirect(url_for("/")) + return render_template("register.html") + + +if __name__ == "__main__": + with app.app_context(): + db.create_all() + app.run() diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..079e2d5 --- /dev/null +++ b/compose.yml @@ -0,0 +1,16 @@ +services: + web: + image: git.rasmusbendtsen.dk/rasmus/madplaner:latest + container_name: madplaner + restart: always + ports: + - "${HOST_PORT:-80}:5000" + volumes: + # sqlite DB lives here + - ./instance:/app/instance + environment: + - FLASK_ENV=production + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} + - OPENROUTER_MODEL= ${OPENROUTER_MODEL} + - SECRET_KEY=${SECRET_KEY} + - SOURCECODE_LINK=${SOURCECODE_LINK} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..b0a6b5f --- /dev/null +++ b/readme.md @@ -0,0 +1,58 @@ +# [madplaner.rasmusbendtsen.dk](https://madplaner.rasmusbendtsen.dk) +## Introkduktion +Min kæreste og jeg var trætte af altid at spise spagetti kødsovs eller boller i karry. + +Vi forsøgte os med forskellige måltidskasserne, men sad hurtigt tilbage med en følelse af, at det aldrig rigtig var pengene værd. Især fordi vi primært anvendte som at måde at slippe for, altid at skulle tænke over "hvad skal spise de næste par og hvilke vare skal vi købe". Selve det at handle ind og madlavningen, er ikke et problem. Det handlede mest om planlægningen. Hvis det er eneste formål, så er måltidskasser en dyr løsning. + +Jeg anvendte ChatGPT til hurtigt at generer en indkøbsliste til fire retter. Det fungeret overraskende godt. Min kæreste sagde så, "Skal jeg så printe det ud, inden jeg går ud og handler" + + +![Michael Jordan meme](https://media1.tenor.com/m/B3_AR5dup94AAAAd/michael-jordan.gif) + + +Derfor lavet jeg denne løsning i stedet. + +Den fungere ved at brugeren udfylder nogle inputfelter, som flettes ind i systempromten til en LLM. Svaret fra sprogmodellen sættes så pænt og let tilgængeligt op. + +## Start din egen instans + +```bash +mkdir madplaner; \ +cd madplaner; \ +mkdir instance; \ +wget https://git.rasmusbendtsen.dk/rasmus/madplaner/raw/branch/main/compose.yml; \ +#Opret .env fil eller rediger direkte i compose fil og derefter +docker compose up -d +``` + +Docker compose +```yaml +services: + web: + image: git.rasmusbendtsen.dk/rasmus/madplaner:latest + container_name: madplaner + restart: always + ports: + - "${HOST_PORT:-80}:5000" + volumes: + # sqlite DB lives here + - ./instance:/app/instance + environment: + - FLASK_ENV=production + - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} + - OPENROUTER_MODEL= ${OPENROUTER_MODEL} + - SECRET_KEY=${SECRET_KEY} + # Anvendes hvis der skal vises link til klidekode på forsiden + - SOURCECODE_LINK=${SOURCECODE_LINK} +``` +## Backend +Backenden er lavet med Flask og SQlite. Sprogmodellen kaldes gennem Open-Router. Der bør tilføjes server side validering af inputfelterne for at forhindre prompt injection. + +Servicen kører fra en ældre kontor pc i min lejlighed i Valby. Den er gjort tilgængelig gennem en omvendt proxy på den billigeste VPS jeg kunne finde, ved brug af [Pangolin](https://github.com/fosrl/pangolin) +## Frontend +Frontenden er lavet med tailwindcss. Jeg har forsøgt at begrænse anvendelsen af javascript. Layout og UI er 100% vibecoded. +## Tilpasning +Ved at redigere i dashboard.html og system_prompt.txt. Kan løsningen justeres til andre formål. Eksempelvis +- Lav en træningsplan til mig +- Anbefal nogle film og tv-serier +- Hvilken X vil være bedst at købe, udfra mine behov \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..d14191976cf3e2630faed5ceb9045034e5da67b3 GIT binary patch literal 1050 zcmZ{jJx{}641_%+@l#rn&;lJ8SQroth=CD>G;R54QkoPMemwA;oumZ`MXwUY=kxjO z_xmfelBcr9GEQMr8}Rg3Lp!z9!hkz2 z^S6pivNPhr9lXP~?k+a4YqICwPgmi@bx6ZL?#^z(Ucj2$f)1BBq>PR^aZPj76g(xk z0&N{asPJ|{_etMPLNB{WL&gezbzL|{J!v)*XI~H(V%z7Z?poA-Vy57{?W6uMzz72OZu)-BL{-g|$8 zU)Y-e+FI>x?7n!yOU3`&fpAcDqh@1oR9bq4*jL@Hm`2RO@z8J&oL(AK)F8zW@LL literal 0 HcmV?d00001 diff --git a/system_prompt.txt b/system_prompt.txt new file mode 100644 index 0000000..22be266 --- /dev/null +++ b/system_prompt.txt @@ -0,0 +1,51 @@ +### LANGUAGE INSTRUCTION ### +- You must generate the entire response (Meal Plan, Matrix, Shopping List, and Recipes) in {output_language}. +- Use local culinary terms appropriate for {output_language} (e.g., if Danish, respond only in danish). + +### MAIN PRIORITY ### +You are a professional nutritionist and meal planner. You only speak in a structured format that a machine can parse. + +### CORE GOALS ### +1. Generate a 4-day DINNER-ONLY meal plan. Do not include breakfast or lunch. +2. The "Matrix" must show which ingredients from the shopping list are used in which dinners. Use 'X' for matches. The dishes are columns. The ingredients are the rows. +3. Recipes must be concise and step-by-step. +4. All recipes and shopping lists must use metric units + +### TECHNICAL FORMATTING (STRICT) ### +You must wrap your response in these exact tags. Do not add any text outside of them. + +[[DESCRIPTION_START]] +A 3-sentence summary of the plan. Highligt what makes this plan special, the key dishes and ingredients +[[DESCRIPTION_END]] + +[[SUMMARY_START]] +[Insert 4-day dinner-only meal plan table] + +### INGREDIENT USAGE MATRIX +| Ingredient | Day 1 | Day 2 | Day 3 | Day 4 | +| :--- | :---: | :---: | :---: | :---: | +| Ingredient Name | X | | X | X | + +(Ensure you use the exact Markdown table format above with pipes and alignment colons) +[[SUMMARY_END]] + +[[SHOPPING_START]] +[Consolidated Shopping List here] +[[SHOPPING_END]] + +[[RECIPES_START]] +[Simple Recipes here] +[[RECIPES_END]] + +#### PARAMETERS ### +- People: {people_count} +- Budget: {budget_level} +- Max Time: {max_cooking_time} minutes +- Cooking Skill: {skill_level} +- Dietary Focus: {dietary_focus} +- Diet Preference: {dietary_preference} +- Strategy: {meal_strategy} + +### FRIDGE CLEAR-OUT (HIGH PRIORITY) ### +- Use these ingredients already in the user's kitchen: {fridge_items} +- Instruction: Generate recipes using these items. \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..68444ad --- /dev/null +++ b/templates/base.html @@ -0,0 +1,87 @@ + + + + + + Madplaner + + + + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ {% block content %}{% endblock %} +
+ + + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..0c21630 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,204 @@ +{% extends "base.html" %} +{% block content %} +
+
+

+ Hej, {{ current_user.username }} +

+

Let’s design a menu for the days ahead.

+
+ +
+ +
+
+
+ + + +
+

Personal preferences

+
+ +
+
+
+ {% set fields = [ + ('people_count', 'Number of People', [('1 adult', '1 adult'), ('2 adults', '2 adults'), ('Family + of 4', 'Family of 4')]), + ('budget_level', 'Budget Level', [('Low', 'Low'), ('Medium', 'Medium'), ('High', 'High')]), + ('max_cooking_time', 'Max Cooking Time', [('20', '20 min'), ('30', '30 min'), ('45', '45 min'), + ('60', '60 min')]), + ('skill_level', 'Cooking Skill', [('Beginner (simple steps)', 'Beginner'), ('Intermediate + (comfortable with techniques)', 'Intermediate'), ('Expert (advanced methods)', 'Advanced')]), + ('dietary_preference', 'Dietary Preference', [('Standard (Omnivore)', 'Standard'), + ('Vegetarian', 'Vegetarian'), ('Vegan', 'Vegan'), ('Pescatarian', 'Pescatarian')]), + ('dietary_focus', 'Dietary Focus', [('Balanced', 'Balanced'), ('High Protein', 'High Protein'), + ('Low Carb', 'Low Carb'), ('Kid-Friendly', 'Kid-Friendly')]) + ] %} + + {% for name, label, options in fields %} +
+ + +
+ {% endfor %} +
+ +
+
+ + +
+
+ + +
+
+ +
+ + + +
+
+
+ +
+
+
+ +
+
+
+ + + +
+

Recent Activity

+
+ +
+ {% for plan in plans %} +
+

+ {{ plan.description }}

+
+ Plan #{{ plan.id + }} + + View Plan + +
+
+ {% else %} +
+

Your culinary history will appear here.

+
+ {% endfor %} +
+
+
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..fd8e385 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,111 @@ +{% extends "base.html" %} +{% block content %} +
+
+ + +

+ Dinner, simplified
+ by design. +

+ +

+ A minimalist approach to meal planning. Clear out your fridge, reduce waste, and regain your evenings with AI-tailored dinner plans. +

+ +
+ {% if current_user.is_authenticated %} + + Open Dashboard + + + + + {% else %} + + {% endif %} +
+
+ +
+
+
+ + + +
+

Purely Personal

+

+ Whether you're Keto, Vegan, want kid-friendly meals or gourmet meals, every recipe is filtered for your unique needs. +

+
+
+
+ + + +
+

Zero Waste

+

+ Our "Fridge Clear-out" logic prioritizes what you already have, reducing food waste and grocery bills. +

+
+ +
+
+ + + +
+

Smart Matrix

+

+ Instantly visualize how ingredients are reused across your week with our automated usage matrix. +

+
+ +
+
+ + + +
+

Time-Conscious

+

+ From 20-minute rapid meals to gourmet weekend dinners, we plan around your schedule. +

+
+ +
+ +
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..8bdf2e5 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block content %} +
+

Welcome Back

+
+
+ + +
+
+ + +
+ +
+

Don't have an account? Sign up

+
+{% endblock %} \ No newline at end of file diff --git a/templates/plan_result.html b/templates/plan_result.html new file mode 100644 index 0000000..2d86567 --- /dev/null +++ b/templates/plan_result.html @@ -0,0 +1,144 @@ +{% extends "base.html" %} +{% block content %} +
+
+ + Dashboard + +
+ +
+
+ +
+ + + +
+ +
+
+ {{ plan.summary | safe }} +
+ + + + +
+
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..05e073b --- /dev/null +++ b/templates/register.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Opret bruger

+

Få madplaner tilpasset dine ønsker i dag.

+
+ + {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} + {% endwith %} + +
+
+ + +
+ +
+ + +
+ +
+ + + +
+ + +
+ +
+

+ Already have an account? + Log in +

+
+
+ + +{% endblock %} \ No newline at end of file