{
  "sha": "eaa85e006fbfb9bbe0992e0a0afdba6f6063c625",
  "message": "feat(search): find articles by public writing model (#313)",
  "author": {
    "login": "nnneeeooo-nnneeeooo",
    "name": "",
    "email": ""
  },
  "committer": {
    "login": "web-flow",
    "name": "",
    "email": ""
  },
  "created_at": "2026-10-18T14:38:35Z",
  "files": [
    {
      "filename": "docs/open-source-scouting.md",
      "previous_filename": "",
      "status": "modified",
      "additions": 13,
      "deletions": 1,
      "changes": 14
    },
    {
      "filename": "pipeline/build.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 6,
      "deletions": 2,
      "changes": 8
    },
    {
      "filename": "static/search.js",
      "previous_filename": "",
      "status": "modified",
      "additions": 26,
      "deletions": 1,
      "changes": 27
    },
    {
      "filename": "tests/test_search.py",
      "previous_filename": "",
      "status": "modified",
      "additions": 18,
      "deletions": 0,
      "changes": 18
    },
    {
      "filename": "tests/test_search_client.js",
      "previous_filename": "",
      "status": "modified",
      "additions": 21,
      "deletions": 0,
      "changes": 21
    }
  ],
  "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\", \"ＧＰＴ６\"]) {\\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 }"
}
