Spaces:
Sleeping
Sleeping
| """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] | |