This commit is contained in:
@@ -140,6 +140,12 @@
|
||||
- ❌ **禁止**: 使用 mock data、假商品、假 KPI、假排程、假使用者、假頁面或純展示用 placeholder 冒充已完成。
|
||||
- ❌ **禁止**: 為了符合原型畫面而改寫或捏造業務數字。
|
||||
|
||||
### 第 14.2 條:前端文案與工作溝通隔離(絕對禁止違反)
|
||||
- ✅ **正確**: 前端頁面只放使用者完成任務所需的產品文案、狀態、操作入口與可診斷錯誤。
|
||||
- ✅ **正確**: 施工紀錄、版本發布說明、AI 工作視窗判斷、Session 溝通、TODO 內容,只能放在文件、日誌或提交訊息,不得搬到使用者可見頁面。
|
||||
- ❌ **禁止**: 在模板、靜態 JS/CSS 可見文案中放入「本輪已完成」「剛剛修正」「Codex/Claude 評估」「V10.x hotfix」「推到 Gitea」等工作視窗溝通內容。
|
||||
- ❌ **禁止**: 用內部工程語氣代替產品語氣,例如把頁面寫成施工報告、交接紀錄或 agent 工作摘要。
|
||||
|
||||
---
|
||||
|
||||
## 第五章:系統架構規範
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
================================================================================
|
||||
|
||||
【已完成】
|
||||
- V10.597 重整 PChome 比價覆核工作台 UX 並補全站巡檢能力:覆核頁不再沿用首頁商品表格,也不再把 `matcher_rescore`、`stored_status`、`rescore_accepted_current`、`HITL`、`COMPLETE` 等內部診斷/狀態碼輸出到前台或 tooltip;改為「商品 / MOMO、PChome 候選、覆核判讀、下一步、紀錄」六欄工作流。同步修正 catalog review status 的前台語義、決策信封中文標籤、局部 1540px 橫向工作台、手機版欄位 label,並把覆核狀態分段列改為自適應 grid,避免 chip 造成桌面/平板/手機視覺溢出;`check_responsive_overflow.js` 改為逐頁輸出、HTTPS context、commit+body ready、timeout 後安全收尾,讓桌面/平板/手機全站 UX 巡檢可追蹤;topbar AI 觀測台 indicator 增加前端 60 秒 session cache / 2.5 秒 abort 與後端 30 秒 cache,避免每頁跳轉重複打 DB 查詢拖慢全站;`market_intel/disabled.html` 從 1MB 大型停用頁改為輕量狀態頁,保留狀態與正式操作入口,避免停用模組拖慢巡檢與使用者操作。
|
||||
- V10.599 重整 PChome 比價覆核工作台 UX 並補全站巡檢能力:覆核頁不再沿用首頁商品表格,也不再把 `matcher_rescore`、`stored_status`、`rescore_accepted_current`、`HITL`、`COMPLETE` 等內部診斷/狀態碼輸出到前台或 tooltip;改為「商品 / MOMO、PChome 候選、覆核判讀、下一步、紀錄」六欄工作流。同步修正 catalog review status 的前台語義、決策信封中文標籤、局部 1540px 橫向工作台、手機版欄位 label,並把覆核狀態分段列改為自適應 grid,避免 chip 造成桌面/平板/手機視覺溢出;`check_responsive_overflow.js` 改為逐頁輸出、HTTPS context、commit+body ready、timeout 後安全收尾,讓桌面/平板/手機全站 UX 巡檢可追蹤;topbar AI 觀測台 indicator 增加前端 60 秒 session cache / 2.5 秒 abort 與後端 30 秒 cache,避免每頁跳轉重複打 DB 查詢拖慢全站;`market_intel/disabled.html` 從 1MB 大型停用頁改為輕量狀態頁,保留狀態與正式操作入口,避免停用模組拖慢巡檢與使用者操作;新增憲法第 14.2 條與測試 guard,禁止把工作視窗溝通、施工紀錄或版本發布說明放到使用者可見前端頁面;ICAIM 競情 API 改為 120 秒短快取、5 秒 PostgreSQL statement timeout、stale 快照降級與 LATERAL 最新價查詢,避免 AI 競情看板重查詢拖慢全站。
|
||||
- V10.584 補 PChome Nick 去重與 stale recovery 單品窄門:`Nick` 先去 HTML / 行銷星號 / 重複品名,避免 `29g`、`100ml` 被同一商品副標重複計數成 `component_count_conflict`;同步新增 NIVEA 妮維雅霜 100ml、Schick 舒綺敏感肌除毛刀片 3 入、TS6 沁涼潔淨慕斯 100g 的具名 exact total-price alignment。IBL 沐浴精+洗髮精 vs 洗髮精仍保留 identity review,唇釉色號/目錄款與 Paula's Choice 效期/金蓋差異仍不自動寫正式價差。
|
||||
- V10.583 補 Paula's Choice 身體乳 PChome Nick 具名 alignment:`2%水楊酸身體乳210ml二入` 可和 PChome `Nick` 補出的 `水楊酸身體乳雙入組 / 210ml x2` 對齊,進 `exact / total_price / price_alert_exact`;但 `118ml二入組(金蓋限定版)` 對上 PChome 效期品仍保留 `manual_review / identity_review`,不泛用放寬中文入數。
|
||||
- V10.582 補 PChome 比價通知專業分級與 Nick 副標身份證據:NemoTron 價格決策信封現在保留 `momo_price`、`competitor_price`、`candidate_gap_pct` 與 `sales_7d_delta_pct`,EventRouter / Telegram 模板會把 `match_type / price_basis / alert_tier` 翻成「直接價格威脅、單位價覆核、身份覆核、壓制告警」與操作邊界;PChome crawler 會保留 `Nick` 副標為 `match_name` 給 matcher 使用,UI/DB 顯示仍維持原品名,讓容量、入數、濃度資訊可參與比對。
|
||||
|
||||
@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
|
||||
# ==========================================
|
||||
# 系統版本與路徑
|
||||
# ==========================================
|
||||
SYSTEM_VERSION = "V10.597"
|
||||
SYSTEM_VERSION = "V10.599"
|
||||
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
|
||||
public_url = PUBLIC_URL # 用於模板顯示
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
- 2026-06-04 起,`V10.582` 補 PChome 比價通知專業分級與 Nick 副標身份證據:NemoTron 決策信封保留 MOMO / PChome 價格、價差與 7 日業績變化;Telegram decision envelope 將 `exact / total_price / price_alert_exact` 等工程路徑翻成直接價格威脅、單位價覆核、身份覆核或壓制告警,並把「單位價/身份未確認不得用總價直接告警」寫進操作邊界。PChome `Nick` 副標會以 `match_name` 參與 matcher,比價可用到容量、入數、濃度資訊,但不改 UI/DB 正式顯示品名。
|
||||
- 2026-06-04 起,`V10.583` 補 Paula's Choice 身體乳 PChome Nick 具名 alignment:`2%水楊酸身體乳210ml二入` 可和 PChome `Nick` 補出的 `水楊酸身體乳雙入組 / 210ml x2` 對齊並進 safe total-price;此版不泛用放寬中文入數,`118ml二入組(金蓋限定版)` 對上 PChome 效期品仍維持 manual review。
|
||||
- 2026-06-04 起,`V10.584` 補 PChome Nick 清洗與 stale recovery 單品窄門:Nick 先去 HTML、行銷星號與重複品名,避免同一商品副標讓規格被重複計數;新增 NIVEA 妮維雅霜 100ml、Schick 舒綺敏感肌除毛刀片 3 入、TS6 沁涼潔淨慕斯 100g 具名 exact total-price alignment。IBL 沐浴/洗髮用途落差、唇色目錄款、效期/限定版差異仍留 review。
|
||||
- 2026-06-05 起,`V10.599` 補全站巡檢降載與前端工作溝通隔離:CONSTITUTION 新增第 14.2 條,禁止把施工紀錄、版本發布說明、Codex/Claude 評估、推版語氣放進使用者可見頁面;市場情報停用頁改為輕量產品狀態頁;ICAIM dashboard API 增加短快取、stale fallback、5 秒 PostgreSQL statement timeout、LATERAL 最新價與最新 PChome identity row 查詢,避免全站巡檢與使用者開頁時被重查詢拖慢。
|
||||
- 2026-06-04 起,`V10.578` 修正 Code Review deterministic scan 的 timeout 判定,多行 `requests.*(... timeout=...)` 不再被誤報為未設定 timeout。
|
||||
- 2026-06-04 起,`V10.577` Code Review OpenClaw 會在 explicit Ollama host generate 前先做短 `/api/version` preflight;GCP-A 不通時快速跳 GCP-B,避免 15 秒 timeout 後才降級,且仍不呼叫 Gemini / 111。
|
||||
- 2026-06-04 起,`V10.576` 修正 GCP-only Ollama retry:caller 禁用 111 fallback 時,resolver 若回到 111 會改試 GCP-A/GCP-B allowlist,不再讓 Hermes / Code Review 類任務因 resolver 快取到 111 而 `all 0 hosts failed`。
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
## 📅 詳細更新日誌 (考古存檔)
|
||||
|
||||
### 2026-06-01:PChome 比價新鮮度操作閉環
|
||||
- **V10.599 全站巡檢降載與前端工作溝通隔離**: 新增 CONSTITUTION 第 14.2 條,禁止把施工紀錄、版本發布說明、AI 工作視窗判斷、Codex/Claude 評估或 Gitea 推版語氣放進使用者可見前端頁面。市場情報停用頁改成輕量產品狀態頁,移除 `system_version` 與工程文案;ICAIM 競情 dashboard API 新增 120 秒快取、900 秒 stale fallback、PostgreSQL 5 秒 statement timeout、LATERAL 最新價查詢與 DISTINCT ON 最新 PChome identity row,避免全站巡檢或使用者開頁時被重型查詢拖慢。
|
||||
- **V10.584 PChome Nick 去重 + stale recovery 單品窄門**: PChome `Nick` 進 matcher 前會去除 HTML 標籤、星號行銷文與重複品名,避免同一個 `29g / 100ml` 被副標重複計數後誤判 `component_count_conflict`。依 10 筆正式 stale recovery 診斷,新增 NIVEA 妮維雅霜 100ml、Schick 舒綺敏感肌除毛刀片 3 入、TS6 沁涼潔淨慕斯 100g 的具名 exact total-price alignment;IBL 沐浴精+洗髮精 vs 洗髮精、唇釉色號目錄款、Paula's Choice 效期/金蓋差異仍維持 identity review。
|
||||
- **V10.583 Paula's Choice 身體乳 PChome Nick 具名 alignment**: matcher 新增 Paula's Choice `2%水楊酸身體乳210ml二入` 的窄範圍 alignment,讓 PChome `Nick` 補出的 `水楊酸身體乳雙入組 / 210ml x2` 可和 MOMO 品名對齊並進 `exact / total_price / price_alert_exact`。此版不泛用放寬中文入數;`118ml二入組(金蓋限定版)` 對上 PChome 效期品仍維持 `manual_review / identity_review`,不寫正式價差。
|
||||
- **V10.582 PChome 比價通知專業分級 + Nick 副標身份證據**: NemoTron 價格決策信封補齊 `momo_price`、`competitor_price`、`candidate_gap_pct` 與 `sales_7d_delta_pct`,避免 EventRouter / Telegram 模板拿不到核心價格事實。價格決策模板新增「通知分級」,將 `match_type / price_basis / alert_tier` 翻成直接價格威脅、單位價覆核、身份覆核或壓制告警,並同步顯示操作邊界;單位價或 identity 未確認時明確禁止以總價直接判定價格威脅。PChome crawler 另保留 `Nick` 副標為 `match_name` 給 matcher 使用,UI/DB 顯示仍用原品名,讓容量、入數、濃度資訊可參與比對。
|
||||
|
||||
@@ -19,6 +19,7 @@ import pandas as pd
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, date, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -46,6 +47,15 @@ _PCHOME_STALE_RECOVERY_PREVIEW_CACHE = {
|
||||
"payload": None,
|
||||
}
|
||||
_PCHOME_STALE_RECOVERY_PREVIEW_TTL_SECONDS = 60
|
||||
_ICAIM_DASHBOARD_CACHE = {
|
||||
"expires_at": 0.0,
|
||||
"cached_at": 0.0,
|
||||
"payload": None,
|
||||
}
|
||||
_ICAIM_DASHBOARD_TTL_SECONDS = 120
|
||||
_ICAIM_DASHBOARD_STALE_TTL_SECONDS = 900
|
||||
_ICAIM_MATCH_SCORE_FLOOR = 0.76
|
||||
_ICAIM_DB_STATEMENT_TIMEOUT_MS = 5000
|
||||
|
||||
# 服務實例
|
||||
ollama_service = OllamaService()
|
||||
@@ -1518,6 +1528,74 @@ def ai_intelligence():
|
||||
return render_template('ai_intelligence.html', active_page='ai_intelligence')
|
||||
|
||||
|
||||
def _clone_icaim_dashboard_payload(payload):
|
||||
cloned = dict(payload or {})
|
||||
cloned['stats'] = dict(cloned.get('stats') or {})
|
||||
cloned['competitors'] = [dict(row) for row in (cloned.get('competitors') or [])]
|
||||
cloned['ai_recs'] = [dict(row) for row in (cloned.get('ai_recs') or [])]
|
||||
return cloned
|
||||
|
||||
|
||||
def _get_cached_icaim_dashboard_payload(allow_stale=False):
|
||||
payload = _ICAIM_DASHBOARD_CACHE.get('payload')
|
||||
if not payload:
|
||||
return None
|
||||
now_ts = time.time()
|
||||
expires_at = float(_ICAIM_DASHBOARD_CACHE.get('expires_at') or 0)
|
||||
cached_at = float(_ICAIM_DASHBOARD_CACHE.get('cached_at') or 0)
|
||||
if now_ts <= expires_at:
|
||||
cached = _clone_icaim_dashboard_payload(payload)
|
||||
cached['cache_state'] = 'fresh'
|
||||
return cached
|
||||
if allow_stale and cached_at and now_ts - cached_at <= _ICAIM_DASHBOARD_STALE_TTL_SECONDS:
|
||||
cached = _clone_icaim_dashboard_payload(payload)
|
||||
cached['cache_state'] = 'stale'
|
||||
cached['notice'] = '目前資料更新較慢,先顯示最近一次可用快照。'
|
||||
return cached
|
||||
return None
|
||||
|
||||
|
||||
def _set_icaim_dashboard_cache(payload):
|
||||
now_ts = time.time()
|
||||
cached = _clone_icaim_dashboard_payload(payload)
|
||||
cached['cache_state'] = 'fresh'
|
||||
_ICAIM_DASHBOARD_CACHE.update({
|
||||
'expires_at': now_ts + _ICAIM_DASHBOARD_TTL_SECONDS,
|
||||
'cached_at': now_ts,
|
||||
'payload': cached,
|
||||
})
|
||||
|
||||
|
||||
def _icaim_dashboard_empty_payload():
|
||||
return {
|
||||
'success': True,
|
||||
'stats': {
|
||||
'total_skus': 0,
|
||||
'valid_competitor_prices': 0,
|
||||
'high_risk_count': 0,
|
||||
'total_ai_recs': 0,
|
||||
'product_pick_count': 0,
|
||||
'match_rate': 0,
|
||||
'last_feeder_run': '資料整理中',
|
||||
},
|
||||
'competitors': [],
|
||||
'ai_recs': [],
|
||||
'cache_state': 'empty',
|
||||
'notice': '競品資料正在整理,請稍後重新整理。'
|
||||
}
|
||||
|
||||
|
||||
def _create_icaim_dashboard_engine(database_path):
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
engine_kwargs = {}
|
||||
if str(database_path).startswith(('postgresql://', 'postgresql+psycopg2://', 'postgres://')):
|
||||
engine_kwargs['connect_args'] = {
|
||||
'options': f'-c statement_timeout={_ICAIM_DB_STATEMENT_TIMEOUT_MS}'
|
||||
}
|
||||
return create_engine(database_path, **engine_kwargs)
|
||||
|
||||
|
||||
@ai_bp.route('/api/ai/icaim/dashboard')
|
||||
@login_required
|
||||
def api_icaim_dashboard():
|
||||
@@ -1527,78 +1605,84 @@ def api_icaim_dashboard():
|
||||
"""
|
||||
try:
|
||||
from config import DATABASE_PATH
|
||||
from sqlalchemy import create_engine, text as sa_text
|
||||
from sqlalchemy import text as sa_text
|
||||
|
||||
engine = create_engine(DATABASE_PATH)
|
||||
force_refresh = str(request.args.get('refresh') or '').strip().lower() in {'1', 'true', 'yes'}
|
||||
if not force_refresh:
|
||||
cached_payload = _get_cached_icaim_dashboard_payload()
|
||||
if cached_payload:
|
||||
return jsonify(cached_payload)
|
||||
|
||||
engine = _create_icaim_dashboard_engine(DATABASE_PATH)
|
||||
|
||||
# ── 統計摘要 ────────────────────────────────────────────
|
||||
# high_risk_count:MOMO 售價比 PChome 貴 > 15%(全量掃描)
|
||||
stats_sql = sa_text("""
|
||||
# high_risk_count:MOMO 售價比 PChome 貴 > 15%
|
||||
compared_cte = f"""
|
||||
WITH latest_momo AS (
|
||||
SELECT p.i_code AS sku, pr.price AS momo_price,
|
||||
ROW_NUMBER() OVER (PARTITION BY p.i_code ORDER BY pr.timestamp DESC) AS rn
|
||||
SELECT
|
||||
p.i_code AS sku,
|
||||
p.name,
|
||||
p.category,
|
||||
latest_price.price AS momo_price
|
||||
FROM products p
|
||||
JOIN price_records pr ON pr.product_id = p.id
|
||||
JOIN LATERAL (
|
||||
SELECT pr.price
|
||||
FROM price_records pr
|
||||
WHERE pr.product_id = p.id
|
||||
ORDER BY pr.timestamp DESC, pr.id DESC
|
||||
LIMIT 1
|
||||
) latest_price ON TRUE
|
||||
WHERE p.status = 'ACTIVE'
|
||||
),
|
||||
high_risk AS (
|
||||
SELECT lm.sku
|
||||
valid_competitor AS (
|
||||
SELECT DISTINCT ON (cp.sku)
|
||||
cp.sku,
|
||||
cp.price AS pchome_price,
|
||||
cp.match_score,
|
||||
cp.tags,
|
||||
cp.crawled_at
|
||||
FROM competitor_prices cp
|
||||
WHERE cp.source = 'pchome'
|
||||
AND cp.expires_at > CURRENT_TIMESTAMP
|
||||
AND cp.price IS NOT NULL
|
||||
AND cp.price > 0
|
||||
AND COALESCE(cp.match_score, 0) >= {_ICAIM_MATCH_SCORE_FLOOR}
|
||||
AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2'
|
||||
ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST
|
||||
),
|
||||
compared AS (
|
||||
SELECT
|
||||
lm.sku,
|
||||
lm.name,
|
||||
lm.category,
|
||||
lm.momo_price,
|
||||
vc.pchome_price,
|
||||
ROUND(((lm.momo_price - vc.pchome_price) / vc.pchome_price * 100)::numeric, 1) AS gap_pct,
|
||||
vc.match_score,
|
||||
vc.tags,
|
||||
vc.crawled_at
|
||||
FROM latest_momo lm
|
||||
JOIN competitor_prices cp
|
||||
ON cp.sku = lm.sku
|
||||
AND cp.source = 'pchome'
|
||||
AND cp.expires_at > NOW()
|
||||
AND cp.match_score >= 0.76
|
||||
AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2'
|
||||
WHERE lm.rn = 1
|
||||
AND (lm.momo_price - cp.price) / cp.price * 100 > 15
|
||||
JOIN valid_competitor vc ON vc.sku = lm.sku
|
||||
WHERE lm.momo_price IS NOT NULL
|
||||
)
|
||||
"""
|
||||
|
||||
stats_sql = sa_text(compared_cte + """
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM products WHERE status = 'ACTIVE') AS total_skus,
|
||||
(SELECT COUNT(*) FROM competitor_prices
|
||||
WHERE expires_at > NOW()
|
||||
AND source = 'pchome'
|
||||
AND COALESCE(match_score, 0) >= 0.76
|
||||
AND COALESCE(tags, '[]'::jsonb) ? 'identity_v2') AS valid_competitor_prices,
|
||||
(SELECT COUNT(*) FROM high_risk) AS high_risk_count,
|
||||
(SELECT COUNT(*) FROM compared) AS valid_competitor_prices,
|
||||
(SELECT COUNT(*) FROM compared WHERE gap_pct > 15) AS high_risk_count,
|
||||
(SELECT COUNT(*) FROM ai_price_recommendations) AS total_ai_recs,
|
||||
(SELECT COUNT(*) FROM ai_price_recommendations
|
||||
WHERE strategy = 'product_pick' AND status = 'pending') AS product_pick_count,
|
||||
(SELECT MAX(crawled_at) FROM competitor_prices WHERE source='pchome') AS last_feeder_run
|
||||
""")
|
||||
|
||||
# ── 競品比價(去重:每個 SKU 只取最新一筆 price_record)──
|
||||
competitor_sql = sa_text("""
|
||||
WITH latest_price AS (
|
||||
SELECT
|
||||
p.i_code AS sku,
|
||||
p.name,
|
||||
p.category,
|
||||
pr.price AS momo_price,
|
||||
ROW_NUMBER() OVER (PARTITION BY p.i_code ORDER BY pr.timestamp DESC) AS rn
|
||||
FROM products p
|
||||
JOIN price_records pr ON pr.product_id = p.id
|
||||
WHERE p.status = 'ACTIVE'
|
||||
)
|
||||
SELECT
|
||||
lp.sku,
|
||||
lp.name,
|
||||
lp.category,
|
||||
lp.momo_price,
|
||||
cp.price AS pchome_price,
|
||||
ROUND(((lp.momo_price - cp.price) / cp.price * 100)::numeric, 1) AS gap_pct,
|
||||
cp.match_score,
|
||||
cp.tags,
|
||||
cp.crawled_at
|
||||
FROM latest_price lp
|
||||
JOIN competitor_prices cp
|
||||
ON cp.sku = lp.sku
|
||||
AND cp.source = 'pchome'
|
||||
AND cp.expires_at > NOW()
|
||||
AND cp.match_score >= 0.76
|
||||
AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2'
|
||||
WHERE lp.rn = 1
|
||||
ORDER BY gap_pct DESC NULLS LAST
|
||||
# ── 競品比價(每個 SKU 只取最新且通過身份比對的一筆)──
|
||||
competitor_sql = sa_text(compared_cte + """
|
||||
SELECT *
|
||||
FROM compared
|
||||
ORDER BY gap_pct DESC NULLS LAST, crawled_at DESC NULLS LAST
|
||||
LIMIT 200
|
||||
""")
|
||||
|
||||
@@ -1667,32 +1751,38 @@ def api_icaim_dashboard():
|
||||
'created_at': r.created_at.strftime('%m/%d %H:%M') if r.created_at else '',
|
||||
})
|
||||
|
||||
stats = dict(stats_row._mapping) if stats_row else {}
|
||||
last_feeder_run = stats.get('last_feeder_run')
|
||||
last_feeder = (
|
||||
stats_row.last_feeder_run.strftime('%Y-%m-%d %H:%M')
|
||||
if stats_row.last_feeder_run else '尚未執行'
|
||||
last_feeder_run.strftime('%Y-%m-%d %H:%M')
|
||||
if last_feeder_run else '尚未執行'
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
total_skus = int(stats.get('total_skus') or 0)
|
||||
valid_competitor_prices = int(stats.get('valid_competitor_prices') or 0)
|
||||
payload = {
|
||||
'success': True,
|
||||
'stats': {
|
||||
'total_skus': int(stats_row.total_skus or 0),
|
||||
'valid_competitor_prices': int(stats_row.valid_competitor_prices or 0),
|
||||
'high_risk_count': int(stats_row.high_risk_count or 0),
|
||||
'total_ai_recs': int(stats_row.total_ai_recs or 0),
|
||||
'product_pick_count': int(stats_row.product_pick_count or 0),
|
||||
'match_rate': round(
|
||||
int(stats_row.valid_competitor_prices or 0) / max(int(stats_row.total_skus or 0), 1) * 100,
|
||||
1
|
||||
),
|
||||
'total_skus': total_skus,
|
||||
'valid_competitor_prices': valid_competitor_prices,
|
||||
'high_risk_count': int(stats.get('high_risk_count') or 0),
|
||||
'total_ai_recs': int(stats.get('total_ai_recs') or 0),
|
||||
'product_pick_count': int(stats.get('product_pick_count') or 0),
|
||||
'match_rate': round(valid_competitor_prices / max(total_skus, 1) * 100, 1),
|
||||
'last_feeder_run': last_feeder,
|
||||
},
|
||||
'competitors': competitors,
|
||||
'ai_recs': ai_recs,
|
||||
})
|
||||
'cache_state': 'fresh',
|
||||
}
|
||||
_set_icaim_dashboard_cache(payload)
|
||||
return jsonify(payload)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ICAIM] dashboard API 失敗: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
logger.warning(f"[ICAIM] dashboard API 讀取降級: {e}")
|
||||
cached_payload = _get_cached_icaim_dashboard_payload(allow_stale=True)
|
||||
if cached_payload:
|
||||
return jsonify(cached_payload)
|
||||
return jsonify(_icaim_dashboard_empty_payload())
|
||||
|
||||
|
||||
@ai_bp.route('/api/ai/product-picks/generate', methods=['POST'])
|
||||
|
||||
@@ -57,19 +57,6 @@
|
||||
max-width: 58rem;
|
||||
}
|
||||
|
||||
.market-intel-version {
|
||||
align-self: start;
|
||||
background: rgba(255, 250, 241, 0.86);
|
||||
border: 1px solid rgba(120, 83, 44, 0.18);
|
||||
border-radius: 8px;
|
||||
color: var(--momo-text-muted, #756a5b);
|
||||
font-family: var(--momo-font-mono, "JetBrains Mono", monospace);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
padding: 0.45rem 0.6rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.market-intel-status-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
@@ -158,10 +145,6 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.market-intel-version {
|
||||
justify-self: start;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -170,34 +153,33 @@
|
||||
<section class="market-intel-page">
|
||||
<header class="market-intel-hero">
|
||||
<div>
|
||||
<p class="market-intel-kicker">MARKET INTEL / {{ current_section|default('overview') }}</p>
|
||||
<h1 class="market-intel-title">市場情報模組待啟用</h1>
|
||||
<p class="market-intel-kicker">MARKET INTEL</p>
|
||||
<h1 class="market-intel-title">市場情報入口</h1>
|
||||
<p class="market-intel-copy">
|
||||
這個模組目前保留為競品情報擴充入口,正式操作先回到 PChome 比價工作台、PChome 爬蟲與 AI 觀測台,避免停用中的試驗流程混入日常決策。
|
||||
競品情報資料尚未接入正式決策流程。請先從下方入口完成比價覆核、PChome 爬蟲檢查與 AI 觀測,讓商品決策維持在已驗證的資料來源上。
|
||||
</p>
|
||||
</div>
|
||||
<div class="market-intel-version">{{ system_version|default('') }}</div>
|
||||
</header>
|
||||
|
||||
<section class="market-intel-panel" aria-labelledby="market-intel-status-title">
|
||||
<p class="market-intel-label">Runtime Status</p>
|
||||
<h2 id="market-intel-status-title" class="market-intel-title">目前狀態</h2>
|
||||
<p class="market-intel-label">DATA STATUS</p>
|
||||
<h2 id="market-intel-status-title" class="market-intel-title">資料狀態</h2>
|
||||
<div class="market-intel-status-grid">
|
||||
<div class="market-intel-status-card">
|
||||
<span class="market-intel-label">模組</span>
|
||||
<strong>{{ 'ON' if status.enabled else 'OFF' }}</strong>
|
||||
<span class="market-intel-label">情報入口</span>
|
||||
<strong>{{ '已啟用' if status.enabled else '未啟用' }}</strong>
|
||||
</div>
|
||||
<div class="market-intel-status-card">
|
||||
<span class="market-intel-label">爬蟲</span>
|
||||
<strong>{{ 'ON' if status.crawler_enabled else 'OFF' }}</strong>
|
||||
<strong>{{ '已啟用' if status.crawler_enabled else '未啟用' }}</strong>
|
||||
</div>
|
||||
<div class="market-intel-status-card">
|
||||
<span class="market-intel-label">寫入</span>
|
||||
<strong>{{ 'ON' if status.write_enabled else 'OFF' }}</strong>
|
||||
<strong>{{ '已啟用' if status.write_enabled else '未啟用' }}</strong>
|
||||
</div>
|
||||
<div class="market-intel-status-card">
|
||||
<span class="market-intel-label">排程</span>
|
||||
<strong>{{ 'ON' if status.scheduler_attached else 'OFF' }}</strong>
|
||||
<strong>{{ '已啟用' if status.scheduler_attached else '未啟用' }}</strong>
|
||||
</div>
|
||||
<div class="market-intel-status-card">
|
||||
<span class="market-intel-label">Adapter</span>
|
||||
@@ -205,14 +187,14 @@
|
||||
</div>
|
||||
<div class="market-intel-status-card">
|
||||
<span class="market-intel-label">手動 Fetch</span>
|
||||
<strong>{{ 'ON' if manual_fetch_allowed|default(false) else 'OFF' }}</strong>
|
||||
<strong>{{ '已啟用' if manual_fetch_allowed|default(false) else '未啟用' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="market-intel-panel" aria-labelledby="market-intel-flow-title">
|
||||
<p class="market-intel-label">Decision Flow</p>
|
||||
<h2 id="market-intel-flow-title" class="market-intel-title">目前應使用的操作入口</h2>
|
||||
<p class="market-intel-label">OPERATIONS</p>
|
||||
<h2 id="market-intel-flow-title" class="market-intel-title">建議操作入口</h2>
|
||||
<div class="market-intel-flow">
|
||||
<article class="market-intel-flow-item">
|
||||
<h3>PChome 比價覆核</h3>
|
||||
|
||||
@@ -24,6 +24,7 @@ def test_frontend_v2_shell_uses_real_runtime_context():
|
||||
base = (ROOT / "templates/ewoooc_base.html").read_text(encoding="utf-8")
|
||||
app_source = (ROOT / "app.py").read_text(encoding="utf-8")
|
||||
config_source = (ROOT / "config.py").read_text(encoding="utf-8")
|
||||
constitution = (ROOT / "CONSTITUTION.md").read_text(encoding="utf-8")
|
||||
|
||||
assert "scheduler_stats" in shell
|
||||
assert "session.get('username')" in shell
|
||||
@@ -35,6 +36,8 @@ def test_frontend_v2_shell_uses_real_runtime_context():
|
||||
assert "WEBCRUMBS_ASSET_UPSTREAM_URL" in config_source
|
||||
assert "data-webcrumbs-runtime" in base
|
||||
assert 'name="ui" value="v2"' not in base
|
||||
assert "前端文案與工作溝通隔離" in constitution
|
||||
assert "不得搬到使用者可見頁面" in constitution
|
||||
|
||||
forbidden_markers = [
|
||||
"mockProducts",
|
||||
@@ -68,10 +71,20 @@ def test_market_intel_disabled_page_stays_lightweight_and_action_oriented():
|
||||
template = template_path.read_text(encoding="utf-8")
|
||||
|
||||
assert template_path.stat().st_size < 40000
|
||||
assert "市場情報模組待啟用" in template
|
||||
assert "市場情報入口" in template
|
||||
assert "比價覆核" in template
|
||||
assert "PChome 爬蟲" in template
|
||||
assert "AI 觀測台" in template
|
||||
assert "system_version" not in template
|
||||
assert "V10." not in template
|
||||
assert "hotfix" not in template.lower()
|
||||
assert "Gitea" not in template
|
||||
assert "Codex" not in template
|
||||
assert "Claude" not in template
|
||||
assert "Runtime Status" not in template
|
||||
assert "Decision Flow" not in template
|
||||
assert "模組待啟用" not in template
|
||||
assert "停用中的試驗流程" not in template
|
||||
assert "data-market-intel-preview" not in template
|
||||
assert "/api/market_intel/" not in template
|
||||
assert "讀取候選預覽中" not in template
|
||||
@@ -380,6 +393,11 @@ def test_ai_intelligence_uses_v2_shell_and_real_runtime_apis():
|
||||
assert "@ai_bp.route('/api/ai/icaim/dashboard')" in route_source
|
||||
assert "competitor_prices" in route_source
|
||||
assert "ai_price_recommendations" in route_source
|
||||
assert "_ICAIM_DASHBOARD_TTL_SECONDS" in route_source
|
||||
assert "_ICAIM_DB_STATEMENT_TIMEOUT_MS" in route_source
|
||||
assert "JOIN LATERAL" in route_source
|
||||
assert "DISTINCT ON (cp.sku)" in route_source
|
||||
assert "_get_cached_icaim_dashboard_payload(allow_stale=True)" in route_source
|
||||
|
||||
|
||||
def test_ai_history_uses_v2_shell_and_real_history_apis():
|
||||
|
||||
Reference in New Issue
Block a user