feat: MPS-24 Phase 2 — auto-suggest tags from title + description

Operator with 481 untagged products (printableprompts.com) gets a
one-click path to a usable categorization without hand-tagging each
product. Strictly suggest-then-approve — nothing writes Tag or
ProductTag rows until the operator clicks Apply on a cluster.

- lib/tag_suggest.py: pure-function clusterer. Tokenize title (weight 3)
  + description (weight 1, capped at 100 unique tokens per product),
  strip markdown / URLs / HTML, English + per-shop stopwords, simple
  suffix-strip stemmer, group by stem, drop stems matching existing
  tag slugs, rank by product count, label each cluster with the most
  frequent original word for its stem. No new deps, no ML.
- scripts/backfill_tags.py: CLI preview + --apply for a single shop.
- views/shop.py: shop_tags gains action=apply_suggestion (creates tag +
  bulk-attaches every product in cluster) and action=dismiss_suggestion
  (adds the cluster's words to shop.tag_stopwords_json so it never
  resurfaces). ?show_suggestions=1 triggers the cluster compute.
- templates/shop_tags.j2: "Suggest categories from titles + descriptions"
  button + suggestions well with per-cluster sample titles, Apply, and
  Dismiss buttons.
- 15 new tests (11 unit over tokenize / stem / cluster + 4 functional
  over the suggest/apply/dismiss flow). 1064 total passing.

On a printableprompts-style sample the clusterer surfaces Math, Reading,
Literacy, Seasonal, Novel, Activities, Comprehension — matching what an
operator would manually pick.
This commit is contained in:
russell@unturf.com 2026-05-15 09:09:40 -04:00
parent c03ca53fb4
commit 5dbbe697b6
No known key found for this signature in database
11 changed files with 884 additions and 29 deletions

View file

@ -477,9 +477,17 @@ filters on no-JS. With JS, `static/js/tag_filter.js` intercepts clicks
and filters the grid in place via `data-tag-slugs` attribute on
`.serp-item`, zero network cost, fewer clicks to purchase.
Phase 2 (this ticket, follow-on commit): sectioned-lane layout (`==2`)
+ deterministic auto-tagger script (`scripts/backfill_tags.py`) that
clusters by shared title keywords minus stopwords.
Phase 2 (shipped): deterministic title-plus-description auto-tagger
in `lib/tag_suggest.py`. Title tokens weight × 3, description × 1 (capped
at 100 unique tokens per product). Pipeline: tokenize → English + per-shop
stopwords → suffix-strip stem → group by stem → drop stems matching
existing tag slugs → rank by product count → label = most frequent
original word for that stem. Surface: button on `/s/{id}/tags`
"Suggested categories" well with one-click Apply / Dismiss per cluster
(`action=apply_suggestion` / `action=dismiss_suggestion`). CLI:
`python -m make_post_sell.scripts.backfill_tags data/development.ini
--shop=<id> [--apply]`. **Never auto-commits** — operator approves
every cluster.
Phase 3 (this ticket, gated): ML categorization via uncloseai endpoint
behind `app.features.ml_categorization.enabled` kill switch (mirror MPS-22).