vccs
Vue 3용 차트 컴포넌트 모음, Recharts에서 포팅
세인트루이스 311 보고서에서 반복 불법 투기 지역을 찾아 사진으로 확인하고, 대응 시점과 위치를 계획한다
Finds repeat illegal-dumping hotspots in St. Louis 311 reports, confirms them in street photos, and plans where and when to act.
데이터TypeScript · JavaScriptGitHub @summithack-stl온라인운영자 미확인
써 보셨어요? 한 번만 눌러 주세요. 로그인은 필요 없어요.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기SummitHack 2026 – St. Louis civic AI hackathon
저장소 README에서 가져옴
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Finds the St. Louis blocks where illegal dumping keeps coming back, shows when it gets reported, and writes a camera and cleanup plan for city staff.
Try it: https://tipoff-stl.vercel.app (no login). Pick Dumping, click a hotspot, press Write plan. The morning crew list is at /crew.
Congressional App Challenge entry by Prahlad Venugopalan, Vedant Pawankar and Sarthak Chaudhary.
Run it yourself (Node 20+): git clone https://github.com/summithack-stl/TipOff.git && cd TipOff/app && npm ci && npm run dev, then open http://localhost:3000. The map, scores and crew list work with no keys; the AI plan and photo check need a free Groq key in app/.env.local (see Run locally).
TipOff is a PWA: crews install it from the browser, with no app store and no account.
Installed, it runs full screen and opens straight to /crew, today's list. Reading the map and plans never needs a login.
The icons come from app/src/app/icon.svg; see app/public/icons/README.md to regenerate them.
ios/TipOff/ also contains an iOS 16+ app written in Swift, using SwiftUI and WebKit. It opens the live crew board in a WKWebView, supports pull-to-refresh and back gestures, and sends external links to the system browser or Maps. This is a native shell around the web app, not a separate native implementation of the map. Its Swift package and xtool.yml describe the build; installing it requires iOS signing. The browser PWA above is the public installation path. The shell has no bundled offline copy and needs a connection to load the site.
Crews are outside, where signal drops. app/public/sw.js is a hand-written service
worker (no build step, no extra dependency) that keeps a copy of what matters on the phone:
| What | How it is cached | Why |
|---|---|---|
Pages (/crew, /map, anything opened) | network first, cache on the way past | always fresh with signal, still there without |
/api/hotspots, /api/vacant, /api/hotspots/[id] | stale while revalidating | the list and map draw instantly, then catch up |
| Plans already opened | kept under the hotspot id, replayed when the network fails | a crew can read a block's plan with no bars |
| Map tiles already looked at | cache first, capped at 600 | the map is not blank off-grid |
/_next/static, icons | cache first | hashed by the build, so they cannot go stale |
A replayed plan is labelled "Saved on this phone", so nobody mistakes it for a fresh answer. Live calls
(/api/frames, /api/health) are never served stale: they fail honestly instead. A page never opened
before falls back to /offline. A banner across the top says when the phone has no signal.
Bump VERSION in app/public/sw.js to retire every cached copy at once.
Civic AI Hackathon, Problem Set #3: Illegal Dumping. The City raised the maximum dumping fine from $500 to $1,000 in 2022, but it still struggles to gather evidence: even where cameras are installed, footage often misses the plate or the face. The City asked for help to "proactively identify hotspots of dumping or likely areas/times that offenders frequent to target preventative action."
City staff use TipOff to prioritize work. Residents can keep using 311 or submit a dumping photo through TipOff’s report page. TipOff photo reports do not create an official municipal 311 ticket.
/map, crew list at /crew)At /report, take or attach a JPEG, PNG or WebP photo (up to 4 MB), then use GPS or place a pin. The server checks that the location is in St. Louis City, Ballwin or Chesterfield and uses the Groq/Gemini vision pipeline to look for dumping before saving it. Exact duplicate photos are rejected. A secret-keyed HMAC of the sender’s connection groups repeat submissions so one reporter counts once per block; it does not verify a person or residency.
A block becomes a dumping hotspot once its historical 311 reports plus distinct photo reporters reach five. Photo-only scores are provisional. The public map shows block-level hotspots rather than individual report records, photos or reporter hashes. Production reports and photos are stored in Vercel Blob using public object URLs; that storage is not access-controlled private storage. Locally they are saved under app/.data/reports/. Never include faces, plates or other sensitive information in demo uploads.
The map’s area selector covers St. Louis City, Ballwin and Chesterfield, using the same map library. Ballwin and Chesterfield are checked against Census city boundaries. Historical municipal 311 data is connected only for St. Louis City. The county areas start from TipOff photo reports; missing history is labelled as unavailable, not zero, and the St. Louis backtest does not validate those photo-only scores.
We scored every dumping hotspot as if it were a year ago, using only reports up to 2025-10-14, with the same formula the app uses. Then we checked the next 12 months, which the score never saw:
| Top N blocks | Got dumping or debris reports again |
|---|---|
| 10 | 9 |
| 50 | 42 |
| 100 | 83 |
Full table and method, exactly as shown in our video: data/backtest/2026-10-14-video-RESULTS.md. The nightly job also re-runs the test on the newest data in data/backtest/RESULTS.md, so the proof keeps checking itself; those numbers can shift slightly as the one-year window moves. Re-run it yourself with python data/scripts/backtest.py.
data/scripts/build_hotspots.py splits the city into squares about 155 m on a side (roughly a block). A square is a hotspot once it has 5 or more reports of one type since January 2024. Each hotspot gets a 0-100 score from its last 12 months: report volume (ranked against hotspots of the same type) 40%, how many different weeks it was reported in 30%, trend against the 12 months before 15%, vacant buildings within 150 m 15%. The score orders where to look first; it does not predict crime and never identifies people.
/map draws every repeat hotspot, so at city zoom it clusters them: a grid in screen pixels, one
marker per cell, labelled with its count and coloured by the worst score in it. Tapping a cluster
zooms in. Past zoom 13 every hotspot draws as itself. The rule is clusterHotspots() in
app/src/lib/cluster.ts — about 40 lines, rather than another dependency.
Standing on a block, a crew gets Directions (Apple Maps on iOS, Google Maps elsewhere) and
Share this block, which sends a /map?h=<id> link that opens straight on that hotspot.
POST /api/plan, gpt-oss-120b on Groq): turns a hotspot's sourced facts into a plan (what we know, where and when to watch, actions, evidence for a citation). The prompt allows only the given facts: no invented dates, counts, people, vehicles or causes.POST /api/frames): a fast vision model on Groq flags dumping, potholes, graffiti and curb or sidewalk damage; Gemini double-checks anything uncertain or severe. On 53 hand-labeled test photos it found 11 of 13 street problems (2 of 4 dumping piles), so the 311 history does the heavy lifting.Every night a Vercel Cron calls /api/cron/refresh (app/vercel.json). It downloads only the 2024-2026 entries of the City's 311 zip (byte ranges, ~28 MB of the 128 MB file), rebuilds the hotspots and the backtest in TypeScript (app/src/lib/refresh/), checks the result against what is published, and publishes it to Blob storage, outside git. A refresh that comes out wrong (a truncated file, half the hotspots gone, older data than what is up) is refused and yesterday's data stays. GET /api/refresh says when the data last refreshed and why the last try failed, if it did.
The TypeScript build is a port of data/scripts/*.py, and node app/scripts/refresh-parity.mjs <checkout> checks it against the Python output field by field. Until the cron has proven itself in production, the original .github/workflows/refresh-data.yml keeps running too: it rebuilds with Python, commits app/data/clean/, and Vercel redeploys. That bundled data is also the fallback when no Blob store is connected.
To connect the City's own 311 database instead of the public CSV, replace fetchStreetReports in app/src/lib/refresh/source.ts (and the download step in data/scripts/fetch_311.py while both run) with a query that returns the same columns (request ID, problem code, location, time opened, status). Nothing else changes.
| Dataset | URL | How we use it |
|---|---|---|
| Citizens' Service Bureau (311) requests (City of St. Louis) | https://www.stlouis-mo.gov/data/datasets/distribution.cfm?id=2 | 40,426 reports of dumping and debris (6 problem codes; 2,157 use the "Illegal dumping" code), potholes and graffiti from 2024-2026, grouped into 2,950 repeat hotspots — 1,186 dumping, 1,420 pothole, 344 graffiti (data/scripts/fetch_311.py, build_hotspots.py). We keep location, type and time only, never the caller or free-text fields. Report times are when residents reported, not when dumping happened. |
| Vacant Building List (City of St. Louis) | https://www.stlouis-mo.gov/data/datasets/dataset.cfm?id=108 | The 9,943 buildings on the city's vacant list, with code violations, unpaid vacant-building fees and time on the list. Used as context for hotspots (vacant buildings within 150 m). |
| Parcels / assessor data (City of St. Louis) | https://www.stlouis-mo.gov/data/datasets/dataset.cfm?id=82 | Location, ward and year built for each vacant parcel (9,884 have a location). |
| LRA inventory (Land Reutilization Authority) | https://www.stlouis-mo.gov/data/datasets/dataset.cfm?id=30 | Flags the 338 vacant buildings the city itself owns. |
| Mapillary street-level imagery | https://www.mapillary.com | Street photos at the top 311 hotspots and the riskiest vacant buildings (CC BY-SA 4.0, credited in app/public/photos/index.json). |
| Wikimedia Commons | https://commons.wikimedia.org | Street-issue test photos for the accuracy check (licenses in app/eval/sources.json). |
Python scripts in data/scripts/ download and clean the city data into app/data/clean/. The server reads refreshed Blob data when available and otherwise uses those bundled files. Interactive map requests do not query city APIs; the scheduled refresh downloads the public source file.
city open data ──(data/scripts/*.py)──> app/data/clean/*.json ──┐
├──> Next.js app (Vercel)
Mapillary photos ──(scripts/fetch-building-photos.mjs)──> public/photos/
│
POST /api/frames photo ──> fast vision pass (Groq) ──> unsure or severe? ──> second opinion (Gemini)
│ rate-limited or down: Gemini does the whole frame
POST /api/plan hotspot facts ──> camera and cleanup plan (gpt-oss-120b on Groq, sourced facts only)
POST /api/brief building record + photo findings ──> short firefighter brief (Groq text model)
GET /api/health LLM reachable? which vision providers are configured?
All model calls go through app/src/lib/llm.ts (server only). Each route is rate limited per client and never returns raw provider errors.
Before Problem Set #3 was announced we built a vacant-building inspection map; its code is still in the repo (/api/brief, fetch_vacant_buildings.py), and TipOff uses the vacant-building list only as one hotspot signal. Every vacant building gets a 0–100 score from city records only. Each part is capped so no single field dominates: major code violations up to 35 points, time on the vacant list up to 20, building age up to 15 (pre-1900 gets all 15), minor code violations up to 10, recent 311 complaints up to 10, unpaid vacant-building fees up to 10. The formula lives in data/scripts/fetch_vacant_buildings.py. The score orders buildings for inspection. It does not predict collapse. Each building keeps its plain-language reasons (for example "29 major code violations").
| Route | Input | Output |
|---|---|---|
POST /api/frames | multipart form: image (JPEG/PNG/WebP, max 4 MB), camera_id, optional lat, lon, captured_at, confirm (1 default, 0 skips the second model), precise (1 sends the whole frame to the second model for tighter boxes) | { detections: [{ category, box: {x,y,w,h} (0–1), confidence, severity, note, confirmed }], ms } |
POST /api/plan | JSON { id: "hotspot id, e.g. dumping-27654_-50128", detections?: [{ category, severity, note }] } | { id, headline, what_we_know[], watch[], actions[], evidence[] } |
POST /api/reports | multipart image, lat, lon | Accepted photo finding, block hotspot if qualified, report count and reports still needed; does not create a city 311 ticket. |
GET /api/reports | none | Block-level hotspots created or changed by photo reports; no individual report records. |
GET /api/refresh | none | Latest refresh status and failure reason, if any. |
POST /api/brief (earlier direction) | JSON { handle: "11-digit parcel handle", detections?: [{ category, severity, note }] } | { handle, headline, hazards[], crew_notes[] } |
GET /api/health | none | { ok, provider, model, vision: { fast, confirm }, reply, ms } |
GET /api/hotspots | none | Every hotspot the map draws, one array a row: [id, category, lat, lon, ward, score, report_count, activity, address]. activity packs the busy weekdays (bits 0-6, Sunday first) and busy report-hour blocks (bits 7-10). Cached hard; rebuilt by the nightly refresh. |
GET /api/hotspots/[id] | none | One hotspot in full: monthly history, recent reports, reasons, nearby vacant buildings. Fetched when a hotspot is opened. |
GET /api/vacant | none | The vacant-building layer, a row. Fetched only when a crew switches the layer on. |
Categories: building hazards open_entry, roof_damage, wall_damage, fire_damage; street issues graffiti, pothole, dumping, streetlight_out, broken_curb, sidewalk_damage.
Errors: 400 bad input, 404 unknown building or hotspot, 413 image too large, 429 too many requests from one client (see Retry-After), 502 AI call failed, 503 AI provider busy.
Groq's free vision tier allows about 200,000 tokens a day, which is roughly 95 photos (about 2,100 tokens each), shared by every key in the same Groq organization. When it runs out, /api/frames falls back to Gemini for the whole frame. For demos, use the saved detections in public/photos/index.json or set DEMO_CACHE=1.
That is a daily limit for the whole organization, so a few people using the photo check would
exhaust it before lunch. The per-minute rate limit in app/src/lib/guard.ts does nothing about it,
so each AI route also has a daily budget (DAILY_LIMITS), counted per client and for the whole
deployment, resetting at midnight in St. Louis:
| Route | Per client | Whole deployment |
|---|---|---|
POST /api/frames | 5 a day | 90 a day |
POST /api/plan | 20 a day | 400 a day |
A refused call charges neither budget, so one phone that is already over its allowance cannot burn
the team's. GET /api/health reports what is left, so a supervisor can see it before sending crews
out. Plans are cached per hotspot and nightly build, so a whole crew opening the same block costs
one LLM call — and a cached plan is not charged against anyone's budget, because no AI was spent.
Both budgets are in memory per server instance, like the rate limiter, so on Vercel they are a floor on protection rather than a hard cap. Vercel KV would make them exact.
cd app
npm i
cp .env.example .env.local
# Set LLM_API_KEY in .env.local, then:
npm run dev
Open http://localhost:3000/api/health to check the LLM connection and see whether the vision keys are set. Without an API key, the endpoint reports ok: false in its JSON response.
Copy app/.env.example to app/.env.local and set:
| Variable | Purpose |
|---|---|
LLM_BASE_URL | OpenAI-compatible API base URL; the example uses Groq, with Gemini settings commented out. |
LLM_API_KEY | API key for the chosen provider. Keep it in .env.local. |
LLM_MODEL | Model name available from the chosen provider. |
DEMO_CACHE | 0 by default. Set to 1 to reuse cached LLM replies for matching prompts during demos. |
VISION_BASE_URL, VISION_API_KEY, VISION_MODEL | Fast photo pass (Groq vision). |
CONFIRM_BASE_URL, CONFIRM_API_KEY, CONFIRM_MODEL | Second opinion and fallback (Gemini). |
DEMO_CACHE=1 is useful for repeatable demos and fewer live API calls. Turn it off when you need fresh replies.
Run these from app/:
| Command | Purpose |
|---|---|
npm run dev | Start the local development server. |
npm run build | Create a production build. |
npm run start | Serve the production build. |
npm run lint | Run lint checks. |
npm run typecheck | Run TypeScript with --noEmit. |
| Name | Role |
|---|---|
| Prahlad Venugopalan | Tech lead: app, data/scoring integration, backend and model APIs, iPhone shell |
| Vedant Pawankar | Frontend: hotspot map, overlays and panels; animated video scenes |
| Sarthak Chaudhary | Crew board, app redesign and feature integrations; presentation and video |
TipOff began at the St. Louis Civic AI Hackathon. Original-version credits: Arjun Arun contributed the 311 data extract and hotspots; Aarnav Baddam contributed DevOps/deployment. They are credited for that work separately from the three-person Congressional App Challenge team.
[handle, lat, lon, risk, ward]