A crowdsourced, AI-organized 3D map of one-sentence human insights. People submit short beliefs or opinions; the system embeds them, groups them by topic, infers stance (pro/con), and builds a semantic graph. You explore the map, see βsupportersβ and βchallengersβ for any idea, and chat with support or debate agentsβparticipants who agree or disagreeβrather than a generic assistant.
-
One-sentence insights
Users contribute a single sentence: an opinion, claim, hypothesis, or personal learning (e.g. βRemote work increases productivity when teams define clear norms.β). -
Semantic organization
Each insight is embedded (OpenAI), assigned to a cluster by similarity to cluster centroids (online clustering with EMA updates), and gets a stance (pro / con / neutral) and optional canonical claim and counterclaim from an LLM. -
Graph of ideas
Similar insights in the same cluster are connected by edges (weight = cosine similarity). The result is a graph of nodes (insights) and edges (semantic similarity) that the frontend renders as a 3D force-directed map. -
Supporters & challengers
For any selected insight, the backend returns nearby supporters (same cluster, same stance) and challengers (same cluster, opposite stance), so users see who βagreesβ and who βdisagreesβ in the neighborhood. -
Conversational agents
Users can open a support chat (aligned participant) or debate chat (opposing participant). Both use LLM roleplay with the selected insight and optional counterparty belief; chat messages are guarded by an LLM classifier (allow/block + safe rewrite). -
Guardrails
Submission and chat use LLM-based reasoning (structured JSON), not keyword bans. Submissions get accept / revise / reject with optional suggested revision; chat gets allow / block with a natural safe rewrite when blocked.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Frontend (React + Vite) β
β β’ 3D force graph (react-force-graph-3d) β nodes = insights, edges = sim β
β β’ InsightForm (submit), SidePanel (supporters/challengers, chat triggers) β
β β’ ChatPanel (support / debate conversation) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
β HTTP (VITE_API_BASE_URL β backend)
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Backend (FastAPI) β
β β’ POST /v1/insights β full pipeline: guardrail β embed β cluster β stance β
β β’ GET /v1/graph β neighborhood or recent sample β
β β’ POST /v1/chat β support or debate reply with guardrail β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββββ
β β β
βΌ βΌ βΌ
ββββββββββββββββββββ ββββββββββββββββββββ ββββββββββββββββββββ
β PostgreSQL β β OpenAI API β β Prompt files β
β + pgvector β β (embeddings + β β guardrails/ β
β insights, β β chat completionsβ β chat/ β
β edges, β β JSON + embed) β β clustering/ β
β clusters β β β β (read by backend)β
ββββββββββββββββββββ ββββββββββββββββββββ ββββββββββββββββββββ
- Data store: Postgres with the
vectorextension (pgvector). Tables:insights(text, embedding, cluster_id, stance_label, type_label, canonical_claim, counterclaim, guardrail_json),edges(src, dst, weight),clusters(cluster_id, title, summary, centroid),reports. - Embeddings: One vector per insight (e.g. 1536-d); enriched input includes topic_label, stance_hint, type_label, canonical_claim, and insight text (see
pre_embedding+insight_service). - Clustering: Online centroid-based: assign to best-matching cluster if similarity β₯ threshold; else create new cluster. Centroids updated with EMA when a new insight joins.
- Graph: Edges created only between insights in the same cluster and above an edge similarity threshold; stored in
edgesand used for neighborhood expansion and supporter/challenger derivation.
| Layer | Role |
|---|---|
API (app/main.py) |
POST /v1/insights, GET /v1/graph, POST /v1/chat; CORS; health; DB unique index on normalized insight text. |
Models (app/models.py) |
Insight, Edge, Cluster, Report; pgvector Vector(embedding_dim) on Insight and Cluster. |
Insight pipeline (app/services/insight_service.py) |
Normalize text β duplicate check (normalized key) β submission guardrail (LLM) β embed (enriched context from pre_embedding) β assign_cluster β stance extraction (LLM) β persist insight β kNN neighbors β upsert_edges (same cluster, above threshold) β split supporters/challengers by stance. |
Clustering (app/services/clustering.py) |
Load all clusters; assign to best centroid by cosine similarity; if above threshold, update centroid with EMA and return; else create new cluster with stub title/summary. |
Graph (app/services/graph_service.py) |
No node_id: recent N insights + edges among them. With node_id: BFS expansion by depth and per-node edge budget, symmetric (in/out edges), cap edges per node; return nodes, edges, cluster info. |
Chat (app/services/chat_service.py) |
Chat guardrail (LLM) on user message; load support or debate prompt; substitute user_belief, seed_belief, user_message; build conversation history; call chat_json; return reply + guardrail. |
Guardrails (app/services/guardrails.py) |
run_submission_guardrail: LLM β decision, categories, type_label, suggested_revision. run_chat_guardrail: LLM β decision, reason, safe_rewrite. |
Stance (app/services/stance.py) |
LLM with cluster summary + insight β canonical_claim, stance_label, counterclaim. |
Pre-embedding (app/services/pre_embedding.py) |
LLM with type_label + insight β topic_label, stance_hint, canonical_claim; used to build enriched embedding input. |
LLM client (app/services/llm_client.py) |
chat_json (OpenAI-compatible chat, response_format: json_object), embed_text (embeddings API); settings from env. |
Config is via app/settings.py (Pydantic BaseSettings): DATABASE_URL, OPENAI_*, EMBEDDING_DIM, CLUSTER_SIMILARITY_THRESHOLD, EDGE_SIMILARITY_THRESHOLD, CLUSTER_EMA_ALPHA, MAX_EDGES_PER_NODE, CORS_ORIGINS.
| Part | Role |
|---|---|
App (src/App.jsx) |
Global state: graph (nodes/edges), selected node, your submitted node, supporters, challengers, clusters, chat mode/conversation. Loads initial graph; on node click fetches neighborhood graph and derives supporters/challengers; on submit focuses map on new node and its cluster. Zoom tier (near/mid/far) drives depth/budget refetch. |
Map3D (src/components/Map3D.jsx) |
react-force-graph-3d; node color by cluster; labels as canvas sprites; βYou are hereβ for your insight; click node β onNodeClick, click background β zoom toward point. |
SidePanel (src/components/SidePanel.jsx) |
Shows selected insight text, supporter/challenger previews, βUp for a chat?β (support) and βUp for a debate?β (debate); optional βGo to my insightβ when viewing another node after submitting. |
ChatPanel (src/components/ChatPanel.jsx) |
Support or debate mode; sends POST /v1/chat with mode, seed_insight_id, user_message, conversation_state, optional user_belief/counterparty_belief; appends turn to conversation. |
InsightForm (src/components/InsightForm.jsx) |
Submit one-sentence insight to POST /v1/insights; surfaces revise/reject errors from guardrail. |
| api.js | fetchGraph(params), submitInsight(text), sendChat(...); base URL from VITE_API_BASE_URL. |
- guardrails/
submission_guardrail_prompt.txt(accept/revise/reject, categories, type_label, suggested_revision);chat_message_guardrail_prompt.txt(allow/block, reason, safe_rewrite). - chat/
stance_extraction_prompt.txt(canonical_claim, stance_label, counterclaim);support_agent_prompt.txt,debate_agent_prompt.txt(identity + user_belief/seed_belief/user_message, response as JSON{"response":"..."}). - clustering/
embedding_enrichment_prompt.txt(topic_label, stance_hint, canonical_claim).
Moderation is entirely LLM reasoning; there is no keyword-block layer in this MVP.
| Path | Purpose |
|---|---|
backend/ |
FastAPI app, DB models, services (insight, graph, chat, clustering, guardrails, stance, pre_embedding, llm_client, utils), sql/init.sql (pgvector), scripts (e.g. seed). |
frontend/ |
React + Vite app, 3D map, forms, side panel, chat panel, API client. |
guardrails/ |
LLM prompt specs for submission and chat message classification. |
chat/ |
Prompts for stance extraction and support/debate agents. |
clustering/ |
Prompt for embedding-enrichment classification. |
docker-compose.infra.yml |
Postgres + pgvector only (for local backend/frontend). |
docker-compose.yml |
Full stack: db + backend + frontend. |
If you see connection to server at "127.0.0.1", port 5432 failed: Connection refused β start PostgreSQL first (see Option A below). The backend requires a running Postgres + pgvector instance.
-
Start Postgres + pgvector:
docker-compose -f docker-compose.infra.yml up -d
-
Backend (from repo root):
uv sync cp backend/.env.example backend/.env # set OPENAI_API_KEY uv run uvicorn app.main:app --reload --port 8000 -
Frontend:
cd frontend npm install && cp .env.example .env npm run dev
-
Check:
curl http://localhost:8000/healthβ{"status":"ok"}. Openhttp://localhost:5173/lumina(default base path is/lumina/for the Lumina project). To run the app at the dev server root instead, setVITE_BASE_PATH=/infrontend/.env.
docker compose build && docker compose up -dOpen the app at: http://localhost:8080/lumina/ (not the frontend port directly). Nginx on port 8080 serves the frontend and proxies /lumina/api to the backend, so the graph and ingestion work. Set OPENAI_API_KEY in the environment (e.g. in .env next to docker-compose.yml) for the backend.
Curated re-ingest (recommended): use seed_insights.jsonl and POST each line to /ideas:
uv run python backend/scripts/reingest_ideas.pyRequires the API server to be up. Override with API_BASE and SEED_PATH (see Map empty after deploy? for running reingest on a server via Docker).
Synthetic seed (optional): generate ~250 pro/con sentences and POST to /v1/insights:
python backend/scripts/seed_insights.pyUses pro/con sentence variants and prefixes/suffixes to POST to http://localhost:8000/v1/insights. You can override the API base with the API_BASE env var (e.g. when seeding on a server).
The map is filled from PostgreSQL (insights, edges, topics). Data lives in the Docker volume pg_data. Two common reasons itβs empty after you deploy:
- You deployed to a different machine (e.g. a server). The server has its own, initially empty
pg_datavolume. Your preβingested data only existed on your local machine. - You ran
docker compose down -v. The-vflag removes named volumes, sopg_datawas deleted and the DB started empty.
Ways to fix it:
Easiest β seed from seed_insights.jsonl (one command from repo root; stack must be up):
docker compose run --rm \
-e API_BASE=http://backend:8000 \
-e SEED_PATH=/app/seed_insights.jsonl \
-v "$(pwd)/seed_insights.jsonl:/app/seed_insights.jsonl:ro" \
backend python scripts/reingest_ideas.pyAfter it finishes, reload the app; the graph should show nodes.
- Re-ingest your curated insights (same as above; from
seed_insights.jsonlvia POST /ideas). Use this when the map should show the same ideas you had before. With the stack running on the server, from the repo root (soseed_insights.jsonlis in the current directory), run the block above. The script clears the topic-layer tables, then POSTs each line to the backend (embeddings, topics, edges are created). Takes a few minutes depending on the number of lines. - Synthetic seed (optional, ~250 random pro/con insights). If you want placeholder data instead of
seed_insights.jsonl:docker compose run --rm -e API_BASE=http://backend:8000 backend python scripts/seed_insights.py
- Restore a DB dump if you had exported the DB when it was populated. See Moving the populated graph to a server for
pg_dump/pg_restoresteps.
To avoid losing data on future deploys on the same machine, use docker compose down without -v so the pg_data volume is kept.
To copy your local database (insights, edges, clusters, reports) to a server:
1. Export from local
With Postgres running (e.g. docker-compose -f docker-compose.infra.yml up -d), create a dump. From the repo root:
# If using Docker for Postgres (default): run pg_dump inside the container
docker exec mka_db pg_dump -U postgres -Fc mka > mka_dump.dumpOr if Postgres is installed locally and mka is running on port 5432:
pg_dump -U postgres -Fc -h localhost -p 5432 mka > mka_dump.dump-Fc = custom format (good for pg_restore). For a plain SQL file instead, use -Fp and then restore with psql -f mka_dump.sql.
2. Copy the dump to the server
scp mka_dump.dump user@your-server:/tmp/3. On the server
-
Ensure Postgres has the pgvector extension (e.g. use the same
pgvector/pgvector:pg16image, or install the extension in your existing Postgres). -
Create the database and enable the extension if this is a fresh instance:
# Example: create DB and enable vector (if init.sql isnβt run automatically) psql -U postgres -c "CREATE DATABASE mka;" psql -U postgres -d mka -c "CREATE EXTENSION IF NOT EXISTS vector;"
-
Restore the dump (this overwrites existing tables in
mka):pg_restore -U postgres -d mka -Fc --no-owner --no-acl /tmp/mka_dump.dump
-
Configure the backend on the server to use this DB via
DATABASE_URL(e.g.postgresql+psycopg://user:pass@localhost:5432/mka). Use the same EMBEDDING_DIM (e.g. 1536) as when the data was created.
4. Optional: clean up
rm /tmp/mka_dump.dumpOn your local machine you can remove mka_dump.dump after confirming the server has the data.
This project is configured so that alignmentatlas.online/lumina serves the Lumina app.
- Frontend base path: Vite uses
base: '/lumina/'by default (frontend/vite.config.js). All assets and the app root are under/lumina/, so when the site is served at alignmentatlas.online, the path /lumina (or /lumina/) loads this app. - What you need on the host: Your reverse proxy (e.g. nginx, Cloudflare, or your hosting platform) for alignmentatlas.online should:
- Serve the built frontend static files (e.g.
frontend/dist/) for requests to/luminaand/lumina/*. - Either proxy API requests to your backend (e.g.
/lumina/apiβ backend) or keep the API on the same origin and setVITE_API_BASE_URLto that API base when building.
- Serve the built frontend static files (e.g.
- Build for production: From
frontend/, runnpm run build. The output indist/is meant to be served with base path/lumina/. Upload or deploy the contents ofdist/so that the document root for/luminais that folder (or map/luminato that folder). - Backend CORS: If the API is on a different host/port than the site, set
CORS_ORIGINSin the backend to includehttps://alignmentatlas.online(andhttp://localhost:5173if you still need local dev). - Local dev: With default config, open http://localhost:5173/lumina. To develop at the root (http://localhost:5173/), add
VITE_BASE_PATH=/tofrontend/.env.
The repo includes workflows for staging and production with tests and optional approval for production.
| Workflow | Trigger | What runs | Approval |
|---|---|---|---|
| CI | Push to main/staging, or PR to main |
Backend tests (pytest), frontend build + tests (Vitest) | β |
| Deploy Staging | Push to main |
Same tests, then deploy to staging environment | β |
| Deploy Production | Manual (workflow_dispatch) |
Same tests, then deploy to production environment | Required (see below) |
Tests required for staging/prod to pass
- Backend:
backend/tests/(pytest). Run locally:uv run pytest backend/tests/ -v - Frontend: Build + unit tests. Run locally:
cd frontend && npm ci && npm run build && npm run test
Setting up production approval
- In GitHub: Settings β Environments.
- Create environment
production(and optionallystaging). - Under production, enable Required reviewers and add yourself (or your team). When Deploy Production runs, it will pause at the βDeploy to Productionβ job until an approver approves it in the Actions tab.
Configuring deployments
Deploy jobs are placeholders. Edit:
.github/workflows/deploy-staging.ymlβ add steps to deploy to your staging host (e.g. Cloud Run, Vercel, or SSH +docker compose)..github/workflows/deploy-production.ymlβ add steps to deploy to production. Use Settings β Secrets and variables β Actions for tokens/keys (e.g.VERCEL_TOKEN,PROD_SSH_KEY).
To deploy production: open Actions β Deploy Production β Run workflow, enter deploy in the confirmation input, run; then approve the production environment when prompted.
-
POST /v1/insights
Body:{ "text": "one sentence", optional "user_id" }.
Pipeline: normalize β duplicate check β guardrail β embed β cluster β stance β save β edges β supporters/challengers.
Returns:node,cluster,supporters,challengers,subgraph,moderation_status,guardrail.
On reject: 400 with guardrail; on revise: 422 with guardrail (e.g.suggested_revision). -
GET /v1/graph
Query:node_id(optional),depth(1β3),budget(10β500).
Nonode_id: recentbudgetinsights and edges among them.
Withnode_id: BFS neighborhood withindepthandbudget, with cluster metadata.
Returns:nodes,edges,clusters. -
POST /v1/chat
Body:mode("support" | "debate"),seed_insight_id,user_message,conversation_state(array of{role, content}), optionaluser_belief,counterparty_belief.
Returns:mode,response,conversation_state(updated with new turn),guardrail.
- Cluster titles/summaries are minimal stubs in this baseline; they can be upgraded with an LLM summarization step.
- Duplicate detection uses a normalized key (lowercased, punctuation trimmed, whitespace collapsed) and a unique index on the DB; duplicates return the existing insight and its supporters/challengers without re-running the pipeline.