Skip to content

Repository files navigation

Obsidian

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.

The idea

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.

Architecture

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.py is the whole backend: the two routes, the auth and rate-limit dependencies, embedding, the matching loop, PIL card rendering, and the SMTP sender.
  • models.py defines two SQLAlchemy tables and opens the SQLite file. Importing it creates the database if it does not exist.
  • index.html and admin.html are static pages that POST JSON to the two routes. admin.html is the only thing that should ever call the trigger endpoint.
  • rematch.py is 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.sh runs 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.

Workflow

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):

  1. A student opens index.html and submits name, email, MBTI, and a free-text answer.
  2. /signup/ checks the per-IP rate limit, then calls Gemini to embed the free-text answer into a 768-dimension vector.
  3. The user and their vector get written to the users table with is_matched_this_session = False.
  4. If the embedding call fails, get_embedding returns 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):

  1. The organizer opens admin.html and hits the trigger, which POSTs to /admin/trigger-matches/ with the admin token in the X-Admin-Token header.
  2. The endpoint pulls every user still flagged unmatched. Fewer than two and it stops.
  3. It builds a cosine-similarity matrix across all of their vectors and zeroes out the diagonal so nobody matches themselves.
  4. 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 EventMatch row.
  5. 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.

Setup

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 8000

SQLite 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

docker build -t obsidian .
docker run -p 8000:8000 --env-file .env -v $(pwd)/obsidian.db:/app/obsidian.db obsidian

The 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.

Security

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.

Environment variables

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

Data model

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.

Tests and CI

pip install pytest httpx
pytest

The 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).

What I would add next

  • 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 of google.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.

Files

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

License

MIT. See LICENSE.

About

Friend-matching engine for college orientation events: embeds signup answers, pairs students by similarity, and emails each pair an AI-written icebreaker on a themed black card. FastAPI + SQLite + Gemini.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages