feat(market-intel): add opportunity scoring plan preview
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

This commit is contained in:
OoO
2026-05-18 18:59:41 +08:00
parent 18f8038b01
commit f5b9f1bd74
8 changed files with 482 additions and 17 deletions

View File

@@ -0,0 +1,214 @@
"""市場情報機會與威脅分數模型 preview。
只定義未來 scoring job 的欄位、權重、門檻與 evidence 要求;不查 DB、
不建立 queue、不派送 Telegram、不產生 AI 摘要。
"""
SCORING_DIMENSIONS = (
{
"key": "price_gap_pct",
"label": "競品活動價差百分比",
"weight": 35,
"evidence": [
"confirmed_market_product_match",
"market_product_price_history.price",
"momo_reference_price",
],
"calculation": "max(0, momo_reference_price - market_price) / momo_reference_price",
},
{
"key": "discount_depth",
"label": "活動折扣深度",
"weight": 20,
"evidence": [
"market_campaign_products.original_price",
"market_campaign_products.price",
"market_campaign_products.discount_rate",
],
"calculation": "normalized_discount_rate",
},
{
"key": "match_confidence",
"label": "商品比對可信度",
"weight": 20,
"evidence": [
"market_product_matches.match_score",
"market_product_matches.match_status",
"market_product_matches.match_reason_json",
],
"calculation": "confirmed=1.0, needs_review=score, rejected=0",
},
{
"key": "campaign_urgency",
"label": "活動時間急迫性",
"weight": 15,
"evidence": [
"market_campaigns.end_at",
"market_campaigns.status",
"market_campaign_snapshots.crawled_at",
],
"calculation": "higher_when_active_and_ending_within_72h",
},
{
"key": "data_freshness",
"label": "資料新鮮度",
"weight": 10,
"evidence": [
"market_product_price_history.crawled_at",
"market_crawler_runs.finished_at",
],
"calculation": "higher_when_snapshot_age_under_24h",
},
)
SEVERITY_THRESHOLDS = (
{
"level": "critical",
"minimum_score": 85,
"action": "只建立人工審核項;必須有 confirmed match 與最新價格 evidence。",
},
{
"level": "high",
"minimum_score": 70,
"action": "排入威脅審核清單;不得自動調價或直接推播。",
},
{
"level": "medium",
"minimum_score": 50,
"action": "排入日報候選;需人工確認後才可進 AI 摘要。",
},
{
"level": "watch",
"minimum_score": 30,
"action": "只保留觀察訊號,不進即時告警。",
},
)
REQUIRED_EVIDENCE_TABLES = (
"market_campaigns",
"market_campaign_products",
"market_product_price_history",
"market_product_matches",
"market_crawler_runs",
)
def _schema_tables(schema_smoke):
expected_tables = schema_smoke.get("expected_tables") or []
if expected_tables:
return set(expected_tables)
smoke = schema_smoke.get("schema_smoke") or schema_smoke
return {
item.get("table")
for item in smoke.get("tables", [])
if item.get("table")
}
def build_opportunity_scoring_plan_preview(
*,
runtime_status,
opportunity_plan,
match_review_plan,
scheduler_plan,
schema_smoke,
mcp_fetch_gate,
):
"""建立機會與威脅分數模型 preview不執行任何計算或寫入。"""
available_tables = _schema_tables(schema_smoke)
required_tables_present = set(REQUIRED_EVIDENCE_TABLES) <= available_tables
dimensions = list(SCORING_DIMENSIONS)
total_weight = sum(item["weight"] for item in dimensions)
opportunity_preview_safe = (
opportunity_plan.get("mode") == "opportunity_plan_preview"
and not opportunity_plan.get("opportunity_queue_created")
and not opportunity_plan.get("threat_alert_dispatched")
and not opportunity_plan.get("database_write_executed")
)
match_review_preview_safe = (
match_review_plan.get("mode") == "match_review_plan_preview"
and not match_review_plan.get("auto_confirm_executed")
and not match_review_plan.get("database_write_executed")
)
scheduler_preview_safe = (
scheduler_plan.get("mode") == "scheduler_attach_plan_preview"
and not scheduler_plan.get("scheduler_registration_executed")
and not scheduler_plan.get("crawler_job_started")
)
gate_checks = {
"opportunity_plan_preview_safe": opportunity_preview_safe,
"opportunity_rules_available": bool(opportunity_plan.get("rule_count")),
"opportunity_queue_ready": bool(
opportunity_plan.get("ready_for_opportunity_queue")
),
"match_review_preview_safe": match_review_preview_safe,
"confirmed_match_source_ready": bool(
match_review_plan.get("ready_for_review_queue")
),
"schema_smoke_passed": bool(
(schema_smoke.get("schema_smoke") or schema_smoke).get("passed")
),
"required_evidence_tables_declared": required_tables_present,
"mcp_fetch_gate_closed_for_preview": not bool(
mcp_fetch_gate.get("network_request_allowed")
),
"scheduler_plan_preview_safe": scheduler_preview_safe,
"database_write_still_blocked": not bool(
runtime_status.database_write_allowed
),
"manual_operator_approval": False,
}
blocked_reasons = [
key for key, passed in gate_checks.items()
if not passed
]
return {
"mode": "opportunity_scoring_plan_preview",
"ready_for_scoring_job": False,
"scoring_job_created": False,
"score_calculation_executed": False,
"sample_scores_generated": False,
"opportunity_queue_created": False,
"review_queue_created": False,
"threat_alert_dispatched": False,
"telegram_dispatched": False,
"ai_summary_generated": False,
"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,
"dimension_count": len(dimensions),
"total_weight": total_weight,
"dimensions": dimensions,
"thresholds": list(SEVERITY_THRESHOLDS),
"required_evidence_tables": list(REQUIRED_EVIDENCE_TABLES),
"gate_checks": gate_checks,
"blocked_reasons": blocked_reasons,
"formula": "sum(normalized_dimension_score * weight) / 100",
"promotion_policy": {
"critical": "只可進人工審核;人工確認後才可生成 Telegram 候選。",
"high": "只可進審核清單;不得直接觸發價格調整。",
"medium": "只可進日報候選AI 摘要必須附 evidence id。",
"watch": "只做趨勢觀察,不進告警。",
},
"operator_sequence": [
"確認 market_* migration 與 seed rows 已完成",
"確認商品比對 queue 已有 confirmed / needs_review evidence",
"用最新 price history 與 campaign snapshot 計算 dimension scores",
"依 thresholds 建立人工審核候選,不直接推播或調價",
"人工批准後才允許 AI 摘要或 Telegram 候選進入下一階段",
],
"safe_boundaries": [
"do_not_score_without_confirmed_or_reviewed_match",
"do_not_generate_sample_scores_from_placeholder_data",
"do_not_write_scoring_results_from_preview",
"do_not_dispatch_alerts_from_scoring_preview",
"do_not_generate_ai_summary_from_scoring_preview",
"do_not_auto_adjust_price",
],
}

