Hero emotional-safety guardrail + calmer card polish
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>
This commit is contained in:
@@ -15,15 +15,20 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article class:hero>
|
<article class:hero class:textfirst={!hero && !(article.image_url && imgOk)}>
|
||||||
<a class="media" href={safeHref} target="_blank" rel="noopener">
|
<!-- Hero always shows media (image or fallback). Secondary cards only show a
|
||||||
{#if article.image_url && imgOk}
|
media band when a real image exists, so image-less cards are text-first
|
||||||
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
|
rather than a row of empty blocks. -->
|
||||||
onerror={() => (imgOk = false)} />
|
{#if hero || (article.image_url && imgOk)}
|
||||||
{:else}
|
<a class="media" href={safeHref} target="_blank" rel="noopener">
|
||||||
<span class="fallback">{article.topic ?? 'good news'}</span>
|
{#if article.image_url && imgOk}
|
||||||
{/if}
|
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
|
||||||
</a>
|
onerror={() => (imgOk = false)} />
|
||||||
|
{:else}
|
||||||
|
<span class="fallback">{article.topic ?? 'good news'}</span>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
@@ -95,9 +100,24 @@
|
|||||||
.actions { margin-top: auto; padding-top: 10px; display: flex; gap: 14px; flex-wrap: wrap; }
|
.actions { margin-top: auto; padding-top: 10px; display: flex; gap: 14px; flex-wrap: wrap; }
|
||||||
.actions button {
|
.actions button {
|
||||||
background: none; border: none; padding: 0; color: var(--muted);
|
background: none; border: none; padding: 0; color: var(--muted);
|
||||||
font-size: 0.78rem; border-bottom: 1px dotted var(--line);
|
font-size: 0.76rem; border-bottom: 1px dotted var(--line);
|
||||||
}
|
}
|
||||||
.actions button:hover { color: var(--sage-deep); border-bottom-color: var(--sage); }
|
.actions button:hover { color: var(--sage-deep); border-bottom-color: var(--sage); }
|
||||||
|
/* On pointer devices, keep the tuning controls quiet until the card is hovered
|
||||||
|
or focused — they shouldn't read as interface machinery. Touch devices (no
|
||||||
|
hover) keep them visible. */
|
||||||
|
@media (hover: hover) {
|
||||||
|
.actions { opacity: 0; transition: opacity 0.16s ease; }
|
||||||
|
article:hover .actions,
|
||||||
|
article:focus-within .actions { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text-first secondary cards (no real image): a little breathing room up top. */
|
||||||
|
.textfirst .body { padding-top: 18px; }
|
||||||
|
.textfirst .body::before {
|
||||||
|
content: ""; display: block; width: 28px; height: 3px;
|
||||||
|
background: var(--sage-soft); border-radius: 2px; margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.hero { display: grid; grid-template-columns: 1.1fr 1fr; }
|
.hero { display: grid; grid-template-columns: 1.1fr 1fr; }
|
||||||
.hero .media { aspect-ratio: auto; height: 100%; min-height: 280px; }
|
.hero .media { aspect-ratio: auto; height: 100%; min-height: 280px; }
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from pydantic import BaseModel
|
|||||||
from . import feeds, queries
|
from . import feeds, queries
|
||||||
from .db import connect, init_db
|
from .db import connect, init_db
|
||||||
from .filters import filter_articles, prefs_from_json
|
from .filters import filter_articles, prefs_from_json
|
||||||
|
from .hero import lead_with_gentle
|
||||||
from .llm import LocalModelClient
|
from .llm import LocalModelClient
|
||||||
from .moods import MOODS
|
from .moods import MOODS
|
||||||
from .taxonomy import FLAVORS, TOPICS
|
from .taxonomy import FLAVORS, TOPICS
|
||||||
@@ -280,7 +281,11 @@ def create_app() -> FastAPI:
|
|||||||
items = data["items"]
|
items = data["items"]
|
||||||
if not fp.is_empty():
|
if not fp.is_empty():
|
||||||
# MVP: filter the stored brief DOWN; no refill from outside the brief.
|
# MVP: filter the stored brief DOWN; no refill from outside the brief.
|
||||||
|
# Runs before hero selection, so personal avoid-terms take precedence.
|
||||||
items = filter_articles(items, fp, datetime.now(timezone.utc))
|
items = filter_articles(items, fp, datetime.now(timezone.utc))
|
||||||
|
# Lead with an emotionally-safe story (constructive-but-charged stories
|
||||||
|
# stay in the five, just not as the first thing seen).
|
||||||
|
items = lead_with_gentle(items)
|
||||||
return BriefResponse(
|
return BriefResponse(
|
||||||
brief_date=data["brief_date"],
|
brief_date=data["brief_date"],
|
||||||
title=data["title"],
|
title=data["title"],
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""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
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
- Ability to silence some categories temporarily (Maybe a user doesn't even want to see health-related articles, even good ones, so they're not reminded of an ongoing medical issue -- a way to avoid something purposely for a bit)
|
- Ability to silence some categories temporarily (Maybe a user doesn't even want to see health-related articles, even good ones, so they're not reminded of an ongoing medical issue -- a way to avoid something purposely for a bit)
|
||||||
- Terms to avoid list (To filter even good news that you'd rather not hear about)
|
- Terms to avoid list (To filter even good news that you'd rather not hear about)
|
||||||
-
|
- Favorite/save articles
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
from goodnews.hero import lead_with_gentle, safe_to_lead
|
||||||
|
|
||||||
|
|
||||||
|
def art(title, desc="", cortisol=0, ragebait=0):
|
||||||
|
return {"title": title, "description": desc, "cortisol_score": cortisol, "ragebait_score": ragebait}
|
||||||
|
|
||||||
|
|
||||||
|
def test_calm_story_is_safe_to_lead():
|
||||||
|
assert safe_to_lead(art("Rare British plant returns from the brink"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_medical_grief_terms_are_not_safe_to_lead():
|
||||||
|
assert not safe_to_lead(art("Nanofiber implant doubles survival in glioblastoma mice", "brain cancer"))
|
||||||
|
assert not safe_to_lead(art("New drug for a deadly disease"))
|
||||||
|
assert not safe_to_lead(art("A community remembers those who died"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_high_cortisol_or_ragebait_not_safe():
|
||||||
|
assert not safe_to_lead(art("Calm-sounding title", cortisol=4))
|
||||||
|
assert not safe_to_lead(art("Calm-sounding title", ragebait=3))
|
||||||
|
|
||||||
|
|
||||||
|
def test_substring_safe_words_not_falsely_flagged():
|
||||||
|
# "scar" must not trip on "scared"? we don't list scar; ensure clean titles pass
|
||||||
|
assert safe_to_lead(art("Volunteers restore a coastal wetland"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_with_gentle_promotes_first_safe_item():
|
||||||
|
items = [
|
||||||
|
art("Cancer breakthrough in mice", "kill cancer cells"), # charged — must not lead
|
||||||
|
art("Tiny blue octopus found off the Galapagos"), # calm — should lead
|
||||||
|
art("Solar exports hit a record"),
|
||||||
|
]
|
||||||
|
out = lead_with_gentle(items)
|
||||||
|
assert out[0]["title"].startswith("Tiny blue octopus")
|
||||||
|
assert len(out) == 3
|
||||||
|
assert any("Cancer" in a["title"] for a in out) # still present, just not first
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_with_gentle_keeps_order_when_first_is_already_safe():
|
||||||
|
items = [art("A gentle discovery"), art("Cancer news")]
|
||||||
|
assert lead_with_gentle(items)[0]["title"] == "A gentle discovery"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lead_with_gentle_unchanged_when_none_safe():
|
||||||
|
items = [art("Cancer story"), art("War deaths reported", cortisol=8)]
|
||||||
|
out = lead_with_gentle(items)
|
||||||
|
assert out[0]["title"] == "Cancer story" # unchanged; brief still needs a lead
|
||||||
Reference in New Issue
Block a user