From b8cc27e4c4ae8cc6a649bc04451b7e94d0611bef Mon Sep 17 00:00:00 2001 From: ogt Date: Fri, 10 Jul 2026 21:22:42 +0800 Subject: [PATCH] feat(pchome): close freshness and multi-market decision gaps --- .env.example | 74 ++++ config.py | 2 +- .../pchome_ai_automation_priority_backlog.md | 30 +- .../code_modularization_inventory_20260430.md | 56 +++ governance/pchome_growth_program_review.json | 222 ++++++++++++ run_scheduler.py | 64 ++++ scheduler.py | 12 +- scripts/ops/report_pchome_mapping_backlog.py | 154 ++++++++ services/ai_automation_debt_service.py | 5 +- services/external_market_offer_service.py | 73 +++- services/import_service.py | 38 +- services/pchome_revenue_growth_service.py | 329 ++++++++++++++++-- templates/ai_intelligence.html | 112 ++++-- tests/test_auto_import_failure_boundaries.py | 39 ++- tests/test_competitor_identity_revalidator.py | 16 +- tests/test_competitor_intel_cache.py | 29 +- tests/test_elephant_alpha_engine.py | 10 +- tests/test_event_router.py | 2 +- tests/test_external_market_offer_service.py | 60 +++- tests/test_frontend_v2_assets.py | 3 + tests/test_market_intel_skeleton.py | 2 +- tests/test_momo_crawler_targeted_search.py | 2 +- tests/test_nemotron_decision_envelope.py | 3 +- tests/test_openclaw_daily_template.py | 2 +- tests/test_pchome_revenue_growth_service.py | 103 +++++- tests/test_run_scheduler_embed_consistency.py | 43 +++ tests/test_telegram_triaged_alert_format.py | 2 +- tests/test_webcrumbs_host_data_service.py | 5 +- web/static/css/ewoooc-tokens-v2-alias.css | 4 +- 29 files changed, 1373 insertions(+), 123 deletions(-) create mode 100644 governance/pchome_growth_program_review.json create mode 100755 scripts/ops/report_pchome_mapping_backlog.py diff --git a/.env.example b/.env.example index 151edbf..751bbd9 100644 --- a/.env.example +++ b/.env.example @@ -582,3 +582,77 @@ N8N_PROTOCOL=http N8N_WEBHOOK_BASE_URL=http://192.168.0.110:5678/ N8N_USER=admin N8N_PASSWORD=change-me + +# ────────────────────────────────────────────────────────────────────────── +# 2026-07-10 runtime contract inventory +# Keep every os.getenv key discoverable without exposing production secrets. +# ────────────────────────────────────────────────────────────────────────── + +FLASK_ENV=production +KUBERNETES_SERVICE_HOST= +CICD_PROD_BASE_URL=https://mo.wooo.work +CICD_UAT_BASE_URL=https://uat.mo.wooo.work +SCHEDULER_STATS_PATH=/app/data/scheduler_stats.json + +DAILY_SALES_IMPORT_MAX_STALE_DAYS=3 +DAILY_SALES_IMPORT_STALENESS_GUARD_ENABLED=true +GOOGLE_DRIVE_CREDENTIALS_FILE=/app/config/google_credentials.json +GOOGLE_DRIVE_TOKEN_FILE=/app/config/google_token.json +GOOGLE_DRIVE_LEGACY_PICKLE_FILE=/app/config/google_token.pickle + +EXTERNAL_OFFER_SYNC_LIMIT=500 +MOMO_TARGETED_SEARCH_LIMIT_PER_TERM=3 +MOMO_TARGETED_SEARCH_MAX_PRODUCTS=8 +MOMO_TARGETED_SEARCH_MAX_TERMS=3 +MOMO_TARGETED_SEARCH_MIN_SCORE=0.5 +PCHOME_GROWTH_MOMO_BACKFILL_LIMIT=8 +PCHOME_GROWTH_BACKFILL_COOLDOWN_HOURS=4 +PCHOME_GROWTH_BACKFILL_RETRY_HOURS=1 +PCHOME_PUBLIC_API_TIMEOUT_SECONDS=4 +PCHOME_SALES_JOIN_BY_MOMO_SKU_ENABLED=false +PCHOME_STALE_RECOVERY_ENABLED=true +PCHOME_FEEDER_TTL_HOURS=24 +PCHOME_FEEDER_BACKFILL_TIMEOUT=12 +PCHOME_FEEDER_BACKFILL_MAX_SEARCH_TERMS=6 +PCHOME_FEEDER_BACKFILL_MAX_SECONDS_PER_SKU=45 +PCHOME_FEEDER_BACKFILL_SEARCH_MAX_PAGES=2 +PCHOME_BACKFILL_EXPIRED_REFRESH_LIMIT=40 +PCHOME_BACKFILL_RETRYABLE_LIMIT=40 +PCHOME_BACKFILL_REVALIDATION_LIMIT=40 +PCHOME_BACKFILL_UNMATCHED_LIMIT=40 + +MOMO_PCHOME_AUTO_POLICY_DECISION_PREFLIGHT_BATCH_SIZE=20 +MOMO_PCHOME_AUTO_POLICY_DECISION_PREFLIGHT_TIMEOUT_SECONDS=12 +MOMO_PCHOME_AUTO_POLICY_GUARD_BATCH_SIZE=20 +MOMO_PCHOME_AUTO_POLICY_GUARD_LIMIT=50 +MOMO_PCHOME_AUTO_POLICY_GUARD_TIMEOUT_SECONDS=12 +MOMO_PCHOME_DRIFT_MONITOR_DB_STATEMENT_TIMEOUT_MS=12000 + +MCP_FAST_STATIC_FALLBACK=true +MCP_OLLAMA_NUM_PREDICT=512 +MCP_OLLAMA_TIMEOUT=45 +OLLAMA_SECONDARY_MODEL_FALLBACK= +OLLAMA_SECONDARY_MODEL_FALLBACK_PATTERNS= + +PIXELRAG_VISUAL_EVIDENCE_ROOT=/app/data/pixelrag/evidence +PIXELRAG_VISUAL_EVIDENCE_MANIFEST_QUEUE_ROOT=/app/data/pixelrag/manifest_queue +PIXELRAG_VISUAL_EVIDENCE_MAX_AGE_HOURS=24 +PIXELRAG_PLATFORM_PROBE_CAPTURE_OUTPUT_ROOT=/app/data/pixelrag/platform_probe +PIXELRAG_PLATFORM_PROBE_WORKER_RECEIPT_ROOT=/app/data/pixelrag/receipts/platform_probe +PIXELRAG_SOURCE_CONTRACT_REPLAY_RECEIPT_ROOT=/app/data/pixelrag/receipts/source_contract +PIXELRAG_MARKETPLACE_ADAPTER_DRY_RUN_RECEIPT_ROOT=/app/data/pixelrag/receipts/adapter_dry_run +PIXELRAG_MARKETPLACE_ADAPTER_PREFLIGHT_RECEIPT_ROOT=/app/data/pixelrag/receipts/adapter_preflight +PIXELRAG_MARKETPLACE_CANDIDATE_KNOWLEDGE_REPLAY_RECEIPT_ROOT=/app/data/pixelrag/receipts/knowledge_replay +PIXELRAG_MARKETPLACE_EMBEDDING_SIGNATURE_GUARD_REPLAY_RECEIPT_ROOT=/app/data/pixelrag/receipts/embedding_guard +PIXELRAG_MARKETPLACE_IDENTITY_MATCHER_REPLAY_RECEIPT_ROOT=/app/data/pixelrag/receipts/identity_matcher +PIXELRAG_MARKETPLACE_PROMOTION_GATE_REPLAY_RECEIPT_ROOT=/app/data/pixelrag/receipts/promotion_gate +PIXELRAG_VLM_REPLAY_RECEIPT_ROOT=/app/data/pixelrag/receipts/vlm_replay +PIXELRAG_VLM_MODEL=qwen3-vl +PIXELRAG_VLM_MODEL_CANDIDATES=qwen3-vl,minicpm-v + +MOMO_SITEWIDE_VISUAL_QA_ARTIFACT=/app/data/runtime_artifacts/sitewide_visual_qa.json +MOMO_SITEWIDE_VISUAL_QA_MAX_AGE_HOURS=24 +PPT_SCHEDULED_FAST_FALLBACK=true + +TELEGRAM_WEBHOOK_SECRET_REQUIRED=true +TELEGRAM_WEBHOOK_SECRET_TOKEN= diff --git a/config.py b/config.py index a17bd0b..8adb219 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.773" +SYSTEM_VERSION = "V10.775" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/guides/pchome_ai_automation_priority_backlog.md b/docs/guides/pchome_ai_automation_priority_backlog.md index 5f4a14b..045720f 100644 --- a/docs/guides/pchome_ai_automation_priority_backlog.md +++ b/docs/guides/pchome_ai_automation_priority_backlog.md @@ -1,9 +1,37 @@ # PChome AI 自動化優先工作清單 -> 最後更新: 2026-07-09 Asia/Taipei +> 最後更新: 2026-07-10 Asia/Taipei > 範圍: PChome growth / MOMO mapping AI 自動化主線 > 真相來源: Gitea `dev` / `main`、正式環境 `/health`、正式 DB readback、machine-verifiable artifacts +> 狀態更新:本文件後段大量 preflight / guard / package 紀錄只保留為歷史證據, +> 不再視為業績方案完成度。現行 program truth 以 +> `governance/pchome_growth_program_review.json` 與下列 2026-07-10 重排為準, +> `superseded_by=global_product_governance_v2`。 + +## 2026-07-10 業績閉環重排 + +正式環境基線:業績最新日 `2026-06-24`,落後 16 天;13,032 個有業績商品中, +只有 35 個曾有外部報價,覆蓋約 0.27%。因此目前不能宣稱「PChome 業績提升完整方案已完成」。 + +| 順序 | 工作主線 | 狀態 | 下一個完成條件 | +|---|---|---|---| +| P0-1 | 授權業績資料自動取得與匯入 | 進行中 | 最新業績 <= 1 天,source receipt 與 post-verifier 同 run 完成 | +| P0-2 | 全商品身份圖譜與營收加權對應 | 進行中 | 覆蓋 80% 營收商品的 >= 90% | +| P0-3 | 合法多電商資料取得 | 進行中 | 每個啟用來源具 freshness SLO、normalize、matcher、verifier | +| P0-4 | 多平台營收決策引擎 | 程式已完成,待推版回讀 | 正式環境以至少 2 個已啟用平台產生可重播決策 | +| P0-5 | 調價、促銷、內容、曝光實驗與歸因 | 未開始 | 同 run 完成 apply、轉換驗證、增量營收歸因與 rollback | +| P1-1 | 專業 UI/UX 作戰 cockpit | 程式已完成,待推版與視覺 QA | desktop/mobile 首屏無文字牆、無假圖、只顯示一個主要下一步 | +| P1-2 | Ollama-first MCP / RAG / PixelRAG runtime | 進行中 | 正式 flags 啟用,PixelRAG receipt 可 replay,Qwen3-VL 驗證通過 | +| P1-3 | 資安與治理自動化 | 進行中 | low/medium/high controlled apply 自動閉環,critical 才 break-glass | + +已完成且本輪保留:Google Drive 自動匯入、MOMO 同款與單位價資料模型、 +PixelRAG blocked-page 分類、外部報價正規化、正式 DB 安全邊界。 + +本輪修復:來源 seed 不再覆寫 runtime 啟用狀態、營收服務不再只查 MOMO、 +新增 Yahoo / ETMall / friDay / Rakuten PixelRAG alias、多平台 evidence 與 actionable 分層、 +業績 freshness gate、全 catalog 覆蓋率、漏跑排程追趕,以及首屏來源摘要與過期資料阻擋。 + ## 不可遺漏的插入需求 以下是使用者在主線推進期間插入、且必須保留在完整工作項目裡的要求。後續不得再把這些需求漏掉或變成口頭提醒。 diff --git a/docs/memory/code_modularization_inventory_20260430.md b/docs/memory/code_modularization_inventory_20260430.md index ce423cd..7404d99 100644 --- a/docs/memory/code_modularization_inventory_20260430.md +++ b/docs/memory/code_modularization_inventory_20260430.md @@ -112,6 +112,62 @@ | 1965 | `services/market_intel/deployment_readiness.py` | P2 market intel deployment readiness | preflight gates / readiness payload / route contract helpers | | 846 | `services/market_intel/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_catalog_record_run_receipt.py` | P2 market intel review receipt pipeline | AI summary / persistence / Telegram dispatch / report catalog run receipt orchestration | +### 2026-07-10 Live Addendum + +下表是本次完整 review 的機械式行數回讀;同一路徑若也出現在上表,以下表較新的數字為準。 +這份更新只修正 inventory truth,不把巨型檔案誤標成已完成重構。 + +| 行數 | 檔案 | 分類 | 拆分方向 | +|---:|---|---|---| +| 44888 | `services/pchome_mapping_backlog_service.py` | P0 gate 巨型 service | 立即停止新增 closeout package;拆 policy、executor、verifier、receipt、reporter | +| 14289 | `services/ai_automation_smoke_service.py` | P0 smoke 巨型 service | 拆 family registry、collectors、health projection、metrics adapter | +| 9442 | `routes/openclaw_bot_routes.py` | P0 巨型 Blueprint | route、command、report、scheduler hook 分離 | +| 7641 | `routes/ai_routes.py` | P0 巨型 Blueprint | PChome growth、AI automation、recommendation route extension 分離 | +| 5865 | `services/marketplace_product_matcher.py` | P0 matcher 巨型 service | identity、variant、unit、scoring、explanation 分離 | +| 5513 | `services/ppt_generator.py` | P0 報表巨型 service | orchestration、slides、charts、registry 分離 | +| 4158 | `routes/admin_observability_routes.py` | P0 觀測台巨型 Blueprint | query、action、render context 與 route glue 分離 | +| 3763 | `services/competitor_price_feeder.py` | P0 feeder 巨型 service | acquisition、matching、refresh、persistence、receipt 分離 | +| 3322 | `routes/dashboard_routes.py` | P0 Dashboard Blueprint | query、decision projection、review action 分離 | +| 3290 | `routes/sales_routes.py` | P0 Sales Blueprint | chart、query、calendar、export 分離 | +| 3112 | `scheduler.py` | P0 排程總管 | task registry 與 domain jobs 分離 | +| 2961 | `services/openclaw_strategist_service.py` | P1 strategist | prompt、query、report、notification 分離 | +| 2383 | `services/competitor_intel_repository.py` | P1 repository | query、decision envelope、UI projection、cache 分離 | +| 2332 | `services/elephant_alpha_autonomous_engine.py` | P1 engine | trigger、planner、executor、notification 分離 | +| 2166 | `services/nemoton_dispatcher_service.py` | P1 dispatcher | model client、tool parser、decision、delivery 分離 | +| 2011 | `services/external_market_offer_service.py` | P1 market offers | source registry、normalizer、sync、review、readiness 分離 | +| 1917 | `services/market_intel/deployment_readiness.py` | P1 readiness | check registry、runtime probe、projection 分離 | +| 1658 | `routes/market_intel_review_report_routes.py` | P1 review route | report query、export、route glue 分離 | +| 1494 | `routes/vendor_routes.py` | P1 Vendor Blueprint | stockout mutation、email、route glue 分離 | +| 1456 | `services/ollama_service.py` | P1 AI runtime | routing、health、request、fallback 分離 | +| 1417 | `services/telegram_templates.py` | P1 templates | decision、incident、report renderer 分離 | +| 1409 | `services/import_service.py` | P1 import | acquisition、validation、writer、verifier 分離 | +| 1394 | `services/telegram_bot_service.py` | P1 bot | command、formatter、client 分離 | +| 1370 | `run_scheduler.py` | P1 scheduler entry | registration、catchup、observability wrappers 分離 | +| 1306 | `routes/system_public_routes.py` | P1 public routes | health、metrics、public projection 分離 | +| 1290 | `app.py` | P1 bootstrap | app factory 與 startup guards 分離 | +| 1272 | `services/code_review_pipeline_service.py` | P2 code review | scan、normalize、persist、report 分離 | +| 1202 | `services/pchome_revenue_growth_service.py` | P1 revenue growth | sales query、offer comparison、scoring、projection 分離 | +| 1188 | `services/ppt_auto_generation_service.py` | P2 PPT automation | resolver、queue、generator、catchup 分離 | +| 1121 | `services/pixelrag_crawler_integration_service.py` | P1 PixelRAG | capture routing、manifest、queue、readback 分離 | +| 1023 | `routes/cicd_routes.py` | P2 CI/CD Blueprint | query、deploy action、route glue 分離 | +| 1014 | `services/pchome_crawler.py` | P1 crawler | transport、parser、fallback、rate policy 分離 | +| 1014 | `routes/daily_sales_routes.py` | P2 Daily Sales Blueprint | query、format、export、route glue 分離 | +| 1013 | `routes/export_routes.py` | P2 Export Blueprint | command、path policy、download 分離 | +| 989 | `routes/market_intel_routes.py` | P2 Market Intel Blueprint | page、API、MCP registration 分離 | +| 966 | `services/trend_crawler.py` | P2 trend crawler | adapters、parser、persistence 分離 | +| 942 | `services/learning_pipeline.py` | P2 RAG learning | distill、promotion、persistence、telemetry 分離 | +| 906 | `services/competitor_match_attempt_rescore_audit.py` | P2 rescore audit | query、classifier、projection 分離 | +| 895 | `routes/market_intel_review_routes.py` | P2 review Blueprint | command、query、route glue 分離 | +| 867 | `services/token_report_service.py` | P2 token report | query、aggregate、chart、notify 分離 | +| 864 | `services/ppt_vision_service.py` | P2 vision QA | runtime、queue、probe、audit 分離 | +| 862 | `services/security_governance_review_service.py` | P1 governance | inventory、controls、runtime receipt、scorecard 分離 | +| 861 | `routes/market_intel_review_post_routes.py` | P2 review POST routes | command handlers 與 verifier 分離 | +| 846 | `services/market_intel/candidate_queue_review_ai_summary_persistence_telegram_dispatch_report_catalog_record_run_receipt.py` | P2 receipt pipeline | AI summary、persistence、delivery、catalog 分離 | +| 843 | `services/pixelrag_vlm_replay_worker_service.py` | P1 VLM worker | route selection、inference、validation、receipt 分離 | +| 826 | `routes/market_intel_mcp_run_routes.py` | P2 MCP route extension | run command 與 receipt query 分離 | +| 813 | `services/momo_crawler.py` | P2 MOMO crawler | transport、parser、normalization 分離 | +| 801 | `services/ai_surface_html_readback_service.py` | P2 UI readback | contract registry、HTML probe、result projection 分離 | + ## 市場情報開發前置禁區 - 不得把跨平台活動頁、商品池、比對審核 API 新增到 `routes/sales_routes.py`、`routes/dashboard_routes.py` 或 `app.py`。 diff --git a/governance/pchome_growth_program_review.json b/governance/pchome_growth_program_review.json new file mode 100644 index 0000000..1d9b319 --- /dev/null +++ b/governance/pchome_growth_program_review.json @@ -0,0 +1,222 @@ +{ + "version": "pchome_growth_program_review_v1", + "generated_at": "2026-07-10T20:43:51+08:00", + "supersedes_status_only_completion": true, + "goal": "以新鮮 PChome 業績、可信跨平台同款資料與可歸因動作持續提升業績", + "production_baseline": { + "runtime_source_sha": "b4284693e4a86057e24daa3aa2fb3494f046edae", + "runtime_version": "V10.773", + "latest_sales_date": "2026-06-24", + "sales_age_days": 16, + "catalog_product_count": 13032, + "catalog_products_with_external_offer": 35, + "catalog_external_offer_coverage_pct": 0.27, + "top_opportunity_count": 20, + "top_opportunity_actionable_mapped_count": 5, + "top_opportunity_mapping_rate_pct": 25.0, + "external_offer_sources_with_verified_rows": [ + "momo_reference" + ], + "verified_external_offer_count": 473, + "verified_external_product_count": 394, + "runtime_mcp_router_enabled": false, + "runtime_rag_enabled": false + }, + "codebase_findings": { + "relevant_file_count_approx": 250, + "market_intel_module_count": 140, + "market_intel_line_count": 53988, + "mapping_backlog_service_line_count": 44888, + "mapping_backlog_service_function_count": 297, + "mapping_backlog_gate_marker_count": 1317, + "ai_routes_pchome_growth_route_count": 96, + "primary_root_causes": [ + "status and gate accumulation is much larger than the production data and action loop", + "sales import polls Google Drive but does not acquire the report from the authorized upstream", + "external source seeds overwrote runtime activation on every sync", + "revenue scoring only selected momo_reference even when other normalized offers existed", + "daily mapping backfill could be missed for a full day after a late container restart", + "the first viewport did not block decisions when sales data was stale", + "the source UI expanded every paused platform into a text-heavy card" + ] + }, + "work_items": [ + { + "order": 1, + "id": "PCH-GROWTH-P0-001", + "priority": "P0", + "status": "in_progress", + "lane": "authorized_sales_acquisition", + "objective": "Automatically acquire and import the latest PChome sales report", + "completed": [ + "Google Drive polling and import", + "post-import table verification", + "staleness detection" + ], + "remaining": [ + "authorized PChome backend, scheduled export, or mailbox-to-Drive acquisition connector", + "same-run source receipt and freshness verifier" + ], + "production_acceptance": "latest sales age <= 1 day and decision_ready=true" + }, + { + "order": 2, + "id": "PCH-GROWTH-P0-002", + "priority": "P0", + "status": "in_progress", + "lane": "product_identity_graph", + "objective": "Map the PChome catalog to verified same-product offers across platforms", + "completed": [ + "MOMO exact and unit-price evidence model", + "quality and variant gates", + "bounded high-sales backfill" + ], + "remaining": [ + "raise full-catalog coverage from 0.27%", + "brand, GTIN, MPN, model, size, count, color and seller identity graph", + "automatic replay for unresolved high-sales products" + ], + "production_acceptance": "top revenue 80% weighted catalog coverage >= 90%" + }, + { + "order": 3, + "id": "PCH-GROWTH-P0-003", + "priority": "P0", + "status": "in_progress", + "lane": "lawful_multiplatform_acquisition", + "objective": "Continuously acquire normalized offer evidence from relevant commerce platforms", + "completed": [ + "15-source governed registry", + "MOMO normalized offers", + "PixelRAG evidence lane with blocked-page classification", + "Yahoo, ETMall and friDay public capture feasibility proof" + ], + "remaining": [ + "authorized or structured-data adapters for Yahoo, ETMall and friDay", + "Shopee authorized feed or seller API adapter", + "Coupang official API eligibility and 403 backoff path", + "Google Merchant price competitiveness and market insights eligibility", + "Rakuten retry and backoff adapter", + "Ruten and LINE Shopping feed contracts" + ], + "production_acceptance": "each active source has source receipt, normalized offers, verifier and freshness SLO" + }, + { + "order": 4, + "id": "PCH-GROWTH-P0-004", + "priority": "P0", + "status": "source_complete_runtime_pending", + "lane": "cross_platform_revenue_decision", + "objective": "Compare every qualified platform offer without letting blocked or observational evidence drive price actions", + "completed": [ + "multi-source latest-offer selection", + "runtime source-policy enforcement", + "observation-only evidence separation", + "sales freshness decision gate", + "catalog and platform coverage metrics" + ], + "remaining": [ + "Gitea deployment", + "production API readback", + "production multi-source offer ingestion" + ], + "production_acceptance": "fresh sales plus at least two enabled verified platforms produce replayable decisions" + }, + { + "order": 5, + "id": "PCH-GROWTH-P0-005", + "priority": "P0", + "status": "not_started", + "lane": "growth_action_and_attribution", + "objective": "Turn recommendations into bounded price, promotion, content and exposure experiments and measure incremental revenue", + "completed": [], + "remaining": [ + "action contract and authorized PChome write adapter", + "holdout or matched-control experiment design", + "margin, stock and MAP policy guard", + "post-action conversion and incremental revenue verifier", + "automatic rollback and cooldown" + ], + "production_acceptance": "same run closes decide, apply, verify, attribute and learn with rollback evidence" + }, + { + "order": 6, + "id": "PCH-GROWTH-P1-001", + "priority": "P1", + "status": "source_complete_runtime_pending", + "lane": "professional_ui_ux", + "objective": "Provide a compact decision cockpit instead of a status and text wall", + "completed": [ + "freshness-first next action", + "multi-platform terminology", + "real coverage metrics", + "collapsed pending-source summary", + "removed fake sales sparkline", + "desktop and 390px mobile visual verification without horizontal overflow", + "repaired the circular accent-token fallback that made primary buttons unreadable" + ], + "remaining": [ + "production visible readback", + "per-product cross-platform offer drawer and experiment outcome view" + ], + "production_acceptance": "first viewport shows freshness, revenue trend, coverage, risk and one executable next action without overflow" + }, + { + "order": 7, + "id": "PCH-GROWTH-P1-002", + "priority": "P1", + "status": "in_progress", + "lane": "ai_mcp_rag_runtime", + "objective": "Use Ollama-first agents, MCP and RAG to automate retrieval, matching, validation and learning", + "completed": [ + "internal MCP and RAG inventory", + "PixelRAG receipts and capture manifests", + "text RAG service implementation" + ], + "remaining": [ + "enable and verify MCP router runtime", + "enable and verify RAG runtime", + "Qwen3-VL Ollama-first visual replay", + "PixelRAG receipt to RAG candidate replay", + "agent outcome metrics and durable learning acknowledgement" + ], + "production_acceptance": "runtime flags enabled and same-run retrieval-to-decision receipts pass independent replay" + }, + { + "order": 8, + "id": "PCH-GROWTH-P1-003", + "priority": "P1", + "status": "in_progress", + "lane": "security_and_governance_automation", + "objective": "Apply risk-based controlled execution with asset, policy, verifier, rollback and learning coverage", + "completed": [ + "source and runtime review artifacts", + "read-only visual evidence boundary", + "production DB lifecycle red lines" + ], + "remaining": [ + "replace preview-only gate chains with executable work items", + "shared trace_id, run_id and work_item_id envelope", + "SBOM and dependency policy enforcement", + "automated drift work item creation", + "security control coverage and production closure metrics" + ], + "production_acceptance": "low, medium and high actions close automatically under policy; only critical actions require break-glass" + } + ], + "source_strategy": { + "priority_order": [ + "official or explicitly authorized API and feed", + "public structured product data that permits access", + "PixelRAG visual evidence with OCR or VLM replay", + "blocked-page classification, backoff and alternate source" + ], + "hard_rules": [ + "never bypass anti-bot controls", + "never treat a blocked page as product evidence", + "never write PixelRAG-only prices into the formal price table", + "never allow a paused or disabled source to drive a pricing action", + "never execute a sales action with stale PChome sales data" + ] + } +} diff --git a/run_scheduler.py b/run_scheduler.py index bf4c499..85677e3 100644 --- a/run_scheduler.py +++ b/run_scheduler.py @@ -17,6 +17,7 @@ run_scheduler.py — momo-scheduler 容器入口點 每 1 年 :ppt_auto_generation_annual(1 月 1 日 21:20) """ import asyncio +import json import logging import os import threading @@ -60,6 +61,66 @@ _AI_CALLS_ERROR_SPIKE_LAST_PUSH_TS = 0.0 _OLLAMA_111_USAGE_LAST_PUSH_TS = 0.0 +def _scheduler_stats_path() -> str: + return os.getenv( + "SCHEDULER_STATS_PATH", + os.path.join(os.path.dirname(__file__), "data", "scheduler_stats.json"), + ) + + +def _pchome_growth_backfill_due(*, now=None) -> tuple[bool, str]: + """Return whether the bounded PChome mapping run should catch up.""" + from datetime import datetime as _dt, timedelta as _timedelta, timezone as _timezone + + current = now or _dt.now(_timezone(_timedelta(hours=8))).replace(tzinfo=None) + try: + with open(_scheduler_stats_path(), "r", encoding="utf-8") as handle: + stats = json.load(handle) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return True, "missing_runtime_receipt" + + history = stats.get("pchome_growth_momo_backfill") or [] + latest = history[0] if isinstance(history, list) and history else {} + last_run_raw = str(latest.get("last_run") or "").strip() + if not last_run_raw: + return True, "missing_last_run" + try: + last_run = _dt.strptime(last_run_raw, "%Y-%m-%d %H:%M:%S") + except ValueError: + return True, "invalid_last_run" + + status = str(latest.get("status") or "").lower() + cooldown_hours = float(os.getenv("PCHOME_GROWTH_BACKFILL_COOLDOWN_HOURS", "4")) + if status == "failed": + cooldown_hours = float(os.getenv("PCHOME_GROWTH_BACKFILL_RETRY_HOURS", "1")) + elapsed_hours = max(0.0, (current - last_run).total_seconds() / 3600) + if elapsed_hours >= cooldown_hours: + return True, f"stale_receipt_{elapsed_hours:.1f}h" + return False, f"cooldown_{cooldown_hours:.1f}h" + + +def run_pchome_growth_momo_backfill_catchup_task(): + """Catch up a missed daily run without creating an unbounded crawl loop.""" + try: + due, reason = _pchome_growth_backfill_due() + if not due: + logger.debug("[PChomeGrowthBackfillCatchup] skipped reason=%s", reason) + return {"status": "skipped", "reason": reason} + logger.info("[PChomeGrowthBackfillCatchup] running reason=%s", reason) + run_pchome_growth_momo_backfill_task() + return {"status": "executed", "reason": reason} + except Exception as error: + logger.error("[PChomeGrowthBackfillCatchup] task failed: %s", error, exc_info=True) + _notify_scheduler_failure( + "run_pchome_growth_momo_backfill_catchup_task", + error, + source="Scheduler.PChomeGrowthBackfillCatchup", + event_type="pchome_growth_backfill_catchup_failure", + title="PChome 高業績商品補抓追趕失敗", + ) + return {"status": "failed", "error": str(error)} + + def _env_flag(name: str, default: bool = False) -> bool: raw = os.getenv(name) if raw is None: @@ -312,6 +373,9 @@ def _register_schedules(): schedule.every().day.at("10:45").do(run_pchome_growth_momo_backfill_task) logger.info("📅 每日 10:45:pchome_growth_momo_backfill(高業績商品補 MOMO 對應)") + schedule.every(30).minutes.do(run_pchome_growth_momo_backfill_catchup_task) + logger.info("📅 每 30 分鐘:pchome_growth_momo_backfill_catchup(錯過每日任務時自動補跑)") + # Operation Ollama-First v5.0 — Phase 1 收尾:每日 23:55 LLM Token 日報 schedule.every().day.at("23:55").do(run_daily_token_report_task) logger.info("📅 每日 23:55:daily_token_report") diff --git a/scheduler.py b/scheduler.py index 59ac206..383cdeb 100644 --- a/scheduler.py +++ b/scheduler.py @@ -94,7 +94,10 @@ def _safe_driver_title(driver, task_label, url): def _save_stats(task_name, data): """將任務統計結果寫入 JSON 檔案""" - stats_file = os.path.join(os.path.dirname(__file__), 'data', 'scheduler_stats.json') + stats_file = os.getenv( + 'SCHEDULER_STATS_PATH', + os.path.join(os.path.dirname(__file__), 'data', 'scheduler_stats.json'), + ) try: stats = {} if os.path.exists(stats_file): @@ -2043,7 +2046,12 @@ def run_auto_import_task(): stats = { "file_count": result.get('file_count', 0), "imported_count": result.get('imported_count', 0), - "status": "Success" + "status": "Degraded" if result.get('status') in {'upstream_stale', 'upstream_missing'} else "Success", + "upstream_status": result.get('status'), + "latest_sales_date": result.get('latest_sales_date'), + "data_lag_days": result.get('data_lag_days'), + "decision_ready": result.get('decision_ready'), + "requires_upstream_acquisition": result.get('requires_upstream_acquisition', False), } # V-New: 發送 Telegram 和 Line 通知(僅當有匯入新檔案時) diff --git a/scripts/ops/report_pchome_mapping_backlog.py b/scripts/ops/report_pchome_mapping_backlog.py new file mode 100755 index 0000000..ea1770f --- /dev/null +++ b/scripts/ops/report_pchome_mapping_backlog.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Read-only PChome mapping backlog reporter with production version guard.""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts.ops import check_production_version_truth as version_guard # noqa: E402 +from services.pchome_mapping_backlog_service import summarize_pchome_mapping_backlog # noqa: E402 + + +DEFAULT_API_URL = "https://mo.wooo.work/api/ai/pchome-growth/opportunities" +DEFAULT_HEALTH_URL = "https://mo.wooo.work/health" + + +def with_limit(url: str, limit: int) -> str: + """Add a clamped limit while preserving existing query parameters.""" + parsed = urllib.parse.urlsplit(url) + query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True) + query = [(key, value) for key, value in query if key != "limit"] + query.append(("limit", str(max(5, min(int(limit or 20), 50))))) + return urllib.parse.urlunsplit(( + parsed.scheme, + parsed.netloc, + parsed.path, + urllib.parse.urlencode(query), + parsed.fragment, + )) + + +def fetch_json(url: str, timeout: float) -> dict[str, Any]: + request = urllib.request.Request( + url, + headers={"Accept": "application/json", "User-Agent": "ewoooc-pchome-backlog-report/1"}, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + if not isinstance(payload, dict): + raise ValueError("growth API payload must be a JSON object") + return payload + + +def summarize_payload(payload: dict[str, Any]) -> dict[str, Any]: + return summarize_pchome_mapping_backlog(payload) + + +def build_report( + *, + api_url: str, + limit: int, + timeout: float, + health_url: str, + skip_version_truth: bool, + allow_local_version_drift: bool = False, +) -> dict[str, Any]: + target_url = with_limit(api_url, limit) + guard_report = None + if not skip_version_truth: + try: + guard_report = version_guard.build_report(health_url, timeout) + guard_ok, guard_errors = version_guard.evaluate( + guard_report, + allow_local_version_drift=allow_local_version_drift, + ) + except Exception as error: + return { + "policy": "read_only_production_pchome_mapping_backlog", + "result": "BLOCKED", + "api_url": target_url, + "errors": [f"production version truth unavailable: {error}"], + } + if not guard_ok: + return { + "policy": "read_only_production_pchome_mapping_backlog", + "result": "BLOCKED", + "api_url": target_url, + "version_truth": guard_report, + "errors": guard_errors, + } + + try: + summary = summarize_payload(fetch_json(target_url, timeout)) + except Exception as error: + return { + "policy": "read_only_production_pchome_mapping_backlog", + "result": "BLOCKED", + "api_url": target_url, + "version_truth": guard_report, + "errors": [f"growth API readback unavailable: {error}"], + } + + return { + **summary, + "policy": "read_only_production_pchome_mapping_backlog", + "api_url": target_url, + "version_truth": guard_report, + "errors": [], + } + + +def _render_text(payload: dict[str, Any]) -> str: + stats = payload.get("stats") or {} + backlog = payload.get("backlog") or {} + errors = payload.get("errors") or [] + if errors: + return "\n".join([f"result={payload.get('result', 'BLOCKED')}", *errors]) + return "\n".join([ + f"result={payload.get('result', 'UNKNOWN')}", + f"mapping_rate={stats.get('mapping_rate', 0)}%", + f"needs_mapping={stats.get('needs_mapping_count', 0)}", + f"direct_mapping={backlog.get('direct_mapping_count', 0)}", + f"review_candidates={backlog.get('review_candidate_count', 0)}", + ]) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="輸出正式 PChome 商品對應 backlog。") + parser.add_argument("--api-url", default=DEFAULT_API_URL) + parser.add_argument("--health-url", default=DEFAULT_HEALTH_URL) + parser.add_argument("--limit", type=int, default=20) + parser.add_argument("--timeout", type=float, default=12.0) + parser.add_argument("--skip-version-truth", action="store_true") + parser.add_argument("--allow-local-version-drift", action="store_true") + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + + payload = build_report( + api_url=args.api_url, + limit=args.limit, + timeout=args.timeout, + health_url=args.health_url, + skip_version_truth=args.skip_version_truth, + allow_local_version_drift=args.allow_local_version_drift, + ) + if args.json: + print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)) + else: + print(_render_text(payload)) + return 1 if payload.get("result") == "BLOCKED" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/ai_automation_debt_service.py b/services/ai_automation_debt_service.py index 14e64d7..8900cf7 100644 --- a/services/ai_automation_debt_service.py +++ b/services/ai_automation_debt_service.py @@ -139,7 +139,10 @@ def _relative(path: Path, root: Path) -> str: def _is_excluded(relative_path: str) -> bool: - if relative_path == "services/ai_automation_debt_service.py": + if relative_path in { + "services/ai_automation_debt_service.py", + "services/ai_surface_html_readback_service.py", + }: return True parts = set(relative_path.split("/")) if parts & {".git", ".pytest_cache", "__pycache__", "node_modules", "data", "tests", "migrations"}: diff --git a/services/external_market_offer_service.py b/services/external_market_offer_service.py index 62fd249..912e629 100644 --- a/services/external_market_offer_service.py +++ b/services/external_market_offer_service.py @@ -130,6 +130,28 @@ SOURCE_CONTRACTS = [ "data_quality_label": "暫不進告警", "plain_note": "保留本地平台促銷與價格監控欄位,接入前不影響現有作戰清單。", }, + { + "code": "etmall", + "display_name": "東森購物", + "platform_code": "etmall", + "status_code": "paused", + "status_label": "待接入", + "source_kind": "connector_contract", + "input_methods": ["官方 API", "公開結構化資料", "供應商 API", "手動 CSV"], + "data_quality_label": "暫不進告警", + "plain_note": "保留東森購物的商品、售價與促銷訊號;完成來源授權與商品身份驗證後才進入決策。", + }, + { + "code": "friday", + "display_name": "friDay 購物", + "platform_code": "friday", + "status_code": "paused", + "status_label": "待接入", + "source_kind": "connector_contract", + "input_methods": ["官方 API", "公開結構化資料", "供應商 API", "手動 CSV"], + "data_quality_label": "暫不進告警", + "plain_note": "保留 friDay 購物的商品、售價與促銷訊號;完成來源授權與商品身份驗證後才進入決策。", + }, { "code": "ruten", "display_name": "露天", @@ -533,6 +555,22 @@ def _normalize_source_code(value: Any) -> str: "shopee": "shopee", "酷澎": "coupang", "coupang": "coupang", + "yahoo": "yahoo_shopping", + "yahoo_shopping": "yahoo_shopping", + "yahoo_shopping_tw": "yahoo_shopping", + "yahoo購物": "yahoo_shopping", + "yahoo 購物": "yahoo_shopping", + "東森": "etmall", + "東森購物": "etmall", + "etmall": "etmall", + "etmall_tw": "etmall", + "friday": "friday", + "friday_tw": "friday", + "friday購物": "friday", + "friday 購物": "friday", + "樂天": "rakuten", + "rakuten": "rakuten", + "rakuten_tw": "rakuten", } return mapping.get(lower, mapping.get(raw, raw)) @@ -677,8 +715,6 @@ def _ensure_external_market_source_seeds(conn) -> None: display_name = EXCLUDED.display_name, platform_code = EXCLUDED.platform_code, source_kind = EXCLUDED.source_kind, - status = EXCLUDED.status, - enabled = EXCLUDED.enabled, allowed_input_methods_json = EXCLUDED.allowed_input_methods_json, quality_policy_json = EXCLUDED.quality_policy_json, plain_note = EXCLUDED.plain_note, @@ -698,8 +734,6 @@ def _ensure_external_market_source_seeds(conn) -> None: display_name = excluded.display_name, platform_code = excluded.platform_code, source_kind = excluded.source_kind, - status = excluded.status, - enabled = excluded.enabled, allowed_input_methods_json = excluded.allowed_input_methods_json, quality_policy_json = excluded.quality_policy_json, plain_note = excluded.plain_note, @@ -1709,6 +1743,17 @@ def build_external_source_readiness(engine=None) -> dict[str, Any]: for source in sources: stats = normalized_stats.get(source["code"], {}) + runtime_status = str(stats.get("status") or source["status_code"]).strip() or source["status_code"] + runtime_enabled = bool(stats.get("enabled")) if stats else source["status_code"] == "active" + source["status_code"] = runtime_status + source["enabled"] = runtime_enabled + source["status_label"] = ( + "正在使用" + if runtime_status == "active" and runtime_enabled + else "已停用" + if runtime_status in {"disabled", "retired"} + else "先暫停" + ) if source["code"] == "momo_reference": usable = max( int(stats.get("usable_offer_count") or 0), @@ -1723,8 +1768,9 @@ def build_external_source_readiness(engine=None) -> dict[str, Any]: 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["has_runtime_data"] = usable > 0 or source["review_offer_count"] > 0 + source["can_alert"] = runtime_status == "active" and runtime_enabled and usable > 0 + if runtime_status == "active" and runtime_enabled: if usable: source["plain_state"] = "已接入,可進作戰清單" elif source["review_offer_count"]: @@ -1734,8 +1780,12 @@ def build_external_source_readiness(engine=None) -> dict[str, Any]: 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") + active_count = sum( + 1 for source in sources + if source["status_code"] == "active" and source.get("enabled") + ) + paused_count = len(sources) - active_count + sources_with_data_count = sum(1 for source in sources if source.get("has_runtime_data")) 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) @@ -1744,12 +1794,17 @@ def build_external_source_readiness(engine=None) -> dict[str, Any]: "schema_ready": schema_ready, "active_count": active_count, "paused_count": paused_count, + "sources_with_data_count": sources_with_data_count, + "source_coverage_rate": round(sources_with_data_count / max(len(sources), 1) * 100, 1), "usable_offer_count": usable_count, "review_offer_count": review_offer_count, "sources": sources, "connector_contract": build_connector_contracts(), "offer_evidence_contract": build_offer_evidence_contract(), - "plain_summary": "MOMO 先用;其他主流平台已列管,未接合法穩定來源前不進告警。", + "plain_summary": ( + f"已列管 {len(sources)} 個來源,{sources_with_data_count} 個已有資料;" + "只有 runtime 已啟用且通過身份與品質驗證的報價會進入決策。" + ), } diff --git a/services/import_service.py b/services/import_service.py index 176685c..23dae7c 100644 --- a/services/import_service.py +++ b/services/import_service.py @@ -1143,6 +1143,8 @@ class ImportService: if not files: logger.info("沒有找到待匯入的檔案") + data_lag_days = None + latest_sales_date = None # Staleness gate (critic-approved 2026-05-03) # 'move-then-success' 反模式:成功 import 後 move_file 把 Excel 搬到 @@ -1150,25 +1152,31 @@ class ImportService: # → 4/27~5/2 daily_sales_snapshot 停更 8 天無告警。補主動偵測: # Drive 空 + DB ≥3 天無新資料時主動發催促告警(週末跨假期不誤觸)。 try: - from database.manager import get_session from sqlalchemy import text from datetime import date from services.openclaw_strategist_service import _send_data_stale_alert - _stale_session = get_session() + _stale_session = Session() try: last_date = _stale_session.execute( - text("SELECT MAX(snapshot_date)::date FROM daily_sales_snapshot") + text("SELECT MAX(snapshot_date) FROM daily_sales_snapshot") ).scalar() finally: _stale_session.close() if last_date: - days_since = (date.today() - last_date).days + normalized_last_date = ( + last_date + if isinstance(last_date, date) + else date.fromisoformat(str(last_date)[:10]) + ) + days_since = (date.today() - normalized_last_date).days + data_lag_days = days_since + latest_sales_date = str(normalized_last_date) if days_since >= 3: _send_data_stale_alert( report_type="upstream_drive", - last_date=str(last_date), + last_date=str(normalized_last_date), period=f"已停更 {days_since} 天", ) except Exception: @@ -1179,8 +1187,24 @@ class ImportService: return { 'success': True, - 'message': '沒有找到待匯入的檔案', - 'file_count': 0 + 'status': ( + 'upstream_missing' + if data_lag_days is None + else 'upstream_stale' + if data_lag_days >= 3 + else 'no_pending_file' + ), + 'message': ( + f'上游沒有新檔,最新業績已落後 {data_lag_days} 天' + if data_lag_days is not None and data_lag_days >= 3 + else '沒有找到待匯入的檔案' + ), + 'file_count': 0, + 'imported_count': 0, + 'latest_sales_date': latest_sales_date, + 'data_lag_days': data_lag_days, + 'decision_ready': data_lag_days is not None and data_lag_days <= 1, + 'requires_upstream_acquisition': data_lag_days is None or data_lag_days >= 3, } # 處理每個檔案 diff --git a/services/pchome_revenue_growth_service.py b/services/pchome_revenue_growth_service.py index 6f227c2..0d5b438 100644 --- a/services/pchome_revenue_growth_service.py +++ b/services/pchome_revenue_growth_service.py @@ -6,7 +6,7 @@ from __future__ import annotations import json import logging -from datetime import datetime +from datetime import date, datetime from typing import Any from sqlalchemy import bindparam, inspect, text @@ -54,10 +54,53 @@ def _source_names_by_status(source_readiness: dict[str, Any], status_code: str) return [ str(source.get("display_name") or source.get("code") or "").strip() for source in source_readiness.get("sources") or [] - if source.get("status_code") == status_code and str(source.get("display_name") or source.get("code") or "").strip() + if source.get("status_code") == status_code + and (status_code != "active" or source.get("enabled", True)) + and str(source.get("display_name") or source.get("code") or "").strip() ] +def _sales_freshness(latest_sales_date: Any, *, today: date | None = None) -> dict[str, Any]: + raw = str(latest_sales_date or "").strip() + if not raw: + return { + "status": "missing", + "label": "尚無業績資料", + "age_days": None, + "decision_ready": False, + "next_action": "自動取得並匯入最新 PChome 業績檔", + } + try: + parsed = date.fromisoformat(raw[:10]) + except ValueError: + return { + "status": "invalid", + "label": "業績日期格式異常", + "age_days": None, + "decision_ready": False, + "next_action": "修正業績資料日期後重新匯入", + } + + age_days = max(0, ((today or datetime.now().date()) - parsed).days) + if age_days <= 1: + status, label, ready = "fresh", "資料新鮮", True + elif age_days == 2: + status, label, ready = "warning", "資料即將過期", False + else: + status, label, ready = "critical", "資料已過期", False + return { + "status": status, + "label": label, + "age_days": age_days, + "decision_ready": ready, + "next_action": ( + "依最新業績執行今日作戰清單" + if ready + else "自動取得並匯入最新 PChome 業績檔" + ), + } + + def _table_exists(engine, table_name: str) -> bool: try: return inspect(engine).has_table(table_name) @@ -336,7 +379,7 @@ def _external_price_basis_label(price_basis: str) -> str: return "單位價" if price_basis == "unit_price" else "商品總價" -def _fetch_normalized_external_price_map(conn, pchome_product_ids: list[str]) -> dict[str, dict[str, Any]]: +def _fetch_normalized_external_offer_map(conn, pchome_product_ids: list[str]) -> dict[str, list[dict[str, Any]]]: inspector = inspect(conn) if not inspector.has_table("external_offers"): return {} @@ -347,10 +390,13 @@ def _fetch_normalized_external_price_map(conn, pchome_product_ids: list[str]) -> if conn.dialect.name == "postgresql": sql = """ WITH latest_offer AS ( - SELECT DISTINCT ON (eo.pchome_product_id) + SELECT DISTINCT ON (eo.pchome_product_id, eo.source_code) eo.pchome_product_id, + eo.source_code, + eo.platform_code, eo.source_product_id AS momo_sku, eo.title AS momo_name, + eo.product_url, eo.price AS momo_price, eo.quality_score, eo.quality_notes_json, @@ -358,8 +404,7 @@ def _fetch_normalized_external_price_map(conn, pchome_product_ids: list[str]) -> eo.observed_at, eo.ingestion_method FROM external_offers eo - WHERE eo.source_code = 'momo_reference' - AND eo.pchome_product_id IS NOT NULL + WHERE eo.pchome_product_id IS NOT NULL AND eo.pchome_product_id IN :ids AND eo.price IS NOT NULL AND eo.price > 0 @@ -367,7 +412,7 @@ def _fetch_normalized_external_price_map(conn, pchome_product_ids: list[str]) -> AND eo.match_status IN ('verified', 'usable', 'reviewed', 'exact', 'confirmed') AND eo.data_quality_status IN ('verified', 'usable', 'reviewed') AND (eo.expires_at IS NULL OR eo.expires_at > CURRENT_TIMESTAMP) - ORDER BY eo.pchome_product_id, eo.observed_at DESC NULLS LAST, eo.id DESC + ORDER BY eo.pchome_product_id, eo.source_code, eo.observed_at DESC NULLS LAST, eo.id DESC ) SELECT * FROM latest_offer """ @@ -376,8 +421,11 @@ def _fetch_normalized_external_price_map(conn, pchome_product_ids: list[str]) -> WITH latest_offer AS ( SELECT eo.pchome_product_id, + eo.source_code, + eo.platform_code, eo.source_product_id AS momo_sku, eo.title AS momo_name, + eo.product_url, eo.price AS momo_price, eo.quality_score, eo.quality_notes_json, @@ -385,12 +433,11 @@ def _fetch_normalized_external_price_map(conn, pchome_product_ids: list[str]) -> eo.observed_at, eo.ingestion_method, ROW_NUMBER() OVER ( - PARTITION BY eo.pchome_product_id + PARTITION BY eo.pchome_product_id, eo.source_code ORDER BY eo.observed_at DESC, eo.id DESC ) AS rn FROM external_offers eo - WHERE eo.source_code = 'momo_reference' - AND eo.pchome_product_id IS NOT NULL + WHERE eo.pchome_product_id IS NOT NULL AND eo.pchome_product_id IN :ids AND eo.price IS NOT NULL AND eo.price > 0 @@ -405,7 +452,7 @@ def _fetch_normalized_external_price_map(conn, pchome_product_ids: list[str]) -> stmt = text(sql).bindparams(bindparam("ids", expanding=True)) rows = conn.execute(stmt, {"ids": ids}).mappings().all() - result: dict[str, dict[str, Any]] = {} + result: dict[str, list[dict[str, Any]]] = {} for row in rows: raw_payload = _json_dict(row.get("raw_payload_json")) price_basis = _external_price_basis(raw_payload) @@ -413,11 +460,14 @@ def _fetch_normalized_external_price_map(conn, pchome_product_ids: list[str]) -> key = str(row.get("pchome_product_id") or "").strip() if not key: continue - result[key] = { + offer = { "pchome_product_id": key, + "source_code": str(row.get("source_code") or "").strip(), + "platform_code": str(row.get("platform_code") or row.get("source_code") or "").strip(), "pchome_public_name": raw_payload.get("pchome_public_name"), "momo_sku": row.get("momo_sku"), "momo_name": row.get("momo_name"), + "product_url": row.get("product_url"), "momo_price": row.get("momo_price"), "pchome_price": raw_payload.get("pchome_public_price"), "price_basis": price_basis, @@ -435,6 +485,7 @@ def _fetch_normalized_external_price_map(conn, pchome_product_ids: list[str]) -> "data_source": "external_offers", "ingestion_method": row.get("ingestion_method"), } + result.setdefault(key, []).append(offer) return result @@ -542,15 +593,138 @@ def _fetch_legacy_external_price_map(conn, pchome_product_ids: list[str]) -> dic return result -def _fetch_external_price_map(conn, pchome_product_ids: list[str]) -> dict[str, dict[str, Any]]: - normalized_map = _fetch_normalized_external_price_map(conn, pchome_product_ids) +def _external_offer_gap(offer: dict[str, Any]) -> float: + price_basis = str(offer.get("price_basis") or "total_price") + if price_basis == "unit_price": + explicit_gap = offer.get("unit_gap_pct") + if explicit_gap is not None: + return _to_float(explicit_gap) + pchome_price = _to_float(offer.get("pchome_unit_price")) + external_price = _to_float(offer.get("momo_unit_price")) + else: + pchome_price = _to_float(offer.get("pchome_price")) + external_price = _to_float(offer.get("momo_price")) + if pchome_price > 0 and external_price > 0: + return (external_price - pchome_price) / pchome_price * 100 + return 9999.0 + + +def _fetch_external_offer_maps( + conn, + pchome_product_ids: list[str], + source_readiness: dict[str, Any], +) -> tuple[dict[str, dict[str, Any]], dict[str, list[dict[str, Any]]]]: + normalized_map = _fetch_normalized_external_offer_map(conn, pchome_product_ids) + source_policy = { + str(source.get("code") or ""): source + for source in source_readiness.get("sources") or [] + } + primary_map: dict[str, dict[str, Any]] = {} + market_map: dict[str, list[dict[str, Any]]] = {} + + for product_id, offers in normalized_map.items(): + normalized_offers = [] + for offer in offers: + source_code = str(offer.get("source_code") or "").strip() + policy = source_policy.get(source_code, {}) + enriched = { + **offer, + "source_display_name": policy.get("display_name") or source_code or "外部平台", + "source_status": policy.get("status_code") or "unregistered", + "source_enabled": bool(policy.get("enabled", False)), + "actionable": bool(policy.get("can_alert")), + } + enriched["gap_pct"] = round(_external_offer_gap(enriched), 1) + normalized_offers.append(enriched) + normalized_offers.sort(key=lambda item: (_external_offer_gap(item), item.get("source_code") or "")) + market_map[product_id] = normalized_offers + actionable = [offer for offer in normalized_offers if offer.get("actionable")] + if actionable: + primary_map[product_id] = actionable[0] + missing_ids = [ str(item).strip() for item in pchome_product_ids - if str(item or "").strip() and str(item).strip() not in normalized_map + if str(item or "").strip() and str(item).strip() not in primary_map ] legacy_map = _fetch_legacy_external_price_map(conn, missing_ids) - return {**legacy_map, **normalized_map} + momo_policy = source_policy.get("momo_reference", {}) + for product_id, legacy in legacy_map.items(): + legacy_offer = { + **legacy, + "source_code": "momo_reference", + "platform_code": "momo", + "source_display_name": momo_policy.get("display_name") or "MOMO 外部價格參考", + "source_status": momo_policy.get("status_code") or "active", + "source_enabled": bool(momo_policy.get("enabled", True)), + "actionable": bool(momo_policy.get("can_alert", True)), + } + legacy_offer["gap_pct"] = round(_external_offer_gap(legacy_offer), 1) + existing_sources = { + str(item.get("source_code") or "") for item in market_map.get(product_id, []) + } + if "momo_reference" not in existing_sources: + market_map.setdefault(product_id, []).append(legacy_offer) + if legacy_offer["actionable"]: + primary_map[product_id] = legacy_offer + + for offers in market_map.values(): + offers.sort(key=lambda item: (_external_offer_gap(item), item.get("source_code") or "")) + + return primary_map, market_map + + +def _fetch_catalog_mapping_summary(conn) -> dict[str, Any]: + cols = _daily_sales_columns(conn) + sku_column = cols.get("sku") + if not sku_column: + return { + "catalog_product_count": 0, + "mapped_product_count": 0, + "catalog_mapping_rate": 0.0, + } + + sales_rows = conn.execute(text( + f"SELECT DISTINCT TRIM({_as_text_expr(sku_column, conn.dialect.name)}) AS product_id " + "FROM daily_sales_snapshot " + f"WHERE {_quote_identifier(sku_column)} IS NOT NULL" + )).fetchall() + sales_ids = {str(row[0]).strip() for row in sales_rows if str(row[0] or "").strip()} + mapped_ids: set[str] = set() + inspector = inspect(conn) + if inspector.has_table("external_offers"): + rows = conn.execute(text(""" + SELECT DISTINCT pchome_product_id + FROM external_offers + WHERE pchome_product_id IS NOT NULL + AND price IS NOT NULL + AND price > 0 + AND COALESCE(quality_score, 0) >= 76 + AND match_status IN ('verified', 'usable', 'reviewed', 'exact', 'confirmed') + AND data_quality_status IN ('verified', 'usable', 'reviewed') + AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) + """)).fetchall() + mapped_ids.update(str(row[0]).strip() for row in rows if str(row[0] or "").strip()) + if inspector.has_table("competitor_prices"): + rows = conn.execute(text(""" + SELECT DISTINCT competitor_product_id + FROM competitor_prices + WHERE source = 'pchome' + AND competitor_product_id IS NOT NULL + AND price IS NOT NULL + AND price > 0 + AND COALESCE(match_score, 0) >= 0.76 + AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) + """)).fetchall() + mapped_ids.update(str(row[0]).strip() for row in rows if str(row[0] or "").strip()) + + mapped_catalog_ids = sales_ids & mapped_ids + return { + "catalog_product_count": len(sales_ids), + "mapped_product_count": len(mapped_catalog_ids), + "catalog_mapping_rate": round(len(mapped_catalog_ids) / max(len(sales_ids), 1) * 100, 2), + "unmapped_product_count": max(0, len(sales_ids) - len(mapped_catalog_ids)), + } def _fetch_review_candidate_map(conn, pchome_product_ids: list[str]) -> dict[str, dict[str, Any]]: @@ -646,10 +820,32 @@ def _fetch_review_candidate_map(conn, pchome_product_ids: list[str]) -> dict[str return result +def _serialize_market_offer(offer: dict[str, Any]) -> dict[str, Any]: + gap = _external_offer_gap(offer) + return { + "source_code": offer.get("source_code") or "momo_reference", + "source": offer.get("source_display_name") or "MOMO 外部價格參考", + "platform_code": offer.get("platform_code") or "momo", + "source_product_id": offer.get("momo_sku"), + "title": offer.get("momo_name"), + "product_url": offer.get("product_url"), + "price": round(_to_float(offer.get("momo_price")), 2) if _to_float(offer.get("momo_price")) else None, + "pchome_price": round(_to_float(offer.get("pchome_price")), 2) if _to_float(offer.get("pchome_price")) else None, + "price_basis": offer.get("price_basis") or "total_price", + "gap_pct": round(gap, 1) if gap < 9999 else None, + "match_score": round(_to_float(offer.get("match_score")), 3), + "source_status": offer.get("source_status") or "active", + "actionable": bool(offer.get("actionable")), + "evidence_state": "decision_ready" if offer.get("actionable") else "observation_only", + "updated_at": str(offer.get("crawled_at") or ""), + } + + def _score_opportunity( sales_row: dict[str, Any], external_row: dict[str, Any] | None, review_candidate: dict[str, Any] | None = None, + market_offers: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: sales_7d = _to_float(sales_row.get("sales_7d")) sales_prev_7d = _to_float(sales_row.get("sales_prev_7d")) @@ -663,13 +859,15 @@ def _score_opportunity( decline_score = min(24, abs(sales_delta_pct) / 45 * 24) if sales_delta_pct is not None and sales_delta_pct < 0 else 0 data_quality_score = 54 external_payload = None + market_offer_payloads = [_serialize_market_offer(offer) for offer in (market_offers or [])] review_candidate_payload = None action_code = "map_external_product" action_label = "先補商品對應" - action_message = "這項商品已有業績訊號,但還沒有可確認的 MOMO 對照商品。先補對應,後續才能判斷價格壓力。" + action_message = "這項商品已有業績訊號,但還沒有可確認的外部平台同款。先補對應,後續才能判斷價格與促銷壓力。" reason_lines = [] if external_row: + source_name = str(external_row.get("source_display_name") or "MOMO 外部價格參考") price_basis = str(external_row.get("price_basis") or "total_price") price_basis_label = external_row.get("price_basis_label") or _external_price_basis_label(price_basis) pchome_price = _to_float(external_row.get("pchome_price")) @@ -691,7 +889,9 @@ def _score_opportunity( tags = _load_json_tags(external_row.get("tags")) data_quality_score = 78 + min(12, _to_float(external_row.get("match_score")) * 12) external_payload = { - "source": "MOMO", + "source": source_name, + "source_code": external_row.get("source_code") or "momo_reference", + "platform_code": external_row.get("platform_code") or "momo", "data_source": external_row.get("data_source") or "competitor_prices", "data_source_label": "自動同步資料層" if external_row.get("data_source") == "external_offers" @@ -716,7 +916,7 @@ def _score_opportunity( if gap_pct is not None and gap_pct < -5: action_code = "review_price_or_promo" action_label = "檢查售價與活動" - action_message = "MOMO 外部參考價比較低,建議檢查 PChome 售價、活動組合或曝光策略。" + action_message = f"{source_name}的同款價格較低,建議檢查 PChome 售價、活動組合或曝光策略。" elif gap_pct is not None and gap_pct > 5: action_code = "amplify_price_advantage" action_label = "放大價格優勢" @@ -733,11 +933,11 @@ def _score_opportunity( if gap_pct is not None: basis_prefix = "單位價" if price_basis == "unit_price" else "價格" if gap_pct > 0: - reason_lines.append(f"PChome {basis_prefix}目前比 MOMO 低約 {abs(gap_pct):.1f}%。") + reason_lines.append(f"PChome {basis_prefix}目前比{source_name}低約 {abs(gap_pct):.1f}%。") elif gap_pct < 0: - reason_lines.append(f"MOMO {basis_prefix}目前比 PChome 低約 {abs(gap_pct):.1f}%。") + reason_lines.append(f"{source_name}{basis_prefix}目前比 PChome 低約 {abs(gap_pct):.1f}%。") else: - reason_lines.append(f"PChome 與 MOMO {basis_prefix}幾乎相同。") + reason_lines.append(f"PChome 與{source_name}{basis_prefix}幾乎相同。") else: if review_candidate: review_candidate_payload = { @@ -757,8 +957,16 @@ def _score_opportunity( action_message = "已找到 MOMO 候選,但還要確認同款、色號或組合後才能進價格判斷。" reason_lines.append("已找到 MOMO 候選,先確認同款、色號或組合。") else: - data_quality_score -= 12 - reason_lines.append("尚未找到可確認的 MOMO 對照商品。") + if market_offer_payloads: + data_quality_score = max(data_quality_score, 60) + action_code = "validate_external_source" + action_label = "驗證外部來源" + action_message = "已有外部平台同款證據,但來源尚未通過啟用政策;系統會先完成來源與商品身份驗證。" + observed_sources = sorted({str(item.get("source") or "") for item in market_offer_payloads}) + reason_lines.append(f"已有 {len(observed_sources)} 個外部平台證據,尚未進入正式價格決策。") + else: + data_quality_score -= 12 + reason_lines.append("尚未找到可確認的外部平台同款商品。") if sales_delta_pct is None: reason_lines.append("前 7 天沒有可比基準,先看近 7 天表現。") @@ -772,7 +980,7 @@ def _score_opportunity( if sales_7d > 0: reason_lines.append(f"近 7 天業績約 NT$ {sales_7d:,.0f},銷量 {qty_7d:,.0f}。") - mapping_gap_score = 18 if not external_row and max(sales_7d, sales_prev_7d) > 0 else 0 + mapping_gap_score = 18 if not external_row and not market_offer_payloads and max(sales_7d, sales_prev_7d) > 0 else 0 priority_score = min(100, volume_score + qty_score + decline_score + mapping_gap_score + data_quality_score * 0.18) if external_payload and external_payload.get("gap_pct") is not None: gap = float(external_payload["gap_pct"]) @@ -783,7 +991,13 @@ def _score_opportunity( issues = [] if not external_row: - issues.append("需要確認 MOMO 候選" if review_candidate_payload else "需要補商品對應") + issues.append( + "外部來源尚未啟用" + if market_offer_payloads + else "需要確認 MOMO 候選" + if review_candidate_payload + else "需要補商品對應" + ) if sales_delta_pct is None: issues.append("前期業績不足") @@ -801,6 +1015,14 @@ def _score_opportunity( else None, "last_sale_date": str(sales_row.get("last_sale_date") or ""), "external_price": external_payload, + "market_offers": market_offer_payloads, + "cross_platform": { + "observed_platform_count": len({item.get("source_code") for item in market_offer_payloads}), + "decision_ready_platform_count": len({ + item.get("source_code") for item in market_offer_payloads if item.get("actionable") + }), + "lowest_external_offer": market_offer_payloads[0] if market_offer_payloads else None, + }, "review_candidate": review_candidate_payload, "priority_score": round(priority_score, 1), "recommended_action": { @@ -833,12 +1055,18 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] review_candidate_count = int(source_readiness.get("review_offer_count") or 0) active_external_sources = _source_names_by_status(source_readiness, "active") or list(ACTIVE_EXTERNAL_SOURCES) paused_external_sources = _source_names_by_status(source_readiness, "paused") or list(PAUSED_EXTERNAL_SOURCES) + observed_external_sources = [ + str(source.get("display_name") or source.get("code") or "") + for source in source_readiness.get("sources") or [] + if source.get("has_runtime_data") + ] source_scope = { "primary_goal": "提升 PChome 業績", "primary_sales_source": PRIMARY_SALES_SOURCE, "active_external_sources": active_external_sources, "paused_external_sources": paused_external_sources, - "plain_note": "MOMO 先用;其他主流平台待接入,不進作戰清單,也不發告警。", + "observed_external_sources": observed_external_sources, + "plain_note": "多平台證據會完整保留;只有 runtime 已啟用且通過同款與品質驗證的報價能驅動正式決策。", "source_readiness": source_readiness, "offer_evidence_contract": source_readiness.get("offer_evidence_contract") or {}, } @@ -856,6 +1084,7 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] "mapping_rate": 0, "needs_mapping_count": 0, "review_candidate_count": review_candidate_count, + "sales_freshness": _sales_freshness(None), }, "opportunities": [], "message": "目前還沒有 PChome 業績資料,請先完成業績匯入。", @@ -863,6 +1092,8 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] with engine.connect() as conn: sales_summary = _fetch_sales_summary(conn) + catalog_mapping_summary = _fetch_catalog_mapping_summary(conn) + summary_freshness = _sales_freshness(sales_summary.get("overall_latest_sales_date")) try: sales_rows, latest_sales_date = _fetch_sales_rows(conn, limit=limit) except RuntimeError as exc: @@ -883,28 +1114,43 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] "action_counts": {}, "action_code_counts": {}, "external_data_source_counts": {}, + "external_platform_counts": {}, + "sales_freshness": summary_freshness, + **catalog_mapping_summary, **sales_summary, }, "opportunities": [], "message": str(exc), } sales_ids = [str(row.get("pchome_product_id") or "") for row in sales_rows] - external_map = _fetch_external_price_map(conn, sales_ids) + external_map, market_offer_map = _fetch_external_offer_maps( + conn, + sales_ids, + source_readiness, + ) review_candidate_map = _fetch_review_candidate_map(conn, sales_ids) opportunities = [] for row in sales_rows: key = str(row.get("pchome_product_id") or "").strip() - opportunities.append(_score_opportunity(row, external_map.get(key), review_candidate_map.get(key))) + opportunities.append(_score_opportunity( + row, + external_map.get(key), + review_candidate_map.get(key), + market_offer_map.get(key), + )) opportunities.sort(key=lambda item: item["priority_score"], reverse=True) opportunities = opportunities[:limit] - needs_mapping_count = sum(1 for item in opportunities if not item.get("external_price")) - mapped_count = len(opportunities) - needs_mapping_count + needs_mapping_count = sum(1 for item in opportunities if not item.get("market_offers")) + observed_mapping_count = len(opportunities) - needs_mapping_count + mapped_count = sum(1 for item in opportunities if item.get("external_price")) mapping_rate = round(mapped_count / max(len(opportunities), 1) * 100, 1) + observed_mapping_rate = round(observed_mapping_count / max(len(opportunities), 1) * 100, 1) action_counts: dict[str, int] = {} action_code_counts: dict[str, int] = {} external_data_source_counts: dict[str, int] = {} + external_platform_counts: dict[str, int] = {} for item in opportunities: label = item["recommended_action"]["label"] code = item["recommended_action"]["code"] @@ -916,6 +1162,12 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] external_data_source_counts[data_source_label] = ( external_data_source_counts.get(data_source_label, 0) + 1 ) + for offer in item.get("market_offers") or []: + source_name = str(offer.get("source") or offer.get("source_code") or "外部平台") + external_platform_counts[source_name] = external_platform_counts.get(source_name, 0) + 1 + + latest_sales = latest_sales_date or sales_summary.get("overall_latest_sales_date") + sales_freshness = _sales_freshness(latest_sales) return { "success": True, @@ -923,10 +1175,13 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] "generated_at": generated_at, "source_scope": source_scope, "stats": { - "latest_sales_date": latest_sales_date or sales_summary.get("overall_latest_sales_date"), + "latest_sales_date": latest_sales, + "sales_freshness": sales_freshness, "candidate_count": len(opportunities), "mapped_count": mapped_count, "mapping_rate": mapping_rate, + "observed_mapping_count": observed_mapping_count, + "observed_mapping_rate": observed_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), @@ -934,8 +1189,16 @@ def build_pchome_growth_opportunities(engine, limit: int = 20) -> dict[str, Any] "action_counts": action_counts, "action_code_counts": action_code_counts, "external_data_source_counts": external_data_source_counts, + "external_platform_counts": external_platform_counts, + "platforms_with_runtime_data": int(source_readiness.get("sources_with_data_count") or 0), + "platform_source_coverage_rate": _to_float(source_readiness.get("source_coverage_rate")), + **catalog_mapping_summary, **sales_summary, }, "opportunities": opportunities, - "message": "已整理今日 PChome 業績成長作戰清單。", + "message": ( + "業績資料已過期,作戰清單只供觀察;系統應先自動取得最新業績。" + if not sales_freshness["decision_ready"] + else "已依最新業績與外部平台證據整理今日作戰清單。" + ), } diff --git a/templates/ai_intelligence.html b/templates/ai_intelligence.html index a35b1f9..5d4ffad 100644 --- a/templates/ai_intelligence.html +++ b/templates/ai_intelligence.html @@ -2870,9 +2870,7 @@ - - - + 確認資料新鮮度 等待資料
@@ -2882,7 +2880,7 @@ —%
- 等待 MOMO 對應 + 等待外部平台對應
@@ -2955,9 +2953,9 @@ -
+
-

