From 15d51fb8fd757ecebcf864e54008874c4543fbb7 Mon Sep 17 00:00:00 2001 From: jay Date: Sat, 30 May 2026 22:44:00 +0000 Subject: [PATCH] Hero emotional-safety guardrail + calmer card polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/lib/components/ArticleCard.svelte | 40 ++++++++++---- goodnews/api.py | 5 ++ goodnews/hero.py | 53 +++++++++++++++++++ ideas.md | 2 +- tests/test_hero.py | 48 +++++++++++++++++ 5 files changed, 137 insertions(+), 11 deletions(-) create mode 100644 goodnews/hero.py create mode 100644 tests/test_hero.py diff --git a/frontend/src/lib/components/ArticleCard.svelte b/frontend/src/lib/components/ArticleCard.svelte index cb4559d..c1443fe 100644 --- a/frontend/src/lib/components/ArticleCard.svelte +++ b/frontend/src/lib/components/ArticleCard.svelte @@ -15,15 +15,20 @@ } -
- - {#if article.image_url && imgOk} - (imgOk = false)} /> - {:else} - {article.topic ?? 'good news'} - {/if} - +
+ + {#if hero || (article.image_url && imgOk)} + + {#if article.image_url && imgOk} + (imgOk = false)} /> + {:else} + {article.topic ?? 'good news'} + {/if} + + {/if}
@@ -95,9 +100,24 @@ .actions { margin-top: auto; padding-top: 10px; display: flex; gap: 14px; flex-wrap: wrap; } .actions button { 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); } + /* 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 .media { aspect-ratio: auto; height: 100%; min-height: 280px; } diff --git a/goodnews/api.py b/goodnews/api.py index 28bca2a..78e3173 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -30,6 +30,7 @@ from pydantic import BaseModel from . import feeds, queries from .db import connect, init_db from .filters import filter_articles, prefs_from_json +from .hero import lead_with_gentle from .llm import LocalModelClient from .moods import MOODS from .taxonomy import FLAVORS, TOPICS @@ -280,7 +281,11 @@ def create_app() -> FastAPI: items = data["items"] if not fp.is_empty(): # 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)) + # 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( brief_date=data["brief_date"], title=data["title"], diff --git a/goodnews/hero.py b/goodnews/hero.py new file mode 100644 index 0000000..63b711a --- /dev/null +++ b/goodnews/hero.py @@ -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 diff --git a/ideas.md b/ideas.md index 8a52c78..3bbb5ee 100644 --- a/ideas.md +++ b/ideas.md @@ -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) - Terms to avoid list (To filter even good news that you'd rather not hear about) -- \ No newline at end of file +- Favorite/save articles diff --git a/tests/test_hero.py b/tests/test_hero.py new file mode 100644 index 0000000..8f4684b --- /dev/null +++ b/tests/test_hero.py @@ -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