🚚 chore!: переименование инфраструктуры CustoCRM/hub → Chatballs с миграцией

- каталог code/custocrm → code/chatballs; CLI custocrm → chatballs; пакеты
  hub_platform → chatballs, hub_backend → chatballs_backend (app labels прежние)
- переменные CUS_* и CUSTOCRM_* → CHATBALLS_*; образы chatballs-*; compose-проект
  и база chatballs (были edevs_hub); роли Postgres chatballs_* (были custocrm_*);
  схема RLS chatballs и GUC chatballs.organization_id; cookie chatballs_*
- CI: APP_DIR code/chatballs, DEPLOY_ROOT /opt/chatballs
- deploy/migrate/rename-to-chatballs.sh — миграция существующей установки без
  потери данных: остановка старого проекта, .env (с резервной копией),
  переименование суперпользователя initdb через временную роль, остальных ролей,
  базы, схемы и функции RLS; проверено на локальном стеке
- dev-стек хранит Postgres в bind-mount data/postgres, как prod (именованный том
  compose.dev был устаревшим снимком и вводил в заблуждение)
- снятие демо удаляет объекты, созданные поверх демо-данных тестировавшим
  (звонки по демо-диалогу), вместо падения на PROTECT
- реальные домены *.custocrm.ru и идентификаторы документов не тронуты

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Fable 5.1 committed 2026-09-05 22:21:12 +03:00
1 parent dbd7ae3150
commit 1a61780dec
628 files changed
+5839 -5622

No files matched your search

