15d51fb8fd
Hero guardrail (core to the promise, not cosmetic): - New hero.py: the lead story is chosen with a stricter filter than the rest of the brief — very low cortisol/ragebait and no grief/medical/violence terms (cancer, glioblastoma, death, diagnosis, ...). Such constructive-but-charged stories stay among the five; they just never lead by default. - /api/brief applies user avoid-terms FIRST, then lead_with_gentle, so personal boundaries always take precedence over the general guardrail. - Verified live: the brief no longer leads with a glioblastoma story. Card polish (per review): - Secondary cards with no real image are now text-first (no row of empty media bands); hero still always shows media or a typographic fallback. - Inline tuning actions are quiet until hover/focus on pointer devices, and stay visible (softer) on touch — less interface machinery. Tests: hero safety + lead reordering (70 total). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
54 lines
2.2 KiB
Python
54 lines
2.2 KiB
Python
"""Hero (lead-story) emotional-safety guardrail.
|
|
|
|
The single most prominent thing on the page must feel safe to encounter before
|
|
coffee. A story can be objectively hopeful — a cancer breakthrough, say — and
|
|
still be personally painful to meet unprompted at the top of the page.
|
|
|
|
So the lead is chosen with a STRICTER filter than the rest of the brief: it must
|
|
be very calm and must not mention grief/medical/violence terms. Such stories are
|
|
still welcome among the five — they just don't lead by default. Per-user
|
|
avoid-terms are applied to the brief before this runs, so a reader's personal
|
|
boundaries always take precedence.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from .filters import text_matches_avoid_terms
|
|
|
|
# Terms too emotionally charged to lead with, even inside a constructive story.
|
|
HERO_GUARD_TERMS = [
|
|
"cancer", "glioblastoma", "tumor", "tumour", "carcinoma", "chemotherapy", "chemo",
|
|
"death", "died", "dies", "dying", "dead", "fatal", "terminal", "deadly",
|
|
"diagnosis", "diagnosed", "dementia", "alzheimer", "parkinson",
|
|
"suicide", "murder", "killed", "killing", "war", "assault", "abuse",
|
|
"overdose", "hospice", "palliative", "disease",
|
|
]
|
|
|
|
HERO_MAX_CORTISOL = 1
|
|
HERO_MAX_RAGEBAIT = 1
|
|
|
|
|
|
def safe_to_lead(article: dict) -> bool:
|
|
"""True if an article is calm enough to be the page's lead."""
|
|
if (article.get("cortisol_score") or 0) > HERO_MAX_CORTISOL:
|
|
return False
|
|
if (article.get("ragebait_score") or 0) > HERO_MAX_RAGEBAIT:
|
|
return False
|
|
blob = f"{article.get('title') or ''} {article.get('description') or ''}"
|
|
return not text_matches_avoid_terms(blob, HERO_GUARD_TERMS)
|
|
|
|
|
|
def lead_with_gentle(items: list[dict]) -> list[dict]:
|
|
"""Reorder so the highest-ranked lead-safe item is first (the hero).
|
|
|
|
The remaining items keep their ranked order. If nothing qualifies, the list
|
|
is returned unchanged — the brief still needs a lead, and we never drop a
|
|
constructive story, we only decline to make a charged one the first thing seen.
|
|
"""
|
|
for i, article in enumerate(items):
|
|
if safe_to_lead(article):
|
|
if i > 0:
|
|
return [items[i], *items[:i], *items[i + 1:]]
|
|
return items
|
|
return items
|