Skip to content

Commit cff7ca5

Browse files
committed
Improve Asteria headline board quality
1 parent ce97905 commit cff7ca5

3 files changed

Lines changed: 231 additions & 3 deletions

File tree

‎news_pipeline/news_pipeline/cli/commands/heartbeat_prepare_one.py‎

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from pathlib import Path
88
import re
99
from typing import Any
10+
from urllib.parse import urlparse
1011

1112
import typer
1213
from rapidfuzz.fuzz import token_set_ratio
@@ -77,6 +78,7 @@
7778
}
7879
BLOCKED_BOARD_TERMS = {
7980
"celebrity",
81+
"concert",
8082
"rod stewart",
8183
"ratbag",
8284
"opinion",
@@ -88,6 +90,68 @@
8890
"eurovision entry",
8991
"music museum",
9092
"father ted",
93+
"rapper",
94+
"red card controversy",
95+
"football chiefs",
96+
"training grounds",
97+
}
98+
SPONSORED_URL_MARKERS = {
99+
"/sponsored-content/",
100+
"/brandstudio/",
101+
"/paid-content/",
102+
"/partner-content/",
103+
}
104+
UNREADABLE_PRIMARY_HOSTS = {
105+
"politico.com",
106+
"www.politico.com",
107+
}
108+
TURKISH_READER_SIGNAL_TERMS = {
109+
"tariff",
110+
"tariffs",
111+
"trade",
112+
"sanctions",
113+
"inflation",
114+
"market",
115+
"markets",
116+
"energy",
117+
"oil",
118+
"gas",
119+
"defense",
120+
"defence",
121+
"security",
122+
"nato",
123+
"eu",
124+
"europe",
125+
"ukraine",
126+
"russia",
127+
"china",
128+
"trump",
129+
"ai",
130+
"artificial intelligence",
131+
"chip",
132+
"chips",
133+
"climate",
134+
"heatwave",
135+
"election",
136+
"migration",
137+
"central bank",
138+
"interest rates",
139+
"turkey",
140+
"türkiye",
141+
}
142+
LOW_PUBLIC_VALUE_ODDITY_TERMS = {
143+
"snake",
144+
"snakes",
145+
"cobra",
146+
"cobras",
147+
"venomous",
148+
"escaped",
149+
"rapper",
150+
"concert",
151+
"kanye",
152+
"football chiefs",
153+
"red card",
154+
"infantino",
91155
}
92156

93157

@@ -164,6 +228,24 @@ def _source_name(item: Any) -> str:
164228
return item.draft_sources[0].name if item.draft_sources else "-"
165229

166230

231+
def _source_url(item: Any) -> str:
232+
return str(item.draft_sources[0].url) if item.draft_sources else ""
233+
234+
235+
def _source_host(item: Any) -> str:
236+
host = urlparse(_source_url(item)).netloc.lower()
237+
return host[4:] if host.startswith("www.") else host
238+
239+
240+
def _is_sponsored_source(item: Any) -> bool:
241+
url = _source_url(item).lower()
242+
return any(marker in url for marker in SPONSORED_URL_MARKERS)
243+
244+
245+
def _has_known_unreadable_primary_source(item: Any) -> bool:
246+
return _source_host(item) in UNREADABLE_PRIMARY_HOSTS
247+
248+
167249
def _frontmatter_value(text: str, key: str) -> str:
168250
match = re.search(rf"(?m)^{re.escape(key)}:\s*[\"']?(.*?)[\"']?\s*$", text)
169251
return match.group(1).strip() if match else ""
@@ -329,6 +411,32 @@ def _headline_has_term(headline: str, term: str) -> bool:
329411
return term in headline
330412

331413

