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:
parent
d6ae00073f
commit
3467909b80
7 changed files with 203 additions and 8 deletions
21
CLAUDE.md
21
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 & 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 -%}
|
||||
|
|
|
|||
|
|
@ -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 & 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 «{{ focus_tag.name }}»{% endif -%}
|
||||
|
|
@ -173,6 +198,7 @@
|
|||
{% endif %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<script src="/static/js/tag_bulk.js?v={{ request.git_hash }}" defer></script>
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
4
test.ini
4
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue