"""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]