Skip to content

Commit 5652e1e

Browse files
committed
Tighten Asteria candidate board quality
1 parent 0660645 commit 5652e1e

3 files changed

Lines changed: 182 additions & 20 deletions

File tree

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

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@
2020
from news_pipeline.queue.service import QueueService
2121
from news_pipeline.storage.json_store import JsonStore
2222

23-
MAX_PER_SOURCE = 3
23+
MAX_PER_SOURCE = 2
24+
DEFAULT_MAX_SOURCE_AGE_HOURS = 30
2425
DEFAULT_PREWARM_BOARD_PATH = "news_pipeline/data/heartbeat/prepared-board.json"
2526
MIN_HEALTHY_BOARD_ELIGIBLE = 6
27+
MIN_THIN_SOURCE_TEXT_CHARS = 500
28+
MIN_BOARD_FILL_SCORE = 0.50
2629
HOT_CATEGORY_RECENT_WINDOW = 3
2730
HOT_CATEGORY_REPEAT_THRESHOLD = 2
2831
HOT_CATEGORY_POLICY = "skip_target_fill_only"
@@ -94,6 +97,10 @@
9497
"red card controversy",
9598
"football chiefs",
9699
"training grounds",
100+
"gifted by erdogan",
101+
"pistols with ammunition",
102+
"revolver gifted",
103+
"parting gift",
97104
}
98105
SPONSORED_URL_MARKERS = {
99106
"/sponsored-content/",
@@ -152,6 +159,8 @@
152159
"football chiefs",
153160
"red card",
154161
"infantino",
162+
"revolver gifted",
163+
"parting gift",
155164
}
156165

157166

@@ -237,6 +246,10 @@ def _source_host(item: Any) -> str:
237246
return host[4:] if host.startswith("www.") else host
238247

239248

249+
def _normalized_article(root: Path, item: Any) -> NormalizedArticle | None:
250+
return JsonStore(root / "news_pipeline/data/normalized", NormalizedArticle).load(item.normalized_id)
251+
252+
240253
def _is_sponsored_source(item: Any) -> bool:
241254
url = _source_url(item).lower()
242255
return any(marker in url for marker in SPONSORED_URL_MARKERS)
@@ -246,6 +259,20 @@ def _has_known_unreadable_primary_source(item: Any) -> bool:
246259
return _source_host(item) in UNREADABLE_PRIMARY_HOSTS
247260

248261

262+
def _source_text_quality_rejection(root: Path, item: Any) -> str | None:
263+
article = _normalized_article(root, item)
264+
if article is None:
265+
return "missing normalized article"
266+
source = _normalized(_source_name(item))
267+
text = _normalized(" ".join(part for part in [article.summary or "", article.content_snippet or ""] if part))
268+
text_len = len(text)
269+
if source == "france 24 world" and re.search(r"\bfrance 24'?s\b.*\b(reports|tells us|explains)\b", text):
270+
return "thin France 24 video/report fallback"
271+
if source == "politico europe" and text_len < MIN_THIN_SOURCE_TEXT_CHARS:
272+
return f"thin Politico snippet ({text_len} chars < {MIN_THIN_SOURCE_TEXT_CHARS})"
273+
return None
274+
275+
249276
def _frontmatter_value(text: str, key: str) -> str:
250277
match = re.search(rf"(?m)^{re.escape(key)}:\s*[\"']?(.*?)[\"']?\s*$", text)
251278
return match.group(1).strip() if match else ""
@@ -352,14 +379,12 @@ def _live_source_urls(root: Path) -> set[str]:
352379

353380

354381
def _headline_text(root: Path, item: Any) -> str:
355-
normalized_store = JsonStore(root / "news_pipeline/data/normalized", NormalizedArticle)
356-
article = normalized_store.load(item.normalized_id)
382+
article = _normalized_article(root, item)
357383
return article.title if article else item.draft_title
358384

359385

360386
def _duplicate_probe_text(root: Path, item: Any) -> tuple[str, str]:
361-
normalized_store = JsonStore(root / "news_pipeline/data/normalized", NormalizedArticle)
362-
article = normalized_store.load(item.normalized_id)
387+
article = _normalized_article(root, item)
363388
title_parts = [item.draft_title or ""]
364389
description_parts = [item.draft_description or ""]
365390
if article:
@@ -370,8 +395,7 @@ def _duplicate_probe_text(root: Path, item: Any) -> tuple[str, str]:
370395

371396

372397
def _item_company_hits(root: Path, item: Any) -> set[str]:
373-
normalized_store = JsonStore(root / "news_pipeline/data/normalized", NormalizedArticle)
374-
article = normalized_store.load(item.normalized_id)
398+
article = _normalized_article(root, item)
375399
article_text = ""
376400
if article:
377401
article_text = article.title or ""
@@ -386,8 +410,7 @@ def _item_company_hits(root: Path, item: Any) -> set[str]:
386410

387411

388412
def _item_topic_family_hits(root: Path, item: Any) -> set[str]:
389-
normalized_store = JsonStore(root / "news_pipeline/data/normalized", NormalizedArticle)
390-
article = normalized_store.load(item.normalized_id)
413+
article = _normalized_article(root, item)
391414
text = " ".join(
392415
[
393416
item.draft_title or "",
@@ -412,8 +435,7 @@ def _headline_has_term(headline: str, term: str) -> bool:
412435

413436

414437
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)
438+
article = _normalized_article(root, item)
417439
parts = [
418440
item.draft_title or "",
419441
item.draft_description or "",
@@ -548,6 +570,9 @@ def _passes_basic_board_filter(root: Path, item: Any, max_source_age_hours: int,
548570
return False, "sponsored or paid content"
549571
if _has_known_unreadable_primary_source(item):
550572
return False, "known unreadable primary source"
573+
source_quality_reason = _source_text_quality_rejection(root, item)
574+
if source_quality_reason:
575+
return False, source_quality_reason
551576
fresh, stale_reason = _source_is_fresh(root, item, max_source_age_hours)
552577
if not fresh:
553578
return False, stale_reason
@@ -614,7 +639,9 @@ def _select_headline_board(root: Path, items: list[Any], limit: int, max_source_
614639
def add(item: Any) -> bool:
615640
if item.queue_id in selected_ids:
616641
return False
617-
_, board_reasons = score_map.get(item.queue_id, (round(float(item.editorial_priority), 3), []))
642+
board_score, board_reasons = score_map.get(item.queue_id, (round(float(item.editorial_priority), 3), []))
643+
if board_score < MIN_BOARD_FILL_SCORE:
644+
return False
618645
if _has_board_hard_veto(board_reasons):
619646
return False
620647
headline = _normalized(_headline_text(root, item))
@@ -688,13 +715,13 @@ def add(item: Any) -> bool:
688715
"scienceSpaceBoardLimit": SCIENCE_SPACE_BOARD_LIMIT if science_space_pressure else None,
689716
"maxPerSource": MAX_PER_SOURCE,
690717
"minCategoryTargetScore": MIN_CATEGORY_TARGET_SCORE,
718+
"minBoardFillScore": MIN_BOARD_FILL_SCORE,
691719
}
692720
return selected, skipped, {"scores": score_map, "diagnostics": diagnostics}
693721

694722

695723
def _article_payload(root: Path, item: Any) -> dict[str, Any]:
696-
normalized_store = JsonStore(root / "news_pipeline/data/normalized", NormalizedArticle)
697-
article = normalized_store.load(item.normalized_id)
724+
article = _normalized_article(root, item)
698725
source_age_hours = None
699726
if article and article.published_at:
700727
source_age_hours = round((datetime.now(UTC) - article.published_at.astimezone(UTC)).total_seconds() / 3600, 2)
@@ -791,14 +818,15 @@ def _full_collect_retry_reason(
791818
if not stats:
792819
return None
793820
eligible_count = int(board_meta["diagnostics"].get("eligibleCount") or 0)
821+
pack_count = len(packs)
794822
if stats.get("sources_collected", 0) == 0:
795-
if packs and eligible_count >= MIN_HEALTHY_BOARD_ELIGIBLE:
823+
if pack_count >= MIN_HEALTHY_BOARD_ELIGIBLE and eligible_count >= MIN_HEALTHY_BOARD_ELIGIBLE:
796824
return None
797825
return "cadence produced zero collected sources"
798826
if not packs and stats.get("skipped_cadence", 0) > 0:
799827
return "no board candidates after cadence-limited collect"
800-
if eligible_count < MIN_HEALTHY_BOARD_ELIGIBLE and stats.get("skipped_cadence", 0) > 0:
801-
return f"thin board ({eligible_count} eligible < {MIN_HEALTHY_BOARD_ELIGIBLE}) after cadence-limited collect"
828+
if (pack_count < MIN_HEALTHY_BOARD_ELIGIBLE or eligible_count < MIN_HEALTHY_BOARD_ELIGIBLE) and stats.get("skipped_cadence", 0) > 0:
829+
return f"thin board ({pack_count} selected, {eligible_count} eligible; target {MIN_HEALTHY_BOARD_ELIGIBLE}) after cadence-limited collect"
802830
return None
803831

804832

@@ -933,7 +961,7 @@ def prepare_one_command(
933961
cleanup: bool = typer.Option(True, "--cleanup/--no-cleanup", help="Run queue cleanup before preparing the editorial pack."),
934962
full_collect: bool = typer.Option(False, "--full-collect", help="Bypass source cadence during collect."),
935963
min_score: float = typer.Option(0.68, "--min-score", help="Strict publish threshold used for diagnostics."),
936-
max_source_age_hours: int = typer.Option(24, "--max-source-age-hours", help="Maximum source age for headline board freshness and auto publish diagnostics."),
964+
max_source_age_hours: int = typer.Option(DEFAULT_MAX_SOURCE_AGE_HOURS, "--max-source-age-hours", help="Maximum source age for headline board freshness and auto publish diagnostics."),
937965
limit: int = typer.Option(20, "--limit", help="How many headline candidates to return."),
938966
json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
939967
) -> None:
@@ -983,7 +1011,7 @@ def board_prewarm_command(
9831011
cleanup: bool = typer.Option(True, "--cleanup/--no-cleanup", help="Run queue cleanup before preparing the prewarmed board."),
9841012
full_collect: bool = typer.Option(False, "--full-collect", help="Bypass source cadence during collect."),
9851013
min_score: float = typer.Option(0.68, "--min-score", help="Strict publish threshold used for diagnostics."),
986-
max_source_age_hours: int = typer.Option(24, "--max-source-age-hours", help="Maximum source age for headline board freshness and auto publish diagnostics."),
1014+
max_source_age_hours: int = typer.Option(DEFAULT_MAX_SOURCE_AGE_HOURS, "--max-source-age-hours", help="Maximum source age for headline board freshness and auto publish diagnostics."),
9871015
limit: int = typer.Option(6, "--limit", help="How many headline candidates to return."),
9881016
output: Path = typer.Option(Path(DEFAULT_PREWARM_BOARD_PATH), "--output", "-o", help="Prewarmed board artifact path."),
9891017
max_age_minutes: int = typer.Option(15, "--max-age-minutes", min=1, help="Freshness window consumers should enforce."),

‎news_pipeline/news_pipeline/cli/commands/heartbeat_publish_one.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
DEFAULT_MIN_SCORE = 0.68
2626
ASTERIA_POLISHED_MIN_SCORE = 0.68
27-
DEFAULT_MAX_SOURCE_AGE_HOURS = 24
27+
DEFAULT_MAX_SOURCE_AGE_HOURS = 30
2828
DEFAULT_MIN_INTERVAL_SECONDS = 3000
2929
STATE_PATH = Path("news_pipeline/data/state/heartbeat-publish-one.json")
3030
MAX_STEP_LOG_CHARS = 900

‎news_pipeline/tests/test_safety_gates.py‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,65 @@ def test_prepare_board_excludes_sponsored_content_url(tmp_path: Path) -> None:
712712
assert skipped[0]["reason"] == "sponsored or paid content"
713713

714714

715+
def test_prepare_board_excludes_thin_france24_video_report_fallback(tmp_path: Path) -> None:
716+
article = _article(
717+
"france24-video-report",
718+
url="https://www.france24.com/en/trump-says-will-cut-off-all-trade-with-spain-over-nato-contribution-row",
719+
)
720+
article.source_id = "france24-world"
721+
article.source_name = "France 24 World"
722+
article.title = "Trump says will cut off all trade with Spain over NATO contribution row"
723+
article.summary = "US President Donald Trump threatened to halt trade with Spain over NATO spending. FRANCE 24's Monte Francis reports."
724+
article.content_snippet = article.summary
725+
item = _item("france24-video-report", normalized_id=article.id, url=str(article.canonical_url), priority=0.74)
726+
item.draft_sources = [DraftSource(name="France 24 World", url=str(article.canonical_url))]
727+
_save_runtime(tmp_path, item, article)
728+
729+
selected, skipped, _ = _select_headline_board(tmp_path, [item], limit=10, max_source_age_hours=72)
730+
731+
assert selected == []
732+
assert skipped[0]["reason"] == "thin France 24 video/report fallback"
733+
734+
735+
def test_prepare_board_excludes_thin_politico_snippet(tmp_path: Path) -> None:
736+
article = _article(
737+
"politico-thin-snippet",
738+
url="https://www.politico.eu/article/nato-ankara-summit-wasted-opportunity/",
739+
)
740+
article.source_id = "politico-eu"
741+
article.source_name = "Politico Europe"
742+
article.title = "NATO's Ankara summit was a wasted opportunity"
743+
article.summary = "Europe could have used the meeting to show the U.S. how it will take the lead."
744+
article.content_snippet = "The U.S. may have the strongest military in the world."
745+
item = _item("politico-thin-snippet", normalized_id=article.id, url=str(article.canonical_url), priority=0.72)
746+
item.draft_sources = [DraftSource(name="Politico Europe", url=str(article.canonical_url))]
747+
_save_runtime(tmp_path, item, article)
748+
749+
selected, skipped, _ = _select_headline_board(tmp_path, [item], limit=10, max_source_age_hours=72)
750+
751+
assert selected == []
752+
assert skipped[0]["reason"] == "thin Politico snippet (133 chars < 500)"
753+
754+
755+
def test_prepare_board_keeps_readable_cnbc_even_with_short_normalized_snippet(tmp_path: Path) -> None:
756+
article = _article("cnbc-readable-short", url="https://www.cnbc.com/2026/07/09/meta-ai-coding-market.html")
757+
article.source_id = "cnbc-tech"
758+
article.source_name = "CNBC Technology"
759+
article.title = "Meta jumps into AI coding market in effort to chase Anthropic and OpenAI"
760+
article.summary = "Meta is upgrading its artificial intelligence coding model."
761+
article.content_snippet = "Meta is rolling out a major update as it attempts to compete with OpenAI and Anthropic in critical areas of the AI coding market."
762+
item = _item("cnbc-readable-short", normalized_id=article.id, url=str(article.canonical_url), priority=0.706)
763+
item.draft_title = article.title
764+
item.draft_description = article.content_snippet
765+
item.draft_category = "Teknoloji"
766+
item.draft_sources = [DraftSource(name="CNBC Technology", url=str(article.canonical_url))]
767+
_save_runtime(tmp_path, item, article)
768+
769+
selected, _, _ = _select_headline_board(tmp_path, [item], limit=10, max_source_age_hours=72)
770+
771+
assert [item.queue_id for item in selected] == ["cnbc-readable-short"]
772+
773+
715774
def test_prepare_board_penalizes_low_public_value_oddity_below_board_floor(tmp_path: Path) -> None:
716775
article = _article("snake-flood", url="https://example.org/world/snakes-flood")
717776
article.source_id = "guardian-world"
@@ -733,6 +792,23 @@ def test_prepare_board_penalizes_low_public_value_oddity_below_board_floor(tmp_p
733792
assert "public_value_penalty:oddity_or_entertainment" in reasons
734793

735794

795+
def test_prepare_board_excludes_low_public_value_ceremonial_gift_story(tmp_path: Path) -> None:
796+
article = _article("nato-gift", url="https://example.org/demo/nato-gift")
797+
article.title = "Newly released footage shows revolver gifted by Erdogan to NATO leaders"
798+
article.summary = "Images show a revolver and bullets given as a parting gift after the NATO summit."
799+
item = _item("nato-gift", normalized_id=article.id, url=str(article.canonical_url), priority=0.712)
800+
item.draft_title = article.title
801+
item.draft_description = article.summary
802+
item.draft_category = "Siyaset"
803+
item.draft_sources = [DraftSource(name="Euronews World", url=str(article.canonical_url))]
804+
_save_runtime(tmp_path, item, article)
805+
806+
selected, skipped, _ = _select_headline_board(tmp_path, [item], limit=10, max_source_age_hours=72)
807+
808+
assert selected == []
809+
assert skipped[0]["reason"] == "blocked low-signal headline"
810+
811+
736812
def test_publish_one_retries_next_candidate_after_duplicate_publish_gate(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
737813
monkeypatch.chdir(tmp_path)
738814
first_article = _article("dup-first", url="https://example.org/demo/dup-first")
@@ -1353,6 +1429,25 @@ def test_prepare_one_full_collect_retry_still_runs_when_cadence_board_is_thin()
13531429
assert reason == "cadence produced zero collected sources"
13541430

13551431

1432+
def test_prepare_one_full_collect_retry_when_selected_board_is_thin_even_if_eligible_exists() -> None:
1433+
reason = heartbeat_prepare_one._full_collect_retry_reason(
1434+
collect=True,
1435+
full_collect=False,
1436+
steps=[
1437+
{
1438+
"name": "collect",
1439+
"ok": True,
1440+
"stdout": "sources_collected=4 items=4 skipped_cadence=14 failed=0",
1441+
"stderr": "",
1442+
}
1443+
],
1444+
packs=[{"queueId": f"candidate-{index}"} for index in range(5)],
1445+
board_meta={"diagnostics": {"eligibleCount": 14}},
1446+
)
1447+
1448+
assert reason == "thin board (5 selected, 14 eligible; target 6) after cadence-limited collect"
1449+
1450+
13561451

13571452
def test_headline_board_penalizes_recent_topic_family_saturation(tmp_path: Path) -> None:
13581453
content = tmp_path / "src/content/equinoxHaber"
@@ -1566,6 +1661,45 @@ def test_headline_board_excludes_low_score_category_fill_even_behind_stronger_gl
15661661
assert meta["diagnostics"]["minCategoryTargetScore"] == 0.68
15671662

15681663

1664+
def test_headline_board_does_not_fill_with_very_low_board_score_repeat(tmp_path: Path) -> None:
1665+
content = tmp_path / "src/content/equinoxHaber"
1666+
content.mkdir(parents=True)
1667+
for index in range(2):
1668+
(content / f"recent-openai-{index}.md").write_text(
1669+
f"""---
1670+
title: "OpenAI, yeni ChatGPT sürümünü duyurdu {index}"
1671+
description: "OpenAI ve ChatGPT gündemi işlendi."
1672+
pubDate: '2026-06-15T1{index}:00:00+03:00'
1673+
category: "Teknoloji"
1674+
tags: ["OpenAI", "ChatGPT"]
1675+
sources:
1676+
- name: "Tech Source"
1677+
url: "https://example.org/recent-openai-{index}"
1678+
---
1679+
Gövde.
1680+
""",
1681+
encoding="utf-8",
1682+
)
1683+
article = _article("openai-repeat", url="https://example.org/demo/openai-repeat")
1684+
article.title = "OpenAI rolls out GPT-5.6 after government greenlight"
1685+
article.summary = "OpenAI and ChatGPT remain central to the product announcement."
1686+
article.tags = ["OpenAI", "ChatGPT"]
1687+
item = _item("openai-repeat", normalized_id=article.id, url=str(article.canonical_url), priority=0.68)
1688+
item.draft_title = article.title
1689+
item.draft_description = article.summary
1690+
item.draft_category = "Teknoloji"
1691+
item.draft_tags = ["OpenAI", "ChatGPT"]
1692+
item.draft_sources = [DraftSource(name="The Verge", url=str(article.canonical_url))]
1693+
_save_runtime(tmp_path, item, article)
1694+
1695+
selected, _, meta = _select_headline_board(tmp_path, [item], limit=10, max_source_age_hours=72)
1696+
1697+
assert selected == []
1698+
score, _ = meta["scores"][item.queue_id]
1699+
assert score < heartbeat_prepare_one.MIN_BOARD_FILL_SCORE
1700+
assert meta["diagnostics"]["minBoardFillScore"] == 0.50
1701+
1702+
15691703
def test_politics_is_not_treated_as_hot_category(tmp_path: Path) -> None:
15701704
content = tmp_path / "src/content/equinoxHaber"
15711705
content.mkdir(parents=True)

0 commit comments

Comments
 (0)