From 841db133d03bab8e3b4a93418d2d44228814ea80 Mon Sep 17 00:00:00 2001 From: OoO Date: Mon, 18 May 2026 19:36:19 +0800 Subject: [PATCH] refactor(market-intel): modularize deployment readiness --- ...s-platform-market-campaign-intelligence.md | 1 + routes/README.md | 2 +- services/market_intel/deployment_readiness.py | 270 ++++++++++++++++++ services/market_intel/service.py | 267 +---------------- tests/test_market_intel_skeleton.py | 48 ++-- 5 files changed, 310 insertions(+), 278 deletions(-) create mode 100644 services/market_intel/deployment_readiness.py diff --git a/docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md b/docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md index 7f1447a..112db88 100644 --- a/docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md +++ b/docs/adr/ADR-035-cross-platform-market-campaign-intelligence.md @@ -164,6 +164,7 @@ EwoooC 目前已有 MOMO EDM / 節慶活動資料、`promo_products`、PChome - 2026-05-18 追加 opportunity evidence plan preview:`services.market_intel.opportunity_evidence` 與 `/api/market_intel/opportunity_evidence_plan` 定義 scoring / alert / AI 摘要必須攜帶的 campaign、market product、match review、MOMO reference、scoring trace 五段 evidence bundle contract。此階段不查 DB、不產生 sample evidence、不建立 alert candidate、不派送 Telegram、不產生 AI 摘要。 - 2026-05-18 追加 opportunity alert plan preview:`services.market_intel.opportunity_alerts` 與 `/api/market_intel/opportunity_alert_plan` 定義人工審核、每日摘要、Telegram 候選、AI 摘要候選四種 channel 的最低門檻、去重節流、payload contract 與操作順序。此階段不建立 alert queue、不派送 Telegram、不呼叫 LLM、不寫 DB;所有告警必須先有 evidence bundle、dedupe key 與人工批准。 - 2026-05-18 追加 opportunity alert review preview:`/api/market_intel/opportunity_alert_plan` 擴充人工審核狀態與操作,定義 `draft → needs_review → approved_for_digest / approved_for_telegram / rejected / deferred` 流程、審核理由、審核者身分與派送前二次 gate。此階段不建立 review queue、不執行審核動作、不寫 approval record、不派送 Telegram、不呼叫 LLM。 +- 2026-05-18 追加 deployment readiness modularization:將 `/api/market_intel/deployment_readiness` 的大型 app-only release gate 組裝邏輯由 `services.market_intel.service` 拆至 `services.market_intel.deployment_readiness`,主服務保留薄入口,避免後續 crawler / MCP / 審核功能推進時超過 800 行治理線;行為仍維持 preview-only,不執行 git、部署、SSH、migration 或 DB write。 ### Phase 4:Coupang / Shopee Adapter diff --git a/routes/README.md b/routes/README.md index 62eb916..46c0adb 100644 --- a/routes/README.md +++ b/routes/README.md @@ -19,7 +19,7 @@ | `edm_routes.py` | EDM 與節慶儀表板 | `/edm`, `/festival` | | `monthly_routes.py` | 月結分析 | `/monthly_summary_analysis`, `/api/monthly_summary_data` | | `daily_sales_routes.py` | 當日業績 | `/daily_sales`, `/daily_sales/export*` | -| `market_intel_routes.py` | 市場情報 Phase 39 opportunity alert review preview | `/market_intel`, `/market_intel/*`, `/api/market_intel/status`, `/api/market_intel/schema`, `/api/market_intel/schema_smoke`, `/api/market_intel/schema_db_probe`, `/api/market_intel/platform_seed_db_diff`, `/api/market_intel/legacy_source_bridge`, `/api/market_intel/mcp_readiness`, `/api/market_intel/mcp_tool_contract`, `/api/market_intel/mcp_deploy_preflight`, `/api/market_intel/mcp_activation_runbook`, `/api/market_intel/mcp_fetch_gate`, `/api/market_intel/scheduler_plan`, `/api/market_intel/match_review_plan`, `/api/market_intel/opportunity_plan`, `/api/market_intel/opportunity_scoring_plan`, `/api/market_intel/opportunity_evidence_plan`, `/api/market_intel/opportunity_alert_plan`, `/api/market_intel/adapters`, `/api/market_intel/dry_run_plan`, `/api/market_intel/discovery_plan`, `/api/market_intel/manual_discovery`, `/api/market_intel/candidate_preview`, `/api/market_intel/platform_seed_plan`, `/api/market_intel/platform_seed_write_guard`, `/api/market_intel/platform_seed_writer_plan`, `/api/market_intel/migration_blueprint`, `/api/market_intel/seed_writer_cli_status`, `/api/market_intel/write_approval_runbook`, `/api/market_intel/deployment_readiness` | +| `market_intel_routes.py` | 市場情報 Phase 40 deployment readiness modularization | `/market_intel`, `/market_intel/*`, `/api/market_intel/status`, `/api/market_intel/schema`, `/api/market_intel/schema_smoke`, `/api/market_intel/schema_db_probe`, `/api/market_intel/platform_seed_db_diff`, `/api/market_intel/legacy_source_bridge`, `/api/market_intel/mcp_readiness`, `/api/market_intel/mcp_tool_contract`, `/api/market_intel/mcp_deploy_preflight`, `/api/market_intel/mcp_activation_runbook`, `/api/market_intel/mcp_fetch_gate`, `/api/market_intel/scheduler_plan`, `/api/market_intel/match_review_plan`, `/api/market_intel/opportunity_plan`, `/api/market_intel/opportunity_scoring_plan`, `/api/market_intel/opportunity_evidence_plan`, `/api/market_intel/opportunity_alert_plan`, `/api/market_intel/adapters`, `/api/market_intel/dry_run_plan`, `/api/market_intel/discovery_plan`, `/api/market_intel/manual_discovery`, `/api/market_intel/candidate_preview`, `/api/market_intel/platform_seed_plan`, `/api/market_intel/platform_seed_write_guard`, `/api/market_intel/platform_seed_writer_plan`, `/api/market_intel/migration_blueprint`, `/api/market_intel/seed_writer_cli_status`, `/api/market_intel/write_approval_runbook`, `/api/market_intel/deployment_readiness` | | `api_routes.py` | 通用任務與查詢 API | `/api/run_task`, `/api/history/*` | | `export_routes.py` | 匯出功能 | `/api/export/*` | | `import_routes.py` | 匯入功能 | `/api/import_excel`, `/api/import/monthly_summary` | diff --git a/services/market_intel/deployment_readiness.py b/services/market_intel/deployment_readiness.py new file mode 100644 index 0000000..7bbb784 --- /dev/null +++ b/services/market_intel/deployment_readiness.py @@ -0,0 +1,270 @@ +"""市場情報 app-only release gate 組裝器。 + +本模組只組裝 preview payload,不執行 git、部署、SSH、migration 或 DB write。 +""" + + +def build_deployment_readiness_preview( + *, + service, + market_intel_tables, + schema_smoke_builder, +): + """建立市場情報推版準備狀態;不執行 git、部署或遠端操作。""" + status = service.get_runtime_status() + schema_smoke = schema_smoke_builder(market_intel_tables) + writer_plan = service.build_platform_seed_writer_plan() + mcp_deploy_preflight = service.build_mcp_deploy_preflight() + mcp_activation_runbook = service.build_mcp_activation_runbook() + mcp_fetch_gate = service.build_mcp_fetch_gate() + scheduler_plan = service.build_scheduler_plan() + match_review_plan = service.build_match_review_plan() + opportunity_plan = service.build_opportunity_plan() + opportunity_scoring_plan = service.build_opportunity_scoring_plan() + opportunity_evidence_plan = service.build_opportunity_evidence_plan() + opportunity_alert_plan = service.build_opportunity_alert_plan() + checks = { + "schema_smoke_passed": bool(schema_smoke["passed"]), + "feature_flags_default_safe": bool( + not status.enabled + and not status.crawler_enabled + and not status.write_enabled + ), + "database_write_blocked": bool(not status.database_write_allowed), + "scheduler_detached": bool(not status.scheduler_attached), + "manual_fetch_disabled": bool(not service.manual_fetch_allowed()), + "writer_plan_dry_run_only": bool( + writer_plan["mode"] == "dry_run" + and not writer_plan["writes_executed"] + and not writer_plan["would_write_database"] + ), + "registered_adapters_present": bool(len(service.get_adapter_summaries()) >= 4), + "schema_db_probe_planned_safe": bool( + not service.build_schema_db_probe()["read_only_query_executed"] + ), + "platform_seed_db_diff_planned_safe": bool( + not service.build_platform_seed_db_diff()["read_only_query_executed"] + ), + "legacy_source_bridge_planned_safe": bool( + not service.build_legacy_source_bridge()["read_only_query_executed"] + ), + "mcp_readiness_planned_safe": bool( + service.build_mcp_readiness()["mode"] == "mcp_readiness_planned" + ), + "mcp_tool_contract_ready": bool( + service.build_mcp_tool_contract()["contract_ready"] + ), + "mcp_deploy_preflight_preview_safe": bool( + mcp_deploy_preflight["mode"] == "mcp_external_deploy_preflight_preview" + and not mcp_deploy_preflight["deployment_actions_executed"] + ), + "mcp_activation_runbook_preview_safe": bool( + mcp_activation_runbook["mode"] == "mcp_activation_runbook_preview" + and not mcp_activation_runbook["deployment_actions_executed"] + ), + "mcp_fetch_gate_preview_safe": bool( + mcp_fetch_gate["mode"] == "mcp_fetch_gate_planned" + and not mcp_fetch_gate["network_request_allowed"] + and not mcp_fetch_gate["external_network_executed"] + ), + "scheduler_plan_preview_safe": bool( + scheduler_plan["mode"] == "scheduler_attach_plan_preview" + and not scheduler_plan["scheduler_registration_executed"] + and not scheduler_plan["crawler_job_started"] + and not scheduler_plan["database_write_executed"] + ), + "match_review_plan_preview_safe": bool( + match_review_plan["mode"] == "match_review_plan_preview" + and not match_review_plan["review_queue_created"] + and not match_review_plan["auto_confirm_executed"] + and not match_review_plan["database_write_executed"] + ), + "opportunity_plan_preview_safe": bool( + opportunity_plan["mode"] == "opportunity_plan_preview" + and not opportunity_plan["opportunity_queue_created"] + and not opportunity_plan["threat_alert_dispatched"] + and not opportunity_plan["database_write_executed"] + ), + "opportunity_scoring_plan_preview_safe": bool( + opportunity_scoring_plan["mode"] + == "opportunity_scoring_plan_preview" + and not opportunity_scoring_plan["scoring_job_created"] + and not opportunity_scoring_plan["score_calculation_executed"] + and not opportunity_scoring_plan["database_write_executed"] + ), + "opportunity_evidence_plan_preview_safe": bool( + opportunity_evidence_plan["mode"] + == "opportunity_evidence_plan_preview" + and not opportunity_evidence_plan["evidence_query_executed"] + and not opportunity_evidence_plan["evidence_bundle_created"] + and not opportunity_evidence_plan["database_write_executed"] + ), + "opportunity_alert_plan_preview_safe": bool( + opportunity_alert_plan["mode"] == "opportunity_alert_plan_preview" + and not opportunity_alert_plan["alert_candidate_created"] + and not opportunity_alert_plan["review_action_executed"] + and not opportunity_alert_plan["approval_record_written"] + and not opportunity_alert_plan["telegram_dispatched"] + and not opportunity_alert_plan["database_write_executed"] + and not opportunity_alert_plan["llm_call_executed"] + ), + } + ready_for_production_deploy = all(checks.values()) + blocked_reasons = [ + reason for reason, blocked in ( + ("readiness_checks_not_all_passed", not ready_for_production_deploy), + ("production_deploy_not_executed_by_api", True), + ("git_commit_not_created_by_api", True), + ("git_push_not_executed_by_api", True), + ("backup_must_be_verified_by_operator", True), + ("production_smoke_must_be_verified_by_operator", True), + ) + if blocked + ] + required_manual_steps = [ + { + "key": "review_worktree_scope", + "label": "審核 worktree,只納入市場情報相關變更,排除 unrelated dirty files", + "status": "required", + }, + { + "key": "run_backup_system", + "label": "重大更新前執行 python backup_system.py", + "status": "required", + }, + { + "key": "commit_market_intel_changes_only", + "label": "只 commit 市場情報模組、ADR/TODO 與必要測試", + "status": "operator_optional", + }, + { + "key": "push_reviewed_branch_or_main", + "label": "推送已審核分支或 main,再進入部署 SOP", + "status": "operator_optional", + }, + { + "key": "run_deployment_sop", + "label": "依 deployment SOP app-only 部署,不碰 momo-db", + "status": "required", + }, + { + "key": "verify_health_endpoint", + "label": "部署後先驗證 /health,不使用首頁作為探測", + "status": "required", + }, + { + "key": "verify_market_intel_page_after_deploy", + "label": "驗證 /market_intel 與市場情報 API 仍維持 blocked dry-run", + "status": "required", + }, + ] + fallback_plan = [ + { + "key": "feature_flag_kill_switch", + "label": "MARKET_INTEL_ENABLED、MARKET_INTEL_CRAWLER_ENABLED、MARKET_INTEL_WRITE_ENABLED 保持全關,可立即停用新功能面", + "trigger": "任何 UI/API 異常或非預期連外行為", + }, + { + "key": "app_only_rollback", + "label": "回退到上一個已知正常版本後,只 recreate momo-app,避免影響 momo-db 資料生命週期", + "trigger": "部署後 /health 或 /market_intel smoke 失敗", + }, + { + "key": "scheduler_detached", + "label": "市場情報 scheduler 尚未掛載;異常時不需停爬蟲排程,因為本階段沒有排程入口", + "trigger": "排程或外部流量疑慮", + }, + { + "key": "database_write_blocked", + "label": "writer 仍是 preview_only_no_session_no_commit;異常時不需要 DB rollback", + "trigger": "seed writer 或 schema smoke 異常", + }, + ] + safe_deploy_boundaries = [ + { + "key": "no_remove_orphans", + "label": "禁止使用 docker compose --remove-orphans", + }, + { + "key": "no_momo_db_lifecycle_change", + "label": "禁止 stop/rm/recreate momo-db 或變更資料生命週期", + }, + { + "key": "health_probe_only", + "label": "HTTP health / blackbox / CD 探測只打 /health", + }, + { + "key": "flags_default_off", + "label": "市場情報三個 feature flags 預設維持 OFF", + }, + ] + return { + "phase": service.phase, + "mode": "app_only_release_gate", + "production_deployed": False, + "git_committed": False, + "git_pushed": False, + "ready_for_production_deploy": ready_for_production_deploy, + "deployment_actions_executed": False, + "execution_boundary": { + "api_executes_git": False, + "api_executes_backup": False, + "api_executes_scp": False, + "api_executes_ssh": False, + "api_recreates_container": False, + "api_runs_migration": False, + "api_writes_database": False, + }, + "checks": checks, + "blocked_reasons": blocked_reasons, + "requires_backup_before_major_update": True, + "backup_command": "python backup_system.py", + "required_manual_steps": required_manual_steps, + "fallback_plan": fallback_plan, + "safe_deploy_boundaries": safe_deploy_boundaries, + "production_smoke_targets": [ + "/health", + "/market_intel", + "/api/market_intel/status", + "/api/market_intel/deployment_readiness", + "/api/market_intel/schema_smoke", + "/api/market_intel/schema_db_probe", + "/api/market_intel/platform_seed_db_diff", + "/api/market_intel/legacy_source_bridge", + "/api/market_intel/mcp_readiness", + "/api/market_intel/mcp_tool_contract", + "/api/market_intel/mcp_deploy_preflight", + "/api/market_intel/mcp_activation_runbook", + "/api/market_intel/mcp_fetch_gate", + "/api/market_intel/scheduler_plan", + "/api/market_intel/match_review_plan", + "/api/market_intel/opportunity_plan", + "/api/market_intel/opportunity_scoring_plan", + "/api/market_intel/opportunity_evidence_plan", + "/api/market_intel/opportunity_alert_plan", + ], + "status": status.to_dict(), + "schema_smoke": schema_smoke, + "writer_plan_summary": { + "operation_count": writer_plan["operation_count"], + "writes_executed": writer_plan["writes_executed"], + "would_write_database": writer_plan["would_write_database"], + }, + "write_approval_runbook": service.build_write_approval_runbook(), + "migration_blueprint": service.build_migration_blueprint(), + "seed_writer_cli_status": service.build_seed_writer_cli_status(), + "schema_db_probe": service.build_schema_db_probe(), + "platform_seed_db_diff": service.build_platform_seed_db_diff(), + "legacy_source_bridge": service.build_legacy_source_bridge(), + "mcp_readiness": service.build_mcp_readiness(), + "mcp_tool_contract": service.build_mcp_tool_contract(), + "mcp_deploy_preflight": mcp_deploy_preflight, + "mcp_activation_runbook": mcp_activation_runbook, + "mcp_fetch_gate": mcp_fetch_gate, + "scheduler_plan": scheduler_plan, + "match_review_plan": match_review_plan, + "opportunity_plan": opportunity_plan, + "opportunity_scoring_plan": opportunity_scoring_plan, + "opportunity_evidence_plan": opportunity_evidence_plan, + "opportunity_alert_plan": opportunity_alert_plan, + } diff --git a/services/market_intel/service.py b/services/market_intel/service.py index 7751f98..6502e95 100644 --- a/services/market_intel/service.py +++ b/services/market_intel/service.py @@ -18,6 +18,9 @@ from services.market_intel.adapters import ( get_adapter_summaries, ) from services.market_intel.candidate_preview import build_candidate_preview_from_discovery +from services.market_intel.deployment_readiness import ( + build_deployment_readiness_preview, +) from services.market_intel.discovery_runner import ManualDiscoveryRunner from services.market_intel.legacy_source_bridge import build_legacy_source_bridge_plan from services.market_intel.match_review_plan import build_match_review_plan_preview @@ -80,7 +83,7 @@ class MarketIntelRuntimeStatus: class MarketIntelService: """市場情報入口服務,先集中 feature gate 與安全狀態。""" - phase = "phase_39_opportunity_alert_review_preview" + phase = "phase_40_deployment_readiness_modularized" def get_runtime_status(self) -> MarketIntelRuntimeStatus: return MarketIntelRuntimeStatus( @@ -527,260 +530,8 @@ class MarketIntelService: def build_deployment_readiness(self): """建立市場情報推版準備狀態;不執行 git、部署或遠端操作。""" - status = self.get_runtime_status() - schema_smoke = build_schema_smoke(MARKET_INTEL_TABLES) - writer_plan = self.build_platform_seed_writer_plan() - mcp_deploy_preflight = self.build_mcp_deploy_preflight() - mcp_activation_runbook = self.build_mcp_activation_runbook() - mcp_fetch_gate = self.build_mcp_fetch_gate() - scheduler_plan = self.build_scheduler_plan() - match_review_plan = self.build_match_review_plan() - opportunity_plan = self.build_opportunity_plan() - opportunity_scoring_plan = self.build_opportunity_scoring_plan() - opportunity_evidence_plan = self.build_opportunity_evidence_plan() - opportunity_alert_plan = self.build_opportunity_alert_plan() - checks = { - "schema_smoke_passed": bool(schema_smoke["passed"]), - "feature_flags_default_safe": bool( - not status.enabled - and not status.crawler_enabled - and not status.write_enabled - ), - "database_write_blocked": bool(not status.database_write_allowed), - "scheduler_detached": bool(not status.scheduler_attached), - "manual_fetch_disabled": bool(not self.manual_fetch_allowed()), - "writer_plan_dry_run_only": bool( - writer_plan["mode"] == "dry_run" - and not writer_plan["writes_executed"] - and not writer_plan["would_write_database"] - ), - "registered_adapters_present": bool(len(self.get_adapter_summaries()) >= 4), - "schema_db_probe_planned_safe": bool( - not self.build_schema_db_probe()["read_only_query_executed"] - ), - "platform_seed_db_diff_planned_safe": bool( - not self.build_platform_seed_db_diff()["read_only_query_executed"] - ), - "legacy_source_bridge_planned_safe": bool( - not self.build_legacy_source_bridge()["read_only_query_executed"] - ), - "mcp_readiness_planned_safe": bool( - self.build_mcp_readiness()["mode"] == "mcp_readiness_planned" - ), - "mcp_tool_contract_ready": bool( - self.build_mcp_tool_contract()["contract_ready"] - ), - "mcp_deploy_preflight_preview_safe": bool( - mcp_deploy_preflight["mode"] == "mcp_external_deploy_preflight_preview" - and not mcp_deploy_preflight["deployment_actions_executed"] - ), - "mcp_activation_runbook_preview_safe": bool( - mcp_activation_runbook["mode"] == "mcp_activation_runbook_preview" - and not mcp_activation_runbook["deployment_actions_executed"] - ), - "mcp_fetch_gate_preview_safe": bool( - mcp_fetch_gate["mode"] == "mcp_fetch_gate_planned" - and not mcp_fetch_gate["network_request_allowed"] - and not mcp_fetch_gate["external_network_executed"] - ), - "scheduler_plan_preview_safe": bool( - scheduler_plan["mode"] == "scheduler_attach_plan_preview" - and not scheduler_plan["scheduler_registration_executed"] - and not scheduler_plan["crawler_job_started"] - and not scheduler_plan["database_write_executed"] - ), - "match_review_plan_preview_safe": bool( - match_review_plan["mode"] == "match_review_plan_preview" - and not match_review_plan["review_queue_created"] - and not match_review_plan["auto_confirm_executed"] - and not match_review_plan["database_write_executed"] - ), - "opportunity_plan_preview_safe": bool( - opportunity_plan["mode"] == "opportunity_plan_preview" - and not opportunity_plan["opportunity_queue_created"] - and not opportunity_plan["threat_alert_dispatched"] - and not opportunity_plan["database_write_executed"] - ), - "opportunity_scoring_plan_preview_safe": bool( - opportunity_scoring_plan["mode"] - == "opportunity_scoring_plan_preview" - and not opportunity_scoring_plan["scoring_job_created"] - and not opportunity_scoring_plan["score_calculation_executed"] - and not opportunity_scoring_plan["database_write_executed"] - ), - "opportunity_evidence_plan_preview_safe": bool( - opportunity_evidence_plan["mode"] - == "opportunity_evidence_plan_preview" - and not opportunity_evidence_plan["evidence_query_executed"] - and not opportunity_evidence_plan["evidence_bundle_created"] - and not opportunity_evidence_plan["database_write_executed"] - ), - "opportunity_alert_plan_preview_safe": bool( - opportunity_alert_plan["mode"] == "opportunity_alert_plan_preview" - and not opportunity_alert_plan["alert_candidate_created"] - and not opportunity_alert_plan["review_action_executed"] - and not opportunity_alert_plan["approval_record_written"] - and not opportunity_alert_plan["telegram_dispatched"] - and not opportunity_alert_plan["database_write_executed"] - and not opportunity_alert_plan["llm_call_executed"] - ), - } - ready_for_production_deploy = all(checks.values()) - blocked_reasons = [ - reason for reason, blocked in ( - ("readiness_checks_not_all_passed", not ready_for_production_deploy), - ("production_deploy_not_executed_by_api", True), - ("git_commit_not_created_by_api", True), - ("git_push_not_executed_by_api", True), - ("backup_must_be_verified_by_operator", True), - ("production_smoke_must_be_verified_by_operator", True), - ) - if blocked - ] - required_manual_steps = [ - { - "key": "review_worktree_scope", - "label": "審核 worktree,只納入市場情報相關變更,排除 unrelated dirty files", - "status": "required", - }, - { - "key": "run_backup_system", - "label": "重大更新前執行 python backup_system.py", - "status": "required", - }, - { - "key": "commit_market_intel_changes_only", - "label": "只 commit 市場情報模組、ADR/TODO 與必要測試", - "status": "operator_optional", - }, - { - "key": "push_reviewed_branch_or_main", - "label": "推送已審核分支或 main,再進入部署 SOP", - "status": "operator_optional", - }, - { - "key": "run_deployment_sop", - "label": "依 deployment SOP app-only 部署,不碰 momo-db", - "status": "required", - }, - { - "key": "verify_health_endpoint", - "label": "部署後先驗證 /health,不使用首頁作為探測", - "status": "required", - }, - { - "key": "verify_market_intel_page_after_deploy", - "label": "驗證 /market_intel 與市場情報 API 仍維持 blocked dry-run", - "status": "required", - }, - ] - fallback_plan = [ - { - "key": "feature_flag_kill_switch", - "label": "MARKET_INTEL_ENABLED、MARKET_INTEL_CRAWLER_ENABLED、MARKET_INTEL_WRITE_ENABLED 保持全關,可立即停用新功能面", - "trigger": "任何 UI/API 異常或非預期連外行為", - }, - { - "key": "app_only_rollback", - "label": "回退到上一個已知正常版本後,只 recreate momo-app,避免影響 momo-db 資料生命週期", - "trigger": "部署後 /health 或 /market_intel smoke 失敗", - }, - { - "key": "scheduler_detached", - "label": "市場情報 scheduler 尚未掛載;異常時不需停爬蟲排程,因為本階段沒有排程入口", - "trigger": "排程或外部流量疑慮", - }, - { - "key": "database_write_blocked", - "label": "writer 仍是 preview_only_no_session_no_commit;異常時不需要 DB rollback", - "trigger": "seed writer 或 schema smoke 異常", - }, - ] - safe_deploy_boundaries = [ - { - "key": "no_remove_orphans", - "label": "禁止使用 docker compose --remove-orphans", - }, - { - "key": "no_momo_db_lifecycle_change", - "label": "禁止 stop/rm/recreate momo-db 或變更資料生命週期", - }, - { - "key": "health_probe_only", - "label": "HTTP health / blackbox / CD 探測只打 /health", - }, - { - "key": "flags_default_off", - "label": "市場情報三個 feature flags 預設維持 OFF", - }, - ] - return { - "phase": self.phase, - "mode": "app_only_release_gate", - "production_deployed": False, - "git_committed": False, - "git_pushed": False, - "ready_for_production_deploy": ready_for_production_deploy, - "deployment_actions_executed": False, - "execution_boundary": { - "api_executes_git": False, - "api_executes_backup": False, - "api_executes_scp": False, - "api_executes_ssh": False, - "api_recreates_container": False, - "api_runs_migration": False, - "api_writes_database": False, - }, - "checks": checks, - "blocked_reasons": blocked_reasons, - "requires_backup_before_major_update": True, - "backup_command": "python backup_system.py", - "required_manual_steps": required_manual_steps, - "fallback_plan": fallback_plan, - "safe_deploy_boundaries": safe_deploy_boundaries, - "production_smoke_targets": [ - "/health", - "/market_intel", - "/api/market_intel/status", - "/api/market_intel/deployment_readiness", - "/api/market_intel/schema_smoke", - "/api/market_intel/schema_db_probe", - "/api/market_intel/platform_seed_db_diff", - "/api/market_intel/legacy_source_bridge", - "/api/market_intel/mcp_readiness", - "/api/market_intel/mcp_tool_contract", - "/api/market_intel/mcp_deploy_preflight", - "/api/market_intel/mcp_activation_runbook", - "/api/market_intel/mcp_fetch_gate", - "/api/market_intel/scheduler_plan", - "/api/market_intel/match_review_plan", - "/api/market_intel/opportunity_plan", - "/api/market_intel/opportunity_scoring_plan", - "/api/market_intel/opportunity_evidence_plan", - "/api/market_intel/opportunity_alert_plan", - ], - "status": status.to_dict(), - "schema_smoke": schema_smoke, - "writer_plan_summary": { - "operation_count": writer_plan["operation_count"], - "writes_executed": writer_plan["writes_executed"], - "would_write_database": writer_plan["would_write_database"], - }, - "write_approval_runbook": self.build_write_approval_runbook(), - "migration_blueprint": self.build_migration_blueprint(), - "seed_writer_cli_status": self.build_seed_writer_cli_status(), - "schema_db_probe": self.build_schema_db_probe(), - "platform_seed_db_diff": self.build_platform_seed_db_diff(), - "legacy_source_bridge": self.build_legacy_source_bridge(), - "mcp_readiness": self.build_mcp_readiness(), - "mcp_tool_contract": self.build_mcp_tool_contract(), - "mcp_deploy_preflight": mcp_deploy_preflight, - "mcp_activation_runbook": mcp_activation_runbook, - "mcp_fetch_gate": mcp_fetch_gate, - "scheduler_plan": scheduler_plan, - "match_review_plan": match_review_plan, - "opportunity_plan": opportunity_plan, - "opportunity_scoring_plan": opportunity_scoring_plan, - "opportunity_evidence_plan": opportunity_evidence_plan, - "opportunity_alert_plan": opportunity_alert_plan, - } + return build_deployment_readiness_preview( + service=self, + market_intel_tables=MARKET_INTEL_TABLES, + schema_smoke_builder=build_schema_smoke, + ) diff --git a/tests/test_market_intel_skeleton.py b/tests/test_market_intel_skeleton.py index a149284..81ebc4b 100644 --- a/tests/test_market_intel_skeleton.py +++ b/tests/test_market_intel_skeleton.py @@ -527,7 +527,7 @@ def test_legacy_source_bridge_default_is_planned_only(): bridge = MarketIntelService().build_legacy_source_bridge() assert bridge["mode"] == "legacy_source_bridge_planned" - assert bridge["phase"] == "phase_39_opportunity_alert_review_preview" + assert bridge["phase"] == "phase_40_deployment_readiness_modularized" assert bridge["execute_requested"] is False assert bridge["read_only_query_executed"] is False assert bridge["database_connection_opened"] is False @@ -685,7 +685,7 @@ def test_mcp_tool_contract_preview_is_read_only_and_whitelisted(): contract = MarketIntelService().build_mcp_tool_contract() assert contract["mode"] == "mcp_tool_contract_preview" - assert contract["phase"] == "phase_39_opportunity_alert_review_preview" + assert contract["phase"] == "phase_40_deployment_readiness_modularized" assert contract["caller"] == "market_intel" assert contract["contract_ready"] is True assert contract["blocked_reasons"] == [] @@ -818,7 +818,7 @@ def test_mcp_activation_runbook_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "mcp_activation_runbook_preview" - assert data["phase"] == "phase_39_opportunity_alert_review_preview" + assert data["phase"] == "phase_40_deployment_readiness_modularized" assert data["deployment_actions_executed"] is False assert data["docker_command_executed"] is False assert data["ssh_command_executed"] is False @@ -831,7 +831,7 @@ def test_mcp_fetch_gate_default_blocks_external_fetch(): gate = MarketIntelService().build_mcp_fetch_gate(fetch_requested=True) assert gate["mode"] == "mcp_fetch_gate_planned" - assert gate["phase"] == "phase_39_opportunity_alert_review_preview" + assert gate["phase"] == "phase_40_deployment_readiness_modularized" assert gate["fetch_requested"] is True assert gate["manual_fetch_gate_open"] is False assert gate["network_request_allowed"] is False @@ -901,7 +901,7 @@ def test_mcp_fetch_gate_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "mcp_fetch_gate_planned" - assert data["phase"] == "phase_39_opportunity_alert_review_preview" + assert data["phase"] == "phase_40_deployment_readiness_modularized" assert data["fetch_requested"] is False assert data["network_request_allowed"] is False assert data["external_network_executed"] is False @@ -913,7 +913,7 @@ def test_scheduler_plan_preview_blocks_job_attachment(): plan = MarketIntelService().build_scheduler_plan() assert plan["mode"] == "scheduler_attach_plan_preview" - assert plan["phase"] == "phase_39_opportunity_alert_review_preview" + assert plan["phase"] == "phase_40_deployment_readiness_modularized" assert plan["ready_to_attach_scheduler"] is False assert plan["scheduler_attached"] is False assert plan["scheduler_registration_executed"] is False @@ -951,7 +951,7 @@ def test_scheduler_plan_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "scheduler_attach_plan_preview" - assert data["phase"] == "phase_39_opportunity_alert_review_preview" + assert data["phase"] == "phase_40_deployment_readiness_modularized" assert data["scheduler_registration_executed"] is False assert data["crawler_job_started"] is False assert data["external_network_executed"] is False @@ -962,7 +962,7 @@ def test_match_review_plan_preview_blocks_auto_confirm(): plan = MarketIntelService().build_match_review_plan() assert plan["mode"] == "match_review_plan_preview" - assert plan["phase"] == "phase_39_opportunity_alert_review_preview" + assert plan["phase"] == "phase_40_deployment_readiness_modularized" assert plan["ready_for_review_queue"] is False assert plan["review_queue_created"] is False assert plan["auto_match_executed"] is False @@ -998,7 +998,7 @@ def test_match_review_plan_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "match_review_plan_preview" - assert data["phase"] == "phase_39_opportunity_alert_review_preview" + assert data["phase"] == "phase_40_deployment_readiness_modularized" assert data["review_queue_created"] is False assert data["auto_confirm_executed"] is False assert data["external_network_executed"] is False @@ -1009,7 +1009,7 @@ def test_opportunity_plan_preview_blocks_alerts_and_ai_summary(): plan = MarketIntelService().build_opportunity_plan() assert plan["mode"] == "opportunity_plan_preview" - assert plan["phase"] == "phase_39_opportunity_alert_review_preview" + assert plan["phase"] == "phase_40_deployment_readiness_modularized" assert plan["ready_for_opportunity_queue"] is False assert plan["opportunity_queue_created"] is False assert plan["threat_alert_dispatched"] is False @@ -1050,7 +1050,7 @@ def test_opportunity_plan_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "opportunity_plan_preview" - assert data["phase"] == "phase_39_opportunity_alert_review_preview" + assert data["phase"] == "phase_40_deployment_readiness_modularized" assert data["opportunity_queue_created"] is False assert data["threat_alert_dispatched"] is False assert data["ai_summary_generated"] is False @@ -1061,7 +1061,7 @@ def test_opportunity_scoring_plan_preview_blocks_scoring_and_alerts(): plan = MarketIntelService().build_opportunity_scoring_plan() assert plan["mode"] == "opportunity_scoring_plan_preview" - assert plan["phase"] == "phase_39_opportunity_alert_review_preview" + assert plan["phase"] == "phase_40_deployment_readiness_modularized" assert plan["ready_for_scoring_job"] is False assert plan["scoring_job_created"] is False assert plan["score_calculation_executed"] is False @@ -1109,7 +1109,7 @@ def test_opportunity_scoring_plan_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "opportunity_scoring_plan_preview" - assert data["phase"] == "phase_39_opportunity_alert_review_preview" + assert data["phase"] == "phase_40_deployment_readiness_modularized" assert data["scoring_job_created"] is False assert data["score_calculation_executed"] is False assert data["sample_scores_generated"] is False @@ -1121,7 +1121,7 @@ def test_opportunity_evidence_plan_preview_blocks_queries_and_alerts(): plan = MarketIntelService().build_opportunity_evidence_plan() assert plan["mode"] == "opportunity_evidence_plan_preview" - assert plan["phase"] == "phase_39_opportunity_alert_review_preview" + assert plan["phase"] == "phase_40_deployment_readiness_modularized" assert plan["ready_for_evidence_bundle"] is False assert plan["evidence_bundle_created"] is False assert plan["evidence_query_executed"] is False @@ -1167,7 +1167,7 @@ def test_opportunity_evidence_plan_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "opportunity_evidence_plan_preview" - assert data["phase"] == "phase_39_opportunity_alert_review_preview" + assert data["phase"] == "phase_40_deployment_readiness_modularized" assert data["evidence_bundle_created"] is False assert data["evidence_query_executed"] is False assert data["sample_evidence_generated"] is False @@ -1180,7 +1180,7 @@ def test_opportunity_alert_plan_preview_blocks_dispatch_and_llm_calls(): plan = MarketIntelService().build_opportunity_alert_plan() assert plan["mode"] == "opportunity_alert_plan_preview" - assert plan["phase"] == "phase_39_opportunity_alert_review_preview" + assert plan["phase"] == "phase_40_deployment_readiness_modularized" assert plan["ready_for_alert_candidates"] is False assert plan["alert_candidate_created"] is False assert plan["alert_queue_created"] is False @@ -1245,7 +1245,7 @@ def test_opportunity_alert_plan_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "opportunity_alert_plan_preview" - assert data["phase"] == "phase_39_opportunity_alert_review_preview" + assert data["phase"] == "phase_40_deployment_readiness_modularized" assert data["alert_candidate_created"] is False assert data["alert_queue_created"] is False assert data["review_queue_created"] is False @@ -1320,7 +1320,7 @@ def test_mcp_deploy_preflight_route_is_preview_only(): assert response.status_code == 200 assert data["mode"] == "mcp_external_deploy_preflight_preview" - assert data["phase"] == "phase_39_opportunity_alert_review_preview" + assert data["phase"] == "phase_40_deployment_readiness_modularized" assert data["deployment_actions_executed"] is False assert data["docker_command_executed"] is False assert data["ssh_command_executed"] is False @@ -1335,7 +1335,7 @@ def test_mcp_readiness_default_is_planned_only(monkeypatch): readiness = MarketIntelService().build_mcp_readiness() assert readiness["mode"] == "mcp_readiness_planned" - assert readiness["phase"] == "phase_39_opportunity_alert_review_preview" + assert readiness["phase"] == "phase_40_deployment_readiness_modularized" assert readiness["execute_requested"] is False assert readiness["router_enabled"] is False assert readiness["external_mcp_complete"] is False @@ -1795,6 +1795,16 @@ def test_deployment_readiness_reports_app_only_release_gate(): assert readiness["opportunity_alert_plan"]["database_write_executed"] is False +def test_market_intel_service_keeps_readiness_modularized(): + service_path = Path("services/market_intel/service.py") + readiness_path = Path("services/market_intel/deployment_readiness.py") + + assert service_path.exists() + assert readiness_path.exists() + assert len(service_path.read_text().splitlines()) < 800 + assert "build_deployment_readiness_preview" in readiness_path.read_text() + + def test_write_approval_runbook_is_read_only_and_blocks_real_write(): runbook = MarketIntelService().build_write_approval_runbook() gate_keys = {gate["key"] for gate in runbook["approval_gates"]}