Skip to content

Find a year of buyers from the words they use, and keep the threads that rank - #1

Open
kev1n wants to merge 15 commits into
mainfrom
shared-reading-gate
Open

Find a year of buyers from the words they use, and keep the threads that rank#1
kev1n wants to merge 15 commits into
mainfrom
shared-reading-gate

Conversation

@kev1n

@kev1n kev1n commented Sep 10, 2026

Copy link
Copy Markdown
Member

Four commits: one product-agnostic reading of every post before judging, the backfill rebuild, the recall and precision fixes the quality check proved, and the parallelism.

Why. Backfill was a 30-day poll of a subreddit list. For HotelsAllow the demand is one post per city subreddit per quarter across 38 city subs, so a 30-day poll of 10 subreddits saw 3 buyers where a year-window keyword sweep sees dozens.

What changed

  • reddit.search already returns selftext; store it instead of re-buying the body through reddit.post. The clean run made zero reddit.post calls.
  • Backfill walks one search per constraint and asks each inferred phrasing in plain words, and a query is walked once however many keywords compile to it.
  • A walk ends when Reddit stops handing out a cursor, not when one page carries nothing new. The relevance sort returns sparse pages mid-listing, so the old rule truncated (hotel OR hotels) AND "under 21" to 16 posts where the listing holds 150.
  • A backfill reuses no cached search, so a retry cannot inherit the truncated listing its first attempt stored.
  • The judge has to name the job the matching words actually describe. A shared word is not a shared need.
  • The gate routes instead of only rejecting: a thread where nobody asks but the product plainly fits becomes a "Worth a comment" lead.
  • Retention pins any post a lead or an SEO row references, so ranking threads survive past 30 days.
  • Walks and both model phases run their batches at once, under one concurrency constant, and one dropped model call now costs its own batch instead of the whole sweep.

Proof, same project run before and after the fixes

before after
wall clock 79 min 22 min
posts found 2,203 4,295
posts judged 160 575
leads 37 79
of 48 buyers a manual sweep found and this missed 0 retrieved 33 retrieved, 22 are leads
the one false positive in 37 present gone

Hand-judged precision on the 79: one clear false positive, two kind mislabels, three crosspost duplicates. Gate green at 286 tests.

Still open. The title triage rejects the largest context thread in the corpus (1,121 upvotes, 146 comments), and the all-time feed opens on held review cards because the stream sorts by date.

🤖 Generated with Claude Code

kev1n and others added 15 commits September 7, 2026 17:59
The judgement call spends most of its tokens rejecting posts no product
would ever qualify: sellers announcing what they built, people answering
others, and threads where nobody wants anything. Nothing about that
reading is product-specific, so every project watching a post was paying
to reach the same conclusion again.

One small model call now reads each post before any product is
considered, saying who is speaking and whether they are looking for
something, and only a buyer who is asking reaches the judge. The reading
is stored beside the post rather than beside a verdict, so the first
project to see a post pays for all of them, and it is read again only
when the post's own words change.

The reading claims nothing it did not see: it becomes an assessment with
every score left null, and the existing gates settle the decision from
it, so a rejection here follows exactly the rules a rejection by the
judge follows. A post the model could not read goes to the judge as it
always did, because an outage upstream must cost money and never a lead.

Measured over the 540 judged posts replayed in the embed test, requiring
a buyer who is asking cut 55 to 78% of the rejections on all five
products and lost no lead on any of them, and it agreed with the judge
on every post the judge called a seller and every post it qualified.

SCORER_VERSION moves, so each project's next scan judges its candidates
again under the gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hat rank

Backfill was a 30-day poll of a subreddit list, which is the wrong tool for a
demand that appears once per city subreddit per quarter. It is now a one-time
year-window keyword sweep at project creation, and the threads it finds survive
long enough to show.

- reddit.search already returns selftext; store it instead of re-buying the body
  through reddit.post. The clean run made zero reddit.post calls.
- Walk one search per constraint and ask each inferred phrasing in plain words.
  The combined query stopped at Reddit's bounded listing and lost most buyers.
- Route instead of only rejecting: a thread where nobody asks but the product
  plainly fits becomes a "Worth a comment" lead, beside the unchanged buyer axis.
- Pin any post a lead or an SEO row references, so retention keeps the ranking
  threads it used to delete at 30 days.
- Infer phrasings by situation, not by rewording one sentence.
- A failed backfill retries one scan interval later, and re-persists what it
  found before crediting it, so a concurrent retention run cannot orphan it.

Measured on a fresh HotelsAllow project: 86 walks, 2,203 posts, 37 leads,
41 Google-sourced ranking threads on the SEO tab, gate green at 278 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rd a need

Three defects the HotelsAllow quality check proved, each with a test that fails
without the fix.

The walk stopped on the first page that carried nothing new. Reddit's relevance
sort returns sparse pages in the middle of a listing, so one of them truncated a
whole query: `(hotel OR hotels) AND "under 21"` returned pages of 2, 6, 6, 1, 0
and stopped at 16 posts, where the same call live returns six full pages and 150
posts holding 29 of the 48 buyers that run missed. A listing is over when Reddit
stops handing out a cursor, or hands back the one just followed.

A backfill now reuses no cached search. A retry exists because the first attempt
was truncated or died, and a cached page from that attempt hands the retry the
same truncated listing: the 2026-09-10 run served all 532 of its searches from
the cache of earlier failures and never reached Reddit.