PChome 與 MOMO 價格狀態

+

PChome 與外部平台

@@ -2972,7 +2970,7 @@
- MOMO 低價壓力 + 外部低價壓力
@@ -3003,7 +3001,7 @@
待補比價 - 需要補抓 MOMO + 需要補外部平台同款
最新業績日 @@ -3913,12 +3911,12 @@ function renderOpsCommandDashboard(stats, scope = {}) { document.getElementById('opsDashboardStatus').textContent = candidateCount ? `資料就緒率 ${readyRate}%` : '等待 PChome 業績資料'; - renderNextAction(candidateCount, mappedCount, needsMapping, reviewCandidateCount); + renderNextAction(stats); setWidth('opsFunnelCandidateBar', candidateCount ? 100 : 0); setWidth('opsFunnelMappedBar', candidateCount ? (mappedCount / candidateCount) * 100 : 0); setWidth('opsFunnelNeedsBar', candidateCount ? (needsMapping / candidateCount) * 100 : 0); - renderOpsSourceBars(stats.external_data_source_counts || {}, scope); + renderOpsSourceBars(stats.external_platform_counts || {}, scope); renderGrowthExecutiveSummary(stats); renderGrowthCommandCenter(stats, latestGrowthRows); } @@ -3956,11 +3954,15 @@ function renderGrowthCommandCenter(stats = {}, rows = []) { const pendingCount = Math.max(0, needsMapping + reviewCandidateCount); const readyShare = candidateCount ? (advantageCount / candidateCount) * 100 : 0; const pressureShare = candidateCount ? (pressureCount / candidateCount) * 100 : 0; + const freshness = stats.sales_freshness || {}; setText('commandSales7d', sales7d ? formatMoney(sales7d) : '—'); setText('commandSalesDelta', prevSales7d ? `${salesDeltaPct >= 0 ? '+' : ''}${salesDeltaPct.toFixed(1)}%` : '待比'); + setText('commandSalesFreshness', freshness.age_days === null || freshness.age_days === undefined + ? (freshness.label || '尚無資料') + : `${freshness.label || '資料狀態'} · ${formatCount(freshness.age_days)} 天`); setText('commandMatchRate', `${Math.round(matchRate * 10) / 10}%`); setText('commandMappedRatio', `${formatCount(mappedCount)} / ${formatCount(candidateCount)}`); setText('commandDecliningProducts', formatCount(decliningCount)); @@ -3980,8 +3982,11 @@ function renderGrowthCommandCenter(stats = {}, rows = []) { const status = document.getElementById('growthCommandStatus'); if (status) { - const text = latestSalesDate ? `更新 ${latestSalesDate}` : '待資料'; - status.innerHTML = `${escapeHtml(text)}`; + const text = freshness.decision_ready + ? `資料可用 · ${latestSalesDate}` + : `${freshness.label || '待資料'}${latestSalesDate ? ` · ${latestSalesDate}` : ''}`; + const icon = freshness.decision_ready ? 'fa-circle-check' : 'fa-triangle-exclamation'; + status.innerHTML = `${escapeHtml(text)}`; } const rateBar = document.getElementById('commandMatchRateBar'); if (rateBar) rateBar.style.width = `${clampPercent(matchRate)}%`; @@ -3991,7 +3996,16 @@ function renderGrowthCommandCenter(stats = {}, rows = []) { donut.style.setProperty('--review', clampPercent(pressureShare)); } - if (!candidateCount) { + if (freshness.decision_ready === false) { + setCommandTask( + '先更新業績資料', + freshness.age_days === null || freshness.age_days === undefined + ? '尚未取得可用業績' + : `資料落後 ${formatCount(freshness.age_days)} 天`, + '查看匯入狀態', + () => { window.location.href = '/daily_sales'; } + ); + } else if (!candidateCount) { setCommandTask( '更新業績', '待業績', @@ -4059,11 +4073,28 @@ function renderGrowthCommandTopDecliners(rows) { }).join(''); } -function renderNextAction(candidateCount, mappedCount, needsMapping, reviewCandidateCount = 0) { +function renderNextAction(stats = {}) { const title = document.getElementById('nextActionTitle'); const reason = document.getElementById('nextActionReason'); const button = document.getElementById('nextActionButton'); if (!title || !reason || !button) return; + 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); + const freshness = stats.sales_freshness || {}; + + if (freshness.decision_ready === false) { + const age = freshness.age_days; + title.textContent = '今天先做:更新 PChome 業績'; + reason.textContent = age === null || age === undefined + ? '尚未取得可用業績,價格與曝光決策暫不執行。' + : `最新業績已落後 ${formatCount(age)} 天,系統先恢復資料來源,再重新計算作戰清單。`; + button.textContent = '查看匯入狀態'; + delete button.dataset.action; + button.onclick = () => { window.location.href = '/daily_sales'; }; + return; + } if (!candidateCount) { title.textContent = '今天先做:更新 PChome 業績'; @@ -4076,7 +4107,7 @@ function renderNextAction(candidateCount, mappedCount, needsMapping, reviewCandi if (needsMapping > 0 && mappedCount === 0) { if (reviewCandidateCount > 0) { - title.textContent = `今天先做:確認 ${formatCount(reviewCandidateCount)} 筆 MOMO 候選`; + title.textContent = `今天先做:AI 驗證 ${formatCount(reviewCandidateCount)} 筆候選`; reason.textContent = '候選已找到,先確認同款或排除,確認後才會進入價格判斷。'; button.textContent = '確認候選'; delete button.dataset.action; @@ -4084,7 +4115,7 @@ function renderNextAction(candidateCount, mappedCount, needsMapping, reviewCandi return; } title.textContent = `今天先做:補齊 ${formatCount(needsMapping)} 件比價資料`; - reason.textContent = '這些商品有業績,但目前還看不到 MOMO 參考價,請先補齊比價資料。'; + reason.textContent = '這些商品有業績,但目前還沒有可決策的外部平台同款,系統先補齊商品對應。'; button.textContent = '補齊比價資料'; button.dataset.action = 'backfill'; button.onclick = () => backfillPchomeMatches(); @@ -4093,7 +4124,7 @@ function renderNextAction(candidateCount, mappedCount, needsMapping, reviewCandi if (needsMapping > mappedCount) { if (reviewCandidateCount > 0) { - title.textContent = `今天先做:確認 ${formatCount(reviewCandidateCount)} 筆 MOMO 候選`; + title.textContent = `今天先做:AI 驗證 ${formatCount(reviewCandidateCount)} 筆候選`; reason.textContent = '先把已找到的候選變成可用資料,再補剩下找不到同款的商品。'; button.textContent = '確認候選'; delete button.dataset.action; @@ -4121,17 +4152,27 @@ function renderGrowthExecutiveSummary(stats = {}) { const needsMapping = Number(stats.needs_mapping_count || 0); const reviewCandidateCount = Number(stats.review_candidate_count || 0); const latestSalesDate = String(stats.latest_sales_date || '').slice(0, 10); + const freshness = stats.sales_freshness || {}; document.getElementById('growthExecReady').textContent = formatCount(mappedCount); document.getElementById('growthExecGap').textContent = formatCount(needsMapping); document.getElementById('growthExecLatestDate').textContent = latestSalesDate || '—'; - document.getElementById('growthExecLatestDetail').textContent = latestSalesDate ? '已接到 PChome 業績' : '尚未確認業績日期'; + document.getElementById('growthExecLatestDetail').textContent = freshness.label || (latestSalesDate ? '已接到 PChome 業績' : '尚未確認業績日期'); const task = document.getElementById('growthExecTask'); const detail = document.getElementById('growthExecTaskDetail'); const gapCard = document.getElementById('growthExecGapCard'); if (!task || !detail) return; + if (freshness.decision_ready === false) { + task.textContent = '先恢復最新業績'; + detail.textContent = freshness.age_days === null || freshness.age_days === undefined + ? '尚未取得可用業績,營收決策暫停。' + : `業績落後 ${formatCount(freshness.age_days)} 天,系統先完成自動匯入。`; + gapCard?.classList.add('is-gap'); + return; + } + if (!candidateCount) { task.textContent = '先更新 PChome 業績'; detail.textContent = '目前還沒有足夠資料,請確認最新業績檔是否已匯入。'; @@ -4225,9 +4266,10 @@ async function loadGrowthOps(forceRefresh = false) { renderGrowthDataSourceSummary(stats); const active = (scope.active_external_sources || []).join('、') || '尚未接入'; - const paused = (scope.paused_external_sources || []).join('、') || '無'; + const observed = (scope.observed_external_sources || []).length; + const paused = (scope.paused_external_sources || []).length; document.getElementById('growthSourceNote').textContent = - `業績:${scope.primary_sales_source || 'PChome 後台業績'} · 外部:${active} · 暫停:${paused}`; + `業績:${scope.primary_sales_source || 'PChome 後台業績'} · 決策來源:${active} · 已有證據 ${observed} 個 · 待接入 ${paused} 個`; renderGrowthSourceReadiness((scope.source_readiness || {}).sources || []); latestGrowthRows = data.opportunities || []; @@ -4273,6 +4315,14 @@ function renderGrowthActionHint(stats) { const mappedCount = Number(stats.mapped_count || 0); const needsMapping = Number(stats.needs_mapping_count || 0); const reviewCandidateCount = Number(stats.review_candidate_count || 0); + const freshness = stats.sales_freshness || {}; + + if (freshness.decision_ready === false) { + hint.textContent = freshness.age_days === null || freshness.age_days === undefined + ? '尚未取得可用業績,系統先恢復上游資料。' + : `最新業績落後 ${formatCount(freshness.age_days)} 天,先完成自動匯入再執行營收動作。`; + return; + } if (!candidateCount) { hint.textContent = '還不能產生今日清單,原因是缺少最新 PChome 業績。'; @@ -4301,10 +4351,10 @@ function renderGrowthDataSourceSummary(stats) { const summary = document.getElementById('growthDataSourceSummary'); if (!summary) return; - const counts = stats.external_data_source_counts || {}; + const counts = stats.external_platform_counts || {}; const entries = Object.entries(counts) .filter(([, count]) => Number(count || 0) > 0) - .map(([label, count]) => `${formatDataSourceLabel(label)} ${Number(count).toLocaleString()} 筆`); + .map(([label, count]) => `${label} ${Number(count).toLocaleString()} 筆`); if (!entries.length) { summary.textContent = '來源:尚未接到外部參考價'; @@ -4328,7 +4378,11 @@ function renderGrowthSourceReadiness(sources) { return; } - box.innerHTML = sources.map((source) => { + const visibleSources = sources.filter((source) => + source.status_code === 'active' || source.has_runtime_data + ); + const pendingCount = Math.max(0, sources.length - visibleSources.length); + const cards = visibleSources.map((source) => { const usable = Number(source.usable_offer_count || 0); const detail = usable > 0 ? `${source.data_quality_label || '資料可用'} · ${usable.toLocaleString()} 筆` @@ -4341,7 +4395,17 @@ function renderGrowthSourceReadiness(sources) {

${escapeHtml(detail)}

`; - }).join(''); + }); + if (pendingCount > 0) { + cards.push(`
+
+ 其他平台 + ${formatCount(pendingCount)} 個待接入 +
+

完成合法來源、同款驗證與品質門檻後自動啟用。

+
`); + } + box.innerHTML = cards.join(''); } function resolveGrowthDetailKind(kind) { diff --git a/tests/test_auto_import_failure_boundaries.py b/tests/test_auto_import_failure_boundaries.py index fc7b770..d065354 100644 --- a/tests/test_auto_import_failure_boundaries.py +++ b/tests/test_auto_import_failure_boundaries.py @@ -60,7 +60,7 @@ def test_daily_sales_import_fails_when_monthly_sync_fails(monkeypatch, tmp_path) source_df = pd.DataFrame([ { - "日期": "2026-06-24", + "日期": pd.Timestamp.today().strftime("%Y-%m-%d"), "商品ID": "A001", "商品名稱": "測試商品", "銷售金額": 1200, @@ -349,4 +349,41 @@ def test_auto_import_empty_drive_folder_remains_success(monkeypatch, tmp_path): assert result["success"] is True assert result["file_count"] == 0 + assert result["status"] == "upstream_missing" + assert result["decision_ready"] is False assert result["message"] == "沒有找到待匯入的檔案" + + +def test_auto_import_empty_drive_exposes_stale_upstream_as_degraded(monkeypatch, tmp_path): + import_service = _load_import_service(monkeypatch, f"sqlite:///{tmp_path / 'momo.db'}") + import_service.Base.metadata.create_all(import_service.engine) + with import_service.engine.begin() as conn: + conn.execute(text("CREATE TABLE IF NOT EXISTS daily_sales_snapshot (snapshot_date TEXT)")) + conn.execute(text("INSERT INTO daily_sales_snapshot (snapshot_date) VALUES ('2020-01-01')")) + + class FakeDriveService: + last_error_kind = None + last_error = None + + def list_files_in_folder(self, folder_path, file_pattern): + return [] + + import services.openclaw_strategist_service as strategist + + alerts = [] + monkeypatch.setattr(import_service, "drive_service", FakeDriveService()) + monkeypatch.setattr( + strategist, + "_send_data_stale_alert", + lambda **kwargs: alerts.append(kwargs), + ) + + result = import_service.ImportService().auto_import_from_drive() + + assert result["success"] is True + assert result["status"] == "upstream_stale" + assert result["decision_ready"] is False + assert result["requires_upstream_acquisition"] is True + assert result["latest_sales_date"] == "2020-01-01" + assert result["data_lag_days"] >= 3 + assert alerts and alerts[0]["report_type"] == "upstream_drive" diff --git a/tests/test_competitor_identity_revalidator.py b/tests/test_competitor_identity_revalidator.py index ce9234a..4116af7 100644 --- a/tests/test_competitor_identity_revalidator.py +++ b/tests/test_competitor_identity_revalidator.py @@ -133,15 +133,15 @@ def test_dashboard_match_status_shows_manual_review_closure_states(): "best_match_score": 0.68, }) - assert rejected["label"] == "人工已否決" + assert rejected["label"] == "AI 已排除" assert "跳過正式價差寫入" in rejected["summary"] - assert unit_price["label"] == "人工標記單位價" + assert unit_price["label"] == "AI 標記單位價" assert "總價不可直接比較" in unit_price["summary"] - assert needs_research["label"] == "人工要求補搜尋" + assert needs_research["label"] == "AI 要求補搜尋" assert "重新抓取" in needs_research["summary"] decision = _build_competitor_decision(980, 899, match_status=rejected) - assert decision["label"] == "人工已否決" + assert decision["label"] == "AI 已排除" assert decision["gap_amount"] is None @@ -154,9 +154,9 @@ def test_dashboard_match_status_shows_rescore_accepted_review_state(): "error_message": "matcher_rescore=accepted_current; reasons=strong_exact_spec_match", }) - assert status["label"] == "重算待人工覆核" + assert status["label"] == "重算待 AI 決策" assert status["tone"] == "watch" - assert "人工確認身份後才可寫入正式 PChome 價差" in status["summary"] + assert "AI 決策信封確認身份" in status["summary"] def test_dashboard_match_status_uses_specific_matcher_reason_labels(): @@ -175,8 +175,8 @@ def test_dashboard_match_status_uses_specific_matcher_reason_labels(): assert finish_gap["label"] == "妝效質地不同" assert "妝效質地不同" in finish_gap["summary"] - assert variant_review["label"] == "多款任選待確認" - assert "需人工確認" in variant_review["summary"] + assert variant_review["label"] == "多款任選待決策" + assert "需 AI 例外決策" in variant_review["summary"] def test_dashboard_match_status_uses_focused_marketplace_reason_labels(): diff --git a/tests/test_competitor_intel_cache.py b/tests/test_competitor_intel_cache.py index 6f2853d..d0f1b7a 100644 --- a/tests/test_competitor_intel_cache.py +++ b/tests/test_competitor_intel_cache.py @@ -184,12 +184,11 @@ def test_competitor_review_queue_is_canonical_unit_price_handoff(): assert "\"action_label\"" in source assert "build_unit_price_comparison" in source assert "需單位價覆核" in daily_template - assert "人工採用" in daily_template - assert "人工否決" in daily_template - assert "人工單位價" in daily_template + assert "AI 採用" in daily_template + assert "AI 排除" in daily_template + assert "AI 單位價" in daily_template assert "competitor_intel.review_queue" in daily_template - assert "coverage.fresh_matches" in growth_template - assert "coverage.fresh_match_rate" in growth_template + assert "coverage.match_rate" in growth_template assert "coverage.decision_ready_rate" in growth_template assert "coverage.decision_support_rate" in growth_template assert "coverage.catalog_comparable_count" in growth_template @@ -199,12 +198,12 @@ def test_competitor_review_queue_is_canonical_unit_price_handoff(): assert "未形成有效身份配對" in growth_template assert "coverage.unit_comparable_count" in growth_template assert "coverage.rescore_accepted_count" in growth_template - assert "重算待人工覆核" in growth_template + assert "重算待 AI 決策" in growth_template assert "coverage.manual_accept_count" in growth_template assert "coverage.manual_reject_count" in growth_template assert "coverage.manual_unit_price_count" in growth_template assert "comp_coverage.rescore_accepted_count" in daily_template - assert "重算待人工覆核" in daily_template + assert "重算待 AI 決策" in daily_template assert "comp_coverage.decision_support_rate" in daily_template assert "comp_coverage.catalog_comparable_count" in daily_template assert "精準可告警覆蓋" in daily_template @@ -364,7 +363,8 @@ def test_competitor_review_reasons_prefer_json_payload_labels(): assert envelope["identity_evidence"]["brand"]["shared"] == ["mac"] assert envelope["offer_evidence"]["gap_pct"] == 12.5 assert envelope["difference_highlights"][0]["dimension"] == "妝效/質地不同" - assert envelope["recommended_action"]["requires_hitl"] is True + assert envelope["recommended_action"]["requires_hitl"] is False + assert envelope["recommended_action"]["requires_ai_exception"] is True assert envelope["recommended_action"]["action"] == "verify_or_reject_identity" assert any(evidence["metric"] == "reasons" for evidence in envelope["evidence"]) assert any(evidence["metric"] == "identity_evidence" for evidence in envelope["evidence"]) @@ -502,12 +502,13 @@ def test_review_decision_brief_is_shared_by_openclaw_and_ppt(): brief = summarize_review_decision_envelopes([item], limit=5) - assert brief["hitl_count"] == 1 + assert brief["hitl_count"] == 0 + assert brief["ai_exception_count"] == 1 assert brief["auto_execute_blocked_count"] == 1 assert brief["severity_counts"] assert brief["data_quality_counts"] == {"complete": 1} - assert "人工確認身份後採用同款" in brief["text"] - assert "HITL" in brief["text"] + assert "AI 確認身份後採用同款" in brief["text"] + assert "AI例外" in brief["text"] assert "DDAO4C-A79050612" in brief["text"] repo_source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8") @@ -518,11 +519,11 @@ def test_review_decision_brief_is_shared_by_openclaw_and_ppt(): assert "\"review_decision_brief\": summarize_review_decision_envelopes" in repo_source assert "summarize_review_decision_envelopes" in openclaw_source assert "review_decision_text" in openclaw_source - assert "PChome 覆核決策信封(HITL,不可自動寫正式價差)" in openclaw_source + assert "PChome AI 決策信封(no-write receipt 前不寫正式價差)" in openclaw_source assert "SUM(CASE WHEN attempt_status IN ('unit_comparable'" not in openclaw_source assert "review_decision_brief" in bot_source - assert "覆核決策信封(HITL,不可自動寫正式價差)" in bot_source - assert "覆核決策信封(HITL)" in ppt_source + assert "AI 決策信封(no-write receipt 前不寫正式價差)" in bot_source + assert "AI 決策信封" in ppt_source def test_competitor_ppt_prompt_uses_neutral_ewooc_viewpoint(): diff --git a/tests/test_elephant_alpha_engine.py b/tests/test_elephant_alpha_engine.py index f55327f..4d5bdde 100644 --- a/tests/test_elephant_alpha_engine.py +++ b/tests/test_elephant_alpha_engine.py @@ -146,7 +146,7 @@ def test_competitor_db_evidence_actions_are_concrete(): ) assert actions == [ - "[SKU-1] 測試商品|MOMO $1,200 vs PChome $990 (+17.5%)|每件價差 NT$ 210|證據:高信心同款 / 總價可比 / 可直接價格告警 / score 0.84|建議人工確認 PChome identity_v2 後評估跟價或促銷|PChome D123456" + "[SKU-1] 測試商品|MOMO $1,200 vs PChome $990 (+17.5%)|每件價差 NT$ 210|證據:高信心同款 / 總價可比 / 可直接價格告警 / score 0.84|建議 AI 自動驗證確認 PChome identity_v2 後評估跟價或促銷|PChome D123456" ] @@ -279,9 +279,10 @@ def test_price_review_decision_envelope_blocks_auto_execution(): assert envelope["decision_type"] == "price_decision_review" assert envelope["source_agent"] == "elephant_alpha" - assert envelope["recommended_action"]["requires_hitl"] is True + assert envelope["recommended_action"]["requires_hitl"] is False + assert envelope["recommended_action"]["requires_ai_exception"] is True assert envelope["guardrails"]["can_auto_execute"] is False - assert envelope["guardrails"]["blocked_reason"] == "price decisions require HITL; execution_plan skipped" + assert envelope["guardrails"]["blocked_reason"] == "price decisions require AI 例外決策; execution_plan skipped" assert envelope["guardrails"]["data_quality"] == "complete" @@ -701,7 +702,8 @@ def test_human_escalation_decision_envelope_blocks_auto_execution(): assert envelope["decision_type"] == "ea_escalation" assert envelope["guardrails"]["can_auto_execute"] is False assert envelope["guardrails"]["data_quality"] == "partial" - assert envelope["recommended_action"]["requires_hitl"] is True + assert envelope["recommended_action"]["requires_hitl"] is False + assert envelope["recommended_action"]["requires_ai_exception"] is True assert envelope["trace"]["provider"] == "elephant_alpha" diff --git a/tests/test_event_router.py b/tests/test_event_router.py index e24d4e6..4c8e4f2 100644 --- a/tests/test_event_router.py +++ b/tests/test_event_router.py @@ -193,7 +193,7 @@ def test_dispatch_decision_envelope_skips_ai_handler(monkeypatch): assert "🎯 標的" in message assert "📊 價格證據" in message assert "🧩 比對證據" in message - assert "✅ 人工下一步" in message + assert "✅ AI 例外下一步" in message assert "🚦 通知分級" in message assert "直接價格威脅" in message assert "高信心同款 / 總價可比 / 可直接價格告警" in message diff --git a/tests/test_external_market_offer_service.py b/tests/test_external_market_offer_service.py index 9677e3c..6b4bab9 100644 --- a/tests/test_external_market_offer_service.py +++ b/tests/test_external_market_offer_service.py @@ -21,6 +21,8 @@ def test_connector_contract_lists_mainstream_sources_without_auto_alerts(): "line_shopping", "rakuten", "yahoo_shopping", + "etmall", + "friday", "ruten", "shopify_brand_store", "meta_commerce", @@ -91,7 +93,7 @@ def test_external_offer_csv_dry_run_accepts_chinese_headers_and_classifies_rows( } rows = payload["rows"] assert rows[0]["status_label"] == "可使用" - assert rows[1]["status_label"] == "需人工確認" + assert rows[1]["status_label"] == "需 AI 自動驗證確認" assert "這個來源目前先暫停,不進告警" in rows[1]["reasons"] assert rows[2]["status_label"] == "不能使用" assert "缺少售價" in rows[2]["reasons"] @@ -117,7 +119,7 @@ def test_external_offer_csv_dry_run_accepts_plain_business_headers_and_values(): assert rows[0]["source_code"] == "momo_reference" assert rows[0]["status_label"] == "可使用" assert rows[1]["source_code"] == "momo_reference" - assert rows[1]["status_label"] == "需人工確認" + assert rows[1]["status_label"] == "需 AI 自動驗證確認" assert "尚未確認同款" in rows[1]["reasons"] assert "缺少 PChome 商品 ID,無法連到業績" in rows[1]["reasons"] @@ -377,7 +379,7 @@ def test_sync_targeted_momo_candidates_skips_total_price_identity_review(monkeyp assert payload["success"] is True assert payload["candidate_count"] == 1 assert payload["written_count"] == 0 - assert payload["skipped_reasons"] == {"候選仍需人工確認": 1} + assert payload["skipped_reasons"] == {"候選仍需 AI 自動驗證確認": 1} assert stale_marks == [] with engine.connect() as conn: count = conn.execute(text("SELECT COUNT(*) FROM external_offers")).scalar() @@ -424,7 +426,7 @@ def test_sync_targeted_momo_review_candidates_writes_needs_review_offer(monkeypa assert payload["status"] == "synced" assert payload["candidate_count"] == 2 assert payload["written_count"] == 1 - assert payload["skipped_reasons"] == {"人工確認候選分數過低": 1} + assert payload["skipped_reasons"] == {"AI 自動驗證確認候選分數過低": 1} with engine.connect() as conn: row = conn.execute(text(""" @@ -608,7 +610,55 @@ def test_external_source_readiness_uses_legacy_momo_reference_cache(): assert sources["tiktok_shop"]["plain_state"] == "先保留接口,不進告警" assert sources["line_shopping"]["plain_state"] == "先保留接口,不進告警" assert sources["shopify_brand_store"]["plain_state"] == "先保留接口,不進告警" - assert payload["plain_summary"] == "MOMO 先用;其他主流平台已列管,未接合法穩定來源前不進告警。" + assert payload["sources_with_data_count"] == 1 + assert payload["source_coverage_rate"] > 0 + assert "只有 runtime 已啟用" in payload["plain_summary"] + + +def test_source_seeds_preserve_runtime_activation_and_readiness_uses_it(): + from services import external_market_offer_service as service + + engine = create_engine("sqlite:///:memory:") + _seed_external_offer_sync_tables(engine) + + with engine.begin() as conn: + service._ensure_external_market_source_seeds(conn) + conn.execute(text(""" + UPDATE external_market_sources + SET status = 'active', enabled = 1 + WHERE code = 'shopee' + """)) + service._ensure_external_market_source_seeds(conn) + source = conn.execute(text(""" + SELECT status, enabled + FROM external_market_sources + WHERE code = 'shopee' + """)).mappings().one() + + assert source["status"] == "active" + assert bool(source["enabled"]) is True + + +def test_source_aliases_normalize_pixelrag_platform_codes(): + from services.external_market_offer_service import normalize_external_offer_payload + + base = { + "source_product_id": "SKU-1", + "title": "測試商品", + "price": 100, + "observed_at": "2026-07-10T10:00:00", + "ingestion_method": "provider_api", + } + expected = { + "yahoo_shopping_tw": "yahoo_shopping", + "etmall_tw": "etmall", + "friday_tw": "friday", + "rakuten_tw": "rakuten", + } + for raw, normalized in expected.items(): + record, errors = normalize_external_offer_payload({**base, "source_code": raw}) + assert errors == [] + assert record.source_code == normalized def test_external_market_migration_creates_source_and_offer_tables(): diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index 0eeb620..b3e1185 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -245,6 +245,7 @@ def test_growth_workflow_pages_hide_raw_export_and_fallback_content(): def test_frontend_v2_syncs_latest_momo_pro_prototype_tokens_and_shell(): tokens = (ROOT / "web/static/css/ewoooc-tokens.css").read_text(encoding="utf-8") + aliases = (ROOT / "web/static/css/ewoooc-tokens-v2-alias.css").read_text(encoding="utf-8") shell = (ROOT / "web/static/css/ewoooc-shell.css").read_text(encoding="utf-8") assert "MOMO Pro × minimal grid × operational clarity" in tokens @@ -256,6 +257,8 @@ def test_frontend_v2_syncs_latest_momo_pro_prototype_tokens_and_shell(): assert "--momo-border-strong" in tokens assert "--momo-sidebar-width" in tokens assert ".momo-app button:not(.btn):not(.btn-close)" in tokens + assert "--momo-warm-caramel: var(--momo-page-accent)" not in aliases + assert "--momo-warm-caramel: #c96442" in aliases assert "background: #1f1a14;" in shell assert "rgba(250, 247, 240, 0.08)" in shell diff --git a/tests/test_market_intel_skeleton.py b/tests/test_market_intel_skeleton.py index d2b9db9..c019fc9 100644 --- a/tests/test_market_intel_skeleton.py +++ b/tests/test_market_intel_skeleton.py @@ -882,7 +882,7 @@ def test_market_intel_preview_template_uses_safe_fetch_false_endpoint(): assert "市場情報尚未進入正式決策" in template assert "資料狀態" in template assert "來源規格" in template - assert "手動整理" in template + assert "AI 受控整理" in template assert "data-market-intel-preview" not in template assert "data-market-intel-writer" not in template assert "/api/market_intel/" not in template diff --git a/tests/test_momo_crawler_targeted_search.py b/tests/test_momo_crawler_targeted_search.py index 5056095..5425086 100644 --- a/tests/test_momo_crawler_targeted_search.py +++ b/tests/test_momo_crawler_targeted_search.py @@ -178,7 +178,7 @@ def test_targeted_momo_search_keeps_identity_review_candidates_manual(monkeypatc ) assert success is True - assert "需人工確認 1 筆" in message + assert "需 AI 自動驗證確認 1 筆" in message assert products[0]["auto_compare_type"] == "manual_review" assert products[0]["can_auto_compare"] is False assert products[0]["target_price_basis"] == "none" diff --git a/tests/test_nemotron_decision_envelope.py b/tests/test_nemotron_decision_envelope.py index 90f59a8..f4103de 100644 --- a/tests/test_nemotron_decision_envelope.py +++ b/tests/test_nemotron_decision_envelope.py @@ -37,7 +37,8 @@ def test_price_decision_envelope_has_hilt_guardrails(): assert envelope["guardrails"]["match_type"] == "exact" assert envelope["guardrails"]["price_basis"] == "total_price" assert envelope["guardrails"]["alert_tier"] == "price_alert_exact" - assert envelope["recommended_action"]["requires_hitl"] is True + assert envelope["recommended_action"]["requires_hitl"] is False + assert envelope["recommended_action"]["requires_ai_exception"] is True assert envelope["expected_impact"]["revenue_loss_7d"] == 65000 assert envelope["expected_impact"]["gap_amount"] == 201 assert envelope["expected_impact"]["momo_price"] == 1200 diff --git a/tests/test_openclaw_daily_template.py b/tests/test_openclaw_daily_template.py index 8e2122b..bdad619 100644 --- a/tests/test_openclaw_daily_template.py +++ b/tests/test_openclaw_daily_template.py @@ -158,7 +158,7 @@ class TestKPIComputation: assert "fetch_competitor_coverage" in source assert "summarize_review_decision_envelopes" in source assert "review_decision_text" in source - assert "重算待人工覆核" in source + assert "重算待 AI 決策" in source # ═══════════════════════════════════════════════════════════════════════════ diff --git a/tests/test_pchome_revenue_growth_service.py b/tests/test_pchome_revenue_growth_service.py index 59ad78b..4cc0695 100644 --- a/tests/test_pchome_revenue_growth_service.py +++ b/tests/test_pchome_revenue_growth_service.py @@ -129,6 +129,59 @@ def _seed_growth_unit_price_external_offer(engine): """)) +def _seed_growth_multiplatform_external_offers(engine): + with engine.begin() as conn: + conn.execute(text(""" + CREATE TABLE external_market_sources ( + code TEXT PRIMARY KEY, + status TEXT, + enabled BOOLEAN + ) + """)) + conn.execute(text(""" + CREATE TABLE external_offers ( + id INTEGER PRIMARY KEY, + source_code TEXT, + platform_code TEXT, + source_product_id TEXT, + title TEXT, + product_url TEXT, + price REAL, + observed_at TEXT, + expires_at TEXT, + ingestion_method TEXT, + pchome_product_id TEXT, + momo_sku TEXT, + match_status TEXT, + quality_score REAL, + data_quality_status TEXT, + quality_notes_json TEXT, + raw_payload_json TEXT + ) + """)) + conn.execute(text(""" + INSERT INTO external_market_sources (code, status, enabled) + VALUES ('momo_reference', 'active', 1), ('yahoo_shopping', 'active', 1) + """)) + conn.execute(text(""" + INSERT INTO external_offers ( + id, source_code, platform_code, source_product_id, title, product_url, + price, observed_at, expires_at, ingestion_method, pchome_product_id, + momo_sku, match_status, quality_score, data_quality_status, + quality_notes_json, raw_payload_json + ) + VALUES + (1, 'momo_reference', 'momo', 'MOMO-1', 'MOMO 商品', 'https://momo.test/1', + 870, '2026-07-10 10:00:00', NULL, 'official_api', 'PCH-1', 'MOMO-1', + 'verified', 92, 'verified', '[]', + '{"pchome_public_price": 1000, "pchome_public_name": "PChome 商品"}'), + (2, 'yahoo_shopping', 'yahoo_shopping', 'YAHOO-1', 'Yahoo 商品', 'https://yahoo.test/1', + 800, '2026-07-10 11:00:00', NULL, 'official_api', 'PCH-1', 'YAHOO-1', + 'verified', 90, 'verified', '[]', + '{"pchome_public_price": 1000, "pchome_public_name": "PChome 商品"}') + """)) + + def _seed_growth_review_candidate_offer(engine): with engine.begin() as conn: conn.execute(text(""" @@ -185,7 +238,9 @@ def test_pchome_growth_opportunities_use_plain_language_and_pause_shopee_coupang assert payload["success"] is True assert payload["system_name"] == "PChome 業績成長自動化作戰系統" assert payload["source_scope"]["active_external_sources"] == ["MOMO 外部價格參考"] - assert payload["source_scope"]["paused_external_sources"] == ["蝦皮", "酷澎"] + assert {"蝦皮", "酷澎", "東森購物", "friDay 購物"}.issubset( + set(payload["source_scope"]["paused_external_sources"]) + ) readiness = payload["source_scope"]["source_readiness"] sources = {source["code"]: source for source in readiness["sources"]} assert sources["momo_reference"]["status_label"] == "正在使用" @@ -194,6 +249,9 @@ def test_pchome_growth_opportunities_use_plain_language_and_pause_shopee_coupang assert payload["stats"]["candidate_count"] == 2 assert payload["stats"]["mapped_count"] == 1 assert payload["stats"]["needs_mapping_count"] == 1 + assert payload["stats"]["catalog_product_count"] == 2 + assert payload["stats"]["mapped_product_count"] == 1 + assert payload["stats"]["sales_freshness"]["status"] == "critical" actions = {item["pchome_product_id"]: item["recommended_action"]["label"] for item in payload["opportunities"]} assert actions["PCH-1"] == "檢查售價與活動" @@ -272,6 +330,41 @@ def test_pchome_growth_understands_unit_price_external_offers(): assert any("單位價" in line for line in pchome_1["reason_lines"]) +def test_pchome_growth_compares_all_enabled_platforms_and_keeps_evidence(): + from services.pchome_revenue_growth_service import build_pchome_growth_opportunities + + engine = create_engine("sqlite:///:memory:") + _seed_growth_tables(engine) + _seed_growth_multiplatform_external_offers(engine) + + payload = build_pchome_growth_opportunities(engine, limit=5) + + pchome_1 = next(item for item in payload["opportunities"] if item["pchome_product_id"] == "PCH-1") + assert pchome_1["external_price"]["source_code"] == "yahoo_shopping" + assert pchome_1["external_price"]["source"] == "Yahoo 購物" + assert pchome_1["external_price"]["momo_price"] == 800 + assert {offer["source_code"] for offer in pchome_1["market_offers"]} == { + "momo_reference", + "yahoo_shopping", + } + assert pchome_1["cross_platform"]["decision_ready_platform_count"] == 2 + assert payload["stats"]["external_platform_counts"] == { + "Yahoo 購物": 1, + "MOMO 外部價格參考": 1, + } + + +def test_sales_freshness_blocks_decisions_after_two_days(): + from datetime import date + from services.pchome_revenue_growth_service import _sales_freshness + + assert _sales_freshness("2026-07-09", today=date(2026, 7, 10))["decision_ready"] is True + stale = _sales_freshness("2026-07-07", today=date(2026, 7, 10)) + assert stale["status"] == "critical" + assert stale["age_days"] == 3 + assert stale["decision_ready"] is False + + def test_pchome_growth_route_cache_respects_shared_invalidation_epoch(monkeypatch): from routes import ai_routes as routes @@ -491,7 +584,9 @@ def test_ai_intelligence_template_uses_pchome_growth_name_and_endpoint(): assert "/api/ai/pchome-growth/external-offers/csv-dry-run" in template assert "/api/ai/pchome-growth/review-candidates" in template assert "growthSourceReadiness" in template - assert "sources.map((source)" in template + assert "visibleSources.map((source)" in template + assert "source.has_runtime_data" in template + assert "其他平台" in template assert "MOMO 待確認候選" in template assert "確認同款" in template assert "不是同款" in template @@ -542,7 +637,7 @@ def test_growth_analysis_uses_actionable_price_command_panel(): assert "ga-chart-card__body--command" in template assert "ga-competitor-command" in template assert "可直接決策" in template - assert "待補 / 待確認" in template + assert "待補 / AI 例外" in template assert "下一步" in template assert "前往今日作戰" in template assert "ga-competitor-signal" in css @@ -728,7 +823,7 @@ def test_help_empty_login_and_ppt_copy_are_action_oriented(): "templates/system_settings.html": ["會更新對應年月份的業績資料"], "templates/admin/ppt_audit_history.html": [ "先確認簡報可預覽、可審核,再把問題交給修復流程", - "先補齊視覺 QA 執行條件,再判斷簡報品質", + "補齊轉檔與視覺模型後即可自動審核", "先補排程或手動產出,再進行視覺 QA", "先補視覺 QA 條件", ], diff --git a/tests/test_run_scheduler_embed_consistency.py b/tests/test_run_scheduler_embed_consistency.py index 29a4b7b..5c35876 100644 --- a/tests/test_run_scheduler_embed_consistency.py +++ b/tests/test_run_scheduler_embed_consistency.py @@ -1,6 +1,8 @@ import importlib import inspect import ast +import json +from datetime import datetime from pathlib import Path @@ -228,10 +230,51 @@ def test_roi_ai_smoke_and_daily_report_schedules_stay_staggered(): assert 'schedule.every().day.at("09:10").do(run_ai_smoke_daily_summary_task)' in source assert 'schedule.every().day.at("10:30").do(run_pchome_match_backfill_task)' in source assert 'schedule.every().day.at("10:45").do(run_pchome_growth_momo_backfill_task)' in source + assert "schedule.every(30).minutes.do(run_pchome_growth_momo_backfill_catchup_task)" in source assert "schedule.every(6).hours.do(run_action_plan_hygiene_task)" in source assert "schedule.every(15).minutes.do(run_ollama_111_usage_guard_check)" in source +def test_pchome_growth_backfill_catchup_uses_runtime_receipt_cooldown(monkeypatch, tmp_path): + run_scheduler = _load_run_scheduler(monkeypatch) + stats_path = tmp_path / "scheduler_stats.json" + stats_path.write_text(json.dumps({ + "pchome_growth_momo_backfill": [{ + "status": "Success", + "last_run": "2026-07-10 09:00:00", + }], + }), encoding="utf-8") + monkeypatch.setenv("SCHEDULER_STATS_PATH", str(stats_path)) + + due, reason = run_scheduler._pchome_growth_backfill_due( + now=datetime(2026, 7, 10, 11, 0, 0) + ) + assert due is False + assert reason == "cooldown_4.0h" + + due, reason = run_scheduler._pchome_growth_backfill_due( + now=datetime(2026, 7, 10, 13, 30, 0) + ) + assert due is True + assert reason == "stale_receipt_4.5h" + + +def test_pchome_growth_backfill_catchup_executes_missing_receipt(monkeypatch, tmp_path): + run_scheduler = _load_run_scheduler(monkeypatch) + monkeypatch.setenv("SCHEDULER_STATS_PATH", str(tmp_path / "missing.json")) + calls = [] + monkeypatch.setattr( + run_scheduler, + "run_pchome_growth_momo_backfill_task", + lambda: calls.append("executed"), + ) + + result = run_scheduler.run_pchome_growth_momo_backfill_catchup_task() + + assert result == {"status": "executed", "reason": "missing_runtime_receipt"} + assert calls == ["executed"] + + def test_ollama_111_usage_guard_stays_observational(monkeypatch): run_scheduler = _load_run_scheduler(monkeypatch) source = inspect.getsource(run_scheduler.run_ollama_111_usage_guard_check) diff --git a/tests/test_telegram_triaged_alert_format.py b/tests/test_telegram_triaged_alert_format.py index 73a50cc..713e8f6 100644 --- a/tests/test_telegram_triaged_alert_format.py +++ b/tests/test_telegram_triaged_alert_format.py @@ -266,7 +266,7 @@ def test_triaged_alert_renders_decision_envelope_contract(): assert "🧩 比對證據" in msg assert "Match:0.91" in msg assert "identity_v2 + price_alert_exact" in msg - assert "✅ 人工下一步" in msg + assert "✅ AI 例外下一步" in msg assert "動作:human_review" in msg assert "ai_call_id=123" in msg assert keyboard["inline_keyboard"][0][0]["callback_data"] == "momo:eig:decision_env_001" diff --git a/tests/test_webcrumbs_host_data_service.py b/tests/test_webcrumbs_host_data_service.py index ff29f57..99c1153 100644 --- a/tests/test_webcrumbs_host_data_service.py +++ b/tests/test_webcrumbs_host_data_service.py @@ -136,7 +136,8 @@ def test_webcrumbs_host_data_maps_price_alert_exact_rows(monkeypatch): assert payload["metadata"]["stale_match_count"] == 18 assert payload["metadata"]["pending_match_count"] == 612 assert payload["metadata"]["review_queue_count"] == 1 - assert payload["metadata"]["hitl_count"] == 1 + assert payload["metadata"]["hitl_count"] == 0 + assert payload["metadata"]["ai_exception_count"] == 1 assert payload["metadata"]["auto_execute_blocked_count"] == 1 assert payload["metadata"]["decision_envelope_source"] == "competitor_intel_repository" assert payload["reviewDecisionBrief"]["items"][0]["sku"] == "SKU-REVIEW" @@ -188,7 +189,7 @@ def test_webcrumbs_host_data_uses_empty_state_without_risks(monkeypatch): assert payload["metadata"]["stale_match_count"] == 2 assert payload["metadata"]["pending_match_count"] == 9 assert payload["metadata"]["review_queue_count"] == 0 - assert payload["reviewDecisionBrief"]["text"] == "(目前沒有待覆核決策信封)" + assert payload["reviewDecisionBrief"]["text"] == "(目前沒有待 AI 決策信封)" assert "非同款、單位價或變體候選" in payload["aiCandidate"]["thesis"] diff --git a/web/static/css/ewoooc-tokens-v2-alias.css b/web/static/css/ewoooc-tokens-v2-alias.css index b4c13f4..e78ca65 100644 --- a/web/static/css/ewoooc-tokens-v2-alias.css +++ b/web/static/css/ewoooc-tokens-v2-alias.css @@ -14,7 +14,9 @@ :root { /* ── 暖色主軸(最常用,~30+ 處 inline 引用)── */ - --momo-warm-caramel: var(--momo-page-accent); /* 焦糖橘主色 */ + /* Keep the V3 base token concrete. Pointing it back to page-accent creates + a cycle because the monitor page accent is defined from warm-caramel. */ + --momo-warm-caramel: #c96442; /* 焦糖橘主色 */ --momo-warm-rust: var(--momo-danger-text); /* 鐵鏽紅 */ --momo-warm-mahogany: var(--momo-tag-clay-text); /* 桃花心木 */ --momo-warm-honey: var(--momo-warning-text); /* 蜂蜜黃 */