From 8d804318509b0f054b5ed7eba2af4f34d2101d79 Mon Sep 17 00:00:00 2001 From: sstent Date: Sun, 5 Oct 2025 06:22:14 -0700 Subject: [PATCH] added LLM data extractiondocker compose up --build -d --force-recreate; docker compose logs -f --- alembic.ini | 2 +- ...3_add_browserless_api_key_to_llm_config.py | 32 ++ .../882af51512ff_add_llm_configs_table.py | 49 +++ app/api/routes/__init__.py | 26 ++ app/api/routes/admin.py | 58 ++++ app/api/routes/llm.py | 167 ++++++++++ app/core/config.py | 25 +- app/database.py | 3 + app/models/llm_config.py | 10 + logging.ini | 21 ++ main.py | 27 +- playwright-report/index.html | 2 +- requirements.txt | 4 + templates/admin/index.html | 3 + templates/admin/llm_config.html | 38 +++ templates/base.html | 5 + templates/llm_food_extractor.html | 293 ++++++++++++++++++ test-results/.last-run.json | 6 +- tests/6_llm_extraction.spec.js | 190 ++++++++++++ 19 files changed, 937 insertions(+), 24 deletions(-) create mode 100644 alembic/versions/4522e2de4143_add_browserless_api_key_to_llm_config.py create mode 100644 alembic/versions/882af51512ff_add_llm_configs_table.py create mode 100644 app/api/routes/llm.py create mode 100644 app/models/llm_config.py create mode 100644 logging.ini create mode 100644 templates/admin/llm_config.html create mode 100644 templates/llm_food_extractor.html create mode 100644 tests/6_llm_extraction.spec.js diff --git a/alembic.ini b/alembic.ini index 05e8a7d..676fa77 100644 --- a/alembic.ini +++ b/alembic.ini @@ -60,7 +60,7 @@ version_path_separator = os # Use os.pathsep. Default configuration used for ne # are written from script.py.mako # output_encoding = utf-8 -sqlalchemy.url = sqlite:////app/meal_planner.db +sqlalchemy.url = sqlite:////app/data/meal_planner.db [post_write_hooks] diff --git a/alembic/versions/4522e2de4143_add_browserless_api_key_to_llm_config.py b/alembic/versions/4522e2de4143_add_browserless_api_key_to_llm_config.py new file mode 100644 index 0000000..e6dc142 --- /dev/null +++ b/alembic/versions/4522e2de4143_add_browserless_api_key_to_llm_config.py @@ -0,0 +1,32 @@ +"""add browserless api key to llm config + +Revision ID: 4522e2de4143 +Revises: 882af51512ff +Create Date: 2025-10-04 15:06:27.893934 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4522e2de4143' +down_revision: Union[str, None] = '882af51512ff' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('llm_configs', schema=None) as batch_op: + batch_op.add_column(sa.Column('browserless_api_key', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('llm_configs', schema=None) as batch_op: + batch_op.drop_column('browserless_api_key') + # ### end Alembic commands ### diff --git a/alembic/versions/882af51512ff_add_llm_configs_table.py b/alembic/versions/882af51512ff_add_llm_configs_table.py new file mode 100644 index 0000000..eb5ccee --- /dev/null +++ b/alembic/versions/882af51512ff_add_llm_configs_table.py @@ -0,0 +1,49 @@ +"""Add llm_configs table + +Revision ID: 882af51512ff +Revises: 2498205b9e48 +Create Date: 2025-10-04 13:13:07.498772 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '882af51512ff' +down_revision: Union[str, None] = '2498205b9e48' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('llm_configs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('openrouter_api_key', sa.String(), nullable=True), + sa.Column('preferred_model', sa.String(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_llm_configs_id'), 'llm_configs', ['id'], unique=False) + op.drop_table('_litestream_seq') + op.drop_table('_litestream_lock') + op.drop_column('tracked_meals', 'quantity') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('tracked_meals', sa.Column('quantity', sa.FLOAT(), nullable=True)) + op.create_table('_litestream_lock', + sa.Column('id', sa.INTEGER(), nullable=True) + ) + op.create_table('_litestream_seq', + sa.Column('id', sa.INTEGER(), nullable=True), + sa.Column('seq', sa.INTEGER(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.drop_index(op.f('ix_llm_configs_id'), table_name='llm_configs') + op.drop_table('llm_configs') + # ### end Alembic commands ### diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py index e69de29..c64c6c8 100644 --- a/app/api/routes/__init__.py +++ b/app/api/routes/__init__.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter + +from app.api.routes import ( + admin, + charts, + export, + foods, + llm, + meals, + plans, + templates, + tracker, + weekly_menu, +) + +api_router = APIRouter() +api_router.include_router(tracker.router, tags=["tracker"]) +api_router.include_router(foods.router, tags=["foods"]) +api_router.include_router(meals.router, tags=["meals"]) +api_router.include_router(templates.router, tags=["templates"]) +api_router.include_router(charts.router, tags=["charts"]) +api_router.include_router(admin.router, tags=["admin"]) +api_router.include_router(weekly_menu.router, tags=["weekly_menu"]) +api_router.include_router(plans.router, tags=["plans"]) +api_router.include_router(export.router, tags=["export"]) +api_router.include_router(llm.router, tags=["llm"]) \ No newline at end of file diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index 7a6ef53..1b59776 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -6,13 +6,21 @@ import shutil import sqlite3 import logging from datetime import datetime +from typing import Optional # Import from the database module from app.database import get_db, DATABASE_URL, engine from main import templates +from app.models.llm_config import LLMConfig +from pydantic import BaseModel router = APIRouter() +class LLMConfigUpdate(BaseModel): + openrouter_api_key: Optional[str] = None + preferred_model: str + browserless_api_key: Optional[str] = None + def backup_database(source_db_path, backup_db_path): """Backs up an SQLite database using the online backup API.""" logging.info(f"DEBUG: Starting backup - source: {source_db_path}, backup: {backup_db_path}") @@ -81,6 +89,56 @@ async def admin_page(request: Request): async def admin_imports_page(request: Request): return templates.TemplateResponse(request, "admin/imports.html", {"request": request}) +@router.get("/admin/llm_config", response_class=HTMLResponse) +async def admin_llm_config_page(request: Request, db: Session = Depends(get_db)): + logging.info("DEBUG: Starting llm_config route") + try: + llm_config = db.query(LLMConfig).first() + logging.info(f"DEBUG: LLMConfig query result: {llm_config}") + if not llm_config: + logging.info("DEBUG: No LLMConfig found, creating new one") + llm_config = LLMConfig() + db.add(llm_config) + db.commit() + db.refresh(llm_config) + logging.info(f"DEBUG: Created new LLMConfig: {llm_config}") + logging.info(f"DEBUG: Final llm_config object: {llm_config}") + + logging.info("DEBUG: About to render llm_config.html template") + response = templates.TemplateResponse( + request, + "admin/llm_config.html", + {"request": request, "llm_config": llm_config} + ) + logging.info("DEBUG: Template rendered successfully") + return response + except Exception as e: + logging.error(f"DEBUG: Error in llm_config route: {e}", exc_info=True) + raise + +@router.post("/admin/llm_config", response_class=RedirectResponse) +async def update_llm_config( + request: Request, + openrouter_api_key: Optional[str] = Form(None), + preferred_model: str = Form(...), + browserless_api_key: Optional[str] = Form(None), + db: Session = Depends(get_db) +): + llm_config = db.query(LLMConfig).first() + if not llm_config: + llm_config = LLMConfig() + db.add(llm_config) + db.commit() + db.refresh(llm_config) + + llm_config.openrouter_api_key = openrouter_api_key + llm_config.preferred_model = preferred_model + llm_config.browserless_api_key = browserless_api_key + db.commit() + db.refresh(llm_config) + + return RedirectResponse(url="/admin/llm_config", status_code=303) + @router.get("/admin/backups", response_class=HTMLResponse) async def admin_backups_page(request: Request): BACKUP_DIR = "./backups" diff --git a/app/api/routes/llm.py b/app/api/routes/llm.py new file mode 100644 index 0000000..be13717 --- /dev/null +++ b/app/api/routes/llm.py @@ -0,0 +1,167 @@ +import base64 +import json +import logging +import os +from logging.config import fileConfig +from typing import Optional + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, Form +from fastapi.responses import HTMLResponse +from openai import OpenAI +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.core.config import templates +from app.database import get_db +from app.models.llm_config import LLMConfig + +router = APIRouter() + +@router.get("/llm", response_class=HTMLResponse, include_in_schema=False) +async def llm_food_extractor_page(request: Request): + return templates.TemplateResponse("llm_food_extractor.html", {"request": request}) + +class FoodItem(BaseModel): + name: Optional[str] = Field(None, description="Name of the food item") + brand: Optional[str] = Field(None, description="Brand name of the food item") + serving_size_g: float = Field(description="Actual serving size in grams as labeled on the page") + calories: Optional[int] = Field(None, description="Calories per actual serving") + protein_g: Optional[float] = Field(None, description="Protein in grams per actual serving") + carbohydrate_g: Optional[float] = Field(None, description="Carbohydrates in grams per actual serving") + fat_g: Optional[float] = Field(None, description="Fat in grams per actual serving") + fiber_g: Optional[float] = Field(None, description="Fiber in grams per actual serving") + sugar_g: Optional[float] = Field(None, description="Sugar in grams per actual serving") + sodium_mg: Optional[int] = Field(None, description="Sodium in milligrams per actual serving") + calcium_mg: Optional[int] = Field(None, description="Calcium in milligrams per actual serving") + potassium_mg: Optional[int] = Field(None, description="Potassium in milligrams per actual serving") + cholesterol_mg: Optional[int] = Field(None, description="Cholesterol in milligrams per actual serving") + +@router.post("/llm/extract", response_model=FoodItem) +async def extract_food_data_from_llm( + request: Request, + url: Optional[str] = Form(None), + webpage_url: Optional[str] = Form(None), + image: Optional[UploadFile] = File(None), + db: Session = Depends(get_db) +): + logging.info("Starting food data extraction from LLM.") + llm_config = db.query(LLMConfig).first() + if not llm_config or not llm_config.openrouter_api_key: + logging.error("OpenRouter API key not configured.") + raise HTTPException( + status_code=500, + detail="OpenRouter API key not configured. Please configure it in the Admin section." + ) + if not llm_config.browserless_api_key: + logging.error("Browserless API key not configured.") + raise HTTPException( + status_code=500, + detail="Browserless API key not configured. Please configure it in the Admin section." + ) + logging.info(f"LLM config loaded: preferred_model={llm_config.preferred_model}") + + client = OpenAI( + base_url="https://openrouter.ai/api/v1", + api_key=llm_config.openrouter_api_key + ) + + # LLM prompt for extracting nutrition data from webpage content or images. + # Units: serving_size_g in grams; nutrition values per actual serving size (not normalized to 100g). + # All nutrition fields are in grams except sodium_mg, calcium_mg, potassium_mg, cholesterol_mg in milligrams. + prompt = """You are a nutrition data extractor. Your task is to analyze the provided information (image or website content) and extract the nutritional information for the food item. The output must be a single JSON object that conforms to the following schema. All nutritional values should be for the actual serving size as labeled on the page (e.g., if the page says "per 1 cup (240g)", use values for 240g serving). + + JSON Schema: + { + "name": "string", + "brand": "string", + "serving_size_g": "float", + "calories": "integer", + "protein_g": "float", + "carbohydrate_g": "float", + "fat_g": "float", + "fiber_g": "float", + "sugar_g": "float", + "sodium_mg": "integer", + "calcium_mg": "integer", + "potassium_mg": "integer", + "cholesterol_mg": "integer" + } + + The food name is usually the most prominent header or title on the page. Brand is the manufacturer or brand name if available. serving_size_g should be the actual grams for the serving size shown (e.g., 240 for 1 cup). If the food name is not available, set it to "unknown". If any of the nutritional values are not available, set them to null. Do not include any text or explanations outside of the JSON object in your response. + """ + messages = [{"role": "system", "content": prompt}] + + content = [] + if url: + logging.info(f"Processing image from URL: {url}") + content.append({"type": "image_url", "image_url": {"url": url}}) + elif webpage_url: + logging.info(f"Processing content from webpage URL: {webpage_url}") + try: + async with httpx.AsyncClient() as client: + browserless_url = f"https://production-sfo.browserless.io/content?token={llm_config.browserless_api_key}" + headers = { + "Cache-Control": "no-cache", + "Content-Type": "application/json" + } + payload = {"url": webpage_url} + logging.info(f"Fetching content from Browserless API (POST): {browserless_url} with payload url={webpage_url}") + response = await client.post(browserless_url, headers=headers, json=payload, timeout=30.0) + logging.info(f"Browserless response status={response.status_code}, content_length={len(response.text) if response and response.text is not None else 0}") + response.raise_for_status() + content.append({"type": "text", "text": f"Extract nutritional data from this webpage content: {response.text}"}) + logging.info("Successfully fetched webpage content.") + except httpx.HTTPStatusError as e: + status = e.response.status_code if getattr(e, "response", None) is not None else "unknown" + body = e.response.text if getattr(e, "response", None) is not None else "" + logging.error(f"Browserless HTTP error status={status}, body_snippet={body[:500]}", exc_info=True) + raise HTTPException(status_code=400, detail=f"Browserless HTTP {status}: unable to fetch webpage content") + except httpx.HTTPError as e: + logging.error(f"HTTP client error while fetching webpage content: {e}", exc_info=True) + raise HTTPException(status_code=400, detail=f"Could not fetch webpage content: {e}") + elif image: + logging.info(f"Processing uploaded image: {image.filename}") + image_data = await image.read() + base64_image = base64.b64encode(image_data).decode("utf-8") + content.append({ + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{base64_image}"} + }) + logging.info("Successfully processed uploaded image.") + else: + logging.error("No input provided. Either a URL, a webpage URL, or an image is required.") + raise HTTPException(status_code=400, detail="Either a URL, a webpage URL, or an image must be provided.") + + messages.append({"role": "user", "content": content}) + logging.info(f"LLM prompt: {messages}") + try: + os.makedirs("/app/data", exist_ok=True) + with open("/app/data/llmprompt.txt", "wt") as file: + file.write(json.dumps(messages, indent=2)) + logging.info("Wrote LLM prompt to /app/data/llmprompt.txt") + except Exception as e: + logging.warning(f"Could not write LLM prompt file: {e}", exc_info=True) + + try: + openai_client = OpenAI( + base_url="https://openrouter.ai/api/v1", + api_key=llm_config.openrouter_api_key + ) + logging.info(f"Sending request to LLM with model: {llm_config.preferred_model}") + response = openai_client.chat.completions.create( + model=llm_config.preferred_model, + messages=messages, + response_format={"type": "json_object"} + ) + food_data_str = response.choices[0].message.content + logging.info(f"LLM response: {food_data_str}") + food_data = json.loads(food_data_str) + logging.info("Successfully parsed LLM response.") + # Debug logs for serving size: trace actual serving_size_g from LLM, no rescaling applied + serving_size_g = food_data.get('serving_size_g') + logging.info(f"Extracted serving_size_g: {serving_size_g}g (actual serving size, no normalization to 100g)") + return FoodItem(**food_data) + except Exception as e: + logging.error(f"Error during LLM data extraction: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error extracting food data: {e}") \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py index 957e036..72596aa 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,3 +1,24 @@ -from fastapi.templating import Jinja2Templates +from functools import lru_cache +from typing import Optional -templates = Jinja2Templates(directory="templates") \ No newline at end of file +from fastapi.templating import Jinja2Templates +from pydantic_settings import BaseSettings, SettingsConfigDict + +templates = Jinja2Templates(directory="templates") + +class Settings(BaseSettings): + """ + Application settings. + Settings are loaded from environment variables. + """ + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + DATABASE_URL: str + SECRET_KEY: str + ALGORITHM: str + ACCESS_TOKEN_EXPIRE_MINUTES: int + + +@lru_cache() +def get_settings(): + return Settings() \ No newline at end of file diff --git a/app/database.py b/app/database.py index ca97096..2b7d139 100644 --- a/app/database.py +++ b/app/database.py @@ -35,6 +35,9 @@ engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False} i SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() +# Import all models to ensure they are registered with Base +from app.models.llm_config import LLMConfig + # Database Models class Food(Base): __tablename__ = "foods" diff --git a/app/models/llm_config.py b/app/models/llm_config.py new file mode 100644 index 0000000..818d8d8 --- /dev/null +++ b/app/models/llm_config.py @@ -0,0 +1,10 @@ +from sqlalchemy import Column, Integer, String +from app.database import Base + +class LLMConfig(Base): + __tablename__ = "llm_configs" + + id = Column(Integer, primary_key=True, index=True) + openrouter_api_key = Column(String, nullable=True) + preferred_model = Column(String, default="anthropic/claude-3.5-sonnet") + browserless_api_key = Column(String, nullable=True) \ No newline at end of file diff --git a/logging.ini b/logging.ini new file mode 100644 index 0000000..d7fbcd0 --- /dev/null +++ b/logging.ini @@ -0,0 +1,21 @@ +[loggers] +keys=root + +[handlers] +keys=stream_handler + +[formatters] +keys=formatter + +[logger_root] +level=INFO +handlers=stream_handler + +[handler_stream_handler] +class=StreamHandler +level=INFO +formatter=formatter +args=(sys.stdout,) + +[formatter_formatter] +format=%(asctime)s - %(name)s - %(levelname)s - %(message)s \ No newline at end of file diff --git a/main.py b/main.py index ff40083..9218e9e 100644 --- a/main.py +++ b/main.py @@ -13,11 +13,14 @@ from sqlalchemy import or_ from sqlalchemy.orm import Session from typing import List, Optional from datetime import date, datetime +import time import os import csv import requests from fastapi import File, UploadFile import logging +from logging.config import fileConfig +import sys from alembic.config import Config from alembic import command from apscheduler.schedulers.background import BackgroundScheduler @@ -25,6 +28,8 @@ import shutil import sqlite3 # Configure logging +fileConfig('logging.ini', disable_existing_loggers=False) + # Import database components from the database module from app.database import DATABASE_URL, engine, Base, get_db, SessionLocal, Food, Meal, MealFood, Plan, Template, TemplateMeal, WeeklyMenu, WeeklyMenuDay, TrackedMeal, FoodCreate, FoodResponse, calculate_meal_nutrition, calculate_day_nutrition, calculate_day_nutrition_tracked @@ -33,14 +38,9 @@ from app.database import DATABASE_URL, engine, Base, get_db, SessionLocal, Food, async def lifespan(app: FastAPI): # Startup logging.info("DEBUG: Startup event triggered") + time.sleep(5) run_migrations() - # Re-apply logging configuration after Alembic might have altered it - logging.getLogger().setLevel(logging.INFO) - for handler in logging.getLogger().handlers: - handler.setLevel(logging.INFO) - logging.info("DEBUG: Logging re-configured to INFO level.") - logging.info("DEBUG: Startup event completed") # Schedule the backup job - temporarily disabled for debugging @@ -61,17 +61,9 @@ from app.utils import slugify # Add custom filters to Jinja2 environment templates.env.filters['slugify'] = slugify -from app.api.routes import foods, meals, plans, templates as templates_router, weekly_menu, tracker, admin, export, charts +from app.api.routes import api_router -app.include_router(foods.router, tags=["foods"]) -app.include_router(meals.router, tags=["meals"]) -app.include_router(plans.router, tags=["plans"]) -app.include_router(templates_router.router, tags=["templates"]) -app.include_router(weekly_menu.router, tags=["weekly_menu"]) -app.include_router(tracker.router, tags=["tracker"]) -app.include_router(admin.router, tags=["admin"]) -app.include_router(export.router, tags=["export"]) -app.include_router(charts.router, tags=["charts"]) +app.include_router(api_router) # Add a logging middleware to see incoming requests @app.middleware("http") @@ -86,7 +78,7 @@ PORT = int(os.getenv("PORT", 8999)) # This will be called if running directly with Python if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=PORT) + uvicorn.run(app, host="0.0.0.0", port=PORT, log_config=None) # Import Pydantic models from the database module from app.database import FoodCreate, FoodResponse, MealCreate, TrackedDayCreate, TrackedMealCreate, FoodExport, MealFoodExport, MealExport, PlanExport, TemplateMealExport, TemplateExport, TemplateMealDetail, TemplateDetail, WeeklyMenuDayExport, WeeklyMenuDayDetail, WeeklyMenuExport, WeeklyMenuDetail, TrackedMealExport, TrackedDayExport, AllData, TrackedDay @@ -274,7 +266,6 @@ def run_migrations(): logging.info("DEBUG: Database migrations run successfully.") except Exception as e: logging.error(f"DEBUG: Failed to setup database: {e}", exc_info=True) - raise # Routes @app.get("/", response_class=HTMLResponse) diff --git a/playwright-report/index.html b/playwright-report/index.html index c56e881..4482fc7 100644 --- a/playwright-report/index.html +++ b/playwright-report/index.html @@ -73,4 +73,4 @@ Error generating stack: `+u.message+`
- \ No newline at end of file + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 50e7699..3b9b40b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,10 @@ jinja2==3.1.2 openfoodfacts>=0.2.0 alembic>=1.13.1 mako>=1.3.2 +openai>=1.109.0 +pydantic-settings>=2.2.1 apscheduler pytest + +httpx \ No newline at end of file diff --git a/templates/admin/index.html b/templates/admin/index.html index c2d3a38..e950c24 100644 --- a/templates/admin/index.html +++ b/templates/admin/index.html @@ -10,6 +10,9 @@ +
diff --git a/templates/admin/llm_config.html b/templates/admin/llm_config.html new file mode 100644 index 0000000..556d7db --- /dev/null +++ b/templates/admin/llm_config.html @@ -0,0 +1,38 @@ +{% extends "admin/index.html" %} + +{% block admin_content %} +
+

