diff --git a/config.py b/config.py index f70d27e..4a30dc9 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.637" +SYSTEM_VERSION = "V10.638" 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 5c4e497..2041fbb 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -73,6 +73,7 @@ - V10.621 起 `/price_comparison` 的「自動找 MOMO 候選」會把可直接總價比價與自動單位價候選同步到 `external_offers`,`ingestion_method='targeted_momo_search'`,人工確認候選不得寫入。`external_offers.raw_payload_json.price_basis='unit_price'` 時,作戰清單必須使用 `unit_price_comparison` 的 MOMO / PChome 單位價與 `unit_gap_pct` 判斷價格壓力;不得把 MOMO 組合總價與 PChome 單品總價直接相減。此同步只影響外部價格參考與作戰清單,不寫 `competitor_prices`,也不自動改價。 - V10.622 起任何 `external_offers` 自動同步成功寫入後,必須呼叫 `mark_pchome_growth_cache_stale()` 寫入共享 cache epoch;`/api/ai/pchome-growth/opportunities` 讀快取前必須比對 `get_pchome_growth_cache_epoch()`。這是跨 Gunicorn worker 的可見性保護,避免自動候選已進外部價格參考,但 AI 情報頁仍回 120 秒舊作戰清單。 - V10.623 起 `/price_comparison` 與 `/ai_intelligence` 不得只靠大段文字說明流程:比價頁第一屏必須有主 KPI、目前卡點、四步流程與結果決策摘要;作戰頁第一屏必須有今日任務、可立即處理、待補比價與最新業績日。所有狀態都要由實際 API/前端狀態驅動,讓使用者一眼知道下一步要按哪個動作。 +- V10.638 起 PChome 導向 MOMO 補抓會把「找到但不能自動比價」的候選以 `match_status='needs_review'`、`data_quality_status='needs_review'` 保存到 `external_offers`;這些候選不得進價格壓力判斷,也不得發告警,但 `/api/ai/pchome-growth/opportunities` 可回傳待確認候選數,讓 UI 顯示「已有候選待確認」而不是只顯示無法比價。 ## 零之一、12 Agent 決策信封(2026-05-24) diff --git a/services/external_market_offer_service.py b/services/external_market_offer_service.py index c4ef62c..38b6673 100644 --- a/services/external_market_offer_service.py +++ b/services/external_market_offer_service.py @@ -834,6 +834,92 @@ def _targeted_candidate_to_external_offer( }, "" +def _targeted_review_candidate_to_external_offer( + candidate: dict[str, Any], + *, + observed_at: datetime, +) -> tuple[dict[str, Any] | None, str]: + """保存待人工確認候選;這類資料不得進價格判斷。""" + alert_tier = str(candidate.get("target_alert_tier") or "").strip() + match_type = str(candidate.get("target_match_type") or "").strip() + if alert_tier not in {"identity_review", "unit_price_review"} and match_type in {"", "no_match"}: + return None, "不是可人工確認候選" + + momo_sku = str(candidate.get("product_id") or candidate.get("goodsCode") or candidate.get("id") or "").strip() + pchome_product_id = str(candidate.get("target_pchome_product_id") or "").strip() + momo_price = _to_float(candidate.get("price")) + if not momo_sku: + return None, "缺少 MOMO 商品 ID" + if not pchome_product_id: + return None, "缺少 PChome 商品 ID" + if not momo_price or momo_price <= 0: + return None, "缺少 MOMO 售價" + + match_score = _quality_score_from_match(candidate.get("target_match_score")) + if match_score < 60: + return None, "人工確認候選分數過低" + + unit_price_comparison = ( + candidate.get("target_unit_price_comparison") + if isinstance(candidate.get("target_unit_price_comparison"), dict) + else {} + ) + title = str(candidate.get("name") or candidate.get("title") or momo_sku).strip() + price_basis = str(candidate.get("target_price_basis") or "manual_review").strip() + raw_payload = { + "source": "pchome_targeted_momo_search", + "review_state": "needs_review", + "auto_compare_type": "manual_review", + "price_basis": price_basis, + "pchome_public_price": _to_float(candidate.get("target_pchome_price")), + "pchome_public_name": candidate.get("target_pchome_name"), + "match_score": candidate.get("target_match_score"), + "match_reasons": candidate.get("target_match_reasons") or [], + "comparison_mode": candidate.get("target_comparison_mode"), + "match_type": match_type, + "alert_tier": alert_tier, + "hard_veto": bool(candidate.get("target_hard_veto")), + "target_gap_pct": candidate.get("target_gap_pct"), + "unit_price_comparison": unit_price_comparison, + "search_term": candidate.get("target_search_term"), + "tags": [ + "identity_v2", + "source_targeted_momo_search", + "needs_review", + f"review_{alert_tier or 'identity_review'}", + ], + } + return { + "source_code": "momo_reference", + "platform_code": "momo", + "source_product_id": momo_sku, + "source_offer_key": f"momo_reference:{momo_sku}:{pchome_product_id}:needs_review", + "title": title or momo_sku, + "brand": candidate.get("brand"), + "category_text": candidate.get("category") or candidate.get("category_text"), + "product_url": candidate.get("product_url") or candidate.get("url"), + "image_url": candidate.get("image_url"), + "price": momo_price, + "original_price": _to_float(candidate.get("original_price")), + "currency": "TWD", + "stock_status": None, + "sold_count": None, + "rating": None, + "review_count": None, + "observed_at": observed_at, + "expires_at": None, + "ingestion_method": "targeted_momo_review", + "connector_key": "pchome_targeted_momo_search_review", + "pchome_product_id": pchome_product_id, + "momo_sku": momo_sku, + "match_status": "needs_review", + "quality_score": round(match_score, 2), + "data_quality_status": "needs_review", + "quality_notes_json": json.dumps(["候選已找到,需人工確認同款或色號"], ensure_ascii=False), + "raw_payload_json": json.dumps(raw_payload, ensure_ascii=False), + }, "" + + def sync_targeted_momo_candidates_to_external_offers( engine, candidates: list[dict[str, Any]], @@ -914,6 +1000,76 @@ def sync_targeted_momo_candidates_to_external_offers( } +def sync_targeted_momo_review_candidates_to_external_offers( + engine, + candidates: list[dict[str, Any]], + *, + dry_run: bool = False, +) -> dict[str, Any]: + """把待確認 MOMO 候選保存起來,供人工審核;不進價格告警。""" + generated_at = datetime.now().isoformat(timespec="seconds") + candidates = list(candidates or []) + required_tables = {"external_market_sources", "external_offers"} + + with engine.begin() as conn: + missing_tables = sorted(table for table in required_tables if not _has_table(conn, table)) + if missing_tables: + return { + "success": False, + "status": "skipped", + "generated_at": generated_at, + "candidate_count": len(candidates), + "written_count": 0, + "dry_run": dry_run, + "message": "待確認候選暫時無法保存,缺少必要資料表。", + "missing_tables": missing_tables, + } + + _ensure_external_market_source_seeds(conn) + base_observed_at = datetime.now() + ranked_offers: list[tuple[dict[str, Any], tuple[float, float, float, float]]] = [] + skipped_reasons: dict[str, int] = {} + for index, candidate in enumerate(candidates): + offer, reason = _targeted_review_candidate_to_external_offer( + candidate, + observed_at=base_observed_at + timedelta(microseconds=index), + ) + if offer: + ranked_offers.append((offer, _targeted_candidate_sync_rank(candidate))) + else: + skipped_reasons[reason] = skipped_reasons.get(reason, 0) + 1 + + selected_by_pchome: dict[str, tuple[dict[str, Any], tuple[float, float, float, float]]] = {} + for offer, rank in ranked_offers: + key = str(offer.get("pchome_product_id") or offer.get("source_offer_key") or "").strip() + existing = selected_by_pchome.get(key) + if existing is None or rank > existing[1]: + selected_by_pchome[key] = (offer, rank) + offers = [offer for offer, _ in selected_by_pchome.values()] + + if not dry_run: + for offer in offers: + _upsert_external_offer(conn, offer) + if offers: + mark_pchome_growth_cache_stale() + + return { + "success": True, + "status": "dry_run" if dry_run else "synced", + "generated_at": generated_at, + "candidate_count": len(candidates), + "written_count": 0 if dry_run else len(offers), + "dry_run": dry_run, + "source_code": "momo_reference", + "skipped_reasons": skipped_reasons, + "message": ( + "已保存待人工確認的 MOMO 候選。" + if not dry_run + else "已完成待確認候選預檢,尚未寫入資料。" + ), + } + + def sync_legacy_momo_reference_offers(engine, *, limit: int = 500, dry_run: bool = False) -> dict[str, Any]: """把既有已確認同款的比價快取自動同步到 external_offers。""" limit = max(1, min(int(limit or 500), 5000)) @@ -1172,6 +1328,11 @@ def _normalized_offer_stats(conn) -> dict[str, dict[str, Any]]: AND COALESCE(o.quality_score, 0) >= 76 THEN 1 ELSE 0 END ) AS usable_offer_count, + SUM(CASE + WHEN o.match_status = 'needs_review' + OR o.data_quality_status = 'needs_review' + THEN 1 ELSE 0 END + ) AS review_offer_count, MAX(o.observed_at) AS last_seen_at FROM external_market_sources s LEFT JOIN external_offers o ON o.source_code = s.code @@ -1184,6 +1345,7 @@ def _normalized_offer_stats(conn) -> dict[str, dict[str, Any]]: "enabled": bool(row.get("enabled")), "offer_count": int(row.get("offer_count") or 0), "usable_offer_count": int(row.get("usable_offer_count") or 0), + "review_offer_count": int(row.get("review_offer_count") or 0), "last_seen_at": str(row.get("last_seen_at") or "") or None, } for row in rows @@ -1244,16 +1406,23 @@ def build_external_source_readiness(engine=None) -> dict[str, Any]: source["schema_ready"] = schema_ready source["usable_offer_count"] = usable + source["review_offer_count"] = int(stats.get("review_offer_count") or 0) source["last_seen_at"] = last_seen_at source["can_alert"] = source["status_code"] == "active" and usable > 0 if source["status_code"] == "active": - source["plain_state"] = "已接入,可進作戰清單" if usable else "已接入,等待可用資料" + if usable: + source["plain_state"] = "已接入,可進作戰清單" + elif source["review_offer_count"]: + source["plain_state"] = "已有待確認候選" + else: + source["plain_state"] = "已接入,等待可用資料" else: source["plain_state"] = "先保留接口,不進告警" active_count = sum(1 for source in sources if source["status_code"] == "active") paused_count = sum(1 for source in sources if source["status_code"] == "paused") usable_count = sum(int(source["usable_offer_count"]) for source in sources) + review_offer_count = sum(int(source.get("review_offer_count") or 0) for source in sources) return { "success": True, @@ -1261,6 +1430,7 @@ def build_external_source_readiness(engine=None) -> dict[str, Any]: "active_count": active_count, "paused_count": paused_count, "usable_offer_count": usable_count, + "review_offer_count": review_offer_count, "sources": sources, "connector_contract": build_connector_contracts(), "plain_summary": "MOMO 先用;蝦皮與酷澎先保留接口,暫不進告警。", diff --git a/services/pchome_growth_momo_backfill_service.py b/services/pchome_growth_momo_backfill_service.py index c1d787b..8fe632f 100644 --- a/services/pchome_growth_momo_backfill_service.py +++ b/services/pchome_growth_momo_backfill_service.py @@ -97,6 +97,12 @@ def _default_sync_candidates(engine, candidates: list[dict[str, Any]]) -> dict[s return sync_targeted_momo_candidates_to_external_offers(engine, candidates, dry_run=False) +def _default_sync_review_candidates(engine, candidates: list[dict[str, Any]]) -> dict[str, Any]: + from services.external_market_offer_service import sync_targeted_momo_review_candidates_to_external_offers + + return sync_targeted_momo_review_candidates_to_external_offers(engine, candidates, dry_run=False) + + def run_pchome_growth_momo_backfill( engine, *, @@ -104,16 +110,18 @@ def run_pchome_growth_momo_backfill( build_payload_func: Callable[[Any, int], dict[str, Any]] | None = None, search_func: Callable[[list[dict[str, Any]], int], tuple[bool, str, list[dict[str, Any]]]] | None = None, sync_func: Callable[[Any, list[dict[str, Any]]], dict[str, Any]] | None = None, + sync_review_func: Callable[[Any, list[dict[str, Any]]], dict[str, Any]] | None = None, ) -> dict[str, Any]: """補高業績 PChome 商品的 MOMO 對應。 不呼叫 LLM,只搜尋 MOMO 候選,並只把可自動判斷的 total_price / unit_price - 寫入 external_offers;需人工確認的候選只回報、不寫入。 + 寫入 external_offers;需人工確認的候選會以 needs_review 保存,不進價格判斷。 """ limit = max(1, min(int(limit or 12), 20)) build_payload = build_payload_func or _default_build_payload search_candidates = search_func or _default_search_candidates sync_candidates = sync_func or _default_sync_candidates + sync_review_candidates = sync_review_func or _default_sync_review_candidates before_payload = build_payload(engine, max(limit, 16)) targets = build_momo_backfill_targets(before_payload, limit) @@ -135,6 +143,12 @@ def run_pchome_growth_momo_backfill( "written_count": 0, "message": "沒有需要同步的自動候選。", }, + "review_candidate_sync": { + "success": True, + "status": "not_needed", + "written_count": 0, + "message": "沒有需要保存的待確認候選。", + }, "before_stats": before_payload.get("stats") or {}, "after_stats": before_payload.get("stats") or {}, "targets": [], @@ -165,6 +179,14 @@ def run_pchome_growth_momo_backfill( } if auto_candidates: external_offer_sync = sync_candidates(engine, auto_candidates) + review_candidate_sync = { + "success": True, + "status": "not_found", + "written_count": 0, + "message": "沒有需要保存的待確認候選。", + } + if review_candidates: + review_candidate_sync = sync_review_candidates(engine, review_candidates) after_payload = build_payload(engine, max(limit, 16)) written_count = int(external_offer_sync.get("written_count") or 0) @@ -189,6 +211,7 @@ def run_pchome_growth_momo_backfill( "auto_compare_count": len(auto_candidates), "review_count": len(review_candidates), "external_offer_sync": external_offer_sync, + "review_candidate_sync": review_candidate_sync, "before_stats": before_payload.get("stats") or {}, "after_stats": after_payload.get("stats") or {}, "targets": targets[:8], diff --git a/services/pchome_revenue_growth_service.py b/services/pchome_revenue_growth_service.py index ea58f16..15c75d1 100644 --- a/services/pchome_revenue_growth_service.py +++ b/services/pchome_revenue_growth_service.py @@ -703,6 +703,7 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] limit = max(5, min(int(limit or 20), 50)) generated_at = datetime.now().isoformat(timespec="seconds") source_readiness = build_external_source_readiness(engine) + review_candidate_count = int(source_readiness.get("review_offer_count") or 0) source_scope = { "primary_goal": "提升 PChome 業績", "primary_sales_source": PRIMARY_SALES_SOURCE, @@ -724,6 +725,7 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] "mapped_count": 0, "mapping_rate": 0, "needs_mapping_count": 0, + "review_candidate_count": review_candidate_count, }, "opportunities": [], "message": "目前還沒有 PChome 業績資料,請先完成業績匯入。", @@ -745,6 +747,7 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] "mapped_count": 0, "mapping_rate": 0, "needs_mapping_count": 0, + "review_candidate_count": review_candidate_count, "total_sales_7d": 0, "opportunity_sales_7d": 0, "action_counts": {}, @@ -794,6 +797,7 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] "mapped_count": mapped_count, "mapping_rate": mapping_rate, "needs_mapping_count": needs_mapping_count, + "review_candidate_count": review_candidate_count, "total_sales_7d": round(sum(_to_float(item.get("sales_7d")) for item in opportunities), 2), "opportunity_sales_7d": round(sum(_to_float(item.get("sales_7d")) for item in opportunities), 2), "action_counts": action_counts, diff --git a/templates/ai_intelligence.html b/templates/ai_intelligence.html index 2c6b443..e3fb0ae 100644 --- a/templates/ai_intelligence.html +++ b/templates/ai_intelligence.html @@ -1196,6 +1196,10 @@ 無法比價 +
+ + 待確認 +

正在判斷今天優先處理順序...

來源整理中...

@@ -1724,6 +1728,7 @@ async function loadGrowthOps(forceRefresh = false) { document.getElementById('growthCandidateCount').textContent = (stats.candidate_count || 0).toLocaleString(); document.getElementById('growthMappedCount').textContent = (stats.mapped_count || 0).toLocaleString(); document.getElementById('growthNeedsMapping').textContent = (stats.needs_mapping_count || 0).toLocaleString(); + document.getElementById('growthReviewCandidateCount').textContent = (stats.review_candidate_count || 0).toLocaleString(); renderOpsCommandDashboard(stats, scope); renderGrowthActionHint(stats); renderGrowthDataSourceSummary(stats); @@ -1768,6 +1773,7 @@ function renderGrowthActionHint(stats) { const candidateCount = Number(stats.candidate_count || 0); const mappedCount = Number(stats.mapped_count || 0); const needsMapping = Number(stats.needs_mapping_count || 0); + const reviewCandidateCount = Number(stats.review_candidate_count || 0); if (!candidateCount) { hint.textContent = '還不能產生今日清單,原因是缺少最新 PChome 業績。'; @@ -1779,6 +1785,11 @@ function renderGrowthActionHint(stats) { return; } + if (needsMapping > 0 && reviewCandidateCount > 0) { + hint.textContent = `先處理 ${mappedCount.toLocaleString()} 件可立即處理商品;另有 ${reviewCandidateCount.toLocaleString()} 筆候選等人工確認。`; + return; + } + if (needsMapping > 0) { hint.textContent = `先處理 ${mappedCount.toLocaleString()} 件可立即處理商品,再補 ${needsMapping.toLocaleString()} 件比價資料。`; return; diff --git a/tests/test_external_market_offer_service.py b/tests/test_external_market_offer_service.py index ec2dd0c..685859a 100644 --- a/tests/test_external_market_offer_service.py +++ b/tests/test_external_market_offer_service.py @@ -361,6 +361,74 @@ def test_sync_targeted_momo_candidates_skips_total_price_identity_review(monkeyp assert count == 0 +def test_sync_targeted_momo_review_candidates_writes_needs_review_offer(monkeypatch): + from services import external_market_offer_service as service + + stale_marks = [] + monkeypatch.setattr(service, "mark_pchome_growth_cache_stale", lambda: stale_marks.append(True)) + + engine = create_engine("sqlite:///:memory:") + _seed_external_offer_sync_tables(engine) + + payload = service.sync_targeted_momo_review_candidates_to_external_offers(engine, [ + { + "product_id": "14917079", + "name": "【cle de peau 肌膚之鑰】光采柔焦蜜粉 24g (國際航空版)", + "price": 2618, + "target_pchome_product_id": "PCH-CDP", + "target_pchome_name": "cle de peau 光采柔焦蜜粉 24g #1", + "target_pchome_price": 2790, + "target_match_score": 1.0, + "auto_compare_type": "manual_review", + "target_price_basis": "none", + "target_alert_tier": "identity_review", + "target_match_type": "exact", + "target_match_reasons": ["variant_selection_review", "strong_exact_spec_match"], + "target_comparison_mode": "exact_identity", + "target_gap_pct": -6.16, + }, + { + "product_id": "LOW-SCORE", + "name": "低分候選", + "price": 100, + "target_pchome_product_id": "PCH-LOW", + "target_match_score": 0.4, + "target_alert_tier": "identity_review", + }, + ]) + + assert payload["success"] is True + assert payload["status"] == "synced" + assert payload["candidate_count"] == 2 + assert payload["written_count"] == 1 + assert payload["skipped_reasons"] == {"人工確認候選分數過低": 1} + + with engine.connect() as conn: + row = conn.execute(text(""" + SELECT source_product_id, price, pchome_product_id, match_status, + quality_score, data_quality_status, ingestion_method, + raw_payload_json + FROM external_offers + """)).mappings().one() + + readiness = service.build_external_source_readiness(engine) + + raw_payload = __import__("json").loads(row["raw_payload_json"]) + assert row["source_product_id"] == "14917079" + assert row["price"] == 2618 + assert row["pchome_product_id"] == "PCH-CDP" + assert row["match_status"] == "needs_review" + assert row["quality_score"] == 100 + assert row["data_quality_status"] == "needs_review" + assert row["ingestion_method"] == "targeted_momo_review" + assert raw_payload["review_state"] == "needs_review" + assert raw_payload["price_basis"] == "none" + assert raw_payload["alert_tier"] == "identity_review" + assert "needs_review" in raw_payload["tags"] + assert readiness["review_offer_count"] == 1 + assert stale_marks == [True] + + def test_sync_targeted_momo_candidates_keeps_best_unit_quantity_match(monkeypatch): from services import external_market_offer_service as service diff --git a/tests/test_pchome_revenue_growth_service.py b/tests/test_pchome_revenue_growth_service.py index b88b665..7a29e5f 100644 --- a/tests/test_pchome_revenue_growth_service.py +++ b/tests/test_pchome_revenue_growth_service.py @@ -307,12 +307,21 @@ def test_pchome_growth_momo_backfill_service_targets_unmapped_high_sales_items() "unit_price_count": 1, } + def fake_sync_review(engine, candidates): + captured["review_sync_candidates"] = candidates + return { + "success": True, + "status": "synced", + "written_count": len(candidates), + } + payload = run_pchome_growth_momo_backfill( FakeEngine(), limit=2, build_payload_func=fake_build_payload, search_func=fake_search, sync_func=fake_sync, + sync_review_func=fake_sync_review, ) assert payload["success"] is True @@ -321,10 +330,12 @@ def test_pchome_growth_momo_backfill_service_targets_unmapped_high_sales_items() assert payload["data"]["auto_compare_count"] == 2 assert payload["data"]["review_count"] == 1 assert payload["data"]["external_offer_sync"]["written_count"] == 2 + assert payload["data"]["review_candidate_sync"]["written_count"] == 1 assert payload["data"]["after_stats"]["mapping_rate"] == 66.7 assert [item["product_id"] for item in captured["targets"]] == ["PCH-NEEDS-1", "PCH-NEEDS-2"] assert [item["price"] for item in captured["targets"]] == [920, 760] assert [item["product_id"] for item in captured["sync_candidates"]] == ["MOMO-AUTO", "MOMO-UNIT"] + assert [item["product_id"] for item in captured["review_sync_candidates"]] == ["MOMO-REVIEW"] assert captured["search_limit"] == 2