View File

@@ -28,6 +28,9 @@ 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.opportunity_plan import build_opportunity_plan_preview
from services.market_intel.opportunity_scoring import (
build_opportunity_scoring_plan_preview,
)
from services.market_intel.platform_seed import build_platform_seed_rows
from services.market_intel.platform_seed_db_diff import build_platform_seed_db_diff_plan
from services.market_intel.scheduler_plan import build_scheduler_attach_plan
@@ -71,7 +74,7 @@ class MarketIntelRuntimeStatus:
class MarketIntelService:
"""市場情報入口服務,先集中 feature gate 與安全狀態。"""
phase = "phase_35_opportunity_plan_preview"
phase = "phase_36_opportunity_scoring_plan_preview"
def get_runtime_status(self) -> MarketIntelRuntimeStatus:
return MarketIntelRuntimeStatus(
@@ -405,6 +408,20 @@ class MarketIntelService:
plan["phase"] = self.phase
return plan
def build_opportunity_scoring_plan(self):
"""回報機會與威脅分數模型計畫;不計分、不寫入。"""
schema_smoke = self.build_schema_smoke()
plan = build_opportunity_scoring_plan_preview(
runtime_status=self.get_runtime_status(),
opportunity_plan=self.build_opportunity_plan(),
match_review_plan=self.build_match_review_plan(),
scheduler_plan=self.build_scheduler_plan(),
schema_smoke=schema_smoke,
mcp_fetch_gate=self.build_mcp_fetch_gate(),
)
plan["phase"] = self.phase
return plan
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)
@@ -487,6 +504,7 @@ class MarketIntelService:
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()
checks = {
"schema_smoke_passed": bool(schema_smoke["passed"]),
"feature_flags_default_safe": bool(
@@ -549,6 +567,13 @@ class MarketIntelService:
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"]
),
}
ready_for_production_deploy = all(checks.values())
blocked_reasons = [
@@ -680,6 +705,7 @@ class MarketIntelService:
"/api/market_intel/scheduler_plan",
"/api/market_intel/match_review_plan",
"/api/market_intel/opportunity_plan",
"/api/market_intel/opportunity_scoring_plan",
],
"status": status.to_dict(),
"schema_smoke": schema_smoke,
@@ -702,4 +728,5 @@ class MarketIntelService:
"scheduler_plan": scheduler_plan,
"match_review_plan": match_review_plan,
"opportunity_plan": opportunity_plan,
"opportunity_scoring_plan": opportunity_scoring_plan,
}