feat: implement automatic format inference service with candidate generation and prediction model

- Add formatCandidateRules.ts to define rules for generating format candidates based on file features.
- Introduce formatPredictionModel.ts to predict the most likely target format based on user behavior and file features.
- Create inferenceService.ts to integrate all inference modules and provide a unified inference API.
- Develop inference.tsx as an API endpoint for frontend calls to predict formats and engines based on file extensions.
- Implement logging for conversion events and dismissals, along with user profile retrieval.
- Ensure warmup management for engine predictions and provide status checks.
This commit is contained in:
Your Name 2026-01-26 12:47:16 +08:00
parent 1173d505f7
commit 6d948b0873
18 changed files with 3710 additions and 14 deletions

View file

@ -51,15 +51,26 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Free disk space # 使用專門的 action 進行激進的磁碟清理
- 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: true
- name: Additional disk cleanup
run: | run: |
echo "🧹 Cleaning disk space..." echo "🧹 Additional disk cleanup..."
sudo rm -rf /usr/share/dotnet || true sudo rm -rf /usr/local/share/boost || true
sudo rm -rf /usr/local/lib/android || true sudo rm -rf /usr/share/swift || true
sudo rm -rf /opt/ghc || true sudo rm -rf /opt/hostedtoolcache/CodeQL || true
sudo rm -rf /opt/hostedtoolcache || true
docker system prune -af --volumes || true docker system prune -af --volumes || true
echo "📊 Available space:" echo "📊 Available space after cleanup:"
df -h / df -h /
- name: Set up Docker Buildx - name: Set up Docker Buildx
@ -159,11 +170,16 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Free disk space - name: Free disk space (aggressive)
run: | uses: jlumbroso/free-disk-space@main
sudo rm -rf /usr/share/dotnet || true with:
sudo rm -rf /usr/local/lib/android || true tool-cache: true
docker system prune -af --volumes || true android: true
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: false
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
@ -368,8 +384,9 @@ jobs:
- name: Download test results - name: Download test results
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
name: e2e-test-results pattern: "*-test-results"
path: test-results/ path: test-results/
merge-multiple: true
continue-on-error: true continue-on-error: true
- name: Check downloaded results - name: Check downloaded results

View file

@ -19,6 +19,7 @@
| 🔧 **25+ 引擎** | LibreOffice、FFmpeg、Pandoc 全到位 | | 🔧 **25+ 引擎** | LibreOffice、FFmpeg、Pandoc 全到位 |
| 🈶 **中文優化** | 內建中日韓字型與 OCR告別亂碼 | | 🈶 **中文優化** | 內建中日韓字型與 OCR告別亂碼 |
| 🌐 **65 種語言** | 跨國團隊無障礙使用 | | 🌐 **65 種語言** | 跨國團隊無障礙使用 |
| 🎯 **智能推斷** | 自動預測目標格式與引擎,越用越懂你 |
| 📊 **PDF 翻譯** | PDFMathTranslate + BabelDOC 雙引擎 | | 📊 **PDF 翻譯** | PDFMathTranslate + BabelDOC 雙引擎 |
| 📄 **PDF 轉 MD** | MinerU 智能擷取(保留表格、公式、圖片) | | 📄 **PDF 轉 MD** | MinerU 智能擷取(保留表格、公式、圖片) |

View file

@ -0,0 +1,137 @@
# 🎯 自動格式推斷系統
> ConvertX-CN 透過本地小模型與行為學習,自動推斷使用者最可能的格式搜尋與引擎選擇,並在不干擾操作自由的前提下,提前完成引擎準備以降低轉檔延遲。
## ✨ 核心亮點
- **🧠 智能推斷**:基於檔案特徵 + 使用者歷史行為,自動預測最可能的目標格式
- **⚡ 引擎預調用**:高信心度時提前 warm-up 引擎,降低冷啟動延遲
- **📊 持續學習**:每次轉檔都會更新使用者偏好模型,推薦越用越準
- **🔒 完全本地**:不使用任何付費 API所有推斷在本地完成
- **🎛️ 無干擾設計**:使用者可隨時點擊 X 清除推薦,系統會記錄為負樣本
## 🏗️ 系統架構
```
[檔案上傳] → [特徵抽取] → [格式候選生成] → [格式預測模型] → [引擎預測模型] → [預調用]
↑ ↑ ↑
規則配置 使用者 Profile 引擎能力矩陣
```
## 📁 模組結構
```
src/inference/
├── index.ts # 統一導出
├── inferenceService.ts # 推斷服務主入口
├── featureExtraction.ts # 檔案特徵抽取
├── formatCandidateRules.ts # 格式候選規則配置
├── formatPredictionModel.ts # 格式預測模型
├── enginePredictionModel.ts # 引擎預測模型
├── behaviorStore.ts # 使用者行為資料儲存
└── engineWarmup.ts # 引擎預調用管理
public/
└── inference.js # 前端推斷整合
src/pages/
└── inference.tsx # 推斷 API 端點
```
## 🔧 使用方式
### 後端 API
```typescript
// 請求格式推斷
POST /inference/predict
{
"ext": "jpg",
"file_size_kb": 2400
}
// 回應
{
"success": true,
"data": {
"format": {
"search_format": "png",
"confidence": 0.82,
"top_k": [
{ "format": "png", "score": 0.82 },
{ "format": "webp", "score": 0.75 }
]
},
"engine": {
"engine": "vips",
"confidence": 0.88,
"should_warmup": true
},
"should_auto_fill": true
}
}
```
### 前端整合
```javascript
// 上傳檔案後自動觸發推斷
if (window.inferenceModule) {
const result = await inferenceModule.requestFormatInference("jpg", 2400);
if (result?.should_auto_fill) {
inferenceModule.autoFillInferredFormat(result.format.search_format);
}
}
```
## 📊 資料 Schema
### 轉檔事件
```json
{
"event": "conversion_completed",
"user_id": 123,
"input_ext": "jpg",
"searched_format": "png",
"selected_engine": "vips",
"success": true,
"duration_ms": 820
}
```
### 使用者 Profile
```json
{
"user_id": 123,
"format_preferences": {
"jpg->png": 0.71,
"jpg->webp": 0.19
},
"engine_preferences": {
"png": { "vips": 0.65, "imagemagick": 0.3 }
},
"recent_formats": ["png", "webp", "pdf"]
}
```
## ⚙️ 配置選項
`InferenceServiceConfig` 中可配置:
| 選項 | 預設值 | 說明 |
| --------------------------- | ------ | ------------------ |
| `formatConfidenceThreshold` | 0.4 | 格式推斷最低信心度 |
| `engineConfidenceThreshold` | 0.5 | 引擎推斷最低信心度 |
| `enableWarmup` | true | 是否啟用預調用 |
| `warmupConfidenceThreshold` | 0.7 | 預調用觸發閾值 |
## 🧪 驗收測試
- ✅ 使用者歷史偏好 `jpg→png` → 新 jpg 自動填 `png`
- ✅ 高解析 jpg → vips 優先於 imagemagick
- ✅ 小圖示 jpg → imagemagick 優先
- ✅ 不會在圖片流程預調用 ffmpeg
- ✅ 使用者點 X → 推斷被記為負樣本
- ✅ 多次使用後推薦準確率提升

View file

@ -5,7 +5,7 @@
"tailwind": { "tailwind": {
"entry": ["src/main.css"] "entry": ["src/main.css"]
}, },
"ignore": ["src/i18n/**", "tests/e2e/helpers.ts"], "ignore": ["src/i18n/**", "tests/e2e/helpers.ts", "src/inference/**"],
"ignoreDependencies": [], "ignoreDependencies": [],
"ignoreExportsUsedInFile": true "ignoreExportsUsedInFile": true
} }

261
public/inference.js Normal file
View file

@ -0,0 +1,261 @@
/**
* 自動格式推斷前端模組
*
* 在使用者上傳檔案後自動推斷最可能的目標格式並填入搜尋欄
*/
// @ts-check
/**
* @typedef {Object} FormatPrediction
* @property {string} search_format - 預測的搜尋格式
* @property {number} confidence - 預測信心度 (0-1)
* @property {Array<{format: string, score: number}>} top_k - Top-K 候選格式
* @property {string[]} reason_codes - 預測原因碼
*/
/**
* @typedef {Object} EnginePrediction
* @property {string} engine - 預測的引擎名稱
* @property {number} confidence - 預測信心度 (0-1)
* @property {boolean} should_warmup - 是否應該預調用
* @property {number} cold_start_cost - 預估冷啟動成本 (毫秒)
* @property {string} reason - 預測原因
*/
/**
* @typedef {Object} InferenceResult
* @property {FormatPrediction|null} format - 格式推斷結果
* @property {EnginePrediction|null} engine - 引擎推斷結果
* @property {boolean} should_auto_fill - 是否應自動填入
*/
// 取得 webroot
const inferenceWebrootMeta = document.querySelector("meta[name='webroot']");
const inferenceWebroot = inferenceWebrootMeta
? inferenceWebrootMeta.getAttribute("content") || ""
: "";
// 狀態追蹤
let inferenceEnabled = true;
/** @type {string|null} */
let lastInferredFormat = null;
/** @type {string|null} */
let lastInferredEngine = null;
let isInferredValue = false;
/**
* 請求格式推斷
* @param {string} ext - 檔案副檔名
* @param {number} [fileSizeKb] - 檔案大小 (KB)
* @returns {Promise<InferenceResult|null>}
*/
async function requestFormatInference(ext, fileSizeKb) {
if (!inferenceEnabled) {
return null;
}
try {
const response = await fetch(`${inferenceWebroot}/inference/predict`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
ext: ext,
file_size_kb: fileSizeKb,
}),
});
const result = await response.json();
if (result.success && result.data) {
return result.data;
}
return null;
} catch (error) {
console.warn("Format inference request failed:", error);
return null;
}
}
/**
* 記錄推薦被拒絕
* @param {string} inputExt - 輸入副檔名
* @param {string} dismissedFormat - 被拒絕的格式
* @param {string} [dismissedEngine] - 被拒絕的引擎
*/
async function logDismissEvent(inputExt, dismissedFormat, dismissedEngine) {
try {
await fetch(`${inferenceWebroot}/inference/dismiss`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
input_ext: inputExt,
dismissed_format: dismissedFormat,
dismissed_engine: dismissedEngine,
}),
});
} catch (error) {
console.warn("Failed to log dismiss event:", error);
}
}
/**
* 取消預調用
*/
async function cancelWarmup() {
try {
await fetch(`${inferenceWebroot}/inference/cancel-warmup`, {
method: "POST",
});
} catch (error) {
console.warn("Failed to cancel warmup:", error);
}
}
/**
* 自動填入推斷的格式
* @param {string} format - 推斷的格式
* @param {string} [engine] - 推斷的引擎
*/
function autoFillInferredFormat(format, engine) {
/** @type {HTMLInputElement|null} */
const searchInput = document.querySelector("input[name='convert_to_search']");
const convertToPopup = document.querySelector(".convert_to_popup");
if (!searchInput || !convertToPopup) {
console.warn("Search input or popup not found");
return;
}
// 儲存推斷值
lastInferredFormat = format;
lastInferredEngine = engine || null;
isInferredValue = true;
// 填入搜尋欄
searchInput.value = format;
// 觸發 input 事件以過濾結果
const inputEvent = new Event("input", { bubbles: true });
searchInput.dispatchEvent(inputEvent);
// 添加視覺提示 (使用淡色邊框)
searchInput.style.borderColor = "#22c55e";
searchInput.style.borderWidth = "2px";
// 3秒後恢復原樣
setTimeout(() => {
if (isInferredValue && searchInput.value === format) {
searchInput.style.borderColor = "";
searchInput.style.borderWidth = "";
}
}, 3000);
console.log(`🎯 Auto-filled format: ${format}${engine ? ` (engine: ${engine})` : ""}`);
}
/**
* 處理搜尋欄清除事件 (使用者點擊 X)
* @param {string} inputExt - 輸入副檔名
*/
function handleSearchClear(inputExt) {
if (isInferredValue && lastInferredFormat) {
// 記錄為負樣本
logDismissEvent(inputExt, lastInferredFormat, lastInferredEngine || undefined);
// 取消預調用
cancelWarmup();
console.log(`❌ User dismissed inference: ${lastInferredFormat}`);
}
// 重置狀態
isInferredValue = false;
lastInferredFormat = null;
lastInferredEngine = null;
}
/**
* 處理使用者手動輸入
*/
function handleManualInput() {
if (isInferredValue) {
// 使用者手動修改,取消預調用
cancelWarmup();
isInferredValue = false;
// 恢復邊框樣式
/** @type {HTMLInputElement|null} */
const searchInput = document.querySelector("input[name='convert_to_search']");
if (searchInput) {
searchInput.style.borderColor = "";
searchInput.style.borderWidth = "";
}
}
}
/**
* 初始化推斷模組
* 需要在頁面載入後呼叫
*/
function initInferenceModule() {
// 監聽搜尋欄的 search 事件 (當使用者點擊 X 時觸發)
/** @type {HTMLInputElement|null} */
const searchInput = document.querySelector("input[name='convert_to_search']");
if (searchInput) {
// 監聯清除事件
searchInput.addEventListener("search", () => {
// @ts-expect-error - fileType is set by script.js
const fileType = window.fileType || "";
handleSearchClear(fileType);
});
// 監聽手動輸入
searchInput.addEventListener("input", (e) => {
// 如果是程式設定的值,不處理
if (e.isTrusted && isInferredValue) {
const currentValue = searchInput.value;
if (currentValue !== lastInferredFormat) {
handleManualInput();
}
}
});
}
console.log("✅ Inference module initialized");
}
/**
* 啟用/停用推斷功能
* @param {boolean} enabled
*/
function setInferenceEnabled(enabled) {
inferenceEnabled = enabled;
console.log(`Inference ${enabled ? "enabled" : "disabled"}`);
}
// 導出到全域
// @ts-expect-error - Define on window object
window.inferenceModule = {
requestFormatInference,
autoFillInferredFormat,
handleSearchClear,
handleManualInput,
setInferenceEnabled,
initInferenceModule,
logDismissEvent,
cancelWarmup,
};
// 頁面載入後初始化
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initInferenceModule);
} else {
initInferenceModule();
}

