""" 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 """ # Select angles: when unrestricted, use full/diverse pool; otherwise niche-filtered 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 # Select concepts 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) # Generate combinations 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 # Get compatible concepts based on psychological trigger trigger = angle.get("trigger", "") compatible = get_compatible_concepts(trigger) # If not enough compatible, add diverse ones if len(compatible) < concept_count: extra = get_random_concepts(concept_count - len(compatible), diverse=True) compatible.extend(extra) # Create combinations 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. """ # Get random angle: unrestricted = all angles or vertical-biased for variety; otherwise niche-filtered 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) # Get concept: unrestricted = any concept for max diversity; otherwise trigger-compatible 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. """ # Get angles 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 # Get concepts 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 # Generate combinations 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 # Base score # Check trigger-concept compatibility 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 # Check category compatibility angle_cat = angle.get("category_key") concept_cat = concept.get("category_key") # Good pairs 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), } # Global instance matrix_service = AngleConceptMatrix()