{
  "sha": "e51a50d5bdcd06ce27749c2380af6121bd1ad71f",
  "message": "以每日新聞與 LLM 產生搜尋提示 (#75)\n\n每天臺北時間 00:01 依已發布新聞產生六組中英文搜尋提示，並提供文章綁定驗證與確定性備援。",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-08-09T22:55:37Z",
  "files": [
    {
      "filename": ".github/workflows/hourly.yml",
      "previous_filename": "",
      "status": "modified",
      "additions": 4,
      "deletions": 1,
      "changes": 5
    },
    {
      "filename": "pipeline/CONTRACTS.md",
      "previous_filename": "",
      "status": "modified",
      "additions": 9,
      "deletions": 0,
      "changes": 9
    },
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 23,
      "deletions": 1,
      "changes": 24
    },
    {
      "filename": "pipeline/search_prompts.py",
      "previous_filename": "",
      "status": "added",
      "additions": 308,
      "deletions": 0,
      "changes": 308
    },
    {
      "filename": "tests/test_search.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 3,
      "deletions": 16,
      "changes": 19
    },
    {
      "filename": "tests/test_search_prompts.py",
      "previous_filename": "",
      "status": "added",
      "additions": 116,
      "deletions": 0,
      "changes": 116
    }
  ],
  "diff": "@@ -2,7 +2,7 @@ name: hourly-update\\n \\n on:\\n   schedule:\\n-    - cron: \"0 * * * *\" # every hour on the hour (UTC)\\n+    - cron: \"1 * * * *\" # hourly; 16:01 UTC is 00:01 Asia/Taipei\\n   workflow_dispatch:\\n   push:\\n     branches: [main]\\n@@ -65,6 +65,9 @@ jobs:\\n           python pipeline/dedupe.py\\n           python pipeline/write.py\\n           python pipeline/images.py\\n+          # The saved Taipei date makes this one LLM-assisted refresh per day.\\n+          # Missing/invalid model output falls back to published article titles.\\n+          python pipeline/search_prompts.py --daily-if-due\\n           # a briefing run may have committed newer briefing JSONs since this\\n           # job checked out; deploys race (last wins), so refresh them before\\n           # building or this deploy can put a stale briefing page back live\\n@@ -317,6 +317,15 @@ Planespotters.net (same airframe, by registration) or Wikimedia Commons\\n caches per-article match/none results. build.normalize_image() is the\\n only reader; templates always show kind + credit + license + backlink.\\n \\n+## data/search-prompts.json  (written by search_prompts.py, read by build.py)\\n+\\n+One daily set of six bilingual search-box suggestions. `targetDateTpe` prevents\\n+more than one generation per Taipei calendar day; `generationMode` is `llm`\\n+only after every suggestion passes article-ID, length, safety, uniqueness and\\n+title-anchor validation. `sourceArticleIds` records the published articles used.\\n+When no provider is available or model output is invalid, deterministic prompts\\n+are built from those article titles instead. No model reasoning is stored.\\n+\\n ## data/usage.json  (written by usage.py, read by build.py)\\n \\n Cumulative LLM API spend: per-model-label {calls, inputTokens,\\n@@ -20,6 +20,7 @@\\n import time\\n import unicodedata\\n from datetime import timedelta, timezone\\n+from functools import lru_cache\\n from pathlib import Path\\n from urllib.parse import urlsplit\\n \\n@@ -1001,6 +1002,24 @@ def collect_articles():\\n     return sorted(by_id.values(), key=lambda a: a[\"dt\"], reverse=True)\\n \\n \\n+@lru_cache(maxsize=2)\\n+def header_search_placeholders(lang: str) -> tuple[str, ...]:\\n+    \"\"\"Load the daily LLM output, with the built-in prompts as fallback.\"\"\"\\n+    fallback = tuple(L[lang][\"headerSearchPlaceholders\"])\\n+    payload = load_json(DATA_DIR / \"search-prompts.json\", {})\\n+    prompts = ((payload.get(\"prompts\") or {}).get(lang)\\n+               if isinstance(payload, dict) else None)\\n+    if not isinstance(prompts, list) or not 2 <= len(prompts) <= 8:\\n+        return fallback\\n+    cleaned = []\\n+    for value in prompts:\\n+        text = re.sub(r\"\\s+\", \" \", str(value or \"\")).strip()\\n+        if not text or len(text) > 96 or re.search(r\"[\\x00-\\x1f<>]\", text):\\n+            return fallback\\n+        cleaned.append(text)\\n+    return tuple(cleaned) if len(set(cleaned)) == len(cleaned) else fallback\\n+\\n+\\n def notification_summary(value: str, lang: str) -> str:\\n     \"\"\"Make legacy article summaries glanceable without changing source data.\"\"\"\\n     text = re.sub(r\"\\s+\", \" \", str(value or \"\")).strip()\\n@@ -2323,7 +2342,10 @@ def render_manual_workbench(env, build) -> int:\\n # ── rendering ────────────────────────────────────────────────────────────────\\n \\n def base_ctx(lang, page, sub, *, title, description, ticker, build, hreflang=True):\\n-    t = L[lang]\\n+    t = dict(L[lang])\\n+    prompts = list(header_search_placeholders(lang))\\n+    t[\"headerSearchPlaceholders\"] = prompts\\n+    t[\"headerSearchPlaceholder\"] = prompts[0]\\n     nav_defs = [(\"home\", \"\"), (\"briefings\", \"briefings/\"),\\n                 (\"radar\", \"radar/\"),\\n                 (\"incidents\", \"incidents/\"),\\n@@ -0,0 +1,308 @@\\n+\"\"\"Generate daily, article-grounded search-box prompts.\\n+\\n+The hourly workflow calls this once per run.  A saved Taipei calendar date\\n+keeps the LLM budget to one successful generation per day; the first run after\\n+midnight refreshes the file.  Invalid or unavailable model output always falls\\n+back to deterministic prompts made from published article titles.\\n+\"\"\"\\n+from __future__ import annotations\\n+\\n+import argparse\\n+import json\\n+import os\\n+import re\\n+import sys\\n+from datetime import datetime, timedelta, timezone\\n+from pathlib import Path\\n+\\n+sys.path.insert(0, str(Path(__file__).resolve().parent))\\n+from common import DATA_DIR, iso_minute, load_json, now_utc, parse_iso, save_json  # noqa: E402\\n+\\n+TPE = timezone(timedelta(hours=8), \"UTC+8\")\\n+OUTPUT_NAME = \"search-prompts.json\"\\n+PROMPT_COUNT = 6\\n+MAX_CANDIDATES = 12\\n+MAX_ZH_CHARS = 26\\n+MAX_EN_CHARS = 58\\n+\\n+PROMPT_SCHEMA = {\\n+    \"type\": \"object\",\\n+    \"properties\": {\\n+        \"prompts\": {\\n+            \"type\": \"array\",\\n+            \"items\": {\\n+                \"type\": \"object\",\\n+                \"properties\": {\\n+                    \"articleId\": {\"type\": \"string\"},\\n+                    \"zh\": {\"type\": \"string\"},\\n+                    \"en\": {\"type\": \"string\"},\\n+                },\\n+                \"required\": [\"articleId\", \"zh\", \"en\"],\\n+                \"additionalProperties\": False,\\n+            },\\n+        },\\n+    },\\n+    \"required\": [\"prompts\"],\\n+    \"additionalProperties\": False,\\n+}\\n+\\n+SYSTEM_PROMPT = \"\"\"You create six short search-box suggestions for SKYTICAL.\\n+Use ONLY the supplied published articles. Pick six distinct article IDs and\\n+return JSON matching the schema. Each suggestion must clearly reuse a named\\n+airline, flight number, airport, city, aircraft type, registration or other\\n+searchable entity from that article's title. Do not add facts, dates, numbers,\\n+causes or conclusions. Traditional Chinese must use Taiwan wording and be at\\n+most 26 characters; English must be at most 58 characters. Do not use URLs,\\n+HTML, markdown, relative-time words such as today/yesterday, or generic prompts.\\n+The suggestions should feel friendly and natural, not like headlines.\"\"\"\\n+\\n+_SPACE_RE = re.compile(r\"\\s+\")\\n+_ASCII_TOKEN_RE = re.compile(r\"[A-Za-z0-9][A-Za-z0-9-]{1,}\")\\n+_CJK_RE = re.compile(r\"[\\u3400-\\u9fff]+\")\\n+_UNSAFE_RE = re.compile(\\n+    r\"https?://|<|>|reasoning_content|\\b(?:today|yesterday)\\b|今天|昨天\",\\n+    re.IGNORECASE,\\n+)\\n+\\n+\\n+def _text(value) -> str:\\n+    return _SPACE_RE.sub(\" \", str(value or \"\")).strip()\\n+\\n+\\n+def _article_time(raw: dict) -> datetime | None:\\n+    try:\\n+        return parse_iso(str(raw.get(\"publishedUtc\") or \"\"))\\n+    except (TypeError, ValueError):\\n+        return None\\n+\\n+\\n+def collect_candidates(articles_dir: Path, now: datetime) -> tuple[list[dict], str]:\\n+    \"\"\"Prefer today's Taipei stories, then month-to-date, then latest.\"\"\"\\n+    rows = []\\n+    for path in sorted(articles_dir.glob(\"*.json\")) if articles_dir.is_dir() else []:\\n+        payload = load_json(path, {})\\n+        article_rows = payload.get(\"articles\") if isinstance(payload, dict) else []\\n+        for raw in article_rows if isinstance(article_rows, list) else []:\\n+            if not isinstance(raw, dict) or raw.get(\"archived\") is True:\\n+                continue\\n+            article_id = _text(raw.get(\"id\"))\\n+            published = _article_time(raw)\\n+            if not article_id or published is None or published > now + timedelta(hours=6):\\n+                continue\\n+            zh = raw.get(\"zh\") if isinstance(raw.get(\"zh\"), dict) else {}\\n+            en = raw.get(\"en\") if isinstance(raw.get(\"en\"), dict) else {}\\n+            zh_title = _text(zh.get(\"title\") or en.get(\"title\"))\\n+            en_title = _text(en.get(\"title\") or zh.get(\"title\"))\\n+            if not zh_title or not en_title:\\n+                continue\\n+            rows.append({\\n+                \"id\": article_id,\\n+                \"publishedUtc\": iso_minute(published),\\n+                \"publishedTpeDate\": published.astimezone(TPE).strftime(\"%Y-%m-%d\"),\\n+                \"zhTitle\": zh_title,\\n+                \"zhSummary\": _text(zh.get(\"summary\"))[:240],\\n+                \"enTitle\": en_title,\\n+                \"enSummary\": _text(en.get(\"summary\"))[:320],\\n+                \"_dt\": published,\\n+            })\\n+\\n+    by_id = {}\\n+    for row in sorted(rows, key=lambda item: item[\"_dt\"], reverse=True):\\n+        by_id.setdefault(row[\"id\"], row)\\n+    ordered = list(by_id.values())\\n+    today = now.astimezone(TPE).date()\\n+    month_key = (today.year, today.month)\\n+    today_rows = [row for row in ordered if row[\"_dt\"].astimezone(TPE).date() == today]\\n+    month_rows = [row for row in ordered\\n+                  if (row[\"_dt\"].astimezone(TPE).year,\\n+                      row[\"_dt\"].astimezone(TPE).month) == month_key]\\n+\\n+    selected = []\\n+    for pool in (today_rows, month_rows, ordered):\\n+        for row in pool:\\n+            if row[\"id\"] not in {item[\"id\"] for item in selected}:\\n+                selected.append(row)\\n+            if len(selected) >= MAX_CANDIDATES:\\n+                break\\n+        if len(selected) >= MAX_CANDIDATES:\\n+            break\\n+\\n+    if len(today_rows) >= PROMPT_COUNT:\\n+        window = \"today\"\\n+    elif month_rows:\\n+        window = \"month_to_date\"\\n+    else:\\n+        window = \"latest\"\\n+    for row in selected:\\n+        row.pop(\"_dt\", None)\\n+    return selected, window\\n+\\n+\\n+def _shares_anchor(prompt: str, title: str) -> bool:\\n+    folded_prompt = prompt.casefold()\\n+    tokens = {\\n+        token.casefold() for token in _ASCII_TOKEN_RE.findall(title)\\n+        if len(token) >= 3\\n+    }\\n+    if any(token in folded_prompt for token in tokens):\\n+        return True\\n+    for run in _CJK_RE.findall(title):\\n+        if any(run[index:index + 2] in prompt\\n+               for index in range(max(0, len(run) - 1))):\\n+            return True\\n+    return False\\n+\\n+\\n+def validate_llm_prompts(draft, candidates: list[dict]) -> tuple[dict, list[str]] | None:\\n+    rows = draft.get(\"prompts\") if isinstance(draft, dict) else None\\n+    if not isinstance(rows, list) or len(rows) != PROMPT_COUNT:\\n+        return None\\n+    source_by_id = {row[\"id\"]: row for row in candidates}\\n+    seen_ids, source_ids, zh_prompts, en_prompts = set(), [], [], []\\n+    for row in rows:\\n+        if not isinstance(row, dict):\\n+            return None\\n+        article_id = _text(row.get(\"articleId\"))\\n+        zh = _text(row.get(\"zh\"))\\n+        en = _text(row.get(\"en\"))\\n+        source = source_by_id.get(article_id)\\n+        if source is None or article_id in seen_ids:\\n+            return None\\n+        if not (4 <= len(zh) <= MAX_ZH_CHARS and 8 <= len(en) <= MAX_EN_CHARS):\\n+            return None\\n+        if _UNSAFE_RE.search(zh) or _UNSAFE_RE.search(en):\\n+            return None\\n+        if not _shares_anchor(zh, source[\"zhTitle\"]):\\n+            return None\\n+        if not _shares_anchor(en, source[\"enTitle\"]):\\n+            return None\\n+        seen_ids.add(article_id)\\n+        source_ids.append(article_id)\\n+        zh_prompts.append(zh)\\n+        en_prompts.append(en)\\n+    if len(set(zh_prompts)) != PROMPT_COUNT or len(set(en_prompts)) != PROMPT_COUNT:\\n+        return None\\n+    return {\"zh\": zh_prompts, \"en\": en_prompts}, source_ids\\n+\\n+\\n+def _truncate(text: str, limit: int) -> str:\\n+    return text if len(text) <= limit else text[:limit - 1].rstrip() + \"…\"\\n+\\n+\\n+def deterministic_prompts(candidates: list[dict]) -> tuple[dict, list[str]]:\\n+    zh, en, source_ids = [], [], []\\n+    for row in candidates:\\n+        zh_prompt = _truncate(f\"搜尋：{row['zhTitle']}\", MAX_ZH_CHARS)\\n+        en_prompt = _truncate(f\"Explore: {row['enTitle']}\", MAX_EN_CHARS)\\n+        if zh_prompt in zh or en_prompt in en:\\n+            continue\\n+        zh.append(zh_prompt)\\n+        en.append(en_prompt)\\n+        source_ids.append(row[\"id\"])\\n+        if len(zh) >= PROMPT_COUNT:\\n+            break\\n+    generic_zh = [\\n+        \"搜尋班號，例如 CI100\", \"搜尋航空公司\", \"搜尋機場或城市\",\\n+        \"搜尋機型，例如 A350\", \"探索本月航空新聞\", \"看看最新航空動態\",\\n+    ]\\n+    generic_en = [\\n+        \"Search a flight number, e.g. CI100\", \"Search an airline\",\\n+        \"Search an airport or city\", \"Search an aircraft type, e.g. A350\",\\n+        \"Explore this month's aviation news\", \"See the latest aviation news\",\\n+    ]\\n+    for zh_prompt, en_prompt in zip(generic_zh, generic_en):\\n+        if len(zh) >= PROMPT_COUNT:\\n+            break\\n+        if zh_prompt in zh or en_prompt in en:\\n+            continue\\n+        zh.append(zh_prompt)\\n+        en.append(en_prompt)\\n+    return {\"zh\": zh, \"en\": en}, source_ids\\n+\\n+\\n+def _provider_limit() -> int:\\n+    try:\\n+        return max(1, min(int(os.environ.get(\"SEARCH_PROMPTS_LLM_ATTEMPTS\", \"3\")), 5))\\n+    except ValueError:\\n+        return 3\\n+\\n+\\n+def update_daily_prompts(*, data_dir: Path = DATA_DIR, now: datetime | None = None,\\n+                         force: bool = False, providers=None,\\n+                         record_usage: bool = True) -> bool:\\n+    now = now or now_utc()\\n+    output_path = data_dir / OUTPUT_NAME\\n+    target_date = now.astimezone(TPE).strftime(\"%Y-%m-%d\")\\n+    existing = load_json(output_path, {})\\n+    if not force and isinstance(existing, dict) \\\\n+            and existing.get(\"targetDateTpe\") == target_date:\\n+        print(f\"search-prompts: {target_date} already generated\")\\n+        return False\\n+\\n+    candidates, window = collect_candidates(data_dir / \"articles\", now)\\n+    prompts, source_ids = deterministic_prompts(candidates)\\n+    generation_mode, provider_label = \"deterministic\", None\\n+    attempted = []\\n+\\n+    if len(candidates) >= PROMPT_COUNT:\\n+        if providers is None:\\n+            try:\\n+                from providers import build_providers\\n+                providers = build_providers()\\n+            except Exception as exc:\\n+                print(f\"search-prompts: providers unavailable ({type(exc).__name__})\")\\n+                providers = []\\n+        digest = json.dumps(candidates, ensure_ascii=False, separators=(\",\", \":\"))\\n+        for provider in list(providers or [])[:_provider_limit()]:\\n+            attempted.append(provider)\\n+            try:\\n+                draft = provider.draft(SYSTEM_PROMPT, digest, PROMPT_SCHEMA)\\n+                validated = validate_llm_prompts(draft, candidates)\\n+            except Exception as exc:\\n+                print(f\"search-prompts: {getattr(provider, 'label', '?')} failed \"\\n+                      f\"({type(exc).__name__})\")\\n+                continue\\n+            if validated is None:\\n+                print(f\"search-prompts: {getattr(provider, 'label', '?')} \"\\n+                      \"returned invalid prompts\")\\n+                continue\\n+            prompts, source_ids = validated\\n+            generation_mode = \"llm\"\\n+            provider_label = str(getattr(provider, \"label\", \"unknown\"))[:160]\\n+            break\\n+\\n+    if record_usage and attempted:\\n+        try:\\n+            import usage\\n+            usage.record_providers(attempted)\\n+        except Exception as exc:\\n+            print(f\"search-prompts: usage update failed ({type(exc).__name__})\")\\n+\\n+    payload = {\\n+        \"version\": 1,\\n+        \"generatedUtc\": iso_minute(now),\\n+        \"targetDateTpe\": target_date,\\n+        \"sourceWindow\": window,\\n+        \"generationMode\": generation_mode,\\n+        \"provider\": provider_label,\\n+        \"sourceArticleIds\": source_ids,\\n+        \"prompts\": prompts,\\n+    }\\n+    save_json(output_path, payload)\\n+    print(f\"search-prompts: wrote {PROMPT_COUNT} prompts for {target_date} \"\\n+          f\"({generation_mode}, {window})\")\\n+    return True\\n+\\n+\\n+def main() -> int:\\n+    parser = argparse.ArgumentParser()\\n+    group = parser.add_mutually_exclusive_group()\\n+    group.add_argument(\"--daily-if-due\", action=\"store_true\")\\n+    group.add_argument(\"--force\", action=\"store_true\")\\n+    args = parser.parse_args()\\n+    update_daily_prompts(force=args.force)\\n+    return 0\\n+\\n+\\n+if __name__ == \"__main__\":\\n+    raise SystemExit(main())\\n@@ -89,22 +89,9 @@ def main() -> None:\\n         r\"data-search-placeholders='([^']+)'\", zh).group(1))\\n     en_placeholders = json.loads(re.search(\\n         r\"data-search-placeholders='([^']+)'\", en).group(1))\\n-    assert zh_placeholders == [\\n-        \"搜尋班號，例如 CI100\",\\n-        \"搜尋航空公司\",\\n-        \"搜尋機場或城市\",\\n-        \"搜尋機型，例如 A350\",\\n-        \"隨意搜尋想看的 SKYTICAL 內容\",\\n-        \"今天想看什麼航空新聞？\",\\n-    ]\\n-    assert en_placeholders == [\\n-        \"Search a flight number, e.g. CI100\",\\n-        \"Search an airline\",\\n-        \"Search an airport or city\",\\n-        \"Search an aircraft type, e.g. A350\",\\n-        \"Explore anything on SKYTICAL\",\\n-        \"What aviation news are you looking for?\",\\n-    ]\\n+    assert zh_placeholders == list(build.header_search_placeholders(\"zh\"))\\n+    assert en_placeholders == list(build.header_search_placeholders(\"en\"))\\n+    assert len(zh_placeholders) == len(en_placeholders) == 6\\n     assert zh.count('id=\"news-search-form\"') == 1\\n     assert 'class=\"header-search-form\"' in home\\n     assert 'class=\"header-search-form\"' in article\\n@@ -0,0 +1,116 @@\\n+\"\"\"Offline checks for daily article-grounded search suggestions.\"\"\"\\n+from __future__ import annotations\\n+\\n+import json\\n+import sys\\n+import tempfile\\n+from datetime import datetime, timezone\\n+from pathlib import Path\\n+\\n+ROOT = Path(__file__).resolve().parent.parent\\n+sys.path.insert(0, str(ROOT / \"pipeline\"))\\n+\\n+import search_prompts  # noqa: E402\\n+\\n+\\n+class FakeProvider:\\n+    label = \"fake:test-model\"\\n+    http_calls = 1\\n+    usage = {\"inputTokens\": 100, \"outputTokens\": 50, \"usageEvents\": 1}\\n+\\n+    def __init__(self):\\n+        self.calls = 0\\n+\\n+    def draft(self, system_prompt, user_prompt, schema):\\n+        self.calls += 1\\n+        assert \"ONLY the supplied published articles\" in system_prompt\\n+        assert schema == search_prompts.PROMPT_SCHEMA\\n+        candidates = json.loads(user_prompt)\\n+        return {\"prompts\": [\\n+            {\\n+                \"articleId\": row[\"id\"],\\n+                \"zh\": f\"關注：{row['zhTitle']}\",\\n+                \"en\": f\"Explore {row['enTitle']}\",\\n+            }\\n+            for row in candidates[:6]\\n+        ]}\\n+\\n+\\n+def article(index: int) -> dict:\\n+    return {\\n+        \"id\": f\"a-20260810-000{index}-test-{index}\",\\n+        \"publishedUtc\": f\"2026-08-10T00:0{index}:00Z\",\\n+        \"zh\": {\\n+            \"title\": f\"航空{index} A32{index} 抵達桃園\",\\n+            \"summary\": f\"航空{index}的 A32{index} 航機抵達桃園機場。\",\\n+        },\\n+        \"en\": {\\n+            \"title\": f\"Airline {index} A32{index} arrives at Taoyuan\",\\n+            \"summary\": f\"Airline {index}'s A32{index} arrived at Taoyuan.\",\\n+        },\\n+    }\\n+\\n+\\n+def main() -> None:\\n+    now = datetime(2026, 8, 10, 6, 30, tzinfo=timezone.utc)\\n+    with tempfile.TemporaryDirectory() as tmp:\\n+        data_dir = Path(tmp)\\n+        articles_dir = data_dir / \"articles\"\\n+        articles_dir.mkdir()\\n+        (articles_dir / \"batch.json\").write_text(\\n+            json.dumps({\"articles\": [article(i) for i in range(8)]},\\n+                       ensure_ascii=False),\\n+            encoding=\"utf-8\",\\n+        )\\n+\\n+        candidates, window = search_prompts.collect_candidates(articles_dir, now)\\n+        assert len(candidates) == 8\\n+        assert window == \"today\"\\n+\\n+        provider = FakeProvider()\\n+        changed = search_prompts.update_daily_prompts(\\n+            data_dir=data_dir, now=now, providers=[provider],\\n+            record_usage=False)\\n+        assert changed is True and provider.calls == 1\\n+        payload = json.loads(\\n+            (data_dir / \"search-prompts.json\").read_text(encoding=\"utf-8\"))\\n+        assert payload[\"targetDateTpe\"] == \"2026-08-10\"\\n+        assert payload[\"generationMode\"] == \"llm\"\\n+        assert payload[\"provider\"] == \"fake:test-model\"\\n+        assert len(payload[\"prompts\"][\"zh\"]) == 6\\n+        assert len(payload[\"sourceArticleIds\"]) == 6\\n+\\n+        second_provider = FakeProvider()\\n+        assert search_prompts.update_daily_prompts(\\n+            data_dir=data_dir, now=now, providers=[second_provider],\\n+            record_usage=False) is False\\n+        assert second_provider.calls == 0\\n+\\n+        fallback_dir = data_dir / \"fallback\"\\n+        fallback_articles = fallback_dir / \"articles\"\\n+        fallback_articles.mkdir(parents=True)\\n+        (fallback_articles / \"batch.json\").write_text(\\n+            json.dumps({\"articles\": [article(i) for i in range(8)]},\\n+                       ensure_ascii=False),\\n+            encoding=\"utf-8\",\\n+        )\\n+        invalid = FakeProvider()\\n+        invalid.draft = lambda *_args: {\"prompts\": []}\\n+        search_prompts.update_daily_prompts(\\n+            data_dir=fallback_dir, now=now, force=True, providers=[invalid],\\n+            record_usage=False)\\n+        fallback = json.loads(\\n+            (fallback_dir / \"search-prompts.json\").read_text(encoding=\"utf-8\"))\\n+        assert fallback[\"generationMode\"] == \"deterministic\"\\n+        assert fallback[\"provider\"] is None\\n+        assert fallback[\"prompts\"][\"zh\"][0].startswith(\"搜尋：航空7 A327\")\\n+\\n+    workflow = (ROOT / \".github\" / \"workflows\" / \"hourly.yml\").read_text(\\n+        encoding=\"utf-8\")\\n+    assert 'cron: \"1 * * * *\"' in workflow\\n+    assert \"python pipeline/search_prompts.py --daily-if-due\" in workflow\\n+    print(\"test_search_prompts: OK\")\\n+\\n+\\n+if __name__ == \"__main__\":\\n+    main()"
}
