Skip to content

Repository files navigation

VinGuide

A Danish wine recommendation app with two interfaces: a web chatbot and a Discord sommelier bot (Sommelier Sven). Wines are scraped from Meny.dk via Firecrawl, stored in SQLite, embedded into ChromaDB, and served through a LangGraph RAG pipeline. Both the web UI and Discord talk to the same Flask API.

Built as an exam project for the "LLM for Developers" course.


Prerequisites

Tool Version Notes
Python 3.11+ Backend
Node.js 18+ Frontend
Ollama Latest Runs models locally
Firecrawl β€” Used by the scraper (API key required)

Pull the required Ollama models

ollama pull gemma3:4b
ollama pull embeddinggemma

Installation

Clone the repo, then install both the backend and frontend in one command:

make install

Or step by step:

# Backend β€” create virtualenv and install Python deps
make install-backend

# Frontend β€” install npm packages
make install-frontend

Environment variables

Copy the example file and fill in your keys:

cp backend/.env.example backend/.env

Edit backend/.env:

Variable Default Description
OLLAMA_HOST http://localhost:11434 Ollama server URL
CHAT_MODEL gemma3:4b Local Ollama model used for chat generation
EMBEDDING_MODEL embeddinggemma Local Ollama model used for embeddings
PORT 5001 Flask server port
CHROMA_DIR chroma_db Path to the ChromaDB directory
COLLECTION_NAME vinguide_wines ChromaDB collection name
TOP_K 6 Number of chunks retrieved per query
CHUNK_SIZE 800 RAG chunk size
CHUNK_OVERLAP 120 RAG chunk overlap
FIRECRAWL_API_KEY β€” Required only for the data pipeline (make scrape-*). Get a free key at firecrawl.dev

The chatbot itself runs entirely on the local Ollama model β€” no cloud API key is required at runtime. FIRECRAWL_API_KEY is only needed when scraping new wines into the catalogue.


Data pipeline

The wine catalog is built by scraping Meny and embedding the results into ChromaDB.

1. Scrape wines into SQLite

# Scrape a specific category (default 5 wines, override with MAX=N)
make seed-rodvin MAX=10
make seed-hvidvin
make seed-rose
make seed-mousserende

# Scrape all categories
make seed-all

This writes wine records into backend/vinguide.db.

2. Embed wines into ChromaDB

make ingest

This reads all wines from SQLite and stores their embeddings in backend/chroma_db/. Re-run this any time you add or update wines.


Running locally

Start both the Flask API and the Vite dev server with a single command:

make dev

Or start them separately:

# Terminal 1 β€” Flask API (port 5001)
make backend

# Terminal 2 β€” React dev server (port 5173)
make frontend

Open http://localhost:5173 in your browser.

Starting Sommelier Sven (Discord)

The Hermes gateway auto-starts as a launchd service on login. If Sven isn't responding in Discord:

hermes gateway status   # check if running
hermes gateway start    # start if not
hermes gateway restart  # restart after config changes

The Docker container must be running for scrape commands to work:

docker compose up -d

First time? See Setting up Sommelier Sven below.


Setting up Sommelier Sven (first time)

Sven runs through Hermes Agent. His config lives in ~/.hermes/config.yaml on your local machine β€” it is not in the repo. Follow these steps on a fresh machine.

1. Install Hermes

pip install hermes-agent

2. Create a Discord bot

  1. Go to discord.com/developers/applications β†’ New Application
  2. Under Bot: enable Message Content Intent
  3. Copy the bot token β€” you'll need it in step 3
  4. Under OAuth2 β†’ URL Generator: scope = bot, permissions = Send Messages, Read Message History
  5. Open the generated URL to invite the bot to your server

3. Run the Hermes setup wizard

hermes setup

When prompted:

  • Provider: OpenRouter (free tier works β€” get a key at openrouter.ai)
  • Model: nvidia/nemotron-3-super-120b-a12b:free
  • Platform: Discord
  • Bot token: paste from step 2

4. Add your Discord user ID to the allowlist

Find your Discord user ID (Settings β†’ Advanced β†’ Developer Mode β†’ right-click your name β†’ Copy ID), then:

hermes config set DISCORD_ALLOWED_USERS YOUR_DISCORD_USER_ID

5. Register the MCP server and channel prompt

Add the following to ~/.hermes/config.yaml (edit with any text editor):

mcp_servers:
  vinguide:
    command: python3
    args:
      - /absolute/path/to/VinGuide/backend/mcp_server.py

Replace /absolute/path/to/VinGuide with the actual path on your machine (e.g. /Users/yourname/VSCodeProjects/VinGuide).

Under the discord: section, add:

discord:
  channel_prompts:
    general: 'You are Sommelier Sven, wine assistant for VinGuide. Always use your
      vinguide MCP tools before responding β€” never invent wines or prices. Use recommend_wine
      for questions and recommendations, list_wines to show the catalogue, scrape_wines
      to add new wines. Return tool results as-is.'

6. Install and start the gateway service

hermes gateway install   # registers as a launchd service (auto-starts on login)
hermes gateway start

Verify it works

Send a message in your Discord server's #general channel:

@Sommelier Sven anbefal en rΓΈdvin

You should see βš™οΈ mcp_vinguide_recommend_wine... appear, followed by a recommendation from the catalogue.


Running with Docker

Docker bundles the Flask API and the production frontend build. Ollama still runs on the host.

# Build the image
make docker-build

# Start (serves on port 5001)
make docker-up

# Re-embed wines inside the running container
make docker-ingest

# Tail logs
make docker-logs

# Stop
make docker-down

Inside Docker, OLLAMA_HOST defaults to http://host.docker.internal:11434.


API

Health check

curl http://localhost:5001/health

Chat

curl -X POST http://localhost:5001/api/chat \
  -H "Content-Type: application/json" \
  -d '{"question": "anbefal en vin til pizza"}'

Wine catalog (with optional filters)

curl "http://localhost:5001/api/wines?category=rodvin&max_price=150"

Verification

After starting the backend, run these checks to confirm the system is working end-to-end.

1. Health check β€” confirms Flask is running and ChromaDB is reachable

curl http://localhost:5001/health
# Expected: {"status": "ok", "wines_in_db": N}

2. Wine catalog β€” confirms SQLite is seeded

curl "http://localhost:5001/api/wines?category=rodvin&max_price=150"
# Expected: JSON array of wines

3. RAG chat β€” confirms the full LangGraph pipeline (parse β†’ SQL filter β†’ vector search β†’ Ollama β†’ format)

curl -X POST http://localhost:5001/api/chat \
  -H "Content-Type: application/json" \
  -d '{"question": "anbefal en rΓΈdvin til oksekΓΈd"}'
# Expected: JSON with "answer" (Danish text) and "citations" (array of wines)

4. Web UI β€” open http://localhost:5173 (dev) or http://localhost:5001 (Docker). The wine grid should load and the chat widget should answer questions in Danish with clickable citations.

5. Discord bot (optional) β€” in your Discord server's #general:

@Sommelier Sven anbefal en hvidvin under 100 kr

You should see βš™οΈ mcp_vinguide_recommend_wine… followed by a recommendation.


Architecture

Browser (React + Vite)
  β”œβ”€β”€ GET  /api/wines   β†’  SQLite (vinguide.db)
  └── POST /api/chat
        └── Flask app.py
              └── LangGraph workflow (5 nodes, straight chain)
                    β”œβ”€β”€ 1. parse_query  β†’  Regex/keyword parser extracts structured filters
                    β”‚                      (type, price range, food pairing) β€” no LLM
                    β”œβ”€β”€ 2. filter_db    β†’  SQL query returns matching wine_ids from SQLite
                    β”œβ”€β”€ 3. semantic     β†’  ChromaDB vector search restricted to those wine_ids
                    β”œβ”€β”€ 4. generate     β†’  Local Ollama gemma3:4b writes the Danish recommendation
                    └── 5. format       β†’  enriches citations with live price/image/URL from DB

Data pipeline (run once before starting the server)
  Firecrawl (LLM extractor) β†’ seed.py β†’ vinguide.db (SQLite) β†’ ingest.py β†’ chroma_db (ChromaDB)

One LLM, used where it belongs

The runtime pipeline contains exactly one LLM: the local Ollama model. Query parsing is done with regex and keyword matching because the input vocabulary is small and well-defined β€” four wine types, a handful of price phrasings (under X, max X, hΓΈjst X, fra X til Y), and a fixed set of food keywords. Regex is faster, deterministic, has no external dependency, and is trivially testable.

The LLM is reserved for the one job it's uniquely good at: writing fluent Danish prose grounded in retrieved context. The retrieval (SQL + vector search) hands the model a small, pre-filtered set of wines, and the model turns that into a recommendation.

This keeps the runtime free of cloud dependencies and API keys. Firecrawl is the only cloud service touched anywhere in the project, and it is only used when scraping new wines into the catalogue β€” never at chat time.

