diff --git a/config.py b/config.py index 3edee47..438aeaf 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.614" +SYSTEM_VERSION = "V10.615" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index 5c566ed..fc31cf2 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-06-16 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯;PChome 後台業績匯入韌性已補強;產品定位正名為「PChome 業績成長自動化作戰系統」;外部市場來源正規化層、自動同步、作戰清單與價格參考表優先讀取、CSV 備援預檢、前台操作入口與高可見頁面繁中化守門已建立 -> **適用版本**: V10.614 +> **適用版本**: V10.615 --- @@ -61,6 +61,7 @@ - V10.612 起 `/api/ai/icaim/dashboard` 的「MOMO 外部價格參考」表格也優先讀 `external_offers`,缺資料才 fallback `competitor_prices`;價差與風險改採 PChome 視角,正數代表 PChome 比 MOMO 外部參考價高。 - V10.613 起高可見前台頁面必須以繁體中文呈現:程式碼審查、AI 自動化健康檢查、PPT 產線與商品看板操作標籤不得使用英文工程標題或簡體字;測試需防止頁面文案退回英文。 - V10.614 起部署監控、基礎設施生命線與 PPT 產線狀態也納入繁中守門:前台不得顯示 `Dashboard`、`Pipeline`、`Runtime` 等工程詞,動態階段需轉成「測試 / 建置 / 部署」。 +- V10.615 起 AI 智慧推薦頁必須把 Ollama 顯示為「Ollama 主路徑」,Gemini 只能顯示為「Gemini 備援」且手動選項停用;使用者可見錯誤與搜尋流程不得出現 `Web Search`、`Token:`、半形英文冒號等工程文案。 ## 零之一、12 Agent 決策信封(2026-05-24) diff --git a/docs/memory/current_execution_queue_20260524.md b/docs/memory/current_execution_queue_20260524.md index f6a0155..f3b13ce 100644 --- a/docs/memory/current_execution_queue_20260524.md +++ b/docs/memory/current_execution_queue_20260524.md @@ -239,3 +239,9 @@ - `/cicd` 對使用者顯示為「部署監控」,不再以前台標題顯示 `CI/CD Dashboard`;部署流程、部署歷史、GitLab 部署紀錄皆使用白話繁中。 - 部署流程圖會把後端階段代碼 `test / build / deploy` 轉成「測試 / 建置 / 部署」,診斷狀態也轉成「正常 / 注意 / 失敗」。 - `/observability/host_health` 與 PPT 產線視覺狀態把 `Runtime` / `Vision QA` 改成「執行環境」與「視覺檢查」,並更新測試防回歸。 + +## 18. 2026-06-16 V10.615 AI 智慧推薦頁 Ollama 主路徑文案 + +- `/ai_recommend` 的 AI 路徑顯示改成「Ollama 主路徑 / Gemini 備援」,Gemini 選項保留為角色提示但停用手動選擇,避免使用者誤以為可直接用 Gemini 生成文案。 +- `page-ai-recommend.js` 的狀態 badge、生成結果 meta、搜尋/分析錯誤訊息改用繁中全形冒號與「權杖」用語。 +- 新增測試守門:禁止 `Ollama (本地)`、`Gemini (雲端)`、`Web Search`、`Token:` 與半形英文錯誤前綴回到 AI 智慧推薦頁。 diff --git a/routes/ai_routes.py b/routes/ai_routes.py index 804b13a..df052bf 100644 --- a/routes/ai_routes.py +++ b/routes/ai_routes.py @@ -80,7 +80,7 @@ def _safe_primary_provider(provider: str | None) -> str: def _safe_recommended_provider(provider: str | None) -> str: - """Initial render must never advertise Gemini as a primary provider.""" + """首屏不得把 Gemini 顯示成主要提供者。""" normalized = _safe_primary_provider(provider) return normalized if normalized in ('ollama', 'elephant') else 'none' @@ -175,14 +175,14 @@ def api_set_provider(): 'error': 'Gemini 僅可作為 Ollama 失敗備援,不可設為預設提供者' }), 400 if provider not in ('ollama',): - return jsonify({'success': False, 'error': '無效的提供者,請使用 ollama'}), 400 + return jsonify({'success': False, 'error': '無效的提供者,請使用 Ollama 主路徑'}), 400 success = set_ai_provider(provider) if success: - logger.info(f"AI 提供者已切換至: {provider}") + logger.info("AI 提供者已切換至 Ollama 主路徑") return jsonify({ 'success': True, - 'message': f'已切換至 {provider.upper()}', + 'message': '已切換至 Ollama 主路徑', 'provider': provider }) else: @@ -714,7 +714,7 @@ def api_analyze_weather_products(): return jsonify({'success': False, 'error': result.error}), 500 except Exception as e: - logger.error(f"天氣商品分析失敗: {e}") + logger.error(f"天氣商品分析失敗:{e}") return jsonify({'success': False, 'error': str(e)}), 500 @@ -1174,7 +1174,7 @@ def api_get_mybest_latest(): return jsonify({'success': False, 'error': str(e)}), 500 -# ===== Web Search API ===== +# ===== 網路搜尋 API ===== @ai_bp.route('/api/ai/web_search', methods=['POST']) @login_required @@ -1268,7 +1268,7 @@ def api_web_search(): return jsonify({'success': False, 'error': result.error}), 500 except Exception as e: - logger.error(f"AI 網路搜尋失敗: {e}") + logger.error(f"AI 網路搜尋失敗:{e}") return jsonify({'success': False, 'error': str(e)}), 500 @@ -1356,7 +1356,7 @@ def api_product_insights(): return jsonify({'success': False, 'error': result.error}), 500 except Exception as e: - logger.error(f"商品洞察分析失敗: {e}") + logger.error(f"商品洞察分析失敗:{e}") return jsonify({'success': False, 'error': str(e)}), 500 @@ -1443,7 +1443,7 @@ def api_trend_keywords(): return jsonify({'success': False, 'error': result.error}), 500 except Exception as e: - logger.error(f"趨勢關鍵字搜尋失敗: {e}") + logger.error(f"趨勢關鍵字搜尋失敗:{e}") return jsonify({'success': False, 'error': str(e)}), 500 diff --git a/templates/ai_recommend.html b/templates/ai_recommend.html index 4b1efd0..9dd8d3d 100644 --- a/templates/ai_recommend.html +++ b/templates/ai_recommend.html @@ -7,7 +7,7 @@ {% endblock %} {% block ewooo_content %} -{# Runtime API calls live in page-ai-recommend.js: fetch('/api/ai/generate_copy', ...), fetch('/api/ai/web_search', ...), fetch('/api/ai/product_insights', ...), fetch('/api/ai/gemini_usage?days=30'). #} +{# 頁面執行時會呼叫 page-ai-recommend.js: fetch('/api/ai/generate_copy', ...), fetch('/api/ai/web_search', ...), fetch('/api/ai/product_insights', ...), fetch('/api/ai/gemini_usage?days=30'). #}
@@ -21,10 +21,10 @@
- Ollama {{ '檢查中' if ollama_status is none else ('✓' if ollama_status else '✗') }} + Ollama 主路徑 {{ '檢查中' if ollama_status is none else ('✓' if ollama_status else '✗') }} - Gemini {{ '檢查中' if gemini_status is none else ('✓' if gemini_status else '✗') }} + Gemini 備援 {{ '檢查中' if gemini_status is none else ('✓' if gemini_status else '✗') }}
- +
@@ -105,13 +105,13 @@ {# Gemini 用量面板 #} diff --git a/tests/test_ai_routes_ollama_first.py b/tests/test_ai_routes_ollama_first.py index c064ad7..51e13c0 100644 --- a/tests/test_ai_routes_ollama_first.py +++ b/tests/test_ai_routes_ollama_first.py @@ -3,6 +3,18 @@ """AI recommendation route Ollama-first display contract.""" +def _client(monkeypatch): + from flask import Flask + import auth + from routes.ai_routes import ai_bp + + monkeypatch.setattr(auth, "DISABLE_LOGIN", True) + app = Flask(__name__) + app.secret_key = "test" + app.register_blueprint(ai_bp) + return app.test_client() + + def test_initial_ai_status_sanitizes_cached_gemini_recommendation(monkeypatch): import routes.ai_routes as ai_routes @@ -37,3 +49,10 @@ def test_initial_ai_status_never_recommends_gemini_without_cache(monkeypatch): assert status["default_provider"] == "ollama" assert status["recommended_provider"] == "ollama" + +def test_set_provider_error_uses_ollama_primary_wording(monkeypatch): + client = _client(monkeypatch) + response = client.post("/api/ai/set_provider", json={"provider": "other"}) + + assert response.status_code == 400 + assert response.get_json()["error"] == "無效的提供者,請使用 Ollama 主路徑" diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index 225803b..5d22ffc 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -503,6 +503,7 @@ def test_ai_history_uses_v2_shell_and_real_history_apis(): def test_ai_recommend_uses_v2_shell_and_runtime_category_data(): template = (ROOT / "templates/ai_recommend.html").read_text(encoding="utf-8") + page_js = (ROOT / "web/static/js/page-ai-recommend.js").read_text(encoding="utf-8") route_source = (ROOT / "routes/ai_routes.py").read_text(encoding="utf-8") assert "{% extends 'ewoooc_base.html' %}" in template @@ -519,6 +520,32 @@ def test_ai_recommend_uses_v2_shell_and_runtime_category_data(): assert "fetch('/api/ai/gemini_usage?days=30')" in template assert "mock" not in template.lower() assert "假商品" not in template + assert "Ollama 主路徑" in template + assert "Gemini 備援" in template + assert "Gemini 備援(系統自動,不可手動選)" in template + assert "disabled>☁️ Gemini 備援" in template + assert "權杖:" in template + assert "Ollama 主路徑" in page_js + assert "Gemini 備援" in page_js + assert "搜尋失敗:" in page_js + assert "分析失敗:" in page_js + + forbidden_visible_text = [ + "🖥️ Ollama (本地)", + "☁️ Gemini (雲端)", + "Web Search 功能", + "渲染 Web Search", + "整合 Web Search", + "Token:", + "費用:", + "生成失敗:", + "發生錯誤:", + "搜尋失敗:", + "分析失敗:", + ] + combined = template + "\n" + page_js + for marker in forbidden_visible_text: + assert marker not in combined assert "@ai_bp.route('/ai_recommend')" in route_source assert "render_template('ai_recommend.html'" in route_source diff --git a/web/static/js/page-ai-recommend.js b/web/static/js/page-ai-recommend.js index 57c4a31..27bee5f 100644 --- a/web/static/js/page-ai-recommend.js +++ b/web/static/js/page-ai-recommend.js @@ -283,8 +283,8 @@ .then(data => { if (!data.success || !data.data) return; const status = data.data; - updateAIStatusBadge('ollamaStatus', 'fas fa-server', 'Ollama', status.ollama?.connected, 'ar-status--ok'); - updateAIStatusBadge('geminiStatus', 'fab fa-google', 'Gemini', status.gemini?.connected, 'ar-status--info'); + updateAIStatusBadge('ollamaStatus', 'fas fa-server', 'Ollama 主路徑', status.ollama?.connected, 'ar-status--ok'); + updateAIStatusBadge('geminiStatus', 'fab fa-google', 'Gemini 備援', status.gemini?.connected, 'ar-status--info'); updateOllamaModels(status.ollama?.available_models || []); }) .catch(e => console.warn('AI 狀態刷新失敗:', e)); @@ -422,13 +422,13 @@ document.getElementById('generatedCopy').innerHTML = formattedCopy; // 組合元資料顯示 - let metaHtml = `${data.data.provider === 'gemini' ? 'Gemini' : 'Ollama'}: ${data.data.model}`; - metaHtml += ` | 耗時: ${data.data.duration}秒`; + let metaHtml = `${data.data.provider === 'gemini' ? 'Gemini 備援' : 'Ollama 主路徑'}:${data.data.model}`; + metaHtml += ` | 耗時:${data.data.duration}秒`; // 如果是 Gemini,顯示費用 if (data.data.provider === 'gemini' && data.data.cost) { - metaHtml += ` | 費用: $${data.data.cost.total.toFixed(4)}`; - metaHtml += ` | Token: ${data.data.tokens.total}`; + metaHtml += ` | 費用:$${data.data.cost.total.toFixed(4)}`; + metaHtml += ` | 權杖:${data.data.tokens.total}`; // 刷新使用量面板 loadGeminiUsage(); } @@ -437,7 +437,7 @@ document.getElementById('resultArea').style.display = 'block'; document.getElementById('resultArea').scrollIntoView({ behavior: 'smooth' }); } else { - alert('生成失敗: ' + data.error); + alert('生成失敗:' + data.error); } }) .catch(e => { @@ -447,7 +447,7 @@ btn.innerHTML = originalBtnText; btn.classList.remove('btn-secondary'); btn.classList.add('btn-primary'); - alert('發生錯誤: ' + e.message); + alert('發生錯誤:' + e.message); }); } @@ -508,7 +508,7 @@ document.getElementById('loadingOverlay').classList.add('d-none'); } - // ===== Web Search 功能 ===== + // ===== 網路搜尋功能 ===== // 快速搜尋 function quickWebSearch(query) { @@ -580,12 +580,12 @@ if (e.name === 'AbortError') { contentArea.innerHTML = `
AI 伺服器回應較慢,請稍後再試。
`; } else { - contentArea.innerHTML = `
搜尋失敗: ${e.message}
`; + contentArea.innerHTML = `
搜尋失敗:${e.message}
`; } }); } - // 渲染 Web Search 結果 - 卡片式顯示 + // 渲染網路搜尋結果 - 卡片式顯示 function renderWebSearchResult(data) { const contentArea = document.getElementById('webSearchContent'); let html = ''; @@ -700,7 +700,7 @@ contentArea.innerHTML = html; } - // 商品洞察分析 - 整合 Web Search + // 商品洞察分析 - 整合網路搜尋 function doProductInsights() { const productName = document.getElementById('productName').value.trim(); if (!productName) { @@ -734,7 +734,7 @@ const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 240000); - // 步驟 1: 先進行 Web Search 取得最新資訊 + // 步驟 1: 先進行網路搜尋取得最新資訊 fetch('/api/ai/web_search', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken }, @@ -811,7 +811,7 @@ if (e.name === 'AbortError') { resultArea.innerHTML = `
AI 伺服器回應較慢,請稍後再試。
`; } else { - resultArea.innerHTML = `
分析失敗: ${e.message}
`; + resultArea.innerHTML = `
分析失敗:${e.message}
`; } }); }