Skip to content

lksbrssr/pl-radar

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

88 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

PL R&D Radar β€” Crowd Curator πŸ›°οΈ

A Telegram bot that uses the wisdom of the crowd to decide what goes into the public PL R&D Radar β€” the monthly "catch up in a minute" digest of the strongest signals across Protocol Labs R&D's focus areas.

Curators opt in with a single message, answer two quick questions, then get a few pairwise match-ups a day: two candidate cards, tap the stronger one. The winner stays on the throne and faces a fresh challenger (the "king-of-the-hill" mechanic). Aggregated across dozens of curators, these taps produce a robust ranking of what deserves to be on the Radar β€” plus a read on which segments care about what.

New here (agent or human)? Read AGENTS.md first β€” it's the cold-start briefing: stack, hosts, how to run locally, and how to ship a PR with a live preview. You should be able to start a fresh session, say "we're working on the PL Radar thing, repo is lksbrssr/pl-radar", and file a PR without a long ramp-up.

Status: Live in production on Fly.io (Telegram bot + web app + API). The web app (Radar carousel, month + composite-lens selectors, Data view) is in review. In-browser voting and AI-assisted card/source submission (paste a URL, an LLM drafts a reviewable card; dedup runs on add) are live. Next: internal-source ingestion.


Why this exists

The Radar was previously curated by recency + a hand-picked list. That doesn't scale and misses the collective taste of the org. This project replaces the guesswork with a low-friction voting mechanism:

  • Low friction: it lives entirely in Telegram. No app, no login, no website to visit. Onboarding is under a minute.
  • Statistically sound: pairwise "A beats B" votes feed an Elo rating (the same family as chess ratings and the Bradley-Terry preference model), which turns noisy individual taps into a stable, confident ordering.
  • Segment-aware: curators are tagged by role and focus area at signup, so we can ask "who values what?" β€” e.g. do investors rank governance signals higher than researchers do? This is the lightweight conjoint/part-worth analysis the product brief asked for.

