尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证正在加载完整 README。 以下为已保存的摘录。
When friends can't agree where to eat, a pair of weighted dice makes the call, so nobody has to.
You said «anywhere is fine». Did you mean it, or did you just not want to be the one who picked?
Try it in your browser, nothing to install: uptoyou.jacksong-tw.com
A few friends want dinner. Everybody has a mild preference. Twenty minutes later nobody has chosen,
or the loudest voice did.
Choosing where a group eats goes wrong in three familiar ways:查看完整 README ↗When friends can't agree where to eat, a pair of weighted dice makes the call, so nobody has to.
You said «anywhere is fine». Did you mean it, or did you just not want to be the one who picked?
Try it in your browser, nothing to install: uptoyou.jacksong-tw.com
A few friends want dinner. Everybody has a mild preference. Twenty minutes later nobody has chosen, or the loudest voice did.
Choosing where a group eats goes wrong in three familiar ways:
Up to you hands the choice to a draw nobody owns, and lets those facts change each place's chances in the open:
Two dice have 36 outcomes, one per drawer of the 籤詩櫃 (a temple's cabinet of fortune slips). Each proposed place holds some of them, and the group's facts change how many:
| Factor | Effect on a place's odds | Why |
|---|---|---|
| Rain over its district | lowered, relative to the driest district in tonight's pool | walking in the rain is a real cost |
| The circle went there last time | halved | variety, without removing the option |
| Someone avoids its category | lowered by 1/N, N = seats at the table | one objection among five weighs less than among two |
| Nothing applies | unchanged | every place starts at 1 |
These are weighted dice, not a ranking. An avoided category is a discount, not a veto: with five at the table it loses a fifth and stays reachable. The constants are written policy (last time ×0.5; one avoider takes 1/N off, so ×0.8 at a table of five; rain ×(1 − gap/120), never below ×0.5); with no record of what real groups chose, there is nothing to train a scorer on, and a stated rule can be checked.
Try this in 30 seconds. Open the site and create a circle (a group deciding one meal). Copy the invite link and open it in a private window: that is your second friend. Each of you proposes a place and presses 提交 (submit); when everyone is in, the reveal opens.
What the live demo holds: Taipei's restaurants only, on one small server sized for a few groups at once. No account, no email.
Open the home page: today's weather, and 開一個圈子 to start.
Create a circle: name the group and yourself.
Send the invite link; a friend who opens it joins the circle.
A friend joins with a nickname.
Propose places, and tick 這次不想吃的類別 (the categories you'd skip tonight; your own, never shown to others).
Press 提交 (submit); the round lists 還沒提交 (not yet) and 已提交 (submitted) until everyone is in.
The reveal: dice or the 籤筒, picked per round, then the drawer and its fortune slip.
Everything below is how it's built.
Where to read next: The data pipeline → · The classifier → · The draw and privacy → · Run it yourself → · How it was built, with AI agents →
| Before → after | What was measured | |
|---|---|---|
| Data pipeline | A night with no new data: 15.0 s → 1.6 s | Seven government sources (six scheduled jobs: one weather job feeds two of them) keep the production database's 35,965 Taipei restaurants current. An unchanged file is recognised by the hash of its bytes and never re-read. |
| AI classification | Cuisine labels: 51.5% → 61.0% agreement with the reference labels, by adding retrieval | A local two-billion-parameter model labels each restaurant, scored on 200 frozen names whose reference labels were drafted by one large model and cross-checked by a second. Retrieving five labelled look-alikes first gave the jump, and, compared name by name on the same 200, 33 names went from wrong to right against 14 the other way (McNemar p ≈ 0.008). The set was later re-cut to thirteen categories with 19 rows relabelled; there it reads 71.0% (a different set, so the two are not compared). An MCP tool lets an AI agent ask, through six fixed questions and without writing SQL, which government file or which ingest run a record came from. |
| Production | The API's live link to the database is cut: /health reports it in 0.18 s, reconnects in 1.23 s | Live on one EC2 behind Cloudflare. A failed nightly job sends a phone alert; the nightly backup's restore is rehearsed by hand: 9.4 s on the development database. |
Colours in the charts: blue public data · green weather · vermilion the product · grey stored · purple the scheduled pipeline · yellow alerts and models.
Seven sources in six ingest jobs: the CWA weather job brings in both the forecast and the station observations. Every source is published open data, used inside its licence. Sources are joined on the registry numbers they share (統編, 登錄字號), never on fuzzy name matching.
| Taipei time | Job | What it brings in |
|---|---|---|
| hourly | CWA weather | township forecast F-D0047-061 + station observations O-A0001-001 |
| 03:00 | 食藥署 食品業者登錄 | the restaurant reference list |
| 03:20 | 臺北市食材登錄平台 | company ↔ brand pairs |
| 03:40 | 臺北市餐飲衛生分級評核 | the shop sign an inspector saw |
| 04:00 | 商業登記-餐館業 | which registrations are dead |
| 04:20 | 財政部 全國營業(稅籍)登記 | tax name + industry code |
| 04:30 | freshness check | names any source that stopped publishing, and for how many hours |
| 05:00 | preference erasure | deletes per-meal preferences no draw used |
| 05:40 | weather retention | deletes readings older than ninety days no draw used |
| 06:00 | circle sweep | removes abandoned circles |
| 06:20 | backup | pg_dump to S3, after the deletions; the last thirty kept |
| 07:00 | dataset export | a Parquet file of every place, to S3 beside the dump |
The daily sources start twenty minutes apart to keep two from competing for the server's memory, and the deletions are scheduled before the backup.
A fetched file is identified by the hash of its bytes, not a timestamp, the way git names a
commit. A timestamp can change while the data stays the same. The reference ingest hashes a 17 MB zip
and claims that hash with insert … on conflict do nothing returning id: an id comes back and the
99 MB CSV inside is decompressed, or nothing comes back and the run stops there.
The conflict is the answer: the content is unchanged, so the parse is skipped. That is the 15.0 s → 1.6 s above. The claim is one statement, so two runs starting together cannot both decide the file is new.
Measured on the schedule itself, 8 days, 332 run-log rows:
| Source | New file stored (median) | Nothing new (median) |
|---|---|---|
| CWA township forecast | 2.99 s | 1.85 s |
| CWA station observation | 3.09 s | 0.31 s |
| 食材登錄 brands | 0.57 s | 0.96 s |
| 衛生評核 signs | 0.31 s | 0.43 s |
| 商業登記 status | 25.03 s | 2.09 s |
| 營業稅籍 registry | 12.95 s | 3.58 s |
A night with nothing new saves 3.6× on the tax registry and 12× on the business registration. Airflow's own overhead is flat, about 1.2 s a task.
The government list says 安心食品服務股份有限公司. You know it as 摩斯漢堡. 40.2% of registered names are company names that name no shop. So a display name is resolved down a ladder: the sign an inspector recorded, then the brand, then the registered name, joined by registry number with fixed precedence.
| Step | Rows | Share |
|---|---|---|
| sign | 1,355 | 3.7% |
| brand (single-brand companies) | 4,017 | 11.0% |
| registered name (the rest) | 31,215 | 85.3% |
On the reference list of 2026-10-07 (36,587 rows), measured 2026-10-10.
Overture Maps, the open global places dataset, was tried and dropped: 39.5% trustworthy matches and 46% false joins on address alone.
The production database is 490 MB: 35,965 places, 25,031 with a generated category. Source tables keep every publication side by side, so a new file adds a full set of rows and never overwrites the last one.
| Table | Rows | What it holds |
|---|---|---|
place | 35,965 | a place a circle can choose |
reference_place | 72,963 | the government reference list, two publications (36,376 and 36,587 rows) |
storefront_name | 1,686 | the sign an inspector recorded |
brand_registration | 288 | company ↔ brand pairs |
business_tax_row | 363,412 | tax-registry name and industry code |
business_status_row | 419,896 | business-registration status |
forecast_reading | 970,190 | township forecast readings |
observation_reading | 136,800 | station observation readings |
ingest_run | 1,766 | one row per ingest attempt that finishes or finds its source unavailable, «no change» included |
Every weight the dice use is a stored row linked to the reading it came from, and a lineage tool answers «where did this number come from» for an AI agent (below).
No published source says what a restaurant serves. So a local model reads the best name the pipeline holds and picks one of thirteen cuisine categories (麵食, 火鍋, 日式, 咖啡飲料, …). An answer outside the thirteen is refused.
It is RAG in three steps:
gemma2:2b answers with one category.Missing knowledge is added as labelled examples; the prompt and the model's weights stay as they are.
One frozen set of 200 names, drawn once, stratified over the three kinds of name (at least 30 per stratum), and never re-drawn between rounds. Its reference labels were drafted by one large model and cross-checked by a second, not labelled by a person, so the metric is agreement: the share of names where the model's category equals the reference label. On 200 names one score carries a band of about ±6.5 points. A difference between two scores is wider, about ±9.7 at 51.5% and 61.0%, unless both run on the same names and are compared name by name (McNemar's test). Every candidate runs once on the same set, through the same pipeline a classification pass uses.
| Model | Licence | Agreement on the 200 |
|---|---|---|
qwen2.5:7b-instruct | Apache-2.0 | 72.0% |
gemma2:2b (in use) | Gemma terms | 71.0% |
llama3.2:3b | Llama 3.2 community | 70.5% |
qwen2.5:3b-instruct | research licence, evaluation only | 65.5% |
Why the 2B model: the top three sit inside the set's noise band, so the scores do not separate them. The 2B was already in use, so it stayed; its full city pass took 10.5 hours on one 8 GB graphics card.
Retrieval is measured per model, because the same examples help one model and confuse another: on
the earlier set, with the bge-m3 embedder, retrieval moved gemma2:2b 51.5 → 61.0 and llama3.2:3b
37.0 → 58.0 (compared name by name on the same 200: 33 names wrong→right against 14 the other way,
McNemar p ≈ 0.008; and 52 against 10, p < 0.001), while qwen2.5:3b went 51.0 → 48.5.
The same set also chose the settings. The embedder, the number of retrieved examples and the
prompt were settled on these 200 names, and the scores above are read on the same names, so they
are indicative. A setting chosen on a set is confirmed only on a set it never saw, so a second set was drawn:
200 other names from the same district, labelled the same way by newer drafting models, whose lead
drafter, re-labelling the first set, agrees with its labels on 179 of 200. The configuration in use,
which since September also retrieves from 288 labelled brand names (73.5% on the first set, against
the table's 71.0% without them), has gemma2:2b score 75.5% on it. The difference is well inside the
±8.5 band. The second set's 39 brand-name rows are among the
288 labelled brands, so on those rows retrieval hands back the name itself: those rows measure a
lookup of a known name. That set has now
been used, so any later change needs a fresh one.
Each round writes an answer sheet (every name asked and the parsed answer, with its reference label on a model round's sheet, while the scorer reads every label from the test set; a model round keeps the first 200 characters of the model's raw reply, only for a refused answer, and a nearest-neighbour vote round records the winning vote for every row) and a report.
The sheets were kept out of the public tree until an outside review asked for the rows; then they were
checked for people's names and published. On 2026-10-13 an AI audit the maintainer ran over the public tree asked, so the sheets now sit in evaluation/ beside the
reports; three files from a boot round are held back because they hold sole proprietors' names. Reports
written before that date say «accuracy» where this page says agreement; the scorer's word was changed,
and the older reports keep their wording, except the second set's round, whose report was
re-worded with the scorer.
The embedder was screened the same way (nearest neighbour's label as the answer, leave-one-out):
| Embedder | Agreement |
|---|---|
snowflake-arctic-embed2, bare (in use) | 55.0% |
snowflake-arctic-embed2, with its query: prefix | 53.5% |
e5, with query: | 51.5% |
0.6b, with an instruction | 51.5% |
The method is packaged as an agent skill, model-eval: a frozen labelled set, one metric read against its noise band, one resumable round per candidate and a round table, with a small Python standard-library runner for any Ollama-compatible server.
The whole city, 36,014 names in 10.5 hours on one 8 GB graphics card (about 1.05 s a name on average; the same box's CPU takes 12–19 s). The production server runs no model.
The tax registry carries an industry code. A coffee chain's 245 branches register under a wholesale code, so codes alone would mislabel all of them. The codes are stored at ingest, and nothing reads them yet: a mapping that would trust a code only where it is unambiguous was measured before building and has not been built. Today the model labels every name.
An MCP server over stdio lets an agent ask where a number came from without writing SQL. Six tools
answer: where a forecast reading was published, where an observation came from, which rows each factor
of a draw read, what one ingest attempt did, a source's history, one publication's detail. It connects
as its own database role with read access to twelve tables. A seventh tool, explain_place_loss,
exists only to refuse: why a place lost runs through members' private preferences, so the boundary is
listed where an agent can see it.
The draw's seed is fixed before the first proposal: it is committed when the round opens and revealed when it closes, and both travel with the result, so anyone in the circle can check the draw against what was fixed at the start. Every factor is written as a row when the round closes; the operator's receipt reads those rows back and recomputes nothing.
Privacy is enforced in the database. Closing a round fires a trigger that removes who proposed what, in the same transaction that saves the result. A preference write emits no event at all, because in a small group the moment of an event is one guess away from a name. A nightly job erases unused preferences under a role whose direct deletes are that table and the expired weather readings, plus one function that removes abandoned circles.
What the database roles do and do not guard. The API's own login can read every preference row;
what a member is shown is decided in the application; the database has no row-level rule for it. The
lineage tool's login is an allow-list of tables that leaves out preferences and contributions, and
api/tests/test_role_grants.py asserts that those two reads are
refused. A dump taken before a deletion still holds the deleted row for as long as that backup is
kept, thirty days.
One small EC2, one compose file, one PostgreSQL doing both the relational queries and the vector search, so there is no second service to run and back up for the vectors.
| Layer | What runs |
|---|---|
| Front end | Vite + React 19 + Tailwind 4 + shadcn/ui, built inside the proxy image. Nothing is loaded from a third-party CDN at runtime; Cloudflare caches the built files at its edge. Two fonts cut down to the characters the pages use. |
| API | Python, FastAPI, SQLAlchemy, async end to end; 50 hand-written Alembic migrations (triggers, grants and CHECK constraints). |
| Database | PostgreSQL 17 with pgvector. Seven login roles, one per job: the API, the ingests, the erasure, the backup, the lineage tool, the value checks, and the owner, held only by the one-shot migration container. |
| Orchestration | Apache Airflow 3, LocalExecutor, in the same compose stack. |
| Models | Ollama on an 8 GB card at home, used in batch passes, started by hand or by an Airflow job when a new place publication lands; production runs no model. |
| Deployment | AWS EC2 behind Cloudflare (Full strict). The server pulls published images and builds nothing; nothing pushes into it. An API process that finds the database at the wrong schema version exits at startup instead of serving. |
The CI badge above runs on every public commit: the tests that need no database (standard library only), the web build with its type check and lint, and the compose file read as a fresh clone would. The full suite is 77 test files in two tempos: host-side tests with no network, and tests that build and drop their own database. Nine local checks run before every commit (among them secrets, what may leave app/, fonts, the server's user-facing text and status table, the README pictures' text size and numbers, and staged Python). The CD badge is the image build: GitHub Actions builds the three images for every commit to the release branches, tags them with the commit's full sha, and the server pulls exactly that sha on its timer.
Before a release. The whole suite, the database tests included, is run by hand on the candidate
commit, and main (which the server deploys from) moves only after that, on the maintainer's typed
confirmation. This is a procedure; no check in code enforces it, and nothing in this repository blocks an untested
commit from being pushed. How the work is done day to day, with AI agent sessions
and the checks around them, is in docs/harness.md.
| What | Before | After | Measured on |
|---|---|---|---|
| A night with no new file (reference source) | 15.0 s | 1.6 s | the run log, 8 days |
| Embedding one name | 0.481 s | 0.045 s | 100 names, twice |
| Classifying one name | 12–19 s on CPU | 0.92 s on an 8 GB card | one district, 1,318 names |
| The registry roster ingest's peak memory | 172 MB | 77 MB | the 2 GB server of September 2026 |
| The serving stack at rest | 1,131 MiB | 1,009 MiB | the 2 GB server of September 2026 |
| A long classification pass, first vs last hour | 1.7× slower | level | 36,014 names in 10.5 h |
Name search has no index; it scans. A trigram index was tried across 31 real queries and the database never used it. The slow part was a sub-query that ran once per candidate row, 35,533 times per keystroke. Measure first, then decide whether to index.
The long version has the working behind every number.
Taipei only, twelve districts and one city's open data. Sized for a few groups at once. Every source is used inside its licence; there are no ratings, reviews or scraped pages anywhere.
cp .env.example .env # names only; the comments state the shape of every value
docker compose run --rm migrate # the schema, once per version
docker compose up -d --wait # the stack
curl -s localhost:8080/health
# {"status":"ok","database":"reachable","instance":"…","stream_listener":"up"}
A fresh clone comes up empty, then fills itself. Places arrive with the first reference ingest
(overnight, or by hand). Categories need the classifier backfill, which needs a
graphics card; the scheduled pass also needs UPTO_CLASSIFY_HOST=1 in .env (off by default, so a server's scheduler never starts one); without a card you get every place with the category column empty.
localhost:8080 is the app. The weather ingest needs a free CWA Open Data key
(opendata.cwa.gov.tw); the five open-data files need no credential. New scheduled jobs arrive paused.
Running an ingest by hand, a backfill, the tests and the lineage tool: docs/operations.md.