414+
def _public_value_text(root: Path, item: Any) -> str:
415+
normalized_store = JsonStore(root / "news_pipeline/data/normalized", NormalizedArticle)
416+
article = normalized_store.load(item.normalized_id)
417+
parts = [
418+
item.draft_title or "",
419+
item.draft_description or "",
420+
" ".join(item.draft_tags or []),
421+
_source_name(item),
422+
]
423+
if article:
424+
parts.extend([article.title or "", article.summary or "", article.content_snippet or "", " ".join(article.tags or [])])
425+
return _normalized(" ".join(parts))
426+
427+
428+
def _has_turkish_reader_signal(text: str) -> bool:
429+
return any(_headline_has_term(text, term) for term in TURKISH_READER_SIGNAL_TERMS)
430+
431+
432+
def _has_low_public_value_oddity(text: str) -> bool:
433+
return any(_headline_has_term(text, term) for term in LOW_PUBLIC_VALUE_ODDITY_TERMS)
434+
435+
436+
def _has_board_hard_veto(reasons: list[str]) -> bool:
437+
return any(reason.startswith("public_value_penalty:") for reason in reasons)
438+
439+
332440
def _board_score(
333441
root: Path,
334442
item: Any,
@@ -338,6 +446,7 @@ def _board_score(
338446
recent_posts: list[dict[str, str]] | None = None,
339447
) -> tuple[float, list[str]]:
340448
headline = _normalized(_headline_text(root, item))
449+
public_value_text = _public_value_text(root, item)
341450
category = item.draft_category or ""
342451
source = _normalized(_source_name(item))
343452
score = float(item.editorial_priority)
@@ -371,6 +480,7 @@ def _board_score(
371480
repeated = "+".join(sorted(repeat_counts))
372481
reasons.append(f"recency_penalty:company_repeat:{repeated}:{strongest_repeat}")
373482
family_hits = _item_topic_family_hits(root, item)
483+
has_recent_family_repeat = False
374484
if family_hits:
375485
recent_family_counts = recent_live_topic_family_counts(
376486
root / "src/content/equinoxHaber",
@@ -382,6 +492,7 @@ def _board_score(
382492
if recent_family_counts[family] >= RECENT_TOPIC_FAMILY_PENALTY_THRESHOLD
383493
}
384494
if repeat_counts:
495+
has_recent_family_repeat = True
385496
strongest_repeat = max(repeat_counts.values())
386497
family_penalty = min(RECENT_TOPIC_FAMILY_PENALTY_MAX, strongest_repeat * RECENT_TOPIC_FAMILY_PENALTY_PER_ITEM)
387498
score -= family_penalty
@@ -402,6 +513,15 @@ def _board_score(
402513
score += 0.035
403514
reasons.append(f"signal_boost:{term}")
404515
break
516+
if _has_turkish_reader_signal(public_value_text) and not localized_crime and not incidental_security and not has_recent_family_repeat:
517+
score += 0.045
518+
reasons.append("signal_boost:turkish_reader_interest")
519+
if _has_low_public_value_oddity(public_value_text):
520+
oddity_penalty = 0.18
521+
if _has_turkish_reader_signal(public_value_text):
522+
oddity_penalty = 0.10
523+
score -= oddity_penalty
524+
reasons.append("public_value_penalty:oddity_or_entertainment")
405525
if science_pressure and category == "Bilim":
406526
score -= SCIENCE_RECENT_PENALTY
407527
reasons.append("recency_penalty:science_saturation")
@@ -424,6 +544,10 @@ def _passes_basic_board_filter(root: Path, item: Any, max_source_age_hours: int,
424544
live_urls = live_urls or set()
425545
if any(str(source.url) in live_urls for source in item.draft_sources):
426546
return False, "source already published"
547+
if _is_sponsored_source(item):
548+
return False, "sponsored or paid content"
549+
if _has_known_unreadable_primary_source(item):
550+
return False, "known unreadable primary source"
427551
fresh, stale_reason = _source_is_fresh(root, item, max_source_age_hours)
428552
if not fresh:
429553
return False, stale_reason
@@ -479,6 +603,7 @@ def _select_headline_board(root: Path, items: list[Any], limit: int, max_source_
479603
eligible.append((item, board_score, reasons))
480604

481605
eligible.sort(key=lambda row: row[1], reverse=True)
606+
score_map = {item.queue_id: (score, reasons) for item, score, reasons in eligible}
482607
selected: list[Any] = []
483608
selected_ids: set[str] = set()
484609
selected_headlines: list[str] = []
@@ -489,6 +614,9 @@ def _select_headline_board(root: Path, items: list[Any], limit: int, max_source_
489614
def add(item: Any) -> bool:
490615
if item.queue_id in selected_ids:
491616
return False
617+
_, board_reasons = score_map.get(item.queue_id, (round(float(item.editorial_priority), 3), []))
618+
if _has_board_hard_veto(board_reasons):
619+
return False
492620
headline = _normalized(_headline_text(root, item))
493621
if any(token_set_ratio(headline, existing) >= 88 for existing in selected_headlines):
494622
return False
@@ -531,7 +659,6 @@ def add(item: Any) -> bool:
531659
break
532660
add(item)
533661

534-
score_map = {item.queue_id: (score, reasons) for item, score, reasons in eligible}
535662
selected.sort(key=lambda item: score_map.get(item.queue_id, (round(float(item.editorial_priority), 3), []))[0], reverse=True)
536663
diagnostics = {
537664
"eligibleCount": len(eligible),

‎news_pipeline/news_pipeline/cli/commands/queue_source_text.py‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,17 @@
66
import typer
77

88
from news_pipeline.extractors.article_text import ArticleDetails, fetch_article_details
9+
from news_pipeline.models.article import NormalizedArticle
910
from news_pipeline.queue.service import QueueService
11+
from news_pipeline.storage.json_store import JsonStore
12+
13+
14+
def _metadata_fallback_text(article: NormalizedArticle | None, *, max_chars: int) -> str:
15+
if article is None:
16+
return ""
17+
parts = [article.summary or "", article.content_snippet or ""]
18+
text = " ".join(part.strip() for part in parts if part and part.strip())
19+
return text[:max_chars].strip()
1020

1121

1222
def queue_source_text_command(
@@ -35,6 +45,13 @@ def queue_source_text_command(
3545

3646
source = item.draft_sources[source_index]
3747
details = fetch_article_details(str(source.url), max_paragraphs=max_paragraphs, max_chars=max_chars)
48+
normalized = JsonStore(root / "news_pipeline/data/normalized", NormalizedArticle).load(item.normalized_id)
49+
extraction_status = "full_text" if details.snippet else "empty"
50+
text = details.snippet
51+
if not text:
52+
text = _metadata_fallback_text(normalized, max_chars=max_chars)
53+
if text:
54+
extraction_status = "metadata_fallback"
3855
payload = {
3956
"queueId": item.queue_id,
4057
"title": item.draft_title,
@@ -44,8 +61,9 @@ def queue_source_text_command(
4461
"url": str(source.url),
4562
},
4663
"publishedAt": details.published_at.isoformat() if details.published_at else None,
47-
"textChars": len(details.snippet),
48-
"text": details.snippet,
64+
"extractionStatus": extraction_status,
65+
"textChars": len(text),
66+
"text": text,
4967
}
5068
if json_output:
5169
print(json.dumps(payload, ensure_ascii=False, indent=2))

‎news_pipeline/tests/test_safety_gates.py‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,68 @@ def test_tv_show_source_format_is_excluded_from_publish_and_board(tmp_path: Path
671671
assert skipped[0]["reason"] == "excluded source format (podcast/liveblog)"
672672

673673

674+
def test_prepare_board_excludes_known_unreadable_primary_source(tmp_path: Path) -> None:
675+
article = _article(
676+
"politico-com-cloudflare",
677+
url="https://www.politico.com/news/2026/07/08/trump-spanish-exports-trade-embargo-00990123",
678+
)
679+
article.source_id = "politico-eu"
680+
article.source_name = "Politico Europe"
681+
article.title = "US officials compiling menu of Spanish goods as Trump weighs embargo"
682+
item = _item(
683+
"politico-com-cloudflare",
684+
normalized_id=article.id,
685+
url=str(article.canonical_url),
686+
priority=0.74,
687+
)
688+
item.draft_sources = [DraftSource(name="Politico Europe", url=str(article.canonical_url))]
689+
_save_runtime(tmp_path, item, article)
690+
691+
selected, skipped, _ = _select_headline_board(tmp_path, [item], limit=10, max_source_age_hours=72)
692+
693+
assert selected == []
694+
assert skipped[0]["reason"] == "known unreadable primary source"
695+
696+
697+
def test_prepare_board_excludes_sponsored_content_url(tmp_path: Path) -> None:
698+
article = _article(
699+
"sponsored-ai",
700+
url="https://www.politico.eu/sponsored-content/europes-ai-moment-four-imperatives-for-business-leaders/",
701+
)
702+
article.source_id = "politico-eu"
703+
article.source_name = "Politico Europe"
704+
article.title = "Europe's AI moment: Four imperatives for business leaders"
705+
item = _item("sponsored-ai", normalized_id=article.id, url=str(article.canonical_url), priority=0.72)
706+
item.draft_sources = [DraftSource(name="Politico Europe", url=str(article.canonical_url))]
707+
_save_runtime(tmp_path, item, article)
708+
709+
selected, skipped, _ = _select_headline_board(tmp_path, [item], limit=10, max_source_age_hours=72)
710+
711+
assert selected == []
712+
assert skipped[0]["reason"] == "sponsored or paid content"
713+
714+
715+
def test_prepare_board_penalizes_low_public_value_oddity_below_board_floor(tmp_path: Path) -> None:
716+
article = _article("snake-flood", url="https://example.org/world/snakes-flood")
717+
article.source_id = "guardian-world"
718+
article.source_name = "The Guardian World"
719+
article.title = "Venomous snakes escape breeding farms in southern China during flooding"
720+
article.summary = "Hundreds of cobras and other snakes escaped during flooding in Guangxi."
721+
item = _item("snake-flood", normalized_id=article.id, url=str(article.canonical_url), priority=0.687)
722+
item.draft_title = article.title
723+
item.draft_description = article.summary
724+
item.draft_category = "Bilim"
725+
item.draft_sources = [DraftSource(name="The Guardian World", url=str(article.canonical_url))]
726+
_save_runtime(tmp_path, item, article)
727+
728+
selected, _, meta = _select_headline_board(tmp_path, [item], limit=10, max_source_age_hours=72)
729+
730+
assert selected == []
731+
score, reasons = meta["scores"][item.queue_id]
732+
assert score < heartbeat_prepare_one.MIN_CATEGORY_TARGET_SCORE
733+
assert "public_value_penalty:oddity_or_entertainment" in reasons
734+
735+
674736
def test_publish_one_retries_next_candidate_after_duplicate_publish_gate(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
675737
monkeypatch.chdir(tmp_path)
676738
first_article = _article("dup-first", url="https://example.org/demo/dup-first")
@@ -1080,6 +1142,27 @@ def test_queue_approve_refuses_rejected_item_without_explicit_recovery(tmp_path:
10801142
queue_approve_command(item.queue_id)
10811143

10821144

1145+
def test_queue_source_text_falls_back_to_normalized_context_when_fetch_is_empty(
1146+
tmp_path: Path,
1147+
monkeypatch: pytest.MonkeyPatch,
1148+
capsys: pytest.CaptureFixture[str],
1149+
) -> None:
1150+
monkeypatch.chdir(tmp_path)
1151+
article = _article("source-text-fallback", url="https://example.org/demo/source-text-fallback")
1152+
article.summary = "RSS summary explains the trade and tariff stakes for readers."
1153+
article.content_snippet = "Normalized snippet keeps enough context when the live article blocks extraction."
1154+
item = _item("source-text-fallback", normalized_id=article.id, url=str(article.canonical_url))
1155+
_save_runtime(tmp_path, item, article)
1156+
monkeypatch.setattr(queue_source_text, "fetch_article_details", lambda *_, **__: queue_source_text.ArticleDetails())
1157+
1158+
queue_source_text.queue_source_text_command(item.queue_id, json_output=True)
1159+
1160+
payload = json.loads(capsys.readouterr().out)
1161+
assert payload["extractionStatus"] == "metadata_fallback"
1162+
assert payload["textChars"] > 0
1163+
assert "RSS summary" in payload["text"]
1164+
1165+
10831166

10841167
def test_body_english_gate_allows_turkish_body_with_english_source_name() -> None:
10851168
body = "\n\n".join(

0 commit comments

Comments
 (0)