Climate Culture Recommendation System
Three-service climate-action recommender. Next.js 16 frontend, Node.js/Express backend, FastAPI Python ML service with BGE embeddings + BM25 + LightGBM LambdaRank + type-aware MMR. Users take an 11-question quiz, the ML service ranks 343 climate actions, and drag-and-drop reordering feeds a feedback file used to retrain the ranker.
Climate action feels overwhelming — 300+ things you could do and no obvious way to pick. The system's job is to take an 11-question personal profile and produce 20 diverse, personalized actions ranked by fit, then learn from the user's love/reject/reorder feedback so the next session is better.
The system is deliberately split into three coordinated services because the concerns are different. Next.js 16 + React 19 + TypeScript frontend (src/app has 8 screens: onboarding, quizzes, dashboard, pathway, actionLibrary, impactReport, history, saved) handles the quiz UX and the drag-drop results grid. Node/Express backend (index.js + routes/quiz.js, 581 LoC) validates quiz input with Joi schemas, forwards it to the ML service, transforms the response, and persists feedback. FastAPI Python ML service (ml-service/app.py, 1,820 LoC) does the actual ranking: BGE embedding retrieval, BM25 scoring, LightGBM ranking, barrier filtering, and MMR diversification. Docker Compose glues them together. The split matters because the frontend doesn't need Python and the ML service doesn't need to know about auth or feedback storage.
For each of 343 actions, features.py (457 LoC) computes six ranking features on a 0–1 scale: (1) semantic — cosine similarity between the query BGE embedding and the action's title+description embedding; (2) bm25 — Okapi BM25 keyword match; (3) skills_match — mean cosine similarity to each user skill embedding; (4) action_type_match — same, over preferred action types; (5) cause_match — same, over user's climate causes; (6) engagement_match — exact-level matching with a distance table (0 = perfect at 1.0, distance 1 = 0.7, distance 2 = 0.4, else 0.2). All 343 item embeddings pre-computed at startup and cached in embeddings_cache.pkl so a request never blocks on model warm-up. The user query embedding is computed on-demand per request.
train_lightgbm_from_feedback.py trains a LambdaRank model with metric=ndcg, ndcg_eval_at=[5, 10, 20], num_leaves=15, learning_rate=0.03, and num_boost_round=50 (:243-259). Values were tightened from the ARCHITECTURE.md documented defaults (31/0.05/100) because the training set is small — the inline comments say "Smaller to avoid overfitting on 7 sessions." Feature-importance analysis from training runs shows engagement_match and bm25 as the strongest discriminators (Δ +0.139 and +0.104 mean between expert picks and non-picks) while semantic and action_type_match distinguish poorly (Δ close to 0). The current model reaches Recall@10 = 65.5%, Recall@20 = 86.2% on expert-pick evaluation. Known limitation acknowledged in ARCHITECTURE.md and here honestly: 7 training sessions is thin — the plan is to feed live feedback back into the training set as it accumulates.
After LightGBM produces relevance scores, mmr_select at app.py:1582 diversifies the top-K to avoid handing back 20 near-identical actions. The Maximal Marginal Relevance formula balances relevance × diversity via λ = 0.5: for each candidate, diversity = min(1 − cosine_sim(candidate, selected)) across the already-selected set, and the next pick maximizes λ·relevance + (1−λ)·diversity. The "type-aware" twist (comment at app.py:1558) selects per action-type category to guarantee a minimum count of each type in the top 20 — a raw MMR would happily return 20 "Learn" actions if they all scored high, but users want a mix of Learn/Support/Participate/Lead.
Before ranking, barrier_filter.py (264 LoC) strips actions that don't fit user constraints. Time barrier "minimal" removes anything longer than "a few hours" (drops "a few days", "1+ weeks", "long term"). Budget barrier "free only" strips paid actions (keeps Free or null price, drops "10+", "100+", "500+"). Access barriers filter by location or physical requirements. This runs before LightGBM so the ranker only sees actionable candidates, not just relevance-ranked-but-impossible ones.
Every session records what was shown, what the user loved / interested / rejected, and the reorder if they dragged. Feedback saves to /feedback/{session-id}.json with the original quiz input, the shown actions, and the feedback events. The retraining path converts feedback into training examples: loved → positive labels, rejected → negative labels, reordering → pairwise preferences ("action A > action B"). This is queued for full retraining once feedback volume passes a threshold (~100 sessions) — the current model was trained on 7 sessions of expert interview data, and the feedback pipeline is what pushes it toward user-driven personalization.
The frontend is a real product, not a demo. src/app/ ships 8 screens: onboarding (first-run walkthrough), quizzes (the 11-question profile builder), dashboard (post-quiz landing), pathway (recommended sequence of actions), actionLibrary (browsable full catalog with filters), impactReport (progress + carbon offset estimates), history (past sessions), and saved (bookmarked actions). Built on Next.js 16 with React 19, TypeScript strict, Tailwind, and Prettier + Husky pre-commit. This is where the drag-drop reorder happens: users physically move actions in the 20-item grid and the new positions ship back as pairwise preferences for the retrainer.
ARCHITECTURE.md is refreshingly candid about known issues: 7 training sessions is too few, semantic match is too broad to discriminate well, action_type_match doesn't meaningfully differentiate, there's no per-user personalization yet (same model for everyone), and cold-start for new actions is unsolved. The document lists 7 improvement approaches with references (two-stage ranking, fine-tuned domain embeddings, pairwise learning, neural cross-encoder, hybrid retrieval, Thompson-sampling bandits, session-based refinement) — a proper research plan rather than a pitch deck.