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]