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.
| Tool | Version | Notes |
|---|---|---|
| Python | 3.11+ | Backend |
| Node.js | 18+ | Frontend |
| Ollama | Latest | Runs models locally |
| Firecrawl | β | Used by the scraper (API key required) |
ollama pull gemma3:4b
ollama pull embeddinggemmaClone the repo, then install both the backend and frontend in one command:
make installOr step by step:
# Backend β create virtualenv and install Python deps
make install-backend
# Frontend β install npm packages
make install-frontendCopy the example file and fill in your keys:
cp backend/.env.example backend/.envEdit 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_KEYis only needed when scraping new wines into the catalogue.
The wine catalog is built by scraping Meny and embedding the results into ChromaDB.
# 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-allThis writes wine records into backend/vinguide.db.
make ingestThis reads all wines from SQLite and stores their embeddings in backend/chroma_db/. Re-run this any time you add or update wines.
Start both the Flask API and the Vite dev server with a single command:
make devOr start them separately:
# Terminal 1 β Flask API (port 5001)
make backend
# Terminal 2 β React dev server (port 5173)
make frontendOpen http://localhost:5173 in your browser.
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 changesThe Docker container must be running for scrape commands to work:
docker compose up -dFirst time? See Setting up Sommelier Sven below.
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.
pip install hermes-agent- Go to discord.com/developers/applications β New Application
- Under Bot: enable Message Content Intent
- Copy the bot token β you'll need it in step 3
- Under OAuth2 β URL Generator: scope =
bot, permissions =Send Messages,Read Message History - Open the generated URL to invite the bot to your server
hermes setupWhen 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
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_IDAdd the following to ~/.hermes/config.yaml (edit with any text editor):
mcp_servers:
vinguide:
command: python3
args:
- /absolute/path/to/VinGuide/backend/mcp_server.pyReplace
/absolute/path/to/VinGuidewith 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.'hermes gateway install # registers as a launchd service (auto-starts on login)
hermes gateway startSend 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.
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-downInside Docker,
OLLAMA_HOSTdefaults tohttp://host.docker.internal:11434.
Health check
curl http://localhost:5001/healthChat
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"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 wines3. 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.
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)
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.
- 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:4bis 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.
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)
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 |
Sven reasons about what it needs before responding. When asked for a wine category not yet in the catalogue, it will:
- Call
recommend_wineβ find no results - Call
list_winesβ confirm the gap - Call
scrape_winesautonomously to fill it - Call
recommend_wineagain with the newly scraped data
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 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.
- No conversation memory in the web chat widget (frontend only)
scrape_winesrequires the Docker container to be running β fails gracefully with an error message if not
| 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 |
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 |