From 15010ab72480b15be79a8ce74d2f0e1b5e33fdde Mon Sep 17 00:00:00 2001 From: OoO Date: Tue, 16 Jun 2026 11:27:12 +0800 Subject: [PATCH] V10.620 automate unit price candidates --- config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 5 +- .../current_execution_queue_20260524.md | 9 +- routes/price_comparison_routes.py | 61 +++++-- services/momo_crawler.py | 64 +++++++- templates/price_comparison.html | 151 +++++++++++++++++- tests/test_frontend_v2_assets.py | 5 + tests/test_momo_crawler_targeted_search.py | 13 +- tests/test_price_comparison_routes.py | 23 ++- 9 files changed, 295 insertions(+), 38 deletions(-) diff --git a/config.py b/config.py index 4a7bf62..b43c9ac 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.619" +SYSTEM_VERSION = "V10.620" 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 a1b4ce3..25b1356 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-06-16 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯;PChome 後台業績匯入韌性已補強;產品定位正名為「PChome 業績成長自動化作戰系統」;外部市場來源正規化層、自動同步、作戰清單與價格參考表優先讀取、CSV 備援預檢、前台操作入口與高可見頁面繁中化守門已建立 -> **適用版本**: V10.619 +> **適用版本**: V10.620 --- @@ -65,7 +65,8 @@ - V10.616 起主商品看板 `/` 的統計與補強區塊也納入繁中守門:不得顯示 `ACTIVE`、`PICK COUNT`、`AVG CONFIDENCE`、`EVIDENCE GAP`、`PCHOME MATCH BACKFILL` 等工程標籤;畫面需使用「有效商品」「挑品數」「平均信心」「待補證據」「PChome 比價補強」等白話營運文案。 - V10.617 起 `/ai_intelligence` 必須採「先給下一步」的作戰導向 UI:首屏需先回答「今天先做什麼」,再呈現商品處理進度、外部價格來源與操作捷徑;今日處理清單需用表格呈現優先級、建議動作、商品、近 7 天業績、比價結果、資料可信度與下一步;MOMO 外部價格參考需顯示價格風險分佈,且表格需以 PChome 價格優先,明確顯示「PChome 貴 / PChome 便宜」與可信度,不得只用大段文字說明使用方式。 - V10.618 起 `/price_comparison` 也必須採「先給下一步」的比價決策 UI:首屏需顯示目前卡在哪一步、PChome / MOMO 資料準備狀態與下一個按鈕;比價結果需先呈現「需檢查價格 / 可主推曝光 / 價格接近」分佈,再用表格列出每筆商品的下一步,不得只呈現 Step 流程或原始價差表。 -- V10.619 起 MOMO 比價候選來源新增「PChome 商品導向搜尋」:當比價 API 已有 PChome 商品但缺 MOMO 清單時,必須用每筆 PChome 商品名稱產生精準搜尋詞反查 MOMO,保留品牌、品名、容量與組合線索;新版 MOMO 搜尋頁需解析 Next.js `goodsInfoList` payload。此路徑只擴大候選池,不放寬同款 matcher 門檻;`unit_comparable` 與 hard veto 候選只能標成「需人工確認」,不得直接進自動比價告警。 +- V10.619 起 MOMO 比價候選來源新增「PChome 商品導向搜尋」:當比價 API 已有 PChome 商品但缺 MOMO 清單時,必須用每筆 PChome 商品名稱產生精準搜尋詞反查 MOMO,保留品牌、品名、容量與組合線索;新版 MOMO 搜尋頁需解析 Next.js `goodsInfoList` payload。此路徑只擴大候選池,不放寬同款 matcher 門檻。 +- V10.620 起 `unit_comparable` 不再一律丟人工確認:若 `build_unit_price_comparison()` 可產生明確容量/數量、MOMO 單位價、PChome 單位價與差距百分比,候選需標為「自動單位價比較」並回傳 `auto_compare_type=unit_price`。此類候選可自動呈現價格壓力,但不得混入舊總價同款比價表,也不得直接寫入正式價差或自動改價;無法產生單位證據時才維持「需人工確認」。 ## 零之一、12 Agent 決策信封(2026-05-24) diff --git a/docs/memory/current_execution_queue_20260524.md b/docs/memory/current_execution_queue_20260524.md index 0c61a5b..8a2a81a 100644 --- a/docs/memory/current_execution_queue_20260524.md +++ b/docs/memory/current_execution_queue_20260524.md @@ -274,4 +274,11 @@ - `/api/price_comparison/compare` 在已有 PChome 商品但缺 MOMO 清單時,會優先走 PChome 導向 MOMO 搜尋;完全沒有 PChome 商品時才退回品牌搜尋。 - MOMO 搜尋 parser 已補新版 Next.js `goodsInfoList`,避免明明搜尋頁有商品但 crawler 回 0 筆。 - `/price_comparison` 已新增「自動找 MOMO 候選」操作,PChome 商品準備後可直接搜尋 MOMO;回傳會分成「可直接比價」與「需人工確認」。 -- 新路徑只擴大候選池,不放寬 `score_marketplace_match()` 的 hard veto 與同款分數篩選;`unit_comparable` 候選保留為「需人工確認」,不得直接進自動比價。後續才評估把這條路徑接進背景自動同步 / `external_offers`。 +- 新路徑只擴大候選池,不放寬 `score_marketplace_match()` 的 hard veto 與同款分數篩選;V10.619 當時先把 `unit_comparable` 候選保留為「需人工確認」,此限制已由 V10.620 的自動單位價分流取代。 + +## 23. 2026-06-16 V10.620 單位價候選自動化分流 + +- 使用者要求把需要人工處理的比價工作降到最低;V10.620 將 PChome 導向 MOMO 搜尋的 `unit_comparable` 候選改成三路分流:同款總價、可自動換算單位價、真正需人工確認。 +- `search_momo_products_for_pchome_products()` 會在 `unit_comparable` 時呼叫 `build_unit_price_comparison()`;只有能算出雙方總容量/數量、單位價與差距百分比時,才標成 `auto_compare_type=unit_price` 與「自動單位價比較」。 +- `/api/price_comparison/fetch_momo_for_pchome` 回傳 `products`、`unit_compare_candidates`、`review_candidates` 三段;舊總價比價只吃 `products`,避免把組合包總價誤當同款價差。 +- `/price_comparison` 顯示「同款 / 單位價 / 需確認」三個數量,並新增自動單位價面板;若只找到單位價候選,下一步會引導使用者查看單位價結果,而不是人工確認。 diff --git a/routes/price_comparison_routes.py b/routes/price_comparison_routes.py index 72cac64..2172829 100644 --- a/routes/price_comparison_routes.py +++ b/routes/price_comparison_routes.py @@ -20,6 +20,16 @@ logger = logging.getLogger(__name__) price_comparison_bp = Blueprint('price_comparison', __name__) +def _candidate_auto_compare_type(item: dict) -> str: + """Normalize old and new targeted MOMO candidate flags.""" + explicit_type = item.get("auto_compare_type") + if explicit_type: + return explicit_type + if item.get("can_auto_compare"): + return "total_price" + return "manual_review" + + # ============================================ # 頁面路由 # ============================================ @@ -103,22 +113,27 @@ def compare_prices(): max_products=30, limit_per_product=8, ) + exact_products = [ + item for item in (targeted_momo_products or []) + if _candidate_auto_compare_type(item) == "total_price" + ] + unit_compare_candidates = [ + item for item in (targeted_momo_products or []) + if _candidate_auto_compare_type(item) == "unit_price" + ] + review_candidates = [ + item for item in (targeted_momo_products or []) + if _candidate_auto_compare_type(item) not in {"total_price", "unit_price"} + ] targeted_momo_summary = { "message": msg, "candidate_count": len(targeted_momo_products or []), - "auto_compare_count": len([ - item for item in (targeted_momo_products or []) - if item.get("can_auto_compare") - ]), - "review_count": len([ - item for item in (targeted_momo_products or []) - if not item.get("can_auto_compare") - ]), + "auto_compare_count": len(exact_products) + len(unit_compare_candidates), + "exact_compare_count": len(exact_products), + "unit_compare_count": len(unit_compare_candidates), + "review_count": len(review_candidates), } - momo_products = [ - item for item in (targeted_momo_products or []) - if item.get("can_auto_compare") - ] + momo_products = exact_products else: logger.info(f"自動搜尋 MOMO: {brand}") success, msg, momo_products = search_momo_products(brand, limit=100) @@ -201,16 +216,30 @@ def fetch_momo_for_pchome_products(): max_products=30, limit_per_product=8, ) - auto_products = [item for item in products if item.get("can_auto_compare")] - review_candidates = [item for item in products if not item.get("can_auto_compare")] + exact_products = [ + item for item in products + if _candidate_auto_compare_type(item) == "total_price" + ] + unit_compare_candidates = [ + item for item in products + if _candidate_auto_compare_type(item) == "unit_price" + ] + review_candidates = [ + item for item in products + if _candidate_auto_compare_type(item) not in {"total_price", "unit_price"} + ] return jsonify({ 'success': success, 'message': message, 'data': { - 'products': auto_products, + 'products': exact_products, + 'unit_compare_candidates': unit_compare_candidates, 'review_candidates': review_candidates, - 'count': len(auto_products), + 'count': len(exact_products), + 'exact_compare_count': len(exact_products), + 'unit_compare_count': len(unit_compare_candidates), + 'auto_compare_count': len(exact_products) + len(unit_compare_candidates), 'review_count': len(review_candidates), 'candidate_count': len(products), } diff --git a/services/momo_crawler.py b/services/momo_crawler.py index 1813211..ad8d7b3 100644 --- a/services/momo_crawler.py +++ b/services/momo_crawler.py @@ -612,7 +612,10 @@ def search_momo_products_for_pchome_products( return False, "沒有 PChome 商品可用來搜尋 MOMO", [] try: - from services.marketplace_product_matcher import score_marketplace_match + from services.marketplace_product_matcher import ( + build_unit_price_comparison, + score_marketplace_match, + ) except Exception as exc: logger.error("[MOMO] 無法載入商品比對工具: %s", exc, exc_info=True) return False, "商品比對工具暫時不可用", [] @@ -642,10 +645,11 @@ def search_momo_products_for_pchome_products( momo_name = _product_name_from_payload(row) if not momo_name: continue + momo_price = _to_float(row.get("price")) diagnostics = score_marketplace_match( - pchome_name, momo_name, - momo_price=_to_float(row.get("price")), + pchome_name, + momo_price=momo_price, competitor_price=pchome_price, ) score = float(getattr(diagnostics, "score", 0.0) or 0.0) @@ -653,7 +657,43 @@ def search_momo_products_for_pchome_products( continue hard_veto = bool(getattr(diagnostics, "hard_veto", False)) comparison_mode = getattr(diagnostics, "comparison_mode", "exact_identity") - can_auto_compare = not hard_veto and comparison_mode == "exact_identity" + unit_price_comparison = {} + auto_compare_type = "manual_review" + price_basis = "none" + review_status = "需人工確認" + if not hard_veto and comparison_mode == "exact_identity": + can_auto_compare = True + auto_compare_type = "total_price" + price_basis = "total_price" + review_status = "可直接比價" + elif comparison_mode == "unit_comparable": + unit_price_comparison = build_unit_price_comparison( + momo_name, + pchome_name, + momo_price=momo_price, + competitor_price=pchome_price, + ) + can_auto_compare = bool(unit_price_comparison.get("comparable")) + if can_auto_compare: + auto_compare_type = "unit_price" + price_basis = "unit_price" + review_status = "自動單位價比較" + else: + price_basis = "unit_price_review" + else: + can_auto_compare = False + + if comparison_mode != "unit_comparable": + unit_price_comparison = {} + + gap_pct = None + if unit_price_comparison: + gap_pct = unit_price_comparison.get("unit_gap_pct") + elif pchome_price: + try: + gap_pct = (float(momo_price or 0) - float(pchome_price)) / float(pchome_price) * 100 + except (TypeError, ValueError, ZeroDivisionError): + gap_pct = None product_id = str(row.get("product_id") or row.get("goodsCode") or row.get("id") or "").strip() if not product_id: @@ -672,7 +712,11 @@ def search_momo_products_for_pchome_products( "target_comparison_mode": comparison_mode, "target_hard_veto": hard_veto, "can_auto_compare": can_auto_compare, - "target_review_status": "可直接比價" if can_auto_compare else "需人工確認", + "auto_compare_type": auto_compare_type, + "target_price_basis": price_basis, + "target_gap_pct": round(float(gap_pct), 2) if gap_pct is not None else None, + "target_unit_price_comparison": unit_price_comparison, + "target_review_status": review_status, "source_strategy": "pchome_targeted_momo_search", }) candidates_by_id[product_id] = row @@ -684,11 +728,15 @@ def search_momo_products_for_pchome_products( ) if not candidates: return False, f"已用 {searched_products} 筆 PChome 商品搜尋 MOMO,但沒有找到可用候選", [] - auto_count = sum(1 for item in candidates if item.get("can_auto_compare")) - review_count = len(candidates) - auto_count + exact_count = sum(1 for item in candidates if item.get("auto_compare_type") == "total_price") + unit_count = sum(1 for item in candidates if item.get("auto_compare_type") == "unit_price") + review_count = len(candidates) - exact_count - unit_count return ( True, - f"已用 {searched_products} 筆 PChome 商品搜尋 MOMO,找到 {len(candidates)} 筆候選(可直接比價 {auto_count} 筆、需人工確認 {review_count} 筆)", + ( + f"已用 {searched_products} 筆 PChome 商品搜尋 MOMO,找到 {len(candidates)} 筆候選" + f"(可直接比價 {exact_count} 筆、自動單位價比較 {unit_count} 筆、需人工確認 {review_count} 筆)" + ), candidates, ) diff --git a/templates/price_comparison.html b/templates/price_comparison.html index a37fd02..c8b0fbf 100644 --- a/templates/price_comparison.html +++ b/templates/price_comparison.html @@ -234,6 +234,51 @@ color: var(--momo-info-text); } +.price-note.is-unit { + background: var(--momo-success-bg); + border-color: var(--momo-success-border); + color: var(--momo-success-text); +} + +.price-unit-list { + display: grid; + gap: 8px; + margin-top: 8px; +} + +.price-unit-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 8px 10px; + border: 1px solid rgba(46, 125, 91, 0.2); + border-radius: var(--momo-radius-sm); + background: rgba(255, 255, 255, 0.45); +} + +.price-unit-name { + min-width: 0; + overflow: hidden; + color: var(--momo-text-primary); + font-size: 0.82rem; + font-weight: 850; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.price-unit-metric { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--momo-success-text); + font-family: var(--momo-font-mono, monospace); + font-size: var(--momo-text-label); + font-weight: 900; + white-space: nowrap; +} + .price-table-head th { background: var(--momo-bg-paper) !important; color: var(--momo-text-secondary) !important; @@ -318,6 +363,15 @@ .price-next-action .btn { width: 100%; } + + .price-unit-item { + grid-template-columns: 1fr; + } + + .price-unit-metric { + justify-content: flex-start; + white-space: normal; + } } {% endblock %} @@ -419,8 +473,10 @@
0 筆商品 + 0 筆單位價 0 筆需確認
+ @@ -619,6 +675,7 @@ La Roche-Posay 安得利防曬液 50ml,920