V10.615 AI 推薦頁 Ollama 主路徑文案
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s
This commit is contained in:
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 智慧推薦頁。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 主路徑"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user