+56 -56
View File
@@ -1,58 +1,58 @@
COMPOSE_PROJECT_NAME=edevs_hub
COMPOSE_PROJECT_NAME=chatballs
CUSTOCRM_APP_DOMAIN=app.localhost
CUSTOCRM_PLATFORM_DOMAIN=platform.localhost
CUS_HELP_BASE_DOMAIN=localhost
CUS_HELP_PUBLIC_SCHEME=http
CUS_HELP_PUBLIC_PORT=8080
CUSTOCRM_ACME_EMAIL=local@example.invalid
CUSTOCRM_ADMIN_PORT=18001
CHATBALLS_APP_DOMAIN=app.localhost
CHATBALLS_PLATFORM_DOMAIN=platform.localhost
CHATBALLS_HELP_BASE_DOMAIN=localhost
CHATBALLS_HELP_PUBLIC_SCHEME=http
CHATBALLS_HELP_PUBLIC_PORT=8080
CHATBALLS_ACME_EMAIL=local@example.invalid
CHATBALLS_ADMIN_PORT=18001
CUS_ENV=local
CUS_DEBUG=true
CUS_DELIVERY_MODE=CLOUD
CUS_SECRET_KEY=change-me-only-for-local-development
CUSTOCRM_APP_ALLOWED_HOSTS=localhost,127.0.0.1,app.localhost
CUSTOCRM_APP_CSRF_TRUSTED_ORIGINS=http://localhost,http://app.localhost,http://localhost:8010,http://localhost:5173,http://localhost:5175
CUSTOCRM_PLATFORM_ALLOWED_HOSTS=localhost,127.0.0.1,platform.localhost
CUSTOCRM_PLATFORM_CSRF_TRUSTED_ORIGINS=http://platform.localhost,http://localhost:8011
CUS_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5175
CHATBALLS_ENV=local
CHATBALLS_DEBUG=true
CHATBALLS_DELIVERY_MODE=CLOUD
CHATBALLS_SECRET_KEY=change-me-only-for-local-development
CHATBALLS_APP_ALLOWED_HOSTS=localhost,127.0.0.1,app.localhost
CHATBALLS_APP_CSRF_TRUSTED_ORIGINS=http://localhost,http://app.localhost,http://localhost:8010,http://localhost:5173,http://localhost:5175
CHATBALLS_PLATFORM_ALLOWED_HOSTS=localhost,127.0.0.1,platform.localhost
CHATBALLS_PLATFORM_CSRF_TRUSTED_ORIGINS=http://platform.localhost,http://localhost:8011
CHATBALLS_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:5175
# Шифрование секретов в БД (Fernet-ключ). В local не обязателен — выводится из
# CUS_SECRET_KEY; в production задайте отдельный ключ: Fernet.generate_key().
CUS_FIELD_ENCRYPTION_KEY=
# CHATBALLS_SECRET_KEY; в production задайте отдельный ключ: Fernet.generate_key().
CHATBALLS_FIELD_ENCRYPTION_KEY=
# AI-провайдер. Локально по умолчанию тестовый адаптер; в production задайте ключ.
# CUS_AI_PROVIDER=openrouter
CUS_OPENROUTER_API_KEY=
# CUS_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# CUS_AI_REQUEST_TIMEOUT=30
# CUS_AI_MAX_RETRIES=2
# CUS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=0
# CHATBALLS_AI_PROVIDER=openrouter
CHATBALLS_OPENROUTER_API_KEY=
# CHATBALLS_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
# CHATBALLS_AI_REQUEST_TIMEOUT=30
# CHATBALLS_AI_MAX_RETRIES=2
# CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS=0
# Транспортная безопасность. Вне CUS_DEBUG включается автоматически; здесь — для переопределения.
# CUS_COOKIE_SECURE=true
# CUS_SSL_REDIRECT=true
# CUS_HSTS_SECONDS=31536000
# CUS_COOKIE_SAMESITE=Lax
# Транспортная безопасность. Вне CHATBALLS_DEBUG включается автоматически; здесь — для переопределения.
# CHATBALLS_COOKIE_SECURE=true
# CHATBALLS_SSL_REDIRECT=true
# CHATBALLS_HSTS_SECONDS=31536000
# CHATBALLS_COOKIE_SAMESITE=Lax
# Email (по умолчанию console backend; для реальной отправки задайте SMTP).
# EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
# EMAIL_HOST=
# EMAIL_HOST_USER=
# EMAIL_HOST_PASSWORD=
# DEFAULT_FROM_EMAIL=CustoCRM <no-reply@edevs.tech>
# DEFAULT_FROM_EMAIL=Chatballs <no-reply@edevs.tech>
INTERNAL_UI_BASE_URL=http://localhost:5173
POSTGRES_DB=edevs_hub
POSTGRES_USER=custocrm_bootstrap
POSTGRES_PASSWORD=custocrm_bootstrap
POSTGRES_APP_USER=custocrm_app
POSTGRES_APP_PASSWORD=custocrm_app
POSTGRES_PLATFORM_USER=custocrm_platform
POSTGRES_PLATFORM_PASSWORD=custocrm_platform
POSTGRES_MIGRATION_USER=custocrm_migration
POSTGRES_MIGRATION_PASSWORD=custocrm_migration
POSTGRES_DB=chatballs
POSTGRES_USER=chatballs_bootstrap
POSTGRES_PASSWORD=chatballs_bootstrap
POSTGRES_APP_USER=chatballs_app
POSTGRES_APP_PASSWORD=chatballs_app
POSTGRES_PLATFORM_USER=chatballs_platform
POSTGRES_PLATFORM_PASSWORD=chatballs_platform
POSTGRES_MIGRATION_USER=chatballs_migration
POSTGRES_MIGRATION_PASSWORD=chatballs_migration
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
@@ -60,26 +60,26 @@ REDIS_URL=redis://redis:6379/0
# Tenant-owned object storage. Production требует S3-compatible backend;
# local/test могут явно использовать filesystem.
CUS_STORAGE_BACKEND=filesystem
# CUS_S3_BUCKET=custocrm
# CUS_S3_ENDPOINT_URL=https://s3.example.invalid
# CUS_S3_REGION=ru-central1
# CUS_S3_ACCESS_KEY=
# CUS_S3_SECRET_KEY=
# CUS_S3_ADDRESSING_STYLE=path
# CUS_S3_URL_EXPIRY_SECONDS=900
CHATBALLS_STORAGE_BACKEND=filesystem
# CHATBALLS_S3_BUCKET=chatballs
# CHATBALLS_S3_ENDPOINT_URL=https://s3.example.invalid
# CHATBALLS_S3_REGION=ru-central1
# CHATBALLS_S3_ACCESS_KEY=
# CHATBALLS_S3_SECRET_KEY=
# CHATBALLS_S3_ADDRESSING_STYLE=path
# CHATBALLS_S3_URL_EXPIRY_SECONDS=900
# P2P calls: invite — 5 минут, access token — 1 час.
CUS_CALL_INVITE_TTL_SECONDS=300
CUS_CALL_ACCESS_TTL_SECONDS=3600
CUS_CALL_CONNECT_GRACE_SECONDS=120
CUS_CALL_RECONNECT_GRACE_SECONDS=60
CHATBALLS_CALL_INVITE_TTL_SECONDS=300
CHATBALLS_CALL_ACCESS_TTL_SECONDS=3600
CHATBALLS_CALL_CONNECT_GRACE_SECONDS=120
CHATBALLS_CALL_RECONNECT_GRACE_SECONDS=60
# STUN для WebRTC (через запятую). Локально обычно пусто: direct ICE на localhost.
CUS_CALL_STUN_URLS=
CHATBALLS_CALL_STUN_URLS=
# TURN (Coturn) — production-контур; локально пусто (direct/STUN достаточно).
CUS_CALL_TURN_URLS=
CUS_CALL_TURN_SECRET=
CUS_CALL_TURN_TTL_SECONDS=3600
CHATBALLS_CALL_TURN_URLS=
CHATBALLS_CALL_TURN_SECRET=
CHATBALLS_CALL_TURN_TTL_SECONDS=3600
INTERNAL_UI_PORT=5173
WEB_CHAT_PORT=5175
+4 -4
View File
@@ -1,11 +1,11 @@
# Caddyfile — единый HTTP/HTTPS public boundary CustoCRM (ADR-HUB-0028 §gateway).
# Caddyfile — единый HTTP/HTTPS public boundary Chatballs (ADR-HUB-0028 §gateway).
# Подставляется в release bundle и монтируется в контейнер gateway.
# Caddy: TLS termination + ACME, HTTP->HTTPS redirect, WebSocket upgrade (native).
# App-маршрутизация делегирована frontend-контейнеру, platform API идёт в
# отдельный backend. Admin принципиально отсутствует в public gateway.
{
email {$CUSTOCRM_ACME_EMAIL:}
email {$CHATBALLS_ACME_EMAIL:}
# Caddy admin API не публикуется наружу (default localhost:2019).
on_demand_tls {
ask http://backend-platform:8000/api/v1/gateway/help-domain/
@@ -29,7 +29,7 @@ https:// {
}
# Tenant-facing application surface.
{$CUSTOCRM_APP_DOMAIN} {
{$CHATBALLS_APP_DOMAIN} {
encode gzip zstd
reverse_proxy frontend:80 {
@@ -42,7 +42,7 @@ https:// {
# Edevs platform surface. На C01 опубликован только health endpoint; API
# управления tenant'ами появится на C06.
{$CUSTOCRM_PLATFORM_DOMAIN} {
{$CHATBALLS_PLATFORM_DOMAIN} {
encode gzip zstd
reverse_proxy backend-platform:8000 {
+28 -4
View File
@@ -11,7 +11,7 @@ Windows (PowerShell):
```powershell
git clone <URL репозитория> chatballs
cd chatballs/code/custocrm
cd chatballs/code/chatballs
.\scripts\start.ps1
```
@@ -19,7 +19,7 @@ Linux / macOS:
```bash
git clone <URL репозитория> chatballs
cd chatballs/code/custocrm
cd chatballs/code/chatballs
./scripts/start.sh
```
@@ -55,11 +55,35 @@ docker compose run --rm backend-app python manage.py seed_demo --organization <s
docker compose run --rm backend-app python manage.py seed_demo --organization <slug> --remove
```
Редактируемые данные — `apps/backend/hub_platform/identity/demo_seed/data/`
Редактируемые данные — `apps/backend/chatballs/identity/demo_seed/data/`
(JSON-манифест на домен, `media/` — вложения, аватары, голосовые). Голосовые
сообщения читаются из `media/voice/` (см. README там); если файла нет, сообщение
пропускается.
### Обновление установки со старым именем (CustoCRM / hub → Chatballs)
Установки, развёрнутые до переименования (compose-проект `edevs_hub`, база
`edevs_hub`, роли Postgres `custocrm_*`, переменные `CUS_*`/`CUSTOCRM_*`),
переводятся на новые имена одним скриптом — данные остаются на месте:
```bash
# prod: сначала переместите каталог релизов и инстанса
mv /opt/custocrm /opt/chatballs
CHATBALLS_INSTANCE_DIR=/opt/chatballs/instance ./deploy/migrate/rename-to-chatballs.sh
./chatballs deploy
```
```bash
# dev-стек из каталога репозитория
CHATBALLS_INSTANCE_DIR="$PWD" CHATBALLS_COMPOSE_ARGS="-f compose.dev.yaml --env-file .env.example" deploy/migrate/rename-to-chatballs.sh
docker compose -f compose.yaml -f compose.dev.yaml --env-file .env.example --env-file .env up -d
```
Скрипт останавливает старый compose-проект, переписывает `.env` (резервная
копия рядом, `.env.bak-custocrm`), переименовывает роли, схему RLS (и её GUC),
базу и печатает итог. Cookie сессий меняют имя — пользователи входят заново.
В CI/CD корень деплоя теперь `/opt/chatballs`.
### Режим поставки
По умолчанию локально запускается облачный режим. Коробочный режим — тем же
@@ -71,7 +95,7 @@ docker compose run --rm backend-app python manage.py seed_demo --organization <s
```
Приложение не определяет режим по домену, числу организаций или данным.
Единственный источник — `CUS_DELIVERY_MODE` со значением `CLOUD` или
Единственный источник — `CHATBALLS_DELIVERY_MODE` со значением `CLOUD` или
`SELF_HOSTED`. В production переменная обязательна; шаблон коробочного
экземпляра `env.example` уже содержит `SELF_HOSTED`.
+6 -6
View File
@@ -20,14 +20,14 @@ COPY content /app/content
# Build-time dummy values satisfy C04 runtime guards (distinct DB users in
# settings_database, S3 bucket in settings_storage) that only matter at runtime;
# collectstatic touches neither. Runtime values come from instance .env.
RUN cd apps/backend && CUS_SECRET_KEY=collectstatic-build CUS_DEBUG=false CUS_ENV=production \
CUS_DELIVERY_MODE=CLOUD \
CUSTOCRM_APP_ALLOWED_HOSTS=collectstatic.invalid \
CUS_HELP_PUBLIC_IPV4=127.0.0.1 \
RUN cd apps/backend && CHATBALLS_SECRET_KEY=collectstatic-build CHATBALLS_DEBUG=false CHATBALLS_ENV=production \
CHATBALLS_DELIVERY_MODE=CLOUD \
CHATBALLS_APP_ALLOWED_HOSTS=collectstatic.invalid \
CHATBALLS_HELP_PUBLIC_IPV4=127.0.0.1 \
POSTGRES_APP_USER=build-app \
POSTGRES_PLATFORM_USER=build-platform \
POSTGRES_MIGRATION_USER=build-migration \
CUS_S3_BUCKET=build-placeholder \
CHATBALLS_S3_BUCKET=build-placeholder \
python manage.py collectstatic --noinput
RUN chown -R hub:hub /app
@@ -37,4 +37,4 @@ WORKDIR /app/apps/backend
USER hub
EXPOSE 8000
CMD ["gunicorn", "hub_backend.wsgi_app:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "60"]
CMD ["gunicorn", "chatballs_backend.wsgi_app:application", "--bind", "0.0.0.0:8000", "--workers", "3", "--timeout", "60"]
File renamed without changes.
@@ -1,6 +1,6 @@
from django.contrib import admin
from hub_platform.ai.models import (
from chatballs.ai.models import (
AIAgent,
Knowledge,
KnowledgeAttachment,
@@ -12,15 +12,15 @@ from dataclasses import dataclass
from django.core.exceptions import ValidationError
from django.db import transaction
from hub_platform.ai.agent_knowledge import (
from chatballs.ai.agent_knowledge import (
knowledge_available_to_channel,
portal_articles_available_to_channel,
)
from hub_platform.ai.knowledge_policy import readable_knowledge
from hub_platform.ai.models import AIAgent, Knowledge
from hub_platform.channels.models import Channel
from hub_platform.support_portals.models import PortalArticle
from hub_platform.tenancy.context import TenantContext
from chatballs.ai.knowledge_policy import readable_knowledge
from chatballs.ai.models import AIAgent, Knowledge
from chatballs.channels.models import Channel
from chatballs.support_portals.models import PortalArticle
from chatballs.tenancy.context import TenantContext
@dataclass(frozen=True, slots=True)
@@ -12,18 +12,18 @@ from django.db import transaction
from django.db.models import Count, Q, QuerySet
from django.utils.text import slugify
from hub_platform.ai.models import AIAgent, AIAgentStatus
from hub_platform.ai.provider_selection import configure_agent_provider
from hub_platform.ai.serializers import agent_portal_article_payload
from hub_platform.channels.models import Channel
from hub_platform.channels.services import (
from chatballs.ai.models import AIAgent, AIAgentStatus
from chatballs.ai.provider_selection import configure_agent_provider
from chatballs.ai.serializers import agent_portal_article_payload
from chatballs.channels.models import Channel
from chatballs.channels.services import (
CODE_MAX_LENGTH,
UNSET,
ChannelUpdate,
update_channel,
)
from hub_platform.conversations.models import LifecycleState
from hub_platform.tenancy.context import TenantContext
from chatballs.conversations.models import LifecycleState
from chatballs.tenancy.context import TenantContext
def agent_cards_for_context(context: TenantContext) -> QuerySet[Channel]:
@@ -138,8 +138,8 @@ def create_agent_card(
Канал создаётся без продукта с безопасной операторской политикой (дефолты
модели удовлетворяют P1-P5); код генерируется из имени и неизменен.
"""
from hub_platform.channels import authorization
from hub_platform.channels.services import _clean_name, _group_for_channel
from chatballs.channels import authorization
from chatballs.channels.services import _clean_name, _group_for_channel
authorization.require_organization_manage(context, operation="Создание агента")
clean_name = _clean_name(name)
@@ -178,7 +178,7 @@ def update_agent_card(
"knowledgeIds",
}
if ai_fields & set(body):
from hub_platform.ai.services import AgentInput, update_agent
from chatballs.ai.services import AgentInput, update_agent
knowledge_ids = body.get("knowledgeIds")
if knowledge_ids is not None and (
@@ -234,8 +234,8 @@ def agent_deletion_blockers(channel: Channel) -> list[dict[str, object]]:
@transaction.atomic
def delete_agent_card(*, context: TenantContext, channel: Channel) -> None:
from hub_platform.channels import authorization
from hub_platform.channels.services import ChannelHasReferences
from chatballs.channels import authorization
from chatballs.channels.services import ChannelHasReferences
authorization.require_organization_manage(context, operation="Удаление агента")
blockers = agent_deletion_blockers(channel)
@@ -249,7 +249,7 @@ def set_agent_card_active(
*, context: TenantContext, channel: Channel, is_active: bool
) -> Channel:
"""Активность AI: включает/выключает автоответы, канал остаётся живым."""
from hub_platform.ai.services import set_agent_active
from chatballs.ai.services import set_agent_active
agent = ensure_channel_agent(channel)
set_agent_active(context=context, agent=agent, is_active=is_active)
@@ -2,7 +2,7 @@
from django.urls import path
from hub_platform.ai import agent_card_views as views
from chatballs.ai import agent_card_views as views
urlpatterns = [
path("", views.AgentCardListView.as_view(), name="agent-card-list"),
@@ -7,7 +7,7 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from hub_platform.ai.agent_card import (
from chatballs.ai.agent_card import (
agent_card_for_context,
agent_card_payload,
agent_cards_for_context,
@@ -16,13 +16,13 @@ from hub_platform.ai.agent_card import (
set_agent_card_active,
update_agent_card,
)
from hub_platform.ai.provider.base import ProviderError
from hub_platform.api.permissions import HasCapability
from hub_platform.channels import services as channel_services
from hub_platform.channels.models import Channel
from hub_platform.channels.runtime import run_channel_turn
from hub_platform.channels.selectors import channel_for_context
from hub_platform.identity.audit import record_audit_event
from chatballs.ai.provider.base import ProviderError
from chatballs.api.permissions import HasCapability
from chatballs.channels import services as channel_services
from chatballs.channels.models import Channel
from chatballs.channels.runtime import run_channel_turn
from chatballs.channels.selectors import channel_for_context
from chatballs.identity.audit import record_audit_event
AGENT_NOT_FOUND = {"detail": "Агент не найден"}
@@ -69,7 +69,7 @@ class AgentCardListView(APIView):
cards = cards.filter(group_id=int(group))
except ValueError:
return Response({"detail": "group must be an id or none"}, status=400)
from hub_platform.ai.agent_card import ensure_channel_agent
from chatballs.ai.agent_card import ensure_channel_agent
items = []
for channel in cards:
@@ -112,7 +112,7 @@ class AgentCardDetailView(APIView):
channel = _load(request, agent_id)
except Channel.DoesNotExist:
return Response(AGENT_NOT_FOUND, status=404)
from hub_platform.ai.agent_card import ensure_channel_agent
from chatballs.ai.agent_card import ensure_channel_agent
ensure_channel_agent(channel)
return Response({"agent": agent_card_payload(channel)})
@@ -1,9 +1,9 @@
from django.db.models import QuerySet
from hub_platform.ai.models import AIAgent, Knowledge
from hub_platform.channels.models import Channel
from hub_platform.support_portals.models import PortalArticle
from hub_platform.support_portals.statuses import ArticleStatus, PortalStatus
from chatballs.ai.models import AIAgent, Knowledge
from chatballs.channels.models import Channel
from chatballs.support_portals.models import PortalArticle
from chatballs.support_portals.statuses import ArticleStatus, PortalStatus
# Библиотека знаний — общая для организации (ADR-HUB-0041 §8): агент использует
# только явно выбранные и включённые знания, областей видимости нет.
@@ -4,5 +4,5 @@ from django.apps import AppConfig
class AiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
label = "ai"
name = "hub_platform.ai"
name = "chatballs.ai"
verbose_name = "AI agents, knowledge and releases"
@@ -3,11 +3,11 @@ from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.views import APIView
from hub_platform.ai.models import KnowledgeAttachment
from hub_platform.identity.models import Organization
from hub_platform.tenancy.context import TenantContext
from hub_platform.tenancy.database import tenant_atomic
from hub_platform.tenancy.ingress import attachment_route
from chatballs.ai.models import KnowledgeAttachment
from chatballs.identity.models import Organization
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.ingress import attachment_route
class AttachmentDownloadView(APIView):
@@ -3,20 +3,20 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from hub_platform.ai.agent_attachments import (
from chatballs.ai.agent_attachments import (
AgentLinkResult,
link_knowledge_to_agent,
link_portal_articles_to_agent,
)
from hub_platform.ai.api_errors import validation_error_response
from hub_platform.ai.knowledge_bulk import (
from chatballs.ai.api_errors import validation_error_response
from chatballs.ai.knowledge_bulk import (
add_category_knowledge_to_agent,
bulk_move_knowledge,
)
from hub_platform.ai.models import AIAgent
from hub_platform.ai.selectors import agent_for_employee
from hub_platform.api.permissions import HasCapability
from hub_platform.identity.audit import record_audit_event
from chatballs.ai.models import AIAgent
from chatballs.ai.selectors import agent_for_employee
from chatballs.api.permissions import HasCapability
from chatballs.identity.audit import record_audit_event
def _positive_id(value: object, field: str) -> int:
@@ -3,18 +3,18 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from hub_platform.ai.api_errors import validation_error_response
from hub_platform.ai.knowledge_categories import (
from chatballs.ai.api_errors import validation_error_response
from chatballs.ai.knowledge_categories import (
create_category,
delete_category,
update_category,
)
from hub_platform.ai.knowledge_policy import require_category_manage
from hub_platform.ai.models import KnowledgeCategory
from hub_platform.ai.selectors import category_tree_for_employee
from hub_platform.ai.serializers import category_payload
from hub_platform.api.permissions import HasCapability
from hub_platform.identity.audit import record_audit_event
from chatballs.ai.knowledge_policy import require_category_manage
from chatballs.ai.models import KnowledgeCategory
from chatballs.ai.selectors import category_tree_for_employee
from chatballs.ai.serializers import category_payload
from chatballs.api.permissions import HasCapability
from chatballs.identity.audit import record_audit_event
def _integer(value: object, field: str) -> int:
@@ -1,9 +1,9 @@
from django.conf import settings
from hub_platform.ai.chunking import chunk_text
from hub_platform.ai.invocation import embed_texts
from hub_platform.ai.models import Knowledge, KnowledgeAttachment, KnowledgeFragment
from hub_platform.ai.provider.base import ProviderError
from chatballs.ai.chunking import chunk_text
from chatballs.ai.invocation import embed_texts
from chatballs.ai.models import Knowledge, KnowledgeAttachment, KnowledgeFragment
from chatballs.ai.provider.base import ProviderError
def _store_fragments(*, organization, chunks: list[str], **source) -> list[KnowledgeFragment]:
@@ -16,7 +16,7 @@ def _store_fragments(*, organization, chunks: list[str], **source) -> list[Knowl
embeddings = embed_texts(
organization=organization,
texts=chunks,
model=settings.CUS_AI_EMBEDDING_MODEL,
model=settings.CHATBALLS_AI_EMBEDDING_MODEL,
purpose="knowledge_index",
)
vectors = [result.vector for result in embeddings]
@@ -59,7 +59,7 @@ def reindex_portal_article(article) -> list[KnowledgeFragment]:
текстом, который клиент видит в Help Center. У черновика и архива
фрагментов нет, поэтому в выдачу они не попадают.
"""
from hub_platform.support_portals.statuses import ArticleStatus
from chatballs.support_portals.statuses import ArticleStatus
KnowledgeFragment.objects.filter(portal_article=article).delete()
revision = article.published_revision
@@ -2,19 +2,19 @@ import time
from django.conf import settings
from hub_platform.ai import limits, pricing
from hub_platform.ai.models import LlmInvocation, LlmInvocationStatus
from hub_platform.ai.pii import redact
from hub_platform.ai.provider import routing
from hub_platform.ai.provider.base import (
from chatballs.ai import limits, pricing
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
from chatballs.ai.pii import redact
from chatballs.ai.provider import routing
from chatballs.ai.provider.base import (
ChatMessage,
ChatResult,
EmbeddingResult,
LLMProvider,
ProviderError,
)
from hub_platform.ai.provider.factory import get_provider
from hub_platform.ai.provider.resilience import CircuitBreaker, call_with_resilience
from chatballs.ai.provider.factory import get_provider
from chatballs.ai.provider.resilience import CircuitBreaker, call_with_resilience
_breaker = CircuitBreaker()
@@ -71,7 +71,7 @@ def invoke_chat(
try:
result: ChatResult = call_with_resilience(
lambda: provider.chat(messages=safe_messages, model=model, params=params),
retries=settings.CUS_AI_MAX_RETRIES,
retries=settings.CHATBALLS_AI_MAX_RETRIES,
breaker=_breaker,
)
except ProviderError as error:
@@ -119,7 +119,7 @@ def embed_texts(
provider = get_provider(channel=channel)
results: list[EmbeddingResult] = call_with_resilience(
lambda: provider.embed(texts=texts, model=model),
retries=settings.CUS_AI_MAX_RETRIES,
retries=settings.CHATBALLS_AI_MAX_RETRIES,
breaker=_breaker,
)
tokens = sum(result.tokens for result in results)
@@ -1,9 +1,9 @@
from django.core.exceptions import ValidationError
from rest_framework.request import Request
from hub_platform.ai.knowledge_services import KnowledgeInput
from hub_platform.ai.models import Knowledge
from hub_platform.ai.selectors import KnowledgeFilters
from chatballs.ai.knowledge_services import KnowledgeInput
from chatballs.ai.models import Knowledge
from chatballs.ai.selectors import KnowledgeFilters
def _positive_id(value: object, field: str) -> int:
@@ -5,16 +5,16 @@ from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from django.utils import timezone
from hub_platform.ai.agent_knowledge import knowledge_available_to_channel
from hub_platform.ai.knowledge_policy import employee_can_write_knowledge
from hub_platform.ai.models import (
from chatballs.ai.agent_knowledge import knowledge_available_to_channel
from chatballs.ai.knowledge_policy import employee_can_write_knowledge
from chatballs.ai.models import (
AIAgent,
Knowledge,
KnowledgeCategory,
)
from hub_platform.ai.services import knowledge_for_agent_ids
from hub_platform.channels.models import Channel
from hub_platform.tenancy.context import TenantContext
from chatballs.ai.services import knowledge_for_agent_ids
from chatballs.channels.models import Channel
from chatballs.tenancy.context import TenantContext
def _normalized_knowledge_ids(knowledge_ids: Iterable[int]) -> list[int]:
@@ -1,10 +1,10 @@
from django.core.exceptions import ValidationError
from django.db import transaction
from hub_platform.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME
from hub_platform.ai.models import KnowledgeCategory
from hub_platform.identity.models import Organization
from hub_platform.tenancy.context import TenantContext
from chatballs.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME
from chatballs.ai.models import KnowledgeCategory
from chatballs.identity.models import Organization
from chatballs.tenancy.context import TenantContext
def ensure_uncategorized_category(organization: Organization) -> KnowledgeCategory:
@@ -3,17 +3,17 @@ from dataclasses import dataclass, field
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import transaction
from hub_platform.ai.knowledge_policy import (
from chatballs.ai.knowledge_policy import (
employee_can_write_knowledge,
require_knowledge_create,
)
from hub_platform.ai.knowledge_services import (
from chatballs.ai.knowledge_services import (
KnowledgeInput,
create_knowledge,
update_knowledge,
)
from hub_platform.ai.models import Knowledge, KnowledgeCategory
from hub_platform.tenancy.context import TenantContext
from chatballs.ai.models import Knowledge, KnowledgeCategory
from chatballs.tenancy.context import TenantContext
@dataclass(slots=True)
@@ -1,8 +1,8 @@
from django.core.exceptions import ValidationError
from django.db import models
from hub_platform.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME
from hub_platform.tenancy.models import TenantRelationModel
from chatballs.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME
from chatballs.tenancy.models import TenantRelationModel
class KnowledgeCategory(TenantRelationModel):
@@ -3,9 +3,9 @@ from __future__ import annotations
from django.core.exceptions import PermissionDenied
from django.db.models import QuerySet
from hub_platform.identity.policy import has_capability_any_scope
from hub_platform.ai.models import AIAgent, Knowledge
from hub_platform.tenancy.context import TenantContext
from chatballs.identity.policy import has_capability_any_scope
from chatballs.ai.models import AIAgent, Knowledge
from chatballs.tenancy.context import TenantContext
AI_VIEW = "ai.view"
AI_MANAGE = "ai.manage"
@@ -1,17 +1,17 @@
from django.test import TestCase
from hub_platform.ai.knowledge_categories import (
from chatballs.ai.knowledge_categories import (
create_category,
ensure_uncategorized_category,
)
from hub_platform.ai.models import Knowledge
from hub_platform.identity.models import (
from chatballs.ai.models import Knowledge
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from hub_platform.tenancy.context import TenantContext
from chatballs.tenancy.context import TenantContext
class KnowledgePolicyTestBase(TestCase):
@@ -4,17 +4,17 @@ from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import UploadedFile
from django.db import transaction
from hub_platform.ai.extraction import extract_text
from hub_platform.ai.indexing import reindex_knowledge
from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
from hub_platform.ai.models import (
from chatballs.ai.extraction import extract_text
from chatballs.ai.indexing import reindex_knowledge
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
from chatballs.ai.models import (
Knowledge,
KnowledgeAttachment,
KnowledgeCategory,
)
from hub_platform.tenancy.context import TenantContext
from hub_platform.tenancy.storage import adjust_storage_usage
from hub_platform.tenancy.storage_quota import (
from chatballs.tenancy.context import TenantContext
from chatballs.tenancy.storage import adjust_storage_usage
from chatballs.tenancy.storage_quota import (
finalize_storage,
release_storage,
reserve_storage,
+32
View File
@@ -0,0 +1,32 @@
from django.conf import settings␍
from django.db.models import Sum␍
from django.utils import timezone␍
␍
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus␍
␍
␍
class LimitExceeded(Exception):␍
pass␍
␍
␍
def _day_start():␍
now = timezone.localtime()␍
return now.replace(hour=0, minute=0, second=0, microsecond=0)␍
␍
␍
def daily_cost_micros(channel=None) -> int:␍
queryset = LlmInvocation.objects.filter(created_at__gte=_day_start(), status=LlmInvocationStatus.SUCCESS)␍
if channel is not None:␍
queryset = queryset.filter(channel=channel)␍
return queryset.aggregate(total=Sum("cost_micros"))["total"] or 0␍
␍
␍
def assert_within_limits(channel, agent) -> None:␍
global_limit = settings.CHATBALLS_AI_GLOBAL_DAILY_COST_LIMIT_MICROS␍
if global_limit and daily_cost_micros() >= global_limit:␍
raise LimitExceeded("Global daily AI cost limit reached")␍
# Канальный лимит хранится в целых центах USD (dailyCostUsd); расход учитывается␍
# в micro-USD. 1 цент = 10 000 micro-USD.␍
channel_limit = (agent.limits or {}).get("dailyCostUsd")␍
if channel_limit and daily_cost_micros(channel) >= int(channel_limit) * 10_000:␍
raise LimitExceeded("Channel daily AI cost limit reached")␍
@@ -6,7 +6,7 @@ import uuid
import django.db.models.deletion
from django.db import migrations, models
import hub_platform.ai.models
import chatballs.ai.models
class Migration(migrations.Migration):
@@ -43,7 +43,7 @@ class Migration(migrations.Migration):
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
("file", models.FileField(max_length=512, upload_to=hub_platform.ai.models.attachment_upload_path)),
("file", models.FileField(max_length=512, upload_to=chatballs.ai.models.attachment_upload_path)),
("original_name", models.CharField(max_length=255)),
("content_type", models.CharField(blank=True, max_length=128)),
("size", models.PositiveBigIntegerField(default=0)),
+257
View File
@@ -0,0 +1,257 @@
import uuid␍
␍
from django.core.exceptions import ValidationError␍
from django.db import models␍
from pgvector.django import VectorField␍
␍
from chatballs.tenancy.models import TenantRelationModel␍
␍
# Один основной агент на канал обработки (ADR-HUB-0019, ADR-HUB-0023).␍
DEFAULT_AI_MODEL = "anthropic/claude-sonnet-4.6"␍
␍
␍
class AIAgentStatus(models.TextChoices):␍
DRAFT = "DRAFT", "Draft"␍
ACTIVE = "ACTIVE", "Active"␍
DISABLED = "DISABLED", "Disabled"␍
ARCHIVED = "ARCHIVED", "Archived"␍
␍
␍
# Managed-режим CustoAI удалён вместе с тарифным контуром (ADR-HUB-0042 §3):␍
# AI работает только через провайдера организации (AIAgent.provider_integration).␍
␍
␍
# --- Знания: общая библиотека организации с иерархией категорий␍
# (ADR-HUB-0023, ADR-HUB-0041 §8: областей видимости по отделам нет) ---␍
␍
␍
class Knowledge(models.Model):␍
organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="knowledge_items")␍
category = models.ForeignKey(␍
"ai.KnowledgeCategory",␍
on_delete=models.PROTECT,␍
related_name="knowledge_items",␍
)␍
title = models.CharField(max_length=255)␍
description = models.CharField(max_length=500, blank=True)␍
content = models.TextField(blank=True) # Markdown␍
is_enabled = models.BooleanField(default=True)␍
created_at = models.DateTimeField(auto_now_add=True)␍
updated_at = models.DateTimeField(auto_now=True)␍
␍
class Meta:␍
ordering = ["title"]␍
verbose_name_plural = "knowledge"␍
␍
def clean(self) -> None:␍
super().clean()␍
if self.category_id is not None and self.category.organization_id != self.organization_id:␍
raise ValidationError({"category": "Category belongs to another organization"})␍
␍
def save(self, *args: object, **kwargs: object) -> None:␍
self.clean()␍
super().save(*args, **kwargs)␍
␍
def __str__(self) -> str:␍
return f"knowledge:{self.organization_id}/{self.title}"␍
␍
␍
# Django imports only models.py by convention. Re-export the related knowledge␍
# models after Knowledge exists so they are registered without growing this file.␍
from chatballs.ai.knowledge_models import ( # noqa: E402, F401␍
KnowledgeCategory,␍
)␍
␍
␍
def attachment_upload_path(instance: "KnowledgeAttachment", filename: str) -> str:␍
organization = instance.knowledge.organization␍
return (␍
f"organizations/{organization.public_id}/knowledge/"␍
f"{instance.knowledge_id}/{instance.public_id}/{filename}"␍
)␍
␍
␍
class KnowledgeAttachment(TenantRelationModel):␍
tenant_relation_fields = ("knowledge",)␍
knowledge = models.ForeignKey(Knowledge, on_delete=models.CASCADE, related_name="attachments")␍
# Непредсказуемый идентификатор публичной ссылки скачивания (ADR-HUB-0023):␍
# агент может отдать ссылку клиенту в мессенджер, где нет аутентификации Hub.␍
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)␍
file = models.FileField(upload_to=attachment_upload_path, max_length=512)␍
# Оригинальное имя сохраняется и уникально в рамках знания: текст знания␍
# ссылается на вложение по имени.␍
original_name = models.CharField(max_length=255)␍
content_type = models.CharField(max_length=128, blank=True)␍
size = models.PositiveBigIntegerField(default=0)␍
# Текст, извлечённый из файла (md/txt/pdf/docx) для retrieval-индексации.␍
extracted_text = models.TextField(blank=True)␍
created_at = models.DateTimeField(auto_now_add=True)␍
␍
class Meta:␍
ordering = ["original_name"]␍
constraints = [models.UniqueConstraint(fields=["knowledge", "original_name"], name="uniq_attachment_knowledge_name")]␍
␍
def __str__(self) -> str:␍
return f"attachment:{self.knowledge_id}/{self.original_name}"␍
␍
def public_url(self) -> str:␍
# Абсолютная ссылка скачивания: уходит клиентам в мессенджеры, поэтому␍
# строится от публичного адреса Hub, а не от request.␍
from django.conf import settings␍
from django.urls import reverse␍
␍
path = reverse("ai-attachment-download", kwargs={"public_id": self.public_id})␍
return settings.CHATBALLS_PUBLIC_BASE_URL.rstrip("/") + path␍
␍
␍
class KnowledgeFragment(TenantRelationModel):␍
tenant_relation_fields = ("knowledge", "portal_article")␍
# Чанк источника + его эмбеддинг (pgvector). ADR-HUB-0016. Источник — либо␍
# знание библиотеки, либо опубликованная статья портала поддержки: обе␍
# ветки индексируются одинаково, чтобы retrieval оставался одним запросом.␍
# Перестраивается при каждом изменении содержимого источника.␍
knowledge = models.ForeignKey(␍
Knowledge,␍
on_delete=models.CASCADE,␍
related_name="fragments",␍
null=True,␍
blank=True,␍
)␍
portal_article = models.ForeignKey(␍
"support_portals.PortalArticle",␍
on_delete=models.CASCADE,␍
related_name="fragments",␍
null=True,␍
blank=True,␍
)␍
chunk_index = models.PositiveIntegerField()␍
content = models.TextField()␍
# Размерность не фиксируется: совместимость локального и production embedding-провайдера.␍
embedding = VectorField(null=True, blank=True)␍
created_at = models.DateTimeField(auto_now_add=True)␍
␍
class Meta:␍
ordering = ["knowledge_id", "portal_article_id", "chunk_index"]␍
constraints = [␍
models.UniqueConstraint(␍
fields=["knowledge", "chunk_index"], name="uniq_fragment_knowledge_chunk"␍
),␍
models.UniqueConstraint(␍
fields=["portal_article", "chunk_index"], name="uniq_fragment_article_chunk"␍
),␍
models.CheckConstraint(␍
condition=(␍
models.Q(knowledge__isnull=False, portal_article__isnull=True)␍
| models.Q(knowledge__isnull=True, portal_article__isnull=False)␍
),␍
name="fragment_single_source",␍
),␍
]␍
␍
def __str__(self) -> str:␍
source = (␍
f"knowledge:{self.knowledge_id}"␍
if self.knowledge_id␍
else f"article:{self.portal_article_id}"␍
)␍
return f"fragment:{source}/{self.chunk_index}"␍
␍
@property␍
def source_title(self) -> str:␍
"""Заголовок источника для цитирования в системном промпте."""␍
if self.knowledge_id is not None:␍
return self.knowledge.title␍
revision = self.portal_article.published_revision␍
return revision.title if revision is not None else self.portal_article.slug␍
␍
␍
# --- Агент канала: одна сущность, без релизов (ADR-HUB-0023) ---␍
␍
␍
class AIAgent(TenantRelationModel):␍
tenant_relation_fields = ("channel", "provider_integration")␍
channel = models.OneToOneField("channels.Channel", on_delete=models.CASCADE, related_name="ai_agent")␍
# BYOK-секрет организации (SPEC-HUB-0027 §9). Раньше жил на Channel, из-за␍
# чего credential_mode и model были на агенте, а секрет — на канале: одно␍
# решение в двух таблицах, и форма агента скрыто писала в канал.␍
provider_integration = models.ForeignKey(␍
"integrations.Integration",␍
on_delete=models.PROTECT,␍
related_name="agents",␍
null=True,␍
blank=True,␍
)␍
name = models.CharField(max_length=255)␍
status = models.CharField(␍
max_length=16,␍
choices=AIAgentStatus.choices,␍
default=AIAgentStatus.DRAFT,␍
)␍
lifecycle_version = models.PositiveIntegerField(default=0)␍
model = models.CharField(max_length=128, default=DEFAULT_AI_MODEL)␍
model_params = models.JSONField(default=dict, blank=True)␍
# Инструкции из трёх частей; системный промпт собирается в этом порядке.␍
persona = models.TextField(blank=True) # кто он и что он␍
tone = models.TextField(blank=True) # как он должен говорить␍
instructions = models.TextField(blank=True) # правила работы␍
# Выбор знаний из библиотеки организации.␍
knowledge_items = models.ManyToManyField(Knowledge, blank=True, related_name="agents")␍
# Статьи портала поддержки остаются в support_portals: агент ссылается на␍
# них, а не на копию, поэтому правка статьи сразу меняет ответы агента.␍
portal_articles = models.ManyToManyField(␍
"support_portals.PortalArticle",␍
blank=True,␍
related_name="agents",␍
)␍
allowed_tools = models.JSONField(default=list, blank=True)␍
# Единственный поддерживаемый лимит — дневной бюджет dailyCostUsd (центы USD).␍
limits = models.JSONField(default=dict, blank=True)␍
created_at = models.DateTimeField(auto_now_add=True)␍
updated_at = models.DateTimeField(auto_now=True)␍
␍
def __str__(self) -> str:␍
return f"{self.channel.code}:agent"␍
␍
@property␍
def is_active(self) -> bool:␍
return self.status == AIAgentStatus.ACTIVE␍
␍
@is_active.setter␍
def is_active(self, value: bool) -> None:␍
self.status = AIAgentStatus.ACTIVE if value else AIAgentStatus.DISABLED␍
␍
␍
# --- LLM usage accounting (tokens, cost) ---␍
␍
␍
class LlmInvocationStatus(models.TextChoices):␍
SUCCESS = "SUCCESS", "Успех"␍
ERROR = "ERROR", "Ошибка"␍
BLOCKED = "BLOCKED", "Заблокировано лимитом"␍
␍
␍
class LlmInvocation(TenantRelationModel):␍
tenant_relation_fields = ("channel", "product")␍
# Учёт по каналу (ADR-HUB-0019) и/или продукту, если канал продуктовый.␍
channel = models.ForeignKey("channels.Channel", on_delete=models.SET_NULL, null=True, blank=True, related_name="ai_invocations")␍
product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="ai_invocations", null=True, blank=True)␍
purpose = models.CharField(max_length=64)␍
operation = models.CharField(max_length=16) # chat | embedding␍
model = models.CharField(max_length=128, blank=True)␍
prompt_tokens = models.PositiveIntegerField(default=0)␍
completion_tokens = models.PositiveIntegerField(default=0)␍
total_tokens = models.PositiveIntegerField(default=0)␍
cost_micros = models.PositiveBigIntegerField(default=0)␍
currency = models.CharField(max_length=3, default="USD")␍
latency_ms = models.PositiveIntegerField(default=0)␍
status = models.CharField(max_length=16, choices=LlmInvocationStatus.choices, default=LlmInvocationStatus.SUCCESS)␍
error = models.TextField(blank=True)␍
used_fragment_ids = models.JSONField(default=list, blank=True)␍
created_at = models.DateTimeField(auto_now_add=True, db_index=True)␍
␍
class Meta:␍
ordering = ["-created_at"]␍
indexes = [models.Index(fields=["channel", "created_at"])]␍
␍
def __str__(self) -> str:␍
return f"llm:{self.channel_id}/{self.operation}/{self.status}"␍
File renamed without changes.
@@ -1,17 +1,17 @@
from django.conf import settings
# micro-USD за токен (1 USD = 1_000_000 micro); значение = цена в USD за 1M токенов.
# Fallback на случай, если провайдер не вернул фактическую стоимость (usage.cost).
# Реальные/уточнённые цены задаются через CUS_AI_PRICING.
DEFAULT_PRICING = {
"openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},
"anthropic/claude-sonnet-4.6": {"prompt": 3.0, "completion": 15.0},
}
def cost_micros(model: str, prompt_tokens: int, completion_tokens: int) -> int:
table = {**DEFAULT_PRICING, **getattr(settings, "CUS_AI_PRICING", {})}
price = table.get(model)
if not price:
return 0
return round(prompt_tokens * price["prompt"] + completion_tokens * price["completion"])
from django.conf import settings
# micro-USD за токен (1 USD = 1_000_000 micro); значение = цена в USD за 1M токенов.
# Fallback на случай, если провайдер не вернул фактическую стоимость (usage.cost).
# Реальные/уточнённые цены задаются через CHATBALLS_AI_PRICING.
DEFAULT_PRICING = {
"openai/gpt-4o-mini": {"prompt": 0.15, "completion": 0.60},
"anthropic/claude-sonnet-4.6": {"prompt": 3.0, "completion": 15.0},
}
def cost_micros(model: str, prompt_tokens: int, completion_tokens: int) -> int:
table = {**DEFAULT_PRICING, **getattr(settings, "CHATBALLS_AI_PRICING", {})}
price = table.get(model)
if not price:
return 0
return round(prompt_tokens * price["prompt"] + completion_tokens * price["completion"])
@@ -1,16 +1,16 @@
from hub_platform.ai.provider.openrouter import OpenRouterProvider
class CustomProvider(OpenRouterProvider):
"""Generic OpenAI-compatible BYOK adapter (ADR-HUB-0034).
Reuses the OpenRouter HTTP layer verbatim: the contract is identical
(POST /chat/completions, POST /embeddings, Authorization: Bearer <key>,
response with choices[0].message.content and usage). The only difference
from OpenRouter is the absence of a model catalog — the model identifier
is supplied by the integration owner as free text and read at runtime
(ADR-HUB-0034 §4). The distinct name lets routing and accounting tell the
two BYOK modes apart.
"""
name = "custom"
from chatballs.ai.provider.openrouter import OpenRouterProvider
class CustomProvider(OpenRouterProvider):
"""Generic OpenAI-compatible BYOK adapter (ADR-HUB-0034).
Reuses the OpenRouter HTTP layer verbatim: the contract is identical
(POST /chat/completions, POST /embeddings, Authorization: Bearer <key>,
response with choices[0].message.content and usage). The only difference
from OpenRouter is the absence of a model catalog — the model identifier
is supplied by the integration owner as free text and read at runtime
(ADR-HUB-0034 §4). The distinct name lets routing and accounting tell the
two BYOK modes apart.
"""
name = "custom"
@@ -15,7 +15,7 @@ import hashlib
import math
import re
from hub_platform.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, LLMProvider
from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, LLMProvider
EMBEDDING_DIM = 16
KNOWLEDGE_MARKER = "Отвечай только на основе этих знаний:"
@@ -1,9 +1,9 @@
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from hub_platform.ai.provider import routing
from hub_platform.ai.provider.base import LLMProvider, ProviderError
from hub_platform.ai.provider.local import LocalProvider
from chatballs.ai.provider import routing
from chatballs.ai.provider.base import LLMProvider, ProviderError
from chatballs.ai.provider.local import LocalProvider
def _test_provider() -> LLMProvider:
@@ -21,7 +21,7 @@ def get_provider(*, channel=None) -> LLMProvider:
to resolve and fails cleanly (callers treat ProviderError as "no embeddings",
lexical search keeps working).
"""
if settings.CUS_AI_PROVIDER == "test":
if settings.CHATBALLS_AI_PROVIDER == "test":
return _test_provider()
if channel is None:
@@ -1,7 +1,7 @@
import hashlib
import math
from hub_platform.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, LLMProvider
from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, LLMProvider
# Размерность согласуется со слайсом 3 (pgvector); для тестового провайдера фиксирована.
EMBEDDING_DIM = 16
@@ -1,108 +1,108 @@
"""Shared HTTP layer for OpenAI-compatible LLM providers (ADR-HUB-0033 §7,
ADR-HUB-0034 §3).
The OpenRouter, generic Custom and CustoAI (Yandex AI Studio) providers all
speak the same Chat Completions shape:
- POST /chat/completions with {model, messages, ...}; response has
choices[0].message.content and usage (optionally usage.cost in USD).
- POST /embeddings with {model, input}; response has data[].embedding and usage.
- Authorization: Bearer <key>.
This module owns the HTTP transport and response parsing so the three adapters
do not duplicate it. Adapters stay responsible for their own product semantics
(name, cost handling, catalog). Stdlib only — no third-party HTTP client.
"""
from __future__ import annotations
import http.client
import json
import urllib.error
import urllib.request
from hub_platform.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, ProviderError
from hub_platform.integrations.proxy import build_opener
def post_json(*, base_url: str, path: str, api_key: str, payload: dict, timeout: float, proxy_url: str = "") -> dict:
"""POST a JSON body to {base_url}{path} with Bearer auth; return parsed JSON.
Translates transport errors into ProviderError so callers can apply the
circuit breaker uniformly.
"""
request = urllib.request.Request(
f"{base_url.rstrip('/')}{path}",
data=json.dumps(payload).encode("utf-8"),
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
method="POST",
)
try:
with build_opener(proxy_url).open(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
# http.client.HTTPException covers IncompleteRead/BadStatusLine (dropped reply)
# — those are not OSError, so they would slip past ProviderError otherwise.
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
raise ProviderError(f"{type(error).__name__}: {error}") from error
def get_json(*, base_url: str, path: str, api_key: str, timeout: float, proxy_url: str = "") -> dict:
"""GET {base_url}{path} with Bearer auth; return parsed JSON (used for /models)."""
request = urllib.request.Request(
f"{base_url.rstrip('/')}{path}",
headers={"Authorization": f"Bearer {api_key}"},
method="GET",
)
try:
with build_opener(proxy_url).open(request, timeout=timeout) as response:
body = response.read().decode("utf-8")
return json.loads(body) if body else {}
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
raise ProviderError(f"{type(error).__name__}: {error}") from error
def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage], model: str,
timeout: float, proxy_url: str = "", params: dict | None = None,
include_cost: bool = False) -> ChatResult:
"""POST /chat/completions and parse the OpenAI-shaped response.
`include_cost=True` requests the OpenRouter-style usage.include flag and reads
usage.cost (USD, converted to micros). Providers that do not report cost
(Custom, CustoAI) leave cost_micros=0; ai/pricing.py computes a fallback.
"""
payload: dict = {
"model": model,
"messages": [{"role": m.role, "content": m.content} for m in messages],
**(params or {}),
}
if include_cost:
payload["usage"] = {"include": True}
data = post_json(base_url=base_url, path="/chat/completions", api_key=api_key,
payload=payload, timeout=timeout, proxy_url=proxy_url)
try:
text = data["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as error:
raise ProviderError(f"Unexpected provider response: {error}") from error
usage = data.get("usage") or {}
cost = usage.get("cost")
return ChatResult(
text=text,
model=data.get("model", model),
prompt_tokens=int(usage.get("prompt_tokens", 0)),
completion_tokens=int(usage.get("completion_tokens", 0)),
cost_micros=round(float(cost) * 1_000_000) if cost is not None else 0,
)
def embeddings(*, base_url: str, api_key: str, texts: list[str], model: str,
timeout: float, proxy_url: str = "") -> list[EmbeddingResult]:
"""POST /embeddings and parse the OpenAI-shaped response."""
data = post_json(base_url=base_url, path="/embeddings", api_key=api_key,
payload={"model": model, "input": texts}, timeout=timeout, proxy_url=proxy_url)
try:
items = data["data"]
except (KeyError, TypeError) as error:
raise ProviderError(f"Unexpected provider response: {error}") from error
usage = data.get("usage") or {}
per_text = int(usage.get("prompt_tokens", 0)) // max(1, len(texts))
return [EmbeddingResult(vector=item["embedding"], model=data.get("model", model), tokens=per_text) for item in items]
"""Shared HTTP layer for OpenAI-compatible LLM providers (ADR-HUB-0033 §7,
ADR-HUB-0034 §3).
The OpenRouter, generic Custom and CustoAI (Yandex AI Studio) providers all
speak the same Chat Completions shape:
- POST /chat/completions with {model, messages, ...}; response has
choices[0].message.content and usage (optionally usage.cost in USD).
- POST /embeddings with {model, input}; response has data[].embedding and usage.
- Authorization: Bearer <key>.
This module owns the HTTP transport and response parsing so the three adapters
do not duplicate it. Adapters stay responsible for their own product semantics
(name, cost handling, catalog). Stdlib only — no third-party HTTP client.
"""
from __future__ import annotations
import http.client
import json
import urllib.error
import urllib.request
from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, ProviderError
from chatballs.integrations.proxy import build_opener
def post_json(*, base_url: str, path: str, api_key: str, payload: dict, timeout: float, proxy_url: str = "") -> dict:
"""POST a JSON body to {base_url}{path} with Bearer auth; return parsed JSON.
Translates transport errors into ProviderError so callers can apply the
circuit breaker uniformly.
"""
request = urllib.request.Request(
f"{base_url.rstrip('/')}{path}",
data=json.dumps(payload).encode("utf-8"),
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
method="POST",
)
try:
with build_opener(proxy_url).open(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
# http.client.HTTPException covers IncompleteRead/BadStatusLine (dropped reply)
# — those are not OSError, so they would slip past ProviderError otherwise.
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
raise ProviderError(f"{type(error).__name__}: {error}") from error
def get_json(*, base_url: str, path: str, api_key: str, timeout: float, proxy_url: str = "") -> dict:
"""GET {base_url}{path} with Bearer auth; return parsed JSON (used for /models)."""
request = urllib.request.Request(
f"{base_url.rstrip('/')}{path}",
headers={"Authorization": f"Bearer {api_key}"},
method="GET",
)
try:
with build_opener(proxy_url).open(request, timeout=timeout) as response:
body = response.read().decode("utf-8")
return json.loads(body) if body else {}
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
raise ProviderError(f"{type(error).__name__}: {error}") from error
def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage], model: str,
timeout: float, proxy_url: str = "", params: dict | None = None,
include_cost: bool = False) -> ChatResult:
"""POST /chat/completions and parse the OpenAI-shaped response.
`include_cost=True` requests the OpenRouter-style usage.include flag and reads
usage.cost (USD, converted to micros). Providers that do not report cost
(Custom, CustoAI) leave cost_micros=0; ai/pricing.py computes a fallback.
"""
payload: dict = {
"model": model,
"messages": [{"role": m.role, "content": m.content} for m in messages],
**(params or {}),
}
if include_cost:
payload["usage"] = {"include": True}
data = post_json(base_url=base_url, path="/chat/completions", api_key=api_key,
payload=payload, timeout=timeout, proxy_url=proxy_url)
try:
text = data["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as error:
raise ProviderError(f"Unexpected provider response: {error}") from error
usage = data.get("usage") or {}
cost = usage.get("cost")
return ChatResult(
text=text,
model=data.get("model", model),
prompt_tokens=int(usage.get("prompt_tokens", 0)),
completion_tokens=int(usage.get("completion_tokens", 0)),
cost_micros=round(float(cost) * 1_000_000) if cost is not None else 0,
)
def embeddings(*, base_url: str, api_key: str, texts: list[str], model: str,
timeout: float, proxy_url: str = "") -> list[EmbeddingResult]:
"""POST /embeddings and parse the OpenAI-shaped response."""
data = post_json(base_url=base_url, path="/embeddings", api_key=api_key,
payload={"model": model, "input": texts}, timeout=timeout, proxy_url=proxy_url)
try:
items = data["data"]
except (KeyError, TypeError) as error:
raise ProviderError(f"Unexpected provider response: {error}") from error
usage = data.get("usage") or {}
per_text = int(usage.get("prompt_tokens", 0)) // max(1, len(texts))
return [EmbeddingResult(vector=item["embedding"], model=data.get("model", model), tokens=per_text) for item in items]
@@ -1,5 +1,5 @@
from hub_platform.ai.provider import openai_http
from hub_platform.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, LLMProvider
from chatballs.ai.provider import openai_http
from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, LLMProvider
class OpenRouterProvider(LLMProvider):
@@ -40,9 +40,9 @@ class OpenRouterProvider(LLMProvider):
import urllib.error
import urllib.request
from hub_platform.ai.provider.base import ProviderError
from hub_platform.conversations.transports.base import multipart_body
from hub_platform.integrations.proxy import build_opener
from chatballs.ai.provider.base import ProviderError
from chatballs.conversations.transports.base import multipart_body
from chatballs.integrations.proxy import build_opener
body, body_type = multipart_body(
{"model": model},
@@ -1,7 +1,7 @@
import time
from typing import Callable
from hub_platform.ai.provider.base import ProviderError
from chatballs.ai.provider.base import ProviderError
class CircuitBreakerOpen(ProviderError):
@@ -21,11 +21,11 @@ at runtime and overrides `AIAgent.model`.
from __future__ import annotations
from hub_platform.ai.provider.base import LLMProvider, ProviderError
from hub_platform.ai.provider.custom import CustomProvider
from hub_platform.ai.provider.demo import DemoProvider
from hub_platform.ai.provider.openrouter import OpenRouterProvider
from hub_platform.integrations.models import Integration, IntegrationProvider
from chatballs.ai.provider.base import LLMProvider, ProviderError
from chatballs.ai.provider.custom import CustomProvider
from chatballs.ai.provider.demo import DemoProvider
from chatballs.ai.provider.openrouter import OpenRouterProvider
from chatballs.integrations.models import Integration, IntegrationProvider
class IntegrationNotConfigured(ProviderError):
@@ -80,8 +80,8 @@ def _provider_from_integration(integration: Integration) -> LLMProvider:
if integration.provider == IntegrationProvider.OPENROUTER:
return OpenRouterProvider(
api_key=integration.secret,
base_url=integration.config.get("base_url") or settings.CUS_OPENROUTER_BASE_URL,
timeout=settings.CUS_AI_REQUEST_TIMEOUT,
base_url=integration.config.get("base_url") or settings.CHATBALLS_OPENROUTER_BASE_URL,
timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT,
proxy_url=integration.config.get("proxy_url", ""),
)
if integration.provider == IntegrationProvider.DEMO:
@@ -90,7 +90,7 @@ def _provider_from_integration(integration: Integration) -> LLMProvider:
return CustomProvider(
api_key=integration.secret,
base_url=integration.config["base_url"],
timeout=settings.CUS_AI_REQUEST_TIMEOUT,
timeout=settings.CHATBALLS_AI_REQUEST_TIMEOUT,
proxy_url=integration.config.get("proxy_url", ""),
)
raise IntegrationNotConfigured(
@@ -11,12 +11,12 @@ from dataclasses import dataclass
from django.core.exceptions import ValidationError
from hub_platform.integrations.models import (
from chatballs.integrations.models import (
Integration,
IntegrationKind,
IntegrationProvider,
)
from hub_platform.tenancy.context import TenantContext
from chatballs.tenancy.context import TenantContext
@dataclass(frozen=True, slots=True)
@@ -1,6 +1,6 @@
from django.urls import path
from hub_platform.ai.attachment_views import AttachmentDownloadView
from chatballs.ai.attachment_views import AttachmentDownloadView
urlpatterns = [
path("files/<uuid:public_id>/", AttachmentDownloadView.as_view(), name="ai-attachment-download"),
@@ -3,13 +3,13 @@ from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector
from django.db.models import Q
from pgvector.django import CosineDistance
from hub_platform.ai.agent_knowledge import (
from chatballs.ai.agent_knowledge import (
runtime_knowledge_for_agent,
runtime_portal_articles_for_agent,
)
from hub_platform.ai.invocation import embed_texts
from hub_platform.ai.models import AIAgent, KnowledgeFragment
from hub_platform.ai.provider.base import ProviderError
from chatballs.ai.invocation import embed_texts
from chatballs.ai.models import AIAgent, KnowledgeFragment
from chatballs.ai.provider.base import ProviderError
def _agent_fragments(agent: AIAgent):
@@ -53,7 +53,7 @@ class KnowledgeRetriever:
query_vector = embed_texts(
channel=agent.channel,
texts=[query],
model=settings.CUS_AI_EMBEDDING_MODEL,
model=settings.CHATBALLS_AI_EMBEDDING_MODEL,
purpose="retrieval_query",
)[0].vector
semantic = semantic_search(agent, query_vector, limit=limit)
@@ -1,14 +1,14 @@
from dataclasses import dataclass
from hub_platform.ai.agent_knowledge import (
from chatballs.ai.agent_knowledge import (
runtime_knowledge_for_agent,
runtime_portal_articles_for_agent,
)
from hub_platform.ai.invocation import invoke_chat
from hub_platform.ai.models import AIAgent, KnowledgeFragment
from hub_platform.ai.provider.base import ChatMessage, ChatResult
from hub_platform.ai.retrieval import KnowledgeRetriever
from hub_platform.support_portals.addressing import article_public_url
from chatballs.ai.invocation import invoke_chat
from chatballs.ai.models import AIAgent, KnowledgeFragment
from chatballs.ai.provider.base import ChatMessage, ChatResult
from chatballs.ai.retrieval import KnowledgeRetriever
from chatballs.support_portals.addressing import article_public_url
# Гард стиля для мессенджеров: гарантирует простой текст вне зависимости от
# того, что написано в авторских инструкциях.
@@ -2,12 +2,12 @@ from dataclasses import dataclass
from django.db.models import Count, Prefetch, Q, QuerySet
from hub_platform.ai.agent_knowledge import knowledge_available_to_channel
from hub_platform.ai.knowledge_policy import readable_knowledge, writable_knowledge
from hub_platform.ai.models import AIAgent, Knowledge, KnowledgeCategory
from hub_platform.channels.models import Channel
from hub_platform.identity.policy import has_capability_any_scope
from hub_platform.tenancy.context import TenantContext
from chatballs.ai.agent_knowledge import knowledge_available_to_channel
from chatballs.ai.knowledge_policy import readable_knowledge, writable_knowledge
from chatballs.ai.models import AIAgent, Knowledge, KnowledgeCategory
from chatballs.channels.models import Channel
from chatballs.identity.policy import has_capability_any_scope
from chatballs.tenancy.context import TenantContext
def agents_for_context(context: TenantContext) -> QuerySet[AIAgent]:
@@ -1,9 +1,9 @@
from hub_platform.ai.models import (
from chatballs.ai.models import (
Knowledge,
KnowledgeAttachment,
KnowledgeCategory,
)
from hub_platform.support_portals.addressing import article_public_url
from chatballs.support_portals.addressing import article_public_url
def attachment_payload(attachment: KnowledgeAttachment) -> dict[str, object]:
@@ -3,15 +3,15 @@ from dataclasses import dataclass
from django.core.exceptions import ValidationError
from django.db import transaction
from hub_platform.ai.agent_knowledge import knowledge_available_to_channel
from hub_platform.ai.models import (
from chatballs.ai.agent_knowledge import knowledge_available_to_channel
from chatballs.ai.models import (
AIAgent,
AIAgentStatus,
Knowledge,
)
from hub_platform.ai.provider_selection import configure_agent_provider
from hub_platform.channels.models import Channel
from hub_platform.tenancy.context import TenantContext
from chatballs.ai.provider_selection import configure_agent_provider
from chatballs.channels.models import Channel
from chatballs.tenancy.context import TenantContext
@dataclass(frozen=True)
@@ -188,7 +188,7 @@ def set_agent_active(*, context: TenantContext, agent: AIAgent, is_active: bool)
locked.status = target_status
locked.lifecycle_version += 1
locked.save(update_fields=["status", "lifecycle_version", "updated_at"])
from hub_platform.identity.audit import record_audit_event
from chatballs.identity.audit import record_audit_event
record_audit_event(
action="ai.agent_status_changed",
@@ -201,7 +201,7 @@ def set_agent_active(*, context: TenantContext, agent: AIAgent, is_active: bool)
return locked
from hub_platform.ai.knowledge_services import ( # noqa: E402, F401
from chatballs.ai.knowledge_services import ( # noqa: E402, F401
KnowledgeInput,
add_attachment,
create_knowledge,
@@ -2,26 +2,26 @@ import json
from django.test import TestCase
from hub_platform.ai.agent_knowledge import runtime_portal_articles_for_agent
from hub_platform.ai.knowledge_categories import create_category
from hub_platform.ai.knowledge_services import KnowledgeInput, create_knowledge
from hub_platform.ai.models import AIAgent, AIAgentStatus, KnowledgeFragment
from hub_platform.ai.retrieval import lexical_search
from hub_platform.channels.models import Channel
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.support_portals.content_services import (
from chatballs.ai.agent_knowledge import runtime_portal_articles_for_agent
from chatballs.ai.knowledge_categories import create_category
from chatballs.ai.knowledge_services import KnowledgeInput, create_knowledge
from chatballs.ai.models import AIAgent, AIAgentStatus, KnowledgeFragment
from chatballs.ai.retrieval import lexical_search
from chatballs.channels.models import Channel
from chatballs.identity.bootstrap import bootstrap_edevs_owner
from chatballs.support_portals.content_services import (
add_revision,
archive_article,
create_article,
publish_revision,
)
from hub_platform.support_portals.content_services import (
from chatballs.support_portals.content_services import (
create_category as create_portal_category,
)
from hub_platform.support_portals.models import SupportPortal
from hub_platform.support_portals.portal_services import PortalInput, create_portal
from hub_platform.support_portals.statuses import ArticleStatus
from hub_platform.testing import TenantAPIClient, system_tenant_context
from chatballs.support_portals.models import SupportPortal
from chatballs.support_portals.portal_services import PortalInput, create_portal
from chatballs.support_portals.statuses import ArticleStatus
from chatballs.testing import TenantAPIClient, system_tenant_context
class AgentAttachmentTestCase(TestCase):
@@ -1,18 +1,18 @@
import json
from django.test import TestCase
from hub_platform.testing import TenantAPIClient as APIClient
from chatballs.testing import TenantAPIClient as APIClient
from hub_platform.ai.models import AIAgent, AIAgentStatus
from hub_platform.channels.models import Channel
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.identity.models import (
from chatballs.ai.models import AIAgent, AIAgentStatus
from chatballs.channels.models import Channel
from chatballs.identity.bootstrap import bootstrap_edevs_owner
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from hub_platform.integrations.models import (
from chatballs.integrations.models import (
Integration,
IntegrationKind,
IntegrationProvider,
@@ -70,7 +70,7 @@ class AgentCardCreateTests(AgentCardTestCase):
self.assertEqual(response.status_code, 400)
def test_foreign_group_is_rejected(self) -> None:
from hub_platform.identity.group_models import EmployeeGroup
from chatballs.identity.group_models import EmployeeGroup
other = Organization.objects.create(slug="other-cards", name="Other")
foreign = EmployeeGroup.objects.create(organization=other, name="Чужая")
@@ -177,8 +177,8 @@ class AgentCardUpdateTests(AgentCardTestCase):
def test_knowledge_selection_semantics(self) -> None:
# Пустой список снимает выбор; отсутствие ключа — не трогает.
from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
from hub_platform.ai.models import Knowledge
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
from chatballs.ai.models import Knowledge
knowledge = Knowledge.objects.create(
organization=self.organization,
@@ -202,9 +202,9 @@ class AgentCardActivationTests(AgentCardTestCase):
self.card = self.create_agent().json()["agent"]
def _byok_integration(self) -> Integration:
from hub_platform.integrations.models import IntegrationProvider
from hub_platform.integrations.services import IntegrationInput, create_integration
from hub_platform.testing import system_tenant_context
from chatballs.integrations.models import IntegrationProvider
from chatballs.integrations.services import IntegrationInput, create_integration
from chatballs.testing import system_tenant_context
return create_integration(
context=system_tenant_context(self.organization),
@@ -2,16 +2,16 @@ import json
from django.test import TestCase
from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
from hub_platform.ai.knowledge_services import KnowledgeInput, create_knowledge
from hub_platform.ai.models import AIAgent, AIAgentStatus
from hub_platform.ai.retrieval import lexical_search, semantic_search
from hub_platform.ai.runtime import knowledge_catalog
from hub_platform.channels.models import Channel
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.identity.models import Organization
from hub_platform.tenancy.context import TenantContext
from hub_platform.testing import TenantAPIClient, system_tenant_context
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
from chatballs.ai.knowledge_services import KnowledgeInput, create_knowledge
from chatballs.ai.models import AIAgent, AIAgentStatus
from chatballs.ai.retrieval import lexical_search, semantic_search
from chatballs.ai.runtime import knowledge_catalog
from chatballs.channels.models import Channel
from chatballs.identity.bootstrap import bootstrap_edevs_owner
from chatballs.identity.models import Organization
from chatballs.tenancy.context import TenantContext
from chatballs.testing import TenantAPIClient, system_tenant_context
class AgentKnowledgeAssignmentTests(TestCase):
@@ -52,7 +52,7 @@ class AgentKnowledgeAssignmentTests(TestCase):
)
def _create(self, channel: Channel, knowledge_ids: list[int]) -> AIAgent:
from hub_platform.ai.services import AgentCreateInput, create_agent
from chatballs.ai.services import AgentCreateInput, create_agent
return create_agent(
context=self.context,
@@ -2,15 +2,15 @@
from django.test import TestCase
from hub_platform.ai.provider.base import ChatMessage
from hub_platform.ai.provider.demo import DemoProvider
from hub_platform.ai.provider.routing import _provider_from_integration
from hub_platform.ai.runtime import HANDOFF_TOKEN
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.identity.models import Organization
from hub_platform.integrations.models import Integration, IntegrationKind, IntegrationProvider, IntegrationStatus
from hub_platform.integrations.services import IntegrationInput, create_integration, test_integration
from hub_platform.testing import system_tenant_context
from chatballs.ai.provider.base import ChatMessage
from chatballs.ai.provider.demo import DemoProvider
from chatballs.ai.provider.routing import _provider_from_integration
from chatballs.ai.runtime import HANDOFF_TOKEN
from chatballs.identity.bootstrap import bootstrap_edevs_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import Integration, IntegrationKind, IntegrationProvider, IntegrationStatus
from chatballs.integrations.services import IntegrationInput, create_integration, test_integration
from chatballs.testing import system_tenant_context
KNOWLEDGE = (
"Отвечай только на основе этих знаний:\n"
@@ -1,8 +1,8 @@
import json
from hub_platform.ai.knowledge_categories import create_category
from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
from hub_platform.testing import TenantAPIClient
from chatballs.ai.knowledge_categories import create_category
from chatballs.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
from chatballs.testing import TenantAPIClient
class HierarchicalKnowledgeApiTests(KnowledgePolicyTestBase):
@@ -2,12 +2,12 @@ import json
from django.test import TestCase
from hub_platform.ai.knowledge_categories import create_category
from hub_platform.ai.knowledge_services import KnowledgeInput, create_knowledge
from hub_platform.ai.models import AIAgent, AIAgentStatus
from hub_platform.channels.models import Channel
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.testing import TenantAPIClient, system_tenant_context
from chatballs.ai.knowledge_categories import create_category
from chatballs.ai.knowledge_services import KnowledgeInput, create_knowledge
from chatballs.ai.models import AIAgent, AIAgentStatus
from chatballs.channels.models import Channel
from chatballs.identity.bootstrap import bootstrap_edevs_owner
from chatballs.testing import TenantAPIClient, system_tenant_context
class KnowledgeBulkApiTests(TestCase):
@@ -1,8 +1,8 @@
import json
from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
from hub_platform.ai.models import KnowledgeCategory
from hub_platform.testing import TenantAPIClient
from chatballs.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
from chatballs.ai.models import KnowledgeCategory
from chatballs.testing import TenantAPIClient
class KnowledgeCategoryApiTests(KnowledgePolicyTestBase):
@@ -3,8 +3,8 @@ import json
from django.test import TestCase
from rest_framework.test import APIClient
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.identity.models import Organization
from chatballs.identity.bootstrap import bootstrap_edevs_owner
from chatballs.identity.models import Organization
class KnowledgeDetailPayloadTests(TestCase):
@@ -2,19 +2,19 @@ from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.test import TestCase
from hub_platform.ai.knowledge_categories import (
from chatballs.ai.knowledge_categories import (
create_category,
delete_category,
ensure_uncategorized_category,
move_category,
rename_category,
)
from hub_platform.ai.knowledge_types import (
from chatballs.ai.knowledge_types import (
UNCATEGORIZED_CATEGORY_NAME,
)
from hub_platform.ai.models import Knowledge, KnowledgeCategory
from hub_platform.identity.models import Organization
from hub_platform.tenancy.context import TenantContext
from chatballs.ai.models import Knowledge, KnowledgeCategory
from chatballs.identity.models import Organization
from chatballs.tenancy.context import TenantContext
class KnowledgeCategoryTests(TestCase):
@@ -2,13 +2,13 @@ import json
from django.test import TestCase
from hub_platform.ai.knowledge_categories import create_category
from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
from hub_platform.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME
from hub_platform.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeCategory
from hub_platform.channels.models import Channel
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.testing import TenantAPIClient, system_tenant_context
from chatballs.ai.knowledge_categories import create_category
from chatballs.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
from chatballs.ai.knowledge_types import UNCATEGORIZED_CATEGORY_NAME
from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeCategory
from chatballs.channels.models import Channel
from chatballs.identity.bootstrap import bootstrap_edevs_owner
from chatballs.testing import TenantAPIClient, system_tenant_context
class KnowledgeMetadataImportTests(TestCase):
@@ -1,14 +1,14 @@
from django.core.exceptions import PermissionDenied
from hub_platform.ai.knowledge_policy import (
from chatballs.ai.knowledge_policy import (
employee_can_manage_categories,
employee_can_read_knowledge,
employee_can_write_knowledge,
require_knowledge_create,
)
from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
from hub_platform.ai.models import Knowledge
from hub_platform.ai.selectors import (
from chatballs.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
from chatballs.ai.models import Knowledge
from chatballs.ai.selectors import (
knowledge_for_employee,
writable_knowledge_for_employee,
)
@@ -1,9 +1,9 @@
import json
from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
from hub_platform.ai.models import AIAgent
from hub_platform.channels.models import Channel
from hub_platform.testing import TenantAPIClient
from chatballs.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
from chatballs.ai.models import AIAgent
from chatballs.channels.models import Channel
from chatballs.testing import TenantAPIClient
class KnowledgePolicyApiTests(KnowledgePolicyTestBase):
@@ -1,8 +1,8 @@
from hub_platform.ai.knowledge_categories import create_category
from hub_platform.ai.knowledge_policy import knowledge_is_available_to_agent
from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
from hub_platform.ai.models import AIAgent, Knowledge
from hub_platform.ai.selectors import (
from chatballs.ai.knowledge_categories import create_category
from chatballs.ai.knowledge_policy import knowledge_is_available_to_agent
from chatballs.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
from chatballs.ai.models import AIAgent, Knowledge
from chatballs.ai.selectors import (
KnowledgeFilters,
agent_for_employee,
agents_for_employee,
@@ -11,7 +11,7 @@ from hub_platform.ai.selectors import (
knowledge_available_to_agent,
knowledge_for_employee,
)
from hub_platform.channels.models import Channel
from chatballs.channels.models import Channel
class KnowledgeSelectorTests(KnowledgePolicyTestBase):
@@ -3,25 +3,25 @@ from unittest import mock
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
from hub_platform.ai.invocation import invoke_chat
from hub_platform.ai.provider.base import ChatMessage, ChatResult, ProviderError
from hub_platform.ai.provider.custom import CustomProvider
from hub_platform.ai.provider.factory import get_provider
from hub_platform.ai.provider.local import LocalProvider
from hub_platform.ai.provider.openrouter import OpenRouterProvider
from hub_platform.ai.provider.routing import (
from chatballs.ai.invocation import invoke_chat
from chatballs.ai.provider.base import ChatMessage, ChatResult, ProviderError
from chatballs.ai.provider.custom import CustomProvider
from chatballs.ai.provider.factory import get_provider
from chatballs.ai.provider.local import LocalProvider
from chatballs.ai.provider.openrouter import OpenRouterProvider
from chatballs.ai.provider.routing import (
IntegrationNotConfigured,
resolve_model,
resolve_provider_and_model,
)
from hub_platform.ai.provider_selection import configure_agent_provider
from hub_platform.ai.tests import make_channel_with_agent
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.identity.models import Organization
from hub_platform.integrations.models import Integration, IntegrationProvider
from hub_platform.integrations.services import IntegrationInput, create_integration
from hub_platform.products.models import Product
from hub_platform.testing import system_tenant_context
from chatballs.ai.provider_selection import configure_agent_provider
from chatballs.ai.tests import make_channel_with_agent
from chatballs.identity.bootstrap import bootstrap_edevs_owner
from chatballs.identity.models import Organization
from chatballs.integrations.models import Integration, IntegrationProvider
from chatballs.integrations.services import IntegrationInput, create_integration
from chatballs.products.models import Product
from chatballs.testing import system_tenant_context
class ProviderModeTests(TestCase):
@@ -80,7 +80,7 @@ class ProviderModeTests(TestCase):
self.assertIsInstance(provider, provider_type)
self.assertEqual(model, "runtime-model")
@override_settings(CUS_AI_PROVIDER="")
@override_settings(CHATBALLS_AI_PROVIDER="")
def test_byok_uses_agent_integration(self) -> None:
integration = self._link_integration()
@@ -88,7 +88,7 @@ class ProviderModeTests(TestCase):
self.assertIsInstance(provider, CustomProvider)
self.assertEqual(provider.api_key, integration.secret)
@override_settings(CUS_AI_PROVIDER="")
@override_settings(CHATBALLS_AI_PROVIDER="")
def test_missing_integration_raises_integration_not_configured(self) -> None:
with self.assertRaises(IntegrationNotConfigured):
get_provider(channel=self.channel)
@@ -98,16 +98,16 @@ class ProviderModeTests(TestCase):
# ловит ProviderError (индексация без эмбеддингов, handoff в диалогах).
self.assertTrue(issubclass(IntegrationNotConfigured, ProviderError))
@override_settings(CUS_AI_PROVIDER="test")
@override_settings(CHATBALLS_AI_PROVIDER="test")
def test_test_provider_setting_gives_local_provider(self) -> None:
self.assertIsInstance(get_provider(channel=self.channel), LocalProvider)
def test_openrouter_from_integration_removed(self) -> None:
from hub_platform.ai.provider import factory
from chatballs.ai.provider import factory
self.assertFalse(hasattr(factory, "_openrouter_from_integration"))
@override_settings(CUS_AI_PROVIDER="")
@override_settings(CHATBALLS_AI_PROVIDER="")
def test_default_model_read_in_runtime(self) -> None:
self._link_integration(default_model="integration-model")
with mock.patch.object(
@@ -5,13 +5,13 @@ from django.core.files.storage import default_storage
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
from hub_platform.ai.models import Knowledge
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.identity.models import Organization
from hub_platform.tenancy.database import tenant_atomic
from hub_platform.tenancy.models import OrganizationStorageUsage
from hub_platform.testing import TenantAPIClient, system_tenant_context
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
from chatballs.ai.models import Knowledge
from chatballs.identity.bootstrap import bootstrap_edevs_owner
from chatballs.identity.models import Organization
from chatballs.tenancy.database import tenant_atomic
from chatballs.tenancy.models import OrganizationStorageUsage
from chatballs.testing import TenantAPIClient, system_tenant_context
_MEDIA_ROOT = tempfile.mkdtemp(prefix="c04-storage-isolation-")
@@ -3,14 +3,14 @@ import tempfile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from hub_platform.testing import TenantAPIClient as APIClient, system_tenant_context
from chatballs.testing import TenantAPIClient as APIClient, system_tenant_context
from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
from hub_platform.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeFragment
from hub_platform.channels.models import Channel
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership
from hub_platform.products.models import Product
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
from chatballs.ai.models import AIAgent, AIAgentStatus, Knowledge, KnowledgeFragment
from chatballs.channels.models import Channel
from chatballs.identity.bootstrap import bootstrap_edevs_owner
from chatballs.identity.models import EmployeeRole, HumanUser, Organization, OrganizationMembership
from chatballs.products.models import Product
_MEDIA_ROOT = tempfile.mkdtemp(prefix="hub-test-media-")
@@ -45,7 +45,7 @@ class AIAgentInvariantTests(TestCase):
def test_channel_has_at_most_one_agent(self) -> None:
from django.core.exceptions import ValidationError
from hub_platform.ai.services import AgentCreateInput, create_agent
from chatballs.ai.services import AgentCreateInput, create_agent
channel, _ = make_channel_with_agent(
self.organization, code="firepage-sales", name="FirePage — продажи",
@@ -263,7 +263,7 @@ class KnowledgeImportTests(TestCase):
class PiiRedactionTests(TestCase):
def test_redacts_email_phone_and_long_numbers(self) -> None:
from hub_platform.ai.pii import redact
from chatballs.ai.pii import redact
cleaned = redact("Пишите a.kotova@edevs.tech, тел +7 916 245 14 02, карта 4111 1111 1111 1111")
self.assertNotIn("a.kotova@edevs.tech", cleaned)
@@ -274,8 +274,8 @@ class PiiRedactionTests(TestCase):
class ResilienceTests(TestCase):
def test_retries_then_succeeds(self) -> None:
from hub_platform.ai.provider.base import ProviderError
from hub_platform.ai.provider.resilience import call_with_resilience
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.provider.resilience import call_with_resilience
calls = {"n": 0}
@@ -290,8 +290,8 @@ class ResilienceTests(TestCase):
self.assertEqual(calls["n"], 2)
def test_circuit_breaker_opens_after_threshold(self) -> None:
from hub_platform.ai.provider.base import ProviderError
from hub_platform.ai.provider.resilience import CircuitBreaker, CircuitBreakerOpen, call_with_resilience
from chatballs.ai.provider.base import ProviderError
from chatballs.ai.provider.resilience import CircuitBreaker, CircuitBreakerOpen, call_with_resilience
breaker = CircuitBreaker(failure_threshold=2, reset_timeout=999)
@@ -307,16 +307,16 @@ class ResilienceTests(TestCase):
class ProviderFactoryTests(TestCase):
def test_local_provider_in_tests(self) -> None:
from hub_platform.ai.provider.factory import get_provider
from hub_platform.ai.provider.local import LocalProvider
from chatballs.ai.provider.factory import get_provider
from chatballs.ai.provider.local import LocalProvider
self.assertIsInstance(get_provider(), LocalProvider)
@override_settings(DEBUG=False, TESTING=False, CUS_AI_PROVIDER="test")
@override_settings(DEBUG=False, TESTING=False, CHATBALLS_AI_PROVIDER="test")
def test_local_provider_forbidden_in_production(self) -> None:
from django.core.exceptions import ImproperlyConfigured
from hub_platform.ai.provider.factory import get_provider
from chatballs.ai.provider.factory import get_provider
with self.assertRaises(ImproperlyConfigured):
get_provider()
@@ -332,9 +332,9 @@ class ChatInvocationTests(TestCase):
)
def test_chat_records_invocation_with_cost(self) -> None:
from hub_platform.ai.invocation import invoke_chat
from hub_platform.ai.models import LlmInvocation, LlmInvocationStatus
from hub_platform.ai.provider.base import ChatMessage
from chatballs.ai.invocation import invoke_chat
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
from chatballs.ai.provider.base import ChatMessage
result = invoke_chat(
channel=self.channel,
@@ -347,7 +347,7 @@ class ChatInvocationTests(TestCase):
self.assertEqual(invocation.status, LlmInvocationStatus.SUCCESS)
self.assertGreater(invocation.total_tokens, 0)
# Технический учёт стоимости (ADR-HUB-0042 §2): считается по прайсу модели.
from hub_platform.ai import pricing
from chatballs.ai import pricing
self.assertEqual(
invocation.cost_micros,
@@ -359,8 +359,8 @@ class ChatInvocationTests(TestCase):
def test_pii_is_redacted_before_reaching_provider(self) -> None:
from unittest import mock
from hub_platform.ai.invocation import invoke_chat
from hub_platform.ai.provider.base import ChatMessage, ChatResult
from chatballs.ai.invocation import invoke_chat
from chatballs.ai.provider.base import ChatMessage, ChatResult
captured = {}
@@ -372,7 +372,7 @@ class ChatInvocationTests(TestCase):
def embed(self, *, texts, model): # pragma: no cover
return []
with mock.patch("hub_platform.ai.invocation.get_provider", return_value=_Capturing()):
with mock.patch("chatballs.ai.invocation.get_provider", return_value=_Capturing()):
invoke_chat(
channel=self.channel,
messages=[ChatMessage(role="user", content="email me a@b.com")],
@@ -382,10 +382,10 @@ class ChatInvocationTests(TestCase):
self.assertNotIn("a@b.com", captured["messages"][0].content)
def test_limit_blocks_and_records(self) -> None:
from hub_platform.ai import limits as ai_limits
from hub_platform.ai.invocation import invoke_chat
from hub_platform.ai.models import LlmInvocation, LlmInvocationStatus
from hub_platform.ai.provider.base import ChatMessage
from chatballs.ai import limits as ai_limits
from chatballs.ai.invocation import invoke_chat
from chatballs.ai.models import LlmInvocation, LlmInvocationStatus
from chatballs.ai.provider.base import ChatMessage
self.agent.limits = {"dailyCostUsd": 1}
self.agent.save(update_fields=["limits"])
@@ -400,9 +400,9 @@ class ChatInvocationTests(TestCase):
self.assertTrue(LlmInvocation.objects.filter(channel=self.channel, status=LlmInvocationStatus.BLOCKED).exists())
def test_invocation_records_used_fragment_ids(self) -> None:
from hub_platform.ai.invocation import invoke_chat
from hub_platform.ai.models import LlmInvocation
from hub_platform.ai.provider.base import ChatMessage
from chatballs.ai.invocation import invoke_chat
from chatballs.ai.models import LlmInvocation
from chatballs.ai.provider.base import ChatMessage
invoke_chat(
channel=self.channel,
@@ -416,7 +416,7 @@ class ChatInvocationTests(TestCase):
class ChunkingTests(TestCase):
def test_packs_paragraphs_into_chunks(self) -> None:
from hub_platform.ai.chunking import chunk_text
from chatballs.ai.chunking import chunk_text
text = "\n\n".join(["paragraph " + str(i) + " " + "x" * 200 for i in range(10)])
chunks = chunk_text(text, max_chars=500)
@@ -426,17 +426,17 @@ class ChunkingTests(TestCase):
class TextExtractionTests(TestCase):
def test_extracts_plain_text(self) -> None:
from hub_platform.ai.extraction import extract_text
from chatballs.ai.extraction import extract_text
self.assertEqual(extract_text(filename="a.md", content_type="", data="# Заголовок".encode()), "# Заголовок")
def test_unknown_format_returns_empty(self) -> None:
from hub_platform.ai.extraction import extract_text
from chatballs.ai.extraction import extract_text
self.assertEqual(extract_text(filename="a.bin", content_type="application/octet-stream", data=b"\x00\x01"), "")
def test_broken_pdf_is_not_fatal(self) -> None:
from hub_platform.ai.extraction import extract_text
from chatballs.ai.extraction import extract_text
self.assertEqual(extract_text(filename="a.pdf", content_type="application/pdf", data=b"not a pdf"), "")
@@ -449,7 +449,7 @@ class KnowledgeRetrievalTests(TestCase):
self.organization, code="firepage-sales", name="FirePage — продажи",
product=Product.objects.get(code="firepage"),
)
from hub_platform.ai.services import KnowledgeInput, create_knowledge
from chatballs.ai.services import KnowledgeInput, create_knowledge
self.knowledge = create_knowledge(
context=system_tenant_context(self.organization),
@@ -468,21 +468,21 @@ class KnowledgeRetrievalTests(TestCase):
self.assertTrue(all(fragment.embedding is not None for fragment in fragments))
def test_retriever_returns_agent_scoped_fragments(self) -> None:
from hub_platform.ai.retrieval import KnowledgeRetriever
from chatballs.ai.retrieval import KnowledgeRetriever
results = KnowledgeRetriever().retrieve(agent=self.agent, query="refund", limit=5)
self.assertGreaterEqual(len(results), 1)
self.assertLessEqual(len(results), 5)
def test_retriever_skips_unselected_and_disabled_knowledge(self) -> None:
from hub_platform.ai.retrieval import lexical_search
from chatballs.ai.retrieval import lexical_search
self.knowledge.is_enabled = False
self.knowledge.save(update_fields=["is_enabled"])
self.assertEqual(lexical_search(self.agent, "refund", limit=5), [])
def test_lexical_search_matches_content(self) -> None:
from hub_platform.ai.retrieval import lexical_search
from chatballs.ai.retrieval import lexical_search
results = lexical_search(self.agent, "Delivery", limit=5)
self.assertTrue(any("delivery" in fragment.content.lower() for fragment in results))
@@ -500,7 +500,7 @@ class AgentRuntimeTests(TestCase):
self.agent.tone = "Коротко."
self.agent.instructions = "Отвечай только по знаниям."
self.agent.save()
from hub_platform.ai.services import KnowledgeInput, create_knowledge
from chatballs.ai.services import KnowledgeInput, create_knowledge
self.knowledge = create_knowledge(
context=system_tenant_context(self.organization),
@@ -509,22 +509,22 @@ class AgentRuntimeTests(TestCase):
self.agent.knowledge_items.add(self.knowledge)
def test_system_prompt_joins_three_parts_in_order(self) -> None:
from hub_platform.ai.runtime import agent_system_prompt
from chatballs.ai.runtime import agent_system_prompt
prompt = agent_system_prompt(self.agent)
self.assertEqual(prompt.index("Ты — ассистент"), 0)
self.assertLess(prompt.index("Коротко."), prompt.index("Отвечай только"))
def test_knowledge_catalog_lists_titles_and_attachment_links(self) -> None:
from hub_platform.ai.runtime import knowledge_catalog
from chatballs.ai.runtime import knowledge_catalog
catalog = knowledge_catalog(self.agent)
self.assertIn("FAQ", catalog)
self.assertIn("Возвраты", catalog)
def test_agent_turn_returns_reply_and_fragments(self) -> None:
from hub_platform.ai.models import LlmInvocation
from hub_platform.ai.runtime import run_agent_turn
from chatballs.ai.models import LlmInvocation
from chatballs.ai.runtime import run_agent_turn
result = run_agent_turn(agent=self.agent, message="refund")
@@ -535,8 +535,8 @@ class AgentRuntimeTests(TestCase):
self.assertTrue(invocation.used_fragment_ids)
def test_channel_turn_requires_active_agent(self) -> None:
from hub_platform.ai.provider.base import ProviderError
from hub_platform.channels.runtime import run_channel_turn
from chatballs.ai.provider.base import ProviderError
from chatballs.channels.runtime import run_channel_turn
self.agent.status = AIAgentStatus.DISABLED
self.agent.save(update_fields=["status"])
@@ -1,6 +1,6 @@
from django.urls import path
from hub_platform.ai import bulk_views, category_views, views
from chatballs.ai import bulk_views, category_views, views
urlpatterns = [
# agentId — id AIAgent, как и в knowledge/bulk/agent/ (CRUD агентов — /agents/).
@@ -4,31 +4,31 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from hub_platform.ai.api_errors import validation_error_response
from hub_platform.ai.knowledge_api_inputs import knowledge_filters, knowledge_input
from hub_platform.ai.knowledge_import import import_knowledge_documents
from hub_platform.ai.knowledge_policy import (
from chatballs.ai.api_errors import validation_error_response
from chatballs.ai.knowledge_api_inputs import knowledge_filters, knowledge_input
from chatballs.ai.knowledge_import import import_knowledge_documents
from chatballs.ai.knowledge_policy import (
employee_can_write_knowledge,
require_knowledge_create,
)
from hub_platform.ai.knowledge_services import (
from chatballs.ai.knowledge_services import (
add_attachment,
create_knowledge,
delete_attachment,
delete_knowledge,
update_knowledge,
)
from hub_platform.ai.models import Knowledge
from hub_platform.ai.selectors import (
from chatballs.ai.models import Knowledge
from chatballs.ai.selectors import (
apply_knowledge_filters,
knowledge_for_context,
knowledge_item_for_context,
writable_knowledge_item_for_employee,
)
from hub_platform.ai.serializers import attachment_payload, knowledge_payload
from hub_platform.api.permissions import HasCapability
from hub_platform.identity.audit import record_audit_event
from hub_platform.identity.models import AuditEvent
from chatballs.ai.serializers import attachment_payload, knowledge_payload
from chatballs.api.permissions import HasCapability
from chatballs.identity.audit import record_audit_event
from chatballs.identity.models import AuditEvent
_validation_error = validation_error_response
@@ -3,7 +3,7 @@ from rest_framework.permissions import BasePermission
from rest_framework.request import Request
from rest_framework.views import APIView
from hub_platform.identity.policy import has_capability_any_scope
from chatballs.identity.policy import has_capability_any_scope
class HasCapability(BasePermission):
@@ -34,4 +34,4 @@ class CloudDeliveryOnly(BasePermission):
message = "This operation is available only in Chatballs Cloud"
def has_permission(self, request: Request, view: APIView) -> bool:
return settings.CUS_DELIVERY_MODE == "CLOUD"
return settings.CHATBALLS_DELIVERY_MODE == "CLOUD"
@@ -1,6 +1,6 @@
from django.contrib import admin
from hub_platform.calls.models import CallInvite, CallMetric, CallParticipant, CallSession
from chatballs.calls.models import CallInvite, CallMetric, CallParticipant, CallSession
@admin.register(CallSession)
@@ -3,8 +3,8 @@ from django.apps import AppConfig
class CallsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "hub_platform.calls"
name = "chatballs.calls"
verbose_name = "P2P calls"
def ready(self) -> None:
from hub_platform.calls import event_handlers # noqa: F401 (register outbox handlers)
from chatballs.calls import event_handlers # noqa: F401 (register outbox handlers)
@@ -14,13 +14,13 @@ import logging
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from hub_platform.calls import signaling
from hub_platform.calls.errors import CallTokenError
from hub_platform.calls.models import TERMINAL_CALL_STATUSES
from hub_platform.calls.permissions import staff_call_access_valid
from hub_platform.calls.services import authorize_call_access_context
from hub_platform.calls.models import ParticipantSide
from hub_platform.tenancy.database import run_tenant_operation
from chatballs.calls import signaling
from chatballs.calls.errors import CallTokenError
from chatballs.calls.models import TERMINAL_CALL_STATUSES
from chatballs.calls.permissions import staff_call_access_valid
from chatballs.calls.services import authorize_call_access_context
from chatballs.calls.models import ParticipantSide
from chatballs.tenancy.database import run_tenant_operation
logger = logging.getLogger(__name__)
@@ -10,13 +10,13 @@ import logging
from django.conf import settings
from django.db import transaction
from hub_platform.calls.lifecycle import transition_call
from hub_platform.calls.models import CallInvite, CallSession, CallStatus, InviteDeliveryStatus
from hub_platform.calls.services import CALL_INVITE_SEND
from hub_platform.calls.tokens import issue_invite_token
from hub_platform.conversations import transports
from hub_platform.events.handlers import register
from hub_platform.tenancy.context import TenantContext
from chatballs.calls.lifecycle import transition_call
from chatballs.calls.models import CallInvite, CallSession, CallStatus, InviteDeliveryStatus
from chatballs.calls.services import CALL_INVITE_SEND
from chatballs.calls.tokens import issue_invite_token
from chatballs.conversations import transports
from chatballs.events.handlers import register
from chatballs.tenancy.context import TenantContext
logger = logging.getLogger(__name__)
@@ -51,7 +51,7 @@ def handle_call_invite_send(payload: dict, context: TenantContext | None) -> Non
invite.save(update_fields=["token_hash"])
call_label = "аудиозвонок" if call.kind == "AUDIO" else "видеозвонок"
invite_text = f"Приглашаем вас на {call_label}. Нажмите кнопку, чтобы перейти к звонку."
url = f"{settings.CUS_PUBLIC_BASE_URL.rstrip('/')}/calls/{token}?kind={call.kind}"
url = f"{settings.CHATBALLS_PUBLIC_BASE_URL.rstrip('/')}/calls/{token}?kind={call.kind}"
sent = transports.send_call_invite(
call.delivery_connection,
chat_id=call.conversation.external_chat_id,
@@ -3,16 +3,16 @@ import re
from django.db import transaction
from django.utils import timezone
from hub_platform.calls.errors import CallInvalidTransition
from hub_platform.calls.models import (
from chatballs.calls.errors import CallInvalidTransition
from chatballs.calls.models import (
TERMINAL_CALL_STATUSES,
CallEndedBy,
CallSession,
CallStatus,
ParticipantSide,
)
from hub_platform.conversations.models import Message, MessageAuthor
from hub_platform.tenancy.context import TenantContext
from chatballs.conversations.models import Message, MessageAuthor
from chatballs.tenancy.context import TenantContext
ALLOWED_TRANSITIONS = {
CallStatus.REQUESTED: {
@@ -6,9 +6,9 @@ from datetime import timedelta
from django.conf import settings
from django.utils import timezone
from hub_platform.calls.errors import CallInvalidTransition
from hub_platform.calls.lifecycle import transition_call
from hub_platform.calls.models import CallEndedBy, CallSession, CallStatus
from chatballs.calls.errors import CallInvalidTransition
from chatballs.calls.lifecycle import transition_call
from chatballs.calls.models import CallEndedBy, CallSession, CallStatus
def expire_stale_calls(context) -> int:
@@ -31,7 +31,7 @@ def expire_stale_calls(context) -> int:
continue # состояние сменилось между выборкой и переходом
# Принятый звонок без установленного соединения дольше grace period — FAILED.
connect_deadline = now - timedelta(seconds=settings.CUS_CALL_CONNECT_GRACE_SECONDS)
connect_deadline = now - timedelta(seconds=settings.CHATBALLS_CALL_CONNECT_GRACE_SECONDS)
stuck = CallSession.objects.filter(
organization=context.organization,
status__in=[CallStatus.ACCEPTED, CallStatus.CONNECTING],
@@ -51,7 +51,7 @@ def expire_stale_calls(context) -> int:
# Активный звонок с участником, не восстановившимся после обрыва (SPEC §5:
# временный обрыв → reconnecting, после grace period — FAILED).
reconnect_deadline = now - timedelta(seconds=settings.CUS_CALL_RECONNECT_GRACE_SECONDS)
reconnect_deadline = now - timedelta(seconds=settings.CHATBALLS_CALL_RECONNECT_GRACE_SECONDS)
dropped = (
CallSession.objects.filter(
organization=context.organization,
@@ -1,12 +1,12 @@
from __future__ import annotations
from hub_platform.calls.models import (
from chatballs.calls.models import (
CallConnectionType,
CallMetric,
CallSession,
ParticipantSide,
)
from hub_platform.tenancy.context import TenantContext
from chatballs.tenancy.context import TenantContext
_ALLOWED_CANDIDATE_TYPES = {"host", "srflx", "prflx", "relay"}
_MAX_ROUND_TRIP_MS = 60_000
Loaded 100 of 628 files, more files were not shown because too many files have changed in this diff. Show more