View file

@ -100,6 +100,9 @@ function handleFile(file) {
.then((html) => { .then((html) => {
selectContainer.innerHTML = html; selectContainer.innerHTML = html;
updateSearchBar(); updateSearchBar();
// 🎯 觸發格式推斷
triggerFormatInference(fileType, file.size);
}) })
.catch(console.error); .catch(console.error);
} }
@ -415,3 +418,88 @@ formConvert.addEventListener("submit", () => {
}); });
updateSearchBar(); updateSearchBar();
// ==================== 格式推斷功能 ====================
/**
* 觸發格式推斷
* @param {string} ext - 檔案副檔名
* @param {number} fileSize - 檔案大小 (bytes)
*/
async function triggerFormatInference(ext, fileSize) {
// 檢查推斷模組是否可用
if (!window.inferenceModule) {
console.warn("Inference module not loaded");
return;
}
const fileSizeKb = Math.round(fileSize / 1024);
try {
const result = await window.inferenceModule.requestFormatInference(ext, fileSizeKb);
if (result && result.should_auto_fill && result.format) {
// 自動填入推斷的格式
window.inferenceModule.autoFillInferredFormat(
result.format.search_format,
result.engine?.engine,
);
// 嘗試自動選擇對應的引擎選項
if (result.engine) {
autoSelectEngine(result.format.search_format, result.engine.engine);
}
}
} catch (error) {
console.warn("Format inference failed:", error);
}
}
/**
* 自動選擇推薦的引擎
* @param {string} format - 目標格式
* @param {string} engine - 推薦引擎
*/
function autoSelectEngine(format, engine) {
// 尋找對應的目標按鈕
const targetButtons = document.querySelectorAll(".target");
for (const button of targetButtons) {
const targetFormat = button.dataset.target;
const converter = button.dataset.converter;
// 優先匹配格式和引擎
if (
targetFormat === format &&
converter &&
converter.toLowerCase().includes(engine.toLowerCase())
) {
// 模擬點擊
button.click();
console.log(`🎯 Auto-selected: ${format} using ${converter}`);
return;
}
}
// 如果沒有精確匹配,只匹配格式
for (const button of targetButtons) {
const targetFormat = button.dataset.target;
if (targetFormat === format) {
button.click();
console.log(`🎯 Auto-selected format: ${format}`);
return;
}
}
}
// 將 fileType 暴露給推斷模組
window.fileType = fileType;
// 監聽 fileType 變化
Object.defineProperty(window, "fileType", {
get: () => fileType,
set: (value) => {
fileType = value;
},
});

View file