Architecture at a glance

        Telegram (curators)                    Consumers
              β”‚  taps                                β–²
              β–Ό                                       β”‚ read-only JSON
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚  grammY bot (polling) β”‚        β”‚  plrd.org Radar   PL app-store    β”‚
   β”‚  onboarding + voting  β”‚        β”‚  (picks winners)  dashboard       β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–²β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
               β”‚ writes votes                          β”‚
               β–Ό                                        β”‚
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   Elo + segments   β”Œβ”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚  SQLite (single file) β”‚ ─────────────────▢ β”‚  Express HTTP API β”‚
   β”‚  votes = source of    β”‚                    β”‚  /api/*.json      β”‚
   β”‚  truth; Elo is a cache β”‚                   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

One backend, three read-only faces. The bot + database + ranking live in one small always-on process. It exposes a read-only JSON API that both the public Radar and a PL app-store dashboard can consume. Secrets (bot token, DB) never leave this process and never enter a repo or the PL sandbox.

Why this split?

The PL app-store sandbox injects no secrets and no persistent storage, so a stateful bot can't live there. Instead the bot runs on a tiny always-on host (Fly.io), and the outputs are what the app store / plrd.org read.


Repo layout

src/
  config.ts            Env parsing (BOT_TOKEN, DB path, admins, round size)
  types.ts             Focus areas, roles, Card/Curator types
  db/
    schema.sql         SQLite schema (curators, cards, votes, rounds, sessions)
    index.ts           Connection + idempotent schema bootstrap
    repo.ts            All SQL β€” the single place persistence is defined
  ranking/
    elo.ts             Elo rating + full recompute from vote history
    segments.ts        Per-role leaderboards + attribute win-rates (conjoint)
  bot/
    index.ts           Commands + callback routing
    onboarding.ts      3-step signup wizard (role β†’ focus β†’ cadence)
    voting.ts          King-of-the-hill match-up flow
    keyboards.ts       Inline keyboards
    copy.ts            All user-facing text + progress-bar helper
    session.ts         Transient per-curator flow state type
  http/server.ts       /health + read-only /api/*.json + /api/submit/* (AI submit)
  submit/              Paste-a-URL submission: fetch.ts β†’ llm.ts β†’ parse.ts + dedup.ts
  scheduler.ts         Optional daily nudge + background re-ingest
  seed/                Sample cards + `npm run seed`
  dev/simulate.ts      Offline harness to sanity-check the ranking maths
  index.ts             Entry point (starts HTTP + bot together)
Dockerfile, fly.toml   Deployment (Chunk 3)

Quickstart (local)

1. Create a bot with @BotFather (Telegram):

  • Open a chat with @BotFather β†’ send /newbot.
  • Pick a name (e.g. PL R&D Radar) and a username ending in bot (e.g. plrd_radar_bot).
  • BotFather replies with a token like 123456:ABC-…. Keep it private.
  • (Optional) /setdescription and /setuserpic to make it look polished.

2. Configure & install:

cp .env.example .env          # then paste your BOT_TOKEN into .env
npm install
npm run seed                  # load sample cards to vote on

Find your own Telegram user id via @userinfobot and put it in ADMIN_IDS in .env to unlock /stats and /leaderboard.

3. Run:

npm run dev

Now message your bot /start in Telegram and walk through onboarding β†’ voting.

Verify the ranking without a bot (no token needed):

npm run seed
npx tsx src/dev/simulate.ts   # fabricates curators, prints leaderboards

The curator experience

  1. /start β†’ a warm welcome explaining the 30-seconds-a-day deal.
  2. Step 1/3 β€” Role (πŸ”¬ Researcher, πŸ’Έ Capital, …) β†’ segment tag.
  3. Step 2/3 β€” Focus areas (multi-select, toggles with βœ…) β†’ segment tags.
  4. Step 3/3 β€” Cadence (2/3/5 per day, or 🎲 surprise me).
  5. Done πŸŽ‰ β†’ one tap to start the first round.

Each step edits the same message in place and shows a progress bar (β–°β–°β–± Step 2 of 3), so it feels like one smooth card. Every match-up also edits in place, so a whole round is one live message rather than a wall of texts. The reigning champion is marked πŸ‘‘ reigning so curators feel the NYT-style "does my pick survive the next challenger?" hook.

Commands: /vote (start a round), /settings (redo preferences), /pause / /resume, /help. Admins also get /stats and /leaderboard.


The data + ranking model

  • votes is the source of truth β€” every raw "winner beat loser" pairwise preference, tagged with the curator (and therefore their segment).
  • Elo is a derived cache on each card for instant leaderboards; it can be recomputed from scratch at any time (ranking/elo.ts β†’ recomputeElo).
  • Segment analysis (ranking/segments.ts) slices the same votes:
    • leaderboardForRole(role) β€” Elo using only that segment's votes.
    • attributeWinRates(attr, role?) β€” how often cards with a given attribute (focus area / content type / internal-vs-field) win, per segment. This is the conjoint-flavoured "what pulls each segment's preference" view.

Web app (the / dashboard)

The HTTP root serves a single-page app (Inter + Newsreader, light/dark, styled like plrd.org) with a left sidebar:

  • Radar β€” the published Radar for a chosen month as a swipeable carousel (top 5 + "you're all caught up" + Share on X), recycled from plrd.org/insights. A lens selector switches between the General Radar (all votes) and a peer segment (your role or focus area) β€” "what people like you found most relevant".
  • Vote β€” participate in voting (full in-browser flow is the next PR; for now it links to the Telegram bot).
  • Data β€” curation analytics: who-values-what, per-role preference, curators.

Content is organised by monthly edition: only the current month is open for voting; past months appear as published Radars. Old cards expire out of the pool automatically.

Read-only API (what consumers use)

Endpoint Purpose
GET /health Liveness probe (required by Fly/PLN).
GET /api/editions.json Months available, with labels + a current flag.
GET /api/radar.json?edition=&lens=&limit=5 Top cards for an edition through a lens (general / role:<key> / focus:<slug>), in RadarItem shape.
GET /api/radar-candidates.json?edition=&limit=6 Current-edition winners for plrd.org to ingest.
GET /api/overview.json Everything the Data view needs (stats, win-rates, curators).
GET /api/leaderboard.json Global Elo ranking of the card pool.
GET /api/segments.json Per-role leaderboards + attribute win-rates.

radar-candidates.json intentionally matches the RadarItem type in plrd.org/src/components/PLRadar.tsx, so the Radar can fetch winners with no transformation (see Integrating with plrd.org below).


Deployment (Chunk 3 β€” Fly.io)

The bot must stay awake 24/7 (long polling), which rules out free tiers that sleep. Fly.io runs a tiny always-on machine (~$2/mo, often within free allowance) with a persistent volume for the SQLite file.

fly launch --no-deploy                 # create the app (uses fly.toml)
fly volumes create data --size 1       # 1 GB volume for the SQLite file
fly secrets set BOT_TOKEN=… ADMIN_IDS=… DAILY_NUDGE_HOUR=9
fly deploy

Secrets live only in Fly's secret store β€” never in the repo. The read-only API is exposed over HTTPS for plrd.org and the app-store dashboard to fetch.


Integrating with plrd.org

The public Radar currently derives cards by recency + a hand-picked radar-signals.ts. To let the crowd drive it, fetch the curated winners at build time:

// plrd.org β€” in the Insights page data loader
const res = await fetch(
  'https://plrd-radar-curator.fly.dev/api/radar-candidates.json?limit=6',
  { next: { revalidate: 3600 } },
)
const { items } = await res.json()   // items already match RadarItem

Winners flow straight into the existing PLRadar component. (Deferred until the backend is deployed and has real votes.)


Contributing & previews (agents + humans)

Full workflow lives in AGENTS.md. The short version:

  • Branch off main, npm run typecheck, open a PR (gh pr create --base main).
  • Visual change β†’ screenshot in the PR. Commit the PNG under docs/screenshots/ and link it by commit SHA (branch names have slashes that break raw URLs): https://raw.githubusercontent.com/lksbrssr/pl-radar/<SHA>/docs/screenshots/foo.png
  • Live preview of your branch (Vercel-style, on Fly β€” web-only, no bot, self-seeds demo data):
    fly deploy --config fly.preview.toml --app plrd-radar-curator-preview
    Then put https://plrd-radar-curator-preview.fly.dev/ at the top of the PR. Anyone can open that URL (public HTTPS, no login).

Multiple agents working at once

Assume others are pushing in parallel:

  • Rebase before merge: git fetch origin && git rebase origin/main then git push --force-with-lease. Branches go stale fast.
  • The preview URL is shared β€” last deploy wins, so deploying your branch overwrites whoever deployed before you. Treat it as transient (deploy β†’ demo β†’ move on). Need two previews at once? Spin up a throwaway app: fly apps create plrd-radar-curator-pr-N --org personal β†’ deploy with --app plrd-radar-curator-pr-N β†’ fly apps destroy it after.
  • Only ONE Telegram bot may poll (production). Never run npm run dev (bot) against the prod token from a second session β€” use npm run dev:serve for UI.

Roadmap

  • Voting bot β€” onboarding, king-of-the-hill, SQLite, Elo.
  • Ranking + segments β€” Elo, per-role / per-focus leaderboards, read-only API.
  • Deployed to Fly.io β€” bot + web + API live; results dashboard.
  • Web app β€” sidebar (Radar / Vote / Data), monthly editions, swipe carousel recycled from plrd.org, composite peer lens (role + interests).
  • In-browser voting β€” interest onboarding + king-of-the-hill on the web, reusing the profile/lens concept (next PR).
  • PL app store β€” package the web app for the LabOS AI-apps dashboard.
  • plrd.org wiring β€” Radar consumes /api/radar-candidates.json winners.
  • Internal-source ingestion β€” Doro (news crawler), PL Platform, PL Capital portfolio/sourcing, focus-area lead picks β†’ cards rows.

Notes for maintainers

  • Minimal deps by design: grammY (bot), better-sqlite3 (storage), express (API). No ORM, no message broker β€” the scale (dozens of curators) doesn't warrant it, and SQLite β†’ Postgres is a small migration if it ever does.
  • Everything is recomputable. Delete the Elo cache, re-derive from votes.
  • No secrets in the repo. .env and .data/ are git-ignored; the deploy path uses Fly secrets.

About

Crowd-curation Telegram bot for the PL R&D Radar: pairwise king-of-the-hill voting + Elo/segment analysis surfaces the strongest signal.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors