2026-10-20 TUE UTC 12:42 PM TPE 8:42 PM 每小時自動更新 中文 EN
LIVE 快訊
← 返回網站更新紀錄 Site Changelog

提交原始紀錄

fix article-backed search suggestions

SHA
f7a65783ea40e21766b27b0af8a43bd99a356b16
提交時間
作者
nnneeeooo-nnneeeooo
提交者
nnneeeooo-nnneeeooo

變更檔案 (5)

  • pipeline/CONTRACTS.md modified · +6 / −0
  • pipeline/build.py modified · +50 / −16
  • static/app.js modified · +1 / −0
  • templates/base.html modified · +1 / −0
  • tests/test_search.py modified · +41 / −5

完整差異

@@ -345,13 +345,19 @@ orphaned ticker entries are omitted as well; source JSON remains preserved.\n structured fields and stores body/source text plus matched aliases in its\n search field. The search page loads the index only when a query is made, so\n visiting the page without searching does not download the full index.\n+Daily search-box suggestions are also indexed as exact aliases of their linked\n+article, so every displayed suggestion resolves without weakening ordinary\n+full-text matching.\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+The three ordered lists (`sourceArticleIds`, `prompts.zh`, and `prompts.en`) are\n+one-to-one; malformed mappings fall back to the built-in search hints.\n+Built-in hints are never submitted as suggested queries.\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@@ -1266,22 +1266,47 @@ def collect_articles():\n     return sorted(by_id.values(), key=lambda a: a["dt"], reverse=True)\n \n \n+@lru_cache(maxsize=1)\n+def daily_search_prompt_rows() -> tuple[tuple[str, str, str], ...]:\n+    """Return validated (article ID, zh, en) daily search suggestions."""\n+    payload = load_json(DATA_DIR / "search-prompts.json", {})\n+    prompts = payload.get("prompts") if isinstance(payload, dict) else None\n+    source_ids = (payload.get("sourceArticleIds")\n+                  if isinstance(payload, dict) else None)\n+    zh_prompts = prompts.get("zh") if isinstance(prompts, dict) else None\n+    en_prompts = prompts.get("en") if isinstance(prompts, dict) else None\n+    if not all(isinstance(values, list)\n+               for values in (source_ids, zh_prompts, en_prompts)):\n+        return ()\n+    if not 2 <= len(source_ids) <= 8 \\n+            or not len(source_ids) == len(zh_prompts) == len(en_prompts):\n+        return ()\n+\n+    rows = []\n+    for article_id, zh_value, en_value in zip(source_ids, zh_prompts, en_prompts):\n+        article_id = re.sub(r"\s+", " ", str(article_id or "")).strip()\n+        zh = re.sub(r"\s+", " ", str(zh_value or "")).strip()\n+        en = re.sub(r"\s+", " ", str(en_value or "")).strip()\n+        if not article_id or any(\n+                not text or len(text) > 96 or re.search(r"[\x00-\x1f<>]", text)\n+                for text in (zh, en)):\n+            return ()\n+        rows.append((article_id, zh, en))\n+    if len({row[0] for row in rows}) != len(rows) \\n+            or len({row[1] for row in rows}) != len(rows) \\n+            or len({row[2] for row in rows}) != len(rows):\n+        return ()\n+    return tuple(rows)\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+    """Load article-backed daily prompts, with non-actionable hints as fallback."""\n+    rows = daily_search_prompt_rows()\n+    if rows:\n+        prompt_index = 1 if lang == "zh" else 2\n+        return tuple(row[prompt_index] for row in rows)\n+    return tuple(L[lang]["headerSearchPlaceholders"])\n \n \n def _keyword_summary(value: str, lang: str, limit: int) -> str:\n@@ -1692,7 +1717,7 @@ def expand_search_aliases(text: str, groups=None) -> list[str]:\n     return matched\n \n \n-def search_index_item(article, alias_groups) -> dict:\n+def search_index_item(article, alias_groups, prompt_aliases=()) -> dict:\n     """Build one bilingual, full-text record for the static client index.\n \n     Titles, summaries and display metadata are already stored as structured\n@@ -1716,6 +1741,7 @@ def search_index_item(article, alias_groups) -> dict:\n         searchable.extend([source["name"], source["host"]])\n     for lang in ("zh", "en"):\n         searchable.extend(article[lang]["body"])\n+    searchable.extend(str(value) for value in prompt_aliases if value)\n     raw_search = " ".join(str(value) for value in raw_searchable if value)\n     searchable.extend(expand_search_aliases(raw_search, alias_groups))\n     urls = {\n@@ -1745,11 +1771,18 @@ def search_index_item(article, alias_groups) -> dict:\n \n def search_index_payload(articles, generated_utc) -> dict:\n     alias_groups = load_search_alias_groups()\n+    article_ids = {article["id"] for article in articles}\n+    prompt_aliases = {}\n+    prompt_rows = daily_search_prompt_rows()\n+    if prompt_rows and all(row[0] in article_ids for row in prompt_rows):\n+        for article_id, zh_prompt, en_prompt in prompt_rows:\n+            prompt_aliases[article_id] = (zh_prompt, en_prompt)\n     return {\n         "version": 1,\n         "generatedUtc": generated_utc.isoformat(),\n         "count": len(articles),\n-        "items": [search_index_item(article, alias_groups)\n+        "items": [search_index_item(\n+            article, alias_groups, prompt_aliases.get(article["id"], ()))\n                   for article in articles],\n     }\n \n@@ -3087,6 +3120,7 @@ def base_ctx(lang, page, sub, *, title, description, ticker, build,\n     prompts = list(header_search_placeholders(lang))\n     t["headerSearchPlaceholders"] = prompts\n     t["headerSearchPlaceholder"] = prompts[0]\n+    t["headerSearchSuggestionEnabled"] = bool(daily_search_prompt_rows())\n     nav_defs = [("home", ""), ("briefings", "briefings/"),\n                 ("radar", "radar/"),\n                 ("incidents", "incidents/"),\n@@ -67,6 +67,7 @@\n   if (headerSearchForm && headerSearchInput) {\n     headerSearchForm.addEventListener("submit", function (event) {\n       if (headerSearchInput.value.trim()) return;\n+      if (headerSearchInput.dataset.searchSuggestionEnabled !== "true") return;\n       var suggestedQuery = queryFromSearchPlaceholder(\n         headerSearchInput.placeholder\n       );\n@@ -62,6 +62,7 @@\n       <input id="news-search-input" class="header-search-input" name="q"\n              type="search" autocomplete="off" enterkeyhint="search"\n              placeholder="{{ t.headerSearchPlaceholder }}"\n+             data-search-suggestion-enabled="{{ 'true' if t.headerSearchSuggestionEnabled else 'false' }}"\n              data-search-placeholders='{{ t.headerSearchPlaceholders | tojson }}'>\n       <button id="news-search-clear" class="header-search-clear" type="button"\n               aria-label="{{ t.searchClear }}" title="{{ t.searchClear }}">×</button>\n@@ -4,6 +4,7 @@\n import json\n import re\n import sys\n+import tempfile\n import unicodedata\n from pathlib import Path\n \n@@ -40,8 +41,9 @@ def normalize_search_text(value: str) -> str:\n \n def suggestion_has_result(prompt: str, items: list[dict]) -> bool:\n     terms = normalize_search_text(prompt).split()\n-    minimum_matches = len(terms) if len(terms) < 3 else len(terms) - 1\n-    for item in items:\n+    minimum_matches = len(terms)\n+\n+    def matches(item: dict, required: int) -> bool:\n         values = [\n             item.get("id", ""), item.get("source", ""),\n             item.get("date", ""), item.get("published", ""),\n@@ -54,9 +56,12 @@ def suggestion_has_result(prompt: str, items: list[dict]) -> bool:\n             else:\n                 values.append(localized)\n         haystack = normalize_search_text(" ".join(values))\n-        if sum(term in haystack for term in terms) >= minimum_matches:\n-            return True\n-    return False\n+        return sum(term in haystack for term in terms) >= required\n+\n+    if any(matches(item, minimum_matches) for item in items):\n+        return True\n+    return len(terms) >= 3 and any(\n+        matches(item, minimum_matches - 1) for item in items)\n \n \n def main() -> None:\n@@ -77,6 +82,13 @@ def main() -> None:\n     assert "中華航空" in short["search"]\n     assert "china airlines" in short["search"]\n \n+    suggested = build.search_index_item(\n+        sample_article("維珍澳洲航空班機爆乘客滋擾"), aliases,\n+        ("維珍澳洲航班乘客事件", "Virgin Australia passenger incident"),\n+    )\n+    assert "維珍澳洲航班乘客事件" in suggested["search"]\n+    assert "virgin australia passenger incident" in suggested["search"]\n+\n     # Short Latin aliases require token boundaries: ordinary words must not\n     # accidentally activate an airline group.\n     assert "中華航空" not in build.expand_search_aliases(\n@@ -112,6 +124,7 @@ def main() -> None:\n \n     assert 'id="news-search-input"' in zh\n     assert 'data-search-placeholders=' in zh\n+    assert 'data-search-suggestion-enabled="true"' in zh\n     zh_placeholders = json.loads(re.search(\n         r"data-search-placeholders='([^']+)'", zh).group(1))\n     en_placeholders = json.loads(re.search(\n@@ -121,6 +134,14 @@ def main() -> None:\n     assert len(zh_placeholders) == len(en_placeholders) == 6\n     assert all(suggestion_has_result(prompt, payload["items"])\n                for prompt in zh_placeholders + en_placeholders)\n+    prompt_rows = build.daily_search_prompt_rows()\n+    assert len(prompt_rows) == len(zh_placeholders) == len(en_placeholders)\n+    items_by_id = {row["id"]: row for row in payload["items"]}\n+    for article_id, zh_prompt, en_prompt in prompt_rows:\n+        assert article_id in items_by_id\n+        indexed = items_by_id[article_id]["search"]\n+        assert normalize_search_text(zh_prompt) in indexed\n+        assert normalize_search_text(en_prompt) in indexed\n     assert suggestion_has_result(\n         "亞馬遜空運 貨機 邁阿密 衝出跑道", payload["items"])\n     assert zh.count('id="news-search-form"') == 1\n@@ -176,6 +197,7 @@ def main() -> None:\n     assert "搜尋|查詢|尋找|看看|想看|探索|了解" in app_script\n     assert "Search|Explore|Find|Look\\s+up|Show\\s+me" in app_script\n     assert "if (headerSearchInput.value.trim()) return" in app_script\n+    assert 'dataset.searchSuggestionEnabled !== "true"' in app_script\n     assert "headerSearchInput.value = suggestedQuery" in app_script\n     assert 'document.getElementById("news-search-app")' in app_script\n     assert "event.preventDefault()" in app_script\n@@ -184,6 +206,20 @@ def main() -> None:\n     assert 'headerSearchInput.value = ""' in app_script\n     assert 'new Event("input", { bubbles: true })' in app_script\n \n+    original_data_dir = build.DATA_DIR\n+    try:\n+        with tempfile.TemporaryDirectory() as tmp:\n+            build.DATA_DIR = Path(tmp)\n+            build.daily_search_prompt_rows.cache_clear()\n+            build.header_search_placeholders.cache_clear()\n+            assert build.daily_search_prompt_rows() == ()\n+            assert build.header_search_placeholders("zh") == tuple(\n+                build.L["zh"]["headerSearchPlaceholders"])\n+    finally:\n+        build.DATA_DIR = original_data_dir\n+        build.daily_search_prompt_rows.cache_clear()\n+        build.header_search_placeholders.cache_clear()\n+\n     print("test_search: OK")\n \n 

你發現了隱藏主題!

8-bit 主題已解鎖。重新整理後,就能在主題選單選擇 8-bit。