Upload 16 files
Browse files- Dockerfile +13 -0
- backend/__init__.py +0 -0
- backend/__pycache__/claude_method.cpython-311.pyc +0 -0
- backend/__pycache__/gpt_method.cpython-311.pyc +0 -0
- backend/__pycache__/main.cpython-311.pyc +0 -0
- backend/__pycache__/prompt.cpython-311.pyc +0 -0
- backend/__pycache__/pydantic_schema.cpython-311.pyc +0 -0
- backend/claude_method.py +89 -0
- backend/gpt_method.py +52 -0
- backend/main.py +81 -0
- backend/prompt.py +41 -0
- backend/pydantic_schema.py +113 -0
- frontend/index.html +98 -0
- frontend/script.js +156 -0
- frontend/styles.css +429 -0
- requirements.txt +6 -0
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 & 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
|