feat(market-intel): gate manual fetch behind mcp readiness
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s

This commit is contained in:
OoO
2026-05-18 15:40:56 +08:00
parent d990316d74
commit 921e9eeb15
11 changed files with 429 additions and 13 deletions

View File

@@ -71,6 +71,10 @@ def build_candidate_preview_from_discovery(discovery_result, *, min_band="all",
"platform_code": discovery_result.get("platform_code", "all"),
"fetch_requested": bool(discovery_result.get("fetch_requested")),
"manual_fetch_allowed": bool(discovery_result.get("manual_fetch_allowed")),
"mcp_fetch_gate": discovery_result.get("mcp_fetch_gate"),
"mcp_fetch_gate_open": bool(
(discovery_result.get("mcp_fetch_gate") or {}).get("manual_fetch_gate_open")
),
"min_band": min_band or "all",
"limit": limit,
"candidate_count": len(candidates),

View File

@@ -63,6 +63,7 @@ class ManualDiscoveryRunResult:
errors: int
results: list
error_message: Optional[str] = None
network_gate: Optional[dict] = None
def to_dict(self):
return asdict(self)
@@ -75,11 +76,22 @@ def _now_iso():
class ManualDiscoveryRunner:
"""手動 discovery runner透過 feature gate 控制是否允許網路探測。"""
def __init__(self, *, runtime_status, http_get: Optional[Callable] = None):
def __init__(
self,
*,
runtime_status,
http_get: Optional[Callable] = None,
network_allowed_override: Optional[bool] = None,
network_gate: Optional[dict] = None,
):
self.runtime_status = runtime_status
self.http_get = http_get or requests.get
self.network_allowed_override = network_allowed_override
self.network_gate = network_gate
def _network_allowed(self):
if self.network_allowed_override is not None:
return bool(self.network_allowed_override)
return bool(self.runtime_status.enabled and self.runtime_status.crawler_enabled)
def run(self, adapter, *, fetch=False):
@@ -105,7 +117,12 @@ class ManualDiscoveryRunner:
self._source_result(source, "blocked", True, False).to_dict()
for source in sources
],
error_message="MARKET_INTEL_ENABLED 與 MARKET_INTEL_CRAWLER_ENABLED 必須同時開啟才允許手動 fetch",
error_message=(
self.network_gate.get("operator_message")
if self.network_gate
else "MARKET_INTEL_ENABLED 與 MARKET_INTEL_CRAWLER_ENABLED 必須同時開啟才允許手動 fetch"
),
network_gate=self.network_gate,
)
capped_sources = sources[:adapter.safety_policy.max_pages_per_run]
@@ -174,6 +191,7 @@ class ManualDiscoveryRunner:
sources_fetched=fetched,
errors=errors,
results=results,
network_gate=self.network_gate,
)
def _source_result(

View File

@@ -0,0 +1,108 @@
"""市場情報人工 fetch 的 MCP gate preview。
這裡只計算外部 fetch 是否具備前置條件;不呼叫電商平台、不寫 DB、不掛排程。
"""
from services.market_intel.mcp_readiness import build_mcp_readiness_plan
def _status_value(runtime_status, name, default=False):
if isinstance(runtime_status, dict):
return runtime_status.get(name, default)
return getattr(runtime_status, name, default)
def build_mcp_fetch_gate_preview(
runtime_status,
*,
fetch_requested=False,
execute_readiness=False,
readiness=None,
):
"""建立人工 fetch 前的 MCP gate預設不做 health check、不連 DB。"""
fetch_requested = bool(fetch_requested)
execute_readiness = bool(execute_readiness)
readiness = readiness or build_mcp_readiness_plan(
execute_requested=execute_readiness,
)
readiness_checks = readiness.get("readiness_checks") or {}
gate_checks = {
"market_intel_enabled": bool(_status_value(runtime_status, "enabled")),
"market_intel_crawler_enabled": bool(
_status_value(runtime_status, "crawler_enabled")
),
"database_write_still_blocked": not bool(
_status_value(runtime_status, "database_write_allowed")
),
"scheduler_detached": not bool(
_status_value(runtime_status, "scheduler_attached")
),
"mcp_readiness_executed": bool(readiness.get("execute_requested")),
"mcp_router_enabled": bool(readiness.get("router_enabled")),
"external_mcp_complete": bool(readiness.get("external_mcp_complete")),
"internal_mcp_complete": bool(readiness.get("internal_mcp_complete")),
"market_intel_mcp_integrated": bool(
readiness.get("market_intel_mcp_integrated")
),
"market_intel_tool_contract_ready": bool(
readiness_checks.get("market_intel_tool_contract_ready")
),
"external_servers_all_healthy": bool(
readiness_checks.get("external_servers_all_healthy")
),
}
blocked_reasons = [
key for key, passed in gate_checks.items()
if not passed
]
if not fetch_requested:
blocked_reasons.insert(0, "fetch_false_planned_only")
prerequisites_met = not blocked_reasons
network_request_allowed = bool(fetch_requested and prerequisites_met)
return {
"mode": (
"mcp_fetch_gate_read_only"
if execute_readiness
else "mcp_fetch_gate_planned"
),
"fetch_requested": fetch_requested,
"readiness_execute_requested": bool(readiness.get("execute_requested")),
"manual_fetch_prerequisites_met": prerequisites_met,
"manual_fetch_gate_open": network_request_allowed,
"network_request_allowed": network_request_allowed,
"would_use_external_network": network_request_allowed,
"gate_checks": gate_checks,
"blocked_reasons": blocked_reasons,
"operator_message": (
"人工 fetch 已通過 MCP gate仍只允許公開頁面、限速、不得寫 DB。"
if network_request_allowed
else "人工 fetch 仍被 MCP gate 阻擋;需 feature flags、MCP health、router 與 tool contract 全部通過。"
),
"required_sequence": [
"MARKET_INTEL_ENABLED 與 MARKET_INTEL_CRAWLER_ENABLED 需由操作員明確開啟",
"MCP deploy preflight 必須通過必要 env、compose、localhost port 與 fallback 檢查",
"外部 MCP stack 四個 health endpoint 需全部 200",
"MCP_ROUTER_ENABLED 只能在 health 全過後才打開",
"manual discovery fetch 才能進入公開頁面限速探測,且仍不得寫 DB",
],
"mcp_readiness_summary": {
"mode": readiness.get("mode"),
"router_enabled": bool(readiness.get("router_enabled")),
"external_mcp_complete": bool(readiness.get("external_mcp_complete")),
"internal_mcp_complete": bool(readiness.get("internal_mcp_complete")),
"market_intel_mcp_integrated": bool(
readiness.get("market_intel_mcp_integrated")
),
"blocked_reasons": readiness.get("blocked_reasons", []),
},
"database_session_created": False,
"database_write_executed": False,
"database_commit_executed": False,
"external_network_executed": False,
"scheduler_attached": False,
"writes_executed": False,
"would_write_database": False,
}

View File

@@ -255,6 +255,6 @@ def build_mcp_readiness_plan(
"先通過 /api/market_intel/mcp_deploy_preflight 的 env、compose、port 與 fallback 檢查",
"部署並健康檢查 docker-compose.mcp.yml 的 postgres / omnisearch / firecrawl / filesystem",
"四個 MCP health endpoint 全部 200 後,才在正式環境設定 MCP_ROUTER_ENABLED=true",
"把 market_intel discovery / bridge preview 改成先走 MCP readiness再允許人工 fetch",
"人工 fetch 必須先通過 /api/market_intel/mcp_fetch_gate再允許公開頁面限速探測",
],
}

