diff --git a/config.py b/config.py index 8f46c46..29f7604 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.705" +SYSTEM_VERSION = "V10.708" 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 5eb097e..2a1a44a 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -1,8 +1,8 @@ # PChome 業績成長自動化作戰系統 — AI 競價情報模組 Single Source of Truth -> **最後更新**: 2026-06-18 (台北時間) -> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯;PChome 後台業績匯入韌性已補強;產品定位正名為「PChome 業績成長自動化作戰系統」;外部市場來源正規化層、自動同步、作戰清單與價格參考表優先讀取、CSV 備援預檢、前台操作入口、高可見頁面繁中化守門、比價/作戰 UI 工作台化、GCP embedding 熔斷延後處理、110 proxy rescue 與 direct host health skip 已建立 -> **適用版本**: V10.627 +> **最後更新**: 2026-06-26 (台北時間) +> **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯;PChome 後台業績匯入韌性已補強;產品定位正名為「PChome 業績成長自動化作戰系統」;外部市場來源正規化層、自動同步、作戰清單與價格參考表優先讀取、CSV 備援預檢、前台操作入口、高可見頁面繁中化守門、比價/作戰 UI 工作台化、跨平台來源治理與商品身份 UI 契約已建立,GCP embedding 熔斷延後處理、110 proxy rescue 與 direct host health skip 已建立 +> **適用版本**: V10.708 --- @@ -55,7 +55,10 @@ - 使用者可見 UI、Telegram 與報表文案必須白話、可行動,優先使用「商品對應」「可直接比價」「待補對應」「放大價格優勢」「檢查售價與活動」等營運語言,避免把 `identity_v2`、`match_score`、`candidate queue` 等工程詞直接丟給使用者。 - `services/pchome_revenue_growth_service.py` 是第一版只讀作戰清單:讀 PChome 後台業績與已驗證 MOMO 外部價格參考,輸出 `/api/ai/pchome-growth/opportunities`。此服務不呼叫 LLM、不抓外站、不寫 DB。 - 2026-06-15 只讀盤點確認:`daily_sales_snapshot."商品ID"` 與 `competitor_prices.competitor_product_id` 在正式資料中直接重疊為 0。因此第一版作戰清單不得硬接兩邊 ID;若沒有可驗證對應,只能輸出「先補商品對應」任務。 -- 蝦皮與酷澎暫停接入,不進作戰清單、不發告警;後續只可透過 official API / provider API / manual CSV 進 `external_offers` 類正規化層,並清楚標示資料品質。 +- 外部主流平台不得只限 PChome / MOMO 視角;MOMO 是已接入參考來源,Shopee、Lazada、Amazon、Google Merchant / Shopping、TikTok Shop、LINE 購物、Rakuten、Yahoo 購物、露天、品牌官網 / Shopify、Meta Commerce 與 Coupang 必須先納入來源治理契約。未取得合法穩定來源前只能顯示「待接入 / 不進告警」,不得假裝 AI 已監控或抓到促銷。 +- 所有外部平台資料進作戰清單前必須正規化為同一商品報價格式:平台商品 ID、商品名稱、賣場連結、商品圖、售價、促銷/券/活動、庫存、資料時間、取得方式、同款狀態、資料可信度。缺商品 ID、缺賣場連結、缺圖片時,UI 必須明確顯示待補狀態,不得留下空白、破圖或資料庫欄位名稱。 +- 商品清單、AI 挑品、比價覆核與待確認候選必須採商品身份優先 UI:縮圖、商品 ID、平台、賣場連結、價格/促銷狀態、可信度與下一步動作需在同一商品區塊內可一眼掃描;AI 建議不得只顯示長段理由,必須同時提供可開啟的賣場入口。 +- 蝦皮與酷澎等未接入來源暫停接入,不進作戰清單、不發告警;後續只可透過 official API / provider API / manual CSV 進 `external_offers` 類正規化層,並清楚標示資料品質。 - V10.607 新增 `external_market_sources` / `external_offers` 正規化層與 `/api/ai/pchome-growth/source-contract` 只讀 API。MOMO 先以既有比價快取橋接進來源狀態;蝦皮與酷澎只保留 official API、provider API、manual CSV contract,預設暫停且不進告警。 - V10.608 新增 `/api/ai/pchome-growth/external-offers/csv-dry-run` 與 AI 情報頁「外部報價預檢」。CSV 預檢只讀、不寫 DB;逐列回報「可使用」「需人工確認」「不能使用」,並支援中文表頭,避免格式小錯造成整批匯入失敗。 - V10.609 明確把外部報價主路徑改為自動化:`run_external_offer_sync_task` 每 4 小時將已確認同款的既有比價快取同步進 `external_offers`。CSV 只保留為 API / crawler / provider 失敗時的備援預檢入口,不是日常營運主流程。 @@ -784,3 +787,8 @@ POSTGRES_HOST=momo-db | 2026-06-26 | 全站 UI/UX 工作重點必須文件化並納入入口索引 | V10.704 起新增 `docs/guides/pchome_growth_ui_ux_guardrails.md` 並由 `AGENTS.md` 索引;所有前台頁面以「提升 PChome 業績、快速判斷、直接下一步」為共同目標,避免後續工作再偏回局部文案修補。 | | 2026-06-26 | 待確認商品必須能並排比較兩家賣場 | V10.705 起 `ai_intelligence` 與 `price_comparison` 的 MOMO 待確認候選都要以 PChome/MOMO 兩欄比較卡呈現,並提供「同時開兩家賣場」主要操作;不得只顯示候選摘要或只放單一平台連結。 | | 2026-06-26 | 外部促銷活動要進商業情報與 PChome 解法 | V10.705 起商業情報頁新增外部促銷活動監控,從 24h 外部價格/折扣訊號推導外部低價壓力或促銷訊號,並用守價、組合、曝光、會員四類 PChome 業績提升解法承接。 | +| 2026-06-26 | 商品型 UI 必須顯示商品身份與賣場操作 | V10.706 起商品看板、AI 挑品與 MOMO 待確認候選需在主要商品區塊顯示商品圖、商品 ID、平台賣場連結、價格/可信度與下一步;缺圖需顯示「待補圖片」,不得留下破圖、空白或資料庫欄位名稱。 | +| 2026-06-26 | 外部主流平台需先納入來源治理 | V10.706 起外部來源契約擴充到 Shopee、Lazada、Amazon、Google Merchant / Shopping、Rakuten、Yahoo 購物、Meta Commerce 與 Coupang;未接合法穩定來源前標示待接入且不進告警,避免假裝 AI 已完成監控。 | +| 2026-06-26 | 外部來源視野不可停在少數平台 | V10.707 起外部來源契約再補 TikTok Shop、LINE 購物、露天、品牌官網 / Shopify;所有待接來源必須在 UI 顯示為待接入且不進告警,等官方 API、商品 feed、供應商 API 或人工 CSV 通過品質門檻後才可進作戰清單。 | +| 2026-06-26 | 同版 CSS 修正必須跳版本破快取 | V10.707 起 UI 修正若影響 `web/static` 資產,必須同步提升 `SYSTEM_VERSION`,讓正式 HTML 的 `?v=` 參數改變;不得在同一版本號下修改 CSS 後宣稱使用者一定看得到。 | +| 2026-06-26 | AI 挑品賣場操作必須固定可見 | V10.708 起 AI 挑品清單在桌面寬度固定「AI 建議 / 賣場操作」欄,橫向查看價格與更新欄時仍能直接開 MOMO / PChome 賣場;手機版維持卡片式堆疊。 | diff --git a/docs/guides/pchome_growth_ui_ux_guardrails.md b/docs/guides/pchome_growth_ui_ux_guardrails.md index 7c4e486..97001b3 100644 --- a/docs/guides/pchome_growth_ui_ux_guardrails.md +++ b/docs/guides/pchome_growth_ui_ux_guardrails.md @@ -12,6 +12,8 @@ 4. 資料不足時不能整段消失,要顯示可理解的空狀態與下一步。 5. 不得把工作視窗溝通、部署交接、工程判斷或維護工作摘要搬到前台。 6. 外部促銷活動、折扣、價格壓力與平台活動訊號,必須被整理成「PChome 現況對比」與「業績提升解法」,不能只顯示外部事件本身。 +7. 商品型頁面必須把商品身份放在主要視覺區:商品圖、平台商品 ID、商品名稱、售價、賣場連結、可信度與下一步不得分散到難以掃描的位置。 +8. 外部主流平台來源治理不得只看 PChome / MOMO;Shopee、Lazada、Amazon、Google Merchant / Shopping、TikTok Shop、LINE 購物、Rakuten、Yahoo 購物、露天、品牌官網 / Shopify、Meta Commerce、Coupang 等來源至少要有待接入契約。未接合法穩定來源前只能標示待接入,不得假裝已監控。 ## 每次 UI/UX 修改的驗收 @@ -21,7 +23,10 @@ - 相關業務測試:`tests/test_pchome_revenue_growth_service.py` - 正式 smoke:檢查 `/health` 版本、核心頁 HTTP 200、可見文案無 raw terms、靜態資源 HTTP 200 - 如果頁面有 PChome/MOMO 商品比較,必須能一眼看到兩平台價格與可同時開啟的外部賣場連結 +- 如果頁面有商品列、AI 挑品或候選卡,必須能一眼看到商品圖、商品 ID 與可點擊賣場;缺圖只能顯示「待補圖片」等可診斷狀態,不得破圖或空白 +- 如果商品表格需要橫向捲動,關鍵賣場操作欄必須固定或重複在可見區,不能讓使用者為了開兩家賣場而找不到按鈕 - 如果頁面有外部促銷或競品活動訊號,必須至少提供守價、組合、曝光或會員回饋等 PChome 可執行解法 +- 如果 CSS/JS 在同一輪修正後重新部署,必須同步提升 `SYSTEM_VERSION`,確保正式頁的靜態資源版本參數改變,不讓使用者瀏覽器繼續使用舊樣式 ## 判斷標準 diff --git a/services/external_market_offer_service.py b/services/external_market_offer_service.py index 9f70ccd..b5c3592 100644 --- a/services/external_market_offer_service.py +++ b/services/external_market_offer_service.py @@ -11,6 +11,10 @@ import json import logging import csv import io +import os +import re +from urllib.parse import quote +from urllib.request import Request, urlopen from dataclasses import dataclass, field from datetime import datetime, timedelta from typing import Any @@ -21,6 +25,10 @@ from services.pchome_growth_cache_state import mark_pchome_growth_cache_stale logger = logging.getLogger(__name__) +PCHOME_PUBLIC_PRODUCT_API = "https://ecapi.pchome.com.tw/ecshop/prodapi/v2/prod" +PCHOME_IMAGE_CDN_BASE = "https://cs-a.ecimg.tw" +PCHOME_PRODUCT_ID_RE = re.compile(r"^[A-Z0-9]{6}-[A-Z0-9]{9}-[0-9]{3}$") + SOURCE_CONTRACTS = [ { @@ -45,6 +53,116 @@ SOURCE_CONTRACTS = [ "data_quality_label": "暫不進告警", "plain_note": "先保留資料接口,等有穩定合法來源後再啟用,不會影響目前作戰清單。", }, + { + "code": "lazada", + "display_name": "Lazada", + "platform_code": "lazada", + "status_code": "paused", + "status_label": "待接入", + "source_kind": "connector_contract", + "input_methods": ["官方 API", "供應商 API", "手動 CSV"], + "data_quality_label": "暫不進告警", + "plain_note": "先建立跨境平台資料欄位,等合法穩定來源後才啟用價格與活動監控。", + }, + { + "code": "amazon", + "display_name": "Amazon", + "platform_code": "amazon", + "status_code": "paused", + "status_label": "待接入", + "source_kind": "connector_contract", + "input_methods": ["官方 API", "供應商 API", "手動 CSV"], + "data_quality_label": "暫不進告警", + "plain_note": "保留 SKU / ASIN / Marketplace 維度,未接正式來源前不參與自動判斷。", + }, + { + "code": "google_merchant", + "display_name": "Google Merchant / Shopping", + "platform_code": "google_merchant", + "status_code": "paused", + "status_label": "待接入", + "source_kind": "connector_contract", + "input_methods": ["官方報表", "官方 API", "手動 CSV"], + "data_quality_label": "暫不進告警", + "plain_note": "用於價格競爭力、商品資料完整度與成效 benchmark,待正式資料源接入。", + }, + { + "code": "tiktok_shop", + "display_name": "TikTok Shop", + "platform_code": "tiktok_shop", + "status_code": "paused", + "status_label": "待接入", + "source_kind": "connector_contract", + "input_methods": ["官方 API", "供應商 API", "手動 CSV"], + "data_quality_label": "暫不進告警", + "plain_note": "保留短影音電商的商品、促銷與內容導流欄位;未接合法穩定來源前不參與自動判斷。", + }, + { + "code": "line_shopping", + "display_name": "LINE 購物", + "platform_code": "line_shopping", + "status_code": "paused", + "status_label": "待接入", + "source_kind": "connector_contract", + "input_methods": ["官方報表", "供應商 API", "手動 CSV"], + "data_quality_label": "暫不進告警", + "plain_note": "保留本地導購、點數回饋與活動檔期訊號;接入前只列為待補來源。", + }, + { + "code": "rakuten", + "display_name": "Rakuten", + "platform_code": "rakuten", + "status_code": "paused", + "status_label": "待接入", + "source_kind": "connector_contract", + "input_methods": ["官方 API", "供應商 API", "手動 CSV"], + "data_quality_label": "暫不進告警", + "plain_note": "保留日本與海外平台比價欄位,未確認來源前只列入待接入清單。", + }, + { + "code": "yahoo_shopping", + "display_name": "Yahoo 購物", + "platform_code": "yahoo_shopping", + "status_code": "paused", + "status_label": "待接入", + "source_kind": "connector_contract", + "input_methods": ["官方 API", "供應商 API", "手動 CSV"], + "data_quality_label": "暫不進告警", + "plain_note": "保留本地平台促銷與價格監控欄位,接入前不影響現有作戰清單。", + }, + { + "code": "ruten", + "display_name": "露天", + "platform_code": "ruten", + "status_code": "paused", + "status_label": "待接入", + "source_kind": "connector_contract", + "input_methods": ["官方 API", "供應商 API", "手動 CSV"], + "data_quality_label": "暫不進告警", + "plain_note": "保留拍賣與長尾賣場價格訊號;正式來源未確認前不進入自動告警。", + }, + { + "code": "shopify_brand_store", + "display_name": "品牌官網 / Shopify", + "platform_code": "shopify_brand_store", + "status_code": "paused", + "status_label": "待接入", + "source_kind": "connector_contract", + "input_methods": ["官方 API", "商品 Feed", "手動 CSV"], + "data_quality_label": "暫不進告警", + "plain_note": "保留品牌官網售價、組合與活動檔期,用來和 PChome 主推品對照。", + }, + { + "code": "meta_commerce", + "display_name": "Meta Commerce", + "platform_code": "meta_commerce", + "status_code": "paused", + "status_label": "待接入", + "source_kind": "connector_contract", + "input_methods": ["官方 Catalog API", "手動 CSV"], + "data_quality_label": "暫不進告警", + "plain_note": "保留廣告商品目錄與商品連結欄位,用於後續曝光與導流成效對照。", + }, { "code": "coupang", "display_name": "酷澎", @@ -230,6 +348,75 @@ def _load_json_dict(value: Any) -> dict[str, Any]: return {} +def _looks_like_pchome_product_id(product_id: Any) -> bool: + return bool(PCHOME_PRODUCT_ID_RE.match(str(product_id or "").strip())) + + +def _absolute_pchome_image_url(path: Any) -> str: + image_path = str(path or "").strip() + if not image_path: + return "" + if image_path.startswith("http://") or image_path.startswith("https://"): + return image_path + if not image_path.startswith("/"): + image_path = f"/{image_path}" + return f"{PCHOME_IMAGE_CDN_BASE}{image_path}" + + +def _pchome_api_timeout_seconds() -> float: + try: + return max(1.0, min(float(os.getenv("PCHOME_PUBLIC_API_TIMEOUT_SECONDS", "4")), 10.0)) + except (TypeError, ValueError): + return 4.0 + + +def _parse_pchome_jsonp(payload: str) -> dict[str, Any]: + match = re.search(r"jsonp\((.*)\);\s*}\s*catch", payload or "", re.DOTALL) + if not match: + match = re.search(r"jsonp\((.*)\);", payload or "", re.DOTALL) + if not match: + return {} + parsed = json.loads(match.group(1)) + return parsed if isinstance(parsed, dict) else {} + + +def _fetch_pchome_public_image_map(product_ids: list[str]) -> dict[str, str]: + """Fetch PChome product images from the public product API without blocking the list.""" + ids = [ + str(product_id or "").strip() + for product_id in product_ids + if _looks_like_pchome_product_id(product_id) + ] + ids = list(dict.fromkeys(ids)) + if not ids: + return {} + + url = ( + f"{PCHOME_PUBLIC_PRODUCT_API}" + f"?id={quote(','.join(ids), safe=',')}" + "&fields=Id,Name,Nick,Pic,Price" + "&_callback=jsonp" + ) + try: + request = Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urlopen(request, timeout=_pchome_api_timeout_seconds()) as response: + raw = response.read(512000).decode("utf-8", "ignore") + products = _parse_pchome_jsonp(raw) + except Exception as exc: + logger.warning("[ExternalOffer] PChome image API failed: %s", exc) + return {} + + image_map: dict[str, str] = {} + for product_id, product in products.items(): + if not isinstance(product, dict): + continue + pic = product.get("Pic") if isinstance(product.get("Pic"), dict) else {} + image_url = _absolute_pchome_image_url(pic.get("B") or pic.get("S") or pic.get("W")) + if image_url: + image_map[str(product_id)] = image_url + return image_map + + _PCHOME_PRODUCT_URL_BASE = "https://24h.pchome.com.tw/prod/" _REVIEW_REASON_LABELS = { @@ -1491,7 +1678,7 @@ def build_external_source_readiness(engine=None) -> dict[str, Any]: "review_offer_count": review_offer_count, "sources": sources, "connector_contract": build_connector_contracts(), - "plain_summary": "MOMO 先用;蝦皮與酷澎先保留接口,暫不進告警。", + "plain_summary": "MOMO 先用;其他主流平台已列管,未接合法穩定來源前不進告警。", } @@ -1541,6 +1728,11 @@ def list_momo_review_candidates(engine, *, limit: int = 20) -> dict[str, Any]: LIMIT :limit """), {"limit": limit * 4}).mappings().all() + pchome_image_map = _fetch_pchome_public_image_map([ + str(row.get("pchome_product_id") or "").strip() + for row in rows + ]) + seen: set[tuple[str, str]] = set() items: list[dict[str, Any]] = [] for row in rows: @@ -1567,19 +1759,28 @@ def list_momo_review_candidates(engine, *, limit: int = 20) -> dict[str, Any]: pchome_price = _to_float(raw_payload.get("pchome_public_price")) momo_price = _to_float(row.get("price")) gap_pct = _to_float(raw_payload.get("target_gap_pct")) + pchome_image_url = ( + raw_payload.get("pchome_image_url") + or raw_payload.get("pchome_public_image_url") + or pchome_image_map.get(str(pchome_product_id or "").strip()) + or "" + ) + momo_image_url = row.get("image_url") items.append({ "id": int(row.get("id")), "pchome_product_id": pchome_product_id, "pchome_product_name": raw_payload.get("pchome_public_name") or "", "pchome_url": _build_pchome_product_url(pchome_product_id), + "pchome_image_url": pchome_image_url, "pchome_price": pchome_price, "momo_sku": row.get("momo_sku") or row.get("source_product_id"), "momo_title": row.get("title"), "momo_price": momo_price, "momo_url": momo_url, "product_url": momo_url, - "image_url": row.get("image_url"), + "image_url": momo_image_url, + "momo_image_url": momo_image_url, "quality_score": round(_to_float(row.get("quality_score")) or 0.0, 2), "alert_tier": raw_payload.get("alert_tier") or "identity_review", "price_basis": raw_payload.get("price_basis") or "manual_review", diff --git a/templates/ai_intelligence.html b/templates/ai_intelligence.html index e73fbb9..5c1d588 100644 --- a/templates/ai_intelligence.html +++ b/templates/ai_intelligence.html @@ -58,7 +58,7 @@ align-items: center; justify-content: flex-end; gap: 8px; - max-width: 680px; + max-width: 780px; } .growth-command-status-pill { @@ -75,6 +75,53 @@ padding: 7px 12px; } + .growth-command-action-group { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; + } + + .growth-command-action-group.is-secondary { + padding-left: 8px; + border-left: 1px solid rgba(42, 37, 32, 0.12); + } + + .growth-command-pro .ai-action-btn { + min-height: 38px; + border-width: 1px; + box-shadow: none; + } + + .growth-command-pro .ai-action-btn.btn-primary { + background: #2267d5; + border-color: #2267d5; + color: #fff; + } + + .growth-command-pro .ai-action-btn.btn-primary:hover, + .growth-command-pro .ai-action-btn.btn-primary:focus { + background: #1d58b9; + border-color: #1d58b9; + } + + .growth-command-pro .ai-action-btn.btn-outline-primary, + .growth-command-pro .ai-action-btn.btn-outline-secondary { + background: #fff; + border-color: rgba(71, 82, 97, 0.45); + color: #3e4a59; + } + + .growth-command-pro .ai-action-btn.btn-outline-primary:hover, + .growth-command-pro .ai-action-btn.btn-outline-primary:focus, + .growth-command-pro .ai-action-btn.btn-outline-secondary:hover, + .growth-command-pro .ai-action-btn.btn-outline-secondary:focus { + background: rgba(34, 103, 213, 0.08); + border-color: rgba(34, 103, 213, 0.55); + color: #1f4f9f; + } + .growth-command-kpi-grid { display: grid; grid-template-columns: 1.28fr repeat(4, minmax(0, 1fr)); @@ -425,6 +472,10 @@ box-shadow: var(--momo-shadow-soft); } + .ai-intel-legacy-status { + display: none; + } + .ai-intel-hero::after { content: ""; position: absolute; @@ -1869,6 +1920,7 @@ .growth-source-list { display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 10px; } @@ -2285,6 +2337,21 @@ font-size: 1rem; } + .review-candidate-thumb.is-missing { + align-content: center; + gap: 2px; + color: #786f63; + font-size: 0.62rem; + font-weight: 900; + line-height: 1.1; + text-align: center; + } + + .review-candidate-thumb.is-missing i { + display: block; + font-size: 0.92rem; + } + .review-candidate-thumb img { width: 100%; height: 100%; @@ -2312,6 +2379,24 @@ line-height: 1.25; } + .review-candidate-store-meta { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 6px; + } + + .review-candidate-store-meta span { + border: 1px solid rgba(42, 37, 32, 0.08); + border-radius: 999px; + background: rgba(255, 255, 255, 0.7); + color: var(--momo-text-muted); + font-family: var(--momo-font-mono); + font-size: 0.66rem; + font-weight: 900; + padding: 2px 7px; + } + .review-candidate-store a { white-space: nowrap; font-size: 0.7rem; @@ -2605,6 +2690,7 @@ } .growth-ops-grid, + .growth-source-list, .review-candidate-panel, .growth-executive-strip, .offer-dryrun-grid, @@ -2746,25 +2832,29 @@

PChome 業績成長系統

-

評估業績、分析價差、決定今天的解法。

+

先看今日優先商品,再決定價格、曝光、組合與資料補強。

讀取中 - - - - +
+ + +
+
+ + +
@@ -2898,7 +2988,7 @@ -
+