Spaces:
Sleeping
Sleeping
File size: 5,703 Bytes
ef78361 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 | """Independent trigger evaluation module β queries Supabase directly.
Evaluates 14 trigger rules against cached report data:
- Rules 1-2: report_visibility_daily (visibility gap, decline)
- Rules 3, 5: report_source_daily (official citation, channel concentration)
- Rule 4: answer_sentiment_latest VIEW (negative spike)
- Rules 6-9: report_source_daily channel groups (editorial/ugc/reference/owned)
- Rules 10-12: report_source_brand_daily (coverage gap, format gap, share gap)
- Rules 13-14: answer_sentiment_latest VIEW question types (trust/risk, decision)
No API dependency β all data comes from Supabase tables that
sync-worker syncs daily at 08:00 KST.
"""
from __future__ import annotations
import logging
import sys
from datetime import date, timedelta
from pathlib import Path
logger = logging.getLogger(__name__)
def _find_project_root() -> str | None:
"""Find project root by locating scripts/shared/trigger_rules.py.
Traverses parent directories instead of hardcoding parents[N]
to handle varying deployment paths (local dev, HuggingFace Space, Docker).
"""
current = Path(__file__).resolve().parent
for parent in current.parents:
if (parent / "scripts" / "shared" / "trigger_rules.py").exists():
return str(parent)
return None
_project_root = _find_project_root()
if _project_root and _project_root not in sys.path:
sys.path.insert(0, _project_root)
# Graceful import: trigger rules may not be available in standalone
# dashboard deployments (e.g., HuggingFace Space without full repo).
_TRIGGERS_AVAILABLE = False
try:
from scripts.shared.trigger_rules import ( # noqa: E402
TRIGGER_RULES,
CHANNEL_GROUPS,
_avg_visibility_by_brand,
_is_primary,
_rule_visibility_gap,
_rule_visibility_decline,
_rule_official_low_citation,
_rule_negative_spike,
_rule_channel_concentration,
_rule_channel_type_low,
_rule_gap_coverage,
_rule_gap_format,
_rule_gap_share,
_rule_question_trust_risk,
_rule_question_decision,
_fetch_visibility,
_fetch_source_types,
_fetch_brand_sentiments,
_fetch_source_content_types,
_fetch_source_brand_mix,
_fetch_question_type_stats,
)
_TRIGGERS_AVAILABLE = True
except (ImportError, ModuleNotFoundError) as e:
logger.warning("trigger_rules not available (standalone dashboard?): %s", e)
TRIGGER_RULES = {}
CHANNEL_GROUPS = {}
# Re-export all shared symbols so existing imports keep working
__all__ = [
"TRIGGER_RULES",
"CHANNEL_GROUPS",
"TRIGGERS_AVAILABLE",
"evaluate_triggers",
]
TRIGGERS_AVAILABLE = _TRIGGERS_AVAILABLE
def evaluate_triggers(
campaign_id: int,
start_date: str,
end_date: str,
) -> list[dict]:
"""Evaluate all trigger rules and return action items (max 14).
Args:
campaign_id: Campaign ID
start_date: Start date (YYYY-MM-DD)
end_date: End date (YYYY-MM-DD)
Returns:
List of action item dicts sorted by priority descending.
Raises:
RuntimeError: If trigger rules module is not available.
"""
if not _TRIGGERS_AVAILABLE:
raise RuntimeError(
"νΈλ¦¬κ±° λΆμμ μ¬μ©ν μ μμ΅λλ€. "
"scripts/shared/trigger_rules.pyκ° νμν©λλ€."
)
from core.supabase_client import get_supabase_client
client = get_supabase_client()
items: list[dict] = []
# ββ Data collection ββ
visibility = _fetch_visibility(client, campaign_id, start_date, end_date)
source_types = _fetch_source_types(client, campaign_id, start_date, end_date)
# ββ Rule 1: visibility_gap β own brand < competitor average ββ
items.extend(_rule_visibility_gap(visibility))
# ββ Rule 2: visibility_decline β delta <= -3pp vs prior period ββ
period_days = (date.fromisoformat(end_date) - date.fromisoformat(start_date)).days + 1
prior_end = date.fromisoformat(start_date) - timedelta(days=1)
prior_start = prior_end - timedelta(days=period_days - 1)
prior_visibility = _fetch_visibility(
client, campaign_id, prior_start.isoformat(), prior_end.isoformat(),
)
items.extend(_rule_visibility_decline(visibility, prior_visibility))
# ββ Rule 3: official_low_citation β OFFICIAL < 10% ββ
items.extend(_rule_official_low_citation(source_types))
# ββ Rule 4: negative_spike β in-house negative > 30% ββ
brand_sentiments = _fetch_brand_sentiments(client, campaign_id, visibility)
items.extend(_rule_negative_spike(brand_sentiments))
# ββ Rule 5: channel_concentration β single channel > 50% ββ
items.extend(_rule_channel_concentration(source_types))
# ββ Rules 6-9: channel type rules ββ
for channel_name in CHANNEL_GROUPS:
items.extend(_rule_channel_type_low(source_types, channel_name))
# ββ Rules 10-12: gap rules ββ
source_brand_mix = _fetch_source_brand_mix(client, campaign_id, start_date, end_date)
content_types = _fetch_source_content_types(client, campaign_id, start_date, end_date)
items.extend(_rule_gap_coverage(source_brand_mix))
items.extend(_rule_gap_format(content_types))
items.extend(_rule_gap_share(source_brand_mix))
# ββ Rules 13-14: question type rules ββ
question_stats = _fetch_question_type_stats(client, campaign_id)
items.extend(_rule_question_trust_risk(question_stats))
items.extend(_rule_question_decision(question_stats, source_types))
items.sort(key=lambda x: x["priority"], reverse=True)
return items[:14]
|