├── __init__.py ├── utils ├── __init__.py └── cli.py ├── subagents ├── __init__.py ├── brief_ingestion_agent.py ├── copy_agent.py └── image_agent.py ├── scripts └── cli_run.sh ├── requirements.txt ├── inputs ├── .DS_Store ├── assets │ ├── .DS_Store │ ├── naturaglow_wood_polish │ │ ├── .DS_Store │ │ ├── mascot.png │ │ └── product.png │ └── purepath_floor_wash │ │ ├── mascot.png │ │ └── product.png ├── brand │ ├── rapidclean_logo.png │ └── rapidclean_logo.svg └── briefs │ └── awareness_rapidclean_westcoast.json ├── outputs └── awareness_campaign │ └── v1 │ ├── .DS_Store │ ├── purepath │ ├── 1x1_awareness.png │ ├── 16x9_awareness.png │ ├── 9x16_awareness.png │ └── copy.json │ └── naturaglow │ ├── 1x1_awareness.png │ ├── 16x9_awareness.png │ ├── 9x16_awareness.png │ └── copy.json ├── .gitignore ├── main.py └── README.md /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /subagents/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/cli_run.sh: -------------------------------------------------------------------------------- 1 | python -m scaled_content_agent.utils.cli -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | google-adk 2 | google-generativeai 3 | python-dotenv 4 | Pillow -------------------------------------------------------------------------------- /inputs/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runtime/scaled_content_agent/master/inputs/.DS_Store -------------------------------------------------------------------------------- /inputs/assets/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runtime/scaled_content_agent/master/inputs/assets/.DS_Store -------------------------------------------------------------------------------- /inputs/brand/rapidclean_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runtime/scaled_content_agent/master/inputs/brand/rapidclean_logo.png -------------------------------------------------------------------------------- /outputs/awareness_campaign/v1/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runtime/scaled_content_agent/master/outputs/awareness_campaign/v1/.DS_Store -------------------------------------------------------------------------------- /inputs/assets/naturaglow_wood_polish/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runtime/scaled_content_agent/master/inputs/assets/naturaglow_wood_polish/.DS_Store -------------------------------------------------------------------------------- /inputs/assets/purepath_floor_wash/mascot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runtime/scaled_content_agent/master/inputs/assets/purepath_floor_wash/mascot.png -------------------------------------------------------------------------------- /inputs/assets/purepath_floor_wash/product.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runtime/scaled_content_agent/master/inputs/assets/purepath_floor_wash/product.png -------------------------------------------------------------------------------- /inputs/assets/naturaglow_wood_polish/mascot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runtime/scaled_content_agent/master/inputs/assets/naturaglow_wood_polish/mascot.png -------------------------------------------------------------------------------- /inputs/assets/naturaglow_wood_polish/product.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runtime/scaled_content_agent/master/inputs/assets/naturaglow_wood_polish/product.png -------------------------------------------------------------------------------- /outputs/awareness_campaign/v1/purepath/1x1_awareness.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runtime/scaled_content_agent/master/outputs/awareness_campaign/v1/purepath/1x1_awareness.png -------------------------------------------------------------------------------- /outputs/awareness_campaign/v1/naturaglow/1x1_awareness.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runtime/scaled_content_agent/master/outputs/awareness_campaign/v1/naturaglow/1x1_awareness.png -------------------------------------------------------------------------------- /outputs/awareness_campaign/v1/purepath/16x9_awareness.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runtime/scaled_content_agent/master/outputs/awareness_campaign/v1/purepath/16x9_awareness.png -------------------------------------------------------------------------------- /outputs/awareness_campaign/v1/purepath/9x16_awareness.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runtime/scaled_content_agent/master/outputs/awareness_campaign/v1/purepath/9x16_awareness.png -------------------------------------------------------------------------------- /outputs/awareness_campaign/v1/naturaglow/16x9_awareness.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runtime/scaled_content_agent/master/outputs/awareness_campaign/v1/naturaglow/16x9_awareness.png -------------------------------------------------------------------------------- /outputs/awareness_campaign/v1/naturaglow/9x16_awareness.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/runtime/scaled_content_agent/master/outputs/awareness_campaign/v1/naturaglow/9x16_awareness.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Environments 2 | .env 3 | .env.* 4 | !example.env 5 | 6 | # Python & cache 7 | __pycache__/ 8 | *.pyc 9 | .cache/ 10 | .venv/ 11 | 12 | 13 | # IDE 14 | .idea/ 15 | .vscode/ 16 | 17 | # mac 18 | .DS_Store 19 | 20 | -------------------------------------------------------------------------------- /outputs/awareness_campaign/v1/naturaglow/copy.json: -------------------------------------------------------------------------------- 1 | { 2 | "campaignName": "RapidClean Eco Awareness - West Coast", 3 | "objective": "eco_friendly_awareness", 4 | "targetRegion": "US-West", 5 | "targetAudience": "Young Urban Professionals", 6 | "productId": "naturaglow-wood-polish", 7 | "productName": "NaturaGlow Wood Polish", 8 | "headline": "Shine Your Wood, Not the Planet.", 9 | "body": "Transform your wood surfaces with NaturaGlow! ✨ Get that premium, low-sheen finish you love, without any harsh chemicals. Safe for pets, kids, and your peace of mind. Clean consciously!", 10 | "disclaimer": "Always test in an inconspicuous area. Visit our website for full product usage and safety guidelines." 11 | } -------------------------------------------------------------------------------- /outputs/awareness_campaign/v1/purepath/copy.json: -------------------------------------------------------------------------------- 1 | { 2 | "campaignName": "RapidClean Eco Awareness - West Coast", 3 | "objective": "eco_friendly_awareness", 4 | "targetRegion": "US-West", 5 | "targetAudience": "Young Urban Professionals", 6 | "productId": "purepath-floor-wash", 7 | "productName": "PurePath Floor Wash", 8 | "headline": "PurePath: Naturally Clean Floors, Happy Home!", 9 | "body": "Elevate your West Coast home with PurePath Floor Wash! ✨ This plant-based formula gives you a brilliant, streak-free shine, totally safe for kids and pets. Enjoy a fresh, clean space that aligns with your modern, eco-conscious lifestyle.", 10 | "disclaimer": "While formulated for safety, always refer to product label for full usage instructions. For best results, keep out of reach of children and pets when not actively in use." 11 | } -------------------------------------------------------------------------------- /utils/cli.py: -------------------------------------------------------------------------------- 1 | # scaled_content_agent/utils/cli.py 2 | # python lib to create custom cli args 3 | import argparse 4 | from pathlib import Path 5 | # root agent import 6 | from ..main import Orchestrator 7 | 8 | 9 | def parse_args(): 10 | # usse an instance of the argument parser 11 | parser = argparse.ArgumentParser( 12 | description="RapidClean POC – scaled content generator" 13 | ) 14 | # add arguments (flags) 15 | # one for the brief ingestion 16 | parser.add_argument( 17 | "--brief", 18 | type=str, 19 | default="inputs/briefs/awareness_rapidclean_westcoast.json", 20 | help="Path to the brief JSON (relative to scaled_content_agent/).", 21 | ) 22 | # one for the output root 23 | parser.add_argument( 24 | "--output-root", 25 | type=str, 26 | default="outputs/awareness_campaign/v1", 27 | help="Root output directory for generated render files.", 28 | ) 29 | # one for seeds so we can edit creative later without the agents re-doing every part of it. 30 | parser.add_argument( 31 | "--seed", 32 | type=int, 33 | default=None, 34 | help="Optional Imagen seed for deterministic results.", 35 | ) 36 | 37 | return parser.parse_args() 38 | 39 | 40 | def main(): 41 | args = parse_args() 42 | 43 | # project root = scaled_content_agent/ 44 | project_root = Path(__file__).resolve().parents[1] 45 | # create an instance of the root_agent 46 | orchestrator = Orchestrator(project_root=project_root) 47 | # run the initial method from the model, pass in the path of the brief and the output folder, seed is optional 48 | orchestrator.run_ingestion_and_prepare_outputs( 49 | brief_path=args.brief, 50 | output_root=args.output_root, 51 | seed=args.seed, 52 | ) 53 | 54 | 55 | if __name__ == "__main__": 56 | main() 57 | -------------------------------------------------------------------------------- /inputs/briefs/awareness_rapidclean_westcoast.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "campaign": { 4 | "name": "RapidClean Eco Awareness - West Coast", 5 | "objective": "eco_friendly_awareness", 6 | "kpi": { 7 | "primary": "Increase awareness of eco-friendly cleaning products on the US West Coast", 8 | "secondary_conversion": "Clicks on the legal/terms page from the ad landing page" 9 | }, 10 | "targetRegion": "US-West", 11 | "targetAudience": { 12 | "personaId": "young-urban-professionals", 13 | "label": "Young Urban Professionals", 14 | "description": "Eco-conscious professionals in West Coast cities who want stylish, environmentally friendly products that fit their modern lifestyles.", 15 | "channels": [ 16 | "instagram_feed", 17 | "instagram_stories", 18 | "facebook_feed" 19 | ] 20 | }, 21 | "campaignMessage": "Eco-friendly cleaning that clears your space and your mind." 22 | }, 23 | "brand": { 24 | "name": "RapidClean", 25 | "logoPath": "inputs/brand/rapidclean_logo.png", 26 | "primaryColor": "#3366cc", 27 | "secondaryColor": "#86b95a", 28 | "voice": "Confident, friendly, practical. Always connect a clean home with mental clarity and reduced stress.", 29 | "tagline": "Clean Home = Positive Mental Health", 30 | "useMascotByDefault": false 31 | }, 32 | "products": [ 33 | { 34 | "id": "purepath-floor-wash", 35 | "name": "PurePath Floor Wash", 36 | "type": "eco_floor_cleaner", 37 | "description": "A gentle, plant-based floor cleaner that leaves surfaces spotless and safe for kids and pets.", 38 | "benefits": [ 39 | "Plant-based formula", 40 | "Low-odor, fresh scent", 41 | "Safe for kids and pets", 42 | "Streak-free shine" 43 | ], 44 | "aromatherapyProfile": [ 45 | "Calming eucalyptus", 46 | "Crisp citrus" 47 | ], 48 | "assetFolder": "inputs/assets/purepath_floor_wash", 49 | "preferredScenes": [ 50 | "Bright, daylight living room with shiny hardwood floors", 51 | "Modern apartment with large windows and plants" 52 | ] 53 | }, 54 | { 55 | "id": "naturaglow-wood-polish", 56 | "name": "NaturaGlow Wood Polish", 57 | "type": "gentle_wood_polish", 58 | "description": "An eco-conscious wood polish that enhances the natural beauty of wood surfaces without harsh chemicals.", 59 | "benefits": [ 60 | "Enhances natural wood grain", 61 | "No harsh chemicals", 62 | "Safe for homes with pets and kids", 63 | "Low-sheen, premium finish" 64 | ], 65 | "aromatherapyProfile": [ 66 | "Warm sandalwood", 67 | "Soft vanilla" 68 | ], 69 | "assetFolder": "inputs/assets/naturaglow_wood_polish", 70 | "preferredScenes": [ 71 | "Cozy living room with wooden coffee table", 72 | "Minimalist home office with wood desk and soft daylight" 73 | ] 74 | } 75 | ], 76 | "creativeGuidelines": { 77 | "aspectRatios": [ 78 | "1:1", 79 | "9:16", 80 | "16:9" 81 | ], 82 | "messageLanguage": "en-US", 83 | "copyTone": "Encouraging, non-judgmental, lightly humorous.", 84 | "headlineMaxChars": 40, 85 | "bodyMaxChars": 120, 86 | "layoutHints": { 87 | "logoPlacement": "top-left or bottom-left with clear space", 88 | "safeTextZones": "Avoid extreme edges; keep headline in upper third, body in lower third", 89 | "useMascotWhen": "Optional; for more playful variants or stories rather than core awareness posts." 90 | } 91 | }, 92 | "compliance": { 93 | "requiredPhrases": [ 94 | "RapidClean™" 95 | ], 96 | "bannedPhrases": [ 97 | "miracle", 98 | "guaranteed", 99 | "dull", 100 | "boring", 101 | "shiny" 102 | ], 103 | "disclaimer": "Results may vary by home condition and how often you clean.", 104 | "legalLandingUrl": "https://rapidclean.ninja/legal" 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /subagents/brief_ingestion_agent.py: -------------------------------------------------------------------------------- 1 | # scaled_content_agent/subagents/brief_ingestion_agent.py 2 | 3 | from pathlib import Path 4 | import json 5 | 6 | # create config object schemas 7 | class ProductConfig: 8 | def __init__(self, id, slug, name, description, asset_folder, benefits): 9 | self.id = id 10 | self.slug = slug 11 | self.name = name 12 | self.description = description 13 | self.asset_folder = asset_folder 14 | self.benefits = benefits 15 | 16 | 17 | class CampaignConfig: 18 | def __init__( 19 | self, 20 | name, 21 | objective, 22 | kpi_primary, 23 | kpi_secondary, 24 | target_region, 25 | target_audience_label, 26 | target_audience_desc, 27 | campaign_message, 28 | brand_name, 29 | brand_logo_path, 30 | primary_color, 31 | secondary_color, 32 | products, 33 | legal_disclaimer="", 34 | ): 35 | self.name = name 36 | self.objective = objective 37 | self.kpi_primary = kpi_primary 38 | self.kpi_secondary = kpi_secondary 39 | self.target_region = target_region 40 | self.target_audience_label = target_audience_label 41 | self.target_audience_desc = target_audience_desc 42 | self.campaign_message = campaign_message 43 | self.brand_name = brand_name 44 | self.brand_logo_path = brand_logo_path 45 | self.primary_color = primary_color 46 | self.secondary_color = secondary_color 47 | self.products = products 48 | self.legal_disclaimer = legal_disclaimer 49 | 50 | 51 | # create agent to ingest brief and create configs using schema 52 | 53 | class BriefIngestionAgent: 54 | """ 55 | Read the brief JSON and turn it into simple Python objects 56 | (CampaignConfig + ProductConfig list). 57 | access with cfg. syntax in the main.py 58 | """ 59 | 60 | def __init__(self, project_root): 61 | # project_root will be scaled_content_agent/ 62 | self.project_root = Path(project_root) 63 | 64 | def _load_json(self, path): 65 | with Path(path).open("r", encoding="utf-8") as f: 66 | return json.load(f) 67 | 68 | def _slug_from_product_id(self, product_id): 69 | # "purepath-floor-wash" -> "purepath" 70 | return product_id.split("-")[0] 71 | 72 | def ingest(self, brief_path): 73 | data = self._load_json(brief_path) 74 | 75 | campaign = data["campaign"] 76 | brand = data["brand"] 77 | products_raw = data["products"] 78 | 79 | products = [] 80 | for p in products_raw: 81 | product_id = p["id"] 82 | slug = self._slug_from_product_id(product_id) 83 | asset_folder = self.project_root / p["assetFolder"] 84 | 85 | product = ProductConfig( 86 | id=product_id, 87 | slug=slug, 88 | name=p["name"], 89 | description=p.get("description", ""), 90 | asset_folder=asset_folder, 91 | benefits=p.get("benefits", []), 92 | ) 93 | products.append(product) 94 | 95 | target_audience = campaign["targetAudience"] 96 | brand_logo_path = self.project_root / brand["logoPath"] 97 | #legal_disclaimer = campaign["legalDisclaimer"] 98 | legal_disclaimer = campaign.get("legalDisclaimer", "") 99 | 100 | # set object using keys 101 | # todo implement pyndantic for schema layer for missing key errors 102 | config = CampaignConfig( 103 | name=campaign["name"], 104 | objective=campaign.get("objective", ""), 105 | kpi_primary=campaign["kpi"]["primary"], 106 | kpi_secondary=campaign["kpi"]["secondary_conversion"], 107 | target_region=campaign["targetRegion"], 108 | target_audience_label=target_audience["label"], 109 | target_audience_desc=target_audience["description"], 110 | campaign_message=campaign["campaignMessage"], 111 | brand_name=brand["name"], 112 | brand_logo_path=brand_logo_path, 113 | primary_color=brand["primaryColor"], 114 | secondary_color=brand["secondaryColor"], 115 | products=products, 116 | legal_disclaimer=legal_disclaimer, 117 | ) 118 | # return a data object to be accessed via [.] dot syntax ie cfg.legal_disclaimer easier to read and use 119 | return config 120 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # scaled_content_agent/main.py 2 | 3 | from pathlib import Path 4 | 5 | from .subagents.brief_ingestion_agent import BriefIngestionAgent 6 | from .subagents.copy_agent import CopywritingAgent 7 | from .subagents.image_agent import ImageGenerationAgent 8 | 9 | 10 | class Orchestrator: 11 | """ 12 | v1 Orchestrator 13 | Main is a Single Process, Single Run, batch CLI 14 | no concurrency, or long-lived state for simplicity 15 | ---------------- 16 | Responsibilities: 17 | 1. Load the brief 18 | 2. Build campaign + product config 19 | 3. Create product output folders 20 | 4. Generate copy.json per product 21 | 5. Generate hero images + composite local assets 22 | 6. Print a summary 23 | 24 | """ 25 | 26 | def __init__(self, project_root=None): 27 | # project_root should resolve to scaled_content_agent/ 28 | if project_root is None: 29 | self.project_root = Path(__file__).resolve().parent 30 | else: 31 | self.project_root = Path(project_root) 32 | 33 | # Instantiate tool and llm subagents 34 | self.brief_agent = BriefIngestionAgent(project_root=self.project_root) 35 | self.copy_agent = CopywritingAgent() 36 | self.image_agent = ImageGenerationAgent() 37 | 38 | def run_ingestion_and_prepare_outputs(self, brief_path, output_root, seed=None): 39 | """ 40 | Main entrypoint called by the CLI. 41 | kicks off the chain 42 | """ 43 | 44 | brief_path = Path(brief_path) 45 | output_root = Path(output_root) 46 | 47 | # Resolve relative paths to scaled_content_agent/ 48 | if not brief_path.is_absolute(): 49 | brief_path = self.project_root / brief_path 50 | 51 | if not output_root.is_absolute(): 52 | output_root = self.project_root / output_root 53 | 54 | # 1. BRIEF AGENT: Ingest brief → CampaignConfig + ProductConfigs 55 | campaign_cfg = self.brief_agent.ingest(brief_path) 56 | 57 | # 2. Create per-product output folders 58 | for product in campaign_cfg.products: 59 | product_dir = output_root / product.slug 60 | product_dir.mkdir(parents=True, exist_ok=True) 61 | 62 | # 3. COPY AGENT: Generate copy.json per product 63 | self.copy_agent.generate_copy_for_products( 64 | campaign_cfg=campaign_cfg, 65 | output_root=output_root, 66 | ) 67 | 68 | # 4. IMAGE AGENT: Generate hero images + composite assets 69 | self.image_agent.generate_images_for_products( 70 | campaign_cfg=campaign_cfg, 71 | output_root=output_root, 72 | seed=seed, 73 | ) 74 | 75 | # 5. ROOT AGENT (self) Summary using print statements for convenience 76 | self._print_summary(campaign_cfg, output_root) 77 | 78 | return campaign_cfg 79 | 80 | def _print_summary(self, cfg, output_root): 81 | print("\n=== RapidClean POC – Brief + Copy + Images Complete ===\n") 82 | print(f"Project root: {self.project_root}") 83 | print(f"Output root: {output_root}") 84 | print() 85 | 86 | print(f"Campaign: {cfg.name}") 87 | print(f"Objective: {cfg.objective}") 88 | print(f"KPI (primary): {cfg.kpi_primary}") 89 | print(f"KPI (secondary): {cfg.kpi_secondary}") 90 | print() 91 | 92 | print(f"Region: {cfg.target_region}") 93 | print(f"Audience: {cfg.target_audience_label}") 94 | print(f" → {cfg.target_audience_desc}") 95 | print() 96 | 97 | print("Brand:") 98 | print(f" Name: {cfg.brand_name}") 99 | print(f" Logo path: {cfg.brand_logo_path}") 100 | print(f" Colors: {cfg.primary_color}, {cfg.secondary_color}") 101 | print() 102 | 103 | print("Products:") 104 | for p in cfg.products: 105 | print(f" - {p.name} ({p.id})") 106 | print(f" slug: {p.slug}") 107 | print(f" assets: {p.asset_folder}") 108 | 109 | product_dir = output_root / p.slug 110 | print(f" copy: {product_dir / 'copy.json'}") 111 | print(" renders:") 112 | print(f" - {product_dir / '1x1_awareness.png'}") 113 | print(f" - {product_dir / '9x16_awareness.png'}") 114 | print(f" - {product_dir / '16x9_awareness.png'}") 115 | print(f" Legal disclaimer: {cfg.legal_disclaimer}") 116 | 117 | print("\nStatus: ✅ All outputs generated successfully.\n") 118 | -------------------------------------------------------------------------------- /subagents/copy_agent.py: -------------------------------------------------------------------------------- 1 | # scaled_content_agent/subagents/copy_agent.py 2 | 3 | import os 4 | import json 5 | from pathlib import Path 6 | # this is an agent and it uses genai. env needs to be set accordingly 7 | from google import genai 8 | 9 | 10 | class CopywritingAgent: 11 | # docstring is for AI to clarify purpose, output format and what the downstream agents depend on 12 | """ 13 | v1 GenAI Copy Agent 14 | ------------------- 15 | - Uses Gemini to generate: 16 | • headline 17 | • body 18 | • disclaimer 19 | - Writes copy.json per product: 20 | { 21 | "campaignName": ..., 22 | "objective": ..., 23 | "targetRegion": ..., 24 | "targetAudience": ..., 25 | "productId": ..., 26 | "productName": ..., 27 | "headline": ..., 28 | "body": ..., 29 | "disclaimer": ... 30 | } 31 | """ 32 | 33 | def __init__(self): 34 | # agent needs to own execution context so env vars need to be passed during CONSTRUCTION 35 | # agent can be configured once and reused against many calls. 36 | location = os.environ.get("GOOGLE_CLOUD_LOCATION", "us-central1") 37 | project_id = os.environ.get("GOOGLE_CLOUD_PROJECT", "adk-llm-agent") 38 | 39 | # create agent via genai.Client, pass in project id and location 40 | # agent needs your project id (with billing account) and location (default is global so use regional) 41 | self.client = genai.Client( 42 | vertexai=True, 43 | project=project_id, 44 | location=location, 45 | ) 46 | 47 | # public function called by ingestion agent 48 | # use the config to pass to genai as dynamic prompt 49 | # call the LLM for the copy 50 | # parse it 51 | # write it to the json file 52 | def generate_copy_for_products(self, campaign_cfg, output_root): 53 | output_root = Path(output_root) 54 | 55 | for product in campaign_cfg.products: 56 | product_dir = output_root / product.slug 57 | product_dir.mkdir(parents=True, exist_ok=True) 58 | 59 | # receive a tuple of headline body and legal 60 | headline, body, disclaimer = self._gen_copy_for_product( 61 | campaign_cfg, product 62 | ) 63 | 64 | copy_payload = { 65 | "campaignName": campaign_cfg.name, 66 | "objective": campaign_cfg.objective, 67 | "targetRegion": campaign_cfg.target_region, 68 | "targetAudience": campaign_cfg.target_audience_label, 69 | "productId": product.id, 70 | "productName": product.name, 71 | "headline": headline, 72 | "body": body, 73 | "disclaimer": disclaimer, 74 | } 75 | # save the copy to the local store 76 | # indent 2 = human readable 77 | # ascii = false ensures accents and emojis (localization things) 78 | copy_path = product_dir / "copy.json" 79 | try: 80 | with copy_path.open("w", encoding="utf-8") as f: 81 | json.dump(copy_payload, f, indent=2, ensure_ascii=False) 82 | print(f"✅ Wrote copy.json for {product.name} → {copy_path}") 83 | except OSError as e: 84 | print(f"⚠️ Failed to write copy file for {product.name}: {e}") 85 | 86 | # private method called by self returns a tuple 87 | def _gen_copy_for_product(self, campaign_cfg, product): 88 | """ 89 | Ask Gemini for structured ad copy. 90 | If anything fails, fall back to simple templates. 91 | """ 92 | # Default fallback strings (never leave blank) 93 | fallback_headline = f"Clear your space with {product.name}" 94 | fallback_body = ( 95 | f"{product.description or 'Eco-friendly cleaning made simple.'} " 96 | f"RapidClean™ helps you keep a calm, clutter-free home in {campaign_cfg.target_region}." 97 | ) 98 | fallback_disclaimer = ( 99 | campaign_cfg.legal_disclaimer if getattr(campaign_cfg, "legal_disclaimer", "") else 100 | "Read label for use instructions. Keep out of reach of children and pets." 101 | ) 102 | # some error handling on the benefits aka legal we want to inform people about 103 | try: 104 | benefits_text = ", ".join(product.benefits) if product.benefits else "" 105 | 106 | base_disclaimer = getattr(campaign_cfg, "legal_disclaimer", "") 107 | 108 | # prompt for the copy agent. campaign loaded dynamically for agent to work with 109 | prompt = f""" 110 | You are an ad copywriter for an eco-friendly cleaning brand called RapidClean. 111 | 112 | Write short, social-friendly ad copy for a single static image ad. 113 | Return ONLY valid JSON with the following keys: "headline", "body", "disclaimer". 114 | 115 | Constraints: 116 | - Headline: max 70 characters, punchy and positive. 117 | - Body: 2–3 short sentences, Instagram-caption style, friendly and practical. 118 | - Disclaimer: 1–2 short sentences of legal or safety language. If a base disclaimer is provided, 119 | incorporate or adapt it, but keep it concise. 120 | 121 | Campaign: 122 | - Name: {campaign_cfg.name} 123 | - Objective: {campaign_cfg.objective} 124 | - KPI primary: {campaign_cfg.kpi_primary} 125 | - KPI secondary: {campaign_cfg.kpi_secondary} 126 | - Target region: {campaign_cfg.target_region} 127 | - Target audience: {campaign_cfg.target_audience_label} — {campaign_cfg.target_audience_desc} 128 | - Brand mission: {campaign_cfg.campaign_message} 129 | 130 | Product: 131 | - Name: {product.name} 132 | - Description: {product.description} 133 | - Benefits: {benefits_text} 134 | 135 | Base legal disclaimer (optional): 136 | "{base_disclaimer}" 137 | """ 138 | 139 | # return the response 140 | response = self.client.models.generate_content( 141 | model="gemini-2.5-flash", 142 | contents=prompt, 143 | ) 144 | 145 | # strip the response of docstrings and stuff 146 | text = response.text.strip() 147 | 148 | # Strip ```json code fences if present from the LLM 149 | if text.startswith("```"): 150 | text = text.strip("`") 151 | # remove possible leading 'json' or 'JSON' 152 | if text.lower().startswith("json"): 153 | text = text[4:].strip() 154 | 155 | # then feed the data json.loads 156 | data = json.loads(text) 157 | # use get for the headline, fallback (never blank) if none exists repeat for all three. 158 | headline = data.get("headline", fallback_headline) 159 | body = data.get("body", fallback_body) 160 | disclaimer = data.get("disclaimer", fallback_disclaimer) 161 | 162 | # helper tuple of headline, body and legal, if the genai failed, fall back to json obj data 163 | return headline.strip(), body.strip(), disclaimer.strip() 164 | 165 | except Exception as e: 166 | print(f"⚠️ Gemini copy generation failed for {product.name}, using fallback: {e}") 167 | return fallback_headline, fallback_body, fallback_disclaimer 168 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📘 Scaled Content Orchestration Agent 2 | 3 | #### An agentic platform that accelerates campaign velocity enabling agencies to release more campaigns per month, drive localized engagement and maintain brand guidelines. 4 | This POC was limited to 6–8 hrs intentionally. Show how an agency could automate a large chunk of **production work** on a creative delivery. Some of this would NOT be done in either the creative or the production process. its just a poc. 5 | 6 | #### Author 7 | 👩🏽‍🚀 runtime@github.com 8 | 9 | ### Orchestration Tool Requirements 10 | - Accelerate Campaign Velocity 11 | - Ensure Brand Consistency 12 | - Maximize Relevance & Personalization 13 | - Optimize marketing ROI 14 | - Gain Actionable Insights 15 | 16 | 17 | ### Lets assume we are handling a brief for an awareness campaign for two of our clients products. 18 | In this demo we will create copy and images, but also build resizes. Sort of mixing production and creative together just for poc. 19 | 20 | 21 | #### Our Client 22 | - 🧽 RapidClean 23 | - an eco friendly cleaning product manufacturer who needs to tell CA residents our products are safe. 24 | #### Products that need awareness campaigns 25 | - PurePath Floor Wash 26 | - NaturaGlow Wood Polish 27 | #### Campaign (Assume campaign and users are assigned in workfront->program) 28 | - Eco Awareness - drive traffic to companies local legal & compliance pages etc... 29 | #### Required outputs: 30 | - reuse existing assets - assume assets are in a DAM 31 | - generate missing elements when there are none 32 | - produce 3 sizes 33 | - place consistent copy and legal 34 | - local run cli script with organized outputs 35 | 36 | 37 | ### ✅ Brief ingestion + asset reuse 38 | - Accepts a JSON brief with: 39 | - campaign name, KPIs, audience, region 40 | - two RapidClean products 41 | - campaign messaging & brand guidelines 42 | - legal disclaimer 43 | - Uses reusable assets from `/inputs/assets/...`: 44 | - product bottle PNGs 45 | - mascot PNG 46 | - brand logo 47 | - If assets aren’t there → pipeline still works (Imagen fills the gap) 48 | 49 | 50 | ### ✅ Consistent messaging across all sizes 51 | - Headline, body, and legal are generated once via Vertex Gemini (LLM) 52 | - Written to `copy.json` 53 | - Legal is as is from brief, no LLM. 54 | - Same exact copy is passed to every image generation call → consistent across all formats 55 | 56 | ### ✅ Text overlay placed through Imagen 57 | - The Imagen hero prompt includes: 58 | - headline 59 | - body 60 | - disclaimer 61 | - Pillow compositing ensures brand elements (bottle, mascot, logo) stay exactly where they belong 62 | 63 | ### ✅ Generates Localized Images for Hero 64 | - Generates a localized Hero image for assets and copy to sit over 65 | - composites the product, mascot, headline and text 66 | 67 | ### ✅ Generates Three aspect ratios 68 | Generates 3 standard social sizes for each product: 69 | - **1:1** (Instagram feed) 70 | - **9:16** (Stories/Reels/TikTok) 71 | - **16:9** (YouTube/FB widescreen) 72 | 73 | ### ✅ Local run + organized outputs 74 | ```python 75 | outputs/ 76 | awareness_campaign/ 77 | v1/ 78 | purepath/ 79 | naturaglow/ 80 | ``` 81 | ## Features: 82 | ### 🧠 Google's ADK agentic Development Kit 83 | A valid and quick way to create a scalable POC for a custom orchestration that emulates an agency production pipeline. 84 | The ADK orchestration pattern mirrors how an enterprise platform would coordinate brief → copy → hero → resizing. 85 | 86 | ### 📄 Brief in JSON Format 87 | In a real workflow the brief might be uploaded as PDF/Docx. 88 | In large P&D pipelines i have used Vertex AI or OCR libs which normalizes to JSON for predictable ingestion. 89 | For this POC, the brief is already provided as structured JSON so the tool can consume it instantly. 90 | 91 | ### ✍️ Gemini to generate structured copy in JSON format 92 | This allows the rest of the pipeline to treat copy as data: headline, body, and disclaimer are contained within. fallback for failures makes it llm safe :) 93 | - headline 94 | - body text 95 | - legal disclaimer 96 | - All generated in one structured pass. 97 | 98 | ### 🖼️ Imagen 4 for image generation 99 | Ability to create regional or targeted hero images by passing dynamic prompts (see region in config). 100 | - creates regionally specific hero 101 | - incorporates the generated copy into the ad (shortcut and blessing :P) 102 | - region specific affects look at aesthetic 103 | - prompts are dynamic & constructed from brief data 104 | 105 | ### 🧩 Seeds for consistency 106 | - this version seeding gives us reproducible creatives per product and size via the optional flag `-- seed 42` 107 | - future roadmap, I’d generate a single master 1:1 creative with a fixed seed and then derive the other sizes from that master. 108 | 109 | 110 | ### 🖌️ Pillow to composite product, mascot, and logo 111 | Bottle and logo come from stubbed DAM → no hallucinated product packaging 112 | imagine that they are assets approved by client for product and mascot 113 | ProTip: 🥷🏼 I used transparent png files for product, logo and mascot for compositing 114 | Layout is consistent across sizes, to match brand requirements 115 | `Pillow` handles: 116 | - placement 117 | - scaling 118 | - alpha 119 | - compositing 120 | 121 | ### 👩🏽‍🎨 Wanna try it? ... Pregame Setup: 122 | #### Important: 123 | you will need to fork over a credit card to run this application. 124 | 125 | - create a google cloud account 126 | - create a project and enable the vertexai api and genai apis 127 | - create some keys 128 | 129 | #### Note: 130 | the agent needs to own execution context so env vars need to be passed during construction of the agent. 131 | - create an .env file in the orchestration-agent-poc/scaled_content_agent folder not at the root directory. 132 | - I grab your env in the __init__ of each agents class using os 133 | - use the gcloud project name not the ID as this confuses some folks 134 | - finally you need to gcloud auth in terminal to log you in 135 | all not covered here for speed 136 | 137 | #### Steps to run: 138 | 139 | ```python 140 | cd orchestration-agent-poc 141 | 142 | source .venv/bin/activate 143 | ``` 144 | ```python 145 | pip install -r /scaled_content_agent/requirements.txt 146 | ``` 147 | Simple CLI: 148 | ```bash 149 | python -m scaled_content_agent.utils.cli 150 | ``` 151 | 152 | Simple optional flag for seeds: 153 | ```bash 154 | python -m scaled_content_agent.utils.cli --seed 42 155 | ``` 156 | 157 | 158 | 159 | 160 | ## 📤 Pipeline (Step-by-step) 161 | #### what happens under the hood... 162 | - cli loads the `orchestrator agent` which is our task master 💅🏽 163 | - orchestrator agent calls these agents/tools inline 164 | - 🔧 `brief ingestion agent` (tool): 165 | - loads the brief `campaign.json` acts as a big setter returns a python obj 166 | - builds a `CampaignConfig` (top-level campaign + brand info) 167 | - builds a `ProductConfig` for each product. 168 | - maps a python object -> `campaign_cfg.products[0].name` with properties i can easily deal with 169 | - 🤖 `copy_agent` (Vertex Gemini) 170 | - reads the config and generates the copy for the ad 171 | - writes output to `outputs/.../copy.json` 172 | - structured JSON becomes a data object for the image agent to add for consistency 173 | - 🤖 `image_agent` (Imagen 4 + Pillow) 174 | - leverages imagen `GenerateImagesConfig` 175 | - uses dynamic prompt from orchestration agent - takes location and regional copy 176 | ``` 177 | prompt = ( 178 | f"Bright, minimal, daylight {campaign_cfg.target_region} home interior. " 179 | f"Eco-friendly aesthetic, clean, calm, modern. " 180 | f"Soft shadows, open space near bottom for product placement. " 181 | f"Aspect ratio {width}:{height}. " 182 | f"Do NOT include any product bottles. " 183 | f"Do include copy from copy.text file in input folder. " 184 | f"This is a background hero image for an eco cleaning product ad." 185 | ) 186 | ``` 187 | - saves hero result and opens image in `Pillow` 188 | - composites the image 189 | - creates the sizes 190 | - uses legal and copy json generated by copy_agent to have consistent legal and copy 191 | 192 | 193 | ## How did we do? 194 | #### 🚀 Accelerated campaign velocity 195 | 196 | Brief → copy → 6 creatives in seconds, not hours. 197 | Historically: 198 | 15–20 min per size × 6 sizes = 1.5–2 hours 199 | 200 | At $25/hr → ~$40–$50 saved per campaign 201 | This POC automates ~90% of production workload. 202 | 203 | #### 🧩 Brand governance maintained 204 | 205 | Bottle, logo, and mascot come from DAM folder 206 | 207 | Copy is identical across all creative sizes 208 | 209 | Legal is guaranteed 210 | 211 | Region influences imagery (demo-friendly) 212 | 213 | #### 🛠️ Extensible architecture 214 | 215 | Easy to add: QC agent, localization, optimizer, Workfront/DAM integrations 216 | 217 | Designed for CD/CI-style workflows 218 | 219 | Each agent is testable and isolated 220 | 221 | 222 | ## What else would we do with more time? 223 | 224 | ### Analytics and DCO 225 | we could take campaign analytics and optimize each delivery to perform better 226 | change colors, copy on cta etc.. 227 | 228 | #### Real-time CDP, RTB, PAB 229 | We are at an unprecedented time in history where we can almost literally put our audience into the ads. This could be done with tools like Adobe real-time CDP, Target and other tools along with PAB, RTB and DCO platforms, this becomes a reality. 230 | 231 | #### RAG for insights corpus 232 | using google search tools to build a corpus of data either private or public to serve insights to key agency employees either while they brainstorm or create content. 233 | -------------------------------------------------------------------------------- /subagents/image_agent.py: -------------------------------------------------------------------------------- 1 | # scaled_content_agent/subagents/image_agent.py 2 | 3 | import os 4 | from pathlib import Path 5 | import json 6 | 7 | from tempfile import NamedTemporaryFile # add this import at the top with others 8 | 9 | from google import genai 10 | from google.genai.types import GenerateImagesConfig 11 | 12 | # use pillow to help us load existing assets and compose them 13 | from PIL import Image, ImageOps 14 | 15 | # Create image gen agent 16 | class ImageGenerationAgent: 17 | """ 18 | v1 Image Agent: 19 | - Generates a hero background using Imagen 20 | - Loads local product.png, mascot.png, and brand logo 21 | - Composites layers onto the background using Pillow 22 | - Saves 3 aspect ratios per product 23 | """ 24 | # again pass env vars during construction 25 | # default is vertexai true which is needed 26 | def __init__(self): 27 | location = os.environ.get("GOOGLE_CLOUD_LOCATION", "us-central1") 28 | project_id = os.environ.get("GOOGLE_CLOUD_PROJECT", "adk-llm-agent") 29 | # create genai client 30 | self.client = genai.Client( 31 | vertexai=True, 32 | project=project_id, 33 | location=location, 34 | ) 35 | 36 | self.aspect_ratios = { 37 | "1x1": (1024, 1024), 38 | "9x16": (900, 1600), 39 | "16x9": (1600, 900), 40 | } 41 | 42 | # this function ALWAYS generates outputs. 43 | # it loads assets if they exist for consistency. 44 | # it doesn't cache hero pngs (blessing or curse, up to your needs) 45 | # generate the hero 46 | def generate_images_for_products(self, campaign_cfg, output_root, seed=None): 47 | output_root = Path(output_root) 48 | # loop thru products in config (2) 49 | for product in campaign_cfg.products: 50 | # create the folders if they dont exist on the parent directory 51 | product_dir = output_root / product.slug 52 | product_dir.mkdir(parents=True, exist_ok=True) 53 | 54 | copy_path = product_dir / "copy.json" 55 | if not copy_path.exists(): 56 | print(f"⚠️ No copy.json found for {product.name}, skipping.") 57 | continue 58 | # iterate through the json f = item 59 | with copy_path.open("r", encoding="utf-8") as f: 60 | copy_data = json.load(f) 61 | 62 | # product + mascot image locations 63 | product_image_path = product.asset_folder / "product.png" 64 | mascot_image_path = product.asset_folder / "mascot.png" 65 | 66 | # brand logo 67 | logo_path = campaign_cfg.brand_logo_path 68 | 69 | # Load assets (skip missing, warn lightly) 70 | product_img = self._load_png(product_image_path, "product") 71 | mascot_img = self._load_png(mascot_image_path, "mascot") # optional 72 | logo_img = self._load_png(logo_path, "logo") 73 | 74 | # Generate all ratios 75 | for ratio_label, (w, h) in self.aspect_ratios.items(): 76 | print(f"\n▶ Generating background for {product.name} / {ratio_label}") 77 | # create hero image 78 | background = self._generate_background_image( 79 | product=product, 80 | campaign_cfg=campaign_cfg, 81 | copy_data=copy_data, 82 | width=w, 83 | height=h, 84 | seed=seed, 85 | ) 86 | 87 | # Now composite all the things generated or loaded 88 | final_img = self._composite_layers( 89 | background=background, 90 | product_img=product_img, 91 | mascot_img=mascot_img, 92 | logo_img=logo_img, 93 | ) 94 | # append the file names to have the campaign names in them (some DSPs require specific names) 95 | output_path = product_dir / f"{ratio_label}_awareness.png" 96 | final_img.save(output_path) 97 | print(f"✅ Saved {output_path}") 98 | 99 | # returns None if there is no png, thus omitting it by design 100 | # converts all to rgba so that transparency is considered. 101 | def _load_png(self, path, label): 102 | """ 103 | Loads a PNG if it exists; otherwise returns None. 104 | Keeps POC clean with graceful fallback. 105 | """ 106 | try: 107 | path = Path(path) 108 | if path.exists(): 109 | return Image.open(path).convert("RGBA") 110 | else: 111 | print(f"⚠️ {label} image not found at {path}, skipping.") 112 | return None 113 | except Exception as e: 114 | print(f"⚠️ Failed to load {label} image ({path}): {e}") 115 | return None 116 | 117 | def _generate_background_image(self, product, campaign_cfg, copy_data, width, height, seed): 118 | """ 119 | Calls Imagen to generate a hero background that ALSO includes text: 120 | - headline 121 | - body 122 | - disclaimer (near mascot position) 123 | No product bottle or logo; those are composited later. 124 | """ 125 | headline = copy_data.get("headline", "") 126 | body = copy_data.get("body", "") 127 | disclaimer = copy_data.get("disclaimer", "") or getattr(campaign_cfg, "legal_disclaimer", "") 128 | 129 | prompt = ( 130 | f"Bright, minimal, daylight {campaign_cfg.target_region} home interior. " 131 | f"Eco-friendly aesthetic, clean, calm, modern. " 132 | f"Soft shadows, open space for copy and product placement. " 133 | f"Aspect ratio {width}:{height}. " 134 | f"Do NOT include any product bottles or brand logos. " 135 | f"This is a background hero image for an eco cleaning product ad. " 136 | f"Add this headline text EXACTLY as written near the top center of the ad: '{headline}'. " 137 | f"Add this supporting body text EXACTLY as written below the headline: '{body}'. " 138 | ) 139 | 140 | if disclaimer: 141 | prompt += ( 142 | f"Add this small legal disclaimer text EXACTLY as written near the bottom-left area, " 143 | f"where a mascot or character might sit: '{disclaimer}'. " 144 | ) 145 | 146 | # Build config; only pass seed if not None 147 | if seed is not None: 148 | config = GenerateImagesConfig( 149 | aspect_ratio="1:1", 150 | image_size="1K", 151 | number_of_images=1, 152 | output_mime_type="image/png", 153 | seed=seed, 154 | ) 155 | else: 156 | config = GenerateImagesConfig( 157 | aspect_ratio="1:1", 158 | image_size="1K", 159 | number_of_images=1, 160 | output_mime_type="image/png", 161 | ) 162 | 163 | try: 164 | result = self.client.models.generate_images( 165 | model="imagen-4.0-generate-001", # <-- hard coded model not ideal for fallbacks or degradation 166 | prompt=prompt, 167 | config=config, 168 | ) 169 | 170 | if not result.generated_images: 171 | raise RuntimeError("Imagen returned no images") 172 | 173 | # need to write a temp file as png (for this demo its easier than caching base64 etc.) 174 | # vertexai returns a custom image wrapper not a raw PIL image. 175 | # fastest way is to save a png to a temp file, open it in pillow and delete the image 176 | from tempfile import NamedTemporaryFile 177 | import os 178 | 179 | gimg = result.generated_images[0].image 180 | 181 | with NamedTemporaryFile(suffix=".png", delete=False) as tmp: 182 | tmp_path = tmp.name 183 | 184 | try: 185 | gimg.save(tmp_path) 186 | img = Image.open(tmp_path).convert("RGBA") 187 | finally: 188 | if os.path.exists(tmp_path): 189 | os.remove(tmp_path) 190 | 191 | except Exception as e: 192 | print(f"⚠️ Imagen failed, using plain white background: {e}") 193 | return Image.new("RGBA", (width, height), (255, 255, 255, 255)) 194 | 195 | return img.resize((width, height), Image.LANCZOS) 196 | 197 | # todo create layouts for different campaigns / regions 198 | # for now this is a simple layout tool using Pillow keeping brand guidelines consistent 199 | # this is very much how banner templates are created using any tools necessary, canvas, html etc.. 200 | def _composite_layers(self, background, product_img, mascot_img, logo_img): 201 | """ 202 | Composite: logo → product (bottom-right) → mascot (bottom-left, optional) 203 | """ 204 | canvas = background.copy() 205 | 206 | def scale(img, max_w, max_h): 207 | if img is None: 208 | return None 209 | img = ImageOps.contain(img, (max_w, max_h)) 210 | return img 211 | 212 | W, H = canvas.size 213 | 214 | # Make product more prominent 215 | product_img = scale(product_img, W // 2, H // 2) 216 | mascot_img = scale(mascot_img, W // 5, H // 5) 217 | logo_img = scale(logo_img, W // 6, H // 6) 218 | 219 | # Logo: top-left with margin 220 | if logo_img: 221 | canvas.paste(logo_img, (40, 40), logo_img) 222 | 223 | # Product: bottom-right, more prominent 224 | if product_img: 225 | pw, ph = product_img.size 226 | px = (W - pw) // 2 227 | py = H - ph - 40 228 | canvas.paste(product_img, (px, py), product_img) 229 | 230 | # Mascot: bottom-left (optional). Disclaimer is already in background prompt near here. 231 | if mascot_img: 232 | mw, mh = mascot_img.size 233 | mx = 40 234 | my = H - mh - 40 235 | canvas.paste(mascot_img, (mx, my), mascot_img) 236 | 237 | return canvas 238 | 239 | 240 | -------------------------------------------------------------------------------- /inputs/brand/rapidclean_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 | --------------------------------------------------------------------------------