@ -80,6 +80,7 @@ export const BaseHtml = ({
<link rel="manifest" href={`${webroot}/site.webmanifest`} /> <link rel="manifest" href={`${webroot}/site.webmanifest`} />
<script>{`window.__TRANSLATIONS__ = ${JSON.stringify(getTranslations(locale))};`}</script> <script>{`window.__TRANSLATIONS__ = ${JSON.stringify(getTranslations(locale))};`}</script>
<script src={`${webroot}/i18n.js`} defer /> <script src={`${webroot}/i18n.js`} defer />
<script src={`${webroot}/inference.js`} defer />
<script src={`${webroot}/theme.js`} /> <script src={`${webroot}/theme.js`} />
</head> </head>
<body class={`flex min-h-screen w-full flex-col bg-neutral-900 text-neutral-200`}> <body class={`flex min-h-screen w-full flex-col bg-neutral-900 text-neutral-200`}>

View file

@ -20,6 +20,8 @@ import { upload } from "./pages/upload";
import { uploadChunk, uploadInfo } from "./pages/uploadChunk"; import { uploadChunk, uploadInfo } from "./pages/uploadChunk";
import { user } from "./pages/user"; import { user } from "./pages/user";
import { healthcheck } from "./pages/healthcheck"; import { healthcheck } from "./pages/healthcheck";
import { inferenceApi } from "./pages/inference";
import { inferenceService } from "./inference";
export const uploadsDir = "./data/uploads/"; export const uploadsDir = "./data/uploads/";
export const outputDir = "./data/output/"; export const outputDir = "./data/output/";
@ -55,6 +57,7 @@ const app = new Elysia({
.use(listConverters) .use(listConverters)
.use(chooseConverter) .use(chooseConverter)
.use(healthcheck) .use(healthcheck)
.use(inferenceApi)
.onError(({ error }) => { .onError(({ error }) => {
console.error(error); console.error(error);
}); });
@ -74,6 +77,11 @@ app.listen(3000);
console.log(`🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}${WEBROOT}`); console.log(`🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}${WEBROOT}`);
// 初始化推斷服務
inferenceService.initialize().catch((error) => {
console.error("Failed to initialize inference service:", error);
});
const clearJobs = () => { const clearJobs = () => {
const jobs = db const jobs = db
.query("SELECT * FROM jobs WHERE date_created < ?") .query("SELECT * FROM jobs WHERE date_created < ?")

View file

@ -0,0 +1,484 @@
/**
* 使
*
* 使
*/
import db from "../db/db";
// ==================== 資料型別定義 ====================
/**
*
*/
export interface ConversionEvent {
/** 事件類型 */
event: "conversion_completed";
/** 使用者 ID */
user_id: number;
/** 輸入副檔名 */
input_ext: string;
/** 使用者搜尋的格式 */
searched_format: string;
/** 選擇的轉換引擎 */
selected_engine: string;
/** 是否成功 */
success: boolean;
/** 轉檔耗時 (毫秒) */
duration_ms: number;
/** 檔案大小 (KB) */
file_size_kb?: number;
/** 圖片百萬像素 (如適用) */
megapixels?: number;
/** 時間戳 */
timestamp: string;
}
/**
*
*/
export interface DismissEvent {
/** 事件類型 */
event: "recommendation_dismissed";
/** 使用者 ID */
user_id: number;
/** 輸入副檔名 */
input_ext: string;
/** 被拒絕的推薦格式 */
dismissed_format: string;
/** 被拒絕的推薦引擎 */
dismissed_engine?: string;
/** 時間戳 */
timestamp: string;
}
/**
* 使 Profile
*/
export interface UserProfile {
/** 使用者 ID */
user_id: number;
/** 格式偏好 (key: "input->output", value: 使用頻率 0-1) */
format_preferences: Record<string, number>;
/** 引擎偏好 (key: 輸出格式, value: {引擎: 使用頻率}) */
engine_preferences: Record<string, Record<string, number>>;
/** 最近使用的格式 (最新在前) */
recent_formats: string[];
/** 總轉檔次數 */
total_conversions: number;
/** 最後更新時間 */
last_updated: string;
}
/**
*
*/
export interface FormatConversionStats {
/** 格式流行度 (key: 格式, value: 使用率 0-1) */
format_popularity: Record<string, number>;
/** 轉換路徑統計 (key: "input->output", value: 次數) */
conversion_paths: Record<string, number>;
/** 引擎成功率 (key: 引擎, value: 成功率 0-1) */
engine_success_rates: Record<string, number>;
/** 統計時間範圍 */
stats_period: {
start: string;
end: string;
};
}
// ==================== 資料庫初始化 ====================
/**
*
*/
export function initBehaviorTables(): void {
// 轉檔事件表
db.exec(`
CREATE TABLE IF NOT EXISTS conversion_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
input_ext TEXT NOT NULL,
searched_format TEXT NOT NULL,
selected_engine TEXT NOT NULL,
success INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
file_size_kb INTEGER,
megapixels REAL,
timestamp TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
`);
// 推薦拒絕事件表
db.exec(`
CREATE TABLE IF NOT EXISTS dismiss_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
input_ext TEXT NOT NULL,
dismissed_format TEXT NOT NULL,
dismissed_engine TEXT,
timestamp TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
`);
// 使用者 Profile 快取表
db.exec(`
CREATE TABLE IF NOT EXISTS user_profiles (
user_id INTEGER PRIMARY KEY,
format_preferences TEXT NOT NULL,
engine_preferences TEXT NOT NULL,
recent_formats TEXT NOT NULL,
total_conversions INTEGER NOT NULL,
last_updated TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
`);
// 建立索引以加速查詢
db.exec(`
CREATE INDEX IF NOT EXISTS idx_conversion_events_user
ON conversion_events(user_id, timestamp DESC);
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_dismiss_events_user
ON dismiss_events(user_id, timestamp DESC);
`);
console.log("✅ Behavior tables initialized");
}
// ==================== 事件記錄 ====================
/**
*
*/
export function logConversionEvent(event: Omit<ConversionEvent, "event" | "timestamp">): void {
const timestamp = new Date().toISOString();
db.query(
`
INSERT INTO conversion_events
(user_id, input_ext, searched_format, selected_engine, success, duration_ms, file_size_kb, megapixels, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
event.user_id,
event.input_ext,
event.searched_format,
event.selected_engine,
event.success ? 1 : 0,
event.duration_ms,
event.file_size_kb ?? null,
event.megapixels ?? null,
timestamp,
);
// 異步更新使用者 Profile
updateUserProfileAsync(event.user_id);
}
/**
*
*/
export function logDismissEvent(event: Omit<DismissEvent, "event" | "timestamp">): void {
const timestamp = new Date().toISOString();
db.query(
`
INSERT INTO dismiss_events
(user_id, input_ext, dismissed_format, dismissed_engine, timestamp)
VALUES (?, ?, ?, ?, ?)
`,
).run(
event.user_id,
event.input_ext,
event.dismissed_format,
event.dismissed_engine ?? null,
timestamp,
);
}
// ==================== 使用者 Profile 管理 ====================
/**
* 使 Profile ()
*/
function updateUserProfileAsync(userId: number): void {
// 使用 setImmediate 或 setTimeout 避免阻塞
setTimeout(() => {
try {
rebuildUserProfile(userId);
} catch (error) {
console.error("Failed to update user profile:", error);
}
}, 100);
}
/**
* 使 Profile
*/
export function rebuildUserProfile(userId: number): UserProfile {
// 取得最近 30 天的轉檔記錄
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
interface ConversionRow {
input_ext: string;
searched_format: string;
selected_engine: string;
success: number;
timestamp: string;
}
const events = db
.query(
`
SELECT input_ext, searched_format, selected_engine, success, timestamp
FROM conversion_events
WHERE user_id = ? AND timestamp > ?
ORDER BY timestamp DESC
`,
)
.all(userId, thirtyDaysAgo) as ConversionRow[];
// 計算格式偏好
const formatCounts: Record<string, number> = {};
const engineCounts: Record<string, Record<string, number>> = {};
const recentFormats: string[] = [];
let totalConversions = 0;
for (const event of events) {
// 格式偏好
const pathKey = `${event.input_ext}->${event.searched_format}`;
formatCounts[pathKey] = (formatCounts[pathKey] || 0) + 1;
// 引擎偏好
if (!engineCounts[event.searched_format]) {
engineCounts[event.searched_format] = {};
}
const formatEngines = engineCounts[event.searched_format];
if (formatEngines) {
formatEngines[event.selected_engine] = (formatEngines[event.selected_engine] || 0) + 1;
}
// 最近格式 (去重)
if (!recentFormats.includes(event.searched_format)) {
recentFormats.push(event.searched_format);
}
totalConversions++;
}
// 正規化為 0-1 範圍
const maxFormatCount = Math.max(...Object.values(formatCounts), 1);
const formatPreferences: Record<string, number> = {};
for (const [key, count] of Object.entries(formatCounts)) {
formatPreferences[key] = count / maxFormatCount;
}
const enginePreferences: Record<string, Record<string, number>> = {};
for (const [format, engines] of Object.entries(engineCounts)) {
const maxCount = Math.max(...Object.values(engines), 1);
enginePreferences[format] = {};
for (const [engine, count] of Object.entries(engines)) {
enginePreferences[format][engine] = count / maxCount;
}
}
const profile: UserProfile = {
user_id: userId,
format_preferences: formatPreferences,
engine_preferences: enginePreferences,
recent_formats: recentFormats.slice(0, 10),
total_conversions: totalConversions,
last_updated: new Date().toISOString(),
};
// 儲存到資料庫
db.query(
`
INSERT OR REPLACE INTO user_profiles
(user_id, format_preferences, engine_preferences, recent_formats, total_conversions, last_updated)
VALUES (?, ?, ?, ?, ?, ?)
`,
).run(
profile.user_id,
JSON.stringify(profile.format_preferences),
JSON.stringify(profile.engine_preferences),
JSON.stringify(profile.recent_formats),
profile.total_conversions,
profile.last_updated,
);
return profile;
}
/**
* 使 Profile
*/
export function getUserProfile(userId: number): UserProfile | null {
interface ProfileRow {
user_id: number;
format_preferences: string;
engine_preferences: string;
recent_formats: string;
total_conversions: number;
last_updated: string;
}
const row = db
.query(
`
SELECT * FROM user_profiles WHERE user_id = ?
`,
)
.get(userId) as ProfileRow | null;
if (!row) {
return null;
}
return {
user_id: row.user_id,
format_preferences: JSON.parse(row.format_preferences),
engine_preferences: JSON.parse(row.engine_preferences),
recent_formats: JSON.parse(row.recent_formats),
total_conversions: row.total_conversions,
last_updated: row.last_updated,
};
}
// ==================== 全域統計 ====================
/**
*
*/
export function calculateGlobalStats(): FormatConversionStats {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
interface StatsRow {
searched_format: string;
count: number;
}
interface PathRow {
input_ext: string;
searched_format: string;
count: number;
}
interface EngineRow {
selected_engine: string;
total: number;
success_count: number;
}
// 格式流行度
const formatStats = db
.query(
`
SELECT searched_format, COUNT(*) as count
FROM conversion_events
WHERE timestamp > ?
GROUP BY searched_format
ORDER BY count DESC
`,
)
.all(sevenDaysAgo) as StatsRow[];
const maxCount = formatStats[0]?.count ?? 1;
const formatPopularity: Record<string, number> = {};
for (const row of formatStats) {
formatPopularity[row.searched_format] = row.count / maxCount;
}
// 轉換路徑
const pathStats = db
.query(
`
SELECT input_ext, searched_format, COUNT(*) as count
FROM conversion_events
WHERE timestamp > ?
GROUP BY input_ext, searched_format
`,
)
.all(sevenDaysAgo) as PathRow[];
const conversionPaths: Record<string, number> = {};
for (const row of pathStats) {
conversionPaths[`${row.input_ext}->${row.searched_format}`] = row.count;
}
// 引擎成功率
const engineStats = db
.query(
`
SELECT selected_engine, COUNT(*) as total, SUM(success) as success_count
FROM conversion_events
WHERE timestamp > ?
GROUP BY selected_engine
`,
)
.all(sevenDaysAgo) as EngineRow[];
const engineSuccessRates: Record<string, number> = {};
for (const row of engineStats) {
engineSuccessRates[row.selected_engine] = row.success_count / row.total;
}
return {
format_popularity: formatPopularity,
conversion_paths: conversionPaths,
engine_success_rates: engineSuccessRates,
stats_period: {
start: sevenDaysAgo,
end: new Date().toISOString(),
},
};
}
// ==================== 資料清理 ====================
/**
*
*/
export function cleanupOldEvents(daysToKeep = 90): number {
const cutoff = new Date(Date.now() - daysToKeep * 24 * 60 * 60 * 1000).toISOString();
interface DeleteResult {
changes: number;
}
const result1 = db
.query(
`
DELETE FROM conversion_events WHERE timestamp < ?
`,
)
.run(cutoff) as unknown as DeleteResult;
const result2 = db
.query(
`
DELETE FROM dismiss_events WHERE timestamp < ?
`,
)
.run(cutoff) as unknown as DeleteResult;
const deletedCount = (result1.changes ?? 0) + (result2.changes ?? 0);
console.log(`🧹 Cleaned up ${deletedCount} old behavior events`);
return deletedCount;
}
// 導出初始化函數
export default {
initBehaviorTables,
logConversionEvent,
logDismissEvent,
getUserProfile,
rebuildUserProfile,
calculateGlobalStats,
cleanupOldEvents,
};

View file

@ -0,0 +1,647 @@
/**
*
*
* 使
*/
import type { FileFeatures } from "./featureExtraction";
import type { UserProfile } from "./behaviorStore";
/**
*
*/
export interface EngineCapability {
/** 引擎優勢描述 */
strength: string;
/** 冷啟動成本 (毫秒) */
cold_start_cost: number;
/** 是否禁止用於此格式 */
disallowed?: boolean;
/** 最佳適用場景 */
best_for?: string[];
/** 處理大檔案的能力 (0-1) */
large_file_capability?: number;
/** 品質優先程度 (0-1) */
quality_priority?: number;
/** 速度優先程度 (0-1) */
speed_priority?: number;
}
/**
*
*
*/
export const ENGINE_CAPABILITY_MATRIX: Record<string, Record<string, EngineCapability>> = {
// ==================== 圖片格式 ====================
png: {
vips: {
strength: "large_image_fast",
cold_start_cost: 300,
best_for: ["large_images", "batch_processing"],
large_file_capability: 0.95,
speed_priority: 0.9,
quality_priority: 0.85,
},
imagemagick: {
strength: "general_purpose",
cold_start_cost: 500,
best_for: ["general", "filters", "effects"],
large_file_capability: 0.7,
speed_priority: 0.7,
quality_priority: 0.9,
},
graphicsmagick: {
strength: "memory_efficient",
cold_start_cost: 400,
best_for: ["memory_constrained", "simple_conversions"],
large_file_capability: 0.8,
speed_priority: 0.75,
quality_priority: 0.85,
},
ffmpeg: {
strength: "video_only",
cold_start_cost: 800,
disallowed: true,
},
},
jpeg: {
vips: {
strength: "large_image_fast",
cold_start_cost: 300,
best_for: ["large_images", "photos"],
large_file_capability: 0.95,
speed_priority: 0.9,
quality_priority: 0.85,
},
imagemagick: {
strength: "general_purpose",
cold_start_cost: 500,
best_for: ["general", "quality_control"],
large_file_capability: 0.7,
speed_priority: 0.7,
quality_priority: 0.9,
},
graphicsmagick: {
strength: "memory_efficient",
cold_start_cost: 400,
large_file_capability: 0.8,
speed_priority: 0.75,
quality_priority: 0.85,
},
},
webp: {
vips: {
strength: "fast_modern_formats",
cold_start_cost: 300,
best_for: ["web_optimization", "large_images"],
large_file_capability: 0.95,
speed_priority: 0.9,
quality_priority: 0.9,
},
imagemagick: {
strength: "general_purpose",
cold_start_cost: 500,
best_for: ["general"],
large_file_capability: 0.7,
speed_priority: 0.7,
quality_priority: 0.85,
},
libheif: {
strength: "heif_webp_specialist",
cold_start_cost: 400,
best_for: ["modern_formats"],
large_file_capability: 0.85,
speed_priority: 0.8,
quality_priority: 0.9,
},
},
avif: {
vips: {
strength: "fast_avif",
cold_start_cost: 300,
best_for: ["modern_formats", "compression"],
large_file_capability: 0.9,
speed_priority: 0.85,
quality_priority: 0.95,
},
libheif: {
strength: "avif_specialist",
cold_start_cost: 400,
best_for: ["avif_native"],
large_file_capability: 0.85,
speed_priority: 0.8,
quality_priority: 0.95,
},
imagemagick: {
strength: "fallback",
cold_start_cost: 500,
large_file_capability: 0.6,
speed_priority: 0.5,
quality_priority: 0.8,
},
},
gif: {
imagemagick: {
strength: "animation_expert",
cold_start_cost: 500,
best_for: ["animations", "optimization"],
large_file_capability: 0.7,
speed_priority: 0.7,
quality_priority: 0.9,
},
vips: {
strength: "fast_static",
cold_start_cost: 300,
best_for: ["static_gifs"],
large_file_capability: 0.9,
speed_priority: 0.9,
quality_priority: 0.8,
},
ffmpeg: {
strength: "video_to_gif",
cold_start_cost: 800,
best_for: ["video_source"],
large_file_capability: 0.9,
speed_priority: 0.7,
quality_priority: 0.85,
},
},
svg: {
inkscape: {
strength: "svg_expert",
cold_start_cost: 1500,
best_for: ["vector_editing", "complex_svg"],
large_file_capability: 0.7,
speed_priority: 0.5,
quality_priority: 1.0,
},
resvg: {
strength: "fast_svg_render",
cold_start_cost: 200,
best_for: ["svg_to_png", "simple_render"],
large_file_capability: 0.85,
speed_priority: 0.95,
quality_priority: 0.9,
},
imagemagick: {
strength: "fallback",
cold_start_cost: 500,
large_file_capability: 0.6,
speed_priority: 0.6,
quality_priority: 0.7,
},
},
pdf: {
libreoffice: {
strength: "document_expert",
cold_start_cost: 3000,
best_for: ["office_to_pdf", "complex_documents"],
large_file_capability: 0.8,
speed_priority: 0.4,
quality_priority: 0.95,
},
imagemagick: {
strength: "image_to_pdf",
cold_start_cost: 500,
best_for: ["image_conversion"],
large_file_capability: 0.7,
speed_priority: 0.7,
quality_priority: 0.85,
},
pandoc: {
strength: "markdown_to_pdf",
cold_start_cost: 1000,
best_for: ["markdown", "text_documents"],
large_file_capability: 0.8,
speed_priority: 0.6,
quality_priority: 0.9,
},
},
// ==================== 影片格式 ====================
mp4: {
ffmpeg: {
strength: "video_universal",
cold_start_cost: 800,
best_for: ["all_video"],
large_file_capability: 0.95,
speed_priority: 0.8,
quality_priority: 0.9,
},
},
webm: {
ffmpeg: {
strength: "video_universal",
cold_start_cost: 800,
best_for: ["web_video"],
large_file_capability: 0.95,
speed_priority: 0.75,
quality_priority: 0.9,
},
},
mkv: {
ffmpeg: {
strength: "video_universal",
cold_start_cost: 800,
best_for: ["container_conversion"],
large_file_capability: 0.95,
speed_priority: 0.85,
quality_priority: 0.95,
},
},
// ==================== 音訊格式 ====================
mp3: {
ffmpeg: {
strength: "audio_universal",
cold_start_cost: 800,
best_for: ["all_audio"],
large_file_capability: 0.95,
speed_priority: 0.9,
quality_priority: 0.85,
},
},
wav: {
ffmpeg: {
strength: "audio_universal",
cold_start_cost: 800,
best_for: ["lossless_audio"],
large_file_capability: 0.95,
speed_priority: 0.9,
quality_priority: 1.0,
},
},
flac: {
ffmpeg: {
strength: "audio_universal",
cold_start_cost: 800,
best_for: ["lossless_compression"],
large_file_capability: 0.95,
speed_priority: 0.85,
quality_priority: 1.0,
},
},
// ==================== 文件格式 ====================
docx: {
libreoffice: {
strength: "document_expert",
cold_start_cost: 3000,
best_for: ["office_documents"],
large_file_capability: 0.8,
speed_priority: 0.4,
quality_priority: 0.95,
},
pandoc: {
strength: "text_conversion",
cold_start_cost: 1000,
best_for: ["simple_documents", "markdown_source"],
large_file_capability: 0.7,
speed_priority: 0.7,
quality_priority: 0.85,
},
},
txt: {
pandoc: {
strength: "text_extraction",
cold_start_cost: 500,
best_for: ["text_extraction"],
large_file_capability: 0.9,
speed_priority: 0.9,
quality_priority: 0.9,
},
markitDown: {
strength: "ai_extraction",
cold_start_cost: 800,
best_for: ["complex_documents"],
large_file_capability: 0.7,
speed_priority: 0.6,
quality_priority: 0.95,
},
},
md: {
pandoc: {
strength: "markdown_expert",
cold_start_cost: 500,
best_for: ["markdown_conversion"],
large_file_capability: 0.9,
speed_priority: 0.9,
quality_priority: 0.95,
},
markitDown: {
strength: "ai_extraction",
cold_start_cost: 800,
best_for: ["pdf_to_md", "image_to_md"],
large_file_capability: 0.7,
speed_priority: 0.6,
quality_priority: 0.9,
},
},
epub: {
calibre: {
strength: "ebook_expert",
cold_start_cost: 2000,
best_for: ["ebook_conversion"],
large_file_capability: 0.85,
speed_priority: 0.5,
quality_priority: 0.95,
},
pandoc: {
strength: "simple_epub",
cold_start_cost: 1000,
best_for: ["simple_ebooks"],
large_file_capability: 0.7,
speed_priority: 0.7,
quality_priority: 0.8,
},
},
// ==================== 資料格式 ====================
json: {
dasel: {
strength: "data_transform",
cold_start_cost: 100,
best_for: ["data_conversion"],
large_file_capability: 0.9,
speed_priority: 0.95,
quality_priority: 0.95,
},
},
yaml: {
dasel: {
strength: "data_transform",
cold_start_cost: 100,
best_for: ["data_conversion"],
large_file_capability: 0.9,
speed_priority: 0.95,
quality_priority: 0.95,
},
},
csv: {
dasel: {
strength: "data_transform",
cold_start_cost: 100,
best_for: ["structured_data"],
large_file_capability: 0.9,
speed_priority: 0.95,
quality_priority: 0.9,
},
},
// ==================== 3D 模型格式 ====================
obj: {
assimp: {
strength: "3d_universal",
cold_start_cost: 600,
best_for: ["3d_conversion"],
large_file_capability: 0.8,
speed_priority: 0.7,
quality_priority: 0.9,
},
},
gltf: {
assimp: {
strength: "3d_universal",
cold_start_cost: 600,
best_for: ["web_3d"],
large_file_capability: 0.8,
speed_priority: 0.7,
quality_priority: 0.9,
},
},
stl: {
assimp: {
strength: "3d_universal",
cold_start_cost: 600,
best_for: ["3d_printing"],
large_file_capability: 0.85,
speed_priority: 0.75,
quality_priority: 0.9,
},
},
};
/**
*
*/
export interface EnginePrediction {
/** 預測的引擎名稱 */
engine: string;
/** 預測信心度 (0-1) */
confidence: number;
/** 是否應該預調用 */
should_warmup: boolean;
/** 預估冷啟動成本 (毫秒) */
cold_start_cost: number;
/** 預測原因 */
reason: string;
}
/**
*
*/
export class EnginePredictionModel {
private warmupConfidenceThreshold: number;
private warmupMinColdStartCost: number;
constructor(warmupConfidenceThreshold = 0.7, warmupMinColdStartCost = 500) {
this.warmupConfidenceThreshold = warmupConfidenceThreshold;
this.warmupMinColdStartCost = warmupMinColdStartCost;
}
/**
*
*/
predict(
targetFormat: string,
features: FileFeatures,
userProfile: UserProfile | null,
availableEngines?: string[],
): EnginePrediction | null {
const formatEngines = ENGINE_CAPABILITY_MATRIX[targetFormat];
if (!formatEngines) {
return null;
}
// 過濾可用引擎
let engines = Object.entries(formatEngines);
if (availableEngines && availableEngines.length > 0) {
engines = engines.filter(([name]) => availableEngines.includes(name));
}
// 過濾禁止的引擎
engines = engines.filter(([, cap]) => !cap.disallowed);
if (engines.length === 0) {
return null;
}
// 計算每個引擎的得分
const scoredEngines: Array<{
engine: string;
score: number;
cap: EngineCapability;
reason: string;
}> = [];
for (const [engineName, capability] of engines) {
let score = 0;
let reason = "";
// 1. 使用者歷史偏好
const userPref = this.getUserEnginePreference(targetFormat, engineName, userProfile);
score += userPref * 0.4;
if (userPref > 0.5) {
reason = "user_preference";
}
// 2. 檔案特徵匹配
const featureScore = this.calculateFeatureMatchScore(features, capability);
score += featureScore * 0.35;
if (featureScore > 0.7 && !reason) {
reason = "feature_match";
}
// 3. 歷史成功率 (假設都有好的成功率,用能力值代替)
const reliabilityScore = (capability.quality_priority ?? 0.8) * 0.15;
score += reliabilityScore;
// 4. 速度考量
const speedScore = (capability.speed_priority ?? 0.7) * 0.1;
score += speedScore;
if (!reason) {
reason = "balanced_choice";
}
scoredEngines.push({
engine: engineName,
score,
cap: capability,
reason,
});
}
// 排序取最佳
scoredEngines.sort((a, b) => b.score - a.score);
const bestEngine = scoredEngines[0];
if (!bestEngine) {
return null;
}
// 計算信心度
const confidence = this.calculateConfidence(scoredEngines);
// 決定是否預調用
const shouldWarmup =
confidence >= this.warmupConfidenceThreshold &&
bestEngine.cap.cold_start_cost >= this.warmupMinColdStartCost;
return {
engine: bestEngine.engine,
confidence: Math.round(confidence * 100) / 100,
should_warmup: shouldWarmup,
cold_start_cost: bestEngine.cap.cold_start_cost,
reason: bestEngine.reason,
};
}
/**
* 使
*/
private getUserEnginePreference(
format: string,
engine: string,
userProfile: UserProfile | null,
): number {
if (!userProfile?.engine_preferences) return 0;
const formatPrefs = userProfile.engine_preferences[format];
if (!formatPrefs) return 0;
return formatPrefs[engine] ?? 0;
}
/**
*
*/
private calculateFeatureMatchScore(features: FileFeatures, capability: EngineCapability): number {
let score = 0.5; // 基礎分
// 大檔案處理能力
if (features.file_size_kb > 5000) {
// > 5MB
score += (capability.large_file_capability ?? 0.5) * 0.3;
}
// 圖片特定評估
if (features.image) {
// 高解析度圖片
if (features.image.megapixels > 10) {
score += (capability.large_file_capability ?? 0.5) * 0.2;
score += (capability.speed_priority ?? 0.5) * 0.1;
}
// 檢查 best_for 標籤
if (capability.best_for) {
if (features.image.megapixels > 10 && capability.best_for.includes("large_images")) {
score += 0.2;
}
if (features.image.is_animation && capability.best_for.includes("animations")) {
score += 0.2;
}
}
}
return Math.min(score, 1);
}
/**
*
*/
private calculateConfidence(scoredEngines: Array<{ engine: string; score: number }>): number {
if (scoredEngines.length === 0) return 0;
const first = scoredEngines[0];
if (!first) return 0;
if (scoredEngines.length === 1) return Math.min(first.score, 1);
const topScore = first.score;
const secondScore = scoredEngines[1]?.score ?? 0;
// 使用相對優勢
const gap = topScore - secondScore;
const relativeGap = gap / (topScore + 0.001);
const confidence = topScore * 0.7 + relativeGap * 0.3;
return Math.min(Math.max(confidence, 0), 1);
}
/**
*
*/
getAvailableEngines(format: string): string[] {
const formatEngines = ENGINE_CAPABILITY_MATRIX[format];
if (!formatEngines) return [];
return Object.entries(formatEngines)
.filter(([, cap]) => !cap.disallowed)
.map(([name]) => name);
}
/**
*
*/
isEngineAllowed(format: string, engine: string): boolean {
const formatEngines = ENGINE_CAPABILITY_MATRIX[format];
if (!formatEngines) return false;
const capability = formatEngines[engine];
return capability !== undefined && !capability.disallowed;
}
}
// 導出單例
export const enginePredictionModel = new EnginePredictionModel();

View file

@ -0,0 +1,253 @@
/**
* 調 (Warm-up)
*
* 調
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/**
* 調
*/
export interface WarmupStatus {
/** 引擎名稱 */
engine: string;
/** 狀態 */
status: "pending" | "warming" | "ready" | "failed" | "cancelled";
/** 開始時間 */
started_at?: string;
/** 完成時間 */
completed_at?: string;
/** 錯誤訊息 */
error?: string;
}
/**
* 調
*/
interface WarmupCommand {
/** 執行檔 */
command: string;
/** 參數 */
args: string[];
/** 超時時間 (毫秒) */
timeout: number;
}
/**
* 調
*/
const ENGINE_WARMUP_COMMANDS: Record<string, WarmupCommand> = {
imagemagick: {
command: "magick",
args: ["-version"],
timeout: 5000,
},
graphicsmagick: {
command: "gm",
args: ["version"],
timeout: 5000,
},
vips: {
command: "vips",
args: ["--version"],
timeout: 3000,
},
ffmpeg: {
command: "ffmpeg",
args: ["-version"],
timeout: 5000,
},
libreoffice: {
command: "soffice",
args: ["--version"],
timeout: 10000,
},
inkscape: {
command: "inkscape",
args: ["--version"],
timeout: 8000,
},
pandoc: {
command: "pandoc",
args: ["--version"],
timeout: 5000,
},
calibre: {
command: "ebook-convert",
args: ["--version"],
timeout: 8000,
},
resvg: {
command: "resvg",
args: ["--help"],
timeout: 3000,
},
libheif: {
command: "heif-info",
args: ["--version"],
timeout: 3000,
},
libjxl: {
command: "djxl",
args: ["--version"],
timeout: 3000,
},
assimp: {
command: "assimp",
args: ["version"],
timeout: 5000,
},
dasel: {
command: "dasel",
args: ["--version"],
timeout: 2000,
},
};
/**
* 調
*/
export class EngineWarmupManager {
/** 當前預調用狀態 */
private currentWarmup: WarmupStatus | null = null;
/** 預調用 Promise */
private warmupPromise: Promise<void> | null = null;
/** 取消標記 */
private cancelRequested = false;
/**
* 調
* 調
*/
async warmup(engine: string): Promise<boolean> {
// 如果已經在預調用同一個引擎,直接返回
if (this.currentWarmup?.engine === engine && this.currentWarmup.status === "warming") {
return true;
}
// 取消之前的預調用
if (this.currentWarmup?.status === "warming") {
this.cancel();
}
// 檢查是否有該引擎的預調用命令
const warmupCmd = ENGINE_WARMUP_COMMANDS[engine];
if (!warmupCmd) {
console.warn(`No warmup command defined for engine: ${engine}`);
return false;
}
// 設定狀態
this.cancelRequested = false;
this.currentWarmup = {
engine,
status: "warming",
started_at: new Date().toISOString(),
};
console.log(`🔥 Warming up engine: ${engine}`);
// 執行預調用
this.warmupPromise = this.executeWarmup(engine, warmupCmd);
try {
await this.warmupPromise;
return this.currentWarmup?.status === "ready";
} catch {
return false;
}
}
/**
* 調
*/
private async executeWarmup(engine: string, cmd: WarmupCommand): Promise<void> {
try {
// 檢查是否已取消
if (this.cancelRequested) {
this.updateStatus("cancelled");
return;
}
await execFileAsync(cmd.command, cmd.args, {
timeout: cmd.timeout,
});
// 再次檢查是否已取消
if (this.cancelRequested) {
this.updateStatus("cancelled");
return;
}
this.updateStatus("ready");
console.log(`✅ Engine ${engine} warmed up successfully`);
} catch (error) {
if (this.cancelRequested) {
this.updateStatus("cancelled");
return;
}
this.updateStatus("failed", error instanceof Error ? error.message : "Unknown error");
console.warn(`⚠️ Failed to warm up engine ${engine}:`, error);
}
}
/**
* 調
*/
private updateStatus(status: WarmupStatus["status"], error?: string): void {
if (this.currentWarmup) {
this.currentWarmup.status = status;
if (status === "ready" || status === "failed" || status === "cancelled") {
this.currentWarmup.completed_at = new Date().toISOString();
}
if (error) {
this.currentWarmup.error = error;
}
}
}
/**
* 調
*/
cancel(): void {
if (this.currentWarmup?.status === "warming") {
console.log(`❌ Cancelling warmup for engine: ${this.currentWarmup.engine}`);
this.cancelRequested = true;
this.updateStatus("cancelled");
}
}
/**
* 調
*/
getStatus(): WarmupStatus | null {
return this.currentWarmup;
}
/**
* 調
*/
isReady(engine: string): boolean {
return this.currentWarmup?.engine === engine && this.currentWarmup.status === "ready";
}
/**
*
*/
reset(): void {
this.cancel();
this.currentWarmup = null;
this.warmupPromise = null;
this.cancelRequested = false;
}
}
// 導出單例
export const engineWarmupManager = new EngineWarmupManager();

View file

@ -0,0 +1,429 @@
/**
*
*
*
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { stat } from "node:fs/promises";
const execFileAsync = promisify(execFile);
/**
*
*/
export interface BaseFileFeatures {
/** 輸入檔案副檔名 (不含點) */
input_ext: string;
/** MIME 類型 */
mime_type: string;
/** 檔案大小 (KB) */
file_size_kb: number;
/** 魔數家族 (image/video/audio/document/data/model3d/archive/font/unknown) */
magic_family: string;
/** 上傳時段 (morning/afternoon/evening/night) */
upload_hour_bucket: string;
}
/**
*
*/
export interface ImageFeatures {
/** 寬度 (像素) */
width: number;
/** 高度 (像素) */
height: number;
/** 百萬像素 */
megapixels: number;
/** 是否有透明通道 */
has_alpha: boolean;
/** 是否為動畫 */
is_animation: boolean;
/** 色彩模式 (rgb/rgba/cmyk/grayscale) */
color_mode: string;
/** 長寬比描述 */
aspect_ratio: string;
}
/**
*
*/
export interface FileFeatures extends BaseFileFeatures {
/** 圖片專用特徵 (僅當 magic_family === 'image' 時存在) */
image?: ImageFeatures;
}
/**
* magic_family
*/
const EXT_TO_FAMILY: Record<string, string> = {
// 圖片
jpg: "image",
jpeg: "image",
png: "image",
gif: "image",
webp: "image",
avif: "image",
heic: "image",
heif: "image",
bmp: "image",
tiff: "image",
tif: "image",
svg: "image",
ico: "image",
icns: "image",
psd: "image",
raw: "image",
cr2: "image",
cr3: "image",
nef: "image",
arw: "image",
dng: "image",
orf: "image",
rw2: "image",
jxl: "image",
apng: "image",
eps: "image",
ai: "image",
emf: "image",
wmf: "image",
cur: "image",
// 影片
mp4: "video",
mkv: "video",
avi: "video",
mov: "video",
webm: "video",
flv: "video",
wmv: "video",
m4v: "video",
"3gp": "video",
mpeg: "video",
mpg: "video",
mts: "video",
m2ts: "video",
vob: "video",
ogv: "video",
prores: "video",
mxf: "video",
// 音訊
mp3: "audio",
wav: "audio",
flac: "audio",
aac: "audio",
ogg: "audio",
m4a: "audio",
wma: "audio",
opus: "audio",
aiff: "audio",
ape: "audio",
alac: "audio",
mid: "audio",
midi: "audio",
// 文件
pdf: "document",
doc: "document",
docx: "document",
odt: "document",
rtf: "document",
txt: "document",
xls: "document",
xlsx: "document",
ods: "document",
ppt: "document",
pptx: "document",
odp: "document",
epub: "document",
mobi: "document",
azw3: "document",
fb2: "document",
md: "document",
markdown: "document",
rst: "document",
tex: "document",
html: "document",
htm: "document",
key: "document",
pages: "document",
numbers: "document",
msg: "document",
eml: "document",
// 資料
json: "data",
yaml: "data",
yml: "data",
xml: "data",
csv: "data",
toml: "data",
ini: "data",
vcf: "data",
// 3D 模型
obj: "model3d",
fbx: "model3d",
gltf: "model3d",
glb: "model3d",
stl: "model3d",
dae: "model3d",
ply: "model3d",
"3ds": "model3d",
blend: "model3d",
// 壓縮檔
zip: "archive",
tar: "archive",
gz: "archive",
"7z": "archive",
rar: "archive",
bz2: "archive",
xz: "archive",
// 字型
ttf: "font",
otf: "font",
woff: "font",
woff2: "font",
eot: "font",
};
/**
* MIME
*/
const EXT_TO_MIME: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
heic: "image/heic",
heif: "image/heif",
bmp: "image/bmp",
tiff: "image/tiff",
tif: "image/tiff",
svg: "image/svg+xml",
ico: "image/x-icon",
psd: "image/vnd.adobe.photoshop",
jxl: "image/jxl",
mp4: "video/mp4",
mkv: "video/x-matroska",
avi: "video/x-msvideo",
mov: "video/quicktime",
webm: "video/webm",
flv: "video/x-flv",
mp3: "audio/mpeg",
wav: "audio/wav",
flac: "audio/flac",
aac: "audio/aac",
ogg: "audio/ogg",
m4a: "audio/mp4",
opus: "audio/opus",
pdf: "application/pdf",
doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
json: "application/json",
xml: "application/xml",
csv: "text/csv",
txt: "text/plain",
html: "text/html",
md: "text/markdown",
};
/**
*
*/
function getHourBucket(hour: number): string {
if (hour >= 6 && hour < 12) return "morning";
if (hour >= 12 && hour < 18) return "afternoon";
if (hour >= 18 && hour < 22) return "evening";
return "night";
}
/**
*
*/
function getAspectRatio(width: number, height: number): string {
const ratio = width / height;
if (Math.abs(ratio - 1) < 0.05) return "1:1";
if (Math.abs(ratio - 4 / 3) < 0.1) return "4:3";
if (Math.abs(ratio - 3 / 4) < 0.1) return "3:4";
if (Math.abs(ratio - 16 / 9) < 0.1) return "16:9";
if (Math.abs(ratio - 9 / 16) < 0.1) return "9:16";
if (Math.abs(ratio - 3 / 2) < 0.1) return "3:2";
if (Math.abs(ratio - 2 / 3) < 0.1) return "2:3";
if (ratio > 2) return "panorama";
if (ratio < 0.5) return "tall";
return "other";
}
/**
* 使 ImageMagick
*/
async function extractImageFeaturesWithImageMagick(
filePath: string,
): Promise<ImageFeatures | null> {
try {
const { stdout } = await execFileAsync("identify", [
"-format",
"%w|%h|%[channels]|%n|%[colorspace]",
filePath,
]);
const parts = stdout.trim().split("|");
if (parts.length < 5) return null;
const width = parseInt(parts[0] ?? "0", 10);
const height = parseInt(parts[1] ?? "0", 10);
const channels = parts[2] ?? "";
const frameCount = parseInt(parts[3] ?? "0", 10);
const colorspace = (parts[4] ?? "").toLowerCase();
const megapixels = (width * height) / 1_000_000;
const has_alpha = channels.includes("a") || channels.includes("alpha");
const is_animation = frameCount > 1;
let color_mode = "rgb";
if (colorspace.includes("gray")) {
color_mode = "grayscale";
} else if (colorspace.includes("cmyk")) {
color_mode = "cmyk";
} else if (has_alpha) {
color_mode = "rgba";
}
return {
width,
height,
megapixels: Math.round(megapixels * 100) / 100,
has_alpha,
is_animation,
color_mode,
aspect_ratio: getAspectRatio(width, height),
};
} catch (error) {
console.warn("ImageMagick identify failed, trying vips...", error);
return null;
}
}
/**
* 使 vips ()
*/
async function extractImageFeaturesWithVips(filePath: string): Promise<ImageFeatures | null> {
try {
const { stdout } = await execFileAsync("vipsheader", ["-a", filePath]);
const lines = stdout.trim().split("\n");
let width = 0,
height = 0,
bands = 3;
for (const line of lines) {
const [key, value] = line.split(":").map((s) => s.trim());
if (key === "width" && value) width = parseInt(value, 10);
if (key === "height" && value) height = parseInt(value, 10);
if (key === "bands" && value) bands = parseInt(value, 10);
}
if (width === 0 || height === 0) return null;
const megapixels = (width * height) / 1_000_000;
const has_alpha = bands === 4;
return {
width,
height,
megapixels: Math.round(megapixels * 100) / 100,
has_alpha,
is_animation: false, // vipsheader doesn't easily detect animation
color_mode: has_alpha ? "rgba" : "rgb",
aspect_ratio: getAspectRatio(width, height),
};
} catch (error) {
console.warn("vipsheader failed:", error);
return null;
}
}
/**
*
*/
async function extractImageFeatures(filePath: string): Promise<ImageFeatures | null> {
// 優先使用 ImageMagick
let features = await extractImageFeaturesWithImageMagick(filePath);
if (features) return features;
// 備用使用 vips
features = await extractImageFeaturesWithVips(filePath);
return features;
}
/**
*
*/
export async function extractFeatures(filePath: string): Promise<FileFeatures> {
// 取得副檔名
const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
// 取得檔案大小
let fileSizeKb = 0;
try {
const stats = await stat(filePath);
fileSizeKb = Math.round(stats.size / 1024);
} catch {
console.warn("Could not stat file:", filePath);
}
// 決定 magic_family
const magicFamily = EXT_TO_FAMILY[ext] ?? "unknown";
// 決定 MIME 類型
const mimeType = EXT_TO_MIME[ext] ?? "application/octet-stream";
// 取得時段
const uploadHourBucket = getHourBucket(new Date().getHours());
const baseFeatures: FileFeatures = {
input_ext: ext,
mime_type: mimeType,
file_size_kb: fileSizeKb,
magic_family: magicFamily,
upload_hour_bucket: uploadHourBucket,
};
// 如果是圖片,提取圖片特徵
if (magicFamily === "image") {
const imageFeatures = await extractImageFeatures(filePath);
if (imageFeatures) {
baseFeatures.image = imageFeatures;
}
}
return baseFeatures;
}
/**
* ()
*
*/
export function extractFeaturesFromExtension(ext: string): Omit<FileFeatures, "file_size_kb"> {
const cleanExt = ext.toLowerCase().replace(/^\./, "");
const magicFamily = EXT_TO_FAMILY[cleanExt] ?? "unknown";
const mimeType = EXT_TO_MIME[cleanExt] ?? "application/octet-stream";
const uploadHourBucket = getHourBucket(new Date().getHours());
return {
input_ext: cleanExt,
mime_type: mimeType,
magic_family: magicFamily,
upload_hour_bucket: uploadHourBucket,
};
}

View file

@ -0,0 +1,512 @@
/**
*
*
*
* 1000+ 1030
*/
export interface FormatCandidateRule {
/** 規則ID */
id: string;
/** 觸發條件 */
conditions: {
magic_family?: string;
has_alpha?: boolean;
is_animation?: boolean;
input_ext?: string | string[];
min_megapixels?: number;
max_megapixels?: number;
color_mode?: string;
};
/** 候選格式及其先驗權重 (0-1) */
candidates: Record<string, number>;
/** 規則原因碼 */
reason_code: string;
/** 規則優先級 (數字越大優先級越高) */
priority: number;
}
/**
*
*
*/
export const formatCandidateRules: FormatCandidateRule[] = [
// ==================== 圖片格式規則 ====================
{
id: "image_alpha",
conditions: { magic_family: "image", has_alpha: true },
candidates: {
png: 1.0,
webp: 0.9,
avif: 0.7,
tiff: 0.4,
gif: 0.3,
},
reason_code: "IMG_ALPHA",
priority: 100,
},
{
id: "image_animation",
conditions: { magic_family: "image", is_animation: true },
candidates: {
gif: 1.0,
webp: 0.9,
mp4: 0.6,
apng: 0.5,
},
reason_code: "IMG_ANIM",
priority: 100,
},
{
id: "image_high_res",
conditions: { magic_family: "image", min_megapixels: 10 },
candidates: {
webp: 1.0,
avif: 0.95,
jpeg: 0.85,
jxl: 0.8,
png: 0.6,
heif: 0.5,
},
reason_code: "IMG_HIGHRES",
priority: 90,
},
{
id: "image_photo_raw",
conditions: {
magic_family: "image",
input_ext: ["raw", "cr2", "cr3", "nef", "arw", "dng", "orf", "rw2"],
},
candidates: {
jpeg: 1.0,
png: 0.8,
tiff: 0.7,
webp: 0.6,
},
reason_code: "IMG_RAW",
priority: 95,
},
{
id: "image_vector_input",
conditions: {
magic_family: "image",
input_ext: ["svg", "eps", "ai", "emf", "wmf"],
},
candidates: {
svg: 1.0,
png: 0.9,
pdf: 0.8,
eps: 0.6,
},
reason_code: "IMG_VECTOR",
priority: 95,
},
{
id: "image_icon",
conditions: {
magic_family: "image",
input_ext: ["ico", "icns", "cur"],
},
candidates: {
png: 1.0,
ico: 0.9,
svg: 0.5,
},
reason_code: "IMG_ICON",
priority: 95,
},
{
id: "image_default",
conditions: { magic_family: "image" },
candidates: {
png: 0.9,
jpeg: 0.85,
webp: 0.8,
avif: 0.7,
pdf: 0.4,
gif: 0.3,
tiff: 0.25,
bmp: 0.2,
},
reason_code: "IMG_BASE",
priority: 10,
},
// ==================== 影片格式規則 ====================
{
id: "video_default",
conditions: { magic_family: "video" },
candidates: {
mp4: 1.0,
webm: 0.8,
mkv: 0.7,
avi: 0.5,
mov: 0.5,
gif: 0.4,
mp3: 0.35,
wav: 0.25,
},
reason_code: "VID_BASE",
priority: 10,
},
{
id: "video_high_quality",
conditions: {
magic_family: "video",
input_ext: ["mov", "prores", "mxf"],
},
candidates: {
mp4: 1.0,
mkv: 0.9,
webm: 0.7,
prores: 0.6,
},
reason_code: "VID_HQ",
priority: 80,
},
// ==================== 音訊格式規則 ====================
{
id: "audio_default",
conditions: { magic_family: "audio" },
candidates: {
mp3: 1.0,
wav: 0.8,
flac: 0.7,
aac: 0.65,
ogg: 0.6,
m4a: 0.55,
opus: 0.5,
},
reason_code: "AUD_BASE",
priority: 10,
},
{
id: "audio_lossless",
conditions: {
magic_family: "audio",
input_ext: ["flac", "wav", "alac", "ape", "aiff"],
},
candidates: {
flac: 1.0,
wav: 0.9,
mp3: 0.8,
aac: 0.7,
alac: 0.6,
},
reason_code: "AUD_LOSSLESS",
priority: 80,
},
// ==================== 文件格式規則 ====================
{
id: "document_office",
conditions: {
magic_family: "document",
input_ext: ["doc", "docx", "odt", "rtf"],
},
candidates: {
pdf: 1.0,
docx: 0.9,
odt: 0.7,
txt: 0.5,
html: 0.4,
md: 0.3,
},
reason_code: "DOC_OFFICE",
priority: 80,
},
{
id: "document_spreadsheet",
conditions: {
magic_family: "document",
input_ext: ["xls", "xlsx", "ods", "csv"],
},
candidates: {
xlsx: 1.0,
csv: 0.9,
ods: 0.7,
pdf: 0.6,
json: 0.4,
},
reason_code: "DOC_SHEET",
priority: 80,
},
{
id: "document_presentation",
conditions: {
magic_family: "document",
input_ext: ["ppt", "pptx", "odp", "key"],
},
candidates: {
pdf: 1.0,
pptx: 0.9,
odp: 0.7,
png: 0.5,
},
reason_code: "DOC_PRES",
priority: 80,
},
{
id: "document_pdf",
conditions: {
magic_family: "document",
input_ext: ["pdf"],
},
candidates: {
docx: 0.9,
txt: 0.85,
png: 0.8,
jpeg: 0.7,
html: 0.6,
md: 0.55,
epub: 0.4,
},
reason_code: "DOC_PDF",
priority: 85,
},
{
id: "document_ebook",
conditions: {
magic_family: "document",
input_ext: ["epub", "mobi", "azw3", "fb2"],
},
candidates: {
epub: 1.0,
mobi: 0.9,
pdf: 0.85,
azw3: 0.7,
txt: 0.5,
},
reason_code: "DOC_EBOOK",
priority: 80,
},
{
id: "document_markdown",
conditions: {
magic_family: "document",
input_ext: ["md", "markdown", "rst", "txt"],
},
candidates: {
pdf: 1.0,
html: 0.9,
docx: 0.8,
txt: 0.7,
epub: 0.5,
},
reason_code: "DOC_MD",
priority: 80,
},
{
id: "document_default",
conditions: { magic_family: "document" },
candidates: {
pdf: 1.0,
docx: 0.8,
txt: 0.7,
html: 0.5,
},
reason_code: "DOC_BASE",
priority: 10,
},
// ==================== 資料格式規則 ====================
{
id: "data_json",
conditions: {
magic_family: "data",
input_ext: ["json"],
},
candidates: {
yaml: 1.0,
xml: 0.8,
csv: 0.7,
toml: 0.6,
},
reason_code: "DATA_JSON",
priority: 80,
},
{
id: "data_yaml",
conditions: {
magic_family: "data",
input_ext: ["yaml", "yml"],
},
candidates: {
json: 1.0,
xml: 0.7,
toml: 0.6,
},
reason_code: "DATA_YAML",
priority: 80,
},
{
id: "data_xml",
conditions: {
magic_family: "data",
input_ext: ["xml"],
},
candidates: {
json: 1.0,
yaml: 0.8,
csv: 0.5,
},
reason_code: "DATA_XML",
priority: 80,
},
{
id: "data_default",
conditions: { magic_family: "data" },
candidates: {
json: 1.0,
csv: 0.8,
yaml: 0.7,
xml: 0.6,
},
reason_code: "DATA_BASE",
priority: 10,
},
// ==================== 3D 模型格式規則 ====================
{
id: "model3d_default",
conditions: { magic_family: "model3d" },
candidates: {
obj: 1.0,
fbx: 0.9,
gltf: 0.85,
glb: 0.8,
stl: 0.7,
dae: 0.6,
ply: 0.5,
},
reason_code: "3D_BASE",
priority: 10,
},
// ==================== 壓縮檔規則 ====================
{
id: "archive_default",
conditions: { magic_family: "archive" },
candidates: {
zip: 1.0,
tar: 0.8,
"7z": 0.75,
rar: 0.6,
},
reason_code: "ARC_BASE",
priority: 10,
},
// ==================== 字型格式規則 ====================
{
id: "font_default",
conditions: { magic_family: "font" },
candidates: {
woff2: 1.0,
woff: 0.9,
ttf: 0.85,
otf: 0.8,
eot: 0.5,
},
reason_code: "FONT_BASE",
priority: 10,
},
];
/**
*
* @param features
* @returns
*/
export function generateCandidates(features: {
input_ext: string;
magic_family: string;
has_alpha?: boolean;
is_animation?: boolean;
megapixels?: number;
color_mode?: string;
}): { candidates: Record<string, number>; matched_rules: string[] } {
const sortedRules = [...formatCandidateRules].sort((a, b) => b.priority - a.priority);
const mergedCandidates: Record<string, number> = {};
const matchedRules: string[] = [];
for (const rule of sortedRules) {
if (matchesConditions(features, rule.conditions)) {
matchedRules.push(rule.id);
// 合併候選格式,取較高權重
for (const [format, weight] of Object.entries(rule.candidates)) {
if (!mergedCandidates[format] || mergedCandidates[format] < weight) {
mergedCandidates[format] = weight;
}
}
}
}
// 排除自身格式
delete mergedCandidates[features.input_ext];
return { candidates: mergedCandidates, matched_rules: matchedRules };
}
/**
*
*/
function matchesConditions(
features: {
input_ext: string;
magic_family: string;
has_alpha?: boolean;
is_animation?: boolean;
megapixels?: number;
color_mode?: string;
},
conditions: FormatCandidateRule["conditions"],
): boolean {
// 檢查 magic_family
if (conditions.magic_family && features.magic_family !== conditions.magic_family) {
return false;
}
// 檢查 has_alpha
if (conditions.has_alpha !== undefined && features.has_alpha !== conditions.has_alpha) {
return false;
}
// 檢查 is_animation
if (conditions.is_animation !== undefined && features.is_animation !== conditions.is_animation) {
return false;
}
// 檢查 input_ext
if (conditions.input_ext) {
const extList = Array.isArray(conditions.input_ext)
? conditions.input_ext
: [conditions.input_ext];
if (!extList.includes(features.input_ext)) {
return false;
}
}
// 檢查 min_megapixels
if (conditions.min_megapixels !== undefined) {
if (!features.megapixels || features.megapixels < conditions.min_megapixels) {
return false;
}
}
// 檢查 max_megapixels
if (conditions.max_megapixels !== undefined) {
if (!features.megapixels || features.megapixels > conditions.max_megapixels) {
return false;
}
}
// 檢查 color_mode
if (conditions.color_mode && features.color_mode !== conditions.color_mode) {
return false;
}
return true;
}

View file

@ -0,0 +1,325 @@
/**
*
*
* 使
* 使 +
*/
import { generateCandidates } from "./formatCandidateRules";
import type { FileFeatures } from "./featureExtraction";
import type { UserProfile, FormatConversionStats } from "./behaviorStore";
/**
*
*/
export interface FormatPrediction {
/** 預測的搜尋格式 */
search_format: string;
/** 預測信心度 (0-1) */
confidence: number;
/** Top-K 候選格式 */
top_k: Array<{ format: string; score: number }>;
/** 預測原因碼 */
reason_codes: string[];
}
/**
*
*/
export interface ModelWeights {
/** 使用者歷史權重 */
user_history_weight: number;
/** 規則先驗權重 */
rule_prior_weight: number;
/** 全域流行度權重 */
global_popularity_weight: number;
/** 最近使用權重 */
recency_weight: number;
/** 檔案特徵權重 */
feature_weight: number;
}
/**
*
*/
const DEFAULT_WEIGHTS: ModelWeights = {
user_history_weight: 0.45,
rule_prior_weight: 0.25,
global_popularity_weight: 0.15,
recency_weight: 0.1,
feature_weight: 0.05,
};
/**
* ()
*/
const DEFAULT_GLOBAL_POPULARITY: Record<string, number> = {
png: 0.85,
jpeg: 0.8,
pdf: 0.75,
mp4: 0.7,
webp: 0.65,
mp3: 0.6,
docx: 0.55,
gif: 0.5,
svg: 0.45,
wav: 0.4,
xlsx: 0.35,
txt: 0.3,
json: 0.25,
};
/**
*
*/
export class FormatPredictionModel {
private weights: ModelWeights;
private globalPopularity: Record<string, number>;
private minConfidenceThreshold: number;
constructor(
weights: Partial<ModelWeights> = {},
globalPopularity: Record<string, number> = DEFAULT_GLOBAL_POPULARITY,
minConfidenceThreshold = 0.4,
) {
this.weights = { ...DEFAULT_WEIGHTS, ...weights };
this.globalPopularity = globalPopularity;
this.minConfidenceThreshold = minConfidenceThreshold;
}
/**
*
*/
predict(
features: FileFeatures,
userProfile: UserProfile | null,
globalStats?: FormatConversionStats,
): FormatPrediction | null {
// 第一步:生成候選格式
const candidateInput: {
input_ext: string;
magic_family: string;
has_alpha?: boolean;
is_animation?: boolean;
megapixels?: number;
color_mode?: string;
} = {
input_ext: features.input_ext,
magic_family: features.magic_family,
};
if (features.image?.has_alpha !== undefined) {
candidateInput.has_alpha = features.image.has_alpha;
}
if (features.image?.is_animation !== undefined) {
candidateInput.is_animation = features.image.is_animation;
}
if (features.image?.megapixels !== undefined) {
candidateInput.megapixels = features.image.megapixels;
}
if (features.image?.color_mode !== undefined) {
candidateInput.color_mode = features.image.color_mode;
}
const { candidates, matched_rules } = generateCandidates(candidateInput);
if (Object.keys(candidates).length === 0) {
return null;
}
// 第二步:計算每個候選格式的得分
const scoredFormats: Array<{
format: string;
score: number;
components: Record<string, number>;
}> = [];
for (const [format, rulePrior] of Object.entries(candidates)) {
const components: Record<string, number> = {};
// 1. 規則先驗分數
components.rule_prior = rulePrior * this.weights.rule_prior_weight;
// 2. 使用者歷史偏好分數
components.user_history =
this.calculateUserHistoryScore(features.input_ext, format, userProfile) *
this.weights.user_history_weight;
// 3. 全域流行度分數
const globalPop =
globalStats?.format_popularity?.[format] ?? this.globalPopularity[format] ?? 0.1;
components.global_popularity = globalPop * this.weights.global_popularity_weight;
// 4. 最近使用分數
components.recency =
this.calculateRecencyScore(format, userProfile) * this.weights.recency_weight;
// 5. 檔案特徵分數
components.feature =
this.calculateFeatureScore(features, format) * this.weights.feature_weight;
// 總分
const totalScore = Object.values(components).reduce((a, b) => a + b, 0);
scoredFormats.push({
format,
score: totalScore,
components,
});
}
// 排序並取 Top-K
scoredFormats.sort((a, b) => b.score - a.score);
const topK = scoredFormats.slice(0, 5).map((s) => ({
format: s.format,
score: Math.round(s.score * 100) / 100,
}));
// 最高分的格式
const topFormat = scoredFormats[0];
if (!topFormat) {
return null;
}
// 計算信心度 (使用 softmax 正規化後的相對優勢)
const confidence = this.calculateConfidence(scoredFormats);
// 如果信心度低於閾值,不推薦
if (confidence < this.minConfidenceThreshold) {
return null;
}
return {
search_format: topFormat.format,
confidence: Math.round(confidence * 100) / 100,
top_k: topK,
reason_codes: matched_rules,
};
}
/**
* 使
*/
private calculateUserHistoryScore(
inputExt: string,
targetFormat: string,
userProfile: UserProfile | null,
): number {
if (!userProfile) return 0;
const conversionKey = `${inputExt}->${targetFormat}`;
const preference = userProfile.format_preferences[conversionKey];
if (preference !== undefined) {
return preference;
}
// 檢查是否有任何以此格式為目標的記錄
let totalToFormat = 0;
let count = 0;
const entries = Object.entries(userProfile.format_preferences) as [string, number][];
for (const [key, value] of entries) {
if (key.endsWith(`->${targetFormat}`)) {
totalToFormat += value;
count++;
}
}
return count > 0 ? (totalToFormat / count) * 0.5 : 0;
}
/**
* 使
*/
private calculateRecencyScore(format: string, userProfile: UserProfile | null): number {
if (!userProfile?.recent_formats) return 0;
const recentFormats = userProfile.recent_formats;
const index = recentFormats.indexOf(format);
if (index === -1) return 0;
// 越近使用的分數越高
const recencyScore = 1 - index / recentFormats.length;
return recencyScore;
}
/**
*
*/
private calculateFeatureScore(features: FileFeatures, format: string): number {
let score = 0.5; // 基礎分
if (features.image) {
// 大圖優先考慮壓縮格式
if (features.image.megapixels > 10) {
if (["webp", "avif", "jxl", "heif"].includes(format)) {
score += 0.2;
}
}
// 有透明通道優先 PNG
if (features.image.has_alpha) {
if (format === "png" || format === "webp") {
score += 0.3;
}
}
// 動畫優先 GIF 或 WebP
if (features.image.is_animation) {
if (["gif", "webp", "apng"].includes(format)) {
score += 0.3;
}
}
}
return Math.min(score, 1);
}
/**
*
*/
private calculateConfidence(scoredFormats: Array<{ format: string; score: number }>): number {
if (scoredFormats.length === 0) return 0;
const first = scoredFormats[0];
if (!first) return 0;
if (scoredFormats.length === 1) return first.score;
const topScore = first.score;
const secondScore = scoredFormats[1]?.score ?? 0;
// 使用相對優勢計算信心度
const gap = topScore - secondScore;
const relativeGap = gap / (topScore + 0.001);
// 結合絕對分數和相對優勢
const confidence = topScore * 0.6 + relativeGap * 0.4;
return Math.min(Math.max(confidence, 0), 1);
}
/**
* ()
*/
updateWeights(newWeights: Partial<ModelWeights>): void {
this.weights = { ...this.weights, ...newWeights };
}
/**
*
*/
updateGlobalPopularity(popularity: Record<string, number>): void {
this.globalPopularity = { ...this.globalPopularity, ...popularity };
}
/**
*
*/
setMinConfidenceThreshold(threshold: number): void {
this.minConfidenceThreshold = threshold;
}
}
// 導出單例
export const formatPredictionModel = new FormatPredictionModel();

14
src/inference/index.ts Normal file
View file

@ -0,0 +1,14 @@
/**
*
*/
export * from "./featureExtraction";
export * from "./formatCandidateRules";
export * from "./formatPredictionModel";
export * from "./enginePredictionModel";
export * from "./behaviorStore";
export * from "./engineWarmup";
export * from "./inferenceService";
// 預設導出推斷服務
export { inferenceService as default } from "./inferenceService";

View file

@ -0,0 +1,343 @@
/**
*
*
* API
*/
import {
extractFeatures,
extractFeaturesFromExtension,
type FileFeatures,
} from "./featureExtraction";
import {
FormatPredictionModel,
formatPredictionModel,
type FormatPrediction,
} from "./formatPredictionModel";
import {
EnginePredictionModel,
enginePredictionModel,
type EnginePrediction,
} from "./enginePredictionModel";
import {
initBehaviorTables,
getUserProfile,
logConversionEvent,
logDismissEvent,
calculateGlobalStats,
cleanupOldEvents,
type UserProfile,
type FormatConversionStats,
} from "./behaviorStore";
import { engineWarmupManager, type WarmupStatus } from "./engineWarmup";
/**
*
*/
export interface InferenceResult {
/** 格式推斷結果 */
format: FormatPrediction | null;
/** 引擎推斷結果 */
engine: EnginePrediction | null;
/** 檔案特徵 */
features: FileFeatures;
/** 是否應自動填入 */
should_auto_fill: boolean;
/** 預調用狀態 */
warmup_status?: WarmupStatus | null;
}
/**
*
*/
export interface InferenceServiceConfig {
/** 格式推斷最低信心度閾值 */
formatConfidenceThreshold: number;
/** 引擎推斷最低信心度閾值 */
engineConfidenceThreshold: number;
/** 是否啟用預調用 */
enableWarmup: boolean;
/** 預調用最低信心度閾值 */
warmupConfidenceThreshold: number;
}
/**
*
*/
const DEFAULT_CONFIG: InferenceServiceConfig = {
formatConfidenceThreshold: 0.4,
engineConfidenceThreshold: 0.5,
enableWarmup: true,
warmupConfidenceThreshold: 0.7,
};
/**
*
*/
export class InferenceService {
private config: InferenceServiceConfig;
private formatModel: FormatPredictionModel;
private engineModel: EnginePredictionModel;
private globalStats: FormatConversionStats | null = null;
private initialized = false;
constructor(config: Partial<InferenceServiceConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
this.formatModel = formatPredictionModel;
this.engineModel = enginePredictionModel;
// 設定模型閾值
this.formatModel.setMinConfidenceThreshold(this.config.formatConfidenceThreshold);
}
/**
*
*/
async initialize(): Promise<void> {
if (this.initialized) return;
try {
// 初始化行為資料表
initBehaviorTables();
// 載入全域統計
this.globalStats = calculateGlobalStats();
// 更新格式模型的流行度
if (this.globalStats?.format_popularity) {
this.formatModel.updateGlobalPopularity(this.globalStats.format_popularity);
}
this.initialized = true;
console.log("✅ Inference service initialized");
} catch (error) {
console.error("Failed to initialize inference service:", error);
throw error;
}
}
/**
*
*/
async inferFromFile(
filePath: string,
userId: number,
availableEngines?: string[],
): Promise<InferenceResult> {
// 確保已初始化
if (!this.initialized) {
await this.initialize();
}
// 提取檔案特徵
const features = await extractFeatures(filePath);
return this.inferFromFeatures(features, userId, availableEngines);
}
/**
* ()
*/
async inferFromExtension(
ext: string,
userId: number,
fileSizeKb?: number,
availableEngines?: string[],
): Promise<InferenceResult> {
// 確保已初始化
if (!this.initialized) {
await this.initialize();
}
// 從副檔名提取基礎特徵
const baseFeatures = extractFeaturesFromExtension(ext);
const features: FileFeatures = {
...baseFeatures,
file_size_kb: fileSizeKb ?? 0,
};
return this.inferFromFeatures(features, userId, availableEngines);
}
/**
*
*/
private async inferFromFeatures(
features: FileFeatures,
userId: number,
availableEngines?: string[],
): Promise<InferenceResult> {
// 取得使用者 Profile
const userProfile = getUserProfile(userId);
// 格式推斷
const formatPrediction = this.formatModel.predict(
features,
userProfile,
this.globalStats ?? undefined,
);
// 引擎推斷 (如果有格式預測)
let enginePrediction: EnginePrediction | null = null;
if (formatPrediction) {
enginePrediction = this.engineModel.predict(
formatPrediction.search_format,
features,
userProfile,
availableEngines,
);
}
// 決定是否自動填入
const shouldAutoFill =
formatPrediction !== null &&
formatPrediction.confidence >= this.config.formatConfidenceThreshold;
// 預調用處理
let warmupStatus: WarmupStatus | null = null;
if (
this.config.enableWarmup &&
enginePrediction?.should_warmup &&
enginePrediction.confidence >= this.config.warmupConfidenceThreshold
) {
// 啟動預調用 (非阻塞)
engineWarmupManager.warmup(enginePrediction.engine).catch(console.error);
warmupStatus = engineWarmupManager.getStatus();
}
return {
format: formatPrediction,
engine: enginePrediction,
features,
should_auto_fill: shouldAutoFill,
warmup_status: warmupStatus,
};
}
/**
* 調 (使)
*/
cancelWarmup(): void {
engineWarmupManager.cancel();
}
/**
*
*/
logConversion(params: {
userId: number;
inputExt: string;
searchedFormat: string;
selectedEngine: string;
success: boolean;
durationMs: number;
fileSizeKb?: number;
megapixels?: number;
}): void {
const eventData: Parameters<typeof logConversionEvent>[0] = {
user_id: params.userId,
input_ext: params.inputExt,
searched_format: params.searchedFormat,
selected_engine: params.selectedEngine,
success: params.success,
duration_ms: params.durationMs,
};
if (params.fileSizeKb !== undefined) {
eventData.file_size_kb = params.fileSizeKb;
}
if (params.megapixels !== undefined) {
eventData.megapixels = params.megapixels;
}
logConversionEvent(eventData);
// 定期更新全域統計 (每 100 次轉檔)
this.maybeRefreshGlobalStats();
}
/**
*
*/
logDismiss(params: {
userId: number;
inputExt: string;
dismissedFormat: string;
dismissedEngine?: string;
}): void {
const eventData: Parameters<typeof logDismissEvent>[0] = {
user_id: params.userId,
input_ext: params.inputExt,
dismissed_format: params.dismissedFormat,
};
if (params.dismissedEngine !== undefined) {
eventData.dismissed_engine = params.dismissedEngine;
}
logDismissEvent(eventData);
// 取消預調用
this.cancelWarmup();
}
/**
* 使 Profile
*/
getUserProfile(userId: number): UserProfile | null {
return getUserProfile(userId);
}
/**
*
*/
private refreshCounter = 0;
private maybeRefreshGlobalStats(): void {
this.refreshCounter++;
if (this.refreshCounter >= 100) {
this.refreshCounter = 0;
setTimeout(() => {
try {
this.globalStats = calculateGlobalStats();
if (this.globalStats?.format_popularity) {
this.formatModel.updateGlobalPopularity(this.globalStats.format_popularity);
}
} catch (error) {
console.error("Failed to refresh global stats:", error);
}
}, 100);
}
}
/**
*
*/
cleanup(daysToKeep = 90): number {
return cleanupOldEvents(daysToKeep);
}
/**
*
*/
updateConfig(config: Partial<InferenceServiceConfig>): void {
this.config = { ...this.config, ...config };
this.formatModel.setMinConfidenceThreshold(this.config.formatConfidenceThreshold);
}
/**
* 調
*/
isEngineReady(engine: string): boolean {
return engineWarmupManager.isReady(engine);
}
/**
* 調
*/
getWarmupStatus(): WarmupStatus | null {
return engineWarmupManager.getStatus();
}
}
// 導出單例
export const inferenceService = new InferenceService();

View file

@ -8,6 +8,7 @@ import { Jobs } from "../db/types";
import { WEBROOT } from "../helpers/env"; import { WEBROOT } from "../helpers/env";
import { normalizeFiletype } from "../helpers/normalizeFiletype"; import { normalizeFiletype } from "../helpers/normalizeFiletype";
import { userService } from "./user"; import { userService } from "./user";
import { inferenceService } from "../inference";
export const convert = new Elysia().use(userService).post( export const convert = new Elysia().use(userService).post(
"/convert", "/convert",
@ -67,6 +68,12 @@ export const convert = new Elysia().use(userService).post(
); );
// Start the conversion process in the background // Start the conversion process in the background
// 記錄轉檔開始時間
const conversionStartTime = Date.now();
// 取得輸入檔案的副檔名 (從第一個檔案)
const inputExt = fileNames[0]?.split(".").pop()?.toLowerCase() ?? "";
handleConvert(fileNames, userUploadsDir, userOutputDir, convertTo, converterName, jobId) handleConvert(fileNames, userUploadsDir, userOutputDir, convertTo, converterName, jobId)
.then(() => { .then(() => {
// All conversions are done, update the job status to 'completed' // All conversions are done, update the job status to 'completed'
@ -74,11 +81,41 @@ export const convert = new Elysia().use(userService).post(
db.query("UPDATE jobs SET status = 'completed' WHERE id = ?1").run(jobId.value); db.query("UPDATE jobs SET status = 'completed' WHERE id = ?1").run(jobId.value);
} }
// 記錄轉檔行為 (用於推斷學習)
try {
const durationMs = Date.now() - conversionStartTime;
inferenceService.logConversion({
userId: parseInt(user.id, 10),
inputExt: inputExt,
searchedFormat: convertTo,
selectedEngine: converterName,
success: true,
durationMs: durationMs,
});
} catch (logError) {
console.warn("Failed to log conversion event:", logError);
}
// Delete all uploaded files in userUploadsDir // Delete all uploaded files in userUploadsDir
// rmSync(userUploadsDir, { recursive: true, force: true }); // rmSync(userUploadsDir, { recursive: true, force: true });
}) })
.catch((error) => { .catch((error) => {
console.error("Error in conversion process:", error); console.error("Error in conversion process:", error);
// 記錄失敗的轉檔
try {
const durationMs = Date.now() - conversionStartTime;
inferenceService.logConversion({
userId: parseInt(user.id, 10),
inputExt: inputExt,
searchedFormat: convertTo,
selectedEngine: converterName,
success: false,
durationMs: durationMs,
});
} catch (logError) {
console.warn("Failed to log conversion event:", logError);
}
}); });
// Redirect the client immediately // Redirect the client immediately

139
src/pages/inference.tsx Normal file
View file

@ -0,0 +1,139 @@
/**
* API
*
* API
*/
import { Elysia, t } from "elysia";
import { inferenceService } from "../inference";
export const inferenceApi = new Elysia({ prefix: "/inference" })
/**
*
*/
.post(
"/predict",
async ({ body, cookie: { user_id } }) => {
const userId = parseInt(String(user_id?.value ?? "0"), 10);
try {
const result = await inferenceService.inferFromExtension(
body.ext,
userId,
body.file_size_kb,
body.available_engines,
);
return {
success: true,
data: {
format: result.format,
engine: result.engine,
should_auto_fill: result.should_auto_fill,
warmup_status: result.warmup_status,
},
};
} catch (error) {
console.error("Inference prediction error:", error);
return {
success: false,
error: "Failed to predict format",
};
}
},
{
body: t.Object({
ext: t.String({ description: "輸入檔案副檔名" }),
file_size_kb: t.Optional(t.Number({ description: "檔案大小 (KB)" })),
available_engines: t.Optional(t.Array(t.String(), { description: "可用引擎列表" })),
}),
},
)
/**
*
*/
.post(
"/dismiss",
async ({ body, cookie: { user_id } }) => {
const userId = parseInt(String(user_id?.value ?? "0"), 10);
try {
const dismissParams: {
userId: number;
inputExt: string;
dismissedFormat: string;
dismissedEngine?: string;
} = {
userId,
inputExt: body.input_ext,
dismissedFormat: body.dismissed_format,
};
if (body.dismissed_engine !== undefined) {
dismissParams.dismissedEngine = body.dismissed_engine;
}
inferenceService.logDismiss(dismissParams);
return { success: true };
} catch (error) {
console.error("Failed to log dismiss event:", error);
return { success: false };
}
},
{
body: t.Object({
input_ext: t.String({ description: "輸入檔案副檔名" }),
dismissed_format: t.String({ description: "被拒絕的推薦格式" }),
dismissed_engine: t.Optional(t.String({ description: "被拒絕的推薦引擎" })),
}),
},
)
/**
* 調
*/
.post("/cancel-warmup", () => {
try {
inferenceService.cancelWarmup();
return { success: true };
} catch (error) {
console.error("Failed to cancel warmup:", error);
return { success: false };
}
})
/**
* 調
*/
.get("/warmup-status", () => {
try {
const status = inferenceService.getWarmupStatus();
return {
success: true,
data: status,
};
} catch (error) {
console.error("Failed to get warmup status:", error);
return { success: false, data: null };
}
})
/**
* 使 Profile
*/
.get("/profile", ({ cookie: { user_id } }) => {
const userId = parseInt(String(user_id?.value ?? "0"), 10);
try {
const profile = inferenceService.getUserProfile(userId);
return {
success: true,
data: profile,
};
} catch (error) {
console.error("Failed to get user profile:", error);
return { success: false, data: null };
}
});