The judge treated a word from the product's own vocabulary as a need. The only
false positive in 37 leads was a 20-year-old offering to cover a stranger's
concert ticket and hotel, "looking for 18+ M or F", read as an under-21 check-in
problem. It now has to name the job the matching words actually describe.

A query is also walked once however many compiled keywords produce it, because
without the cache four near-identical keywords would each buy the same listing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A backfill's walks are independent of each other and are nearly all waiting on
Reddit, but they ran strictly one at a time. Walking the full listings that way
put the first sweep of a project at over an hour before the feed had anything in
it, which is the whole first experience.

They now run the same number at a time the reading pass does. That number moves
to constants.ts as its one owner, with the measurement it rests on, instead of
living privately in reading.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A transient OpenRouter disconnection on triage batch 48 of 51 threw away every
batch before it and killed the run, for the third time in two days. A batch the
model never answered now means what a batch it answered with nothing already
meant: those candidates are unread, never rejected, so the sweep finishes and
the next run judges them. A failed scoring batch loses its own ten and no more.

Both model phases also run their batches at once rather than one after another,
under the same concurrency the reads and the walks use. That constant and its
worker pool now live in constants.ts as their one owner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kevin, on the re-run: "There's so many false positives ... and even now,
taking 22 minutes is kinda unacceptable from a first user perspective."

The post he quoted was never a lead. Its evaluation is `review`, fit 2, and
the judge's own reason says no check-in need is stated. The feed put it in
front of him anyway, because the stream merged the held pile with the leads
and sorted the lot by date: four held cards, warm-badged "Maybe a buyer",
opened the all-time feed above the first real lead.

- The stream carries leads only, in the feed query's own order: score, then
  how recently the need was posted. Freshness is already half of that score,
  so re-sorting by date on top of it threw fit and intent away and opened the
  feed with whatever was newest.
- Held candidates get their own shut section under the feed, labelled for
  what they are.
- Model calls get their own concurrency, separate from Reddit's. Measured
  against OpenRouter with the real prompt: 30 batches take 279s ten at a time,
  136.5s thirty at a time, and 60 batches take 147.8s sixty at a time, with no
  failure and no per-call slowdown at any arm.
- Reasoning effort is low. The endpoint refuses to turn reasoning off at all,
  but over 100 already-judged posts low effort agrees with the shipped run on
  96 of 100, exactly as the default does, in a third of the wall clock and
  well under half the output tokens.
- Triage's free-text reason is gone. Nothing read it.
- The backfill commits each batch of verdicts as it lands, so the feed fills
  while the sweep is still running, and writes anything uncommitted at the end.
- Posts are upserted in id order. Two concurrent walks landing on the same
  post in different orders deadlocked and killed a sweep.

Same project, nothing cached: 22 minutes becomes 3.5, 4,295 posts found
becomes 4,786, and the all-time feed opens on a buyer.
- `main` had no `min-w-0`, so it could not shrink below its widest content:
  one long Reddit body sized the whole app to 5,246px inside a 1,440px window,
  every card ran off the right edge with its score and actions off screen, and
  the people strip's own horizontal scroll never engaged because its parent
  had already grown to fit it. Measured after: 1,440px on all seven app pages,
  and the strip scrolls itself.
- The rail and the header are sticky. The rail is how you leave the page you
  are on, and it has to be reachable from the bottom of a feed of eighty
  leads. The header stays with it, because a rail pinned to the top edge of a
  header that had scrolled away would float.
- A header height token, so the rail's offset and the header's own height are
  one value.
- A long project name no longer sits under the switcher's own select arrow.
An SEO refresh on a project with no problem phrasings returned before it
booked its successor, so the one job a new project got was also its last.
Nothing queued insights except the button on its own page, and nothing
queued a competitor scan except the button on its own page, so a project
whose owner never pressed either had no themes and no rivals, ever.

A scan and a year sweep now book the grouping when they wrote leads, boot
seeds both missing kinds for projects made before this, and a test reads
every source file for an em or en dash.
deleteExpiredPosts drops every post past the retention window across the
whole database, so a 40-day-old fixture post vanished mid-test whenever
tests/retention.test.ts ran beside this file. The report counts on
judged_at and first_seen_at and never reads the post's own age.
…rose

The leads page gave every lead a full card: the judge's reason, three 0-4
meters, the reason codes, and a 320-character slice of the post. Eighty of
those is a debug log you scroll, and the one thing you came for - what the
person actually asked - was the part that got cut.

It is a workspace now. The list column carries only what picks a row out of
eighty: face, ask, community, age, score. The detail column is pinned under
the header and holds the whole post, one labelled line saying why it is a
lead, and a ledger rail of who asked, where, what the thread did and how it
scored. Held candidates are the second group in the same list, still shut
until asked for, and open into the same pane with their verdict and their
reason codes in plain words.

Nothing in the rail is estimated: a karma, an account age, a weekly-active
count or a judgement the scan never made reads as a dash.
The Leads header read "84 cleared the bar" above a list of 8, because
scanReport bounded on lead_evaluations.judged_at and
candidate_sources.first_seen_at - when we looked - while the feed bounds
on the need date, coalesce(reddit_comments.created_at,
reddit_posts.created_at). A backfill judges old threads today, so the two
populations diverge by hundreds of posts and nothing on the page says so.

scanReport now reuses the feed's own newerThan, joining reddit_posts and
left-joining reddit_comments so a comment verdict is dated by its comment
and not by the thread it sits in. The candidate count has no comment
dimension, so it bounds on the post's own date.

The fixture posts are written at today's date, given a lead row, and only
then aged, because deleteExpiredPosts drops every unreferenced post past
the retention window across the whole database.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant