|
|
""" |
|
|
Angle × Concept Matrix Service |
|
|
|
|
|
Implements the scaling formula: |
|
|
1 Offer → 5-8 Angles → 3-5 Concepts per angle → Kill fast, scale hard |
|
|
|
|
|
This creates systematic ad testing by generating all possible |
|
|
angle × concept combinations with compatibility scoring. |
|
|
""" |
|
|
|
|
|
import os |
|
|
import sys |
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
|
|
|
|
|
from typing import Dict, List, Any, Optional |
|
|
import random |
|
|
from data.angles import ( |
|
|
get_all_angles, |
|
|
get_random_angles, |
|
|
get_angles_for_niche, |
|
|
get_top_angles, |
|
|
get_angle_by_key, |
|
|
AngleCategory, |
|
|
) |
|
|
try: |
|
|
from data.ecom_verticals import get_random_vertical, get_angle_keys_for_vertical |
|
|
except ImportError: |
|
|
get_random_vertical = None |
|
|
get_angle_keys_for_vertical = None |
|
|
from data.concepts import ( |
|
|
get_all_concepts, |
|
|
get_random_concepts, |
|
|
get_top_concepts, |
|
|
get_concept_by_key, |
|
|
get_compatible_concepts, |
|
|
ConceptCategory, |
|
|
) |
|
|
|
|
|
|
|
|
class AngleConceptMatrix: |
|
|
""" |
|
|
Service for generating angle × concept combinations. |
|
|
|
|
|
Implements the scaling formula: |
|
|
- Initial testing: 6 angles × 5 concepts = 30 ad variations |
|
|
- Scale winners: 3 winning angles × 5-10 new concepts |
|
|
""" |
|
|
|
|
|
def __init__(self): |
|
|
"""Initialize with all angles and concepts.""" |
|
|
self.all_angles = get_all_angles() |
|
|
self.all_concepts = get_all_concepts() |
|
|
|
|
|
def generate_testing_matrix( |
|
|
self, |
|
|
niche: Optional[str] = None, |
|
|
angle_count: int = 6, |
|
|
concept_count: int = 5, |
|
|
strategy: str = "balanced", |
|
|
unrestricted: bool = True, |
|
|
) -> List[Dict[str, Any]]: |
|
|
""" |
|
|
Generate initial testing matrix. |
|
|
|
|
|
Default: 6 angles × 5 concepts = 30 combinations |
|
|
|
|
|
Args: |
|
|
niche: Target niche for filtering (ignored for angles when unrestricted=True) |
|
|
angle_count: Number of angles to test |
|
|
concept_count: Number of concepts per angle |
|
|
strategy: Selection strategy (balanced, top_performers, diverse) |
|
|
unrestricted: If True, use full angle pool (all angles) for maximum diversity |
|
|
Returns: |
|
|
List of angle × concept combinations |
|
|
""" |
|
|
|
|
|
if unrestricted: |
|
|
if strategy == "top_performers": |
|
|
angles = get_top_angles()[:angle_count] |
|
|
else: |
|
|
angles = get_random_angles(angle_count, diverse=True) |
|
|
if len(angles) < angle_count: |
|
|
angles.extend(get_random_angles(angle_count - len(angles), diverse=True)) |
|
|
elif strategy == "top_performers": |
|
|
angles = get_top_angles()[:angle_count] |
|
|
elif strategy == "diverse": |
|
|
angles = get_random_angles(angle_count, diverse=True) |
|
|
elif niche: |
|
|
angles = get_angles_for_niche(niche)[:angle_count] |
|
|
if len(angles) < angle_count: |
|
|
extra = get_random_angles(angle_count - len(angles), diverse=True) |
|
|
angles.extend(extra) |
|
|
else: |
|
|
top = get_top_angles()[:angle_count // 2] |
|
|
diverse = get_random_angles(angle_count - len(top), diverse=True) |
|
|
angles = top + diverse |
|
|
|
|
|
|
|
|
if strategy == "top_performers": |
|
|
concepts = get_top_concepts() |
|
|
if len(concepts) < concept_count: |
|
|
concepts.extend(get_random_concepts(concept_count - len(concepts))) |
|
|
else: |
|
|
concepts = get_random_concepts(concept_count, diverse=True) |
|
|
|
|
|
|
|
|
combinations = [] |
|
|
for angle in angles[:angle_count]: |
|
|
for concept in concepts[:concept_count]: |
|
|
combo = self._create_combination(angle, concept) |
|
|
combinations.append(combo) |
|
|
|
|
|
return combinations |
|
|
|
|
|
def generate_scaling_matrix( |
|
|
self, |
|
|
winning_angle_keys: List[str], |
|
|
concept_count: int = 5 |
|
|
) -> List[Dict[str, Any]]: |
|
|
""" |
|
|
Generate scaling matrix for winning angles. |
|
|
|
|
|
After initial testing, scale the winning angles with new concepts. |
|
|
|
|
|
Args: |
|
|
winning_angle_keys: List of winning angle keys |
|
|
concept_count: Number of new concepts per angle |
|
|
|
|
|
Returns: |
|
|
List of angle × concept combinations for scaling |
|
|
""" |
|
|
combinations = [] |
|
|
|
|
|
for angle_key in winning_angle_keys: |
|
|
angle = get_angle_by_key(angle_key) |
|
|
if not angle: |
|
|
continue |
|
|
|
|
|
|
|
|
trigger = angle.get("trigger", "") |
|
|
compatible = get_compatible_concepts(trigger) |
|
|
|
|
|
|
|
|
if len(compatible) < concept_count: |
|
|
extra = get_random_concepts(concept_count - len(compatible), diverse=True) |
|
|
compatible.extend(extra) |
|
|
|
|
|
|
|
|
for concept in compatible[:concept_count]: |
|
|
combo = self._create_combination(angle, concept) |
|
|
combinations.append(combo) |
|
|
|
|
|
return combinations |
|
|
|
|
|
def generate_single_combination( |
|
|
self, |
|
|
niche: Optional[str] = None, |
|
|
unrestricted: bool = True, |
|
|
) -> Dict[str, Any]: |
|
|
""" |
|
|
Generate a single random angle × concept combination. |
|
|
|
|
|
Good for generating one-off ads with variety. |
|
|
When unrestricted=True (default), use full angle and concept pool for maximum diversity. |
|
|
When unrestricted=False and niche provided, filter angles by niche. |
|
|
""" |
|
|
|
|
|
if unrestricted or not niche: |
|
|
if get_random_vertical and get_angle_keys_for_vertical and random.random() < 0.4: |
|
|
v = get_random_vertical() |
|
|
keys = get_angle_keys_for_vertical(v.get("key", "")) |
|
|
vertical_angles = [get_angle_by_key(k) for k in keys if get_angle_by_key(k)] |
|
|
angle = random.choice(vertical_angles) if vertical_angles else random.choice(self.all_angles) |
|
|
else: |
|
|
angle = random.choice(self.all_angles) |
|
|
else: |
|
|
angles = get_angles_for_niche(niche) |
|
|
angle = random.choice(angles) if angles else random.choice(self.all_angles) |
|
|
|
|
|
|
|
|
if unrestricted: |
|
|
concept = random.choice(self.all_concepts) |
|
|
else: |
|
|
trigger = angle.get("trigger", "") |
|
|
compatible = get_compatible_concepts(trigger) |
|
|
if compatible: |
|
|
concept = random.choice(compatible) |
|
|
else: |
|
|
concept = random.choice(self.all_concepts) |
|
|
|
|
|
return self._create_combination(angle, concept) |
|
|
|
|
|
def generate_all_permutations( |
|
|
self, |
|
|
angle_keys: Optional[List[str]] = None, |
|
|
concept_keys: Optional[List[str]] = None, |
|
|
max_combinations: int = 100 |
|
|
) -> List[Dict[str, Any]]: |
|
|
""" |
|
|
Generate all possible permutations. |
|
|
|
|
|
100 angles × 100 concepts = 10,000 possible combinations. |
|
|
Limited by max_combinations for performance. |
|
|
""" |
|
|
|
|
|
if angle_keys: |
|
|
angles = [get_angle_by_key(k) for k in angle_keys if get_angle_by_key(k)] |
|
|
else: |
|
|
angles = self.all_angles |
|
|
|
|
|
|
|
|
if concept_keys: |
|
|
concepts = [get_concept_by_key(k) for k in concept_keys if get_concept_by_key(k)] |
|
|
else: |
|
|
concepts = self.all_concepts |
|
|
|
|
|
|
|
|
combinations = [] |
|
|
for angle in angles: |
|
|
for concept in concepts: |
|
|
if len(combinations) >= max_combinations: |
|
|
break |
|
|
combo = self._create_combination(angle, concept) |
|
|
combinations.append(combo) |
|
|
if len(combinations) >= max_combinations: |
|
|
break |
|
|
|
|
|
return combinations |
|
|
|
|
|
def _create_combination( |
|
|
self, |
|
|
angle: Dict[str, Any], |
|
|
concept: Dict[str, Any] |
|
|
) -> Dict[str, Any]: |
|
|
"""Create an angle × concept combination with metadata.""" |
|
|
compatibility = self._calculate_compatibility(angle, concept) |
|
|
|
|
|
return { |
|
|
"combination_id": f"{angle.get('key')}_{concept.get('key')}", |
|
|
"angle": { |
|
|
"key": angle.get("key"), |
|
|
"name": angle.get("name"), |
|
|
"trigger": angle.get("trigger"), |
|
|
"example": angle.get("example"), |
|
|
"category": angle.get("category"), |
|
|
}, |
|
|
"concept": { |
|
|
"key": concept.get("key"), |
|
|
"name": concept.get("name"), |
|
|
"structure": concept.get("structure"), |
|
|
"visual": concept.get("visual"), |
|
|
"category": concept.get("category"), |
|
|
}, |
|
|
"compatibility_score": compatibility, |
|
|
"prompt_guidance": self._build_prompt_guidance(angle, concept), |
|
|
} |
|
|
|
|
|
def _calculate_compatibility( |
|
|
self, |
|
|
angle: Dict[str, Any], |
|
|
concept: Dict[str, Any] |
|
|
) -> float: |
|
|
""" |
|
|
Calculate compatibility score between angle and concept. |
|
|
|
|
|
Higher score = better match. |
|
|
""" |
|
|
score = 0.5 |
|
|
|
|
|
|
|
|
trigger = angle.get("trigger", "") |
|
|
compatible_concepts = get_compatible_concepts(trigger) |
|
|
if any(c.get("key") == concept.get("key") for c in compatible_concepts): |
|
|
score += 0.3 |
|
|
|
|
|
|
|
|
angle_cat = angle.get("category_key") |
|
|
concept_cat = concept.get("category_key") |
|
|
|
|
|
|
|
|
good_pairs = [ |
|
|
(AngleCategory.FINANCIAL, ConceptCategory.COMPARISON), |
|
|
(AngleCategory.EMOTIONAL, ConceptCategory.STORYTELLING), |
|
|
(AngleCategory.SOCIAL_PROOF, ConceptCategory.SOCIAL_PROOF), |
|
|
(AngleCategory.AUTHORITY, ConceptCategory.AUTHORITY), |
|
|
(AngleCategory.URGENCY, ConceptCategory.SCROLL_STOPPING), |
|
|
(AngleCategory.CURIOSITY, ConceptCategory.SCROLL_STOPPING), |
|
|
(AngleCategory.CONVENIENCE, ConceptCategory.EDUCATIONAL), |
|
|
(AngleCategory.PROBLEM_SOLUTION, ConceptCategory.STORYTELLING), |
|
|
] |
|
|
|
|
|
if (angle_cat, concept_cat) in good_pairs: |
|
|
score += 0.2 |
|
|
|
|
|
return min(score, 1.0) |
|
|
|
|
|
def _build_prompt_guidance( |
|
|
self, |
|
|
angle: Dict[str, Any], |
|
|
concept: Dict[str, Any] |
|
|
) -> str: |
|
|
"""Build prompt guidance for ad generation.""" |
|
|
return f""" |
|
|
ANGLE: {angle.get('name')} |
|
|
- Psychological trigger: {angle.get('trigger')} |
|
|
- Example hook: "{angle.get('example')}" |
|
|
- Why it works: Appeals to {angle.get('trigger').lower()} |
|
|
|
|
|
CONCEPT: {concept.get('name')} |
|
|
- Visual structure: {concept.get('structure')} |
|
|
- Visual guidance: {concept.get('visual')} |
|
|
|
|
|
COMBINED APPROACH: |
|
|
Create an ad that uses the "{angle.get('name')}" angle with a "{concept.get('name')}" visual concept. |
|
|
The headline should trigger {angle.get('trigger').lower()} while the image follows the {concept.get('structure').lower()} structure. |
|
|
""".strip() |
|
|
|
|
|
def get_matrix_summary( |
|
|
self, |
|
|
combinations: List[Dict[str, Any]] |
|
|
) -> Dict[str, Any]: |
|
|
"""Get summary statistics for a matrix.""" |
|
|
if not combinations: |
|
|
return { |
|
|
"total_combinations": 0, |
|
|
"unique_angles": 0, |
|
|
"unique_concepts": 0, |
|
|
"average_compatibility": 0.0, |
|
|
} |
|
|
|
|
|
unique_angles = set(c["angle"]["key"] for c in combinations) |
|
|
unique_concepts = set(c["concept"]["key"] for c in combinations) |
|
|
avg_compat = sum(c.get("compatibility_score", 0) for c in combinations) / len(combinations) |
|
|
|
|
|
return { |
|
|
"total_combinations": len(combinations), |
|
|
"unique_angles": len(unique_angles), |
|
|
"unique_concepts": len(unique_concepts), |
|
|
"average_compatibility": round(avg_compat, 2), |
|
|
"angles_used": list(unique_angles), |
|
|
"concepts_used": list(unique_concepts), |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
matrix_service = AngleConceptMatrix() |
|
|
|
|
|
|