mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
🚚 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:
1 parent
dbd7ae3150
commit
1a61780dec
628 files changed
+5839
-5622
No files matched your search
+56
-56
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
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,
|
||||
+6
-6
@@ -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)
|
||||
+13
-13
@@ -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)
|
||||
+1
-1
@@ -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"),
|
||||
+10
-10
@@ -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)})
|
||||
+4
-4
@@ -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): агент использует
|
||||
# только явно выбранные и включённые знания, областей видимости нет.
|
||||
File renamed without changes.
@@ -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"
|
||||
+5
-5
@@ -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):
|
||||
+7
-7
@@ -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:
|
||||
+8
-8
@@ -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:
|
||||
File renamed without changes.
File renamed without changes.
@@ -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
|
||||
+9
-9
@@ -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)
|
||||
+3
-3
@@ -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:
|
||||
+6
-6
@@ -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]:
|
||||
+4
-4
@@ -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:
|
||||
+4
-4
@@ -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)
|
||||
+2
-2
@@ -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
-3
@@ -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"
|
||||
+4
-4
@@ -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):
|
||||
+7
-7
@@ -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,
|
||||
File renamed without changes.
@@ -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")␍
|
||||
File renamed without changes.
+2
-2
@@ -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)),
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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"])
|
||||
File renamed without changes.
File renamed without changes.
+16
-16
@@ -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"
|
||||
+1
-1
@@ -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 = "Отвечай только на основе этих знаний:"
|
||||
+4
-4
@@ -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
-1
@@ -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
|
||||
+108
-108
@@ -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]
|
||||
+5
-5
@@ -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
-1
@@ -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):
|
||||
+8
-8
@@ -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(
|
||||
+2
-2
@@ -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
-1
@@ -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]:
|
||||
+2
-2
@@ -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,
|
||||
+13
-13
@@ -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):
|
||||
+12
-12
@@ -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),
|
||||
+11
-11
@@ -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,
|
||||
+9
-9
@@ -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"
|
||||
+3
-3
@@ -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):
|
||||
+6
-6
@@ -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):
|
||||
+3
-3
@@ -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):
|
||||
File renamed without changes.
+2
-2
@@ -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):
|
||||
+5
-5
@@ -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):
|
||||
+7
-7
@@ -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):
|
||||
+4
-4
@@ -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,
|
||||
)
|
||||
+4
-4
@@ -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):
|
||||
+6
-6
@@ -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):
|
||||
+20
-20
@@ -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(
|
||||
+7
-7
@@ -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
|
||||
|
||||
File renamed without changes.
File renamed without changes.
+2
-2
@@ -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"
|
||||
File renamed without changes.
@@ -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)
|
||||
+7
-7
@@ -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__)
|
||||
|
||||
File renamed without changes.
+8
-8
@@ -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,
|
||||
+4
-4
@@ -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: {
|
||||
+5
-5
@@ -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,
|
||||
+2
-2
@@ -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
|
||||
File renamed without changes.
Loaded 100 of 628 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user