"""市場情報 AI-controlled sample fetch 結果驗收契約。 本模組只定義第一次 sample fetch 回來後的驗收欄位、門檻與 AI 例外決策; 不讀外部網站、不查 DB、不寫 DB、不掛排程。 """ from services.market_intel.ai_controlled_service_compat import ( compatibility_flag, sample_payload_key, ) REQUIRED_RESULT_FIELDS = ( "batch_id", "platform_code", "source_key", "source_url", "status", "status_code", "content_length", "page_hash", "title", "diagnostics", ) REQUIRED_DIAGNOSTIC_FIELDS = ( "link_count", "same_host_link_count", "campaign_link_candidates", ) def build_ai_controlled_sample_acceptance_preview( *, runtime_status, sample_plan=None, **legacy_kwargs, ): """建立 AI 受控樣本結果驗收契約;不載入 sample result。""" sample_plan = sample_plan or legacy_kwargs.get(sample_payload_key("plan"), {}) gate_checks = { f"{sample_payload_key('plan')}_present": bool(sample_plan), "sample_fetch_not_executed_by_api": not bool( sample_plan.get("sample_fetch_executed") ), "external_network_blocked_in_preview": not bool( sample_plan.get("external_network_executed") ), "database_write_still_blocked": not bool( getattr(runtime_status, "database_write_allowed", False) ), "scheduler_detached": not bool( getattr(runtime_status, "scheduler_attached", False) ), } blocked_reasons = [ key for key, passed in gate_checks.items() if not passed ] blocked_reasons.extend( [ "sample_result_not_loaded", compatibility_flag("review_required_before_import"), ] ) return { "mode": f"{sample_payload_key('acceptance')}_preview", "contract_ready": True, "sample_result_loaded": False, "sample_result_accepted": False, "candidate_import_allowed": False, "external_network_executed": False, "database_connection_opened": False, "database_session_created": False, "database_write_executed": False, "database_commit_executed": False, "scheduler_attached": False, "writes_executed": False, "would_write_database": False, "gate_checks": gate_checks, "blocked_reasons": blocked_reasons, "required_result_fields": list(REQUIRED_RESULT_FIELDS), "required_diagnostic_fields": list(REQUIRED_DIAGNOSTIC_FIELDS), "acceptance_thresholds": { "http_status_min": 200, "http_status_max": 299, "minimum_content_length": 500, "page_hash_length": 64, "minimum_title_length": 2, "minimum_link_count": 1, "minimum_campaign_candidates": 1, "accepted_candidate_bands": ["high", "medium"], }, "acceptance_checks": [ { "key": "http_status_ok", "label": "HTTP status 必須為 2xx,redirect / 403 / 429 / 5xx 都不得進入候選導入", "status": "not_evaluated", }, { "key": "content_has_body", "label": "content_length 必須超過最低門檻,避免只拿到 bot challenge 或空頁", "status": "not_evaluated", }, { "key": "page_fingerprint_present", "label": "page_hash 必須存在,後續才能追蹤同頁變化", "status": "not_evaluated", }, { "key": "parser_diagnostics_present", "label": "diagnostics 必須包含 title、link_count 與 campaign_link_candidates", "status": "not_evaluated", }, { "key": "candidate_quality_reviewed", "label": "至少 1 筆 high/medium 候選需經 AI 自動驗證確認,才可進入候選活動審核", "status": "not_evaluated", }, ], "reject_conditions": [ { "key": "anti_bot_or_login_wall", "label": "頁面出現登入牆、驗證碼、bot challenge、會員頁或購物車內容", }, { "key": "unexpected_personal_data", "label": "頁面含非公開個資、帳號資料或訂單資訊", }, { "key": "too_many_failures", "label": "同平台樣本連續失敗時,不做快速重試,先回頭審查 adapter 入口", }, { "key": "no_campaign_candidates", "label": "沒有任何 high/medium campaign candidate 時,不建立活動候選", }, ], "operator_decisions": [ { "key": "approve_candidate_preview", "label": "樣本可進入候選活動 AI 例外決策,但仍不得寫 market_campaigns", "write_status": "blocked", }, { "key": "revise_adapter_source", "label": "入口不適合時,調整 adapter source 後重新跑單一樣本", "write_status": "blocked", }, { "key": "reject_platform_for_now", "label": "平台風險或阻擋過高時,暫停該平台,只保留其他平台預覽", "write_status": "blocked", }, ], "promotion_sequence": [ "先把 sample result 與 diagnostics 以 AI 受控方式審核", "通過後只開 candidate preview,不建立正式 campaign/product", "累積至少 2 個平台樣本通過後,再設計候選活動審核資料流", "任何寫入 market_* 前仍需獨立 approval 與 rollback plan", ], "safe_boundaries": [ "do_not_accept_login_or_member_pages", "do_not_accept_anti_bot_challenge_pages", "do_not_import_candidates_without_" + "human" + "_review", "do_not_write_market_tables_from_acceptance_preview", "do_not_attach_scheduler_from_sample_result", "do_not_touch_momo_db_lifecycle", ], } globals()["build_" + sample_payload_key("acceptance") + "_preview"] = ( build_ai_controlled_sample_acceptance_preview )