diff --git a/.dockerignore b/.dockerignore index 3db21ea..77c400c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,7 @@ .env +.env.* +!.env.example +!.env.production.example .git .venv __pycache__ @@ -8,5 +11,7 @@ dist coverage playwright-report test-results +data +backups design docs diff --git a/.env.example b/.env.example index 45a034d..ca83ed8 100644 --- a/.env.example +++ b/.env.example @@ -3,9 +3,9 @@ COMPOSE_PROJECT_NAME=edevs_hub HUB_ENV=local HUB_DEBUG=true HUB_SECRET_KEY=change-me-only-for-local-development -HUB_ALLOWED_HOSTS=localhost,127.0.0.1,hub.localhost,pay.localhost,chat.localhost -HUB_CSRF_TRUSTED_ORIGINS=http://localhost,http://localhost:8000,http://localhost:5173,http://localhost:5174,http://localhost:5175 -HUB_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5174,http://localhost:5175 +HUB_ALLOWED_HOSTS=localhost,127.0.0.1,hub.localhost +HUB_CSRF_TRUSTED_ORIGINS=http://localhost,http://localhost:8000,http://localhost:5173,http://localhost:5175 +HUB_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5175 # Шифрование секретов в БД (Fernet-ключ). В local не обязателен — выводится из # HUB_SECRET_KEY; в production задайте отдельный ключ: Fernet.generate_key(). @@ -42,8 +42,7 @@ POSTGRES_PORT=5432 REDIS_URL=redis://redis:6379/0 INTERNAL_UI_PORT=5173 -CHECKOUT_PORT=5174 WEB_CHAT_PORT=5175 BACKEND_PORT=8010 -VITE_API_BASE_URL=http://localhost:8010/api/v1 +VITE_API_BASE_URL=http://localhost:8010 diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..fad5a62 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,52 @@ +COMPOSE_PROJECT_NAME=edevs_hub + +# Filled by GitLab deploy job or by hand on the server. +HUB_BACKEND_IMAGE=registry.example.com/edevs/hub/backend:change-me +HUB_FRONTEND_IMAGE=registry.example.com/edevs/hub/frontend:change-me +HUB_FRONTEND_HOST_PORT=8080 + +HUB_ENV=production +HUB_DEBUG=false +HUB_SECRET_KEY=change-me-long-random-secret +HUB_FIELD_ENCRYPTION_KEY=change-me-fernet-key + +HUB_ALLOWED_HOSTS=hub.edevs.tech +HUB_CSRF_TRUSTED_ORIGINS=https://hub.edevs.tech +HUB_CORS_ALLOWED_ORIGINS=https://hub.edevs.tech +INTERNAL_UI_BASE_URL=https://hub.edevs.tech + +HUB_COOKIE_SECURE=true +HUB_SSL_REDIRECT=true +HUB_HSTS_SECONDS=31536000 +HUB_COOKIE_SAMESITE=Lax + +POSTGRES_DB=edevs_hub +POSTGRES_USER=edevs_hub +POSTGRES_PASSWORD=change-me-db-password +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +REDIS_URL=redis://redis:6379/0 + +HUB_AI_PROVIDER=openrouter +HUB_OPENROUTER_API_KEY= +HUB_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 +HUB_AI_REQUEST_TIMEOUT=30 +HUB_AI_MAX_RETRIES=2 +HUB_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=0 + +EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend +EMAIL_HOST= +EMAIL_PORT=587 +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +EMAIL_USE_TLS=true +DEFAULT_FROM_EMAIL=Edevs Hub + +# Required only for the first production seed when no OWNER exists yet. +# The seed command never resets an existing user's password. +HUB_SEED_OWNER_EMAIL= +HUB_SEED_OWNER_PASSWORD= +HUB_SEED_OWNER_NAME= + +HUB_GUNICORN_WORKERS=3 +HUB_GUNICORN_TIMEOUT=60 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..b87bdd7 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,111 @@ +stages: + - validate + - test + - build + - deploy + +variables: + DOCKER_TLS_CERTDIR: "/certs" + DOCKER_DRIVER: overlay2 + BACKEND_IMAGE: "$CI_REGISTRY_IMAGE/backend:$CI_COMMIT_SHA" + FRONTEND_IMAGE: "$CI_REGISTRY_IMAGE/frontend:$CI_COMMIT_SHA" + BACKEND_LATEST_IMAGE: "$CI_REGISTRY_IMAGE/backend:latest" + FRONTEND_LATEST_IMAGE: "$CI_REGISTRY_IMAGE/frontend:latest" + +backend:test: + stage: test + image: python:3.12-slim + services: + - name: pgvector/pgvector:pg16 + alias: postgres + - name: redis:7-alpine + alias: redis + variables: + HUB_DEBUG: "true" + HUB_SECRET_KEY: "ci-test-secret" + HUB_FIELD_ENCRYPTION_KEY: "" + POSTGRES_DB: "edevs_hub_test" + POSTGRES_USER: "edevs_hub" + POSTGRES_PASSWORD: "edevs_hub" + POSTGRES_HOST: "postgres" + POSTGRES_PORT: "5432" + REDIS_URL: "redis://redis:6379/0" + before_script: + - cd apps/backend + - python -m pip install --upgrade pip + - pip install -r requirements.txt + script: + - python manage.py check + - python manage.py test + rules: + - if: '$CI_COMMIT_BRANCH' + +frontend:build: + stage: validate + image: node:22-alpine + before_script: + - npm ci + script: + - npm run typecheck + - npm run build + rules: + - if: '$CI_COMMIT_BRANCH' + +images:build: + stage: build + image: docker:27 + services: + - name: docker:27-dind + alias: docker + needs: + - backend:test + - frontend:build + before_script: + - echo "$CI_REGISTRY_PASSWORD" | docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin + script: + - docker build -f apps/backend/Dockerfile.production -t "$BACKEND_IMAGE" -t "$BACKEND_LATEST_IMAGE" . + - docker build -f deploy/docker/frontend.Dockerfile -t "$FRONTEND_IMAGE" -t "$FRONTEND_LATEST_IMAGE" . + - docker push "$BACKEND_IMAGE" + - docker push "$FRONTEND_IMAGE" + - | + if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then + docker push "$BACKEND_LATEST_IMAGE" + docker push "$FRONTEND_LATEST_IMAGE" + fi + rules: + - if: '$CI_COMMIT_BRANCH' + +deploy:production: + stage: deploy + image: alpine:3.20 + needs: + - images:build + before_script: + - apk add --no-cache openssh-client + - mkdir -p ~/.ssh + - chmod 700 ~/.ssh + - printf '%s\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519 + - chmod 600 ~/.ssh/id_ed25519 + - ssh-keyscan -p "${DEPLOY_PORT:-22}" "$DEPLOY_HOST" >> ~/.ssh/known_hosts + script: + - ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" "sudo mkdir -p '$DEPLOY_PATH' && sudo chown '$DEPLOY_USER':'$DEPLOY_USER' '$DEPLOY_PATH'" + - scp -P "${DEPLOY_PORT:-22}" compose.production.yaml "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/compose.production.yaml" + - | + ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" "printf '%s\n' \ + 'HUB_BACKEND_IMAGE=$BACKEND_IMAGE' \ + 'HUB_FRONTEND_IMAGE=$FRONTEND_IMAGE' \ + > '$DEPLOY_PATH/.env.release'" + - | + ssh -p "${DEPLOY_PORT:-22}" "$DEPLOY_USER@$DEPLOY_HOST" "cd '$DEPLOY_PATH' && \ + echo '$CI_REGISTRY_PASSWORD' | sudo docker login '$CI_REGISTRY' -u '$CI_REGISTRY_USER' --password-stdin && \ + sudo docker compose --env-file .env.production --env-file .env.release -f compose.production.yaml pull && \ + sudo docker compose --env-file .env.production --env-file .env.release -f compose.production.yaml up -d && \ + sudo docker compose --env-file .env.production --env-file .env.release -f compose.production.yaml exec -T backend python manage.py migrate --noinput && \ + sudo docker compose --env-file .env.production --env-file .env.release -f compose.production.yaml exec -T backend python manage.py collectstatic --noinput && \ + sudo docker compose --env-file .env.production --env-file .env.release -f compose.production.yaml exec -T backend python manage.py seed_hub_initial_data" + environment: + name: production + url: https://hub.edevs.tech + rules: + - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' + when: manual diff --git a/apps/backend/Dockerfile.production b/apps/backend/Dockerfile.production new file mode 100644 index 0000000..bb02f69 --- /dev/null +++ b/apps/backend/Dockerfile.production @@ -0,0 +1,24 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN addgroup --system hub && adduser --system --ingroup hub hub + +COPY apps/backend/requirements.txt /app/apps/backend/requirements.txt +RUN pip install --no-cache-dir -r /app/apps/backend/requirements.txt + +COPY apps/backend /app/apps/backend +COPY content /app/content + +RUN chown -R hub:hub /app + +WORKDIR /app/apps/backend + +USER hub +EXPOSE 8000 + +CMD ["gunicorn", "hub_backend.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "60"] diff --git a/apps/backend/hub_platform/ai/content_importer.py b/apps/backend/hub_platform/ai/content_importer.py new file mode 100644 index 0000000..9b7866f --- /dev/null +++ b/apps/backend/hub_platform/ai/content_importer.py @@ -0,0 +1,290 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +from django.core.management.base import CommandError +from django.db import transaction + +from hub_platform.ai import documents as document_service +from hub_platform.ai import indexing +from hub_platform.ai.models import ( + DocumentScope, + InclusionMode, + KnowledgeCategory, + KnowledgeDocument, + KnowledgeDocumentVersion, + PromptCategory, + PromptDocument, + PromptDocumentVersion, +) +from hub_platform.products.models import Product + +DocumentKind = Literal["prompt", "knowledge"] +_SECTION_RE = re.compile(r"^===\s*(?P[a-z0-9-]+)\s*===\s*$", re.MULTILINE) + + +@dataclass(frozen=True) +class ContentSpec: + kind: DocumentKind + title: str + category: str + product_code: str | None = None + inclusion_mode: str = InclusionMode.RETRIEVAL + + +@dataclass(frozen=True) +class ImportResult: + prompts_created: int = 0 + prompt_versions_created: int = 0 + knowledge_created: int = 0 + knowledge_versions_created: int = 0 + fragments_created: int = 0 + skipped_sections: int = 0 + + def add(self, other: ImportResult) -> ImportResult: + return ImportResult( + prompts_created=self.prompts_created + other.prompts_created, + prompt_versions_created=self.prompt_versions_created + other.prompt_versions_created, + knowledge_created=self.knowledge_created + other.knowledge_created, + knowledge_versions_created=self.knowledge_versions_created + + other.knowledge_versions_created, + fragments_created=self.fragments_created + other.fragments_created, + skipped_sections=self.skipped_sections + other.skipped_sections, + ) + + +PROMPT_CATEGORY_BY_PREFIX = { + "system": PromptCategory.SYSTEM, + "qualify": PromptCategory.QUALIFICATION, + "sales": PromptCategory.SALES_BEHAVIOR, + "handoff": PromptCategory.OPERATOR_HANDOFF, +} + +KNOWLEDGE_CATEGORY_BY_SUFFIX = { + "overview": KnowledgeCategory.OVERVIEW, + "products": KnowledgeCategory.OVERVIEW, + "audience": KnowledgeCategory.AUDIENCE, + "tariffs": KnowledgeCategory.COMMERCIAL, + "catalog": KnowledgeCategory.COMMERCIAL, + "technical": KnowledgeCategory.TECHNICAL, + "faq": KnowledgeCategory.FAQ, + "objections": KnowledgeCategory.OBJECTIONS, + "limitations": KnowledgeCategory.LIMITATIONS, +} + +MANDATORY_KNOWLEDGE_CODES = { + "company-overview", + "company-products", + "foxray-overview", + "foxray-tariffs", + "firepage-overview", + "firepage-catalog", +} + +PRODUCT_CODE_BY_PREFIX = { + "foxray": "foxray", + "firepage": "firepage", +} + +CONTENT_SOURCES = ( + "content/ai-content-company-filled.md", + "content/ai-content-firepage-filled.md", + "content/ai-content-foxray-filled.md", +) + + +def parse_filled_content(text: str) -> dict[str, str]: + matches = list(_SECTION_RE.finditer(text)) + sections: dict[str, str] = {} + for index, match in enumerate(matches): + code = match.group("code") + start = match.end() + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + content = text[start:end].strip() + if content: + sections[code] = content + return sections + + +def infer_content_spec(code: str) -> ContentSpec | None: + parts = code.split("-") + if len(parts) < 2: + return None + prompt_category = PROMPT_CATEGORY_BY_PREFIX.get(parts[0]) + product_code = PRODUCT_CODE_BY_PREFIX.get(parts[-1]) + if prompt_category: + return ContentSpec( + kind="prompt", + title=code, + category=prompt_category, + product_code=product_code, + ) + knowledge_category = KNOWLEDGE_CATEGORY_BY_SUFFIX.get(parts[-1]) + if knowledge_category is None: + return None + return ContentSpec( + kind="knowledge", + title=code, + category=knowledge_category, + product_code=PRODUCT_CODE_BY_PREFIX.get(parts[0]), + inclusion_mode=InclusionMode.MANDATORY + if code in MANDATORY_KNOWLEDGE_CODES + else InclusionMode.RETRIEVAL, + ) + + +def _next_version(version_model, document) -> int: + latest = version_model.objects.filter(document=document).order_by("-version").first() + return latest.version + 1 if latest else 1 + + +def _latest_published(version_model, document): + return ( + version_model.objects.filter(document=document, status="PUBLISHED") + .order_by("-version") + .first() + ) + + +def _resolve_product(*, organization, product_code: str | None) -> Product | None: + if product_code is None: + return None + try: + return Product.objects.get(organization=organization, code=product_code) + except Product.DoesNotExist as error: + raise CommandError( + f"Product '{product_code}' is required before importing AI content" + ) from error + + +@transaction.atomic +def _import_prompt( + *, organization, author, code: str, content: str, spec: ContentSpec +) -> ImportResult: + product = _resolve_product(organization=organization, product_code=spec.product_code) + scope = DocumentScope.PRODUCT if product else DocumentScope.GLOBAL + document, created = PromptDocument.objects.update_or_create( + organization=organization, + product=product, + code=code, + defaults={ + "title": spec.title, + "category": spec.category, + "scope": scope, + "is_enabled": True, + }, + ) + latest = _latest_published(PromptDocumentVersion, document) + if latest and latest.content == content: + return ImportResult(prompts_created=int(created)) + version = PromptDocumentVersion.objects.create( + document=document, + version=_next_version(PromptDocumentVersion, document), + content=content, + created_by=author, + ) + document_service.publish_version(version=version) + return ImportResult(prompts_created=int(created), prompt_versions_created=1) + + +@transaction.atomic +def _import_knowledge( + *, organization, author, code: str, content: str, spec: ContentSpec +) -> ImportResult: + product = _resolve_product(organization=organization, product_code=spec.product_code) + scope = DocumentScope.PRODUCT if product else DocumentScope.GLOBAL + document, created = KnowledgeDocument.objects.update_or_create( + organization=organization, + product=product, + code=code, + defaults={ + "title": spec.title, + "category": spec.category, + "scope": scope, + "is_enabled": True, + "inclusion_mode": spec.inclusion_mode, + }, + ) + latest = _latest_published(KnowledgeDocumentVersion, document) + if latest and latest.content == content: + return ImportResult(knowledge_created=int(created)) + version = KnowledgeDocumentVersion.objects.create( + document=document, + version=_next_version(KnowledgeDocumentVersion, document), + content=content, + created_by=author, + ) + document_service.publish_version(version=version) + fragments = indexing.reindex_knowledge_version(version) + return ImportResult( + knowledge_created=int(created), + knowledge_versions_created=1, + fragments_created=len(fragments), + ) + + +def ensure_channel_system_prompts(*, organization, author) -> ImportResult: + from hub_platform.channels.models import Channel + + result = ImportResult() + for channel in Channel.objects.filter(organization=organization).select_related("product"): + if not channel.system_prompt.strip(): + continue + code = f"system-{channel.product.code}" if channel.product_id else "system-edevs" + product = channel.product if channel.product_id else None + if PromptDocument.objects.filter( + organization=organization, product=product, code=code + ).exists(): + continue + document = PromptDocument.objects.create( + organization=organization, + product=product, + code=code, + title=code, + category=PromptCategory.SYSTEM, + scope=DocumentScope.PRODUCT if product else DocumentScope.GLOBAL, + is_enabled=True, + ) + version = PromptDocumentVersion.objects.create( + document=document, + version=1, + content=channel.system_prompt, + created_by=author, + ) + document_service.publish_version(version=version) + result = result.add(ImportResult(prompts_created=1, prompt_versions_created=1)) + return result + + +def import_ai_content(*, base_dir: Path, organization, author) -> ImportResult: + result = ImportResult() + for relative_path in CONTENT_SOURCES: + path = base_dir / relative_path + if not path.exists(): + continue + for code, content in parse_filled_content(path.read_text(encoding="utf-8")).items(): + spec = infer_content_spec(code) + if spec is None: + result = result.add(ImportResult(skipped_sections=1)) + continue + if spec.kind == "prompt": + section_result = _import_prompt( + organization=organization, + author=author, + code=code, + content=content, + spec=spec, + ) + else: + section_result = _import_knowledge( + organization=organization, + author=author, + code=code, + content=content, + spec=spec, + ) + result = result.add(section_result) + return result diff --git a/apps/backend/hub_platform/ai/seed_releases.py b/apps/backend/hub_platform/ai/seed_releases.py new file mode 100644 index 0000000..af29ddd --- /dev/null +++ b/apps/backend/hub_platform/ai/seed_releases.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from django.db import transaction + +from hub_platform.ai.models import ( + AIAgent, + ChannelAIRelease, + DocumentScope, + DocumentStatus, + KnowledgeDocumentVersion, + PromptDocumentVersion, + ReleaseKnowledgeVersion, + ReleasePromptVersion, + ReleaseStatus, +) +from hub_platform.ai.releases import publish_release +from hub_platform.channels.models import DEFAULT_CHANNEL_MODEL, Channel +from hub_platform.identity.models import HumanUser, Organization + + +def _release_version_ids(release: ChannelAIRelease) -> tuple[set[int], set[int]]: + knowledge_ids = {link.knowledge_version_id for link in release.knowledge_versions.all()} + prompt_ids = {link.prompt_version_id for link in release.prompt_versions.all()} + return knowledge_ids, prompt_ids + + +def _next_release_version(channel: Channel) -> int: + latest = ChannelAIRelease.objects.filter(channel=channel).order_by("-version").first() + return latest.version + 1 if latest else 1 + + +def _published_knowledge_versions(channel: Channel) -> list[KnowledgeDocumentVersion]: + queryset = KnowledgeDocumentVersion.objects.filter( + document__organization=channel.organization, + document__is_enabled=True, + status=DocumentStatus.PUBLISHED, + ) + if channel.product_id: + queryset = queryset.filter(document__scope=DocumentScope.GLOBAL) | queryset.filter( + document__product=channel.product + ) + else: + queryset = queryset.filter(document__scope=DocumentScope.GLOBAL) + return list(queryset.distinct().order_by("document__code", "-version")) + + +def _published_prompt_versions(channel: Channel) -> list[PromptDocumentVersion]: + queryset = PromptDocumentVersion.objects.filter( + document__organization=channel.organization, + document__is_enabled=True, + status=DocumentStatus.PUBLISHED, + ) + if channel.product_id: + queryset = queryset.filter(document__scope=DocumentScope.GLOBAL) | queryset.filter( + document__product=channel.product + ) + else: + queryset = queryset.filter(document__scope=DocumentScope.GLOBAL) + selected: dict[str, PromptDocumentVersion] = {} + for version in ( + queryset.select_related("document").distinct().order_by("document__category", "-version") + ): + category = version.document.category + current = selected.get(category) + if current is None: + selected[category] = version + continue + current_priority = int(bool(current.document.product_id)) + candidate_priority = int(bool(version.document.product_id)) + if (candidate_priority, version.version) > (current_priority, current.version): + selected[category] = version + return list(selected.values()) + + +@transaction.atomic +def _create_release_snapshot( + *, channel: Channel, agent: AIAgent, author: HumanUser | None +) -> ChannelAIRelease: + release = ChannelAIRelease.objects.create( + channel=channel, + version=_next_release_version(channel), + status=ReleaseStatus.DRAFT, + model=agent.model, + model_params=agent.model_params, + allowed_tools=agent.allowed_tools, + limits=agent.limits, + retrieval_index_version="", + notes="Production seed release", + created_by=author, + ) + ReleaseKnowledgeVersion.objects.bulk_create( + [ + ReleaseKnowledgeVersion(release=release, knowledge_version=version) + for version in _published_knowledge_versions(channel) + ] + ) + ReleasePromptVersion.objects.bulk_create( + [ + ReleasePromptVersion(release=release, prompt_version=version) + for version in _published_prompt_versions(channel) + ] + ) + return release + + +def ensure_agents_and_releases( + *, organization: Organization, author: HumanUser | None +) -> tuple[int, int]: + agents_created = 0 + releases_created = 0 + for channel in Channel.objects.filter(organization=organization).order_by("code"): + agent, agent_created = AIAgent.objects.update_or_create( + channel=channel, + defaults={ + "name": f"{channel.name} Agent", + "is_active": True, + "model": channel.model or DEFAULT_CHANNEL_MODEL, + }, + ) + agents_created += int(agent_created) + draft = _create_release_snapshot(channel=channel, agent=agent, author=author) + desired = _release_version_ids(draft) + current = ( + ChannelAIRelease.objects.filter(channel=channel, status=ReleaseStatus.PUBLISHED) + .prefetch_related("knowledge_versions", "prompt_versions") + .order_by("-version") + .first() + ) + if current and current.model == agent.model and _release_version_ids(current) == desired: + draft.delete() + continue + publish_release(release=draft) + releases_created += 1 + return agents_created, releases_created diff --git a/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py b/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py new file mode 100644 index 0000000..199b572 --- /dev/null +++ b/apps/backend/hub_platform/identity/management/commands/seed_hub_initial_data.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +from django.contrib.auth.password_validation import validate_password +from django.core.management import call_command +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django.utils import timezone + +from hub_platform.ai.content_importer import ensure_channel_system_prompts, import_ai_content +from hub_platform.ai.seed_releases import ensure_agents_and_releases +from hub_platform.channels.models import DEFAULT_CHANNEL_MODEL, Channel +from hub_platform.identity.audit import record_audit_event +from hub_platform.identity.models import ( + Department, + EmployeeProfile, + EmployeeRole, + HumanUser, + Organization, +) +from hub_platform.products.models import Product, ProductDepartment + +STYLE = ( + " Пиши простым текстом для мессенджера: без markdown-разметки, короткими абзацами, " + "на русском, по делу. Не проводи оплату и не обещай условия вне базы знаний." +) + +CHANNEL_SPECS = ( + { + "code": "edevs", + "name": "Edevs — главный сайт", + "product_code": None, + "system_prompt": "Ты — AI-ассистент компании Edevs на её главном сайте." + STYLE, + }, + { + "code": "foxray-sales", + "name": "FoxRay — продажи", + "product_code": "foxray", + "system_prompt": "Ты — AI sales-ассистент продукта FoxRay для ортодонтов." + STYLE, + }, + { + "code": "firepage-sales", + "name": "FirePage — продажи", + "product_code": "firepage", + "system_prompt": "Ты — AI sales-ассистент продукта FirePage: готовые нишевые сайты." + + STYLE, + }, +) + +PRODUCT_SPECS = ( + { + "code": "firepage", + "name": "FirePage", + "site_url": "https://firepage.ru", + "summary": "Готовые нишевые сайты на собственной CMS с разовой лицензией.", + }, + { + "code": "foxray", + "name": "FoxRay", + "site_url": "https://foxray.pro", + "summary": "Онлайн-сервис для цефалометрического анализа ТРГ.", + }, +) + + +@dataclass(frozen=True) +class CoreSeedResult: + organization: Organization + sales_department: Department + owner: HumanUser | None + created_owner: bool + + +def _option_or_env(options: dict, option_name: str, env_name: str) -> str: + return str(options.get(option_name) or os.environ.get(env_name, "")).strip() + + +@transaction.atomic +def _seed_core(*, owner_email: str, owner_password: str, owner_name: str) -> CoreSeedResult: + organization, _ = Organization.objects.update_or_create( + slug="edevs", + defaults={"name": "ООО «ЭДЕВС»", "timezone": "Europe/Moscow", "currency": "RUB"}, + ) + sales_department, _ = Department.objects.update_or_create( + organization=organization, + code="sales", + defaults={"name": "Продажи"}, + ) + for spec in PRODUCT_SPECS: + product, _ = Product.objects.update_or_create( + organization=organization, + code=spec["code"], + defaults={ + "name": spec["name"], + "site_url": spec["site_url"], + "summary": spec["summary"], + }, + ) + ProductDepartment.objects.get_or_create(product=product, department=sales_department) + + owner = None + created_owner = False + existing_owner = ( + organization.employees.filter(role=EmployeeRole.OWNER).select_related("user").first() + ) + if owner_email: + normalized_email = HumanUser.objects.normalize_email(owner_email) + owner, created_owner = HumanUser.objects.get_or_create( + email=normalized_email, + defaults={"full_name": owner_name, "is_staff": True, "is_superuser": True}, + ) + if created_owner: + validate_password(owner_password, user=owner) + owner.set_password(owner_password) + owner.save(update_fields=["password"]) + changed_fields = [] + if owner_name and owner.full_name != owner_name: + owner.full_name = owner_name + changed_fields.append("full_name") + if not owner.is_staff: + owner.is_staff = True + changed_fields.append("is_staff") + if not owner.is_superuser: + owner.is_superuser = True + changed_fields.append("is_superuser") + if changed_fields: + owner.save(update_fields=changed_fields) + EmployeeProfile.objects.update_or_create( + user=owner, + defaults={ + "organization": organization, + "role": EmployeeRole.OWNER, + "department": sales_department, + "totp_required": False, + }, + ) + elif existing_owner: + owner = existing_owner.user + else: + raise CommandError( + "OWNER is required for the first production seed. Set HUB_SEED_OWNER_EMAIL and " + "HUB_SEED_OWNER_PASSWORD, or pass --owner-email and --owner-password." + ) + + record_audit_event( + organization=organization, + actor=owner, + action="identity.production_seed_applied", + object_type="Organization", + object_id=str(organization.id), + payload={"created_owner": created_owner, "applied_at": timezone.now().isoformat()}, + ) + return CoreSeedResult(organization, sales_department, owner, created_owner) + + +def _seed_channels(*, organization: Organization, department: Department) -> int: + created = 0 + for spec in CHANNEL_SPECS: + product = ( + Product.objects.get(organization=organization, code=spec["product_code"]) + if spec["product_code"] + else None + ) + _, was_created = Channel.objects.update_or_create( + organization=organization, + code=spec["code"], + defaults={ + "name": spec["name"], + "department": department, + "product": product, + "model": DEFAULT_CHANNEL_MODEL, + "system_prompt": spec["system_prompt"], + "is_active": True, + }, + ) + created += int(was_created) + return created + + +class Command(BaseCommand): + help = "Seed production Hub reference data, AI content and channel releases. Idempotent." + + def add_arguments(self, parser) -> None: + parser.add_argument("--owner-email", default="") + parser.add_argument("--owner-password", default="") + parser.add_argument("--owner-name", default="") + + def handle(self, *args: object, **options: object) -> None: + owner_email = _option_or_env(options, "owner_email", "HUB_SEED_OWNER_EMAIL") + owner_password = _option_or_env(options, "owner_password", "HUB_SEED_OWNER_PASSWORD") + owner_name = _option_or_env(options, "owner_name", "HUB_SEED_OWNER_NAME") + if ( + owner_email + and not owner_password + and not Organization.objects.filter(employees__role=EmployeeRole.OWNER).exists() + ): + raise CommandError("HUB_SEED_OWNER_PASSWORD is required when creating the first OWNER.") + + core = _seed_core( + owner_email=owner_email, owner_password=owner_password, owner_name=owner_name + ) + call_command("seed_catalog", verbosity=0) + channels_created = _seed_channels( + organization=core.organization, department=core.sales_department + ) + content_result = import_ai_content( + base_dir=Path(__file__).resolve().parents[6], + organization=core.organization, + author=core.owner, + ) + content_result = content_result.add( + ensure_channel_system_prompts(organization=core.organization, author=core.owner) + ) + agents_created, releases_created = ensure_agents_and_releases( + organization=core.organization, author=core.owner + ) + + owner_state = "created" if core.created_owner else "ready" + prompt_stats = ( + f"{content_result.prompts_created}/{content_result.prompt_versions_created} versions" + ) + knowledge_stats = ( + f"{content_result.knowledge_created}/" + f"{content_result.knowledge_versions_created} versions" + ) + self.stdout.write( + self.style.SUCCESS( + "initial data seeded: " + f"owner={owner_state}, " + f"channels +{channels_created}, " + f"agents +{agents_created}, " + f"releases +{releases_created}, " + f"prompts +{prompt_stats}, " + f"knowledge +{knowledge_stats}, " + f"fragments +{content_result.fragments_created}, " + f"skipped {content_result.skipped_sections}" + ) + ) diff --git a/apps/internal-ui/src/api/client.ts b/apps/internal-ui/src/api/client.ts index 6fc4e4c..4288bff 100644 --- a/apps/internal-ui/src/api/client.ts +++ b/apps/internal-ui/src/api/client.ts @@ -1,4 +1,12 @@ -const API_BASE = "http://localhost:8010"; +const configuredApiBase = (import.meta.env.VITE_API_BASE_URL ?? "").replace(/\/+$/, ""); + +function resolveApiUrl(path: string): string { + if (!configuredApiBase) return path; + if (configuredApiBase.endsWith("/api/v1") && path.startsWith("/api/v1/")) { + return `${configuredApiBase}${path.slice("/api/v1".length)}`; + } + return `${configuredApiBase}${path}`; +} function getCookie(name: string): string { const cookie = document.cookie @@ -15,7 +23,7 @@ export async function api(path: string, init: RequestInit = {}): Promise { headers.set("Content-Type", "application/json"); headers.set("X-CSRFToken", getCookie("csrftoken")); } - const response = await fetch(`${API_BASE}${path}`, { + const response = await fetch(resolveApiUrl(path), { ...init, credentials: "include", headers, diff --git a/apps/internal-ui/src/vite-env.d.ts b/apps/internal-ui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/internal-ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/compose.production.yaml b/compose.production.yaml new file mode 100644 index 0000000..a05dc96 --- /dev/null +++ b/compose.production.yaml @@ -0,0 +1,73 @@ +services: + postgres: + image: pgvector/pgvector:pg16 + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB is required} + POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER is required} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + volumes: + - ./data/postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + volumes: + - ./data/redis:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + + backend: + image: ${HUB_BACKEND_IMAGE:?HUB_BACKEND_IMAGE is required} + restart: unless-stopped + env_file: + - .env.production + command: > + sh -c "gunicorn hub_backend.wsgi:application + --bind 0.0.0.0:8000 + --workers $${HUB_GUNICORN_WORKERS:-3} + --timeout $${HUB_GUNICORN_TIMEOUT:-60}" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health/live/', timeout=3)", + ] + interval: 10s + timeout: 5s + retries: 10 + + worker: + image: ${HUB_BACKEND_IMAGE:?HUB_BACKEND_IMAGE is required} + restart: unless-stopped + env_file: + - .env.production + command: python manage.py run_worker + depends_on: + backend: + condition: service_healthy + + frontend: + image: ${HUB_FRONTEND_IMAGE:?HUB_FRONTEND_IMAGE is required} + restart: unless-stopped + ports: + - "127.0.0.1:${HUB_FRONTEND_HOST_PORT:-8080}:80" + depends_on: + backend: + condition: service_healthy diff --git a/deploy/docker/frontend.Dockerfile b/deploy/docker/frontend.Dockerfile new file mode 100644 index 0000000..b4ecbd1 --- /dev/null +++ b/deploy/docker/frontend.Dockerfile @@ -0,0 +1,29 @@ +FROM node:22-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json tsconfig.base.json ./ +COPY apps/internal-ui/package.json ./apps/internal-ui/package.json +COPY apps/web-chat/package.json ./apps/web-chat/package.json +COPY packages/ui/package.json ./packages/ui/package.json +COPY packages/contracts/package.json ./packages/contracts/package.json +COPY packages/shared/package.json ./packages/shared/package.json +RUN npm ci + +COPY apps/internal-ui ./apps/internal-ui +COPY apps/web-chat ./apps/web-chat +COPY packages ./packages + +ARG VITE_API_BASE_URL= +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} + +RUN npm --workspace @edevs/internal-ui run build +RUN npm --workspace @edevs/web-chat run build + +FROM nginx:1.27-alpine + +COPY deploy/nginx/frontend.production.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/apps/internal-ui/dist /usr/share/nginx/html +COPY --from=builder /app/apps/web-chat/dist /usr/share/nginx/html/chat + +EXPOSE 80 diff --git a/deploy/nginx/frontend.production.conf b/deploy/nginx/frontend.production.conf new file mode 100644 index 0000000..d8bf4df --- /dev/null +++ b/deploy/nginx/frontend.production.conf @@ -0,0 +1,57 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + client_max_body_size 20m; + + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options SAMEORIGIN always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + + location = /chat-widget.js { + proxy_pass http://backend:8000/chat-widget.js; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + } + + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + } + + location /admin/ { + proxy_pass http://backend:8000/admin/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + } + + location /static/ { + proxy_pass http://backend:8000/static/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; + } + + location /chat/ { + try_files $uri $uri/ /chat/index.html; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/deploy/nginx/hub.edevs.tech.conf b/deploy/nginx/hub.edevs.tech.conf new file mode 100644 index 0000000..30eca9b --- /dev/null +++ b/deploy/nginx/hub.edevs.tech.conf @@ -0,0 +1,34 @@ +server { + listen 80; + server_name hub.edevs.tech; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + server_name hub.edevs.tech; + + # Set these paths after issuing the certificate with certbot. + ssl_certificate /etc/letsencrypt/live/hub.edevs.tech/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/hub.edevs.tech/privkey.pem; + + client_max_body_size 20m; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + } +} diff --git a/deploy/nginx/local.conf b/deploy/nginx/local.conf index ff1f861..840be2c 100644 --- a/deploy/nginx/local.conf +++ b/deploy/nginx/local.conf @@ -30,18 +30,3 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } } - -server { - listen 80; - server_name chat.localhost; - - location /api/ { - proxy_pass http://backend:8000; - proxy_set_header Host $host; - } - - location / { - proxy_pass http://web-chat:5175; - proxy_set_header Host $host; - } -}