View File

@@ -23,6 +23,7 @@ from services.market_intel.legacy_source_bridge import build_legacy_source_bridg
from services.market_intel.mcp_activation_runbook import build_mcp_activation_runbook_preview
from services.market_intel.mcp_contract import build_mcp_tool_contract_preview
from services.market_intel.mcp_deploy_preflight import build_mcp_deploy_preflight_plan
from services.market_intel.mcp_fetch_gate import build_mcp_fetch_gate_preview
from services.market_intel.mcp_readiness import build_mcp_readiness_plan
from services.market_intel.migration_blueprint import build_migration_blueprint
from services.market_intel.platform_seed import build_platform_seed_rows
@@ -67,7 +68,7 @@ class MarketIntelRuntimeStatus:
class MarketIntelService:
"""市場情報入口服務,先集中 feature gate 與安全狀態。"""
phase = "phase_31_mcp_activation_runbook_preview"
phase = "phase_32_mcp_fetch_gate_preview"
def get_runtime_status(self) -> MarketIntelRuntimeStatus:
return MarketIntelRuntimeStatus(
@@ -145,6 +146,11 @@ class MarketIntelService:
"""手動執行 discovery dry-run預設不發 request永遠不寫 DB。"""
registry = get_adapter_registry()
adapters = []
status = self.get_runtime_status()
mcp_fetch_gate = self.build_mcp_fetch_gate(
fetch_requested=fetch,
execute_readiness=bool(fetch and status.enabled and status.crawler_enabled),
)
if platform_code and platform_code != "all":
adapter = get_adapter(platform_code)
@@ -160,14 +166,17 @@ class MarketIntelService:
adapters = list(registry.values())
runner = ManualDiscoveryRunner(
runtime_status=self.get_runtime_status(),
runtime_status=status,
http_get=http_get,
network_allowed_override=mcp_fetch_gate["network_request_allowed"],
network_gate=mcp_fetch_gate,
)
return {
"platform_code": platform_code or "all",
"found": True,
"fetch_requested": bool(fetch),
"manual_fetch_allowed": self.manual_fetch_allowed(),
"mcp_fetch_gate": mcp_fetch_gate,
"runs": [
runner.run(adapter, fetch=fetch).to_dict()
for adapter in adapters
@@ -347,6 +356,16 @@ class MarketIntelService:
runbook["phase"] = self.phase
return runbook
def build_mcp_fetch_gate(self, *, fetch_requested=False, execute_readiness=False):
"""回報人工 fetch 前的 MCP gate不抓電商頁、不寫 DB。"""
gate = build_mcp_fetch_gate_preview(
self.get_runtime_status(),
fetch_requested=fetch_requested,
execute_readiness=execute_readiness,
)
gate["phase"] = self.phase
return gate
def build_platform_seed_writer_plan(self, platform_code="all"):
"""建立 platform seed writer dry-run plan不建立 DB session。"""
seed_plan = self.build_platform_seed_plan(platform_code=platform_code)
@@ -425,6 +444,7 @@ class MarketIntelService:
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()
checks = {
"schema_smoke_passed": bool(schema_smoke["passed"]),
"feature_flags_default_safe": bool(
@@ -464,6 +484,11 @@ class MarketIntelService:
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"]
),
}
ready_for_production_deploy = all(checks.values())
blocked_reasons = [
@@ -591,6 +616,7 @@ class MarketIntelService:
"/api/market_intel/mcp_tool_contract",
"/api/market_intel/mcp_deploy_preflight",
"/api/market_intel/mcp_activation_runbook",
"/api/market_intel/mcp_fetch_gate",
],
"status": status.to_dict(),
"schema_smoke": schema_smoke,
@@ -609,4 +635,5 @@ class MarketIntelService:
"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,
}