V10.615 AI 推薦頁 Ollama 主路徑文案
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s

This commit is contained in:
OoO
2026-06-16 10:16:23 +08:00
parent 32b7071ab6
commit eb521fd6d8
8 changed files with 88 additions and 35 deletions

View File

@@ -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 # 用於模板顯示

View File

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

View File

@@ -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 智慧推薦頁。

View File

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

View File

@@ -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'). #}
<div class="momo-app" data-page-group="ai">
<div class="ai-recommend-page">
@@ -21,10 +21,10 @@
</div>
<div class="ar-hero__actions">
<span id="ollamaStatus" class="ar-status {{ 'ar-status--ok' if ollama_status else 'ar-status--off' }}">
<i class="fas fa-server"></i> Ollama {{ '檢查中' if ollama_status is none else ('✓' if ollama_status else '✗') }}
<i class="fas fa-server"></i> Ollama 主路徑 {{ '檢查中' if ollama_status is none else ('✓' if ollama_status else '✗') }}
</span>
<span id="geminiStatus" class="ar-status {{ 'ar-status--info' if gemini_status else 'ar-status--off' }}">
<i class="fab fa-google"></i> Gemini {{ '檢查中' if gemini_status is none else ('✓' if gemini_status else '✗') }}
<i class="fab fa-google"></i> Gemini 備援 {{ '檢查中' if gemini_status is none else ('✓' if gemini_status else '✗') }}
</span>
<button class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#helpModal" title="使用說明">
<i class="fas fa-question-circle"></i>
@@ -81,10 +81,10 @@
</select>
</div>
<div class="col-4">
<label class="form-label small fw-bold mb-1">AI 引擎</label>
<label class="form-label small fw-bold mb-1">AI 路徑</label>
<select class="form-select form-select-sm" id="aiProvider" onchange="onProviderChange()">
<option value="ollama" {% if default_provider == 'ollama' %}selected{% endif %}>🖥️ Ollama (本地)</option>
<option value="gemini" {% if default_provider == 'gemini' %}selected{% endif %}>☁️ Gemini (雲端)</option>
<option value="ollama" {% if default_provider == 'ollama' %}selected{% endif %}>🖥️ Ollama 主路徑</option>
<option value="gemini" disabled>☁️ Gemini 備援(系統自動,不可手動選)</option>
</select>
</div>
<div class="col-4">
@@ -105,13 +105,13 @@
{# Gemini 用量面板 #}
<div id="geminiUsagePanel" class="alert alert-info py-2 mb-3" style="display: none;">
<div class="d-flex justify-content-between align-items-center">
<small class="fw-bold"><i class="fab fa-google me-1"></i>Gemini 本月使用量</small>
<small class="fw-bold"><i class="fab fa-google me-1"></i>Gemini 備援本月使用量</small>
<button type="button" class="btn btn-link btn-sm p-0" onclick="loadGeminiUsage()"><i class="fas fa-sync-alt"></i></button>
</div>
<div class="d-flex justify-content-between small mt-1">
<span>費用: <strong id="geminiMonthlyCost">$0.0000</strong></span>
<span>請求: <span id="geminiRequestCount">0</span></span>
<span>Token: <span id="geminiTokenUsage">0</span></span>
<span>費用<strong id="geminiMonthlyCost">$0.0000</strong></span>
<span>請求<span id="geminiRequestCount">0</span></span>
<span>權杖:<span id="geminiTokenUsage">0</span></span>
</div>
</div>

View File

@@ -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 主路徑"

View File

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

View File

@@ -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 = `<i class="fas fa-robot me-1"></i>${data.data.provider === 'gemini' ? 'Gemini' : 'Ollama'}: ${data.data.model}`;
metaHtml += ` | <i class="fas fa-clock me-1"></i>耗時: ${data.data.duration}`;
let metaHtml = `<i class="fas fa-robot me-1"></i>${data.data.provider === 'gemini' ? 'Gemini 備援' : 'Ollama 主路徑'}${data.data.model}`;
metaHtml += ` | <i class="fas fa-clock me-1"></i>耗時${data.data.duration}`;
// 如果是 Gemini顯示費用
if (data.data.provider === 'gemini' && data.data.cost) {
metaHtml += ` | <i class="fas fa-coins me-1"></i>費用: $${data.data.cost.total.toFixed(4)}`;
metaHtml += ` | Token: ${data.data.tokens.total}`;
metaHtml += ` | <i class="fas fa-coins me-1"></i>費用$${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 = `<div class="alert alert-warning py-2 mb-0"><i class="fas fa-clock me-1"></i>AI 伺服器回應較慢,請稍後再試。</div>`;
} else {
contentArea.innerHTML = `<div class="alert alert-danger py-2 mb-0">搜尋失敗: ${e.message}</div>`;
contentArea.innerHTML = `<div class="alert alert-danger py-2 mb-0">搜尋失敗${e.message}</div>`;
}
});
}
// 渲染 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 = `<div class="alert alert-warning py-2 mb-0"><i class="fas fa-clock me-1"></i>AI 伺服器回應較慢,請稍後再試。</div>`;
} else {
resultArea.innerHTML = `<div class="alert alert-danger py-2 mb-0">分析失敗: ${e.message}</div>`;
resultArea.innerHTML = `<div class="alert alert-danger py-2 mb-0">分析失敗${e.message}</div>`;
}
});
}