userIdc2024 commited on
Commit
de08998
·
verified ·
1 Parent(s): 6f6dbb6

Upload 16 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY backend/ ./backend/
9
+ COPY frontend/ ./frontend/
10
+
11
+ EXPOSE 7860
12
+
13
+ CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
backend/__init__.py ADDED
File without changes
backend/__pycache__/claude_method.cpython-311.pyc ADDED
Binary file (3.58 kB). View file
 
backend/__pycache__/gpt_method.cpython-311.pyc ADDED
Binary file (1.99 kB). View file
 
backend/__pycache__/main.cpython-311.pyc ADDED
Binary file (4.14 kB). View file
 
backend/__pycache__/prompt.cpython-311.pyc ADDED
Binary file (2.9 kB). View file
 
backend/__pycache__/pydantic_schema.cpython-311.pyc ADDED
Binary file (2.79 kB). View file
 
backend/claude_method.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Claude-based researcher implementation.
3
+ Uses output_config.format for native structured outputs.
4
+ Ref: https://platform.claude.com/docs/en/build-with-claude/structured-outputs
5
+ """
6
+ import json
7
+ from anthropic import Anthropic
8
+ from backend.pydantic_schema import ImageAdEssentialsOutput
9
+ from backend.prompt import get_system_prompt, get_user_prompt
10
+ from dotenv import load_dotenv
11
+ import os
12
+
13
+ load_dotenv()
14
+
15
+ ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
16
+
17
+
18
+ def _add_additional_properties_false(schema: dict) -> dict:
19
+ """
20
+ Recursively add 'additionalProperties': false to all object types.
21
+ Required by Claude's structured outputs.
22
+ """
23
+ if isinstance(schema, dict):
24
+ if schema.get("type") == "object":
25
+ schema["additionalProperties"] = False
26
+ for value in schema.values():
27
+ if isinstance(value, dict):
28
+ _add_additional_properties_false(value)
29
+ elif isinstance(value, list):
30
+ for item in value:
31
+ if isinstance(item, dict):
32
+ _add_additional_properties_false(item)
33
+ return schema
34
+
35
+
36
+ def researcher_claude(target_audience: str, product_category: str, product_description: str):
37
+ """
38
+ Claude-based researcher function using native structured outputs.
39
+
40
+ Args:
41
+ target_audience: Target audience from the predefined list
42
+ product_category: Product category (e.g., "ring", "bangles")
43
+ product_description: Description of the product
44
+
45
+ Returns:
46
+ list[ImageAdEssentials]: List of psychology triggers, angles, and concepts
47
+ """
48
+ # Initialize Claude client
49
+ claude_client = Anthropic(api_key=ANTHROPIC_API_KEY)
50
+
51
+ # Get prompts
52
+ system_prompt = get_system_prompt()
53
+ user_prompt = get_user_prompt(target_audience, product_category, product_description)
54
+
55
+ # Build JSON schema from Pydantic model and add required additionalProperties: false
56
+ json_schema = ImageAdEssentialsOutput.model_json_schema()
57
+ json_schema = _add_additional_properties_false(json_schema)
58
+
59
+ # Use Claude's native structured outputs via output_config.format
60
+ message = claude_client.messages.create(
61
+ model="claude-opus-4-6",
62
+ max_tokens=1024,
63
+ system=system_prompt,
64
+ messages=[
65
+ {
66
+ "role": "user",
67
+ "content": user_prompt
68
+ }
69
+ ],
70
+ output_config={
71
+ "format": {
72
+ "type": "json_schema",
73
+ "schema": json_schema
74
+ }
75
+ }
76
+ )
77
+
78
+ # Check for safety refusal
79
+ if message.stop_reason == "refusal":
80
+ raise ValueError("Claude refused the request.")
81
+
82
+ # Check if response was truncated
83
+ if message.stop_reason == "max_tokens":
84
+ raise ValueError("Claude response was truncated — increase max_tokens.")
85
+
86
+ # Parse the JSON response and validate with Pydantic
87
+ response_data = json.loads(message.content[0].text)
88
+ output = ImageAdEssentialsOutput(**response_data)
89
+ return output.output
backend/gpt_method.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GPT-based researcher implementation.
3
+ Uses the latest Responses API with text_format for structured outputs.
4
+ """
5
+ from openai import OpenAI
6
+ from backend.pydantic_schema import ImageAdEssentialsOutput
7
+ from backend.prompt import get_system_prompt, get_user_prompt
8
+ from dotenv import load_dotenv
9
+ import os
10
+
11
+ load_dotenv()
12
+
13
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
14
+
15
+
16
+ def researcher_gpt(target_audience: str, product_category: str, product_description: str):
17
+ """
18
+ GPT-based researcher function using the Responses API.
19
+
20
+ Args:
21
+ target_audience: Target audience from the predefined list
22
+ product_category: Product category (e.g., "ring", "bangles")
23
+ product_description: Description of the product
24
+
25
+ Returns:
26
+ list[ImageAdEssentials]: List of psychology triggers, angles, and concepts
27
+ """
28
+ # Initialize GPT client
29
+ gpt_client = OpenAI(api_key=OPENAI_API_KEY)
30
+
31
+ # Get prompts
32
+ system_prompt = get_system_prompt()
33
+ user_prompt = get_user_prompt(target_audience, product_category, product_description)
34
+
35
+ # Use the Responses API with text_format for structured output
36
+ response = gpt_client.responses.parse(
37
+ model="gpt-4o",
38
+ instructions=system_prompt,
39
+ input=[
40
+ {
41
+ "role": "user",
42
+ "content": user_prompt
43
+ }
44
+ ],
45
+ text_format=ImageAdEssentialsOutput,
46
+ )
47
+
48
+ # output_parsed returns the Pydantic model directly
49
+ if response.output_parsed:
50
+ return response.output_parsed.output
51
+ else:
52
+ raise ValueError("GPT returned an unparseable response.")
backend/main.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI backend for the Image Ad Essentials Researcher.
3
+ """
4
+ import json
5
+ from typing import Literal
6
+ from pathlib import Path
7
+ from fastapi import FastAPI, HTTPException
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from fastapi.staticfiles import StaticFiles
10
+ from pydantic import BaseModel
11
+ from backend.pydantic_schema import ImageAdEssentials, TARGET_AUDIENCE_OPTIONS
12
+ from backend.gpt_method import researcher_gpt
13
+ from backend.claude_method import researcher_claude
14
+
15
+ app = FastAPI(title="Image Ad Essentials Researcher")
16
+
17
+ # Allow frontend to call the API
18
+ app.add_middleware(
19
+ CORSMiddleware,
20
+ allow_origins=["*"],
21
+ allow_credentials=True,
22
+ allow_methods=["*"],
23
+ allow_headers=["*"],
24
+ )
25
+
26
+
27
+ # --- Request / Response schemas ---
28
+
29
+ class ResearchRequest(BaseModel):
30
+ target_audience: str
31
+ product_category: str
32
+ product_description: str
33
+ method: Literal["gpt", "claude"]
34
+
35
+
36
+ class ResearchResponse(BaseModel):
37
+ output: list[ImageAdEssentials]
38
+
39
+
40
+ # --- Endpoints ---
41
+
42
+ @app.get("/api/target-audiences")
43
+ def get_target_audiences():
44
+ """Return the predefined list of target audience options."""
45
+ return {"audiences": TARGET_AUDIENCE_OPTIONS}
46
+
47
+
48
+ @app.post("/api/research", response_model=ResearchResponse)
49
+ def run_research(req: ResearchRequest):
50
+ """
51
+ Run the researcher using the selected method (GPT or Claude).
52
+ Returns a list of psychology triggers with angles and concepts.
53
+ """
54
+ try:
55
+ if req.method == "gpt":
56
+ result = researcher_gpt(
57
+ req.target_audience, req.product_category, req.product_description
58
+ )
59
+ elif req.method == "claude":
60
+ result = researcher_claude(
61
+ req.target_audience, req.product_category, req.product_description
62
+ )
63
+ else:
64
+ raise HTTPException(status_code=400, detail="Invalid method. Use 'gpt' or 'claude'.")
65
+
66
+ return ResearchResponse(output=result)
67
+
68
+ except ValueError as e:
69
+ raise HTTPException(status_code=500, detail=str(e))
70
+ except Exception as e:
71
+ raise HTTPException(status_code=500, detail=f"An error occurred: {str(e)}")
72
+
73
+
74
+ # --- Serve frontend static files (MUST be after API routes) ---
75
+ FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
76
+ app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend")
77
+
78
+
79
+ if __name__ == "__main__":
80
+ import uvicorn
81
+ uvicorn.run(app, host="0.0.0.0", port=7860)
backend/prompt.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Prompt templates for the researcher functions.
3
+ """
4
+
5
+ def get_system_prompt() -> str:
6
+ """
7
+ Returns the system prompt for both GPT and Claude methods.
8
+ """
9
+ return """You are the researcher for the e-commerce brand company which does research on trending angles, concepts and psychology trigers based on the user input.
10
+ The e-commerce brand company name is Amalfa which is a contemporary jewellery brand known in the Indian market for its demi-fine and fashion jewellery collections.
11
+ Amalfa aims to be a style-forward, expressive brand for today's youth and modern women, blending trend-driven design with accessible pricing.
12
+ A psychology trigger is an emotional or cognitive stimulus that pushes someone toward action—clicking, signing up, or buying—before logic kicks in.
13
+ An ad angle is the reason someone should care right now. Same product → different reasons to click → different angles.
14
+ An ad concept is the creative execution style or storyline you use to deliver an angle.
15
+
16
+ Keeping in mind all this, make sure you provide different angles and concepts we can try based on the phsychology triggers for the image ads for the given input based on e-commerce brand.
17
+ User will provide you the category on which he needs to run the ads, his requirement, product description and what is target audience.
18
+
19
+ Important output guidelines:
20
+ - Provide exactly 3 psychology triggers, each as a separate item.
21
+ - Each psychology trigger should be a short label (e.g. "Scarcity & FOMO", "Social Proof", "Self-Reward"). Do NOT write long explanations.
22
+ - For each trigger, provide 3 short ad angles (one line each).
23
+ - For each trigger, provide 3 short ad concepts (one line each).
24
+ - Keep the entire response concise and actionable."""
25
+
26
+
27
+ def get_user_prompt(target_audience: str, product_category: str, product_description: str) -> str:
28
+ """
29
+ Returns the user prompt with the provided inputs.
30
+
31
+ Args:
32
+ target_audience: Target audience from the predefined list
33
+ product_category: Product category (e.g., "ring", "bangles")
34
+ product_description: Description of the product
35
+ """
36
+ return f"""Following are the inputs:
37
+ Product Category: {product_category}
38
+ Target Audience: {target_audience}
39
+ Product Description: {product_description}
40
+
41
+ Provide the different phsychology triggers, angles and concept based on the given input."""
backend/pydantic_schema.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+
3
+ # Pydantic Models
4
+ class ImageAdEssentials(BaseModel):
5
+ phsychologyTriggers: str
6
+ angles: list[str]
7
+ concepts: list[str]
8
+
9
+ class ImageAdEssentialsOutput(BaseModel):
10
+ output: list[ImageAdEssentials]
11
+
12
+
13
+ # Target Audience Options
14
+ TARGET_AUDIENCE_OPTIONS = [
15
+ "Women 18–24",
16
+ "Women 25–34",
17
+ "Women 35–44",
18
+ "Urban Tier 1 Women",
19
+ "Urban Tier 2 Women",
20
+ "Working Professionals",
21
+ "Corporate Women",
22
+ "Women Entrepreneurs",
23
+ "Disposable Income ₹30k+",
24
+ "Living Independently",
25
+ "Married, No Kids",
26
+ "Newly Married",
27
+ "Single Women",
28
+ "Monthly Online Shoppers",
29
+ "English Digital-first",
30
+ "Demi-fine Jewelry Buyers",
31
+ "Minimalist Lovers",
32
+ "Statement Buyers",
33
+ "Everyday Wear Buyers",
34
+ "Occasion Shoppers",
35
+ "Layering Lovers",
36
+ "Choker Buyers",
37
+ "CZ Jewelry Fans",
38
+ "Anti-tarnish Seekers",
39
+ "Hypoallergenic Buyers",
40
+ "Gold Finish Lovers",
41
+ "Silver Finish Lovers",
42
+ "Indo-western Fans",
43
+ "Contemporary Ethnic",
44
+ "Sustainable Shoppers",
45
+ "Premium Accessories",
46
+ "IG Jewelry Followers",
47
+ "Pinterest Users",
48
+ "Fashion Discovery",
49
+ "Outfit Reel Savers",
50
+ "Online Jewelry Shoppers",
51
+ "Cart Abandoners",
52
+ "Instagram Shop Users",
53
+ "Google Shoppers",
54
+ "Self-Gifters",
55
+ "Repeat Buyers",
56
+ "Sale-responsive",
57
+ "Value Premium Buyers",
58
+ "₹1.5k–₹3k Buyers",
59
+ "COD Buyers",
60
+ "UPI-first",
61
+ "Mobile-only",
62
+ "D2C Followers",
63
+ "Instagram Trusters",
64
+ "Brand Switchers",
65
+ "Limited Edition Buyers",
66
+ "First-time Buyers",
67
+ "Impulse Buyers",
68
+ "Birthday Self-Gift",
69
+ "Anniversary Buyers",
70
+ "Wedding Guests",
71
+ "Bridesmaids",
72
+ "Festive Buyers",
73
+ "Rakhi Self-Gift",
74
+ "Valentine Self-love",
75
+ "New Year Parties",
76
+ "Office Parties",
77
+ "Vacation Shoppers",
78
+ "Date-night",
79
+ "Wedding Season",
80
+ "Festive Office",
81
+ "Outfit Completion",
82
+ "Reel Jewelry",
83
+ "Fashion Influencer Followers",
84
+ "Jewelry Influencer Fans",
85
+ "Styling Reel Fans",
86
+ "Vogue India",
87
+ "Elle India",
88
+ "Harper's Bazaar",
89
+ "Nykaa Fashion",
90
+ "Myntra Premium",
91
+ "Ajio Luxe",
92
+ "Fashion Page Followers",
93
+ "Self-love Believers",
94
+ "Self-rewarders",
95
+ "Aesthetic Buyers",
96
+ "Aspirational Value",
97
+ "Uniqueness Seekers",
98
+ "Anti-mass Market",
99
+ "Creative Women",
100
+ "Fashion Experimenters",
101
+ "Early Adopters",
102
+ "Global Trend Followers",
103
+ "Quiet Luxury Fans",
104
+ "Instagram-first",
105
+ "UGC Creators",
106
+ "Event Goers",
107
+ "Photo Dressers",
108
+ "Website Visitors",
109
+ "Product Viewers",
110
+ "Add-to-Cart",
111
+ "Past Purchasers",
112
+ "Top 10% LAL"
113
+ ]
frontend/index.html ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Amalfa · Image Ad Essentials</title>
7
+ <link rel="stylesheet" href="styles.css" />
8
+ <!-- Google Fonts -->
9
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
11
+ <link
12
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap"
13
+ rel="stylesheet"
14
+ />
15
+ </head>
16
+ <body>
17
+ <!-- Background blobs -->
18
+ <div class="blob blob-1"></div>
19
+ <div class="blob blob-2"></div>
20
+
21
+ <div class="container">
22
+ <!-- Header -->
23
+ <header class="header">
24
+ <div class="logo">
25
+ <span class="logo-icon">✦</span>
26
+ <span class="logo-text">Amalfa</span>
27
+ </div>
28
+ <h1 class="title">Image Ad Essentials</h1>
29
+ <p class="subtitle">Generate psychology-driven ad angles &amp; concepts for your jewellery campaigns</p>
30
+ </header>
31
+
32
+ <!-- Form Card -->
33
+ <div class="card form-card">
34
+ <form id="researchForm">
35
+ <!-- Target Audience -->
36
+ <div class="field">
37
+ <label for="targetAudience">Target Audience</label>
38
+ <select id="targetAudience" required>
39
+ <option value="" disabled selected>Loading audiences…</option>
40
+ </select>
41
+ </div>
42
+
43
+ <!-- Product Category -->
44
+ <div class="field">
45
+ <label for="productCategory">Product Category</label>
46
+ <input
47
+ type="text"
48
+ id="productCategory"
49
+ placeholder="e.g. Ring, Bangles, Necklace"
50
+ required
51
+ />
52
+ </div>
53
+
54
+ <!-- Product Description -->
55
+ <div class="field">
56
+ <label for="productDescription">Product Description</label>
57
+ <textarea
58
+ id="productDescription"
59
+ rows="3"
60
+ placeholder="e.g. Gold-plated minimalist ring with CZ stones, anti-tarnish coating"
61
+ required
62
+ ></textarea>
63
+ </div>
64
+
65
+ <!-- Method Toggle -->
66
+ <div class="field">
67
+ <label>AI Method</label>
68
+ <div class="toggle-group">
69
+ <button type="button" class="toggle-btn active" data-method="gpt">
70
+ <span class="toggle-icon">⚡</span> GPT
71
+ </button>
72
+ <button type="button" class="toggle-btn" data-method="claude">
73
+ <span class="toggle-icon">🧠</span> Claude
74
+ </button>
75
+ </div>
76
+ </div>
77
+
78
+ <!-- Submit -->
79
+ <button type="submit" class="submit-btn" id="submitBtn">
80
+ <span class="btn-text">Generate Ad Essentials</span>
81
+ <span class="btn-loader hidden">
82
+ <span class="spinner"></span>
83
+ Generating…
84
+ </span>
85
+ </button>
86
+ </form>
87
+ </div>
88
+
89
+ <!-- Error banner -->
90
+ <div id="errorBanner" class="error-banner hidden"></div>
91
+
92
+ <!-- Results -->
93
+ <div id="results" class="results hidden"></div>
94
+ </div>
95
+
96
+ <script src="script.js"></script>
97
+ </body>
98
+ </html>
frontend/script.js ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ===== Configuration =====
2
+ const API_BASE = "";
3
+
4
+ // ===== DOM Elements =====
5
+ const form = document.getElementById("researchForm");
6
+ const audienceSelect = document.getElementById("targetAudience");
7
+ const categoryInput = document.getElementById("productCategory");
8
+ const descriptionInput = document.getElementById("productDescription");
9
+ const submitBtn = document.getElementById("submitBtn");
10
+ const btnText = submitBtn.querySelector(".btn-text");
11
+ const btnLoader = submitBtn.querySelector(".btn-loader");
12
+ const errorBanner = document.getElementById("errorBanner");
13
+ const resultsDiv = document.getElementById("results");
14
+ const toggleBtns = document.querySelectorAll(".toggle-btn");
15
+
16
+ let selectedMethod = "gpt";
17
+
18
+ // ===== Load Target Audiences =====
19
+ async function loadAudiences() {
20
+ try {
21
+ const res = await fetch(`${API_BASE}/api/target-audiences`);
22
+ if (!res.ok) throw new Error("Failed to load audiences");
23
+ const data = await res.json();
24
+
25
+ audienceSelect.innerHTML = '<option value="" disabled selected>Select target audience</option>';
26
+ data.audiences.forEach((audience) => {
27
+ const opt = document.createElement("option");
28
+ opt.value = audience;
29
+ opt.textContent = audience;
30
+ audienceSelect.appendChild(opt);
31
+ });
32
+ } catch (err) {
33
+ console.error("Could not load audiences:", err);
34
+ audienceSelect.innerHTML = '<option value="" disabled selected>⚠ Could not load — is the backend running?</option>';
35
+ }
36
+ }
37
+
38
+ // ===== Method Toggle =====
39
+ toggleBtns.forEach((btn) => {
40
+ btn.addEventListener("click", () => {
41
+ toggleBtns.forEach((b) => b.classList.remove("active"));
42
+ btn.classList.add("active");
43
+ selectedMethod = btn.dataset.method;
44
+ });
45
+ });
46
+
47
+ // ===== Form Submit =====
48
+ form.addEventListener("submit", async (e) => {
49
+ e.preventDefault();
50
+ hideError();
51
+ hideResults();
52
+
53
+ const payload = {
54
+ target_audience: audienceSelect.value,
55
+ product_category: categoryInput.value.trim(),
56
+ product_description: descriptionInput.value.trim(),
57
+ method: selectedMethod,
58
+ };
59
+
60
+ if (!payload.target_audience || !payload.product_category || !payload.product_description) {
61
+ showError("Please fill in all fields.");
62
+ return;
63
+ }
64
+
65
+ setLoading(true);
66
+
67
+ try {
68
+ const res = await fetch(`${API_BASE}/api/research`, {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json" },
71
+ body: JSON.stringify(payload),
72
+ });
73
+
74
+ if (!res.ok) {
75
+ const errData = await res.json().catch(() => null);
76
+ throw new Error(errData?.detail || `Server error (${res.status})`);
77
+ }
78
+
79
+ const data = await res.json();
80
+ renderResults(data.output, selectedMethod);
81
+ } catch (err) {
82
+ showError(err.message || "Something went wrong. Please try again.");
83
+ } finally {
84
+ setLoading(false);
85
+ }
86
+ });
87
+
88
+ // ===== Render Results =====
89
+ function renderResults(triggers, method) {
90
+ const badge = method === "gpt"
91
+ ? `<span class="results-badge gpt">GPT</span>`
92
+ : `<span class="results-badge claude">Claude</span>`;
93
+
94
+ let html = `
95
+ <div class="results-header">
96
+ <h2 class="results-title">Results</h2>
97
+ ${badge}
98
+ </div>
99
+ `;
100
+
101
+ triggers.forEach((item, idx) => {
102
+ html += `
103
+ <div class="trigger-card">
104
+ <span class="trigger-label">PsychologicalTrigger ${idx + 1}</span>
105
+ <h3 class="trigger-name">${escapeHtml(item.phsychologyTriggers)}</h3>
106
+
107
+ <div class="trigger-section">
108
+ <p class="section-title">Ad Angles</p>
109
+ <ul class="section-list">
110
+ ${item.angles.map((a) => `<li>${escapeHtml(a)}</li>`).join("")}
111
+ </ul>
112
+ </div>
113
+
114
+ <div class="trigger-section">
115
+ <p class="section-title">Ad Concepts</p>
116
+ <ul class="section-list">
117
+ ${item.concepts.map((c) => `<li>${escapeHtml(c)}</li>`).join("")}
118
+ </ul>
119
+ </div>
120
+ </div>
121
+ `;
122
+ });
123
+
124
+ resultsDiv.innerHTML = html;
125
+ resultsDiv.classList.remove("hidden");
126
+ resultsDiv.scrollIntoView({ behavior: "smooth", block: "start" });
127
+ }
128
+
129
+ // ===== Helpers =====
130
+ function setLoading(isLoading) {
131
+ submitBtn.disabled = isLoading;
132
+ btnText.classList.toggle("hidden", isLoading);
133
+ btnLoader.classList.toggle("hidden", !isLoading);
134
+ }
135
+
136
+ function showError(msg) {
137
+ errorBanner.textContent = msg;
138
+ errorBanner.classList.remove("hidden");
139
+ }
140
+
141
+ function hideError() {
142
+ errorBanner.classList.add("hidden");
143
+ }
144
+
145
+ function hideResults() {
146
+ resultsDiv.classList.add("hidden");
147
+ }
148
+
149
+ function escapeHtml(str) {
150
+ const div = document.createElement("div");
151
+ div.textContent = str;
152
+ return div.innerHTML;
153
+ }
154
+
155
+ // ===== Init =====
156
+ loadAudiences();
frontend/styles.css ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ===== Reset & Base ===== */
2
+ *,
3
+ *::before,
4
+ *::after {
5
+ margin: 0;
6
+ padding: 0;
7
+ box-sizing: border-box;
8
+ }
9
+
10
+ :root {
11
+ --bg: #0f0f13;
12
+ --surface: #1a1a22;
13
+ --surface-hover: #22222d;
14
+ --border: #2a2a38;
15
+ --text: #eaeaf0;
16
+ --text-muted: #8888a0;
17
+ --accent: #c9a46c;
18
+ --accent-light: #e0c999;
19
+ --accent-dark: #a07d4a;
20
+ --danger: #e85454;
21
+ --radius: 12px;
22
+ --radius-sm: 8px;
23
+ }
24
+
25
+ html {
26
+ font-size: 16px;
27
+ scroll-behavior: smooth;
28
+ }
29
+
30
+ body {
31
+ font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
32
+ background: var(--bg);
33
+ color: var(--text);
34
+ min-height: 100vh;
35
+ overflow-x: hidden;
36
+ position: relative;
37
+ }
38
+
39
+ /* ===== Background Blobs ===== */
40
+ .blob {
41
+ position: fixed;
42
+ border-radius: 50%;
43
+ filter: blur(120px);
44
+ opacity: 0.15;
45
+ pointer-events: none;
46
+ z-index: 0;
47
+ }
48
+
49
+ .blob-1 {
50
+ width: 500px;
51
+ height: 500px;
52
+ background: var(--accent);
53
+ top: -120px;
54
+ right: -100px;
55
+ }
56
+
57
+ .blob-2 {
58
+ width: 400px;
59
+ height: 400px;
60
+ background: #6c5ce7;
61
+ bottom: -80px;
62
+ left: -100px;
63
+ }
64
+
65
+ /* ===== Container ===== */
66
+ .container {
67
+ position: relative;
68
+ z-index: 1;
69
+ max-width: 720px;
70
+ margin: 0 auto;
71
+ padding: 48px 20px 80px;
72
+ }
73
+
74
+ /* ===== Header ===== */
75
+ .header {
76
+ text-align: center;
77
+ margin-bottom: 40px;
78
+ }
79
+
80
+ .logo {
81
+ display: inline-flex;
82
+ align-items: center;
83
+ gap: 8px;
84
+ margin-bottom: 16px;
85
+ }
86
+
87
+ .logo-icon {
88
+ font-size: 1.5rem;
89
+ color: var(--accent);
90
+ }
91
+
92
+ .logo-text {
93
+ font-family: "Playfair Display", serif;
94
+ font-size: 1.6rem;
95
+ font-weight: 700;
96
+ letter-spacing: 0.04em;
97
+ color: var(--accent-light);
98
+ }
99
+
100
+ .title {
101
+ font-family: "Playfair Display", serif;
102
+ font-size: 2rem;
103
+ font-weight: 600;
104
+ margin-bottom: 8px;
105
+ }
106
+
107
+ .subtitle {
108
+ color: var(--text-muted);
109
+ font-size: 0.95rem;
110
+ line-height: 1.5;
111
+ }
112
+
113
+ /* ===== Card ===== */
114
+ .card {
115
+ background: var(--surface);
116
+ border: 1px solid var(--border);
117
+ border-radius: var(--radius);
118
+ padding: 32px;
119
+ }
120
+
121
+ /* ===== Form Fields ===== */
122
+ .field {
123
+ margin-bottom: 24px;
124
+ }
125
+
126
+ .field label {
127
+ display: block;
128
+ font-size: 0.85rem;
129
+ font-weight: 600;
130
+ text-transform: uppercase;
131
+ letter-spacing: 0.06em;
132
+ color: var(--text-muted);
133
+ margin-bottom: 8px;
134
+ }
135
+
136
+ .field input,
137
+ .field textarea,
138
+ .field select {
139
+ width: 100%;
140
+ padding: 12px 16px;
141
+ background: var(--bg);
142
+ border: 1px solid var(--border);
143
+ border-radius: var(--radius-sm);
144
+ color: var(--text);
145
+ font-family: inherit;
146
+ font-size: 0.95rem;
147
+ transition: border-color 0.2s, box-shadow 0.2s;
148
+ outline: none;
149
+ }
150
+
151
+ .field input:focus,
152
+ .field textarea:focus,
153
+ .field select:focus {
154
+ border-color: var(--accent);
155
+ box-shadow: 0 0 0 3px rgba(201, 164, 108, 0.15);
156
+ }
157
+
158
+ .field input::placeholder,
159
+ .field textarea::placeholder {
160
+ color: var(--text-muted);
161
+ opacity: 0.6;
162
+ }
163
+
164
+ .field select {
165
+ cursor: pointer;
166
+ appearance: none;
167
+ -webkit-appearance: none;
168
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' fill='none'%3E%3Cpath d='M1 1.5l5 5 5-5' stroke='%238888a0' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
169
+ background-repeat: no-repeat;
170
+ background-position: right 16px center;
171
+ padding-right: 40px;
172
+ }
173
+
174
+ .field textarea {
175
+ resize: vertical;
176
+ min-height: 80px;
177
+ }
178
+
179
+ /* ===== Toggle Group ===== */
180
+ .toggle-group {
181
+ display: flex;
182
+ gap: 12px;
183
+ }
184
+
185
+ .toggle-btn {
186
+ flex: 1;
187
+ padding: 12px 16px;
188
+ border: 1px solid var(--border);
189
+ border-radius: var(--radius-sm);
190
+ background: var(--bg);
191
+ color: var(--text-muted);
192
+ font-family: inherit;
193
+ font-size: 0.95rem;
194
+ font-weight: 500;
195
+ cursor: pointer;
196
+ transition: all 0.2s;
197
+ display: flex;
198
+ align-items: center;
199
+ justify-content: center;
200
+ gap: 8px;
201
+ }
202
+
203
+ .toggle-btn:hover {
204
+ background: var(--surface-hover);
205
+ border-color: var(--text-muted);
206
+ }
207
+
208
+ .toggle-btn.active {
209
+ background: rgba(201, 164, 108, 0.1);
210
+ border-color: var(--accent);
211
+ color: var(--accent-light);
212
+ }
213
+
214
+ .toggle-icon {
215
+ font-size: 1.1rem;
216
+ }
217
+
218
+ /* ===== Submit Button ===== */
219
+ .submit-btn {
220
+ width: 100%;
221
+ padding: 14px 24px;
222
+ border: none;
223
+ border-radius: var(--radius-sm);
224
+ background: linear-gradient(135deg, var(--accent-dark), var(--accent));
225
+ color: #fff;
226
+ font-family: inherit;
227
+ font-size: 1rem;
228
+ font-weight: 600;
229
+ cursor: pointer;
230
+ transition: opacity 0.2s, transform 0.1s;
231
+ }
232
+
233
+ .submit-btn:hover {
234
+ opacity: 0.9;
235
+ }
236
+
237
+ .submit-btn:active {
238
+ transform: scale(0.98);
239
+ }
240
+
241
+ .submit-btn:disabled {
242
+ opacity: 0.6;
243
+ cursor: not-allowed;
244
+ }
245
+
246
+ .btn-loader {
247
+ display: inline-flex;
248
+ align-items: center;
249
+ gap: 8px;
250
+ }
251
+
252
+ .spinner {
253
+ display: inline-block;
254
+ width: 18px;
255
+ height: 18px;
256
+ border: 2px solid rgba(255, 255, 255, 0.3);
257
+ border-top-color: #fff;
258
+ border-radius: 50%;
259
+ animation: spin 0.6s linear infinite;
260
+ }
261
+
262
+ @keyframes spin {
263
+ to {
264
+ transform: rotate(360deg);
265
+ }
266
+ }
267
+
268
+ .hidden {
269
+ display: none !important;
270
+ }
271
+
272
+ /* ===== Error Banner ===== */
273
+ .error-banner {
274
+ margin-top: 24px;
275
+ padding: 14px 20px;
276
+ background: rgba(232, 84, 84, 0.1);
277
+ border: 1px solid rgba(232, 84, 84, 0.3);
278
+ border-radius: var(--radius-sm);
279
+ color: var(--danger);
280
+ font-size: 0.9rem;
281
+ line-height: 1.5;
282
+ }
283
+
284
+ /* ===== Results Section ===== */
285
+ .results {
286
+ margin-top: 40px;
287
+ }
288
+
289
+ .results-header {
290
+ display: flex;
291
+ align-items: center;
292
+ justify-content: space-between;
293
+ margin-bottom: 24px;
294
+ }
295
+
296
+ .results-title {
297
+ font-family: "Playfair Display", serif;
298
+ font-size: 1.4rem;
299
+ font-weight: 600;
300
+ }
301
+
302
+ .results-badge {
303
+ padding: 4px 12px;
304
+ border-radius: 20px;
305
+ font-size: 0.75rem;
306
+ font-weight: 600;
307
+ text-transform: uppercase;
308
+ letter-spacing: 0.06em;
309
+ }
310
+
311
+ .results-badge.gpt {
312
+ background: rgba(16, 163, 127, 0.15);
313
+ color: #10a37f;
314
+ }
315
+
316
+ .results-badge.claude {
317
+ background: rgba(204, 150, 68, 0.15);
318
+ color: var(--accent-light);
319
+ }
320
+
321
+ /* ===== Trigger Card ===== */
322
+ .trigger-card {
323
+ background: var(--surface);
324
+ border: 1px solid var(--border);
325
+ border-radius: var(--radius);
326
+ padding: 28px;
327
+ margin-bottom: 20px;
328
+ animation: fadeUp 0.4s ease both;
329
+ }
330
+
331
+ .trigger-card:nth-child(2) { animation-delay: 0.1s; }
332
+ .trigger-card:nth-child(3) { animation-delay: 0.2s; }
333
+ .trigger-card:nth-child(4) { animation-delay: 0.3s; }
334
+
335
+ @keyframes fadeUp {
336
+ from {
337
+ opacity: 0;
338
+ transform: translateY(16px);
339
+ }
340
+ to {
341
+ opacity: 1;
342
+ transform: translateY(0);
343
+ }
344
+ }
345
+
346
+ .trigger-label {
347
+ display: inline-block;
348
+ font-size: 0.7rem;
349
+ font-weight: 600;
350
+ text-transform: uppercase;
351
+ letter-spacing: 0.08em;
352
+ color: var(--accent);
353
+ background: rgba(201, 164, 108, 0.1);
354
+ padding: 4px 10px;
355
+ border-radius: 4px;
356
+ margin-bottom: 10px;
357
+ }
358
+
359
+ .trigger-name {
360
+ font-size: 1.15rem;
361
+ font-weight: 600;
362
+ margin-bottom: 20px;
363
+ line-height: 1.4;
364
+ }
365
+
366
+ .trigger-section {
367
+ margin-bottom: 16px;
368
+ }
369
+
370
+ .trigger-section:last-child {
371
+ margin-bottom: 0;
372
+ }
373
+
374
+ .section-title {
375
+ font-size: 0.75rem;
376
+ font-weight: 600;
377
+ text-transform: uppercase;
378
+ letter-spacing: 0.06em;
379
+ color: var(--text-muted);
380
+ margin-bottom: 8px;
381
+ }
382
+
383
+ .section-list {
384
+ list-style: none;
385
+ }
386
+
387
+ .section-list li {
388
+ position: relative;
389
+ padding: 8px 0 8px 20px;
390
+ font-size: 0.9rem;
391
+ line-height: 1.5;
392
+ color: var(--text);
393
+ border-bottom: 1px solid rgba(42, 42, 56, 0.5);
394
+ }
395
+
396
+ .section-list li:last-child {
397
+ border-bottom: none;
398
+ }
399
+
400
+ .section-list li::before {
401
+ content: "";
402
+ position: absolute;
403
+ left: 0;
404
+ top: 14px;
405
+ width: 6px;
406
+ height: 6px;
407
+ border-radius: 50%;
408
+ background: var(--accent);
409
+ opacity: 0.6;
410
+ }
411
+
412
+ /* ===== Responsive ===== */
413
+ @media (max-width: 600px) {
414
+ .container {
415
+ padding: 32px 16px 60px;
416
+ }
417
+
418
+ .card {
419
+ padding: 24px 20px;
420
+ }
421
+
422
+ .title {
423
+ font-size: 1.5rem;
424
+ }
425
+
426
+ .toggle-group {
427
+ gap: 8px;
428
+ }
429
+ }
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ openai
4
+ anthropic
5
+ pydantic
6
+ python-dotenv