From fcc9e362205667c782094cfafce27e4ee68621ca Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 26 Jan 2026 14:14:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E6=8E=A8=E6=96=B7?= =?UTF-8?q?=E6=A8=A1=E7=B5=84=E4=BB=A5=E4=BD=BF=E7=94=A8=20search=20token?= =?UTF-8?q?=20=E9=80=B2=E8=A1=8C=E6=A0=BC=E5=BC=8F=E6=8E=A8=E6=96=B7?= =?UTF-8?q?=EF=BC=8C=E4=B8=A6=E6=96=B0=E5=A2=9E=E6=A0=BC=E5=BC=8F=E6=90=9C?= =?UTF-8?q?=E5=B0=8B=E5=AD=97=E8=A9=9E=E8=A9=9E=E5=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docker-e2e-tests.yml | 11 + public/inference.js | 56 ++- public/script.js | 6 +- src/inference/formatPredictionModel.ts | 29 +- src/inference/index.ts | 1 + src/inference/inferenceService.ts | 2 +- src/inference/tokenLexicon.ts | 458 +++++++++++++++++++++++++ 7 files changed, 515 insertions(+), 48 deletions(-) create mode 100644 src/inference/tokenLexicon.ts diff --git a/.github/workflows/docker-e2e-tests.yml b/.github/workflows/docker-e2e-tests.yml index 0ed6f89..b842fa2 100644 --- a/.github/workflows/docker-e2e-tests.yml +++ b/.github/workflows/docker-e2e-tests.yml @@ -287,6 +287,17 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Free disk space (aggressive) + uses: jlumbroso/free-disk-space@main + with: + tool-cache: true + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: false + - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: diff --git a/public/inference.js b/public/inference.js index 7a3c949..fea621d 100644 --- a/public/inference.js +++ b/public/inference.js @@ -1,16 +1,19 @@ /** - * 自動格式推斷前端模組 + * 智慧搜尋代理 - 前端模組 * - * 在使用者上傳檔案後,自動推斷最可能的目標格式並填入搜尋欄 + * 在使用者上傳檔案後,自動推斷最可能的目標格式 + * 並模擬使用者在搜尋欄輸入 token (prefix matching) + * + * UI 行為完全等同真人輸入 */ // @ts-check /** * @typedef {Object} FormatPrediction - * @property {string} search_format - 預測的搜尋格式 + * @property {string} search_token - 預測的搜尋 token (用於 prefix matching) * @property {number} confidence - 預測信心度 (0-1) - * @property {Array<{format: string, score: number}>} top_k - Top-K 候選格式 + * @property {Array<{token: string, score: number}>} top_k - Top-K 候選 token * @property {string[]} reason_codes - 預測原因碼 */ @@ -39,7 +42,7 @@ const inferenceWebroot = inferenceWebrootMeta // 狀態追蹤 let inferenceEnabled = true; /** @type {string|null} */ -let lastInferredFormat = null; +let lastInferredToken = null; /** @type {string|null} */ let lastInferredEngine = null; let isInferredValue = false; @@ -119,10 +122,11 @@ async function cancelWarmup() { /** * 自動填入推斷的格式 - * @param {string} format - 推斷的格式 + * UI 行為完全等同使用者手動輸入 + * @param {string} token - 推斷的 search token * @param {string} [engine] - 推斷的引擎 */ -function autoFillInferredFormat(format, engine) { +function autoFillInferredFormat(token, engine) { /** @type {HTMLInputElement|null} */ const searchInput = document.querySelector("input[name='convert_to_search']"); const convertToPopup = document.querySelector(".convert_to_popup"); @@ -133,30 +137,20 @@ function autoFillInferredFormat(format, engine) { } // 儲存推斷值 - lastInferredFormat = format; + lastInferredToken = token; lastInferredEngine = engine || null; isInferredValue = true; - // 填入搜尋欄 - searchInput.value = format; + // 填入搜尋欄 - UI 行為完全等同使用者輸入 + searchInput.value = token; // 觸發 input 事件以過濾結果 const inputEvent = new Event("input", { bubbles: true }); searchInput.dispatchEvent(inputEvent); - // 添加視覺提示 (使用淡色邊框) - searchInput.style.borderColor = "#22c55e"; - searchInput.style.borderWidth = "2px"; + // 不修改任何 UI 樣式 - 純粹模擬使用者輸入 - // 3秒後恢復原樣 - setTimeout(() => { - if (isInferredValue && searchInput.value === format) { - searchInput.style.borderColor = ""; - searchInput.style.borderWidth = ""; - } - }, 3000); - - console.log(`🎯 Auto-filled format: ${format}${engine ? ` (engine: ${engine})` : ""}`); + console.log(`🎯 Auto-filled search token: ${token}${engine ? ` (engine: ${engine})` : ""}`); } /** @@ -164,19 +158,19 @@ function autoFillInferredFormat(format, engine) { * @param {string} inputExt - 輸入副檔名 */ function handleSearchClear(inputExt) { - if (isInferredValue && lastInferredFormat) { + if (isInferredValue && lastInferredToken) { // 記錄為負樣本 - logDismissEvent(inputExt, lastInferredFormat, lastInferredEngine || undefined); + logDismissEvent(inputExt, lastInferredToken, lastInferredEngine || undefined); // 取消預調用 cancelWarmup(); - console.log(`❌ User dismissed inference: ${lastInferredFormat}`); + console.log(`❌ User dismissed inference: ${lastInferredToken}`); } // 重置狀態 isInferredValue = false; - lastInferredFormat = null; + lastInferredToken = null; lastInferredEngine = null; } @@ -188,14 +182,6 @@ function handleManualInput() { // 使用者手動修改,取消預調用 cancelWarmup(); isInferredValue = false; - - // 恢復邊框樣式 - /** @type {HTMLInputElement|null} */ - const searchInput = document.querySelector("input[name='convert_to_search']"); - if (searchInput) { - searchInput.style.borderColor = ""; - searchInput.style.borderWidth = ""; - } } } @@ -221,7 +207,7 @@ function initInferenceModule() { // 如果是程式設定的值,不處理 if (e.isTrusted && isInferredValue) { const currentValue = searchInput.value; - if (currentValue !== lastInferredFormat) { + if (currentValue !== lastInferredToken) { handleManualInput(); } } diff --git a/public/script.js b/public/script.js index a24b0d4..a79a281 100644 --- a/public/script.js +++ b/public/script.js @@ -439,15 +439,15 @@ async function triggerFormatInference(ext, fileSize) { const result = await window.inferenceModule.requestFormatInference(ext, fileSizeKb); if (result && result.should_auto_fill && result.format) { - // 自動填入推斷的格式 + // 自動填入推斷的 search token (模擬使用者輸入) window.inferenceModule.autoFillInferredFormat( - result.format.search_format, + result.format.search_token, result.engine?.engine, ); // 嘗試自動選擇對應的引擎選項 if (result.engine) { - autoSelectEngine(result.format.search_format, result.engine.engine); + autoSelectEngine(result.format.search_token, result.engine.engine); } } } catch (error) { diff --git a/src/inference/formatPredictionModel.ts b/src/inference/formatPredictionModel.ts index 2444d52..a1d85f2 100644 --- a/src/inference/formatPredictionModel.ts +++ b/src/inference/formatPredictionModel.ts @@ -2,10 +2,11 @@ * 格式預測模型 * * 基於使用者行為歷史和檔案特徵預測最可能的目標格式 - * 使用輕量級的邏輯回歸模型 + 規則融合 + * 輸出 search_token 用於 prefix matching 搜尋 */ import { generateCandidates } from "./formatCandidateRules"; +import { normalizeToken } from "./tokenLexicon"; import type { FileFeatures } from "./featureExtraction"; import type { UserProfile, FormatConversionStats } from "./behaviorStore"; @@ -13,12 +14,12 @@ import type { UserProfile, FormatConversionStats } from "./behaviorStore"; * 格式預測結果 */ export interface FormatPrediction { - /** 預測的搜尋格式 */ - search_format: string; + /** 預測的搜尋 token (用於 prefix matching) */ + search_token: string; /** 預測信心度 (0-1) */ confidence: number; - /** Top-K 候選格式 */ - top_k: Array<{ format: string; score: number }>; + /** Top-K 候選 token */ + top_k: Array<{ token: string; score: number }>; /** 預測原因碼 */ reason_codes: string[]; } @@ -76,15 +77,18 @@ export class FormatPredictionModel { private weights: ModelWeights; private globalPopularity: Record; private minConfidenceThreshold: number; + private coldStartThreshold: number; constructor( weights: Partial = {}, globalPopularity: Record = DEFAULT_GLOBAL_POPULARITY, - minConfidenceThreshold = 0.4, + minConfidenceThreshold = 0.35, + coldStartThreshold = 0.15, ) { this.weights = { ...DEFAULT_WEIGHTS, ...weights }; this.globalPopularity = globalPopularity; this.minConfidenceThreshold = minConfidenceThreshold; + this.coldStartThreshold = coldStartThreshold; } /** @@ -171,7 +175,7 @@ export class FormatPredictionModel { // 排序並取 Top-K scoredFormats.sort((a, b) => b.score - a.score); const topK = scoredFormats.slice(0, 5).map((s) => ({ - format: s.format, + token: normalizeToken(s.format), score: Math.round(s.score * 100) / 100, })); @@ -184,13 +188,20 @@ export class FormatPredictionModel { // 計算信心度 (使用 softmax 正規化後的相對優勢) const confidence = this.calculateConfidence(scoredFormats); + // 根據是否有使用者歷史選擇不同的閾值 + // 冷啟動時使用較低閾值,讓系統可以開始學習 + const hasUserHistory = userProfile && Object.keys(userProfile.format_preferences).length > 0; + const effectiveThreshold = hasUserHistory + ? this.minConfidenceThreshold + : this.coldStartThreshold; + // 如果信心度低於閾值,不推薦 - if (confidence < this.minConfidenceThreshold) { + if (confidence < effectiveThreshold) { return null; } return { - search_format: topFormat.format, + search_token: normalizeToken(topFormat.format), confidence: Math.round(confidence * 100) / 100, top_k: topK, reason_codes: matched_rules, diff --git a/src/inference/index.ts b/src/inference/index.ts index 545d238..4adf657 100644 --- a/src/inference/index.ts +++ b/src/inference/index.ts @@ -2,6 +2,7 @@ * 推斷模組統一導出 */ +export * from "./tokenLexicon"; export * from "./featureExtraction"; export * from "./formatCandidateRules"; export * from "./formatPredictionModel"; diff --git a/src/inference/inferenceService.ts b/src/inference/inferenceService.ts index 4899c44..c3b0120 100644 --- a/src/inference/inferenceService.ts +++ b/src/inference/inferenceService.ts @@ -181,7 +181,7 @@ export class InferenceService { let enginePrediction: EnginePrediction | null = null; if (formatPrediction) { enginePrediction = this.engineModel.predict( - formatPrediction.search_format, + formatPrediction.search_token, features, userProfile, availableEngines, diff --git a/src/inference/tokenLexicon.ts b/src/inference/tokenLexicon.ts new file mode 100644 index 0000000..fd51b1e --- /dev/null +++ b/src/inference/tokenLexicon.ts @@ -0,0 +1,458 @@ +/** + * Token Lexicon - 格式搜尋字詞詞庫 + * + * 定義所有可搜尋的格式 token,用於: + * 1. 標準化格式名稱 + * 2. 支援別名搜尋 + * 3. 分類格式家族 + */ + +/** + * 格式 Token 定義 + */ +export interface FormatToken { + /** 主要 token (用於搜尋) */ + token: string; + /** 格式家族 */ + family: "image" | "video" | "audio" | "document" | "data" | "3d" | "archive" | "font" | "other"; + /** 別名列表 (也會觸發搜尋匹配) */ + aliases: string[]; + /** 顯示名稱 */ + display_name: string; + /** 是否為常用格式 */ + is_common: boolean; +} + +/** + * Format Lexicon - 所有可搜尋的格式 token + */ +export const FORMAT_LEXICON: FormatToken[] = [ + // ==================== 圖片格式 ==================== + { + token: "png", + family: "image", + aliases: ["portable network graphics"], + display_name: "PNG", + is_common: true, + }, + { + token: "jpeg", + family: "image", + aliases: ["jpg", "jpe", "jfif"], + display_name: "JPEG", + is_common: true, + }, + { + token: "webp", + family: "image", + aliases: [], + display_name: "WebP", + is_common: true, + }, + { + token: "gif", + family: "image", + aliases: ["graphics interchange format"], + display_name: "GIF", + is_common: true, + }, + { + token: "avif", + family: "image", + aliases: ["av1 image"], + display_name: "AVIF", + is_common: true, + }, + { + token: "svg", + family: "image", + aliases: ["svgz", "scalable vector graphics"], + display_name: "SVG", + is_common: true, + }, + { + token: "ico", + family: "image", + aliases: ["icon", "favicon"], + display_name: "ICO", + is_common: false, + }, + { + token: "bmp", + family: "image", + aliases: ["bitmap", "dib"], + display_name: "BMP", + is_common: false, + }, + { + token: "tiff", + family: "image", + aliases: ["tif"], + display_name: "TIFF", + is_common: false, + }, + { + token: "heif", + family: "image", + aliases: ["heic", "heics"], + display_name: "HEIF", + is_common: false, + }, + { + token: "jxl", + family: "image", + aliases: ["jpeg xl"], + display_name: "JPEG XL", + is_common: false, + }, + { + token: "psd", + family: "image", + aliases: ["photoshop"], + display_name: "PSD", + is_common: false, + }, + { + token: "eps", + family: "image", + aliases: ["encapsulated postscript"], + display_name: "EPS", + is_common: false, + }, + + // ==================== 影片格式 ==================== + { + token: "mp4", + family: "video", + aliases: ["m4v", "mpeg4"], + display_name: "MP4", + is_common: true, + }, + { + token: "webm", + family: "video", + aliases: [], + display_name: "WebM", + is_common: true, + }, + { + token: "mkv", + family: "video", + aliases: ["matroska"], + display_name: "MKV", + is_common: true, + }, + { + token: "avi", + family: "video", + aliases: [], + display_name: "AVI", + is_common: false, + }, + { + token: "mov", + family: "video", + aliases: ["quicktime"], + display_name: "MOV", + is_common: false, + }, + + // ==================== 音訊格式 ==================== + { + token: "mp3", + family: "audio", + aliases: ["mpeg audio layer 3"], + display_name: "MP3", + is_common: true, + }, + { + token: "wav", + family: "audio", + aliases: ["wave", "riff"], + display_name: "WAV", + is_common: true, + }, + { + token: "flac", + family: "audio", + aliases: ["free lossless audio codec"], + display_name: "FLAC", + is_common: true, + }, + { + token: "aac", + family: "audio", + aliases: ["m4a"], + display_name: "AAC", + is_common: false, + }, + { + token: "ogg", + family: "audio", + aliases: ["oga", "vorbis"], + display_name: "OGG", + is_common: false, + }, + { + token: "opus", + family: "audio", + aliases: [], + display_name: "Opus", + is_common: false, + }, + + // ==================== 文件格式 ==================== + { + token: "pdf", + family: "document", + aliases: ["portable document format"], + display_name: "PDF", + is_common: true, + }, + { + token: "docx", + family: "document", + aliases: ["doc", "word"], + display_name: "DOCX", + is_common: true, + }, + { + token: "txt", + family: "document", + aliases: ["text", "plain text"], + display_name: "TXT", + is_common: true, + }, + { + token: "md", + family: "document", + aliases: ["markdown"], + display_name: "Markdown", + is_common: true, + }, + { + token: "html", + family: "document", + aliases: ["htm", "webpage"], + display_name: "HTML", + is_common: false, + }, + { + token: "rtf", + family: "document", + aliases: ["rich text"], + display_name: "RTF", + is_common: false, + }, + { + token: "epub", + family: "document", + aliases: ["ebook"], + display_name: "EPUB", + is_common: false, + }, + { + token: "mobi", + family: "document", + aliases: ["kindle"], + display_name: "MOBI", + is_common: false, + }, + { + token: "xlsx", + family: "document", + aliases: ["xls", "excel", "spreadsheet"], + display_name: "XLSX", + is_common: true, + }, + { + token: "pptx", + family: "document", + aliases: ["ppt", "powerpoint", "presentation"], + display_name: "PPTX", + is_common: false, + }, + + // ==================== 資料格式 ==================== + { + token: "json", + family: "data", + aliases: ["javascript object notation"], + display_name: "JSON", + is_common: true, + }, + { + token: "yaml", + family: "data", + aliases: ["yml"], + display_name: "YAML", + is_common: true, + }, + { + token: "xml", + family: "data", + aliases: ["extensible markup language"], + display_name: "XML", + is_common: false, + }, + { + token: "csv", + family: "data", + aliases: ["comma separated values"], + display_name: "CSV", + is_common: true, + }, + { + token: "toml", + family: "data", + aliases: [], + display_name: "TOML", + is_common: false, + }, + + // ==================== 3D 格式 ==================== + { + token: "obj", + family: "3d", + aliases: ["wavefront"], + display_name: "OBJ", + is_common: true, + }, + { + token: "stl", + family: "3d", + aliases: ["stereolithography"], + display_name: "STL", + is_common: true, + }, + { + token: "gltf", + family: "3d", + aliases: ["glb", "gl transmission format"], + display_name: "glTF", + is_common: true, + }, + { + token: "fbx", + family: "3d", + aliases: ["filmbox"], + display_name: "FBX", + is_common: false, + }, + { + token: "dae", + family: "3d", + aliases: ["collada"], + display_name: "DAE", + is_common: false, + }, + { + token: "ply", + family: "3d", + aliases: ["polygon file format"], + display_name: "PLY", + is_common: false, + }, + + // ==================== 壓縮格式 ==================== + { + token: "zip", + family: "archive", + aliases: [], + display_name: "ZIP", + is_common: true, + }, + { + token: "tar", + family: "archive", + aliases: ["tarball"], + display_name: "TAR", + is_common: false, + }, + { + token: "7z", + family: "archive", + aliases: ["7zip"], + display_name: "7Z", + is_common: false, + }, + + // ==================== 字體格式 ==================== + { + token: "ttf", + family: "font", + aliases: ["truetype"], + display_name: "TTF", + is_common: true, + }, + { + token: "otf", + family: "font", + aliases: ["opentype"], + display_name: "OTF", + is_common: true, + }, + { + token: "woff", + family: "font", + aliases: ["web open font format"], + display_name: "WOFF", + is_common: false, + }, + { + token: "woff2", + family: "font", + aliases: [], + display_name: "WOFF2", + is_common: false, + }, +]; + +// 建立索引 +const TOKEN_INDEX = new Map(); +const ALIAS_INDEX = new Map(); + +for (const token of FORMAT_LEXICON) { + TOKEN_INDEX.set(token.token, token); + for (const alias of token.aliases) { + ALIAS_INDEX.set(alias.toLowerCase(), token); + } +} + +/** + * 根據 token 取得格式定義 + */ +export function getTokenByName(name: string): FormatToken | undefined { + const lower = name.toLowerCase(); + return TOKEN_INDEX.get(lower) ?? ALIAS_INDEX.get(lower); +} + +/** + * 取得指定家族的所有 token + */ +export function getTokensByFamily(family: FormatToken["family"]): FormatToken[] { + return FORMAT_LEXICON.filter((t) => t.family === family); +} + +/** + * 取得所有常用格式 + */ +export function getCommonTokens(): FormatToken[] { + return FORMAT_LEXICON.filter((t) => t.is_common); +} + +/** + * 檢查是否為有效的 token + */ +export function isValidToken(token: string): boolean { + return TOKEN_INDEX.has(token.toLowerCase()); +} + +/** + * 標準化 token (將別名轉換為主要 token) + */ +export function normalizeToken(input: string): string { + const lower = input.toLowerCase(); + const token = TOKEN_INDEX.get(lower) ?? ALIAS_INDEX.get(lower); + return token?.token ?? lower; +}