A friend-matching engine for a college orientation event. Freshmen sign up with a short prompt about themselves. When the organizer says go, the system pairs everyone by how similar their answers are, writes each pair a custom icebreaker, and emails both people a black "membership card" telling them who to go find in the room.
Built for a UCSC freshman event and themed as a secret society ("Obsidian // Freshman Protocol"), so that walking up to a stranger feels like a game instead of a name-tag mixer.
Orientation mixers fail the same way every time. You put a hundred strangers in a room, give nobody a reason to talk to any specific person, and everyone ends up talking to the two people they already knew. The fix is not a better icebreaker. It is removing the choice. Everyone gets assigned exactly one person plus a reason to find them.
The matching is deliberately boring: turn each person's free-text answer into a vector and pair each person with their nearest neighbor. The LLM only writes the archetype and icebreaker for a pair. It does not pick the matches, because cosine similarity on embeddings is cheaper and more predictable than asking a model "who should be friends." The theming is the part that actually gets an 18-year-old to cross the room.
Three tiers. Two thin HTML pages the students and organizer touch, one FastAPI process that holds all the logic, and a set of external services the process calls out to.
BROWSER APPLICATION (obsidian_engine.py) EXTERNAL
------------------ ---------------------------------------- ------------------
index.html FastAPI app
signup form ── POST ──▶ ├─ CORS middleware
/signup/ ├─ rate_limit_signup (5/min per IP)
├─ get_embedding() ─────────────────────▶ Gemini
│ embedding-001
└─ writes User row ───────┐
▼
admin.html ┌─────────┐
trigger page ── POST ──▶ ├─ require_admin │ SQLite │
/admin/trigger-matches/│ (X-Admin-Token) │ obsidian│
+ token ├─ cosine_similarity │ .db │
│ over User rows ◀──┤ users │
├─ greedy pairing │ event_ │
├─ writes EventMatch ─▶│ matches │
└─ per pair, in a └─────────┘
BackgroundTask:
├─ one chat call ───────────────────▶ OpenRouter
│ archetype + icebreaker gemini-2.0-flash
├─ create_obsidian_card() (PIL) ────▶ assets/*.png
└─ send_obsidian_email() (SMTP) ────▶ Gmail
What each piece owns:
obsidian_engine.pyis the whole backend: the two routes, the auth and rate-limit dependencies, embedding, the matching loop, PIL card rendering, and the SMTP sender.models.pydefines two SQLAlchemy tables and opens the SQLite file. Importing it creates the database if it does not exist.index.htmlandadmin.htmlare static pages that POST JSON to the two routes.admin.htmlis the only thing that should ever call the trigger endpoint.rematch.pyis a separate command-line tool for the one case the main flow does not cover: a student complains at the event and needs a fresh match on the spot. It reads a Google Sheet instead of the SQLite database, so it is independent of the running server.start_obsidian.shruns uvicorn and opens an ngrok tunnel so the signup form works from phones at the venue.
Two models, each sized to its job. Embeddings use gemini-embedding-001 with the clustering task type. The archetype and icebreaker text uses gemini-2.0-flash-001 through OpenRouter, a small fast model, because that job is a one-line creative write and not reasoning.
The whole thing runs in two phases: a long open signup window, then a single organizer-triggered drop at the event.
Phase 1, signup (runs for days before the event):
- A student opens
index.htmland submits name, email, MBTI, and a free-text answer. /signup/checks the per-IP rate limit, then calls Gemini to embed the free-text answer into a 768-dimension vector.- The user and their vector get written to the
userstable withis_matched_this_session = False. - If the embedding call fails,
get_embeddingreturns a zero vector instead of throwing, so a signup is never lost to an API hiccup. That user just matches poorly until re-embedded.
Phase 2, the drop (one call, at the event, when signups close):
- The organizer opens
admin.htmland hits the trigger, which POSTs to/admin/trigger-matches/with the admin token in theX-Admin-Tokenheader. - The endpoint pulls every user still flagged unmatched. Fewer than two and it stops.
- It builds a cosine-similarity matrix across all of their vectors and zeroes out the diagonal so nobody matches themselves.
- It walks the list greedily. For each still-unpaired person it picks the most similar still-unpaired partner, marks both as matched, and writes an
EventMatchrow. - Each pair is handed to a background task so the response returns fast instead of blocking on email. That task makes one LLM call for both archetypes and icebreakers, renders a card PNG for each person with PIL, and emails both of them through Gmail with the card attached.
Rematch (rare, manual, at the event):
If someone needs a new match on the spot, an organizer runs python rematch.py, types the person's name, and the script pulls a random other attendee from the Google Sheet, generates a red "override" card, and emails them. It is a pressure-release valve, not part of the automated path.
You need Python 3.11 or newer, a Gemini API key, an OpenRouter key, and a Gmail account with an app password.
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # fill in the keys, see the table below
uvicorn obsidian_engine:app --port 8000SQLite creates obsidian.db on first run, so there is no database to provision. Point a browser at index.html or POST directly:
curl -X POST localhost:8000/signup/ -H "Content-Type: application/json" \
-d '{"name":"Sammy Slug","email":"sammy@ucsc.edu","mbti":"ENFP","answers":"I could talk about tide pools for three hours"}'When signups close, trigger the drop. This emails everyone, so do not run it early:
curl -X POST localhost:8000/admin/trigger-matches/ -H "X-Admin-Token: $ADMIN_TOKEN"start_obsidian.sh runs uvicorn and opens the ngrok tunnel in one step.
docker build -t obsidian .
docker run -p 8000:8000 --env-file .env -v $(pwd)/obsidian.db:/app/obsidian.db obsidianThe container serves through gunicorn's uvicorn worker instead of the dev server. It runs a single worker on purpose, because the signup rate limiter keeps its counters in process memory and multiple workers would not share a limit. The volume mount keeps signups across restarts, since SQLite is just a file on disk.
The admin endpoint is the one that matters. /admin/trigger-matches/ fires the entire drop: it emails every user and spends LLM and email quota. It sits behind a shared secret sent as the X-Admin-Token header and compared in constant time with secrets.compare_digest. If ADMIN_TOKEN is unset the check is skipped so local development still works, which means a real deploy has to set the token or the endpoint is wide open. If you do set it, update admin.html to send the header too.
/signup/ is public and every call spends a Gemini embedding, so it is capped at 5 requests per minute per client IP. The limiter is in-memory and per-process, which is why the Docker image runs a single worker.
Secrets stay out of git. .env, credentials.json, and obsidian.db are all gitignored, the last one because it holds real names and emails. The generated cards in assets/ and the processed_agents.txt log are ignored for the same reason.
| Variable | Purpose |
|---|---|
GEMINI_API_KEY |
Embeds signup answers into 768-dimension vectors |
OPENROUTER_API_KEY |
Generates the archetype and icebreaker for each pair |
EMAIL_USER |
Gmail address that sends the match emails |
EMAIL_APP_PASSWORD |
Gmail app password, not the account password |
ADMIN_TOKEN |
Gates /admin/*. Unset means the check is skipped, dev only. Set it in prod |
GOOGLE_SHEET_NAME |
Used only by rematch.py, the sheet the override CLI reads |
Two SQLite tables, defined in models.py. The users table holds each signup plus its embedding, stored as a JSON list, and an is_matched_this_session flag. The event_matches table records each pairing with a timestamp. The flag is how a user is excluded from a second drop. Note that it never resets, so re-running a drop only pairs people who signed up since the last one.
pip install pytest httpx
pytestThe tests cover the parts that hurt when they break: the admin endpoint rejects callers without the token, the signup limiter trips at the cap, and name sanitization strips HTML and symbols before a name reaches email or an image. GitHub Actions runs them on every push and pull request (.github/workflows/ci.yml).
- Odd headcounts. The greedy loop leaves the last unpaired person with no match and no email. It should fold them into an existing pair as a trio, or match the two closest leftovers.
- Better pairing. Greedy nearest-neighbor is asymmetric, because A's best match may already be taken, so A gets a worse one. A global maximum-weight matching would pair everyone optimally.
- Move off
google.generativeai, which is deprecated in favor ofgoogle.genai. - Take the 2-second sleep out of the request. Matching currently sleeps between pairs inside the handler. For a large event that pacing belongs fully in the background task.
| File | Role |
|---|---|
obsidian_engine.py |
FastAPI server: both routes, matching, card rendering, email |
models.py |
SQLAlchemy models and SQLite setup |
rematch.py |
Standalone CLI to manually re-pair one person from a Sheet |
find_embedding.py |
One-off script that lists the Gemini embedding models a key can use |
index.html / admin.html |
Signup form and organizer trigger page |
start_obsidian.sh |
Runs uvicorn and opens the ngrok tunnel |
MIT. See LICENSE.