From e088ead06fb8d88851f426dca7f9d7d48f005ddd Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 8 Sep 2026 18:13:33 +0300 Subject: [PATCH] =?UTF-8?q?:memo:=20docs:=20=D0=B8=D0=B4=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=82=D0=BE=D1=80=D1=8B=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BD=D1=8B=D1=85=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D1=8B=20=D0=BA=20CHATBALLS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Документация переработана: действующее отделено от истории, отменённые контуры (продажи, биллинг, managed AI, отделы, сущность Product) убраны из действующих документов в архив. Здесь — только кодовая часть: ссылки на документы в комментариях. Ссылки на действующие документы переименованы ADR/SPEC/ARCH/BUS-HUB-NNNN → *-CHATBALLS-NNNN (274 ссылки в 173 файлах). Ссылки на документы, ушедшие в архив, намеренно сохранили прежний идентификатор: он совпадает с именем архивного файла. Логика не менялась — правки только в комментариях, докстрингах и одном описании теста. Co-Authored-By: Claude Opus 5 --- .../backend/chatballs/ai/agent_attachments.py | 2 +- apps/backend/chatballs/ai/agent_card.py | 6 +- apps/backend/chatballs/ai/agent_card_urls.py | 2 +- apps/backend/chatballs/ai/agent_card_views.py | 2 +- apps/backend/chatballs/ai/agent_knowledge.py | 2 +- apps/backend/chatballs/ai/extraction.py | 2 +- apps/backend/chatballs/ai/indexing.py | 2 +- apps/backend/chatballs/ai/invocation.py | 2 +- apps/backend/chatballs/ai/knowledge_policy.py | 2 +- .../ai/knowledge_policy_test_base.py | 2 +- .../0002_knowledge_and_agent_instructions.py | 2 +- .../migrations/0003_flatten_agent_config.py | 2 +- .../0004_drop_documents_and_releases.py | 2 +- .../0008_aiagent_credential_mode.py | 2 +- .../0015_backfill_channel_agents.py | 2 +- .../migrations/0016_remove_credential_mode.py | 2 +- apps/backend/chatballs/ai/models.py | 771 ++++++++++++------ apps/backend/chatballs/ai/pii.py | 2 +- apps/backend/chatballs/ai/provider/custom.py | 48 +- apps/backend/chatballs/ai/provider/factory.py | 2 +- .../chatballs/ai/provider/openai_http.py | 324 +++++--- .../chatballs/ai/provider/openrouter.py | 2 +- apps/backend/chatballs/ai/provider/routing.py | 10 +- .../chatballs/ai/provider_selection.py | 4 +- apps/backend/chatballs/ai/retrieval.py | 2 +- apps/backend/chatballs/ai/runtime.py | 6 +- apps/backend/chatballs/ai/services.py | 2 +- apps/backend/chatballs/ai/test_agent_cards.py | 4 +- .../chatballs/ai/test_provider_modes.py | 2 +- apps/backend/chatballs/ai/tests.py | 2 +- apps/backend/chatballs/api/permissions.py | 2 +- apps/backend/chatballs/calls/consumers.py | 2 +- .../backend/chatballs/calls/event_handlers.py | 2 +- apps/backend/chatballs/calls/lifecycle.py | 2 +- apps/backend/chatballs/calls/maintenance.py | 2 +- apps/backend/chatballs/calls/models.py | 2 +- apps/backend/chatballs/calls/signaling.py | 2 +- .../chatballs/calls/tests/test_signaling.py | 2 +- apps/backend/chatballs/calls/turn.py | 2 +- .../chatballs/channels/authorization.py | 2 +- .../0004_remove_channel_ai_fields.py | 2 +- .../0005_enforce_policy_invariants.py | 2 +- apps/backend/chatballs/channels/models.py | 8 +- apps/backend/chatballs/channels/policy.py | 309 ++++--- apps/backend/chatballs/channels/selectors.py | 2 +- .../chatballs/conversations/clients.py | 8 +- .../chatballs/conversations/contacts_merge.py | 2 +- .../backend/chatballs/conversations/ingest.py | 8 +- .../chatballs/conversations/maintenance.py | 75 +- .../backend/chatballs/conversations/models.py | 16 +- .../conversations/reporting_views.py | 2 +- .../chatballs/conversations/selectors.py | 2 +- .../chatballs/conversations/serializers.py | 2 +- .../chatballs/conversations/services.py | 615 +++++++++----- apps/backend/chatballs/conversations/stats.py | 2 +- .../conversations/test_authorization.py | 2 +- .../conversations/test_contacts_merge.py | 4 +- .../conversations/test_email_transport.py | 2 +- apps/backend/chatballs/conversations/tests.py | 2 +- .../conversations/transports/__init__.py | 2 +- .../conversations/transports/base.py | 2 +- .../conversations/transports/email.py | 4 +- .../chatballs/conversations/transports/max.py | 2 +- .../conversations/transports/telegram.py | 2 +- apps/backend/chatballs/conversations/views.py | 6 +- .../identity/administration_views.py | 2 +- .../chatballs/identity/auth/profile.py | 2 +- apps/backend/chatballs/identity/bootstrap.py | 2 +- .../chatballs/identity/capabilities.py | 6 +- .../demo_seed/loaders/conversations.py | 2 +- .../chatballs/identity/employee_support.py | 2 +- apps/backend/chatballs/identity/governance.py | 6 +- .../chatballs/identity/group_models.py | 2 +- .../backend/chatballs/identity/group_views.py | 2 +- .../migrations/0005_move_product_state.py | 2 +- .../migrations/0008_enforce_position_title.py | 2 +- .../0019_drop_sales_capabilities.py | 2 +- apps/backend/chatballs/identity/models.py | 8 +- apps/backend/chatballs/identity/policy.py | 4 +- apps/backend/chatballs/identity/setup.py | 2 +- .../chatballs/identity/test_authorization.py | 2 +- apps/backend/chatballs/identity/tests.py | 12 +- apps/backend/chatballs/integrations/checks.py | 10 +- apps/backend/chatballs/integrations/models.py | 6 +- apps/backend/chatballs/integrations/proxy.py | 2 +- .../chatballs/integrations/serializers.py | 2 +- .../chatballs/integrations/services.py | 4 +- .../chatballs/integrations/test_email.py | 4 +- apps/backend/chatballs/integrations/tests.py | 4 +- .../migrations/0007_drop_payment_types.py | 2 +- .../chatballs/platform/authentication.py | 2 +- .../chatballs/platform/capabilities.py | 2 +- .../chatballs/platform/operator_models.py | 2 +- .../platform/provisioning_service.py | 2 +- .../platform/tests/test_platform_auth.py | 2 +- .../support_portals/content_services.py | 2 +- .../chatballs/support_portals/models.py | 4 +- .../chatballs/support_portals/portal_views.py | 2 +- .../tests/test_management_api.py | 2 +- .../chatballs/support_portals/themes.py | 2 +- .../0002_cross_tenant_constraints.py | 2 +- .../tenancy/migrations/0003_rls_policies.py | 4 +- .../migrations/0004_ingress_directory.py | 4 +- .../0005_platform_provisioning_grants.py | 2 +- .../migrations/0010_support_portal_guards.py | 2 +- .../tenancy/migrations/0017_drop_commerce.py | 2 +- .../migrations/0018_employee_groups_rls.py | 2 +- ...ort_portal_directory_without_department.py | 2 +- .../tenancy/migrations/0020_drop_billing.py | 2 +- .../migrations/0029_drop_product_support.py | 2 +- .../chatballs/tenancy/storage_quota.py | 2 +- apps/backend/chatballs/tenancy/test_rls.py | 771 ++++++++++++------ .../chatballs/tenancy/test_storage_quota.py | 2 +- apps/backend/chatballs/webchat/loader.py | 2 +- .../migrations/0005_remove_widget_mode.py | 2 +- apps/backend/chatballs/webchat/models.py | 2 +- apps/backend/chatballs/webchat/services.py | 2 +- apps/backend/chatballs/webchat/throttling.py | 2 +- .../chatballs_backend/settings_admin.py | 2 +- .../chatballs_backend/settings_base.py | 10 +- apps/internal-ui/src/auth/access.test.ts | 2 +- apps/internal-ui/src/auth/access.ts | 2 +- .../src/features/agents/AgentDetailPage.tsx | 2 +- .../features/agents/AgentKnowledgeDialog.tsx | 2 +- .../src/features/agents/AgentsPage.tsx | 2 +- apps/internal-ui/src/features/agents/model.ts | 4 +- .../src/features/ai/agentOptions.ts | 2 +- .../ai/knowledge/KnowledgeAgentDialog.tsx | 2 +- .../ai/knowledge/knowledgeLibraryModel.ts | 2 +- .../ai/knowledge/parseKnowledgeYaml.ts | 2 +- .../conversations/ConversationWorkspace.tsx | 4 +- .../src/features/conversations/model.ts | 4 +- .../features/help-center/HelpCenterApp.tsx | 2 +- .../features/help-center/themes/contract.css | 2 +- .../features/help-center/themes/registry.ts | 2 +- .../src/features/help-center/themes/types.ts | 2 +- .../integrations/ConnectionsTable.tsx | 2 +- .../src/features/integrations/EmailFields.tsx | 2 +- .../features/integrations/IntegrationForm.tsx | 2 +- .../src/features/integrations/model.test.ts | 2 +- .../src/features/integrations/model.ts | 10 +- .../src/features/integrations/rows.tsx | 2 +- .../src/features/integrations/styles.css | 6 +- .../profile/ProfileAppearanceCard.tsx | 2 +- .../SalesClientIdentitiesTab.tsx | 2 +- .../client-detail/SalesClientMergeDialog.tsx | 2 +- .../src/features/sales/client-detail/model.ts | 2 +- .../features/sales/client-detail/styles.css | 2 +- .../sales/client-detail/useClientDetail.ts | 2 +- .../src/features/sales/clients/model.ts | 2 +- .../features/sales/clients/useSalesClients.ts | 2 +- .../support-portals/styles-detail.css | 2 +- .../src/layout/LaunchChecklist.tsx | 2 +- apps/internal-ui/src/layout/styles.css | 2 +- apps/internal-ui/src/router.test.ts | 2 +- apps/internal-ui/src/router.ts | 4 +- apps/internal-ui/src/shared/appearance.ts | 2 +- apps/internal-ui/src/shared/icons.tsx | 2 +- apps/internal-ui/src/shared/ui-controls.tsx | 2 +- apps/internal-ui/src/styles/base.css | 2 +- apps/internal-ui/src/types.ts | 6 +- apps/internal-ui/src/vite-env.d.ts | 2 +- apps/web-chat/call.html | 2 +- apps/web-chat/src/api.ts | 4 +- compose.dev.yaml | 2 +- compose.yaml | 12 +- deploy/cli/lib/deploy.sh | 2 +- deploy/cli/lib/status.sh | 2 +- deploy/nginx/frontend.production.conf | 4 +- packages/shared/src/callRtc.ts | 2 +- packages/ui/src/theme.ts | 2 +- tests/cli/conftest.py | 2 +- tests/cli/test_custocrm_cli.py | 4 +- 173 files changed, 2187 insertions(+), 1216 deletions(-) diff --git a/apps/backend/chatballs/ai/agent_attachments.py b/apps/backend/chatballs/ai/agent_attachments.py index 76b2e2f..c739ee6 100644 --- a/apps/backend/chatballs/ai/agent_attachments.py +++ b/apps/backend/chatballs/ai/agent_attachments.py @@ -3,7 +3,7 @@ Обе библиотеки — знания организации и статьи портала поддержки — прикрепляются по одному правилу: недоступные агенту по отделу элементы не прикрепляются, но и не роняют операцию, а возвращаются в `skipped_ids`. Так массовое действие над -смешанной выборкой остаётся предсказуемым (SPEC-HUB-0026 §5). +смешанной выборкой остаётся предсказуемым (SPEC-CHATBALLS-0026 §5). """ from collections.abc import Iterable diff --git a/apps/backend/chatballs/ai/agent_card.py b/apps/backend/chatballs/ai/agent_card.py index ad51f7e..2898741 100644 --- a/apps/backend/chatballs/ai/agent_card.py +++ b/apps/backend/chatballs/ai/agent_card.py @@ -1,4 +1,4 @@ -"""Агент как единая сущность (ADR-HUB-0041 §4, SPEC-HUB-0031 §4.3). +"""Агент как единая сущность (ADR-CHATBALLS-0041 §4, SPEC-CHATBALLS-0031 §4.3). Для администратора существует только «Агент»: имя, группа, инструкции, знания, подключения, активность. Физически карточка агрегирует Channel (несущая ось @@ -94,7 +94,7 @@ def _connections_payload(channel: Channel) -> list[dict[str, object]]: def knowledge_total_for_organization(organization_id: int) -> int: """Сколько всего материалов можно выбрать агенту — знаний библиотеки и опубликованных статей порталов («4 из 18» в шапке блока «Знания»). - Библиотека общая для организации (ADR-HUB-0041 §8), поэтому число одно + Библиотека общая для организации (ADR-CHATBALLS-0041 §8), поэтому число одно на всех агентов — список считает его один раз.""" from chatballs.ai.models import Knowledge from chatballs.support_portals.models import PortalArticle @@ -193,7 +193,7 @@ def ensure_channel_agent(channel: Channel) -> AIAgent: def create_agent_card( *, context: TenantContext, name: object, group_id: int | None ) -> Channel: - """Мастер одного шага (SPEC-HUB-0031 §4.3): имя и необязательная группа. + """Мастер одного шага (SPEC-CHATBALLS-0031 §4.3): имя и необязательная группа. Канал создаётся без продукта с безопасной операторской политикой (дефолты модели удовлетворяют P1-P5); код генерируется из имени и неизменен. diff --git a/apps/backend/chatballs/ai/agent_card_urls.py b/apps/backend/chatballs/ai/agent_card_urls.py index daa9a00..6af5b5b 100644 --- a/apps/backend/chatballs/ai/agent_card_urls.py +++ b/apps/backend/chatballs/ai/agent_card_urls.py @@ -1,4 +1,4 @@ -"""Роуты единой сущности «Агент» (ADR-HUB-0041 §4).""" +"""Роуты единой сущности «Агент» (ADR-CHATBALLS-0041 §4).""" from django.urls import path diff --git a/apps/backend/chatballs/ai/agent_card_views.py b/apps/backend/chatballs/ai/agent_card_views.py index 68252d6..6bf1878 100644 --- a/apps/backend/chatballs/ai/agent_card_views.py +++ b/apps/backend/chatballs/ai/agent_card_views.py @@ -1,4 +1,4 @@ -"""HTTP-слой карточек агентов (/api/v1/agents/, ADR-HUB-0041 §4).""" +"""HTTP-слой карточек агентов (/api/v1/agents/, ADR-CHATBALLS-0041 §4).""" from __future__ import annotations diff --git a/apps/backend/chatballs/ai/agent_knowledge.py b/apps/backend/chatballs/ai/agent_knowledge.py index 674c01e..ec6af02 100644 --- a/apps/backend/chatballs/ai/agent_knowledge.py +++ b/apps/backend/chatballs/ai/agent_knowledge.py @@ -5,7 +5,7 @@ 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): агент использует +# Библиотека знаний — общая для организации (ADR-CHATBALLS-0041 §8): агент использует # только явно выбранные и включённые знания, областей видимости нет. diff --git a/apps/backend/chatballs/ai/extraction.py b/apps/backend/chatballs/ai/extraction.py index 1fd2bd3..fc7fc65 100644 --- a/apps/backend/chatballs/ai/extraction.py +++ b/apps/backend/chatballs/ai/extraction.py @@ -1,4 +1,4 @@ -"""Извлечение текста из файловых вложений знаний (ADR-HUB-0023). +"""Извлечение текста из файловых вложений знаний (ADR-CHATBALLS-0023). Поддерживаются текстовые форматы (md, txt и любой text/*), PDF (pypdf) и DOCX (python-docx). Остальные форматы хранятся без индексации — extract_text вернёт diff --git a/apps/backend/chatballs/ai/indexing.py b/apps/backend/chatballs/ai/indexing.py index 8de0ef6..0292e3d 100644 --- a/apps/backend/chatballs/ai/indexing.py +++ b/apps/backend/chatballs/ai/indexing.py @@ -36,7 +36,7 @@ def _store_fragments(*, organization, chunks: list[str], **source) -> list[Knowl def reindex_knowledge(knowledge: Knowledge) -> list[KnowledgeFragment]: - """Rebuild fragments for a knowledge item (ADR-HUB-0016/0023): content plus + """Rebuild fragments for a knowledge item (ADR-CHATBALLS-0016/0023): content plus extracted text of its attachments.""" KnowledgeFragment.objects.filter(knowledge=knowledge).delete() sources = [knowledge.content] diff --git a/apps/backend/chatballs/ai/invocation.py b/apps/backend/chatballs/ai/invocation.py index 90771bb..f9d5e98 100644 --- a/apps/backend/chatballs/ai/invocation.py +++ b/apps/backend/chatballs/ai/invocation.py @@ -32,7 +32,7 @@ def _record_blocked(*, channel, purpose: str, model: str, error: Exception) -> N def _prepare_invocation(*, channel, requested_model: str | None) -> tuple[LLMProvider, str]: - # BYOK — единственный режим (ADR-HUB-0042 §3): модель берётся из интеграции + # BYOK — единственный режим (ADR-CHATBALLS-0042 §3): модель берётся из интеграции # организации с fallback на модель агента. Без интеграции модель остаётся # агентской: тестовый провайдер работает, прод упадёт в get_provider штатно. agent = channel.ai_agent diff --git a/apps/backend/chatballs/ai/knowledge_policy.py b/apps/backend/chatballs/ai/knowledge_policy.py index 761ba70..70b250d 100644 --- a/apps/backend/chatballs/ai/knowledge_policy.py +++ b/apps/backend/chatballs/ai/knowledge_policy.py @@ -10,7 +10,7 @@ from chatballs.tenancy.context import TenantContext AI_VIEW = "ai.view" AI_MANAGE = "ai.manage" -# Библиотека знаний — общая для организации (ADR-HUB-0041 §8): областей +# Библиотека знаний — общая для организации (ADR-CHATBALLS-0041 §8): областей # видимости нет, доступ определяется ролью (ai.view/ai.manage у OWNER/ADMIN). diff --git a/apps/backend/chatballs/ai/knowledge_policy_test_base.py b/apps/backend/chatballs/ai/knowledge_policy_test_base.py index fc3b175..fa75569 100644 --- a/apps/backend/chatballs/ai/knowledge_policy_test_base.py +++ b/apps/backend/chatballs/ai/knowledge_policy_test_base.py @@ -15,7 +15,7 @@ from chatballs.tenancy.context import TenantContext class KnowledgePolicyTestBase(TestCase): - """База knowledge-тестов: библиотека общая для организации (ADR-HUB-0041 §8), + """База knowledge-тестов: библиотека общая для организации (ADR-CHATBALLS-0041 §8), доступ ролевой — у EMPLOYEE нет ai.*, у OWNER/ADMIN есть всё.""" def setUp(self) -> None: diff --git a/apps/backend/chatballs/ai/migrations/0002_knowledge_and_agent_instructions.py b/apps/backend/chatballs/ai/migrations/0002_knowledge_and_agent_instructions.py index 52211fe..149224c 100644 --- a/apps/backend/chatballs/ai/migrations/0002_knowledge_and_agent_instructions.py +++ b/apps/backend/chatballs/ai/migrations/0002_knowledge_and_agent_instructions.py @@ -1,4 +1,4 @@ -# ADR-HUB-0023: плоские Знания с вложениями + инструкции агента из трёх частей. +# ADR-CHATBALLS-0023: плоские Знания с вложениями + инструкции агента из трёх частей. # Добавляющая часть; перенос данных — 0003, снос старых моделей — 0004. import uuid diff --git a/apps/backend/chatballs/ai/migrations/0003_flatten_agent_config.py b/apps/backend/chatballs/ai/migrations/0003_flatten_agent_config.py index b071d58..80e2849 100644 --- a/apps/backend/chatballs/ai/migrations/0003_flatten_agent_config.py +++ b/apps/backend/chatballs/ai/migrations/0003_flatten_agent_config.py @@ -1,4 +1,4 @@ -# ADR-HUB-0023: перенос данных из версионируемых документов и релизов в +# ADR-CHATBALLS-0023: перенос данных из версионируемых документов и релизов в # плоские Знания и поля агента. # # - KnowledgeDocument -> Knowledge (содержимое последней опубликованной версии, diff --git a/apps/backend/chatballs/ai/migrations/0004_drop_documents_and_releases.py b/apps/backend/chatballs/ai/migrations/0004_drop_documents_and_releases.py index 269d3ff..995439b 100644 --- a/apps/backend/chatballs/ai/migrations/0004_drop_documents_and_releases.py +++ b/apps/backend/chatballs/ai/migrations/0004_drop_documents_and_releases.py @@ -1,4 +1,4 @@ -# ADR-HUB-0023: снос версионируемых документов и релизов после переноса данных (0003). +# ADR-CHATBALLS-0023: снос версионируемых документов и релизов после переноса данных (0003). import django.db.models.deletion from django.db import migrations, models diff --git a/apps/backend/chatballs/ai/migrations/0008_aiagent_credential_mode.py b/apps/backend/chatballs/ai/migrations/0008_aiagent_credential_mode.py index bcb3396..6deb7fe 100644 --- a/apps/backend/chatballs/ai/migrations/0008_aiagent_credential_mode.py +++ b/apps/backend/chatballs/ai/migrations/0008_aiagent_credential_mode.py @@ -1,4 +1,4 @@ -# Generated for CustoAI / BYOK credential mode (ADR-HUB-0033 §4, SPEC-HUB-0024 §2). +# Generated for CustoAI / BYOK credential mode (ADR-HUB-0033 §4, SPEC-CHATBALLS-0024 §2). from django.db import migrations, models diff --git a/apps/backend/chatballs/ai/migrations/0015_backfill_channel_agents.py b/apps/backend/chatballs/ai/migrations/0015_backfill_channel_agents.py index ed74c54..7d08456 100644 --- a/apps/backend/chatballs/ai/migrations/0015_backfill_channel_agents.py +++ b/apps/backend/chatballs/ai/migrations/0015_backfill_channel_agents.py @@ -1,4 +1,4 @@ -# ADR-HUB-0041 §4: агент и канал — одна сущность. Каждому каналу без AIAgent +# ADR-CHATBALLS-0041 §4: агент и канал — одна сущность. Каждому каналу без AIAgent # создаётся DRAFT-агент (AI не отвечает, поведение канала не меняется), чтобы # карточка агента существовала для всех исторических каналов. from django.db import migrations diff --git a/apps/backend/chatballs/ai/migrations/0016_remove_credential_mode.py b/apps/backend/chatballs/ai/migrations/0016_remove_credential_mode.py index ea0e8d4..320eb1a 100644 --- a/apps/backend/chatballs/ai/migrations/0016_remove_credential_mode.py +++ b/apps/backend/chatballs/ai/migrations/0016_remove_credential_mode.py @@ -1,4 +1,4 @@ -# ADR-HUB-0042 §3: managed-режим CustoAI удалён вместе с тарифным контуром. +# ADR-CHATBALLS-0042 §3: managed-режим CustoAI удалён вместе с тарифным контуром. # BYOK — единственный режим; поле credential_mode больше не нужно. from django.db import migrations diff --git a/apps/backend/chatballs/ai/models.py b/apps/backend/chatballs/ai/models.py index ddbca09..5c7fae9 100644 --- a/apps/backend/chatballs/ai/models.py +++ b/apps/backend/chatballs/ai/models.py @@ -1,257 +1,514 @@ -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.urls import reverse - - from chatballs.identity.instance_settings import public_base_url - - path = reverse("ai-attachment-download", kwargs={"public_id": self.public_id}) - return public_base_url() + 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",) - # Учёт по каналу (ADR-HUB-0019). - channel = models.ForeignKey("channels.Channel", on_delete=models.SET_NULL, null=True, blank=True, related_name="ai_invocations") - 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}" +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-CHATBALLS-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-CHATBALLS-0042 §3): + +# AI работает только через провайдера организации (AIAgent.provider_integration). + + + + + +# --- Знания: общая библиотека организации с иерархией категорий + +# (ADR-CHATBALLS-0023, ADR-CHATBALLS-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-CHATBALLS-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.urls import reverse + + + + from chatballs.identity.instance_settings import public_base_url + + + + path = reverse("ai-attachment-download", kwargs={"public_id": self.public_id}) + + return public_base_url() + path + + + + + +class KnowledgeFragment(TenantRelationModel): + + tenant_relation_fields = ("knowledge", "portal_article") + + # Чанк источника + его эмбеддинг (pgvector). ADR-CHATBALLS-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-CHATBALLS-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",) + + # Учёт по каналу (ADR-HUB-0019). + + channel = models.ForeignKey("channels.Channel", on_delete=models.SET_NULL, null=True, blank=True, related_name="ai_invocations") + + 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}" + diff --git a/apps/backend/chatballs/ai/pii.py b/apps/backend/chatballs/ai/pii.py index f28c37b..9349c2d 100644 --- a/apps/backend/chatballs/ai/pii.py +++ b/apps/backend/chatballs/ai/pii.py @@ -1,6 +1,6 @@ import re -# Минимизация данных перед LLM (ADR-HUB-0011): email, телефоны, длинные +# Минимизация данных перед LLM (ADR-CHATBALLS-0011): email, телефоны, длинные # числовые идентификаторы (карты/платежи/заказы) не передаются в модель. _EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+") _LONG_DIGITS = re.compile(r"\b\d[\d\s-]{10,}\d\b") diff --git a/apps/backend/chatballs/ai/provider/custom.py b/apps/backend/chatballs/ai/provider/custom.py index dc4f208..e737a8c 100644 --- a/apps/backend/chatballs/ai/provider/custom.py +++ b/apps/backend/chatballs/ai/provider/custom.py @@ -1,16 +1,32 @@ -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 , - 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-CHATBALLS-0034). + + + + Reuses the OpenRouter HTTP layer verbatim: the contract is identical + + (POST /chat/completions, POST /embeddings, Authorization: Bearer , + + 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-CHATBALLS-0034 §4). The distinct name lets routing and accounting tell the + + two BYOK modes apart. + + """ + + + + name = "custom" + diff --git a/apps/backend/chatballs/ai/provider/factory.py b/apps/backend/chatballs/ai/provider/factory.py index 39e315a..d470381 100644 --- a/apps/backend/chatballs/ai/provider/factory.py +++ b/apps/backend/chatballs/ai/provider/factory.py @@ -13,7 +13,7 @@ def _test_provider() -> LLMProvider: def get_provider(*, channel=None) -> LLMProvider: - """Resolve the organization's own provider (BYOK, ADR-HUB-0042 §3). + """Resolve the organization's own provider (BYOK, ADR-CHATBALLS-0042 §3). The test adapter is an explicit test-surface override. Managed platform credentials were removed with the billing domain: every invocation uses the diff --git a/apps/backend/chatballs/ai/provider/openai_http.py b/apps/backend/chatballs/ai/provider/openai_http.py index f17419e..4a0b2a1 100644 --- a/apps/backend/chatballs/ai/provider/openai_http.py +++ b/apps/backend/chatballs/ai/provider/openai_http.py @@ -1,108 +1,216 @@ -"""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 . - -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] +"""Shared HTTP layer for OpenAI-compatible LLM providers (ADR-HUB-0033 §7, + +ADR-CHATBALLS-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 . + + + +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] + diff --git a/apps/backend/chatballs/ai/provider/openrouter.py b/apps/backend/chatballs/ai/provider/openrouter.py index 2f9a6a1..3343777 100644 --- a/apps/backend/chatballs/ai/provider/openrouter.py +++ b/apps/backend/chatballs/ai/provider/openrouter.py @@ -7,7 +7,7 @@ class OpenRouterProvider(LLMProvider): OpenAI Chat Completions shape with usage.include=true (returns the actual USD cost in usage.cost). Delegates HTTP/parsing to the shared openai_http - layer (ADR-HUB-0033 §7, ADR-HUB-0034 §3); this adapter only carries the + layer (ADR-HUB-0033 §7, ADR-CHATBALLS-0034 §3); this adapter only carries the OpenRouter product semantics (cost reporting). Exercised with a real key; tests use the LocalProvider. """ diff --git a/apps/backend/chatballs/ai/provider/routing.py b/apps/backend/chatballs/ai/provider/routing.py index 25a0f18..5fb4d1f 100644 --- a/apps/backend/chatballs/ai/provider/routing.py +++ b/apps/backend/chatballs/ai/provider/routing.py @@ -1,4 +1,4 @@ -"""BYOK provider routing for AI invocations (ADR-HUB-0020:45, ADR-HUB-0034). +"""BYOK provider routing for AI invocations (ADR-CHATBALLS-0020:45, ADR-CHATBALLS-0034). Resolves an LLM provider and the effective model from the channel agent's `provider_integration`. This is the BYOK path: the organization supplies its @@ -10,11 +10,11 @@ managed AI credits are not consumed. попавших в data-миграцию; после удаления поля канала fallback уходит. Selecting the first OpenRouter integration of the org or globally overriding -the owner's choice is forbidden (ADR-HUB-0020:45). The integration MUST be +the owner's choice is forbidden (ADR-CHATBALLS-0020:45). The integration MUST be the one the agent points at. This module also closes the as-built gap where the OpenRouter «Модель по -умолчанию» field was decorative (SPEC-HUB-0005:388, SPEC-HUB-0024 §4.3, §6): +умолчанию» field was decorative (SPEC-HUB-0005:388, SPEC-CHATBALLS-0024 §4.3, §6): for OpenRouter and Custom integrations the configured `default_model` is read at runtime and overrides `AIAgent.model`. """ @@ -32,8 +32,8 @@ class IntegrationNotConfigured(ProviderError): """Raised when a channel has no provider_integration. Surfaces a clear configuration error instead of silently falling back to a - global/first integration (forbidden by ADR-HUB-0020:45). Наследует - ProviderError: после удаления managed-режима (ADR-HUB-0042 §3) отсутствие + global/first integration (forbidden by ADR-CHATBALLS-0020:45). Наследует + ProviderError: после удаления managed-режима (ADR-CHATBALLS-0042 §3) отсутствие интеграции — штатное «провайдера нет», а не 500: индексация знаний пишет фрагменты без эмбеддингов, ретривер работает лексически. """ diff --git a/apps/backend/chatballs/ai/provider_selection.py b/apps/backend/chatballs/ai/provider_selection.py index ae26463..7803af5 100644 --- a/apps/backend/chatballs/ai/provider_selection.py +++ b/apps/backend/chatballs/ai/provider_selection.py @@ -1,7 +1,7 @@ -"""Выбор LLM-провайдера агента (SPEC-HUB-0027 §9, ADR-HUB-0042 §3). +"""Выбор LLM-провайдера агента (SPEC-HUB-0027 §9, ADR-CHATBALLS-0042 §3). Managed-режим CustoAI удалён вместе с тарифным контуром: агент работает только -через интеграцию организации (BYOK, ADR-HUB-0034). Функция ничего не пишет: +через интеграцию организации (BYOK, ADR-CHATBALLS-0034). Функция ничего не пишет: возвращает разрешённую интеграцию и модель, вызывающий сервис ставит их на агента в одной транзакции. Агент без интеграции — валидное состояние черновика; активация без провайдера запрещена в set_agent_active. diff --git a/apps/backend/chatballs/ai/retrieval.py b/apps/backend/chatballs/ai/retrieval.py index b446787..76b86ed 100644 --- a/apps/backend/chatballs/ai/retrieval.py +++ b/apps/backend/chatballs/ai/retrieval.py @@ -14,7 +14,7 @@ from chatballs.ai.provider.base import ProviderError def _agent_fragments(agent: AIAgent): # Оба источника знаний агента живут в одной таблице фрагментов, поэтому - # поиск остаётся одним запросом (ADR-HUB-0016). + # поиск остаётся одним запросом (ADR-CHATBALLS-0016). return KnowledgeFragment.objects.filter( Q(knowledge_id__in=runtime_knowledge_for_agent(agent).values("id")) | Q(portal_article_id__in=runtime_portal_articles_for_agent(agent).values("id")) diff --git a/apps/backend/chatballs/ai/runtime.py b/apps/backend/chatballs/ai/runtime.py index 0bff949..d121352 100644 --- a/apps/backend/chatballs/ai/runtime.py +++ b/apps/backend/chatballs/ai/runtime.py @@ -20,7 +20,7 @@ MESSENGER_STYLE_GUARD = ( ) # Протокол передачи оператору: модель добавляет технический токен, система его -# ловит, ставит диалог в очередь и уведомляет операторов (ADR-HUB-0003). +# ловит, ставит диалог в очередь и уведомляет операторов (ADR-CHATBALLS-0003). HANDOFF_TOKEN = "<>" HANDOFF_PROTOCOL = ( "Если по правилам нужно подключить живого оператора (клиент просит человека; " @@ -39,7 +39,7 @@ class AgentTurnResult: def agent_system_prompt(agent: AIAgent) -> str: - # Порядок частей фиксирован (ADR-HUB-0023): Персонализация -> Тон -> Инструкции. + # Порядок частей фиксирован (ADR-CHATBALLS-0023): Персонализация -> Тон -> Инструкции. parts = [ part.strip() for part in (agent.persona, agent.tone, agent.instructions) if part.strip() ] @@ -131,5 +131,5 @@ def run_agent_turn( used_fragment_ids=[fragment.id for fragment in fragments], ) - # Нет основания в знаниях -> кандидат на передачу оператору (ADR-HUB-0003). + # Нет основания в знаниях -> кандидат на передачу оператору (ADR-CHATBALLS-0003). return AgentTurnResult(result=result, fragments=fragments, handoff_suggested=not fragments) diff --git a/apps/backend/chatballs/ai/services.py b/apps/backend/chatballs/ai/services.py index 9a23c62..e0a0e1c 100644 --- a/apps/backend/chatballs/ai/services.py +++ b/apps/backend/chatballs/ai/services.py @@ -169,7 +169,7 @@ def update_agent(*, context: TenantContext, agent: AIAgent, data: AgentInput) -> @transaction.atomic def set_agent_active(*, context: TenantContext, agent: AIAgent, is_active: bool) -> AIAgent: - """Смена статуса AI без тарифных слотов (ADR-HUB-0042 §2): количество + """Смена статуса AI без тарифных слотов (ADR-CHATBALLS-0042 §2): количество активных агентов не ограничено; активация требует настроенного провайдера.""" if agent.channel.organization_id != context.organization_id: raise ValidationError({"agent": "Agent belongs to another organization"}) diff --git a/apps/backend/chatballs/ai/test_agent_cards.py b/apps/backend/chatballs/ai/test_agent_cards.py index 1e93154..58da8ff 100644 --- a/apps/backend/chatballs/ai/test_agent_cards.py +++ b/apps/backend/chatballs/ai/test_agent_cards.py @@ -20,7 +20,7 @@ from chatballs.integrations.models import ( class AgentCardTestCase(TestCase): - """Единая сущность «Агент» = канал + AI-конфигурация (ADR-HUB-0041 §4).""" + """Единая сущность «Агент» = канал + AI-конфигурация (ADR-CHATBALLS-0041 §4).""" def setUp(self) -> None: bootstrap_owner(email="owner@example.com", password="temporary-password") @@ -275,7 +275,7 @@ class AgentCardActivationTests(AgentCardTestCase): ) def test_activation_without_provider_integration_is_rejected(self) -> None: - # Активация требует выбранного провайдера организации (ADR-HUB-0042 §2); + # Активация требует выбранного провайдера организации (ADR-CHATBALLS-0042 §2); # деактивация свободна. response = self.client.post(f"/api/v1/agents/{self.card['id']}/activate/") diff --git a/apps/backend/chatballs/ai/test_provider_modes.py b/apps/backend/chatballs/ai/test_provider_modes.py index 139969d..0a49a2e 100644 --- a/apps/backend/chatballs/ai/test_provider_modes.py +++ b/apps/backend/chatballs/ai/test_provider_modes.py @@ -24,7 +24,7 @@ from chatballs.testing import system_tenant_context class ProviderModeTests(TestCase): - """BYOK — единственный режим работы AI (ADR-HUB-0042 §3).""" + """BYOK — единственный режим работы AI (ADR-CHATBALLS-0042 §3).""" def setUp(self) -> None: bootstrap_owner(email="owner@example.com", password="temporary-password") diff --git a/apps/backend/chatballs/ai/tests.py b/apps/backend/chatballs/ai/tests.py index 363b88c..7d9f759 100644 --- a/apps/backend/chatballs/ai/tests.py +++ b/apps/backend/chatballs/ai/tests.py @@ -338,7 +338,7 @@ class ChatInvocationTests(TestCase): invocation = LlmInvocation.objects.get(channel=self.channel, operation="chat") self.assertEqual(invocation.status, LlmInvocationStatus.SUCCESS) self.assertGreater(invocation.total_tokens, 0) - # Технический учёт стоимости (ADR-HUB-0042 §2): считается по прайсу модели. + # Технический учёт стоимости (ADR-CHATBALLS-0042 §2): считается по прайсу модели. from chatballs.ai import pricing self.assertEqual( diff --git a/apps/backend/chatballs/api/permissions.py b/apps/backend/chatballs/api/permissions.py index 2c98a60..2a15275 100644 --- a/apps/backend/chatballs/api/permissions.py +++ b/apps/backend/chatballs/api/permissions.py @@ -7,7 +7,7 @@ from chatballs.identity.policy import has_capability_any_scope class HasCapability(BasePermission): - """DRF entry-point guard backed by the shared role policy (SPEC-HUB-0031 §3). + """DRF entry-point guard backed by the shared role policy (SPEC-CHATBALLS-0031 §3). Views declare ``required_capability`` or a method keyed ``required_capabilities`` mapping. Object/resource scope is still checked by the diff --git a/apps/backend/chatballs/calls/consumers.py b/apps/backend/chatballs/calls/consumers.py index 60224eb..f1c0565 100644 --- a/apps/backend/chatballs/calls/consumers.py +++ b/apps/backend/chatballs/calls/consumers.py @@ -1,4 +1,4 @@ -"""WebSocket signaling звонков (SPEC-HUB-0013 §9). +"""WebSocket signaling звонков (SPEC-CHATBALLS-0013 §9). Правила: - аутентификация первым сообщением {"type": "auth", "token": } diff --git a/apps/backend/chatballs/calls/event_handlers.py b/apps/backend/chatballs/calls/event_handlers.py index 2d921fa..99c95cc 100644 --- a/apps/backend/chatballs/calls/event_handlers.py +++ b/apps/backend/chatballs/calls/event_handlers.py @@ -1,4 +1,4 @@ -"""Outbox-доставка приглашения на звонок в TG/MAX (SPEC-HUB-0013 §7.2). +"""Outbox-доставка приглашения на звонок в TG/MAX (SPEC-CHATBALLS-0013 §7.2). Сырой invite token не хранится в БД и payload события: при каждой попытке доставки token выпускается заново, в БД пишется только hash, а ссылка diff --git a/apps/backend/chatballs/calls/lifecycle.py b/apps/backend/chatballs/calls/lifecycle.py index 7fca6de..c7b0633 100644 --- a/apps/backend/chatballs/calls/lifecycle.py +++ b/apps/backend/chatballs/calls/lifecycle.py @@ -41,7 +41,7 @@ def _format_duration(seconds: int) -> str: def _timeline_text(call: CallSession, target_status: str) -> str | None: - # Системные события звонка в timeline диалога (SPEC-HUB-0013 §13). + # Системные события звонка в timeline диалога (SPEC-CHATBALLS-0013 §13). # Вызывается только при фактической смене статуса — retry дублей не даёт. if target_status == CallStatus.ACCEPTED: return "Клиент принял приглашение на звонок" diff --git a/apps/backend/chatballs/calls/maintenance.py b/apps/backend/chatballs/calls/maintenance.py index cda6e49..b14a2b3 100644 --- a/apps/backend/chatballs/calls/maintenance.py +++ b/apps/backend/chatballs/calls/maintenance.py @@ -1,4 +1,4 @@ -"""Серверные таймауты звонков (SPEC-HUB-0013 §5): истечение приглашения и +"""Серверные таймауты звонков (SPEC-CHATBALLS-0013 §5): истечение приглашения и зависшее соединение обрабатываются воркером, а не браузером клиента.""" from datetime import timedelta diff --git a/apps/backend/chatballs/calls/models.py b/apps/backend/chatballs/calls/models.py index 3a64e92..3e7a24d 100644 --- a/apps/backend/chatballs/calls/models.py +++ b/apps/backend/chatballs/calls/models.py @@ -190,7 +190,7 @@ class CallParticipant(TenantRelationModel): class CallMetric(TenantRelationModel): - """Технические метрики соединения без медиаконтента (SPEC-HUB-0013 §13). + """Технические метрики соединения без медиаконтента (SPEC-CHATBALLS-0013 §13). Хранится только КАТЕГОРИЯ ICE-кандидата (host/srflx/prflx/relay) и RTT, но никогда сам ICE candidate, его адрес, SDP или медиапоток. Позволяет считать diff --git a/apps/backend/chatballs/calls/signaling.py b/apps/backend/chatballs/calls/signaling.py index db49f9c..8cba96d 100644 --- a/apps/backend/chatballs/calls/signaling.py +++ b/apps/backend/chatballs/calls/signaling.py @@ -1,4 +1,4 @@ -"""Доменные операции WebSocket-signaling (SPEC-HUB-0013 §9). +"""Доменные операции WebSocket-signaling (SPEC-CHATBALLS-0013 §9). Вызываются consumer'ом через database_sync_to_async и возвращают готовые payload-словари: ORM не утекает в async-контекст. Source of truth lifecycle — diff --git a/apps/backend/chatballs/calls/tests/test_signaling.py b/apps/backend/chatballs/calls/tests/test_signaling.py index 001ce5c..ba772b8 100644 --- a/apps/backend/chatballs/calls/tests/test_signaling.py +++ b/apps/backend/chatballs/calls/tests/test_signaling.py @@ -1,4 +1,4 @@ -"""WebSocket signaling (проход B, SPEC-HUB-0013 §9): auth по access token, +"""WebSocket signaling (проход B, SPEC-CHATBALLS-0013 §9): auth по access token, relay только между участниками звонка, переходы CONNECTING/ACTIVE/ENDED, reconnect без новой CallSession, поздние события игнорируются.""" diff --git a/apps/backend/chatballs/calls/turn.py b/apps/backend/chatballs/calls/turn.py index 1dbc445..42c438d 100644 --- a/apps/backend/chatballs/calls/turn.py +++ b/apps/backend/chatballs/calls/turn.py @@ -11,7 +11,7 @@ from django.conf import settings def turn_credentials( *, label: str = "hub", now: int | None = None, ttl_seconds: int | None = None ) -> tuple[str, str]: - """Краткоживущие TURN REST credentials для Coturn (SPEC-HUB-0013 §11). + """Краткоживущие TURN REST credentials для Coturn (SPEC-CHATBALLS-0013 §11). Схема coturn `use-auth-secret`: username = ":