Known limitations

  • Small catalogue. Firecrawl's free tier provides 1,000 credits/month and a full LLM extraction costs five credits per wine, capping practical scraping to ~180 wines/month.
  • Small local model. gemma3:4b is good enough for short grounded recommendations from pre-filtered context, but quality degrades on long or analytical questions. The strict system prompt compensates for the model's limited capacity.
  • No web-chat memory. The Discord bot has per-user memory through Hermes; the web chat widget does not β€” a returning web user starts from scratch.
  • Full re-index on ingest. ChromaDB is rebuilt from scratch on every make ingest. Fine at the current scale, but incremental upserts would be the natural next step.
  • Local hosting. Hermes and Ollama both run on the developer machine, so Sommelier Sven is only online when the laptop is on. A small VPS would keep him online continuously.
  • Finite parser vocabulary. The regex query parser covers four wine types, a handful of price phrasings, and a fixed set of food keywords. Queries outside that set fall back to an unfiltered semantic search β€” acceptable but not ideal.

Discord β€” Sommelier Sven (Hermes agent)

Sommelier Sven is a Discord bot running through the Hermes Agent gateway. His primary role is catalogue maintenance β€” keeping the wine database fresh and accurate. Wine recommendations for end users are handled by the frontend RAG chatbot; Sven is the admin interface.

Discord server (#general)
  └── User: "skrab 5 nye hvidvin"
        └── Hermes gateway (launchd service, auto-starts on login)
              └── LLM agent: nvidia/nemotron-3-super-120b-a12b (via OpenRouter)
                    └── MCP server (backend/mcp_server.py, stdio transport)
                          β”œβ”€β”€ recommend_wine(question)   β†’ POST /api/chat
                          β”œβ”€β”€ list_wines(type, max_price) β†’ GET  /api/wines
                          └── scrape_wines(category, count)
                                β”œβ”€β”€ docker exec seed.py       (scrape Meny.dk)
                                └── docker exec ingest_from_db (embed into ChromaDB)

How it works

Sven uses typed MCP tools (Model Context Protocol) rather than raw terminal commands. Each tool has a defined schema β€” the LLM selects and calls them by name without needing to construct shell commands or know file paths.

The MCP server (backend/mcp_server.py) runs as a stdio subprocess managed by Hermes. It exposes three tools:

Tool What it does
recommend_wine(question) Calls /api/chat, returns the RAG answer verbatim
list_wines(type?, max_price?) Calls /api/wines with optional filters
scrape_wines(category, count) Runs seed.py + ingest_from_db inside the Docker container

Autonomous behaviour

Sven reasons about what it needs before responding. When asked for a wine category not yet in the catalogue, it will:

  1. Call recommend_wine β†’ find no results
  2. Call list_wines β†’ confirm the gap
  3. Call scrape_wines autonomously to fill it
  4. Call recommend_wine again with the newly scraped data

Starting Hermes

Hermes auto-starts as a launchd service on login. The Docker container must be running for scrape_wines to work:

docker compose up -d        # start the Flask API + persistent volume
hermes gateway start        # start the Discord gateway (if not auto-started)

Hermes memory

Hermes has persistent memory enabled (memory_enabled: true in ~/.hermes/config.yaml). After 6 turns it writes a user profile to disk and loads it back next session β€” Sven remembers preferences per Discord user without any custom code.

Known gaps

  • No conversation memory in the web chat widget (frontend only)
  • scrape_wines requires the Docker container to be running β€” fails gracefully with an error message if not

Key files

File Purpose
backend/app.py Flask API β€” /api/chat, /api/wines, /health
backend/mcp_server.py MCP stdio server β€” exposes recommend_wine, list_wines, scrape_wines to Hermes
backend/workflow.py LangGraph 5-node pipeline (parse_query β†’ filter_db β†’ semantic β†’ generate/describe β†’ format)
backend/prompts.py System prompt
backend/rag_helpers.py ChromaDB client and Ollama embedding helpers
backend/ingest.py Embeds wines from SQLite into ChromaDB
backend/seed.py Scrapes Meny and writes wines to SQLite
backend/scraper/firecrawl_meny.py Firecrawl-based Meny scraper (JS rendering + LLM extraction)
backend/db.py SQLite schema and query helpers
src/components/ChatWidget.jsx Floating chat UI
src/App.jsx Wine grid and filter bar

Useful make targets

Run make help to see all targets.

Target Description
make install Install all dependencies
make dev Start Flask + Vite in parallel
make seed-all MAX=20 Scrape all wine categories (20 per category)
make ingest Re-embed wines into ChromaDB
make docker-up Start the app in Docker
make clean-chroma Delete ChromaDB (re-run make ingest after)
make clean-all Full clean

About

🍷 Local-first RAG wine recommender. LangGraph state machine: regex parse β†’ SQLite filter β†’ ChromaDB retrieval β†’ grounded gen on Ollama gemma3. Firecrawl ingestion. Discord agent on Hermes via MCP stdio tool server. Flask + React.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages