commit 36a7d530c129c6b361564a052e6a585ed9263ea7 Author: Ilnar Date: Thu Mar 5 06:55:42 2026 +0300 MVP0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..61de7fe --- /dev/null +++ b/README.md @@ -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 дней diff --git a/agents_service/.env b/agents_service/.env new file mode 100644 index 0000000..f4238f6 --- /dev/null +++ b/agents_service/.env @@ -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 diff --git a/agents_service/.env.example b/agents_service/.env.example new file mode 100644 index 0000000..ce64648 --- /dev/null +++ b/agents_service/.env.example @@ -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 diff --git a/agents_service/Dockerfile b/agents_service/Dockerfile new file mode 100644 index 0000000..09dac83 --- /dev/null +++ b/agents_service/Dockerfile @@ -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"] diff --git a/agents_service/README.md b/agents_service/README.md new file mode 100644 index 0000000..bd8a5ae --- /dev/null +++ b/agents_service/README.md @@ -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` diff --git a/agents_service/requirements.txt b/agents_service/requirements.txt new file mode 100644 index 0000000..6eac052 --- /dev/null +++ b/agents_service/requirements.txt @@ -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 diff --git a/agents_service/src/agents/analyze_agent.py b/agents_service/src/agents/analyze_agent.py new file mode 100644 index 0000000..affdc4b --- /dev/null +++ b/agents_service/src/agents/analyze_agent.py @@ -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, + } diff --git a/agents_service/src/agents/textgen_agent.py b/agents_service/src/agents/textgen_agent.py new file mode 100644 index 0000000..33ae1a1 --- /dev/null +++ b/agents_service/src/agents/textgen_agent.py @@ -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} diff --git a/agents_service/src/api/routes.py b/agents_service/src/api/routes.py new file mode 100644 index 0000000..1c59c50 --- /dev/null +++ b/agents_service/src/api/routes.py @@ -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)) diff --git a/agents_service/src/chains/text_generation.py b/agents_service/src/chains/text_generation.py new file mode 100644 index 0000000..10481f5 --- /dev/null +++ b/agents_service/src/chains/text_generation.py @@ -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]) diff --git a/agents_service/src/llm/gigachat_client.py b/agents_service/src/llm/gigachat_client.py new file mode 100644 index 0000000..b29aada --- /dev/null +++ b/agents_service/src/llm/gigachat_client.py @@ -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, + ) diff --git a/agents_service/src/main.py b/agents_service/src/main.py new file mode 100644 index 0000000..7203baf --- /dev/null +++ b/agents_service/src/main.py @@ -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} diff --git a/agents_service/src/models/schemas.py b/agents_service/src/models/schemas.py new file mode 100644 index 0000000..a3290a6 --- /dev/null +++ b/agents_service/src/models/schemas.py @@ -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 diff --git a/backend_django/.env b/backend_django/.env new file mode 100644 index 0000000..7739550 --- /dev/null +++ b/backend_django/.env @@ -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 diff --git a/backend_django/.env.example b/backend_django/.env.example new file mode 100644 index 0000000..0e5b9a0 --- /dev/null +++ b/backend_django/.env.example @@ -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 diff --git a/backend_django/Dockerfile b/backend_django/Dockerfile new file mode 100644 index 0000000..89cec06 --- /dev/null +++ b/backend_django/Dockerfile @@ -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"] diff --git a/backend_django/README.md b/backend_django/README.md new file mode 100644 index 0000000..cb0aaea --- /dev/null +++ b/backend_django/README.md @@ -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` diff --git a/backend_django/adsassistant_backend/adsassistant_backend/settings.py b/backend_django/adsassistant_backend/adsassistant_backend/settings.py new file mode 100644 index 0000000..863b427 --- /dev/null +++ b/backend_django/adsassistant_backend/adsassistant_backend/settings.py @@ -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), +} diff --git a/backend_django/adsassistant_backend/adsassistant_backend/urls.py b/backend_django/adsassistant_backend/adsassistant_backend/urls.py new file mode 100644 index 0000000..0072f05 --- /dev/null +++ b/backend_django/adsassistant_backend/adsassistant_backend/urls.py @@ -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")), +] diff --git a/backend_django/adsassistant_backend/adsassistant_backend/wsgi.py b/backend_django/adsassistant_backend/adsassistant_backend/wsgi.py new file mode 100644 index 0000000..1eedf00 --- /dev/null +++ b/backend_django/adsassistant_backend/adsassistant_backend/wsgi.py @@ -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() diff --git a/backend_django/adsassistant_backend/api/admin.py b/backend_django/adsassistant_backend/api/admin.py new file mode 100644 index 0000000..2c0ec4a --- /dev/null +++ b/backend_django/adsassistant_backend/api/admin.py @@ -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) diff --git a/backend_django/adsassistant_backend/api/migrations/__init__.py b/backend_django/adsassistant_backend/api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend_django/adsassistant_backend/api/models.py b/backend_django/adsassistant_backend/api/models.py new file mode 100644 index 0000000..70545c9 --- /dev/null +++ b/backend_django/adsassistant_backend/api/models.py @@ -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) diff --git a/backend_django/adsassistant_backend/api/serializers.py b/backend_django/adsassistant_backend/api/serializers.py new file mode 100644 index 0000000..9731b4a --- /dev/null +++ b/backend_django/adsassistant_backend/api/serializers.py @@ -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") diff --git a/backend_django/adsassistant_backend/api/services.py b/backend_django/adsassistant_backend/api/services.py new file mode 100644 index 0000000..76eda6f --- /dev/null +++ b/backend_django/adsassistant_backend/api/services.py @@ -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 diff --git a/backend_django/adsassistant_backend/api/urls.py b/backend_django/adsassistant_backend/api/urls.py new file mode 100644 index 0000000..739e21b --- /dev/null +++ b/backend_django/adsassistant_backend/api/urls.py @@ -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)), +] diff --git a/backend_django/adsassistant_backend/api/views.py b/backend_django/adsassistant_backend/api/views.py new file mode 100644 index 0000000..cf4e9f2 --- /dev/null +++ b/backend_django/adsassistant_backend/api/views.py @@ -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) diff --git a/backend_django/manage.py b/backend_django/manage.py new file mode 100644 index 0000000..858977e --- /dev/null +++ b/backend_django/manage.py @@ -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() diff --git a/backend_django/requirements.txt b/backend_django/requirements.txt new file mode 100644 index 0000000..1dbbafb --- /dev/null +++ b/backend_django/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e66be1a --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..c2058b5 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:8000 diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000..c2058b5 --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:8000 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..231ea38 --- /dev/null +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..0cfde16 --- /dev/null +++ b/frontend/README.md @@ -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 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3076373 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,4 @@ +AdsAssistant + + +
diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..0eea1de --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,10 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7afed9b --- /dev/null +++ b/frontend/package.json @@ -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" + } +} \ No newline at end of file diff --git a/frontend/public/.keep b/frontend/public/.keep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..f927f88 --- /dev/null +++ b/frontend/src/api/client.ts @@ -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) +} diff --git a/frontend/src/components/AutoTextarea.tsx b/frontend/src/components/AutoTextarea.tsx new file mode 100644 index 0000000..aa19f75 --- /dev/null +++ b/frontend/src/components/AutoTextarea.tsx @@ -0,0 +1,8 @@ +import React, { useEffect, useRef } from 'react' +type Props = React.TextareaHTMLAttributes & { value: string; onChange: (e: React.ChangeEvent) => void } +export default function AutoTextarea(props: Props){ + const ref = useRef(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