feat: MPS-24 — manual tags are ghost metadata; hide behind a flag

Operator direction: stop hand-attaching tags ('ghost metadata'
invisible to the humans and agents reading the page). Derive tags
from title + description (auto-hydrate + suggest engine) instead.

- New MPS-22-style kill switch: app.features.manual_tags.enabled
  (request.manual_tags_enabled, DEFAULT FALSE, env
  MPS_FEATURES_MANUAL_TAGS_ENABLED, =True in test.ini so the existing
  tag suite keeps passing).
- product_edit.j2: hides the chip editor + comma tags field +
  product_tags.js; shows a 'tags are derived from your title &
  description' note (lists current auto-derived tags read-only).
- shop_tags.j2: hides 'Create a tag' + the per-product apply (focus)
  section; shows a 'How tags work' note. Suggest categories + the
  category overview stay (the blessed linguistic path).
- Endpoints remain functional -> flipping the flag On is instant and
  lossless ('until further notice').
- CLAUDE.md: 'Tag Philosophy' section + manual_tags row in the
  kill-switch matrix. mps-24.md Phase 2.8o.
- Tests: TestManualTagsKillSwitch (fresh app, flag False; mirrors
  TestKillSwitches). 1147 passed; existing tag suite green under
  test.ini (flag True).
This commit is contained in:
russell@unturf.com 2026-05-17 12:08:55 -04:00
parent d6ae00073f
commit 3467909b80
No known key found for this signature in database
7 changed files with 203 additions and 8 deletions

View file

@ -720,6 +720,27 @@ in `~/git/foxhop-pillar/uwsgi/makepostsell/init.sls` for prod). Pattern mirrors
| `app.features.popout_player.enabled` | `request.popout_player_enabled` | True | Working |
| `app.features.karaoke.enabled` | `request.karaoke_enabled` | **False** | Broken (MPS-18) |
| `app.features.torrent.enabled` | `request.torrent_enabled` | **False** | Broken (MPS-19) |
| `app.features.manual_tags.enabled` | `request.manual_tags_enabled` | **False** | Intentional — manual tags are ghost metadata (MPS-24) |
## Tag Philosophy (MPS-24) — derive, don't hand-attach
**Manual tags are "ghost metadata"** — operator-attached labels that
are invisible to the humans and agents actually reading the page, and
that drift out of sync with the words that matter. The blessed model:
**tags are derived linguistically from the title + description** so
humans and agents consume the same signal.
- New products / edited title or description → `auto_hydrate_tags`
files the product into the shop's **existing** categories by content
(additive, idempotent, never creates).
- Growing the category set → the **suggest** engine reads titles +
descriptions and proposes groupings the operator approves.
- `request.manual_tags_enabled` (default **False**) hides the manual
add/apply UI on the product edit page and the bulk tagger; the
Suggest panel and the category overview stay. Flip
`MPS_FEATURES_MANUAL_TAGS_ENABLED=True` to restore manual tagging
"until further notice". Endpoints (`/p/{id}/tags`, bulk create/apply)
remain functional so the flip is instant and lossless.
When a flag is off:
1. Templates wrap UI in `{% if request.X_enabled %}` — section hidden

View file

@ -363,6 +363,23 @@ Tests (`test_functional.py::TestProductTagsSpa`):
Deferred (occasional click, not the hot path): AJAX-ifying the
"Suggest categories" link — still a full navigation by design.
**Phase 2.8o — manual tags are ghost metadata: hide behind a flag**
(shipped 2026-05-17): operator direction — stop hand-attaching tags
("ghost metadata" invisible to humans/agents reading the page);
derive them from title + description instead. New MPS-22-style kill
switch `app.features.manual_tags.enabled` (`request.manual_tags_enabled`,
**default False**, `MPS_FEATURES_MANUAL_TAGS_ENABLED` env, `True` in
test.ini). When off: `product_edit.j2` hides the chip editor + comma
field + `product_tags.js`, showing a "tags are derived from your
title & description" note; `shop_tags.j2` hides the "Create a tag"
form + the per-product apply (focus) section, showing a "How tags
work" note — the **Suggest** panel + category overview stay (the
derived path). Endpoints stay functional so the flip is instant +
lossless ("until further notice"). Tests:
`TestManualTagsKillSwitch` (fresh app, flag False — mirrors
`TestKillSwitches`). Docs: CLAUDE.md "Tag Philosophy" + kill-switch
matrix row.
**Phase 2.8n — auto-hydrate products into existing tags** (shipped
2026-05-17): operator wants new/edited products auto-filed into
existing categories without manual tagging.

View file

