MVP0
This commit is contained in:
138
README.md
Normal file
138
README.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# AdsAssistant — генерация и автотестирование рекламных текстов
|
||||
|
||||
Монорепозиторий из 3 сервисов:
|
||||
|
||||
1) **agents_service** (FastAPI) — агент №1 (генерация текстов) и агент №2 (анализ/ранжирование по метрикам)
|
||||
2) **backend_django** (Django + DRF + JWT, SQLite) — хранение брифов/вариантов/тестов/результатов + Swagger
|
||||
3) **frontend** (React/Vite) — пользовательский интерфейс для создания брифа, выбора форматов и генерации текстов
|
||||
|
||||
## Возможности (MVP0)
|
||||
|
||||
- Пользователь создаёт бриф и **сам выбирает форматы**: `social_post`, `search_ad`, `email`
|
||||
- Агент №1 генерирует тексты по выбранным форматам
|
||||
- Backend сохраняет варианты и отдаёт их фронтенду
|
||||
- Swagger для backend: `http://localhost:8000/api/docs/`
|
||||
- Swagger для agents: `http://localhost:8001/docs`
|
||||
|
||||
> Модуль тестирования (создание тестов, сегменты, ручной ввод результатов, анализ) уже заложен в backend,
|
||||
> но UI для него можно расширять следующим шагом.
|
||||
|
||||
---
|
||||
|
||||
## Быстрый старт через Docker (рекомендуется)
|
||||
|
||||
### 1) Требования
|
||||
- Docker Desktop (Windows/macOS) или Docker Engine + Compose (Linux)
|
||||
|
||||
### 2) Скачивание
|
||||
Склонируйте репозиторий или распакуйте архив в папку, например `adsassistant_full_project`.
|
||||
|
||||
### 3) Настройка секретов GigaChat
|
||||
Откройте файл: `agents_service/.env` и заполните:
|
||||
|
||||
```env
|
||||
GIGACHAT_CLIENT_ID=...
|
||||
GIGACHAT_CLIENT_SECRET=...
|
||||
```
|
||||
|
||||
### 4) Запуск
|
||||
Из корня проекта:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Откройте:
|
||||
- Frontend: http://localhost:5174
|
||||
- Backend Swagger: http://localhost:8000/api/docs/
|
||||
- Agents Swagger: http://localhost:8001/docs
|
||||
|
||||
---
|
||||
|
||||
## Запуск без Docker (локальная разработка)
|
||||
|
||||
### 1) Agents Service (8001)
|
||||
```bash
|
||||
cd agents_service
|
||||
python -m venv .venv
|
||||
# Windows: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
# заполните GIGACHAT_CLIENT_ID / GIGACHAT_CLIENT_SECRET
|
||||
python -m uvicorn src.main:app --reload --port 8001
|
||||
```
|
||||
|
||||
### 2) Django Backend (8000)
|
||||
```bash
|
||||
cd backend_django
|
||||
python -m venv .venv
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
python manage.py migrate
|
||||
python manage.py runserver 0.0.0.0:8000
|
||||
```
|
||||
|
||||
### 3) Frontend (5174)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
cp .env.example .env
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Первый сценарий использования
|
||||
|
||||
1) Создайте пользователя:
|
||||
- В Swagger backend: `POST /api/auth/register/`
|
||||
2) Получите JWT:
|
||||
- `POST /api/auth/token/` → `access`
|
||||
3) Во фронтенде войдите с логином/паролем
|
||||
4) Создайте бриф:
|
||||
- заполните продукт, аудиторию, выберите форматы
|
||||
5) Нажмите **«Сгенерировать тексты (Агент №1)»**
|
||||
6) Посмотрите список вариантов (ID + format + payload)
|
||||
|
||||
---
|
||||
|
||||
## Полезные адреса
|
||||
|
||||
- Frontend: `http://localhost:5174`
|
||||
- Backend API: `http://localhost:8000/api/`
|
||||
- Backend Swagger: `http://localhost:8000/api/docs/`
|
||||
- Django Admin: `http://localhost:8000/admin/` (можно создать суперпользователя локально: `python manage.py createsuperuser`)
|
||||
- Agents Swagger: `http://localhost:8001/docs`
|
||||
|
||||
---
|
||||
|
||||
## Структура репозитория
|
||||
|
||||
```text
|
||||
adsassistant_full_project/
|
||||
├── docker-compose.yml
|
||||
├── agents_service/
|
||||
├── backend_django/
|
||||
└── frontend/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Примечания
|
||||
|
||||
- SQLite база backend сохраняется в docker volume `backend_db`.
|
||||
- В Docker-сборке frontend использует `VITE_API_BASE_URL=http://localhost:8000` (см. `frontend/.env.production`).
|
||||
- Для продакшена рекомендуется:
|
||||
- вынести SQLite на PostgreSQL
|
||||
- включить HTTPS и нормальные секреты
|
||||
- добавить роль admin и отдельную админ-панель/страницы
|
||||
|
||||
|
||||
Note: frontend/public is optional; docker build does not require it.
|
||||
|
||||
|
||||
## JWT токены (время жизни)
|
||||
|
||||
В `backend_django/adsassistant_backend/adsassistant_backend/settings.py` настроено:
|
||||
- ACCESS token: 1 день
|
||||
- REFRESH token: 7 дней
|
||||
6
agents_service/.env
Normal file
6
agents_service/.env
Normal file
@@ -0,0 +1,6 @@
|
||||
GIGACHAT_CLIENT_ID=019cb2c4-b0cc-782b-af27-07bf919ce7c4
|
||||
GIGACHAT_CLIENT_SECRET=58757e78-eaa4-4f67-b06a-2d02b655fd5d
|
||||
GIGACHAT_SCOPE=GIGACHAT_API_PERS
|
||||
GIGACHAT_MODEL=GigaChat
|
||||
GIGACHAT_VERIFY_SSL_CERTS=false
|
||||
PORT=8001
|
||||
6
agents_service/.env.example
Normal file
6
agents_service/.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
GIGACHAT_CLIENT_ID=your_client_id_here
|
||||
GIGACHAT_CLIENT_SECRET=your_client_secret_here
|
||||
GIGACHAT_SCOPE=GIGACHAT_API_PERS
|
||||
GIGACHAT_MODEL=GigaChat
|
||||
GIGACHAT_VERIFY_SSL_CERTS=false
|
||||
PORT=8001
|
||||
13
agents_service/Dockerfile
Normal file
13
agents_service/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY src ./src
|
||||
COPY .env.example ./.env.example
|
||||
|
||||
EXPOSE 8001
|
||||
CMD ["python","-m","uvicorn","src.main:app","--host","0.0.0.0","--port","8001"]
|
||||
14
agents_service/README.md
Normal file
14
agents_service/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Agents Service (FastAPI)
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
# Windows: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
python -m uvicorn src.main:app --reload --port 8001
|
||||
```
|
||||
|
||||
- Swagger: http://localhost:8001/docs
|
||||
|
||||
|
||||
Docker: build from root with `docker compose up --build`
|
||||
7
agents_service/requirements.txt
Normal file
7
agents_service/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
fastapi>=0.110,<1
|
||||
uvicorn[standard]>=0.29,<1
|
||||
pydantic>=2.10,<3
|
||||
python-dotenv>=1,<2
|
||||
langchain>=0.3.27,<0.4
|
||||
langchain-community>=0.3.0,<0.4
|
||||
gigachat>=0.1.0
|
||||
193
agents_service/src/agents/analyze_agent.py
Normal file
193
agents_service/src/agents/analyze_agent.py
Normal file
@@ -0,0 +1,193 @@
|
||||
from math import inf
|
||||
import re
|
||||
|
||||
def safe_div(a, b):
|
||||
return (a / b) if b else None
|
||||
|
||||
KPI_LABELS = {
|
||||
"ctr": "CTR",
|
||||
"cpc": "CPC",
|
||||
"cr": "CR",
|
||||
"cpl": "CPL",
|
||||
"cpa": "CPA",
|
||||
}
|
||||
|
||||
def parse_policy_text(text: str) -> dict:
|
||||
"""Very small heuristic parser for free-form requests.
|
||||
|
||||
Supports phrases like:
|
||||
'Оптимизируй по CPA, хороший до 800, средний до 1200, клики минимум 20'
|
||||
"""
|
||||
if not text:
|
||||
return {}
|
||||
t = text.lower()
|
||||
kpi = None
|
||||
for k in ["cpa","cpl","cpc","ctr","cr"]:
|
||||
if k in t:
|
||||
kpi = k
|
||||
break
|
||||
direction = "max" if kpi in ("ctr","cr") else "min"
|
||||
nums = list(map(float, re.findall(r"(\d+[\.,]?\d*)", t)))
|
||||
good = nums[0] if len(nums) >= 1 else None
|
||||
ok = nums[1] if len(nums) >= 2 else None
|
||||
min_clicks = 0
|
||||
m = re.search(r"клик[аио]в?\s*(?:минимум|min)\s*(\d+)", t)
|
||||
if m: min_clicks = int(m.group(1))
|
||||
min_impr = 0
|
||||
m = re.search(r"показ[а-я]*\s*(?:минимум|min)\s*(\d+)", t)
|
||||
if m: min_impr = int(m.group(1))
|
||||
return {
|
||||
"mode": "text",
|
||||
"primary_kpi": kpi or "cpl",
|
||||
"direction": direction,
|
||||
"good_threshold": good,
|
||||
"ok_threshold": ok,
|
||||
"min_clicks": min_clicks,
|
||||
"min_impressions": min_impr,
|
||||
"query_text": text,
|
||||
}
|
||||
|
||||
class AnalyzeAgent:
|
||||
def analyze(self, req: dict) -> dict:
|
||||
rows = req["rows"]
|
||||
objective = (req.get("objective") or "leads").lower()
|
||||
policy = req.get("policy") or {}
|
||||
if policy.get("mode") == "text" and policy.get("query_text"):
|
||||
policy = {**parse_policy_text(policy.get("query_text")), **policy}
|
||||
|
||||
primary_kpi = (policy.get("primary_kpi") or "").lower() or ("cpl" if objective=="leads" else "cpa" if objective=="conversions" else "cpc")
|
||||
direction = (policy.get("direction") or ("max" if primary_kpi in ("ctr","cr") else "min")).lower()
|
||||
good_th = policy.get("good_threshold", None)
|
||||
ok_th = policy.get("ok_threshold", None)
|
||||
min_impr = int(policy.get("min_impressions") or 0)
|
||||
min_clicks = int(policy.get("min_clicks") or 0)
|
||||
|
||||
# score per (variant, segment) first
|
||||
by_variant = {}
|
||||
for r in rows:
|
||||
vid = r.get("variant_id")
|
||||
fmt = r.get("format")
|
||||
seg_id = r.get("segment_id")
|
||||
seg_name = r.get("segment_name") or (f"Сегмент {seg_id}" if seg_id is not None else None)
|
||||
|
||||
impressions = int(r.get("impressions") or 0)
|
||||
clicks = int(r.get("clicks") or 0)
|
||||
conversions = int(r.get("conversions") or 0)
|
||||
leads = int(r.get("leads") or 0)
|
||||
spend = float(r.get("spend") or 0.0)
|
||||
|
||||
ctr = safe_div(clicks, impressions)
|
||||
cr = safe_div(conversions, clicks)
|
||||
cpc = safe_div(spend, clicks)
|
||||
cpa = safe_div(spend, conversions) if conversions else None
|
||||
cpl = safe_div(spend, leads) if leads else None
|
||||
|
||||
m = {
|
||||
"impressions": impressions, "clicks": clicks, "conversions": conversions, "leads": leads, "spend": spend,
|
||||
"ctr": ctr, "cr": cr, "cpc": cpc, "cpa": cpa, "cpl": cpl
|
||||
}
|
||||
|
||||
entry = by_variant.setdefault(vid, {"variant_id": vid, "format": fmt, "totals": {"impressions":0,"clicks":0,"conversions":0,"leads":0,"spend":0.0}, "segments": []})
|
||||
entry["format"] = entry["format"] or fmt
|
||||
entry["totals"]["impressions"] += impressions
|
||||
entry["totals"]["clicks"] += clicks
|
||||
entry["totals"]["conversions"] += conversions
|
||||
entry["totals"]["leads"] += leads
|
||||
entry["totals"]["spend"] += spend
|
||||
entry["segments"].append({"segment_id": seg_id, "segment_name": seg_name, "metrics": m})
|
||||
|
||||
# compute aggregated metrics per variant
|
||||
scored=[]
|
||||
for vid, data in by_variant.items():
|
||||
t = data["totals"]
|
||||
ctr = safe_div(t["clicks"], t["impressions"])
|
||||
cr = safe_div(t["conversions"], t["clicks"])
|
||||
cpc = safe_div(t["spend"], t["clicks"])
|
||||
cpa = safe_div(t["spend"], t["conversions"]) if t["conversions"] else None
|
||||
cpl = safe_div(t["spend"], t["leads"]) if t["leads"] else None
|
||||
|
||||
agg = {**t, "ctr": ctr, "cr": cr, "cpc": cpc, "cpa": cpa, "cpl": cpl}
|
||||
|
||||
# choose KPI value
|
||||
kpi_value = agg.get(primary_kpi)
|
||||
# low data rule
|
||||
low_data = (t["impressions"] < min_impr) or (t["clicks"] < min_clicks)
|
||||
status = "low_data" if low_data else "unknown"
|
||||
if not low_data and kpi_value is not None:
|
||||
if direction == "min":
|
||||
if good_th is not None and kpi_value <= good_th:
|
||||
status = "good"
|
||||
elif ok_th is not None and kpi_value <= ok_th:
|
||||
status = "ok"
|
||||
else:
|
||||
status = "bad"
|
||||
else:
|
||||
if good_th is not None and kpi_value >= good_th:
|
||||
status = "good"
|
||||
elif ok_th is not None and kpi_value >= ok_th:
|
||||
status = "ok"
|
||||
else:
|
||||
status = "bad"
|
||||
|
||||
# sort key
|
||||
if kpi_value is None:
|
||||
sort_k = inf if direction=="min" else -inf
|
||||
else:
|
||||
sort_k = kpi_value if direction=="min" else -kpi_value
|
||||
scored.append({
|
||||
"variant_id": vid,
|
||||
"format": data["format"],
|
||||
"status": status,
|
||||
"kpi": primary_kpi,
|
||||
"kpi_value": kpi_value,
|
||||
"metrics": agg,
|
||||
"segments": data["segments"],
|
||||
"_sort": (0 if status!="low_data" else 1, sort_k, -(ctr or 0)),
|
||||
})
|
||||
|
||||
scored.sort(key=lambda x: x["_sort"])
|
||||
|
||||
ranking=[]
|
||||
for i, s in enumerate(scored, start=1):
|
||||
ranking.append({
|
||||
"rank": i,
|
||||
"variant_id": s["variant_id"],
|
||||
"format": s["format"],
|
||||
"status": s["status"],
|
||||
"kpi": s["kpi"],
|
||||
"kpi_value": s["kpi_value"],
|
||||
"metrics": s["metrics"],
|
||||
"segments": s["segments"],
|
||||
})
|
||||
|
||||
# recommendations (deterministic, but "agent-like")
|
||||
rec=[]
|
||||
if ranking:
|
||||
good = [x for x in ranking if x["status"]=="good"]
|
||||
bad = [x for x in ranking if x["status"]=="bad"]
|
||||
ok = [x for x in ranking if x["status"]=="ok"]
|
||||
ld = [x for x in ranking if x["status"]=="low_data"]
|
||||
|
||||
if good:
|
||||
rec.append(f"Масштабируйте: лучший текст #{good[0]['variant_id']} (статус: хороший по {KPI_LABELS.get(primary_kpi, primary_kpi)}).")
|
||||
if ok:
|
||||
rec.append("Средние варианты можно улучшить: проверьте УТП/CTA и уточните аудиторию сегмента.")
|
||||
if bad:
|
||||
rec.append("Плохие варианты лучше переписать: попробуйте другой заголовок/обещание, проверьте ограничения и соответствие сегменту.")
|
||||
if ld:
|
||||
rec.append("Для части вариантов мало данных. Наберите больше показов/кликов, затем повторите анализ.")
|
||||
|
||||
return {
|
||||
"policy_used": {
|
||||
"mode": policy.get("mode") or "thresholds",
|
||||
"primary_kpi": primary_kpi,
|
||||
"direction": direction,
|
||||
"good_threshold": good_th,
|
||||
"ok_threshold": ok_th,
|
||||
"min_impressions": min_impr,
|
||||
"min_clicks": min_clicks,
|
||||
"query_text": policy.get("query_text"),
|
||||
},
|
||||
"ranking": ranking,
|
||||
"recommendations": rec,
|
||||
}
|
||||
29
agents_service/src/agents/textgen_agent.py
Normal file
29
agents_service/src/agents/textgen_agent.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from src.chains.text_generation import TextGenerationChain
|
||||
|
||||
class TextGenAgent:
|
||||
def __init__(self):
|
||||
self.chain = TextGenerationChain()
|
||||
|
||||
async def generate(self, req: dict) -> dict:
|
||||
formats = req["formats"]
|
||||
n = int(req.get("variants_per_format", 3))
|
||||
brief = {
|
||||
"product": req["product"],
|
||||
"audience": req["audience"],
|
||||
"usp": req.get("usp"),
|
||||
"benefits": req.get("benefits") or [],
|
||||
"constraints": req.get("constraints"),
|
||||
"tone": req.get("tone"),
|
||||
}
|
||||
variants = []
|
||||
for fmt in formats:
|
||||
items = await self.chain.generate_for_format(brief, fmt, n)
|
||||
variants.append({
|
||||
"format": fmt,
|
||||
"items": [{
|
||||
"payload": it,
|
||||
"placement_tips": "Тестируйте 2-3 варианта в одном канале, фиксируйте клики/лиды вручную.",
|
||||
"expected_effect": "Гипотеза: улучшение CTR/CR за счёт другого УТП/CTA."
|
||||
} for it in items],
|
||||
})
|
||||
return {"formats": formats, "variants": variants}
|
||||
30
agents_service/src/api/routes.py
Normal file
30
agents_service/src/api/routes.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from src.models.schemas import TextGenRequest, AnalyzeRequest
|
||||
from src.agents.textgen_agent import TextGenAgent
|
||||
from src.agents.analyze_agent import AnalyzeAgent
|
||||
|
||||
router = APIRouter()
|
||||
_textgen = None
|
||||
_analyze = AnalyzeAgent()
|
||||
|
||||
def get_textgen():
|
||||
global _textgen
|
||||
if _textgen is None:
|
||||
_textgen = TextGenAgent()
|
||||
return _textgen
|
||||
|
||||
@router.post("/texts/generate")
|
||||
async def texts_generate(req: TextGenRequest):
|
||||
try:
|
||||
return await get_textgen().generate(req.model_dump())
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/tests/analyze")
|
||||
async def tests_analyze(req: AnalyzeRequest):
|
||||
try:
|
||||
return _analyze.analyze(req.model_dump())
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
50
agents_service/src/chains/text_generation.py
Normal file
50
agents_service/src/chains/text_generation.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import json
|
||||
from langchain.prompts import PromptTemplate
|
||||
from langchain.chains import LLMChain
|
||||
from src.llm.gigachat_client import GigaChatClient
|
||||
|
||||
FORMAT_SPECS = {
|
||||
"social_post": {
|
||||
"instruction": "Пост для соцсетей: hook (1 строка), body (3-6 строк), cta (1 строка).",
|
||||
"schema": {"hook":"...", "body":"...", "cta":"..."}
|
||||
},
|
||||
"search_ad": {
|
||||
"instruction": "Поисковое объявление: 5 заголовков (<= 56 символов) и 5 описаний (<= 81 символ).",
|
||||
"schema": {"headlines":["..."], "descriptions":["..."], "cta":"..."}
|
||||
},
|
||||
"email": {
|
||||
"instruction": "Email: subject, preheader, body (коротко), cta.",
|
||||
"schema": {"subject":"...", "preheader":"...", "body":"...", "cta":"..."}
|
||||
}
|
||||
}
|
||||
|
||||
def _escape_braces(s: str) -> str:
|
||||
return s.replace("{","{{").replace("}","}}")
|
||||
|
||||
class TextGenerationChain:
|
||||
def __init__(self):
|
||||
self.client = GigaChatClient(temperature=0.7, max_tokens=2200)
|
||||
|
||||
async def generate_for_format(self, brief: dict, fmt: str, n: int) -> list[dict]:
|
||||
spec = FORMAT_SPECS.get(fmt, {"instruction":"Рекламный текст + CTA.", "schema":{"text":"...", "cta":"..."}})
|
||||
schema = _escape_braces(json.dumps(spec["schema"], ensure_ascii=False, indent=2))
|
||||
|
||||
prompt = PromptTemplate(
|
||||
input_variables=["brief_json","n"],
|
||||
template=(
|
||||
"Ты маркетолог и копирайтер. Пиши по-русски.\n"
|
||||
"Соблюдай ограничения, не обещай гарантии.\n"
|
||||
f"Формат: {fmt}. {spec['instruction']}\n\n"
|
||||
"Бриф (JSON): {brief_json}\n"
|
||||
"Сгенерируй {n} вариантов.\n"
|
||||
"Верни ТОЛЬКО JSON массив объектов, без markdown.\n"
|
||||
"Пример схемы одного объекта:\n"
|
||||
f"{schema}\n"
|
||||
),
|
||||
)
|
||||
chain = LLMChain(llm=self.client.llm, prompt=prompt)
|
||||
raw = await chain.apredict(brief_json=json.dumps(brief, ensure_ascii=False), n=str(n))
|
||||
start, end = raw.find("["), raw.rfind("]")
|
||||
if start == -1 or end == -1:
|
||||
raise ValueError("LLM did not return JSON array")
|
||||
return json.loads(raw[start:end+1])
|
||||
22
agents_service/src/llm/gigachat_client.py
Normal file
22
agents_service/src/llm/gigachat_client.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import os
|
||||
import base64
|
||||
from langchain_community.llms import GigaChat
|
||||
|
||||
class GigaChatClient:
|
||||
def __init__(self, temperature: float = 0.6, max_tokens: int = 2000):
|
||||
client_id = (os.getenv("GIGACHAT_CLIENT_ID") or "").strip().strip('"').strip("'")
|
||||
client_secret = (os.getenv("GIGACHAT_CLIENT_SECRET") or "").strip().strip('"').strip("'")
|
||||
if not client_id or not client_secret:
|
||||
raise ValueError("Set GIGACHAT_CLIENT_ID and GIGACHAT_CLIENT_SECRET in .env")
|
||||
credentials = base64.b64encode(f"{client_id}:{client_secret}".encode("utf-8")).decode("utf-8")
|
||||
scope = (os.getenv("GIGACHAT_SCOPE") or "GIGACHAT_API_PERS").strip()
|
||||
model = (os.getenv("GIGACHAT_MODEL") or "GigaChat").strip()
|
||||
verify_ssl = (os.getenv("GIGACHAT_VERIFY_SSL_CERTS","false").lower() in ("1","true","yes","y","on"))
|
||||
self.llm = GigaChat(
|
||||
credentials=credentials,
|
||||
scope=scope,
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
verify_ssl_certs=verify_ssl,
|
||||
)
|
||||
15
agents_service/src/main.py
Normal file
15
agents_service/src/main.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
load_dotenv()
|
||||
app = FastAPI(title="AdsAssistant Agents Service", version="0.1.0")
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
from src.api.routes import router # noqa
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"ok": True}
|
||||
18
agents_service/src/models/schemas.py
Normal file
18
agents_service/src/models/schemas.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
class TextGenRequest(BaseModel):
|
||||
product: str
|
||||
audience: str
|
||||
usp: Optional[str] = None
|
||||
benefits: List[str] = []
|
||||
constraints: Optional[str] = None
|
||||
tone: Optional[str] = None
|
||||
formats: List[str] = Field(..., min_length=1)
|
||||
variants_per_format: int = Field(3, ge=1, le=10)
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
rows: List[Dict[str, Any]] # {variant_id, format, impressions, clicks, conversions, leads, spend}
|
||||
objective: str = "leads"
|
||||
policy: Optional[Dict[str, Any]] = None
|
||||
notes: Optional[str] = None
|
||||
5
backend_django/.env
Normal file
5
backend_django/.env
Normal file
@@ -0,0 +1,5 @@
|
||||
DJANGO_SECRET_KEY=dev-secret
|
||||
DJANGO_DEBUG=1
|
||||
ALLOWED_HOSTS=127.0.0.1,localhost
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:5174
|
||||
AGENTS_SERVICE_URL=http://agents:8001
|
||||
5
backend_django/.env.example
Normal file
5
backend_django/.env.example
Normal file
@@ -0,0 +1,5 @@
|
||||
DJANGO_SECRET_KEY=dev-secret
|
||||
DJANGO_DEBUG=1
|
||||
ALLOWED_HOSTS=127.0.0.1,localhost
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:5174
|
||||
AGENTS_SERVICE_URL=http://localhost:8001
|
||||
16
backend_django/Dockerfile
Normal file
16
backend_django/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy Django project (project root folder) and manage.py
|
||||
WORKDIR /app/adsassistant_backend
|
||||
COPY adsassistant_backend/ ./
|
||||
COPY manage.py ./manage.py
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["bash","-lc","mkdir -p data && python manage.py makemigrations api && python manage.py migrate && python manage.py runserver 0.0.0.0:8000"]
|
||||
15
backend_django/README.md
Normal file
15
backend_django/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Django Backend
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
|
||||
python manage.py migrate
|
||||
python manage.py runserver 0.0.0.0:8000
|
||||
```
|
||||
|
||||
Swagger: http://localhost:8000/api/docs/
|
||||
|
||||
|
||||
Docker: build from root with `docker compose up --build`
|
||||
@@ -0,0 +1,64 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import timedelta
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-secret")
|
||||
DEBUG = os.getenv("DJANGO_DEBUG","1") == "1"
|
||||
ALLOWED_HOSTS = [h.strip() for h in os.getenv("ALLOWED_HOSTS","127.0.0.1,localhost").split(",") if h.strip()]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin","django.contrib.auth","django.contrib.contenttypes","django.contrib.sessions","django.contrib.messages","django.contrib.staticfiles",
|
||||
"corsheaders","rest_framework","drf_spectacular","api",
|
||||
]
|
||||
MIDDLEWARE = [
|
||||
"corsheaders.middleware.CorsMiddleware","django.middleware.security.SecurityMiddleware","django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware","django.middleware.csrf.CsrfViewMiddleware","django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware","django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
ROOT_URLCONF="adsassistant_backend.urls"
|
||||
TEMPLATES=[{"BACKEND":"django.template.backends.django.DjangoTemplates","DIRS":[],"APP_DIRS":True,"OPTIONS":{"context_processors":[
|
||||
"django.template.context_processors.debug","django.template.context_processors.request","django.contrib.auth.context_processors.auth","django.contrib.messages.context_processors.messages",
|
||||
]}}]
|
||||
WSGI_APPLICATION="adsassistant_backend.wsgi.application"
|
||||
DATABASES={"default":{"ENGINE":"django.db.backends.sqlite3","NAME":BASE_DIR/"data"/"db.sqlite3"}}
|
||||
|
||||
LANGUAGE_CODE="ru-ru"
|
||||
TIME_ZONE="Europe/Amsterdam"
|
||||
USE_I18N=True
|
||||
USE_TZ=True
|
||||
STATIC_URL="static/"
|
||||
|
||||
CORS_ALLOWED_ORIGINS=[o.strip() for o in os.getenv("CORS_ALLOWED_ORIGINS","http://localhost:5174").split(",") if o.strip()]
|
||||
|
||||
REST_FRAMEWORK={
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework_simplejwt.authentication.JWTAuthentication",),
|
||||
"DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
}
|
||||
SPECTACULAR_SETTINGS={
|
||||
"TITLE":"AdsAssistant Backend API",
|
||||
"VERSION":"0.1.0",
|
||||
"SERVE_INCLUDE_SCHEMA": False,
|
||||
# By default, endpoints are protected with JWT (bearerAuth).
|
||||
# Public endpoints can override with @extend_schema(auth=[]).
|
||||
"SECURITY":[{"bearerAuth": []}],
|
||||
"COMPONENTS":{
|
||||
"securitySchemes":{
|
||||
"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT"}
|
||||
}
|
||||
},
|
||||
"SWAGGER_UI_SETTINGS":{"persistAuthorization": True},
|
||||
}
|
||||
AGENTS_SERVICE_URL=os.getenv("AGENTS_SERVICE_URL","http://localhost:8001").rstrip("/")
|
||||
|
||||
DEFAULT_AUTO_FIELD="django.db.models.BigAutoField"
|
||||
|
||||
# JWT settings (SimpleJWT)
|
||||
# Access token lifetime controls how long the user can call API without re-login.
|
||||
SIMPLE_JWT = {
|
||||
"ACCESS_TOKEN_LIFETIME": timedelta(days=1),
|
||||
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
|
||||
path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
|
||||
path("api/", include("api.urls")),
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
import os
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE","adsassistant_backend.settings")
|
||||
application = get_wsgi_application()
|
||||
9
backend_django/adsassistant_backend/api/admin.py
Normal file
9
backend_django/adsassistant_backend/api/admin.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.contrib import admin
|
||||
from .models import Brief, TextVariant, Test, Segment, Assignment, ResultEntry, MetricsSnapshot
|
||||
admin.site.register(Brief)
|
||||
admin.site.register(TextVariant)
|
||||
admin.site.register(Test)
|
||||
admin.site.register(Segment)
|
||||
admin.site.register(Assignment)
|
||||
admin.site.register(ResultEntry)
|
||||
admin.site.register(MetricsSnapshot)
|
||||
98
backend_django/adsassistant_backend/api/models.py
Normal file
98
backend_django/adsassistant_backend/api/models.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
class Brief(models.Model):
|
||||
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name="briefs")
|
||||
product = models.TextField()
|
||||
audience = models.TextField()
|
||||
usp = models.TextField(blank=True, null=True)
|
||||
benefits = models.JSONField(default=list, blank=True)
|
||||
constraints = models.TextField(blank=True, null=True)
|
||||
tone = models.CharField(max_length=120, blank=True, null=True)
|
||||
|
||||
formats = models.JSONField(default=list) # client chooses formats
|
||||
variants_per_format = models.PositiveIntegerField(default=3)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class TextVariant(models.Model):
|
||||
brief = models.ForeignKey(Brief, on_delete=models.CASCADE, related_name="variants")
|
||||
format = models.CharField(max_length=50)
|
||||
payload = models.JSONField(default=dict)
|
||||
placement_tips = models.TextField(blank=True, default="")
|
||||
expected_effect = models.TextField(blank=True, default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Test(models.Model):
|
||||
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tests")
|
||||
brief = models.ForeignKey(Brief, on_delete=models.CASCADE, related_name="tests")
|
||||
name = models.CharField(max_length=200, default="Тест")
|
||||
channel = models.CharField(max_length=120, blank=True, default="")
|
||||
duration_days = models.PositiveIntegerField(default=3)
|
||||
sample_size = models.PositiveIntegerField(default=0)
|
||||
objective = models.CharField(max_length=32, default="leads") # leads|conversions|clicks
|
||||
status = models.CharField(max_length=20, default="draft")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Segment(models.Model):
|
||||
test = models.ForeignKey(Test, on_delete=models.CASCADE, related_name="segments")
|
||||
name = models.CharField(max_length=200)
|
||||
description = models.TextField(blank=True, default="")
|
||||
|
||||
class Assignment(models.Model):
|
||||
test = models.ForeignKey(Test, on_delete=models.CASCADE, related_name="assignments")
|
||||
segment = models.ForeignKey(Segment, on_delete=models.CASCADE, related_name="assignments")
|
||||
variant = models.ForeignKey(TextVariant, on_delete=models.CASCADE, related_name="assignments")
|
||||
|
||||
class ResultEntry(models.Model):
|
||||
test = models.ForeignKey(Test, on_delete=models.CASCADE, related_name="results")
|
||||
segment = models.ForeignKey(Segment, on_delete=models.CASCADE, related_name="results")
|
||||
variant = models.ForeignKey(TextVariant, on_delete=models.CASCADE, related_name="results")
|
||||
date = models.DateField()
|
||||
impressions = models.PositiveIntegerField(default=0)
|
||||
clicks = models.PositiveIntegerField(default=0)
|
||||
conversions = models.PositiveIntegerField(default=0)
|
||||
leads = models.PositiveIntegerField(default=0)
|
||||
spend = models.FloatField(default=0.0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class MetricsSnapshot(models.Model):
|
||||
test = models.OneToOneField(Test, on_delete=models.CASCADE, related_name="snapshot")
|
||||
ranking = models.JSONField(default=list)
|
||||
recommendations = models.JSONField(default=list)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
|
||||
class OptimizationPolicy(models.Model):
|
||||
"""User-defined optimization rules for Agent #2.
|
||||
|
||||
Supports two modes:
|
||||
- thresholds: structured KPI + thresholds
|
||||
- text: free-form query that describes ranking/thresholds
|
||||
"""
|
||||
MODE_CHOICES = [
|
||||
("thresholds", "thresholds"),
|
||||
("text", "text"),
|
||||
]
|
||||
KPI_CHOICES = [
|
||||
("cpa", "cpa"),
|
||||
("cpl", "cpl"),
|
||||
("cpc", "cpc"),
|
||||
("ctr", "ctr"),
|
||||
("cr", "cr"),
|
||||
]
|
||||
test = models.OneToOneField("Test", on_delete=models.CASCADE, related_name="policy")
|
||||
mode = models.CharField(max_length=16, choices=MODE_CHOICES, default="thresholds")
|
||||
|
||||
# Structured mode
|
||||
primary_kpi = models.CharField(max_length=8, choices=KPI_CHOICES, default="cpl")
|
||||
direction = models.CharField(max_length=8, default="min") # min or max
|
||||
good_threshold = models.FloatField(null=True, blank=True)
|
||||
ok_threshold = models.FloatField(null=True, blank=True)
|
||||
min_impressions = models.IntegerField(default=0)
|
||||
min_clicks = models.IntegerField(default=0)
|
||||
|
||||
# Text mode
|
||||
query_text = models.TextField(blank=True, null=True)
|
||||
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
63
backend_django/adsassistant_backend/api/serializers.py
Normal file
63
backend_django/adsassistant_backend/api/serializers.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth.models import User
|
||||
from .models import Brief, TextVariant, Test, Segment, Assignment, ResultEntry, MetricsSnapshot, OptimizationPolicy
|
||||
|
||||
class RegisterSerializer(serializers.ModelSerializer):
|
||||
password = serializers.CharField(write_only=True, min_length=6)
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ("username","password","email")
|
||||
def create(self, validated):
|
||||
user = User(username=validated["username"], email=validated.get("email",""))
|
||||
user.set_password(validated["password"])
|
||||
user.save()
|
||||
return user
|
||||
|
||||
class BriefSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Brief
|
||||
fields = "__all__"
|
||||
read_only_fields = ("id","owner","created_at")
|
||||
|
||||
class TextVariantSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = TextVariant
|
||||
fields = "__all__"
|
||||
read_only_fields = ("id","created_at")
|
||||
|
||||
class TestSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Test
|
||||
fields = "__all__"
|
||||
read_only_fields = ("id","owner","created_at")
|
||||
|
||||
class SegmentSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Segment
|
||||
fields = "__all__"
|
||||
read_only_fields = ("id",)
|
||||
|
||||
class AssignmentSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Assignment
|
||||
fields = "__all__"
|
||||
read_only_fields = ("id",)
|
||||
|
||||
class ResultEntrySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ResultEntry
|
||||
fields = "__all__"
|
||||
read_only_fields = ("id","created_at")
|
||||
|
||||
class MetricsSnapshotSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = MetricsSnapshot
|
||||
fields = "__all__"
|
||||
read_only_fields = ("id","created_at")
|
||||
|
||||
|
||||
class OptimizationPolicySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = OptimizationPolicy
|
||||
fields = "__all__"
|
||||
read_only_fields = ("id","test","updated_at")
|
||||
77
backend_django/adsassistant_backend/api/services.py
Normal file
77
backend_django/adsassistant_backend/api/services.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import requests
|
||||
|
||||
|
||||
def _raise_with_detail(r: requests.Response):
|
||||
"""Raise an HTTPError but include upstream error body for easier debugging."""
|
||||
try:
|
||||
detail = r.json()
|
||||
except Exception:
|
||||
detail = r.text
|
||||
http_error_msg = f"{r.status_code} {r.url}: {detail}"
|
||||
raise requests.HTTPError(http_error_msg, response=r)
|
||||
from django.conf import settings
|
||||
from django.db.models import Sum
|
||||
from .models import Test, ResultEntry, TextVariant
|
||||
|
||||
def agents_generate_texts(payload: dict) -> dict:
|
||||
url = f"{settings.AGENTS_SERVICE_URL}/api/v1/texts/generate"
|
||||
r = requests.post(url, json=payload, timeout=180)
|
||||
|
||||
if r.status_code >= 400:
|
||||
_raise_with_detail(r)
|
||||
|
||||
return r.json()
|
||||
|
||||
def agents_analyze(rows: list[dict], objective: str, policy: dict | None = None) -> dict:
|
||||
url = f"{settings.AGENTS_SERVICE_URL}/api/v1/tests/analyze"
|
||||
payload = {"rows": rows, "objective": objective}
|
||||
if policy:
|
||||
payload["policy"] = policy
|
||||
r = requests.post(url, json=payload, timeout=90)
|
||||
|
||||
if r.status_code >= 400:
|
||||
_raise_with_detail(r)
|
||||
|
||||
return r.json()
|
||||
|
||||
def aggregate_test_rows(test: Test) -> list[dict]:
|
||||
qs = (ResultEntry.objects.filter(test=test).values("variant_id").annotate(
|
||||
impressions=Sum("impressions"), clicks=Sum("clicks"), conversions=Sum("conversions"), leads=Sum("leads"), spend=Sum("spend")
|
||||
))
|
||||
rows=[]
|
||||
for row in qs:
|
||||
v = TextVariant.objects.get(id=row["variant_id"])
|
||||
rows.append({
|
||||
"variant_id": v.id, "format": v.format,
|
||||
"impressions": int(row["impressions"] or 0),
|
||||
"clicks": int(row["clicks"] or 0),
|
||||
"conversions": int(row["conversions"] or 0),
|
||||
"leads": int(row["leads"] or 0),
|
||||
"spend": float(row["spend"] or 0.0),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def aggregate_test_rows_by_segment(test: Test) -> list[dict]:
|
||||
"""Aggregate stats per (segment, variant)."""
|
||||
qs = (ResultEntry.objects.filter(test=test)
|
||||
.values("segment_id", "variant_id")
|
||||
.annotate(impressions=Sum("impressions"), clicks=Sum("clicks"),
|
||||
conversions=Sum("conversions"), leads=Sum("leads"), spend=Sum("spend")))
|
||||
rows = []
|
||||
# preload formats
|
||||
fmt_map = {tv.id: tv.format for tv in TextVariant.objects.filter(brief=test.brief)}
|
||||
seg_map = {s.id: s.name for s in test.segments.all()}
|
||||
for r in qs:
|
||||
rows.append({
|
||||
"variant_id": r["variant_id"],
|
||||
"format": fmt_map.get(r["variant_id"]),
|
||||
"segment_id": r["segment_id"],
|
||||
"segment_name": seg_map.get(r["segment_id"]),
|
||||
"impressions": int(r["impressions"] or 0),
|
||||
"clicks": int(r["clicks"] or 0),
|
||||
"conversions": int(r["conversions"] or 0),
|
||||
"leads": int(r["leads"] or 0),
|
||||
"spend": float(r["spend"] or 0.0),
|
||||
})
|
||||
return rows
|
||||
23
backend_django/adsassistant_backend/api/urls.py
Normal file
23
backend_django/adsassistant_backend/api/urls.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from rest_framework_simplejwt.views import TokenRefreshView
|
||||
|
||||
from .views import (
|
||||
register,
|
||||
PublicTokenObtainPairView,
|
||||
BriefViewSet,
|
||||
TextVariantViewSet,
|
||||
TestViewSet,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r"briefs", BriefViewSet, basename="brief")
|
||||
router.register(r"variants", TextVariantViewSet, basename="variant")
|
||||
router.register(r"tests", TestViewSet, basename="test")
|
||||
|
||||
urlpatterns = [
|
||||
path("auth/register/", register),
|
||||
path("auth/token/", PublicTokenObtainPairView.as_view(), name="token_obtain_pair"),
|
||||
path("auth/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
|
||||
path("", include(router.urls)),
|
||||
]
|
||||
274
backend_django/adsassistant_backend/api/views.py
Normal file
274
backend_django/adsassistant_backend/api/views.py
Normal file
@@ -0,0 +1,274 @@
|
||||
from rest_framework import viewsets, permissions, status
|
||||
from rest_framework.decorators import action, api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
|
||||
from rest_framework_simplejwt.views import TokenObtainPairView
|
||||
|
||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import Brief, TextVariant, Test, Segment, Assignment, ResultEntry, MetricsSnapshot, OptimizationPolicy
|
||||
from .serializers import (
|
||||
RegisterSerializer,
|
||||
BriefSerializer,
|
||||
TextVariantSerializer,
|
||||
TestSerializer,
|
||||
SegmentSerializer,
|
||||
AssignmentSerializer,
|
||||
ResultEntrySerializer,
|
||||
MetricsSnapshotSerializer,
|
||||
OptimizationPolicySerializer,
|
||||
)
|
||||
from .services import agents_generate_texts, agents_analyze, aggregate_test_rows, aggregate_test_rows_by_segment
|
||||
|
||||
|
||||
# --- Public Auth Endpoints ---
|
||||
|
||||
@extend_schema(
|
||||
auth=[],
|
||||
request=RegisterSerializer,
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="RegisterResponse",
|
||||
fields={
|
||||
"id": serializers.IntegerField(),
|
||||
"username": serializers.CharField(),
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
@api_view(["POST"])
|
||||
@permission_classes([permissions.AllowAny])
|
||||
def register(request):
|
||||
ser = RegisterSerializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
user = ser.save()
|
||||
return Response({"id": user.id, "username": user.username})
|
||||
|
||||
|
||||
class PublicTokenObtainPairView(TokenObtainPairView):
|
||||
"""Same JWT token endpoint, but marked as public for Swagger (auth=[])."""
|
||||
|
||||
@extend_schema(auth=[])
|
||||
def post(self, request, *args, **kwargs):
|
||||
return super().post(request, *args, **kwargs)
|
||||
|
||||
|
||||
# --- Protected API (JWT required by DEFAULT_PERMISSION_CLASSES) ---
|
||||
|
||||
class BriefViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = BriefSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
queryset = Brief.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
return Brief.objects.filter(owner=self.request.user).order_by("-id")
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(owner=self.request.user)
|
||||
|
||||
@action(detail=True, methods=["GET"])
|
||||
def segments(self, request, pk=None):
|
||||
test = self.get_object()
|
||||
qs = Segment.objects.filter(test=test).order_by("id")
|
||||
return Response(SegmentSerializer(qs, many=True).data)
|
||||
|
||||
@action(detail=True, methods=["GET"])
|
||||
def assignments(self, request, pk=None):
|
||||
test = self.get_object()
|
||||
qs = Assignment.objects.filter(test=test).order_by("id")
|
||||
return Response(AssignmentSerializer(qs, many=True).data)
|
||||
|
||||
@action(detail=True, methods=["GET"])
|
||||
def results(self, request, pk=None):
|
||||
test = self.get_object()
|
||||
qs = ResultEntry.objects.filter(test=test).order_by("-date", "-id")
|
||||
return Response(ResultEntrySerializer(qs, many=True).data)
|
||||
|
||||
@action(detail=True, methods=["POST"])
|
||||
def generate(self, request, pk=None):
|
||||
brief = self.get_object()
|
||||
# Build payload for agents service and ensure types are correct for validation
|
||||
formats = brief.formats or []
|
||||
if isinstance(formats, str):
|
||||
# try to parse comma-separated / json-like strings
|
||||
formats = [x.strip() for x in formats.split(",") if x.strip()]
|
||||
if not isinstance(formats, list):
|
||||
formats = []
|
||||
|
||||
benefits = brief.benefits or []
|
||||
if isinstance(benefits, str):
|
||||
benefits = [benefits]
|
||||
if not isinstance(benefits, list):
|
||||
benefits = []
|
||||
|
||||
if not formats:
|
||||
# Without formats the agents service will return 422. Give a clear error to user.
|
||||
return Response({"detail": "Выберите хотя бы один формат в брифе (например: Поисковое объявление)."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
payload = {
|
||||
"product": brief.product,
|
||||
"audience": brief.audience,
|
||||
"usp": brief.usp,
|
||||
"benefits": benefits,
|
||||
"constraints": brief.constraints,
|
||||
"tone": brief.tone,
|
||||
"formats": formats,
|
||||
"variants_per_format": max(1, min(int(brief.variants_per_format or 3), 10)),
|
||||
}
|
||||
res = agents_generate_texts(payload)
|
||||
|
||||
created = []
|
||||
for block in res.get("variants", []):
|
||||
fmt = block.get("format")
|
||||
for item in block.get("items", []):
|
||||
v = TextVariant.objects.create(
|
||||
brief=brief,
|
||||
format=fmt,
|
||||
payload=item.get("payload") or {},
|
||||
placement_tips=item.get("placement_tips", ""),
|
||||
expected_effect=item.get("expected_effect", ""),
|
||||
)
|
||||
created.append(v.id)
|
||||
return Response({"created_variant_ids": created})
|
||||
|
||||
|
||||
class TextVariantViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = TextVariantSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
queryset = TextVariant.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
qs = TextVariant.objects.filter(brief__owner=self.request.user).order_by("-id")
|
||||
brief_id = self.request.query_params.get("brief")
|
||||
if brief_id:
|
||||
qs = qs.filter(brief_id=brief_id)
|
||||
return qs
|
||||
|
||||
|
||||
class TestViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = TestSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
queryset = Test.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
return Test.objects.filter(owner=self.request.user).order_by("-id")
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(owner=self.request.user)
|
||||
|
||||
|
||||
@action(detail=True, methods=["GET","POST"])
|
||||
def policy(self, request, pk=None):
|
||||
"""Get or update optimization rules for this test."""
|
||||
test = self.get_object()
|
||||
obj, _ = OptimizationPolicy.objects.get_or_create(test=test)
|
||||
if request.method == "GET":
|
||||
return Response(OptimizationPolicySerializer(obj).data)
|
||||
|
||||
data = request.data or {}
|
||||
# Normalize direction by KPI
|
||||
kpi = (data.get("primary_kpi") or obj.primary_kpi or "cpl").lower()
|
||||
direction = data.get("direction")
|
||||
if not direction:
|
||||
direction = "max" if kpi in ("ctr","cr") else "min"
|
||||
for k in ("mode","primary_kpi","direction","good_threshold","ok_threshold","min_impressions","min_clicks","query_text"):
|
||||
if k in data:
|
||||
setattr(obj, k, data.get(k))
|
||||
obj.direction = direction
|
||||
obj.save()
|
||||
return Response(OptimizationPolicySerializer(obj).data)
|
||||
|
||||
@action(detail=True, methods=["GET"])
|
||||
def segments(self, request, pk=None):
|
||||
test = self.get_object()
|
||||
qs = Segment.objects.filter(test=test).order_by("id")
|
||||
return Response(SegmentSerializer(qs, many=True).data)
|
||||
|
||||
@action(detail=True, methods=["GET"])
|
||||
def assignments(self, request, pk=None):
|
||||
test = self.get_object()
|
||||
qs = Assignment.objects.filter(test=test).order_by("id")
|
||||
return Response(AssignmentSerializer(qs, many=True).data)
|
||||
|
||||
@action(detail=True, methods=["GET"])
|
||||
def results(self, request, pk=None):
|
||||
test = self.get_object()
|
||||
qs = ResultEntry.objects.filter(test=test).order_by("-date", "-id")
|
||||
return Response(ResultEntrySerializer(qs, many=True).data)
|
||||
|
||||
@action(detail=True, methods=["POST"])
|
||||
def add_segments(self, request, pk=None):
|
||||
test = self.get_object()
|
||||
segments = request.data.get("segments") or []
|
||||
ids = []
|
||||
for s in segments:
|
||||
seg = Segment.objects.create(
|
||||
test=test,
|
||||
name=s.get("name", "Segment"),
|
||||
description=s.get("description", ""),
|
||||
)
|
||||
ids.append(seg.id)
|
||||
return Response({"created_segment_ids": ids})
|
||||
|
||||
@action(detail=True, methods=["POST"])
|
||||
def assign(self, request, pk=None):
|
||||
test = self.get_object()
|
||||
assignments = request.data.get("assignments") or []
|
||||
ids = []
|
||||
for a in assignments:
|
||||
seg = Segment.objects.get(id=a["segment_id"], test=test)
|
||||
var = TextVariant.objects.get(id=a["variant_id"], brief=test.brief)
|
||||
obj = Assignment.objects.create(test=test, segment=seg, variant=var)
|
||||
ids.append(obj.id)
|
||||
return Response({"created_assignment_ids": ids})
|
||||
|
||||
@action(detail=True, methods=["POST"])
|
||||
def add_results(self, request, pk=None):
|
||||
test = self.get_object()
|
||||
rows = request.data.get("results") or []
|
||||
ids = []
|
||||
for r in rows:
|
||||
seg = Segment.objects.get(id=r["segment_id"], test=test)
|
||||
var = TextVariant.objects.get(id=r["variant_id"], brief=test.brief)
|
||||
obj = ResultEntry.objects.create(
|
||||
test=test,
|
||||
segment=seg,
|
||||
variant=var,
|
||||
date=r["date"],
|
||||
impressions=r.get("impressions", 0),
|
||||
clicks=r.get("clicks", 0),
|
||||
conversions=r.get("conversions", 0),
|
||||
leads=r.get("leads", 0),
|
||||
spend=r.get("spend", 0.0),
|
||||
)
|
||||
ids.append(obj.id)
|
||||
return Response({"created_result_ids": ids})
|
||||
|
||||
@action(detail=True, methods=["POST"])
|
||||
def analyze(self, request, pk=None):
|
||||
test = self.get_object()
|
||||
policy = None
|
||||
if hasattr(test, "policy"):
|
||||
policy = OptimizationPolicySerializer(test.policy).data
|
||||
# allow overriding policy from request
|
||||
if isinstance(request.data, dict) and request.data.get("policy"):
|
||||
policy = request.data.get("policy")
|
||||
rows = aggregate_test_rows_by_segment(test)
|
||||
res = agents_analyze(rows, test.objective, policy=policy)
|
||||
|
||||
snap, _ = MetricsSnapshot.objects.update_or_create(
|
||||
test=test,
|
||||
defaults={
|
||||
"ranking": res.get("ranking", []),
|
||||
"recommendations": res.get("recommendations", []),
|
||||
},
|
||||
)
|
||||
return Response(MetricsSnapshotSerializer(snap).data)
|
||||
|
||||
@action(detail=True, methods=["GET"])
|
||||
def report(self, request, pk=None):
|
||||
test = self.get_object()
|
||||
if hasattr(test, "snapshot"):
|
||||
return Response(MetricsSnapshotSerializer(test.snapshot).data)
|
||||
return Response({"detail": "No analysis yet"}, status=status.HTTP_404_NOT_FOUND)
|
||||
8
backend_django/manage.py
Normal file
8
backend_django/manage.py
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env python
|
||||
import os, sys
|
||||
def main():
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "adsassistant_backend.settings")
|
||||
from django.core.management import execute_from_command_line
|
||||
execute_from_command_line(sys.argv)
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
7
backend_django/requirements.txt
Normal file
7
backend_django/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
Django>=5.0,<6
|
||||
djangorestframework>=3.15,<4
|
||||
djangorestframework-simplejwt>=5.3,<6
|
||||
drf-spectacular>=0.27,<1
|
||||
django-cors-headers>=4.4,<5
|
||||
python-dotenv>=1,<2
|
||||
requests>=2.32,<3
|
||||
39
docker-compose.yml
Normal file
39
docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
services:
|
||||
agents:
|
||||
build: ./agents_service
|
||||
env_file:
|
||||
- ./agents_service/.env
|
||||
ports:
|
||||
- "8001:8001"
|
||||
|
||||
backend:
|
||||
build: ./backend_django
|
||||
env_file:
|
||||
- ./backend_django/.env
|
||||
environment:
|
||||
# Make sure backend can reach agents by service name inside compose network
|
||||
- AGENTS_SERVICE_URL=http://agents:8001
|
||||
- CORS_ALLOWED_ORIGINS=http://localhost:5174,http://localhost:8080
|
||||
- ALLOWED_HOSTS=127.0.0.1,localhost
|
||||
depends_on:
|
||||
- agents
|
||||
volumes:
|
||||
# Persist SQLite DB on host
|
||||
- backend_db:/app/adsassistant_backend/data
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
environment:
|
||||
# Nginx serves static files; API base is baked at build time for Vite.
|
||||
# For simplicity we keep default and use browser -> localhost:8000.
|
||||
# If you want to change, set VITE_API_BASE_URL before build.
|
||||
- NGINX_PORT=80
|
||||
ports:
|
||||
- "5174:80"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
backend_db:
|
||||
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
1
frontend/.env.production
Normal file
1
frontend/.env.production
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:8000
|
||||
11
frontend/Dockerfile
Normal file
11
frontend/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json tsconfig.json vite.config.ts index.html ./
|
||||
COPY src ./src
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
15
frontend/README.md
Normal file
15
frontend/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Frontend
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env
|
||||
npm run dev
|
||||
```
|
||||
|
||||
|
||||
Docker: UI served by nginx on http://localhost:5174
|
||||
|
||||
## Тестирование
|
||||
|
||||
- /tests — создание теста
|
||||
- /tests/:id — сегменты, распределение, ручной ввод результатов, Analyze
|
||||
4
frontend/index.html
Normal file
4
frontend/index.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<!doctype html><html lang="ru"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>AdsAssistant</title> <link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
</head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||
10
frontend/nginx.conf
Normal file
10
frontend/nginx.conf
Normal file
@@ -0,0 +1,10 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "adsassistant-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.3"
|
||||
}
|
||||
}
|
||||
0
frontend/public/.keep
Normal file
0
frontend/public/.keep
Normal file
26
frontend/src/api/client.ts
Normal file
26
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export const API_BASE = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000').replace(/\/$/, '')
|
||||
const TOKEN_KEY = 'adsassistant_token'
|
||||
export function setToken(t: string){ localStorage.setItem(TOKEN_KEY,t) }
|
||||
export function getToken(){ return localStorage.getItem(TOKEN_KEY) }
|
||||
export function clearToken(){ localStorage.removeItem(TOKEN_KEY) }
|
||||
async function handle(res: Response){
|
||||
const text = await res.text()
|
||||
let data: any = null
|
||||
try{ data = text ? JSON.parse(text) : null } catch { data = text }
|
||||
if(!res.ok) throw new Error((data && (data.detail||data.error)) || res.statusText)
|
||||
return data
|
||||
}
|
||||
export async function apiPost(path:string, body?:any){
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE}${path}`,{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json', ...(token?{Authorization:`Bearer ${token}`}:{})},
|
||||
body: JSON.stringify(body ?? {}),
|
||||
})
|
||||
return handle(res)
|
||||
}
|
||||
export async function apiGet(path:string){
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE}${path}`,{ headers: token?{Authorization:`Bearer ${token}`}:{}})
|
||||
return handle(res)
|
||||
}
|
||||
8
frontend/src/components/AutoTextarea.tsx
Normal file
8
frontend/src/components/AutoTextarea.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
type Props = React.TextareaHTMLAttributes<HTMLTextAreaElement> & { value: string; onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void }
|
||||
export default function AutoTextarea(props: Props){
|
||||
const ref = useRef<HTMLTextAreaElement|null>(null)
|
||||
const resize=()=>{ const el=ref.current; if(!el) return; el.style.height='0px'; el.style.height=el.scrollHeight+'px' }
|
||||
useEffect(()=>{ resize() },[props.value])
|
||||
return <textarea {...props} ref={ref} onChange={(e)=>{ props.onChange(e); requestAnimationFrame(resize) }} style={{...(props.style||{}), overflow:'hidden'}}/>
|
||||
}
|
||||
62
frontend/src/components/NavBar.tsx
Normal file
62
frontend/src/components/NavBar.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { clearToken, getToken } from '../api/client'
|
||||
|
||||
function NavItem({ to, label }: { to: string; label: string }) {
|
||||
const loc = useLocation()
|
||||
const active = loc.pathname === to || loc.pathname.startsWith(to + '/')
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="small"
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: 12,
|
||||
border: '1px solid transparent',
|
||||
background: active ? 'rgba(45,96,255,.10)' : 'transparent',
|
||||
color: active ? '#2d60ff' : undefined,
|
||||
fontWeight: active ? 700 : 600,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NavBar() {
|
||||
const nav = useNavigate()
|
||||
const token = getToken()
|
||||
|
||||
return (
|
||||
<div className="card compact" style={{ marginBottom: 16 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div className="row" style={{ alignItems: 'center' }}>
|
||||
<div style={{ fontWeight: 800, letterSpacing: '-0.03em' }}>AdsAssistant</div>
|
||||
<div className="row" style={{ marginLeft: 10, gap: 8 }}>
|
||||
<NavItem to="/briefs" label="Брифы" />
|
||||
<NavItem to="/tests" label="Тесты" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ alignItems: 'center' }}>
|
||||
{token ? (
|
||||
<button
|
||||
className="btn secondary"
|
||||
onClick={() => {
|
||||
clearToken()
|
||||
nav('/login')
|
||||
}}
|
||||
>
|
||||
Выйти
|
||||
</button>
|
||||
) : (
|
||||
<Link to="/login" className="small">
|
||||
Вход
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
frontend/src/components/Tooltip.tsx
Normal file
31
frontend/src/components/Tooltip.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React, { useId, useState } from 'react'
|
||||
|
||||
export default function Tooltip({ text }: { text: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const id = useId()
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-describedby={id}
|
||||
className="tipbtn"
|
||||
title={text}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
i
|
||||
</button>
|
||||
{open && (
|
||||
<span id={id} role="tooltip" className="tooltip">
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
28
frontend/src/main.tsx
Normal file
28
frontend/src/main.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import './styles/app.css'
|
||||
import Login from './pages/Login'
|
||||
import Briefs from './pages/Briefs'
|
||||
import Tests from './pages/Tests'
|
||||
import TestDetail from './pages/TestDetail'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/briefs" element={<Briefs />} />
|
||||
<Route path="/tests" element={<Tests />} />
|
||||
<Route path="/tests/:id" element={<TestDetail />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
392
frontend/src/pages/Briefs.tsx
Normal file
392
frontend/src/pages/Briefs.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { apiGet, apiPost } from '../api/client'
|
||||
import AutoTextarea from '../components/AutoTextarea'
|
||||
import NavBar from '../components/NavBar'
|
||||
import Tooltip from '../components/Tooltip'
|
||||
|
||||
const FORMATS = [
|
||||
{ id: 'social_post', label: 'Пост для соцсетей' },
|
||||
{ id: 'search_ad', label: 'Поисковое объявление' },
|
||||
{ id: 'email', label: 'Email‑письмо' },
|
||||
]
|
||||
|
||||
function formatRu(fmt: string) {
|
||||
const v = (fmt || '').toLowerCase()
|
||||
if (v === 'search_ad') return 'Поисковое объявление'
|
||||
if (v === 'social_post') return 'Пост для соцсетей'
|
||||
if (v === 'email') return 'Email‑письмо'
|
||||
return fmt
|
||||
}
|
||||
|
||||
function PayloadView({ format, payload }: { format: string; payload: any }) {
|
||||
const p = payload || {}
|
||||
|
||||
if (format === 'search_ad') {
|
||||
const headlines: string[] = p.headlines || []
|
||||
const descriptions: string[] = p.descriptions || []
|
||||
return (
|
||||
<div>
|
||||
<div className="small" style={{ marginBottom: 8 }}>
|
||||
Заголовки
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{headlines.map((h, i) => (
|
||||
<span key={i} className="pill">
|
||||
{h}
|
||||
</span>
|
||||
))}
|
||||
{!headlines.length && <span className="small">—</span>}
|
||||
</div>
|
||||
|
||||
<div className="small" style={{ marginTop: 12, marginBottom: 8 }}>
|
||||
Описания
|
||||
</div>
|
||||
<div className="list" style={{ gap: 8 }}>
|
||||
{descriptions.map((d, i) => (
|
||||
<div key={i} className="item" style={{ boxShadow: 'none', padding: 12 }}>
|
||||
<div className="meta">
|
||||
<div className="s">{d}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!descriptions.length && <div className="small">—</div>}
|
||||
</div>
|
||||
|
||||
{p.cta && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<span className="small">CTA: </span>
|
||||
<span style={{ fontWeight: 700 }}>{p.cta}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (format === 'social_post') {
|
||||
return (
|
||||
<div className="list">
|
||||
<div className="item" style={{ boxShadow: 'none' }}>
|
||||
<div className="avatar">H</div>
|
||||
<div className="meta">
|
||||
<div className="h">Hook</div>
|
||||
<div className="s">{p.hook || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item" style={{ boxShadow: 'none' }}>
|
||||
<div className="avatar">B</div>
|
||||
<div className="meta">
|
||||
<div className="h">Body</div>
|
||||
<div className="s" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{p.body || '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item" style={{ boxShadow: 'none' }}>
|
||||
<div className="avatar">C</div>
|
||||
<div className="meta">
|
||||
<div className="h">CTA</div>
|
||||
<div className="s">{p.cta || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (format === 'email') {
|
||||
return (
|
||||
<div className="list">
|
||||
<div className="item" style={{ boxShadow: 'none' }}>
|
||||
<div className="avatar">S</div>
|
||||
<div className="meta">
|
||||
<div className="h">Subject</div>
|
||||
<div className="s">{p.subject || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item" style={{ boxShadow: 'none' }}>
|
||||
<div className="avatar">P</div>
|
||||
<div className="meta">
|
||||
<div className="h">Preheader</div>
|
||||
<div className="s">{p.preheader || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item" style={{ boxShadow: 'none' }}>
|
||||
<div className="avatar">B</div>
|
||||
<div className="meta">
|
||||
<div className="h">Body</div>
|
||||
<div className="s" style={{ whiteSpace: 'pre-wrap' }}>
|
||||
{p.body || '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item" style={{ boxShadow: 'none' }}>
|
||||
<div className="avatar">C</div>
|
||||
<div className="meta">
|
||||
<div className="h">CTA</div>
|
||||
<div className="s">{p.cta || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{JSON.stringify(p, null, 2)}</pre>
|
||||
}
|
||||
|
||||
export default function Briefs() {
|
||||
const [briefs, setBriefs] = useState<any[]>([])
|
||||
const [variants, setVariants] = useState<any[]>([])
|
||||
const [selectedBrief, setSelectedBrief] = useState<number | null>(null)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const [form, setForm] = useState<any>({
|
||||
product: '',
|
||||
audience: '',
|
||||
usp: '',
|
||||
benefits: [],
|
||||
constraints: '',
|
||||
tone: 'дружелюбный',
|
||||
formats: ['social_post', 'search_ad'],
|
||||
variants_per_format: 3,
|
||||
})
|
||||
|
||||
const variantsSorted = useMemo(() => [...variants].sort((a, b) => Number(a.id) - Number(b.id)), [variants])
|
||||
|
||||
const loadBriefs = async () => {
|
||||
const bs = await apiGet('/api/briefs/')
|
||||
setBriefs(bs)
|
||||
if (bs.length && selectedBrief == null) setSelectedBrief(bs[0].id)
|
||||
}
|
||||
|
||||
const loadVariants = async (briefId: number) => {
|
||||
const vs = await apiGet('/api/variants/?brief=' + briefId)
|
||||
setVariants(vs)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadBriefs().catch((e) => setErr(e.message))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedBrief) loadVariants(selectedBrief).catch((e) => setErr(e.message))
|
||||
}, [selectedBrief])
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<NavBar />
|
||||
|
||||
<div className="row">
|
||||
<div className="card" style={{ flex: 1, minWidth: 380 }}>
|
||||
<div className="cardtitle">
|
||||
<h2 style={{ margin: 0 }}>Бриф</h2>
|
||||
<div className="small">Генерация рекламных текстов</div>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ justifyContent: 'flex-end', marginBottom: 10 }}>
|
||||
<button
|
||||
className="btn secondary"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm({
|
||||
...form,
|
||||
product: 'Онлайн‑курс по английскому',
|
||||
audience: 'Взрослые 25–45, хотят разговорный английский для работы и поездок',
|
||||
usp: '1‑й урок бесплатно',
|
||||
constraints: 'Без обещаний гарантированного результата',
|
||||
tone: 'дружелюбный',
|
||||
formats: ['search_ad', 'social_post'],
|
||||
variants_per_format: 3,
|
||||
})
|
||||
}
|
||||
>
|
||||
Пресет 1
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn secondary"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm({
|
||||
...form,
|
||||
product: 'Сервис доставки здорового питания',
|
||||
audience: 'Мужчины и женщины 23–40, следят за питанием, мало времени готовить',
|
||||
usp: 'Скидка 20% на первый заказ',
|
||||
constraints: 'Не использовать медицинские обещания. Без “гарантируем похудение”.',
|
||||
tone: 'энергичный',
|
||||
formats: ['social_post', 'email'],
|
||||
variants_per_format: 3,
|
||||
})
|
||||
}
|
||||
>
|
||||
Пресет 2
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err && <div style={{ color: '#fe5c73', marginBottom: 10 }}>{err}</div>}
|
||||
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
<div>
|
||||
<div className="tiprow">
|
||||
<div className="small">Описание продукта</div>
|
||||
<Tooltip text="Что вы продаёте и в чём суть предложения. Чем точнее — тем лучше тексты." />
|
||||
</div>
|
||||
<AutoTextarea className="input" value={form.product} onChange={(e) => setForm({ ...form, product: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="tiprow">
|
||||
<div className="small">Аудитория</div>
|
||||
<Tooltip text="Кто покупает: возраст, гео, интересы, боль. Это влияет на стиль и формулировки." />
|
||||
</div>
|
||||
<AutoTextarea className="input" value={form.audience} onChange={(e) => setForm({ ...form, audience: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="tiprow">
|
||||
<div className="small">Tone</div>
|
||||
<Tooltip text="Тональность: дружелюбный, строгий, экспертный и т.п. Меняет стиль текста." />
|
||||
</div>
|
||||
<input className="input" value={form.tone} onChange={(e) => setForm({ ...form, tone: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ width: 190 }}>
|
||||
<div className="tiprow">
|
||||
<div className="small">Вариантов/формат</div>
|
||||
<Tooltip text="Сколько вариантов агент генерирует на каждый выбранный формат." />
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={form.variants_per_format}
|
||||
onChange={(e) => setForm({ ...form, variants_per_format: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="tiprow">
|
||||
<div className="small">УТП</div>
|
||||
<Tooltip text="Уникальное торговое предложение: почему выбрать вас (например: 1-й урок бесплатно)." />
|
||||
</div>
|
||||
<AutoTextarea className="input" value={form.usp} onChange={(e) => setForm({ ...form, usp: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="tiprow">
|
||||
<div className="small">Ограничения</div>
|
||||
<Tooltip text="Что нельзя обещать/упоминать (например: без гарантий результата, без “лучшие”)." />
|
||||
</div>
|
||||
<AutoTextarea className="input" value={form.constraints} onChange={(e) => setForm({ ...form, constraints: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="card compact">
|
||||
<div className="tiprow" style={{ marginBottom: 8 }}>
|
||||
<div className="small">Форматы (выбирает клиент)</div>
|
||||
<Tooltip text="Какие типы текстов генерировать: соцсети / поиск / email. Можно выбрать несколько." />
|
||||
</div>
|
||||
<div className="row" style={{ gap: 14 }}>
|
||||
{FORMATS.map((f) => (
|
||||
<label key={f.id} className="small" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.formats.includes(f.id)}
|
||||
onChange={(e) => {
|
||||
const set = new Set(form.formats)
|
||||
if (e.target.checked) set.add(f.id)
|
||||
else set.delete(f.id)
|
||||
setForm({ ...form, formats: Array.from(set) })
|
||||
}}
|
||||
/>
|
||||
{f.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn"
|
||||
disabled={busy || !form.product || !form.audience || !form.formats.length}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const b = await apiPost('/api/briefs/', form)
|
||||
await loadBriefs()
|
||||
setSelectedBrief(b.id)
|
||||
} catch (e: any) {
|
||||
setErr(e.message || String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Сохранить бриф
|
||||
</button>
|
||||
|
||||
{selectedBrief && (
|
||||
<button
|
||||
className="btn secondary"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await apiPost(`/api/briefs/${selectedBrief}/generate/`)
|
||||
await loadVariants(selectedBrief)
|
||||
} catch (e: any) {
|
||||
setErr(e.message || String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Сгенерировать тексты
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ flex: 1.2, minWidth: 420 }}>
|
||||
<div className="cardtitle">
|
||||
<h2 style={{ margin: 0 }}>Тексты</h2>
|
||||
<div className="small">Структурированный вывод</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div className="small">Выбранный бриф</div>
|
||||
<select className="input" value={selectedBrief ?? ''} onChange={(e) => setSelectedBrief(Number(e.target.value))}>
|
||||
{briefs.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{String(b.product).slice(0, 60)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="list" style={{ marginTop: 14 }}>
|
||||
{variantsSorted.map((v, idx) => (
|
||||
<div key={v.id} className="item">
|
||||
<div className="avatar">{String(formatRu(v.format || '?')).slice(0, 1).toUpperCase()}</div>
|
||||
<div className="meta">
|
||||
<div className="h">
|
||||
Текст №{idx + 1} <span className="small">• {formatRu(v.format)}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<PayloadView format={v.format} payload={v.payload} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!variantsSorted.length && (
|
||||
<div className="small" style={{ padding: 12 }}>
|
||||
Пока нет текстов. Сгенерируй варианты.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
frontend/src/pages/Login.tsx
Normal file
29
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React, { useState } from 'react'
|
||||
import { apiPost, setToken } from '../api/client'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
export default function Login(){
|
||||
const [username,setUsername]=useState('')
|
||||
const [password,setPassword]=useState('')
|
||||
const [err,setErr]=useState<string|null>(null)
|
||||
const nav=useNavigate()
|
||||
return (
|
||||
<div className="container" style={{maxWidth:520,paddingTop:60}}>
|
||||
<div className="card">
|
||||
<h2 style={{marginTop:0}}>Вход</h2>
|
||||
<div style={{display:'grid',gap:10,marginTop:12}}>
|
||||
<input className="input" placeholder="username" value={username} onChange={e=>setUsername(e.target.value)} />
|
||||
<input className="input" placeholder="password" type="password" value={password} onChange={e=>setPassword(e.target.value)} />
|
||||
{err && <div style={{color:'#fca5a5'}}>{err}</div>}
|
||||
<button className="btn" onClick={async()=>{
|
||||
setErr(null)
|
||||
try{
|
||||
const res = await apiPost('/api/auth/token/',{username,password})
|
||||
setToken(res.access)
|
||||
nav('/briefs')
|
||||
}catch(e:any){ setErr(e.message||String(e)) }
|
||||
}}>Войти</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
618
frontend/src/pages/TestDetail.tsx
Normal file
618
frontend/src/pages/TestDetail.tsx
Normal file
@@ -0,0 +1,618 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { apiGet, apiPost } from '../api/client'
|
||||
import NavBar from '../components/NavBar'
|
||||
import Tooltip from '../components/Tooltip'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
|
||||
function formatRu(fmt: string) {
|
||||
const v = (fmt || '').toLowerCase()
|
||||
if (v === 'search_ad') return 'Поисковое объявление'
|
||||
if (v === 'social_post') return 'Пост для соцсетей'
|
||||
if (v === 'email') return 'Email‑письмо'
|
||||
return fmt
|
||||
}
|
||||
|
||||
function objectiveRu(x: string) {
|
||||
const v = (x || '').toLowerCase()
|
||||
if (v === 'leads') return 'Лиды'
|
||||
if (v === 'conversions') return 'Конверсии'
|
||||
if (v === 'clicks') return 'Клики'
|
||||
return x
|
||||
}
|
||||
function statusRu(x: string) {
|
||||
const v = (x || '').toLowerCase()
|
||||
if (v === 'draft') return 'Черновик'
|
||||
if (v === 'running') return 'Запущен'
|
||||
if (v === 'done' || v === 'finished') return 'Завершён'
|
||||
return x
|
||||
}
|
||||
|
||||
function safePct01(x: any) {
|
||||
if (x == null || Number.isNaN(Number(x))) return '—'
|
||||
return (Number(x) * 100).toFixed(2) + '%'
|
||||
}
|
||||
function safeNum(x: any) {
|
||||
if (x == null || Number.isNaN(Number(x))) return '—'
|
||||
const n = Number(x)
|
||||
if (!Number.isFinite(n)) return '—'
|
||||
return Math.abs(n - Math.round(n)) < 1e-9 ? String(Math.round(n)) : n.toFixed(2)
|
||||
}
|
||||
|
||||
type Agg = {
|
||||
segment_id: number
|
||||
variant_id: number
|
||||
impressions: number
|
||||
clicks: number
|
||||
conversions: number
|
||||
leads: number
|
||||
spend: number
|
||||
ctr: number | null
|
||||
cpc: number | null
|
||||
cr: number | null
|
||||
cpl: number | null
|
||||
cpa: number | null
|
||||
}
|
||||
|
||||
export default function TestDetail() {
|
||||
const { id } = useParams()
|
||||
const testId = Number(id)
|
||||
|
||||
const [test, setTest] = useState<any | null>(null)
|
||||
const [brief, setBrief] = useState<any | null>(null)
|
||||
|
||||
const [variants, setVariants] = useState<any[]>([])
|
||||
const [segments, setSegments] = useState<any[]>([])
|
||||
const [assignments, setAssignments] = useState<any[]>([])
|
||||
const [results, setResults] = useState<any[]>([])
|
||||
const [snapshot, setSnapshot] = useState<any | null>(null)
|
||||
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const todayISO = new Date().toISOString().slice(0, 10)
|
||||
|
||||
const [segName, setSegName] = useState('Группа A')
|
||||
const [segDesc, setSegDesc] = useState('')
|
||||
|
||||
const [assignForm, setAssignForm] = useState<any>({ segment_id: '', variant_id: '' })
|
||||
|
||||
const [result, setResult] = useState<any>({
|
||||
segment_id: '',
|
||||
variant_id: '',
|
||||
date: todayISO,
|
||||
impressions: 0,
|
||||
clicks: 0,
|
||||
conversions: 0,
|
||||
leads: 0,
|
||||
spend: 0,
|
||||
})
|
||||
|
||||
const load = async () => {
|
||||
setErr(null)
|
||||
|
||||
const t = await apiGet(`/api/tests/${testId}/`)
|
||||
setTest(t)
|
||||
|
||||
const b = await apiGet(`/api/briefs/${t.brief}/`)
|
||||
setBrief(b)
|
||||
|
||||
const vs = await apiGet(`/api/variants/?brief=${t.brief}`)
|
||||
setVariants(vs)
|
||||
|
||||
let segs: any[] = []
|
||||
let asg: any[] = []
|
||||
let rs: any[] = []
|
||||
try {
|
||||
segs = await apiGet(`/api/tests/${testId}/segments/`)
|
||||
asg = await apiGet(`/api/tests/${testId}/assignments/`)
|
||||
rs = await apiGet(`/api/tests/${testId}/results/`)
|
||||
} catch (e: any) {
|
||||
segs = []
|
||||
asg = []
|
||||
rs = []
|
||||
}
|
||||
|
||||
setSegments(segs)
|
||||
setAssignments(asg)
|
||||
setResults(rs)
|
||||
|
||||
if (segs?.length) {
|
||||
setAssignForm((a: any) => ({ ...a, segment_id: a.segment_id || segs[0].id }))
|
||||
setResult((r: any) => ({ ...r, segment_id: r.segment_id || segs[0].id }))
|
||||
}
|
||||
if (vs?.length) {
|
||||
setAssignForm((a: any) => ({ ...a, variant_id: a.variant_id || vs[0].id }))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(testId)) return
|
||||
load().catch((e: any) => setErr(e.message || String(e)))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [testId])
|
||||
|
||||
const variantsSorted = useMemo(() => [...variants].sort((a, b) => Number(a.id) - Number(b.id)), [variants])
|
||||
const textNumberByVariantId = useMemo(() => {
|
||||
const map = new Map<number, number>()
|
||||
variantsSorted.forEach((v, idx) => map.set(Number(v.id), idx + 1))
|
||||
return map
|
||||
}, [variantsSorted])
|
||||
|
||||
function variantDisplay(variantId: any) {
|
||||
const v = variants.find((x) => Number(x.id) === Number(variantId))
|
||||
const n = textNumberByVariantId.get(Number(variantId))
|
||||
if (!v) return n ? `Текст №${n}` : `Текст`
|
||||
const fmt = formatRu(v.format)
|
||||
return n ? `Текст №${n} • ${fmt}` : fmt
|
||||
}
|
||||
|
||||
function segmentName(segId: any) {
|
||||
const s = segments.find((x) => Number(x.id) === Number(segId))
|
||||
return s?.name || 'Сегмент'
|
||||
}
|
||||
|
||||
const assignedVariantIdsForSegment = useMemo(() => {
|
||||
const segId = Number(result.segment_id || assignForm.segment_id)
|
||||
const ids = assignments
|
||||
.filter((a) => Number(a.segment) === segId || Number(a.segment_id) === segId)
|
||||
.map((a) => Number(a.variant) || Number(a.variant_id))
|
||||
return new Set(ids)
|
||||
}, [assignForm.segment_id, assignments, result.segment_id])
|
||||
|
||||
const variantsForSelectedSegment = useMemo(() => {
|
||||
const ids = assignedVariantIdsForSegment
|
||||
const filtered = variantsSorted.filter((v) => ids.has(Number(v.id)))
|
||||
return filtered.length ? filtered : variantsSorted
|
||||
}, [assignedVariantIdsForSegment, variantsSorted])
|
||||
|
||||
useEffect(() => {
|
||||
if (!result.segment_id) return
|
||||
const list = variantsForSelectedSegment
|
||||
if (!list.length) return
|
||||
const ok = list.some((v) => Number(v.id) === Number(result.variant_id))
|
||||
if (!ok) setResult((r: any) => ({ ...r, variant_id: list[0].id }))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [result.segment_id, variantsForSelectedSegment.length])
|
||||
|
||||
const aggByVariant = useMemo(() => {
|
||||
const map = new Map<number, Agg[]>()
|
||||
const tmp = new Map<string, Agg>()
|
||||
|
||||
for (const r of results) {
|
||||
const segId = Number(r.segment)
|
||||
const varId = Number(r.variant)
|
||||
const key = `${segId}:${varId}`
|
||||
|
||||
const cur =
|
||||
tmp.get(key) ||
|
||||
({
|
||||
segment_id: segId,
|
||||
variant_id: varId,
|
||||
impressions: 0,
|
||||
clicks: 0,
|
||||
conversions: 0,
|
||||
leads: 0,
|
||||
spend: 0,
|
||||
ctr: null,
|
||||
cpc: null,
|
||||
cr: null,
|
||||
cpl: null,
|
||||
cpa: null,
|
||||
} as Agg)
|
||||
|
||||
cur.impressions += Number(r.impressions || 0)
|
||||
cur.clicks += Number(r.clicks || 0)
|
||||
cur.conversions += Number(r.conversions || 0)
|
||||
cur.leads += Number(r.leads || 0)
|
||||
cur.spend += Number(r.spend || 0)
|
||||
|
||||
tmp.set(key, cur)
|
||||
}
|
||||
|
||||
for (const cur of tmp.values()) {
|
||||
cur.ctr = cur.impressions > 0 ? cur.clicks / cur.impressions : null
|
||||
cur.cpc = cur.clicks > 0 ? cur.spend / cur.clicks : null
|
||||
cur.cr = cur.clicks > 0 ? cur.conversions / cur.clicks : null
|
||||
cur.cpl = cur.leads > 0 ? cur.spend / cur.leads : null
|
||||
cur.cpa = cur.conversions > 0 ? cur.spend / cur.conversions : null
|
||||
|
||||
const arr = map.get(cur.variant_id) || []
|
||||
arr.push(cur)
|
||||
map.set(cur.variant_id, arr)
|
||||
}
|
||||
|
||||
for (const [varId, arr] of map.entries()) {
|
||||
arr.sort((a, b) => segmentName(a.segment_id).localeCompare(segmentName(b.segment_id)))
|
||||
map.set(varId, arr)
|
||||
}
|
||||
|
||||
return map
|
||||
}, [results, segments])
|
||||
|
||||
const ranking = snapshot?.ranking || []
|
||||
const recs = snapshot?.recommendations || []
|
||||
|
||||
const cleanedReason = (s: string) => {
|
||||
if (!s) return ''
|
||||
return s.replace(/Primary[^;]*;?\s*/gi, '').replace(/\s{2,}/g, ' ').trim()
|
||||
}
|
||||
|
||||
const applyResultPreset = (n: 1 | 2) => {
|
||||
if (n === 1) setResult((r: any) => ({ ...r, impressions: 5000, clicks: 60, leads: 5, conversions: 2, spend: 2400 }))
|
||||
else setResult((r: any) => ({ ...r, impressions: 3000, clicks: 30, leads: 3, conversions: 3, spend: 3400 }))
|
||||
}
|
||||
|
||||
const presetSegmentsAB = async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
await apiPost(`/api/tests/${testId}/add_segments/`, {
|
||||
segments: [
|
||||
{ name: 'Группа A', description: 'Аудитория A' },
|
||||
{ name: 'Группа B', description: 'Аудитория B' },
|
||||
],
|
||||
})
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
setErr(e.message || String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const presetAssignmentsAB = async () => {
|
||||
if (segments.length < 2 || variantsSorted.length < 2) {
|
||||
setErr('Нужно минимум 2 сегмента и 2 текста, чтобы сделать пресет распределения.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const segA = segments[0].id
|
||||
const segB = segments[1].id
|
||||
const vA = variantsSorted[0].id
|
||||
const vB = variantsSorted[1].id
|
||||
await apiPost(`/api/tests/${testId}/assign/`, {
|
||||
assignments: [
|
||||
{ segment_id: segA, variant_id: vA },
|
||||
{ segment_id: segB, variant_id: vB },
|
||||
],
|
||||
})
|
||||
await load()
|
||||
setAssignForm({ segment_id: segA, variant_id: vA })
|
||||
setResult((r: any) => ({ ...r, segment_id: segA, variant_id: vA }))
|
||||
} catch (e: any) {
|
||||
setErr(e.message || String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<NavBar />
|
||||
|
||||
<div className="card">
|
||||
<div className="row" style={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0 }}>{test?.name || 'Тест'}</h2>
|
||||
{test && (
|
||||
<div className="small">
|
||||
цель: <b>{objectiveRu(test.objective)}</b> • бриф: <b>{brief ? String(brief.product).slice(0, 60) : ''}</b> • статус:{' '}
|
||||
<b>{statusRu(test.status)}</b>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="row">
|
||||
<Link to="/tests" className="btn secondary">
|
||||
Назад
|
||||
</Link>
|
||||
<button className="btn" disabled={busy} onClick={async () => {
|
||||
setBusy(true); setErr(null);
|
||||
try { const res = await apiPost(`/api/tests/${testId}/analyze/`); setSnapshot(res); }
|
||||
catch(e:any){ setErr(e.message||String(e)); }
|
||||
finally{ setBusy(false); }
|
||||
}}>
|
||||
Проанализировать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{err && <div style={{ color: '#fe5c73', marginTop: 10 }}>{err}</div>}
|
||||
</div>
|
||||
|
||||
<div className="row section">
|
||||
<div className="card" style={{ flex: 1, minWidth: 320 }}>
|
||||
<div className="cardtitle">
|
||||
<h3 style={{ margin: 0 }}>Сегменты</h3>
|
||||
<Tooltip text="Создайте аудитории/группы (A/B). Потом распределите тексты по сегментам." />
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ alignItems: 'flex-end' }}>
|
||||
<div style={{ flex: 1, minWidth: 160 }}>
|
||||
<div className="tiprow"><div className="small">Название</div><Tooltip text="Название аудитории/группы, например: “Группа A: 25–34 Москва”." /></div>
|
||||
<input className="input" value={segName} onChange={(e) => setSegName(e.target.value)} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 160 }}>
|
||||
<div className="tiprow"><div className="small">Описание (опц.)</div><Tooltip text="Коротко: интересы/гео/признак сегмента." /></div>
|
||||
<input className="input" value={segDesc} onChange={(e) => setSegDesc(e.target.value)} />
|
||||
</div>
|
||||
<button className="btn" disabled={busy || !segName.trim()} onClick={async () => {
|
||||
setBusy(true); setErr(null);
|
||||
try { await apiPost(`/api/tests/${testId}/add_segments/`, { segments: [{ name: segName, description: segDesc }] }); await load(); }
|
||||
catch(e:any){ setErr(e.message||String(e)); }
|
||||
finally{ setBusy(false); }
|
||||
}}>
|
||||
Добавить
|
||||
</button>
|
||||
<button className="btn secondary" disabled={busy} onClick={presetSegmentsAB}>Пресет A/B</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ flex: 1, minWidth: 320 }}>
|
||||
<div className="cardtitle">
|
||||
<h3 style={{ margin: 0 }}>Распределение текстов</h3>
|
||||
<Tooltip text="Привяжите текст к сегменту (A/B). Это влияет на список текстов при вводе результатов." />
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ alignItems: 'flex-end' }}>
|
||||
<div style={{ flex: 1, minWidth: 160 }}>
|
||||
<div className="tiprow"><div className="small">Сегмент</div><Tooltip text="На какую аудиторию показываем выбранный текст." /></div>
|
||||
<select className="input" value={assignForm.segment_id || ''} onChange={(e) => setAssignForm({ ...assignForm, segment_id: Number(e.target.value) })} disabled={!segments.length}>
|
||||
{segments.length ? segments.map((s) => <option key={s.id} value={s.id}>{s.name}</option>) : <option value="">Сначала добавьте сегменты</option>}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 220 }}>
|
||||
<div className="tiprow"><div className="small">Текст</div><Tooltip text="Какой текст показываем этому сегменту." /></div>
|
||||
<select className="input" value={assignForm.variant_id || ''} onChange={(e) => setAssignForm({ ...assignForm, variant_id: Number(e.target.value) })} disabled={!variantsSorted.length}>
|
||||
{variantsSorted.length ? variantsSorted.map((v) => <option key={v.id} value={v.id}>{variantDisplay(v.id)}</option>) : <option value="">Сначала сгенерируйте тексты</option>}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button className="btn" disabled={busy || !assignForm.segment_id || !assignForm.variant_id} onClick={async () => {
|
||||
setBusy(true); setErr(null);
|
||||
try { await apiPost(`/api/tests/${testId}/assign/`, { assignments: [{ segment_id: assignForm.segment_id, variant_id: assignForm.variant_id }] }); await load(); setResult((r:any)=>({...r, segment_id: assignForm.segment_id, variant_id: assignForm.variant_id})); }
|
||||
catch(e:any){ setErr(e.message||String(e)); }
|
||||
finally{ setBusy(false); }
|
||||
}}>
|
||||
Добавить
|
||||
</button>
|
||||
|
||||
<button className="btn secondary" disabled={busy} onClick={presetAssignmentsAB}>Пресет A/B</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row section">
|
||||
<div className="card" style={{ flex: 1, minWidth: 320 }}>
|
||||
<div className="cardtitle">
|
||||
<h3 style={{ margin: 0 }}>Результаты (ручной ввод)</h3>
|
||||
<Tooltip text="Введите показатели по тексту и сегменту: показы, клики, конверсии, лиды, расход." />
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ alignItems: 'flex-end' }}>
|
||||
<div style={{ width: 170 }}>
|
||||
<div className="tiprow"><div className="small">Дата</div><Tooltip text="Дата, к которой относится статистика (если вводите по дням)." /></div>
|
||||
<input className="input" type="date" value={result.date} onChange={(e) => setResult({ ...result, date: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 180 }}>
|
||||
<div className="tiprow"><div className="small">Сегмент</div><Tooltip text="Сегмент аудитории. Список текстов справа фильтруется по распределению." /></div>
|
||||
<select className="input" value={result.segment_id || ''} onChange={(e) => setResult({ ...result, segment_id: Number(e.target.value) })} disabled={!segments.length}>
|
||||
{segments.length ? segments.map((s) => <option key={s.id} value={s.id}>{s.name}</option>) : <option value="">Сначала добавьте сегменты</option>}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 260 }}>
|
||||
<div className="tiprow"><div className="small">Текст</div><Tooltip text="Показывает только тексты, распределённые выбранному сегменту." /></div>
|
||||
<select className="input" value={result.variant_id || ''} onChange={(e) => setResult({ ...result, variant_id: Number(e.target.value) })} disabled={!variantsForSelectedSegment.length}>
|
||||
{variantsForSelectedSegment.length ? variantsForSelectedSegment.map((v) => <option key={v.id} value={v.id}>{variantDisplay(v.id)}</option>) : <option value="">Нет текстов для сегмента</option>}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<div style={{ width: 160 }}>
|
||||
<div className="tiprow"><div className="small">Показы</div><Tooltip text="Impressions — нужно для CTR (клики/показы)." /></div>
|
||||
<input className="input" type="number" value={result.impressions} onChange={(e) => setResult({ ...result, impressions: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div style={{ width: 160 }}>
|
||||
<div className="tiprow"><div className="small">Клики</div><Tooltip text="Clicks — нужно для CTR и CPC (расход/клики)." /></div>
|
||||
<input className="input" type="number" value={result.clicks} onChange={(e) => setResult({ ...result, clicks: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div style={{ width: 160 }}>
|
||||
<div className="tiprow"><div className="small">Конверсии</div><Tooltip text="Conversions — нужно для CR и CPA (расход/конверсии)." /></div>
|
||||
<input className="input" type="number" value={result.conversions} onChange={(e) => setResult({ ...result, conversions: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div style={{ width: 160 }}>
|
||||
<div className="tiprow"><div className="small">Лиды</div><Tooltip text="Leads — для лидогенерации (CPL = расход/лиды)." /></div>
|
||||
<input className="input" type="number" value={result.leads} onChange={(e) => setResult({ ...result, leads: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div style={{ width: 180 }}>
|
||||
<div className="tiprow"><div className="small">Расход</div><Tooltip text="Spend — расходы на этот текст (для CPC/CPL/CPA)." /></div>
|
||||
<input className="input" type="number" value={result.spend} onChange={(e) => setResult({ ...result, spend: Number(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ marginTop: 12 }}>
|
||||
<button className="btn" disabled={busy || !result.segment_id || !result.variant_id || !result.date} onClick={async () => {
|
||||
setBusy(true); setErr(null);
|
||||
try { await apiPost(`/api/tests/${testId}/add_results/`, { results: [result] }); await load(); }
|
||||
catch(e:any){ setErr(e.message||String(e)); }
|
||||
finally{ setBusy(false); }
|
||||
}}>
|
||||
Сохранить
|
||||
</button>
|
||||
|
||||
<button className="btn secondary" disabled={busy} onClick={() => applyResultPreset(1)}>Пресет 1</button>
|
||||
<button className="btn secondary" disabled={busy} onClick={() => applyResultPreset(2)}>Пресет 2</button>
|
||||
</div>
|
||||
|
||||
<div className="small" style={{ marginTop: 10 }}>
|
||||
После ввода результатов нажмите <b>«Проанализировать»</b> сверху — появится рейтинг и метрики.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card section">
|
||||
<div className="cardtitle">
|
||||
<h3 style={{ margin: 0 }}>Сводка и таблицы</h3>
|
||||
<div className="small">Результаты теста и история</div>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div style={{ flex: 1, minWidth: 340 }}>
|
||||
<div className="small" style={{ marginBottom: 8 }}>Сегменты</div>
|
||||
<div className="tx-wrap">
|
||||
<table className="tx-table">
|
||||
<thead><tr><th>#</th><th>Название</th><th>Описание</th></tr></thead>
|
||||
<tbody>
|
||||
{segments.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td className="small">{s.id}</td>
|
||||
<td style={{ fontWeight: 800 }}>{s.name}</td>
|
||||
<td className="small">{s.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!segments.length && <tr><td colSpan={3} className="small">Пока нет сегментов.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 340 }}>
|
||||
<div className="small" style={{ marginBottom: 8 }}>Распределение (A/B)</div>
|
||||
<div className="tx-wrap">
|
||||
<table className="tx-table">
|
||||
<thead><tr><th>#</th><th>Сегмент</th><th>Текст</th></tr></thead>
|
||||
<tbody>
|
||||
{assignments.map((a) => (
|
||||
<tr key={a.id}>
|
||||
<td className="small">{a.id}</td>
|
||||
<td className="small">{segmentName(a.segment || a.segment_id)}</td>
|
||||
<td style={{ fontWeight: 800 }} className="small">{variantDisplay(a.variant || a.variant_id)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!assignments.length && <tr><td colSpan={3} className="small">Пока нет распределения.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row section">
|
||||
<div style={{ flex: 1, minWidth: 340 }}>
|
||||
<div className="small" style={{ marginBottom: 8 }}>История результатов</div>
|
||||
<div className="tx-wrap">
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th><th>Сегмент</th><th>Текст</th><th>Показы</th><th>Клики</th><th>Лиды</th><th>Конв.</th><th>Расход</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className="small">{r.date}</td>
|
||||
<td className="small">{segmentName(r.segment)}</td>
|
||||
<td className="small">{variantDisplay(r.variant)}</td>
|
||||
<td>{r.impressions}</td>
|
||||
<td>{r.clicks}</td>
|
||||
<td>{r.leads}</td>
|
||||
<td>{r.conversions}</td>
|
||||
<td style={{ fontWeight: 800 }}>{safeNum(r.spend)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!results.length && <tr><td colSpan={8} className="small">Пока нет введённых результатов.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row section">
|
||||
<div style={{ flex: 1, minWidth: 340 }}>
|
||||
<div className="cardtitle">
|
||||
<div className="small">Рейтинг текстов (лучший → худший)</div>
|
||||
<div className="meta2">
|
||||
<span className="kv"><b>CTR</b> <Tooltip text="Click‑Through Rate: клики / показы." /></span>
|
||||
<span className="kv"><b>CPC</b> <Tooltip text="Cost per Click: расход / клики." /></span>
|
||||
<span className="kv"><b>CR</b> <Tooltip text="Conversion Rate: конверсии / клики." /></span>
|
||||
<span className="kv"><b>CPL</b> <Tooltip text="Cost per Lead: расход / лиды." /></span>
|
||||
<span className="kv"><b>CPA</b> <Tooltip text="Cost per Action: расход / конверсии." /></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!snapshot ? (
|
||||
<div className="small">Пока анализа нет. Нажмите «Проанализировать».</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="list" style={{ marginTop: 12 }}>
|
||||
{ranking.map((r: any) => {
|
||||
const varId = Number(r.variant_id)
|
||||
const perSeg = aggByVariant.get(varId) || []
|
||||
const reason = cleanedReason(r.reason || '')
|
||||
return (
|
||||
<div key={varId} className="item">
|
||||
<div className="avatar">{r.rank}</div>
|
||||
<div className="meta">
|
||||
<div className="h">{variantDisplay(varId)}</div>
|
||||
{reason && <div className="s">{reason}</div>}
|
||||
|
||||
<div className="sep" />
|
||||
|
||||
<div className="small" style={{ marginBottom: 8 }}>Показатели по сегментам</div>
|
||||
|
||||
{!perSeg.length ? (
|
||||
<div className="small">Нет введённых результатов для этого текста.</div>
|
||||
) : (
|
||||
<div className="tx-wrap" style={{ boxShadow: 'none' }}>
|
||||
<table className="tx-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Сегмент</th>
|
||||
<th>CTR <Tooltip text="клики / показы" /></th>
|
||||
<th>CPC <Tooltip text="расход / клики" /></th>
|
||||
<th>CR <Tooltip text="конверсии / клики" /></th>
|
||||
<th>CPL <Tooltip text="расход / лиды" /></th>
|
||||
<th>CPA <Tooltip text="расход / конверсии" /></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{perSeg.map((m) => (
|
||||
<tr key={`${m.segment_id}:${m.variant_id}`}>
|
||||
<td className="small">{segmentName(m.segment_id)}</td>
|
||||
<td className="small">{safePct01(m.ctr)}</td>
|
||||
<td className="small">{safeNum(m.cpc)}</td>
|
||||
<td className="small">{safePct01(m.cr)}</td>
|
||||
<td className="small">{safeNum(m.cpl)}</td>
|
||||
<td className="small">{safeNum(m.cpa)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="card compact" style={{ marginTop: 12, border: '1px solid var(--border)', boxShadow: 'none' }}>
|
||||
<div style={{ fontWeight: 800, marginBottom: 6 }}>Рекомендации</div>
|
||||
<ul style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{recs.map((x: string, i: number) => (
|
||||
<li key={i} className="small" style={{ marginBottom: 6 }}>
|
||||
{x}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
169
frontend/src/pages/Tests.tsx
Normal file
169
frontend/src/pages/Tests.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { apiGet, apiPost } from '../api/client'
|
||||
import NavBar from '../components/NavBar'
|
||||
import Tooltip from '../components/Tooltip'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
|
||||
function briefLabel(briefs: any[], id: any) {
|
||||
const b = briefs.find((x) => Number(x.id) === Number(id))
|
||||
if (!b) return `Бриф`
|
||||
const name = String(b.product || '').trim()
|
||||
const short = name.length > 42 ? name.slice(0, 42) + '…' : name
|
||||
return short || 'Бриф'
|
||||
}
|
||||
|
||||
export default function Tests() {
|
||||
const [tests, setTests] = useState<any[]>([])
|
||||
const [briefs, setBriefs] = useState<any[]>([])
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const nav = useNavigate()
|
||||
|
||||
const [form, setForm] = useState<any>({
|
||||
brief: '',
|
||||
name: 'Тест',
|
||||
channel: '',
|
||||
duration_days: 3,
|
||||
sample_size: 0,
|
||||
objective: 'leads',
|
||||
status: 'draft',
|
||||
})
|
||||
|
||||
const load = async () => {
|
||||
setErr(null)
|
||||
const [ts, bs] = await Promise.all([apiGet('/api/tests/'), apiGet('/api/briefs/')])
|
||||
setTests(ts)
|
||||
setBriefs(bs)
|
||||
if (!form.brief && bs.length) setForm((f: any) => ({ ...f, brief: bs[0].id }))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load().catch((e: any) => setErr(e.message || String(e)))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<NavBar />
|
||||
<div className="stats">
|
||||
<div className="stat"><div className="dot">B</div><div><div className="label">Briefs</div><div className="value">{briefs.length}</div></div></div>
|
||||
<div className="stat"><div className="dot">T</div><div><div className="label">Tests</div><div className="value">{tests.length}</div></div></div>
|
||||
<div className="stat"><div className="dot">A</div><div><div className="label">Objective</div><div className="value">{String(form.objective).toUpperCase()}</div></div></div>
|
||||
<div className="stat"><div className="dot">D</div><div><div className="label">Duration</div><div className="value">{form.duration_days}d</div></div></div>
|
||||
</div>
|
||||
<div style={{ height: 16 }} />
|
||||
<div className="row">
|
||||
<div className="card" style={{ flex: 1, minWidth: 340 }}>
|
||||
<h2 style={{ marginTop: 0 }}>Создать тест</h2>
|
||||
{err && <div style={{ color: '#fca5a5' }}>{err}</div>}
|
||||
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<div>
|
||||
<div className="tiprow"><div className="small">Бриф</div><Tooltip text="Выбираем бриф (продукт/аудитория/УТП), из которого берём варианты текстов для теста." /></div>
|
||||
<select
|
||||
className="input"
|
||||
value={form.brief ?? ''}
|
||||
onChange={(e) => setForm({ ...form, brief: Number(e.target.value) })}
|
||||
>
|
||||
{briefs.map((b) => (
|
||||
<option key={b.id} value={b.id}>{String(b.product).slice(0, 50)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="tiprow"><div className="small">Название</div><Tooltip text="Короткое имя теста для удобства: например “Яндекс_Поиск_март_1”." /></div>
|
||||
<input className="input" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ width: 180 }}>
|
||||
<div className="tiprow"><div className="small">Цель</div><Tooltip text="По чему ранжируем варианты: лиды / конверсии / клики. Агент №2 будет оптимизировать под эту цель." /></div>
|
||||
<select className="input" value={form.objective} onChange={(e) => setForm({ ...form, objective: e.target.value })}>
|
||||
<option value="leads">Лиды</option>
|
||||
<option value="conversions">Конверсии</option>
|
||||
<option value="clicks">Клики</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row">
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="tiprow"><div className="small">Канал (опц.)</div><Tooltip text="Где тестируете: Яндекс, VK, Email и т.д. Нужно для истории и будущих рекомендаций по каналу." /></div>
|
||||
<input className="input" value={form.channel} onChange={(e) => setForm({ ...form, channel: e.target.value })} />
|
||||
</div>
|
||||
<div style={{ width: 160 }}>
|
||||
<div className="tiprow"><div className="small">Дней</div><Tooltip text="Плановая длительность теста (сколько дней собираете статистику)." /></div>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={form.duration_days}
|
||||
onChange={(e) => setForm({ ...form, duration_days: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 160 }}>
|
||||
<div className="tiprow"><div className="small">Выборка</div><Tooltip text="Плановый объём данных (например показы/клики). Ориентир “достаточно ли данных” для выводов." /></div>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={form.sample_size}
|
||||
onChange={(e) => setForm({ ...form, sample_size: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn"
|
||||
disabled={busy || !form.brief}
|
||||
onClick={async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
const res = await apiPost('/api/tests/', form)
|
||||
await load()
|
||||
nav(`/tests/${res.id}`)
|
||||
} catch (e: any) {
|
||||
setErr(e.message || String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ flex: 1, minWidth: 340 }}>
|
||||
<h2 style={{ marginTop: 0 }}>Тесты</h2>
|
||||
<div className="small">Открой тест, чтобы добавить сегменты, распределение, результаты и анализ.</div>
|
||||
|
||||
<table className="table" style={{ marginTop: 12 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Objective</th>
|
||||
<th>Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tests.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td>{t.id}</td>
|
||||
<td>
|
||||
<Link to={`/tests/${t.id}`} style={{ fontWeight: 700 }}>
|
||||
{t.name}
|
||||
</Link>
|
||||
<div className="small">{briefLabel(briefs, t.brief)}</div>
|
||||
</td>
|
||||
<td>{t.objective}</td>
|
||||
<td className="small">{t.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
402
frontend/src/styles/app.css
Normal file
402
frontend/src/styles/app.css
Normal file
@@ -0,0 +1,402 @@
|
||||
:root{
|
||||
--bg: #f5f7fa;
|
||||
--card: #ffffff;
|
||||
--text: #343c6a;
|
||||
--muted: #718ebf;
|
||||
--border: #e6eff5;
|
||||
--primary: #2d60ff;
|
||||
--danger: #fe5c73;
|
||||
--shadow: 0 10px 30px rgba(0,0,0,.06);
|
||||
|
||||
--radius-lg: 16px;
|
||||
--radius-md: 12px;
|
||||
--radius-sm: 10px;
|
||||
|
||||
--pad: 16px;
|
||||
--pad-lg: 22px;
|
||||
}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{
|
||||
margin:0;
|
||||
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
a{color:inherit;text-decoration:none}
|
||||
a:hover{opacity:.9;text-decoration:underline}
|
||||
|
||||
.container{max-width:1180px;margin:0 auto;padding:24px}
|
||||
.row{display:flex;gap:16px;flex-wrap:wrap}
|
||||
|
||||
.card{
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--pad-lg);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card.compact{padding: var(--pad)}
|
||||
|
||||
h2,h3{letter-spacing:-0.02em}
|
||||
h2{font-size:22px;line-height:28px}
|
||||
h3{font-size:18px;line-height:24px}
|
||||
|
||||
.small{color:var(--muted);font-size:12px}
|
||||
|
||||
.input{
|
||||
width:100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 14px;
|
||||
background:#fff;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
.input:focus{
|
||||
border-color: rgba(45,96,255,.55);
|
||||
box-shadow: 0 0 0 4px rgba(45,96,255,.10);
|
||||
}
|
||||
|
||||
textarea{resize:none}
|
||||
|
||||
.btn{
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 11px 14px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
transition: transform .04s ease, opacity .15s ease;
|
||||
}
|
||||
.btn:hover{opacity:.95}
|
||||
.btn:active{transform: translateY(1px)}
|
||||
.btn:disabled{opacity:.55;cursor:not-allowed}
|
||||
|
||||
.btn.secondary{
|
||||
background:#eef3ff;
|
||||
color: var(--primary);
|
||||
border: 1px solid rgba(45,96,255,.18);
|
||||
}
|
||||
|
||||
.badge{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
}
|
||||
.badge::before{
|
||||
content:"";
|
||||
width:8px;height:8px;border-radius:999px;
|
||||
background: var(--muted);
|
||||
}
|
||||
.badge.active::before{background: #16a34a}
|
||||
.badge.paused::before{background: var(--danger)}
|
||||
|
||||
.table{width:100%;border-collapse:separate;border-spacing:0}
|
||||
.table th{
|
||||
text-align:left;
|
||||
font-size:12px;
|
||||
color: var(--muted);
|
||||
font-weight:700;
|
||||
padding: 10px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.table td{
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
pre{
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
overflow:auto;
|
||||
}
|
||||
|
||||
.code{
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
background:#f8fafc;
|
||||
border:1px solid var(--border);
|
||||
padding:2px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* --- BankDash-like dashboard layout helpers --- */
|
||||
.shell{
|
||||
display:flex;
|
||||
gap: 18px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.sidebar{
|
||||
width: 230px;
|
||||
position: sticky;
|
||||
top: 18px;
|
||||
}
|
||||
.main{
|
||||
flex: 1;
|
||||
min-width: 340px;
|
||||
}
|
||||
.brand{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
font-weight:800;
|
||||
letter-spacing:-0.03em;
|
||||
}
|
||||
.nav{
|
||||
display:grid;
|
||||
gap: 6px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.nav a{
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav a.active{
|
||||
background: rgba(45,96,255,.10);
|
||||
color: var(--primary);
|
||||
border-color: rgba(45,96,255,.12);
|
||||
}
|
||||
.topbar{
|
||||
display:flex;
|
||||
justify-content: space-between;
|
||||
align-items:center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.search{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap: 10px;
|
||||
background:#fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
min-width: 280px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.iconbtn{
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.iconbtn:hover{background:#f8fafc}
|
||||
|
||||
.stats{
|
||||
display:grid;
|
||||
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
@media (max-width: 1100px){
|
||||
.stats{ grid-template-columns: repeat(2, minmax(160px, 1fr)); }
|
||||
.sidebar{ display:none; }
|
||||
}
|
||||
.stat{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.stat .dot{
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
background: rgba(45,96,255,.10);
|
||||
color: var(--primary);
|
||||
font-weight: 800;
|
||||
}
|
||||
.stat .label{ font-size: 12px; color: var(--muted); }
|
||||
.stat .value{ font-size: 18px; font-weight: 800; letter-spacing: -0.02em; }
|
||||
|
||||
.cardtitle{
|
||||
display:flex;
|
||||
justify-content: space-between;
|
||||
align-items:center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.pill{
|
||||
border: 1px solid rgba(45,96,255,.20);
|
||||
background: #fff;
|
||||
color: var(--primary);
|
||||
border-radius: 999px;
|
||||
padding: 7px 12px;
|
||||
font-weight: 700;
|
||||
cursor:pointer;
|
||||
}
|
||||
.pill:hover{background: rgba(45,96,255,.06)}
|
||||
.pill.primary{
|
||||
background: var(--primary);
|
||||
color:#fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
.pill.danger{
|
||||
background: rgba(254,92,115,.10);
|
||||
color: var(--danger);
|
||||
border-color: rgba(254,92,115,.20);
|
||||
}
|
||||
|
||||
.list{
|
||||
display:grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.item{
|
||||
display:flex;
|
||||
gap: 12px;
|
||||
align-items:flex-start;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
background:#fff;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.item .avatar{
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 16px;
|
||||
background: rgba(45,96,255,.10);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
font-weight: 800;
|
||||
color: var(--primary);
|
||||
}
|
||||
.item .meta{ flex:1; }
|
||||
.item .meta .h{ font-weight: 800; margin-bottom: 2px; }
|
||||
.item .meta .s{ color: var(--muted); font-size: 12px; }
|
||||
.item .actions{ display:flex; gap: 8px; align-items:center; }
|
||||
|
||||
/* Tooltips */
|
||||
.tiprow{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
||||
.tipbtn{
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
color: var(--muted);
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
}
|
||||
.tipbtn:hover{background:#f8fafc;color:var(--text)}
|
||||
.tooltip{
|
||||
position: absolute;
|
||||
top: 28px;
|
||||
right: 0;
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
z-index: 50;
|
||||
}
|
||||
.tooltip::before{
|
||||
content:"";
|
||||
position:absolute;
|
||||
top:-6px;
|
||||
right:10px;
|
||||
width:10px;height:10px;
|
||||
transform: rotate(45deg);
|
||||
background:#fff;
|
||||
border-left: 1px solid var(--border);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* BankDash-like transaction table */
|
||||
.tx-wrap{
|
||||
background:#fff;
|
||||
border:1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow);
|
||||
overflow:hidden;
|
||||
}
|
||||
.tx-table{
|
||||
width:100%;
|
||||
border-collapse:separate;
|
||||
border-spacing:0;
|
||||
}
|
||||
.tx-table th{
|
||||
font-size:12px;
|
||||
color: var(--muted);
|
||||
font-weight:800;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #fff;
|
||||
}
|
||||
.tx-table td{
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
font-size: 14px;
|
||||
}
|
||||
.tx-table tr:last-child td{border-bottom:none}
|
||||
.tx-row{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap: 12px;
|
||||
}
|
||||
.tx-ic{
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(45,96,255,.25);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
color: var(--primary);
|
||||
background: rgba(45,96,255,.06);
|
||||
font-weight: 900;
|
||||
}
|
||||
.tx-btn{
|
||||
border: 1px solid rgba(45,96,255,.28);
|
||||
background: #fff;
|
||||
color: var(--primary);
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.tx-btn:hover{background: rgba(45,96,255,.06)}
|
||||
.section{
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
|
||||
.meta2{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-top:6px}
|
||||
.kv{display:inline-flex;gap:6px;align-items:center;color:var(--muted);font-size:12px}
|
||||
.kv b{color:var(--text);font-weight:800}
|
||||
.sep{height:1px;background:var(--border);margin:14px 0}
|
||||
1
frontend/tsconfig.json
Normal file
1
frontend/tsconfig.json
Normal file
@@ -0,0 +1 @@
|
||||
{"compilerOptions":{"target":"ES2022","lib":["ES2022","DOM","DOM.Iterable"],"module":"ESNext","moduleResolution":"Bundler","resolveJsonModule":true,"isolatedModules":true,"noEmit":true,"jsx":"react-jsx","strict":true,"skipLibCheck":true},"include":["src"]}
|
||||
3
frontend/vite.config.ts
Normal file
3
frontend/vite.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
export default defineConfig({ plugins: [react()], server: { port: 5174 } })
|
||||
Reference in New Issue
Block a user