feat: 更新推斷模組以使用 search token 進行格式推斷,並新增格式搜尋字詞詞庫

This commit is contained in:
Your Name 2026-01-26 14:14:38 +08:00
parent 6d948b0873
commit fcc9e36220
7 changed files with 515 additions and 48 deletions

View file

@ -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:

View file

@ -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();
}
}

View file

@ -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) {

View file

@ -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<string, number>;
private minConfidenceThreshold: number;
private coldStartThreshold: number;
constructor(
weights: Partial<ModelWeights> = {},
globalPopularity: Record<string, number> = 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,

View file

@ -2,6 +2,7 @@
*
*/
export * from "./tokenLexicon";
export * from "./featureExtraction";
export * from "./formatCandidateRules";
export * from "./formatPredictionModel";

View file

@ -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,

View file

@ -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<string, FormatToken>();
const ALIAS_INDEX = new Map<string, FormatToken>();
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;
}