diff --git a/CLAUDE.md b/CLAUDE.md index e846156..d059fa5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/tickets/mps-24.md b/docs/tickets/mps-24.md index 6f70315..5121369 100644 --- a/docs/tickets/mps-24.md +++ b/docs/tickets/mps-24.md @@ -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. diff --git a/make_post_sell/request_methods.py b/make_post_sell/request_methods.py index b3d45c8..f7f161d 100644 --- a/make_post_sell/request_methods.py +++ b/make_post_sell/request_methods.py @@ -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 diff --git a/make_post_sell/templates/product_edit.j2 b/make_post_sell/templates/product_edit.j2 index 4a4d287..6aa347b 100644 --- a/make_post_sell/templates/product_edit.j2 +++ b/make_post_sell/templates/product_edit.j2 @@ -495,14 +495,16 @@

- {# 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. #}
@@ -543,6 +545,31 @@ your tag editor.
+ {% else %} +
+ +
+

+ Tags are derived from your title & description — not + hand-attached. Manual tags are ghost metadata: 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. +

+

+ Want it in a category? Say so in the title or description. To + see or create the categories themselves, visit + your tag editor. +

+ {% if request.product.tags|list %} +

+ Currently filed under: + {% for t in request.product.tags %}{{ t.name }}{% if not loop.last %} {% endif %}{% endfor %} +

+ {% endif %} +
+
+ {% endif %}

@@ -630,6 +657,8 @@ +{% if request.manual_tags_enabled %} +{% endif %} {%- endblock -%} diff --git a/make_post_sell/templates/shop_tags.j2 b/make_post_sell/templates/shop_tags.j2 index 5f9ce19..f0913de 100644 --- a/make_post_sell/templates/shop_tags.j2 +++ b/make_post_sell/templates/shop_tags.j2 @@ -15,6 +15,7 @@ own flash via base.j2 after the 302 redirect. #}
+{% if request.manual_tags_enabled %}

Create a tag

@@ -26,6 +27,29 @@
+{% else %} +
+

How tags work

+

+ Tags are derived from each product's title & description, + not hand-attached. Manual tags are ghost metadata: invisible + to the shoppers and agents actually reading the page, and they drift + out of sync with the words that matter. +

+

+ 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 + Suggest categories below — it reads your catalog's titles and + descriptions and proposes real groupings you approve with one click. +

+

+ 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. +

+
+{% endif %} {# MPS-24 Phase 2: suggest categories from product title + description. #}
@@ -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 %}

{%- if focus_tag %}Products in «{{ focus_tag.name }}»{% endif -%} @@ -173,6 +198,7 @@ {% endif %}

+{% endif %} diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index d90c174..cc9305f 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -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. diff --git a/test.ini b/test.ini index a22f493..6ad993c 100644 --- a/test.ini +++ b/test.ini @@ -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