GitHub Action
Sync from GitHub
ef78361
Raw
History Blame Contribute Delete
5.7 kB
"""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]