V10.599 收斂前端文案與 ICAIM 載入
All checks were successful
CD Pipeline / deploy (push) Successful in 1m42s

This commit is contained in:
OoO
2026-06-05 16:01:37 +08:00
parent 9be44d27b1
commit 400133fec1
8 changed files with 203 additions and 105 deletions

View File

@@ -140,6 +140,12 @@
- ❌ **禁止**: 使用 mock data、假商品、假 KPI、假排程、假使用者、假頁面或純展示用 placeholder 冒充已完成。
- ❌ **禁止**: 為了符合原型畫面而改寫或捏造業務數字。
### 第 14.2 條:前端文案與工作溝通隔離(絕對禁止違反)
- ✅ **正確**: 前端頁面只放使用者完成任務所需的產品文案、狀態、操作入口與可診斷錯誤。
- ✅ **正確**: 施工紀錄、版本發布說明、AI 工作視窗判斷、Session 溝通、TODO 內容,只能放在文件、日誌或提交訊息,不得搬到使用者可見頁面。
- ❌ **禁止**: 在模板、靜態 JS/CSS 可見文案中放入「本輪已完成」「剛剛修正」「Codex/Claude 評估」「V10.x hotfix」「推到 Gitea」等工作視窗溝通內容。
- ❌ **禁止**: 用內部工程語氣代替產品語氣,例如把頁面寫成施工報告、交接紀錄或 agent 工作摘要。
---
## 第五章:系統架構規範

View File

@@ -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 顯示仍維持原品名,讓容量、入數、濃度資訊可參與比對。

View File

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

View File

@@ -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` preflightGCP-A 不通時快速跳 GCP-B避免 15 秒 timeout 後才降級,且仍不呼叫 Gemini / 111。
- 2026-06-04 起,`V10.576` 修正 GCP-only Ollama retrycaller 禁用 111 fallback 時resolver 若回到 111 會改試 GCP-A/GCP-B allowlist不再讓 Hermes / Code Review 類任務因 resolver 快取到 111 而 `all 0 hosts failed`

View File

@@ -13,6 +13,7 @@
## 📅 詳細更新日誌 (考古存檔)
### 2026-06-01PChome 比價新鮮度操作閉環
- **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 alignmentIBL 沐浴精+洗髮精 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 顯示仍用原品名,讓容量、入數、濃度資訊可參與比對。

View File

@@ -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_countMOMO 售價比 PChome 貴 > 15%(全量掃描)
stats_sql = sa_text("""
# high_risk_countMOMO 售價比 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'])

View File

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

View File

@@ -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():