tubesentry 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,336 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from typing import Any
5
+
6
+ import pandas as pd
7
+ from rich.console import Console
8
+
9
+ from tubesentry.contracts.schemas import (
10
+ AuditSummaryReport,
11
+ ClassifiedChannelRecord,
12
+ WasteCategoryBreakdown,
13
+ )
14
+ from tubesentry.contracts.taxonomy import CATEGORY_DESCRIPTIONS, WasteCategory
15
+ from tubesentry.core.heuristics import evaluate_heuristics_detailed
16
+ from tubesentry.core.seed_db import lookup_seed_channel
17
+
18
+
19
+ def normalize_topic_category(topic_str: str) -> str:
20
+ """Extracts clean topic name from Wikipedia URL or Freebase string."""
21
+ if "wiki/" in topic_str:
22
+ topic_name = topic_str.split("wiki/", 1)[1]
23
+ elif "/" in topic_str:
24
+ topic_name = topic_str.rsplit("/", 1)[-1]
25
+ else:
26
+ topic_name = topic_str
27
+ return topic_name.replace("_", " ").strip()
28
+
29
+
30
+ def run_free_audit(
31
+ df: pd.DataFrame,
32
+ target_languages: list[str] | None = None,
33
+ target_cpa: float | None = None,
34
+ print_banner: bool = True,
35
+ currency: str = "EUR",
36
+ ) -> AuditSummaryReport:
37
+ """Executes the free diagnostic placement audit using the bundled seed DB and heuristics.
38
+
39
+ Reveals the exact total wasted spend across all 6 risk categories, impression-weighted metrics,
40
+ and the top 25 worst offending channels unmasked, while masking the tail behind Pro upgrade notices.
41
+ """
42
+ total_placements = len(df)
43
+ total_spend = float(df["cost"].sum()) if not df.empty and "cost" in df.columns else 0.0
44
+ total_impressions = int(df["impressions"].sum()) if not df.empty and "impressions" in df.columns else 0
45
+
46
+ classified_records: list[ClassifiedChannelRecord] = []
47
+
48
+ # Aggregate by unique channel_id or raw_placement to avoid duplicate rows for the same channel
49
+ grouped = (
50
+ df.groupby(
51
+ ["raw_placement", "channel_id", "channel_title"],
52
+ dropna=False,
53
+ as_index=False,
54
+ )
55
+ .agg(
56
+ {
57
+ "cost": "sum",
58
+ "impressions": "sum",
59
+ "views": "sum",
60
+ "clicks": "sum",
61
+ "conversions": "sum",
62
+ }
63
+ )
64
+ .sort_values(by="cost", ascending=False)
65
+ )
66
+
67
+ category_stats: dict[str, dict[str, Any]] = defaultdict(
68
+ lambda: {
69
+ "wasted_spend": 0.0,
70
+ "channel_count": 0,
71
+ "impressions": 0,
72
+ "views": 0,
73
+ "clicks": 0,
74
+ "conversions": 0.0,
75
+ }
76
+ )
77
+
78
+ # Accumulator for impression-weighted quality scoring (1 to 10 scale)
79
+ total_quality_points = 0.0
80
+
81
+ for _, row in grouped.iterrows():
82
+ raw_pl = str(row["raw_placement"]) if pd.notna(row["raw_placement"]) else ""
83
+ ch_id = str(row["channel_id"]) if pd.notna(row["channel_id"]) else None
84
+ title = str(row["channel_title"]) if pd.notna(row["channel_title"]) else raw_pl
85
+ cost = float(row["cost"])
86
+ impr = int(row["impressions"])
87
+ views = int(row["views"])
88
+ clicks = int(row["clicks"])
89
+ conv = float(row["conversions"])
90
+
91
+ # 1. Check Seed DB
92
+ category: str = "CLEAN"
93
+ confidence: float = 0.0
94
+ reason: str = "Clean"
95
+ is_young_kids = False
96
+ granular_sub = "CLEAN_AUDIENCE"
97
+
98
+ if ch_id:
99
+ seed_match = lookup_seed_channel(ch_id)
100
+ if seed_match:
101
+ category = seed_match["category"]
102
+ confidence = seed_match["confidence"]
103
+ reason = seed_match["reason"]
104
+ if category == "MADE_FOR_KIDS":
105
+ is_young_kids = True
106
+ granular_sub = "YOUNG_KIDS_TODDLER"
107
+
108
+ # 2. Check Open-Core Heuristics if not matched in seed
109
+ if category == "CLEAN":
110
+ h_res = evaluate_heuristics_detailed(
111
+ channel_title=title,
112
+ raw_placement=raw_pl,
113
+ impressions=impr,
114
+ views=views,
115
+ clicks=clicks,
116
+ cost=cost,
117
+ conversions=conv,
118
+ target_languages=target_languages,
119
+ target_cpa=target_cpa,
120
+ )
121
+ if h_res["category"] != "CLEAN":
122
+ category = h_res["category"]
123
+ confidence = h_res["confidence"]
124
+ reason = h_res["reason"]
125
+ is_young_kids = h_res.get("is_young_kids", False)
126
+ granular_sub = h_res.get("granular_sub_category", category)
127
+
128
+ # Compute placement quality points (1.0 to 10.0 scale)
129
+ if category == "CLEAN":
130
+ row_quality = 10.0
131
+ elif is_young_kids:
132
+ row_quality = 1.0
133
+ elif category in ("MADE_FOR_KIDS", "AI_SLOP_CLICK_FARM"):
134
+ row_quality = 1.5
135
+ elif category in ("PASSIVE_AUDIO_MUSIC", "FOREIGN_SCRIPT_LEAK"):
136
+ row_quality = 2.0
137
+ elif category == "LOW_AGE_GAMING":
138
+ row_quality = 2.5
139
+ else: # PERFORMANCE_SINKHOLE
140
+ row_quality = 3.5
141
+
142
+ total_quality_points += row_quality * max(impr, 1)
143
+
144
+ if category != "CLEAN":
145
+ rec = ClassifiedChannelRecord(
146
+ channel_id=ch_id or "UC_UNKNOWN_HANDLE",
147
+ channel_title=title,
148
+ waste_category=category, # type: ignore[arg-type]
149
+ waste_confidence=round(confidence, 2),
150
+ made_for_kids_official=(category == "MADE_FOR_KIDS"),
151
+ is_young_kids=is_young_kids,
152
+ granular_sub_category=granular_sub,
153
+ thumbnail_url=None,
154
+ detected_language=None,
155
+ detected_country=None,
156
+ topic_categories=[],
157
+ cost=round(cost, 2),
158
+ impressions=impr,
159
+ views=views,
160
+ clicks=clicks,
161
+ conversions=round(conv, 2),
162
+ reason_summary=reason,
163
+ is_masked=False,
164
+ )
165
+ classified_records.append(rec)
166
+
167
+ stats = category_stats[category]
168
+ stats["wasted_spend"] += cost
169
+ stats["channel_count"] += 1
170
+ stats["impressions"] += impr
171
+ stats["views"] += views
172
+ stats["clicks"] += clicks
173
+ stats["conversions"] += conv
174
+
175
+ # Sort offending channels descending by wasted cost
176
+ classified_records.sort(key=lambda x: x.cost, reverse=True)
177
+
178
+ total_wasted_spend = sum(stats["wasted_spend"] for stats in category_stats.values())
179
+ waste_percentage = (total_wasted_spend / total_spend * 100.0) if total_spend > 0 else 0.0
180
+ total_waste_channels = len(classified_records)
181
+
182
+ total_wasted_impressions = sum(stats["impressions"] for stats in category_stats.values())
183
+ wasted_impressions_percentage = (
184
+ (total_wasted_impressions / total_impressions * 100.0) if total_impressions > 0 else 0.0
185
+ )
186
+
187
+ weight_denom = max(total_impressions, len(grouped))
188
+ weighted_quality_score = round(total_quality_points / weight_denom, 1) if weight_denom > 0 else 10.0
189
+ weighted_quality_score = max(1.0, min(10.0, weighted_quality_score))
190
+
191
+ # Free Tier Masking Invariant:
192
+ # First 25 channels are completely unmasked.
193
+ # Channels 26+ are masked with UC•••••••••••• and locked placeholder title.
194
+ top_offenders: list[ClassifiedChannelRecord] = []
195
+ masked_count = 0
196
+
197
+ for idx, rec in enumerate(classified_records):
198
+ if idx < 25:
199
+ top_offenders.append(rec)
200
+ else:
201
+ masked_count += 1
202
+ top_offenders.append(
203
+ rec.model_copy(
204
+ update={
205
+ "channel_id": "UC••••••••••••",
206
+ "channel_title": "[LOCKED IN FREE TIER — Upgrade to TubeSentry Pro]",
207
+ "is_masked": True,
208
+ }
209
+ )
210
+ )
211
+
212
+ breakdowns: dict[str, WasteCategoryBreakdown] = {}
213
+ for cat_enum in WasteCategory:
214
+ cat_key = cat_enum.value
215
+ if cat_key == "CLEAN":
216
+ continue
217
+ stats = category_stats[cat_key]
218
+ spend_pct = (stats["wasted_spend"] / total_spend * 100.0) if total_spend > 0 else 0.0
219
+ breakdowns[cat_key] = WasteCategoryBreakdown(
220
+ category=cat_key, # type: ignore[arg-type]
221
+ wasted_spend=round(stats["wasted_spend"], 2),
222
+ wasted_spend_percentage=round(spend_pct, 2),
223
+ channel_count=int(stats["channel_count"]),
224
+ impressions=int(stats["impressions"]),
225
+ views=int(stats["views"]),
226
+ clicks=int(stats["clicks"]),
227
+ conversions=round(stats["conversions"], 2),
228
+ description=CATEGORY_DESCRIPTIONS.get(cat_enum, ""),
229
+ )
230
+
231
+ coppa_kids_spend = category_stats["MADE_FOR_KIDS"]["wasted_spend"]
232
+ annual_runrate = total_wasted_spend * 12.0
233
+
234
+ banner_text = _build_diagnostic_banner(
235
+ total_placements=total_placements,
236
+ total_spend=total_spend,
237
+ total_wasted_spend=total_wasted_spend,
238
+ waste_percentage=waste_percentage,
239
+ total_waste_channels=total_waste_channels,
240
+ total_impressions=total_impressions,
241
+ total_wasted_impressions=total_wasted_impressions,
242
+ wasted_impressions_percentage=wasted_impressions_percentage,
243
+ weighted_quality_score=weighted_quality_score,
244
+ breakdowns=breakdowns,
245
+ masked_count=masked_count,
246
+ currency=currency,
247
+ )
248
+
249
+ if print_banner:
250
+ console = Console(stderr=False)
251
+ console.print(banner_text)
252
+
253
+ return AuditSummaryReport(
254
+ total_placements_audited=total_placements,
255
+ total_spend=round(total_spend, 2),
256
+ total_wasted_spend=round(total_wasted_spend, 2),
257
+ wasted_spend_percentage=round(waste_percentage, 2),
258
+ total_waste_channels=total_waste_channels,
259
+ total_impressions_audited=total_impressions,
260
+ total_wasted_impressions=total_wasted_impressions,
261
+ wasted_impressions_percentage=round(wasted_impressions_percentage, 2),
262
+ weighted_quality_score=round(weighted_quality_score, 1),
263
+ top_topic_categories=[],
264
+ currency_code=currency,
265
+ category_breakdowns=breakdowns,
266
+ top_offenders=top_offenders,
267
+ masked_offenders_count=masked_count,
268
+ is_pro_audit=False,
269
+ coppa_kids_risk_spend=round(coppa_kids_spend, 2),
270
+ annualized_recoverable_runrate=round(annual_runrate, 2),
271
+ upgrade_prompt=banner_text,
272
+ )
273
+
274
+
275
+ def _build_diagnostic_banner(
276
+ total_placements: int,
277
+ total_spend: float,
278
+ total_wasted_spend: float,
279
+ waste_percentage: float,
280
+ total_waste_channels: int,
281
+ total_impressions: int,
282
+ total_wasted_impressions: int,
283
+ wasted_impressions_percentage: float,
284
+ weighted_quality_score: float,
285
+ breakdowns: dict[str, WasteCategoryBreakdown],
286
+ masked_count: int,
287
+ currency: str = "EUR",
288
+ ) -> str:
289
+ """Constructs the Rich upgrade diagnostic banner string matching spec Section 4.1."""
290
+ sym = "€" if currency == "EUR" else ("$" if currency == "USD" else currency)
291
+
292
+ lines = [
293
+ "=" * 84,
294
+ " TUBESENTRY OPEN-CORE v0.1.0 — YOUTUBE PLACEMENT WASTE DIAGNOSTIC",
295
+ "=" * 84,
296
+ f" Audited Placements: {total_placements:,} placements | Total Spend: {sym} {total_spend:,.2f}",
297
+ f" Identified Wasted Spend: {sym} {total_wasted_spend:,.2f} ({waste_percentage:.1f}% of budget) across {total_waste_channels:,} channels",
298
+ f" Impression Waste Impact: {total_wasted_impressions:,} imps ({wasted_impressions_percentage:.1f}% wasted) | Placement Quality: {weighted_quality_score:.1f}/10",
299
+ "-" * 84,
300
+ " [!] WASTED BUDGET BY CATEGORY:",
301
+ ]
302
+
303
+ # Sort categories by wasted spend descending
304
+ sorted_cats = sorted(breakdowns.values(), key=lambda b: b.wasted_spend, reverse=True)
305
+ idx = 1
306
+ category_labels = {
307
+ "MADE_FOR_KIDS": "COPPA / Toddler",
308
+ "PASSIVE_AUDIO_MUSIC": "Lofi / Sleep / BG",
309
+ "FOREIGN_SCRIPT_LEAK": "Cyrillic / Arabic / Asian",
310
+ "LOW_AGE_GAMING": "Roblox / Minecraft",
311
+ "AI_SLOP_CLICK_FARM": "Faceless AI / Slop",
312
+ "PERFORMANCE_SINKHOLE": "High CPA / 0 Conv",
313
+ }
314
+
315
+ for b in sorted_cats:
316
+ if b.wasted_spend > 0:
317
+ label = category_labels.get(b.category, "")
318
+ lines.append(
319
+ f" {idx}. [{b.category:<20}] {sym} {b.wasted_spend:>10,.2f} ({b.wasted_spend_percentage:>4.1f}%) — {b.channel_count:>5,} channels ({label})"
320
+ )
321
+ idx += 1
322
+
323
+ lines.extend(
324
+ [
325
+ "-" * 84,
326
+ " [!] FREE TIER EXPORT LIMIT:",
327
+ f" Showing Top 25 worst offending channels below. {masked_count:,} additional waste channels are",
328
+ " masked (`UC••••••••••••`).",
329
+ "",
330
+ f" [*] Unlock All {total_waste_channels:,} Flagged Channels, 65k Google Ads Exclusion CSV & HTML Deck:",
331
+ " >>> tubesentry auth --key TS_PRO_...",
332
+ " Get an instant Single Audit Pass (€199) or Agency License: https://buy.polar.sh/polar_cl_lRuifr3uJS0LR0k6frhg3JEKBRZNQq75hv7cS2am9cX",
333
+ "=" * 84,
334
+ ]
335
+ )
336
+ return "\n".join(lines)
@@ -0,0 +1,238 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import unicodedata
5
+ from typing import Any
6
+
7
+ # Compiled regexes for high-performance vectorized evaluation
8
+ YOUNG_KIDS_REGEX = re.compile(
9
+ r"(?i)\b(cocomelon|bebefinn|chuchu\s*tv|peppa\s*pig|super\s*simple\s*songs|"
10
+ r"little\s*baby\s*bum|pinkfong|baby\s*shark|vlad\s*and\s*niki|diana\s*and\s*roma|"
11
+ r"nursery\s*rhymes?|kinderlieder|canciones\s*infantiles|comptines|canzoni\s*per\s*bambini|"
12
+ r"el\s*reino\s*infantil|gallina\s*pintadita|toy\s*unboxing|learn\s*colors|alphabet\s*song|"
13
+ r"toddler|kindergarten|sing-along|lullaby|schlaflied|baby\s*songs?)\b"
14
+ )
15
+
16
+ KIDS_REGEX = re.compile(
17
+ r"(?i)\b(nursery\s*rhymes?|kinderlieder|canciones\s*infantiles|comptines|"
18
+ r"canzoni\s*per\s*bambini|cocomelon|bebefinn|chuchu\s*tv|peppa\s*pig|"
19
+ r"paw\s*patrol|blippi|super\s*simple\s*songs|little\s*baby\s*bum|pinkfong|"
20
+ r"baby\s*shark|vlad\s*and\s*niki|diana\s*and\s*roma|el\s*reino\s*infantil|"
21
+ r"gallina\s*pintadita|toy\s*unboxing|learn\s*colors|alphabet\s*song|"
22
+ r"toddler|kindergarten|sing-along|lullaby|schlaflied|baby\s*songs?)\b"
23
+ )
24
+
25
+ PASSIVE_MUSIC_REGEX = re.compile(
26
+ r"(?i)\b(lofi|lo-fi|relaxing\s*music|sleep\s*music|rain\s*sounds|white\s*noise|"
27
+ r"study\s*beats|ambient\s*music|meditation\s*music|healing\s*frequency|432\s*hz|"
28
+ r"528\s*hz|10\s*hours|8\s*hours|karaoke|instrumental\s*mix|deep\s*focus|"
29
+ r"coffee\s*shop\s*jazz|schlafmusik|música\s*para\s*dormir)\b"
30
+ )
31
+
32
+ LOW_AGE_GAMING_REGEX = re.compile(
33
+ r"(?i)\b(roblox|brookhaven|blox\s*fruits|minecraft\s*pe|brawl\s*stars|gacha\s*life|"
34
+ r"gacha\s*club|skibidi\s*toilet|fnf|friday\s*night\s*funkin|among\s*us\s*animation|"
35
+ r"subway\s*surfers|stumble\s*guys|pk\s*xd|toca\s*life|toca\s*boca|geometry\s*dash)\b"
36
+ )
37
+
38
+ AI_SLOP_REGEX = re.compile(
39
+ r"(?i)\b(reddit\s*stories|aita\s*stories|ai\s*facts|satisfying\s*slime|"
40
+ r"hydraulic\s*press\s*shorts|family\s*guy\s*clips|subway\s*surfers\s*reddit)\b"
41
+ )
42
+
43
+ # Unicode non-Latin script ranges:
44
+ # Cyrillic: \u0400-\u04FF
45
+ # Arabic: \u0600-\u06FF
46
+ # Devanagari (Hindi): \u0900-\u097F
47
+ # Bengali: \u0980-\u09FF
48
+ # Thai: \u0E00-\u0E7F
49
+ # Hangul: \uAC00-\uD7AF, \u1100-\u11FF
50
+ # CJK: \u4E00-\u9FFF, \u3400-\u4DBF
51
+ NON_LATIN_SCRIPT_PATTERN = re.compile(
52
+ r"[\u0400-\u04FF\u0600-\u06FF\u0900-\u097F\u0980-\u09FF\u0E00-\u0E7F"
53
+ r"\uAC00-\uD7AF\u1100-\u11FF\u4E00-\u9FFF\u3400-\u4DBF]"
54
+ )
55
+
56
+
57
+ def compute_non_latin_ratio(text: str) -> float:
58
+ """Computes the ratio of non-Latin script characters to total alphabetic letters.
59
+
60
+ Returns float in [0.0, 1.0].
61
+ """
62
+ if not text:
63
+ return 0.0
64
+
65
+ total_letters = 0
66
+ non_latin_letters = 0
67
+
68
+ for char in text:
69
+ if unicodedata.category(char).startswith("L"):
70
+ total_letters += 1
71
+ if NON_LATIN_SCRIPT_PATTERN.match(char):
72
+ non_latin_letters += 1
73
+
74
+ if total_letters == 0:
75
+ return 0.0
76
+ return non_latin_letters / total_letters
77
+
78
+
79
+ def is_young_kids_text(text: str) -> bool:
80
+ """Checks if text specifically targets toddlers / under 10 (nursery rhymes, CoCoMelon, etc.)."""
81
+ return bool(YOUNG_KIDS_REGEX.search(text)) if text else False
82
+
83
+
84
+ def is_made_for_kids_text(text: str) -> bool:
85
+ """Checks if text contains toddler or Made-for-Kids keywords."""
86
+ return bool(KIDS_REGEX.search(text)) if text else False
87
+
88
+
89
+ def is_passive_music_text(text: str) -> bool:
90
+ """Checks if text contains passive audio / background music keywords."""
91
+ if not text:
92
+ return False
93
+ if text.endswith((" - Topic", "VEVO", " VEVO")):
94
+ return True
95
+ return bool(PASSIVE_MUSIC_REGEX.search(text))
96
+
97
+
98
+ def is_low_age_gaming_text(text: str) -> bool:
99
+ """Checks if text contains underage gaming keywords."""
100
+ return bool(LOW_AGE_GAMING_REGEX.search(text)) if text else False
101
+
102
+
103
+ def is_ai_slop_text(text: str) -> bool:
104
+ """Checks if text contains faceless AI slop keywords."""
105
+ return bool(AI_SLOP_REGEX.search(text)) if text else False
106
+
107
+
108
+ def evaluate_heuristics_detailed(
109
+ channel_title: str,
110
+ raw_placement: str = "",
111
+ impressions: int = 0,
112
+ views: int = 0,
113
+ clicks: int = 0,
114
+ cost: float = 0.0,
115
+ conversions: float = 0.0,
116
+ target_languages: list[str] | None = None,
117
+ target_cpa: float | None = None,
118
+ ) -> dict[str, Any]:
119
+ """Evaluates placement heuristics with granular sub-category and toddler detection.
120
+
121
+ Returns dict with keys:
122
+ category, confidence, reason, is_young_kids, granular_sub_category
123
+ """
124
+ [lang.lower()[:2] for lang in (target_languages or ["de", "en"])]
125
+ combined_text = f"{channel_title} {raw_placement}"
126
+
127
+ # Rule 1: MADE_FOR_KIDS & YOUNG_KIDS
128
+ if is_made_for_kids_text(combined_text):
129
+ young = is_young_kids_text(combined_text)
130
+ sub_cat = "YOUNG_KIDS_TODDLER" if young else "GENERAL_KIDS_CARTOON"
131
+ reason = (
132
+ "Matched toddler / under-10 nursery rhyme & brainrot lexicon."
133
+ if young
134
+ else "Matched Made-for-Kids toddler/nursery rhyme lexicon."
135
+ )
136
+ return {
137
+ "category": "MADE_FOR_KIDS",
138
+ "confidence": 0.95 if young else 0.92,
139
+ "reason": reason,
140
+ "is_young_kids": young,
141
+ "granular_sub_category": sub_cat,
142
+ }
143
+
144
+ # Rule 2: PASSIVE_AUDIO_MUSIC
145
+ if is_passive_music_text(channel_title):
146
+ return {
147
+ "category": "PASSIVE_AUDIO_MUSIC",
148
+ "confidence": 0.90,
149
+ "reason": "Matched passive audio/background music streams.",
150
+ "is_young_kids": False,
151
+ "granular_sub_category": "PASSIVE_AUDIO_STREAM",
152
+ }
153
+
154
+ # Rule 3: LOW_AGE_GAMING
155
+ if is_low_age_gaming_text(combined_text):
156
+ return {
157
+ "category": "LOW_AGE_GAMING",
158
+ "confidence": 0.85,
159
+ "reason": "Matched underage gaming franchise lexicon (Roblox/Minecraft/etc.).",
160
+ "is_young_kids": False,
161
+ "granular_sub_category": "LOW_AGE_GAMING_SINK",
162
+ }
163
+
164
+ # Rule 4: FOREIGN_SCRIPT_LEAK
165
+ ratio = compute_non_latin_ratio(channel_title)
166
+ if ratio >= 0.25:
167
+ return {
168
+ "category": "FOREIGN_SCRIPT_LEAK",
169
+ "confidence": 0.95,
170
+ "reason": f"Non-Latin script ratio {ratio:.1%} in title (Cyrillic/Arabic/Asian scripts).",
171
+ "is_young_kids": False,
172
+ "granular_sub_category": "GEO_LANGUAGE_LEAK",
173
+ }
174
+
175
+ # Rule 5: AI_SLOP_CLICK_FARM
176
+ if is_ai_slop_text(combined_text) and conversions == 0:
177
+ return {
178
+ "category": "AI_SLOP_CLICK_FARM",
179
+ "confidence": 0.82,
180
+ "reason": "Matched faceless AI content lexicon with 0 conversions.",
181
+ "is_young_kids": False,
182
+ "granular_sub_category": "AI_SLOP_FARM",
183
+ }
184
+
185
+ # Rule 6: PERFORMANCE_SINKHOLE
186
+ vtr = (views / max(impressions, 1)) if impressions > 0 else 0.0
187
+ if (
188
+ target_cpa is not None
189
+ and target_cpa > 0
190
+ and cost >= 3.0 * target_cpa
191
+ and conversions == 0
192
+ and vtr < 0.15
193
+ ):
194
+ return {
195
+ "category": "PERFORMANCE_SINKHOLE",
196
+ "confidence": 0.80,
197
+ "reason": f"Placement spend (€{cost:.2f}) >= 3x target CPA (€{target_cpa:.2f}) with 0 conv and VTR {vtr:.1%}.",
198
+ "is_young_kids": False,
199
+ "granular_sub_category": "HIGH_CPA_SINKHOLE",
200
+ }
201
+
202
+ return {
203
+ "category": "CLEAN",
204
+ "confidence": 0.0,
205
+ "reason": "No waste patterns detected.",
206
+ "is_young_kids": False,
207
+ "granular_sub_category": "CLEAN_AUDIENCE",
208
+ }
209
+
210
+
211
+ def evaluate_heuristics(
212
+ channel_title: str,
213
+ raw_placement: str = "",
214
+ impressions: int = 0,
215
+ views: int = 0,
216
+ clicks: int = 0,
217
+ cost: float = 0.0,
218
+ conversions: float = 0.0,
219
+ target_languages: list[str] | None = None,
220
+ target_cpa: float | None = None,
221
+ ) -> tuple[str, float, str]:
222
+ """Applies open-core deterministic heuristic rules to evaluate placement waste.
223
+
224
+ Returns:
225
+ tuple[waste_category, confidence, reason_summary]
226
+ """
227
+ res = evaluate_heuristics_detailed(
228
+ channel_title=channel_title,
229
+ raw_placement=raw_placement,
230
+ impressions=impressions,
231
+ views=views,
232
+ clicks=clicks,
233
+ cost=cost,
234
+ conversions=conversions,
235
+ target_languages=target_languages,
236
+ target_cpa=target_cpa,
237
+ )
238
+ return (res["category"], res["confidence"], res["reason"])