LLM Configuration

+
+
+ + + Your API key for OpenRouter.ai +
+
+ + + e.g., anthropic/claude-3.5-sonnet, openai/gpt-4o +
+
+ + + Your API key for Browserless.io +
+ +
+
+{% endblock %} + +{% block extra_scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index f94b04b..e1508bb 100644 --- a/templates/base.html +++ b/templates/base.html @@ -98,6 +98,11 @@ Admin +
diff --git a/templates/llm_food_extractor.html b/templates/llm_food_extractor.html new file mode 100644 index 0000000..9d1455f --- /dev/null +++ b/templates/llm_food_extractor.html @@ -0,0 +1,293 @@ +{% extends "base.html" %} + +{% block title %}LLM Food Extractor{% endblock %} + +{% block content %} +
+

Extract Food Data using LLM

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

Click here and paste an image

+ +
+
+ +
+ + + + + + +
+ + +{% endblock %} \ No newline at end of file diff --git a/test-results/.last-run.json b/test-results/.last-run.json index cbcc1fb..d5c04b3 100644 --- a/test-results/.last-run.json +++ b/test-results/.last-run.json @@ -1,4 +1,6 @@ { - "status": "passed", - "failedTests": [] + "status": "failed", + "failedTests": [ + "cbe50082e65e8e610128-6c81c1ca2919b3ea739f" + ] } \ No newline at end of file diff --git a/tests/6_llm_extraction.spec.js b/tests/6_llm_extraction.spec.js new file mode 100644 index 0000000..1b146e9 --- /dev/null +++ b/tests/6_llm_extraction.spec.js @@ -0,0 +1,190 @@ +const { test, expect } = require('@playwright/test'); + +test.describe('LLM Food Extraction', () => { + test('should allow extracting data from a pasted image', async ({ page }) => { + await page.goto('/llm'); + + // This is a simplified way to "paste" an image. + // In a real test, you might need a more complex setup to interact with the clipboard. + await page.evaluate(() => { + const dataTransfer = new DataTransfer(); + const file = new File(['dummy image content'], 'pasted.png', { type: 'image/png' }); + dataTransfer.items.add(file); + const pasteEvent = new ClipboardEvent('paste', { + clipboardData: dataTransfer, + bubbles: true, + cancelable: true, + }); + document.getElementById('paste-container').dispatchEvent(pasteEvent); + }); + + // Verify the image preview is shown + const imagePreview = page.locator('#pasted-image-preview'); + await expect(imagePreview).toBeVisible(); + + // Mock the API response + await page.route('/llm/extract', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + name: 'Pasted Food', + brand: 'Test Brand', + serving_size_g: 100.0, + calories: 150, + protein_g: 8.0, + carbohydrate_g: 15.0, + fat_g: 3.0, + fiber_g: 2.0, + sugar_g: 1.0, + sodium_mg: 75, + calcium_mg: 30 + }), + }); + }); + + // Submit the form + await page.click('button[type="submit"]'); + + // Verify the result + const resultContainer = page.locator('#resultContainer'); + await expect(resultContainer).toBeVisible(); + await expect(resultContainer).toContainText('Pasted Food'); + }); + + test('should allow extracting data from a webpage URL', async ({ page }) => { + await page.goto('/llm'); + + // Fill in the webpage URL + await page.fill('#webpageUrl', 'https://example.com/recipe'); + + // Mock the API response + await page.route('/llm/extract', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + name: 'Webpage Food', + brand: 'Web Brand', + serving_size_g: 200.0, + calories: 250, + protein_g: 12.0, + carbohydrate_g: 25.0, + fat_g: 6.0, + fiber_g: 3.0, + sugar_g: 4.0, + sodium_mg: 120, + calcium_mg: 60 + }), + }); + }); + + // Submit the form + await page.click('button[type="submit"]'); + + // Verify the result + const resultContainer = page.locator('#resultContainer'); + await expect(resultContainer).toBeVisible(); + await expect(resultContainer).toContainText('Webpage Food'); + }); + + test('should still allow extracting data from an uploaded image', async ({ page }) => { + await page.goto('/llm'); + + // Upload an image + await page.setInputFiles('#imageUpload', { + name: 'food.jpg', + mimeType: 'image/jpeg', + buffer: Buffer.from('dummy image content'), + }); + + // Mock the API response + await page.route('/llm/extract', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + name: 'Uploaded Food', + brand: 'Upload Brand', + serving_size_g: 150.0, + calories: 350, + protein_g: 15.0, + carbohydrate_g: 30.0, + fat_g: 10.0, + fiber_g: 5.0, + sugar_g: 8.0, + sodium_mg: 200, + calcium_mg: 100 + }), + }); + }); + + // Submit the form + await page.click('button[type="submit"]'); + + // Verify the result + const resultContainer = page.locator('#resultContainer'); + await expect(resultContainer).toBeVisible(); + await expect(resultContainer).toContainText('Uploaded Food'); + }); + + test('should allow editing extracted data and saving to foods', async ({ page }) => { + await page.goto('/llm'); + + // Fill in the webpage URL + await page.fill('#webpageUrl', 'https://example.com/recipe'); + + // Mock the API response + await page.route('/llm/extract', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + name: 'Editable Food', + brand: 'Test Brand', + serving_size_g: 100.0, + calories: 200, + protein_g: 10.0, + carbohydrate_g: 20.0, + fat_g: 5.0, + fiber_g: 2.5, + sugar_g: 3.0, + sodium_mg: 150, + calcium_mg: 50 + }), + }); + }); + + // Mock the save response + await page.route('/foods/add', route => { + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'success', message: 'Food added successfully' }), + }); + }); + + // Submit the extraction form + await page.click('button[type="submit"]'); + + // Verify the result container is visible + const resultContainer = page.locator('#resultContainer'); + await expect(resultContainer).toBeVisible(); + + // Verify form is populated + await expect(page.locator('#foodName')).toHaveValue('Editable Food'); + await expect(page.locator('#foodBrand')).toHaveValue('Test Brand'); + await expect(page.locator('#servingSizeG')).toHaveValue('100'); + await expect(page.locator('#calories')).toHaveValue('200'); + + // Edit some values + await page.fill('#foodName', 'Edited Food Name'); + await page.fill('#calories', '250'); + + // Click Confirm and Save + await page.click('#confirmAndSave'); + + // Verify save was attempted (mocked, so no actual redirect) + // In a real test, you might check for a success message or redirect + }); +}); \ No newline at end of file