2026-10-20 TUE UTC 12:42 PM TPE 8:42 PM Auto-updates hourly 中文 EN
LIVE WIRE
← Back to the site changelog Site Changelog

Original commit record

feat(search): find articles by public writing model (#313)

SHA
eaa85e006fbfb9bbe0992e0a0afdba6f6063c625
Commit date
Author
nnneeeooo-nnneeeooo
Committer
web-flow

Changed files (5)

  • docs/open-source-scouting.md modified · +13 / −1
  • pipeline/build.py modified · +6 / −2
  • static/search.js modified · +26 / −1
  • tests/test_search.py modified · +18 / −0
  • tests/test_search_client.js modified · +21 / −0

Full diff

@@ -1,6 +1,18 @@\n # SKYTICAL Open-source Scouting\n \n-更新日期:2026-09-26\n+更新日期:2026-10-18\n+\n+## 撰稿模型搜尋評估(2026-10-18)\n+\n+透過 GitHub API 查證下列搜尋專案;本次只比較設計,未複製程式碼。\n+\n+| 專案 | 最近推送 | Stars | 語言/授權 | 可借鑑設計與導入成本 |\n+| --- | --- | --- | --- | --- |\n+| [MiniSearch](https://github.com/lucaong/minisearch) | 2025-09-16 | 6,173 | TypeScript/MIT | 欄位搜尋與結果過濾;需新增瀏覽器套件及索引流程。 |\n+| [FlexSearch](https://github.com/nextapps-de/flexsearch) | 2026-06-28 | 13,807 | JavaScript/Apache-2.0 | 文件欄位與標籤索引;設定與打包成本高於目前需求。 |\n+| [Fuse](https://github.com/krisk/Fuse) | 2026-08-09 | 20,506 | JavaScript/Apache-2.0 | 多欄位搜尋;模糊比對可能混淆精確模型版本。 |\n+\n+SKYTICAL 已有靜態 JSON 索引與零套件搜尋。本次保留現況,將公開模型署名加入獨立欄位,再以模型名稱篩選結果;支援連寫與連字號,模型條件不套用放寬比對。這樣可避免內部 provider ID 進入公開索引,也不需要新增依賴。MiniSearch 的欄位過濾可留待資料量成長後評估,目前不導入。\n \n 這份文件記錄在新增功能前值得研究的成熟 GitHub 專案。它不是依賴清單,也不代表應直接導入;每次實作仍需先和 SKYTICAL 現有架構、授權、維護成本與新聞核驗要求比較。\n \n@@ -189,7 +189,7 @@ def static_asset_version() -> str:\n             "隨意搜尋想看的 SKYTICAL 內容",\n             "今天想看什麼航空新聞?",\n         ],\n-        "searchSub": "搜尋標題、摘要、全文、來源、日期、航班、機型、機場代碼與航空公司 IATA/ICAO 代碼;航空公司正式名稱與常用簡稱可互相查找。",\n+        "searchSub": "搜尋標題、摘要、全文、來源、日期、航班、機型、機場代碼、航空公司 IATA/ICAO 代碼與撰稿模型(例如 GPT 6、GPT6、GPT 6.1 Sol);航空公司正式名稱與常用簡稱可互相查找。",\n         "searchButton": "搜尋", "searchClear": "清除",\n         "searchLoading": "正在載入新聞索引…",\n         "searchPrompt": "輸入關鍵字開始搜尋全部新聞。",\n@@ -324,7 +324,7 @@ def static_asset_version() -> str:\n             "Explore anything on SKYTICAL",\n             "What aviation news are you looking for?",\n         ],\n-        "searchSub": "Search titles, summaries, full text, sources, dates, flights, aircraft types, airport codes and airline IATA/ICAO codes. Official airline names and common short names are interchangeable.",\n+        "searchSub": "Search titles, summaries, full text, sources, dates, flights, aircraft types, airport codes, airline IATA/ICAO codes and writing models (e.g. GPT 6, GPT6, GPT 6.1 Sol). Official airline names and common short names are interchangeable.",\n         "searchButton": "Search", "searchClear": "Clear",\n         "searchLoading": "Loading the news index…",\n         "searchPrompt": "Enter keywords to search every published story.",\n@@ -1090,6 +1090,8 @@ def _public_writer_model_name(value):\n \n \n _WRITER_MODELS = (\n+    ("gpt-6.1-sol", "GPT 6.1 Sol"),\n+    ("gpt-6-sol", "GPT 6 Sol"),\n     ("gpt-6-luna", "GPT 6"),\n     ("gpt-5.6-sol", "GPT-5.6 Sol"),\n     ("nemotron-3-ultra", "Nemotron 3 Ultra"),\n@@ -1998,6 +2000,8 @@ def search_index_item(article, alias_groups, prompt_aliases=(),\n         "articleFormat": article["article_format"],\n         "availableLanguages": article["available_languages"],\n         "airlines": article_airline_keys(article, airline_catalog),\n+        "models": [label for label in (article.get("writer_model") or "").split("、")\n+                   if label],\n         "url": urls,\n         "search": normalize_search_text(" ".join(\n             str(value) for value in searchable if value)),\n@@ -77,6 +77,7 @@\n       localizedValues(record, "summary").join(" "),\n       localizedValues(record, "category").join(" "),\n       record.search,\n+      (record.models || []).join(" "),\n     ].join(" "));\n   }\n \n@@ -103,6 +104,9 @@\n   function matchingRecords(records, criteria, minimumMatches) {\n     return records.map(function (record) {\n       var airlines = Array.isArray(record.airlines) ? record.airlines : [];\n+      if (criteria.model && (record.models || []).indexOf(criteria.model) === -1) {\n+        return { record: record, score: -1 };\n+      }\n       if (criteria.airlineKey && airlines.indexOf(criteria.airlineKey) === -1) {\n         return { record: record, score: -1 };\n       }\n@@ -115,6 +119,23 @@\n     });\n   }\n \n+  function parseModelQuery(records, query) {\n+    // Only public credits enter this index. Match versions as a whole and\n+    // accept spaces, hyphens or compact spelling without exposing internal IDs.\n+    var labels = Array.from(new Set(records.flatMap(function (record) {\n+      return Array.isArray(record.models) ? record.models : [];\n+    }))).sort(function (a, b) { return b.length - a.length; });\n+    for (var label of labels) {\n+      var parts = label.normalize("NFKC").match(/[\p{L}\p{N}]+/gu) || [];\n+      var pattern = parts.map(function (part) {\n+        return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");\n+      }).join("[\\s.-]*");\n+      var match = new RegExp("^" + pattern + "(?![\\p{L}\\p{N}]|\\.\\d)", "iu").exec(query);\n+      if (match) return { model: label, remainder: query.slice(match[0].length).trim() };\n+    }\n+    return null;\n+  }\n+\n   function searchRecords(records, query, airlineCodes, lang) {\n     query = String(query || "").trim();\n     lang = lang === "en" ? "en" : "zh";\n@@ -124,16 +145,20 @@\n \n     var airlineQuery = parseAirlineCodeQuery(query, airlineCodes);\n     var textQuery = airlineQuery ? airlineQuery.remainder : query;\n+    var modelQuery = parseModelQuery(records, textQuery.normalize("NFKC"));\n+    if (modelQuery) textQuery = modelQuery.remainder;\n     var normalizedQuery = normalize(textQuery);\n     var terms = normalizedQuery.split(/\s+/).filter(Boolean);\n     var criteria = {\n       normalizedQuery: normalizedQuery,\n       terms: terms,\n       airlineKey: airlineQuery ? airlineQuery.key : "",\n+      model: modelQuery ? modelQuery.model : "",\n       lang: lang\n     };\n     var matches = matchingRecords(records, criteria, terms.length);\n-    if (!matches.length && terms.length >= 3) {\n+    if (!matches.length && terms.length >= 3 && !modelQuery &&\n+        !/^gpt[\s-]*\d/i.test(textQuery)) {\n       /* Daily suggestions may paraphrase one phrase from the source title.\n          Keep normal searches strict, then allow one unmatched term only when\n          the strict pass produced nothing. */\n@@ -66,7 +66,25 @@ def matches(item: dict, required: int) -> bool:\n         matches(item, minimum_matches - 1) for item in items)\n \n \n+def test_public_model_search_index():\n+    article = sample_article("華航新增航班")\n+    aliases = build.load_search_alias_groups()\n+    for writer, supplied, expected in [\n+        ("openai:gpt-6-luna", None, ["GPT 6"]),\n+        ("manual:GPT 6.1 Sol", None, ["GPT 6.1 Sol"]),\n+        ("openai:gpt-6.1-sol", None, ["GPT 6.1 Sol"]),\n+        ("manual:owner", ["GPT 6 Luna", "GPT 6.1 Sol"], ["GPT 6", "GPT 6.1 Sol"]),\n+        (None, None, []),\n+    ]:\n+        article["writer_model"] = build.writer_model(writer, supplied)\n+        item = build.search_index_item(article, aliases)\n+        assert item["models"] == expected\n+        assert "luna" not in json.dumps(item, ensure_ascii=False).lower()\n+        assert "openai:" not in json.dumps(item, ensure_ascii=False).lower()\n+\n+\n def main() -> None:\n+    test_public_model_search_index()\n     aliases = build.load_search_alias_groups()\n     assert len(aliases) >= 25\n     catalog = build.load_airline_search_catalog()\n@@ -61,6 +61,27 @@ const articles = [\n   { id: "unrelated", title: { en: "Airport terminal" }, search: "航廈" }\n ];\n const originalArticles = JSON.stringify(articles);\n+const modelArticles = [\n+  { id: "six", models: ["GPT 6"], search: "華航", airlines: ["CAL"] },\n+  { id: "sol", models: ["GPT 6.1 Sol"], search: "華航" },\n+  { id: "mixed", models: ["GPT 6.1 Sol", "DeepSeek V4 Flash"] },\n+  { id: "other", models: ["DeepSeek V4 Flash"], search: "GPT 6.1 Sol 華航" },\n+  { id: "uncredited", search: "GPT 6.1 Sol 華航" }\n+];\n+for (const lang of ["zh", "en"]) {\n+  for (const query of ["GPT 6", "GPT6", "gpt-6", "GPT6"]) {\n+    assert.deepStrictEqual(ids(find(query, lang, modelArticles)), ["six"]);\n+  }\n+  for (const query of ["GPT 6.1 Sol", "GPT6.1Sol", "gpt-6.1-sol"]) {\n+    assert.deepStrictEqual(ids(find(query, lang, modelArticles)), ["sol", "mixed"]);\n+  }\n+  assert.deepStrictEqual(ids(find("GPT6 華航", lang, modelArticles)), ["six"]);\n+  assert.deepStrictEqual(ids(find("CI GPT6", lang, modelArticles)), ["six"]);\n+  assert.deepStrictEqual(ids(find("GPT 6.1 Sol missing", lang, modelArticles)), []);\n+  assert.deepStrictEqual(ids(find("GPT 6.2 Sol", lang, modelArticles)), []);\n+  assert.deepStrictEqual(ids(find("GPT6 Luna", lang, modelArticles)), []);\n+  assert.deepStrictEqual(ids(find("DeepSeekV4Flash", lang, modelArticles)), ["mixed", "other"]);\n+}\n function find(query, lang = "en", items = articles) {\n   return search.searchRecords(items, query, codes, lang);\n }

You found a secret theme!

8-bit mode is unlocked. Refresh to add it to your theme selector.