@ -458,6 +458,23 @@ def includeme(config):
return val
return False
def add_manual_tags_enabled(request):
"""MPS-24: manual tag UI kill switch. **Default OFF.**
Tags are "ghost metadata" the operator hand-attaches invisible
to the humans and agents reading the page. We instead derive
tags linguistically from the title + description (auto-hydrate +
suggest engine). This flag hides the manual add/apply UI on the
product edit page and the bulk tagger "until further notice";
flip it on (MPS_FEATURES_MANUAL_TAGS_ENABLED=True) to restore.
"""
val = request.app.get("features.manual_tags.enabled")
if isinstance(val, str):
return val.strip().lower() in ("1", "true", "yes", "on")
elif isinstance(val, bool):
return val
return False
# Feature toggles
config.add_request_method(
add_popout_player_enabled, "popout_player_enabled", reify=True
@ -468,6 +485,9 @@ def includeme(config):
config.add_request_method(
add_torrent_enabled_global, "torrent_enabled", reify=True
)
config.add_request_method(
add_manual_tags_enabled, "manual_tags_enabled", reify=True
)
def add_git_hash(request):
"""Short git hash baked in at deploy time — used to cache-bust

View file

@ -495,14 +495,16 @@
<br />
<br />
{# MPS-24: tags. Capability-driven (CLAUDE.md):
- No JS: edit the comma-separated field, persists on "Save Settings"
like every other field on this form (full page reload).
- JS on: product_tags.js builds a chip editor from this field, hides
the raw input, and POSTs each add/remove to /p/{id}/tags with
X-Requested-With so the chip mutates in place — no full page
reload. It rewrites the hidden field on every change so a later
full "Save Settings" is a no-op, never a stale revert. #}
{# MPS-24: manual tagging is "ghost metadata" — invisible to the
humans and agents reading the page. Hidden by default
(request.manual_tags_enabled, see CLAUDE.md). Tags are derived
linguistically from the title + description instead and auto-
applied on save. Flip MPS_FEATURES_MANUAL_TAGS_ENABLED=True to
restore the manual chip editor below. #}
{% if request.manual_tags_enabled %}
{# Capability-driven: no-JS edits the comma field (persists on
"Save Settings"); product_tags.js turns it into a chip editor
that POSTs each add/remove to /p/{id}/tags. #}
<div class="product-tags-field"
data-product-tags
data-product-tags-url="/p/{{ product.id }}/tags">
@ -543,6 +545,31 @@
<a href="/s/{{ request.shop.id }}/tags" class="shop-theme-link-color">your tag editor</a>.
</small>
</div>
{% else %}
<div class="product-tags-field">
<label>Tags</label>
<div class="well2 tag-explainer">
<p class="type-body-sm">
<b>Tags are derived from your title &amp; description</b> — not
hand-attached. Manual tags are <i>ghost metadata</i>: invisible
to the shoppers and agents actually reading the page. When you
save, this product is automatically filed into your shop's
existing categories by the words in its title and description.
</p>
<p class="type-body-sm">
Want it in a category? Say so in the title or description. To
see or create the categories themselves, visit
<a href="/s/{{ request.shop.id }}/tags" class="shop-theme-link-color">your tag editor</a>.
</p>
{% if request.product.tags|list %}
<p class="type-body-sm">
Currently filed under:
{% for t in request.product.tags %}<span class="tag-chip-removable"><span class="tag-chip-removable-label">{{ t.name }}</span></span>{% if not loop.last %} {% endif %}{% endfor %}
</p>
{% endif %}
</div>
</div>
{% endif %}
<br />
<br />
@ -630,6 +657,8 @@
</div>
{% if request.manual_tags_enabled %}
<script src="/static/js/product_tags.js?v={{ request.git_hash }}" defer></script>
{% endif %}
{%- endblock -%}

View file

@ -15,6 +15,7 @@
own flash via base.j2 after the 302 redirect. #}
<div class="tag-flash" data-tag-flash aria-live="polite"></div>
{% if request.manual_tags_enabled %}
<section class="one-column well">
<h2 class="type-title">Create a tag</h2>
<form method="POST" action="/s/{{ request.shop.id }}/tags" class="tag-create-form" data-tag-form="create">
@ -26,6 +27,29 @@
<button type="submit" class="mps-button mps-button-primary">Add tag</button>
</form>
</section>
{% else %}
<section class="one-column well">
<h2 class="type-title">How tags work</h2>
<p class="type-body-sm">
Tags are <b>derived from each product's title &amp; description</b>,
not hand-attached. Manual tags are <i>ghost metadata</i>: invisible
to the shoppers and agents actually reading the page, and they drift
out of sync with the words that matter.
</p>
<p class="type-body-sm">
Instead: write a clear title and description. New and edited
products are automatically filed into your existing categories by
the language they contain. To grow your category set, click
<b>Suggest categories</b> below — it reads your catalog's titles and
descriptions and proposes real groupings you approve with one click.
</p>
<p class="type-body-sm">
Manual tag creation and per-product apply are disabled until
further notice. The category list below stays available so you can
review, reorder, or remove categories.
</p>
</section>
{% endif %}
{# MPS-24 Phase 2: suggest categories from product title + description. #}
<section class="one-column well">
@ -143,6 +167,7 @@
clicked, instead of a full-page reload of the whole bulk tagger
(catastrophic on a 481-product catalog). No-JS still works: the
?focus= link is a real navigation and this block server-renders. #}
{% if request.manual_tags_enabled %}
<section class="one-column well" data-focus-section{% if not focus_tag %} hidden{% endif %}>
<h2 class="type-title" data-focus-heading>
{%- if focus_tag %}Products in &laquo;{{ focus_tag.name }}&raquo;{% endif -%}
@ -173,6 +198,7 @@
{% endif %}
</ul>
</section>
{% endif %}
<script src="/static/js/tag_bulk.js?v={{ request.git_hash }}" defer></script>

View file

@ -5637,6 +5637,84 @@ class TestKillSwitches(_AuthenticatedBase):
self.assertNotIn("karaoke vocal isolation", body)
class TestManualTagsKillSwitch(_AuthenticatedBase):
"""MPS-24: with app.features.manual_tags.enabled False (the prod
default), the manual add/apply tag UI is hidden on the product edit
page and the bulk tagger, replaced by a "tags are derived" note.
test.ini sets it True so the other tag tests keep working this
class builds a fresh app with it False (mirrors TestKillSwitches)."""
def setUp(self):
from make_post_sell import main
self.settings = get_appsettings("test.ini")
off_settings = dict(self.settings)
off_settings["app.features.manual_tags.enabled"] = "False"
self.app = main({}, **off_settings)
self.testapp = webtest.TestApp(self.app)
self.session_factory = self.app.registry["dbsession_factory"]
self.engine = self.session_factory.kw["bind"]
Base.metadata.create_all(bind=self.engine)
self.dbsession = get_tm_session(self.session_factory, transaction.manager)
self.shop1_params = {
"name": "russell's shop",
"phone_number": "555-555-8688",
"billing_address": "555 example way\nnorth pole\n555555\n",
"description": "russell's shop sells some great digital downloads.",
"stripe_public_api_key": environ["MPS_TEST_STRIPE_PUBLIC_API_KEY"],
"stripe_secret_api_key": environ["MPS_TEST_STRIPE_SECRET_API_KEY"],
"domain_name": "localhost.localhost",
}
self.product1_params = {
"title": "russell's product",
"description": "russell's product",
"price": "3.50",
"is_sellable": "on",
"submit": True,
}
self.user1 = get_or_create_user_by_email(self.dbsession, "test1@example.com")
self.user1_creds = ("test1@example.com", self.user1.new_password())
self.dbsession.add(self.user1)
self.dbsession.flush()
transaction.manager.commit()
self.user1 = get_or_create_user_by_email(self.dbsession, "test1@example.com")
def _shop_and_content(self):
shop = self._create_shop_helper(user_creds=self.user1_creds)
params = dict(self.product1_params)
params["title"] = "Holiday Math Pack"
params.pop("is_sellable", None) # content product
self.testapp.post(f"/p/new?shop_id={shop.id}", params)
from ..models.product import get_all_products
product = get_all_products(self.dbsession).all()[-1]
return shop, product
def test_product_edit_hides_manual_tags_and_explains(self):
shop, product = self._shop_and_content()
res = self.testapp.get(f"/p/{product.id}/edit", status=200)
body = res.body.decode()
# Manual chip editor + comma field + script are gone.
self.assertNotIn("data-product-tags", body)
self.assertNotIn("data-tag-add-input", body)
self.assertNotIn('name="tags"', body)
self.assertNotIn("product_tags.js", body)
# Replaced by the "derived" explanation.
self.assertIn("derived from your title", body)
def test_bulk_tagger_hides_create_and_apply_keeps_suggest(self):
shop, _ = self._shop_and_content()
res = self.testapp.get(f"/s/{shop.id}/tags", status=200)
body = res.body.decode()
# Manual create form + per-product apply gone.
self.assertNotIn('data-tag-form="create"', body)
self.assertNotIn("data-focus-section", body)
# Explanation shown; the Suggest path stays.
self.assertIn("How tags work", body)
self.assertIn("Suggest categories", body)
class TestAuctionRoutes(_AuthenticatedBase):
"""MPS-20: HTTP-level coverage for auction views.

View file

@ -40,6 +40,10 @@ app.payments.paypal.enabled = True
# MPS-18 + MPS-19 land). TestKillSwitches builds a fresh app with both off.
app.features.karaoke.enabled = True
app.features.torrent.enabled = True
# Manual tag UI ON in tests so existing tag tests keep working;
# prod/dev default it False (tags derived from title+desc). A dedicated
# test builds a fresh app with it False to verify the hidden path.
app.features.manual_tags.enabled = True
# Bounded SSE (offer/auction live feeds) — tiny windows so the streaming
# endpoints return almost immediately under webtest instead of holding for