Merge pull request #4 from dartdavros/codex/release-1.15.2-cleanup

Подготовка релиза v1.15.2: очистка устаревших терминов
This commit is contained in:
Andrey Ermolaev authored and GitHub committed 2026-09-24 18:03:41 +03:00
commit d5c9d5c638
62 files changed
+95 -104

No files matched your search

+1 -1
View File
@@ -33,7 +33,7 @@ UI / дизайн:
- Перед тем как написать свой элемент, искать существующий в `shared/` и в соседних фичах. Второй стандарт того же элемента — это дефект, а не свобода реализации.
Никаких служебных записок в продукте:
- В продуктовом UI не должно быть служебной лексики. Запрещены на экране: коды спек и решений (SPEC-HUB-*, ADR-HUB-*, DG-*), номера правил (P1-P5 и любые другие), слова «инвариант», «констрейнт», «миграция», «scope» в техническом смысле, имена таблиц, полей и enum-значений БД и API (`allowCheckoutActions`, `Conversation.channel`, `PROTECT`, `OK`/`ERROR` как есть), ссылки на внутренние документы и любые пометки для разработчика.
- В продуктовом UI не должно быть служебной лексики. Запрещены на экране: коды спек и решений (SPEC-*, ADR-*, DG-*), номера правил (P1-P5 и любые другие), слова «инвариант», «констрейнт», «миграция», «scope» в техническом смысле, имена таблиц, полей и enum-значений БД и API (`allowCheckoutActions`, `Conversation.channel`, `PROTECT`, `OK`/`ERROR` как есть), ссылки на внутренние документы и любые пометки для разработчика.
- Пользователю показывается следствие и способ исправить, а не внутреннее правило. «Коммерческие действия недоступны непродуктовому каналу. Назначьте продукт» — да. «Запрещено инвариантом P1» — нет.
- Технические коды остаются в коде, комментариях, логах и API-ответах, но не в текстах интерфейса.
- Это относится и к baseline-макетам: служебный текст, попавший в макет, не является основанием выводить его на экран. Макет реализуется, дополняется по указанию владельца, служебная лексика в реализацию не переносится.
+1 -1
View File
@@ -1,4 +1,4 @@
# Caddyfile — единый HTTP/HTTPS public boundary Chatballs (ADR-HUB-0028 §gateway).
# Caddyfile — единый HTTP/HTTPS public boundary Chatballs (ADR-CHATBALLS-0028 §gateway).
# Подставляется в release bundle и монтируется в контейнер gateway.
#
# Свежая установка не знает своего домена: человек поднимает докер на сервере и
+1 -1
View File
@@ -22,7 +22,7 @@ COPY apps/backend /app/apps/backend
COPY deploy/secrets/generate-instance-secrets.sh /usr/local/bin/chatballs-generate-secrets.sh
RUN chmod 0755 /usr/local/bin/chatballs-generate-secrets.sh
# collectstatic в образе (ADR-HUB-0028): STATIC_ROOT испечён, runtime-шаг не нужен.
# collectstatic в образе (ADR-CHATBALLS-0028): STATIC_ROOT испечён, runtime-шаг не нужен.
# Build-time secret нужен только чтобы settings загрузились в production-режиме;
# collectstatic не обращается к БД/Redis/S3. whitenoise раздаёт static в runtime.
# Build-time dummy values satisfy C04 runtime guards (distinct DB users in
@@ -228,7 +228,7 @@ class AgentCardDeactivateView(_AgentCardStatusView):
class AgentCardTestChatView(APIView):
permission_classes = [HasCapability]
# Исполняет агента, а не изменяет канал: остаётся на ai.manage (ADR-HUB-0037 §9).
# Исполняет агента, а не изменяет канал: остаётся на ai.manage.
required_capability = "ai.manage"
def post(self, request: Request, agent_id: int) -> Response:
@@ -1,4 +1,4 @@
# Generated for CustoAI / BYOK credential mode (ADR-HUB-0033 §4, SPEC-CHATBALLS-0024 §2).
# Историческое поле режима доступа к AI; удалено в 0016.
from django.db import migrations, models
@@ -14,8 +14,8 @@ class Migration(migrations.Migration):
model_name='aiagent',
name='credential_mode',
field=models.CharField(
choices=[('CUSTOAI', 'CustoAI (Managed)'), ('BYOK', 'BYOK')],
default='CUSTOAI',
choices=[('BYOK', 'BYOK')],
default='BYOK',
max_length=16,
),
),
@@ -1,4 +1,4 @@
# SPEC-HUB-0027 §9, ADR-HUB-0037 §8 — этап 5, шаги 1-2.
# Провайдер LLM переезжает с канала на агента.
#
# BYOK-секрет переезжает с канала на агента. До сих пор credential_mode и model
# жили на AIAgent, а provider_integration — на Channel: одно решение было
@@ -15,7 +15,7 @@ def backfill_agents(apps, schema_editor):
channel=channel,
name=channel.name,
status="DRAFT",
credential_mode="CUSTOAI",
credential_mode="BYOK",
)
@@ -1,5 +1,4 @@
# ADR-CHATBALLS-0042 §3: managed-режим CustoAI удалён вместе с тарифным контуром.
# BYOK — единственный режим; поле credential_mode больше не нужно.
# ADR-CHATBALLS-0042 §3: BYOK — единственный режим; поле credential_mode больше не нужно.
from django.db import migrations
+5 -7
View File
@@ -6,7 +6,7 @@ from pgvector.django import VectorField
from chatballs.tenancy.models import TenantRelationModel
# Один основной агент на канал обработки (ADR-HUB-0019, ADR-CHATBALLS-0023).
# Один основной агент на канал обработки (ADR-CHATBALLS-0023).
DEFAULT_AI_MODEL = "anthropic/claude-sonnet-4.6"
@@ -40,8 +40,6 @@ class AIAgentStatus(models.TextChoices):
# Managed-режим CustoAI удалён вместе с тарифным контуром (ADR-CHATBALLS-0042 §3):
# AI работает только через провайдера организации (AIAgent.provider_integration).
@@ -151,7 +149,7 @@ class KnowledgeAttachment(TenantRelationModel):
# Непредсказуемый идентификатор публичной ссылки скачивания (ADR-CHATBALLS-0023):
# агент может отдать ссылку клиенту в мессенджер, где нет аутентификации Hub.
# агент может отдать ссылку клиенту в мессенджер, где нет аутентификации установки.
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
@@ -193,7 +191,7 @@ class KnowledgeAttachment(TenantRelationModel):
# Абсолютная ссылка скачивания: уходит клиентам в мессенджеры, поэтому
# строится от публичного адреса Hub, а не от request.
# строится от публичного адреса установки, а не от request.
from django.urls import reverse
@@ -343,7 +341,7 @@ class AIAgent(TenantRelationModel):
channel = models.OneToOneField("channels.Channel", on_delete=models.CASCADE, related_name="ai_agent")
# BYOK-секрет организации (SPEC-HUB-0027 §9). Раньше жил на Channel, из-за
# BYOK-секрет организации. Раньше жил на Channel, из-за
# чего credential_mode и model были на агенте, а секрет — на канале: одно
@@ -494,7 +492,7 @@ 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")
@@ -1,10 +1,8 @@
"""Shared HTTP layer for OpenAI-compatible LLM providers (ADR-HUB-0033 §7,
ADR-CHATBALLS-0034 §3).
"""Shared HTTP layer for OpenAI-compatible LLM providers (ADR-CHATBALLS-0034 §3).
The OpenRouter, generic Custom and CustoAI (Yandex AI Studio) providers all
The OpenRouter and generic Custom providers both
speak the same Chat Completions shape:
@@ -20,7 +18,7 @@ speak the same Chat Completions shape:
This module owns the HTTP transport and response parsing so the three adapters
This module owns the HTTP transport and response parsing so the adapters
do not duplicate it. Adapters stay responsible for their own product semantics
@@ -8,7 +8,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-CHATBALLS-0034 §3); this adapter only carries the
layer (ADR-CHATBALLS-0034 §3); this adapter only carries the
OpenRouter product semantics (cost reporting). Exercised with a real key;
tests use the LocalProvider.
"""
@@ -5,7 +5,7 @@ Resolves an LLM provider and the effective model from the channel agent's
own credentials, the integration is selected explicitly on `AIAgent`, and
managed AI credits are not consumed.
Источник провайдера переехал с канала на агента (SPEC-HUB-0027 §9). Один
Источник провайдера переехал с канала на агента. Один
релиз резолвер падает на `Channel.provider_integration` для записей, не
попавших в data-миграцию; после удаления поля канала fallback уходит.
@@ -14,7 +14,7 @@ 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-CHATBALLS-0024 §4.3, §6):
умолчанию» field was decorative (SPEC-CHATBALLS-0024 §4.3, §6):
for OpenRouter and Custom integrations the configured `default_model` is read
at runtime and overrides `AIAgent.model`.
"""
@@ -114,7 +114,7 @@ def _channel_integration(channel) -> Integration:
agent = getattr(channel, "ai_agent", None)
integration = getattr(agent, "provider_integration", None) if agent else None
if integration is None:
# Переходный fallback на один релиз (SPEC-HUB-0027 §9 шаг 3): записи,
# Переходный fallback на один релиз: записи,
# не попавшие в data-миграцию, продолжают работать через канал.
integration = getattr(channel, "provider_integration", None)
if integration is None or not integration.secret:
@@ -1,7 +1,7 @@
"""Выбор LLM-провайдера агента (SPEC-HUB-0027 §9, ADR-CHATBALLS-0042 §3).
"""Выбор LLM-провайдера агента (ADR-CHATBALLS-0042 §3).
Managed-режим CustoAI удалён вместе с тарифным контуром: агент работает только
через интеграцию организации (BYOK, ADR-CHATBALLS-0034). Функция ничего не пишет:
Агент работает только через интеграцию организации (BYOK,
ADR-CHATBALLS-0034). Функция ничего не пишет:
возвращает разрешённую интеграцию и модель, вызывающий сервис ставит их на
агента в одной транзакции. Агент без интеграции — валидное состояние черновика;
активация без провайдера запрещена в set_agent_active.
@@ -130,7 +130,7 @@ class ProviderModeTests(TestCase):
class AgentProviderOwnershipTests(TestCase):
"""SPEC-HUB-0027 §9 — провайдер живёт на агенте, канал не изменяется."""
"""Провайдер живёт на агенте, канал не изменяется."""
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
+1 -1
View File
@@ -16,7 +16,7 @@ _MEDIA_ROOT = tempfile.mkdtemp(prefix="hub-test-media-")
def make_channel_with_agent(organization, *, code, name, model="openai/gpt-4o-mini"):
"""Канал обработки + его агент (ADR-HUB-0019/0023). Bootstrap не создаёт
"""Канал обработки + его агент (ADR-CHATBALLS-0023). Bootstrap не создаёт
каналы/агентов — в тестах их собирает этот helper."""
channel = Channel.objects.create(organization=organization, code=code, name=name)
agent = AIAgent.objects.create(
@@ -1,4 +1,4 @@
# SPEC-HUB-0027 §3.2/§12, ADR-HUB-0037 §7 — этап 3.
# Проверка инвариантов политики канала.
#
# Приводит существующие каналы в соответствие P1-P2 и меняет дефолты модели,
# которые сами по себе их нарушали: attribution и checkout были включены по
+1 -1
View File
@@ -1,6 +1,6 @@
from django.db import models
# Канал обработки — якорь AI-контекста (ADR-HUB-0019). Группа видимости и
# Канал обработки — якорь AI-контекста. Группа видимости и
# ссылка на провайдер-интеграцию. Поведение AI (модель, инструкции, знания)
# живёт на агенте канала (ADR-CHATBALLS-0023).
+1 -1
View File
@@ -4,7 +4,7 @@ from chatballs.i18n import t
def run_channel_turn(*, channel, message: str, history: list[dict] | None = None) -> ChatResult:
"""One AI turn for a processing channel (ADR-HUB-0019/0023).
"""One AI turn for a processing channel (ADR-CHATBALLS-0023).
AI-поведение канала целиком определяет его агент. Без активного агента
AI-ответа нет: вызывающий код (ingest/support) обрабатывает ProviderError
+1 -2
View File
@@ -13,8 +13,7 @@ def _with_relations(queryset: QuerySet[Channel]) -> QuerySet[Channel]:
).prefetch_related(
Prefetch("connections", queryset=Integration.objects.order_by("id"))
).annotate(
# Один агрегат на весь список: N+1 запросов на счётчик не допускается
# (SPEC-HUB-0027 §6.1).
# Один агрегат на весь список: N+1 запросов на счётчик не допускается.
open_conversations_count=Count(
"conversations",
filter=Q(conversations__lifecycle=LifecycleState.OPEN),
+2 -2
View File
@@ -1,4 +1,4 @@
"""Операции над каналом обработки (SPEC-HUB-0027 §6, §7)."""
"""Операции над каналом обработки."""
from __future__ import annotations
@@ -42,7 +42,7 @@ class ChannelHasReferences(Exception):
class ConnectionAlreadyBound(Exception):
"""Подключение принадлежит ровно одному каналу (ADR-HUB-0019)."""
"""Подключение принадлежит ровно одному каналу."""
def __init__(self, *, integration: Integration) -> None:
self.integration = integration
@@ -7,7 +7,7 @@ from django.utils import timezone
from chatballs.i18n import t
from chatballs.tenancy.models import TenantRelationModel
# Минимальный домен диалогов (ADR-HUB-0001/0002/0003/0006). Состояние диалога
# Минимальный домен диалогов (ADR-CHATBALLS-0002/0003/0006). Состояние диалога
# разделено на независимые оси; перехват оператором — атомарный.
@@ -267,10 +267,10 @@ class VkOutboundTests(SimpleTestCase):
with mock.patch.object(vk_send.vk_api, "call", api):
with mock.patch.object(vk_send, "customer_language", return_value="ru"):
sent = vk_send.send_call_invite(
_integration(), chat_id="77", user_id="77", text="Звонок", url="https://hub.test/calls/abc"
_integration(), chat_id="77", user_id="77", text="Звонок", url="https://chatballs.test/calls/abc"
)
self.assertTrue(sent)
self.assertIn("https://hub.test/calls/abc", api.params("messages.send")["keyboard"])
self.assertIn("https://chatballs.test/calls/abc", api.params("messages.send")["keyboard"])
def test_rejected_send_is_reported_as_failure(self) -> None:
api = _Api(**{"messages.send": VkRejected("ВКонтакте отклонил запрос (7): access denied")})
@@ -119,7 +119,7 @@ def multipart_body(
"""Ручной multipart/form-data (в репо urllib, без requests)."""
import uuid
boundary = "----hub" + uuid.uuid4().hex
boundary = "----chatballs" + uuid.uuid4().hex
crlf = "\r\n"
parts: list[bytes] = []
for name, value in fields.items():
@@ -110,7 +110,7 @@ class Command(BaseCommand):
role = str(options["role"])
does_events = role in (ROLE_ALL, ROLE_EVENTS)
does_polling = role in (ROLE_ALL, ROLE_POLLER)
self.stdout.write(f"Hub worker started (role={role})")
self.stdout.write(f"Chatballs worker started (role={role})")
last_poll = 0.0
last_maintenance = 0.0
last_call_sweep = 0.0
@@ -81,7 +81,7 @@ class InvitationRegisterView(APIView):
class InvitationAcceptView(APIView):
"""Accept an organization invitation: OWNER (SPEC-HUB-0021 §8.2) or employee.
"""Accept an organization invitation: OWNER or employee.
Authenticated endpoint: the caller must already have a HumanUser account
(created through sign-up / password setup). The token is read from the body;
+1 -1
View File
@@ -41,7 +41,7 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst
support_group, _ = EmployeeGroup.objects.get_or_create(
organization=organization, name="Поддержка"
)
# Каналы обработки и их агенты (ADR-HUB-0019) создаются через API каналов.
# Каналы обработки и их агенты создаются через API каналов.
owner, created_owner = HumanUser.objects.get_or_create(
email=HumanUser.objects.normalize_email(email),
@@ -30,7 +30,7 @@ from chatballs.tenancy.ingress import invitation_route
# Приглашение существующего пользователя в организацию: письмо отправляет
# воркер по этому событию (identity.event_handlers).
MEMBERSHIP_INVITATION_REQUESTED = "identity.membership_invitation_requested"
# Приглашение владельца из платформенного провижининга (SPEC-HUB-0021 §8.2):
# Приглашение владельца из платформенного провижининга:
# учётной записи может ещё не быть, тогда человек создаёт её по ссылке.
OWNER_INVITATION_REQUESTED = "organization.owner_invitation_requested"
MEMBERSHIP_INVITATION_TTL = timedelta(days=7)
@@ -240,7 +240,7 @@ def _invitation_for_token(token: str, *, accepted: bool) -> OrganizationInvitati
@transaction.atomic
def accept_invitation(*, token: str, user: HumanUser) -> AcceptedInvitation:
"""Принять приглашение: владельца (SPEC-HUB-0021 §8.2) или сотрудника.
"""Принять приглашение: владельца или сотрудника.
Членство собирается из полей приглашения; для владельца организация ещё
и активируется. Идемпотентно: повторное принятие того же токена не создаёт
@@ -337,7 +337,7 @@ def _already_accepted_for(
token: str, user: HumanUser
) -> AcceptedInvitation | None:
"""Idempotent re-accept: if this token was already accepted by the same user,
return the existing result instead of raising (SPEC-HUB-0021 §11/§15)."""
return the existing result instead of raising."""
invitation = _invitation_for_token(token, accepted=True)
if invitation is None:
return None
@@ -1,4 +1,4 @@
# ADR-HUB-0027 / SPEC-HUB-0018 фаза M1-M2 (этап 1): additive schema + backfill.
# Роли, должности и отделы сотрудников: additive schema + backfill.
# Роли OWNER/ADMIN/EMPLOYEE, обязательная должность (пока nullable), основной отдел
# и размещение владельца на уровне компании. Доступ существующих сотрудников не
# расширяется: OPERATOR → EMPLOYEE, права сохраняются compatibility-адаптером.
@@ -8,7 +8,7 @@ from django.db import migrations, models
def operator_to_employee(apps, schema_editor):
EmployeeProfile = apps.get_model("identity", "EmployeeProfile")
# OPERATOR больше не является системной ролью (ADR-HUB-0027).
# OPERATOR больше не является системной ролью.
EmployeeProfile.objects.filter(role="OPERATOR").update(role="EMPLOYEE")
# Владелец всегда на уровне компании: снимаем основной отдел.
EmployeeProfile.objects.filter(role="OWNER").exclude(primary_department__isnull=True).update(
@@ -1,4 +1,4 @@
# ADR-HUB-0027 / SPEC-HUB-0018 фаза M2 (этап 1): после явного заполнения должностей
# После явного заполнения должностей
# включается обязательность. Оставшиеся незаполненные значения нормализуются в "";
# непустая должность гарантируется application contract (SPEC-CHATBALLS-0016 §5).
from django.db import migrations, models
@@ -1,4 +1,4 @@
# Generated for C06 tenant provisioning (SPEC-HUB-0021 §6/§8).
# Generated for tenant provisioning.
# Adds Organization.status and backfills existing organizations to ACTIVE.
from django.db import migrations, models
@@ -1,4 +1,4 @@
# SPEC-HUB-0027 §5.1/§5.3, ADR-HUB-0037 §9 — этап 1.
# Права на управление каналами.
#
# Вводит `channels.view` / `channels.manage` и выдаёт их существующим профилям
# доступа по текущим `ai.view` / `ai.manage`, чтобы никто не потерял доступ в
+1 -1
View File
@@ -102,7 +102,7 @@ class HumanUser(AbstractUser):
return self.email
class OrganizationStatus(models.TextChoices):
# SPEC-HUB-0021 §6/§8: PENDING_OWNER до принятия OWNER invitation, ACTIVE после.
# PENDING_OWNER до принятия OWNER invitation, ACTIVE после.
ACTIVE = "ACTIVE", "Active"
PENDING_OWNER = "PENDING_OWNER", "Pending owner"
+1 -1
View File
@@ -7,7 +7,7 @@
Запись идёт по основному соединению процесса. Роль app вправе вставить
организацию только пока их нет (политика tenancy/0032); дальше создавать
организации может только роль platform (SPEC-HUB-0021 §10). Так пароль
организации может только роль platform. Так пароль
platform-роли не нужен процессу backend-app.
"""
+2 -2
View File
@@ -770,7 +770,7 @@ class ThrottlingTests(TestCase):
class EmployeeModelInvariantTests(TestCase):
"""ADR-HUB-0027 / SPEC-CHATBALLS-0016 §5,§7 — инварианты модели сотрудника после
"""SPEC-CHATBALLS-0016 §5,§7 — инварианты модели сотрудника после
миграции этапа 1: роли OWNER/ADMIN/EMPLOYEE, обязательная должность,
размещение владельца на уровне компании и ровно один владелец на организацию."""
@@ -823,7 +823,7 @@ class EmployeeModelInvariantTests(TestCase):
class EmployeeGovernanceTests(TestCase):
"""ADR-HUB-0027 этап 2 / SPEC-CHATBALLS-0016 §8,§12: административная иерархия
"""SPEC-CHATBALLS-0016 §8,§12: административная иерархия
OWNER/ADMIN/EMPLOYEE, target-aware управление и передача владения."""
def setUp(self) -> None:
@@ -3,7 +3,7 @@ from django.db import models
from chatballs.identity.crypto import EncryptedCharField
# Интеграции: провайдеры (LLM) и подключения (боты/виджеты). ADR-CHATBALLS-0020.
# Привязка подключения к каналу обработки появляется в M1 (ADR-HUB-0019).
# Привязка подключения к каналу обработки появляется в M1.
class IntegrationKind(models.TextChoices):
@@ -63,7 +63,7 @@ class Integration(models.Model):
voice_messages_enabled = models.BooleanField(default=True)
audio_calls_enabled = models.BooleanField(default=True)
video_calls_enabled = models.BooleanField(default=True)
# Подключение (бот/виджет) привязано к каналу обработки (ADR-HUB-0019, M2).
# Подключение (бот/виджет) привязано к каналу обработки.
channel = models.ForeignKey("channels.Channel", on_delete=models.SET_NULL, null=True, blank=True, related_name="connections")
# Курсор Long Polling (marker MAX / offset Telegram).
poll_marker = models.CharField(max_length=64, blank=True)
@@ -30,7 +30,7 @@ class IntegrationInput:
name: str
secret: str | None = None # None = не менять при update
config: dict = field(default_factory=dict)
channel_id: int | None = None # канал обработки для подключения (ADR-HUB-0019)
channel_id: int | None = None # канал обработки для подключения
is_active: bool | None = None
@@ -120,7 +120,7 @@ def _normalized_config(provider: str, config: dict) -> dict:
except OutboundUrlRejected as error:
raise ValidationError({"config": t("settings.proxy_url_rejected", error=error)}) from error
# LLM-провайдеры (OpenRouter, Custom) хранят модель по умолчанию свободным текстом.
# Для OpenRouter поле исторически декоративно (SPEC-HUB-0005:388); для Custom оно
# Для OpenRouter поле исторически декоративно; для Custom оно
# читается в рантайме (ADR-CHATBALLS-0034 §4). Версионирование модели — дорожка ADR-0034.
if provider in (IntegrationProvider.OPENROUTER, IntegrationProvider.CUSTOM, IntegrationProvider.DEMO):
default_model = str(config.get("defaultModel", config.get("default_model", ""))).strip()
+1 -1
View File
@@ -510,7 +510,7 @@ class OpenRouterProviderProxyTests(TestCase):
provider = OpenRouterProvider(api_key="sk-test", base_url="https://openrouter.ai/api/v1", proxy_url="http://proxy:8080")
# После рефакторинга общий HTTP-слой живёт в openai_http (ADR-HUB-0033 §7):
# После рефакторинга общий HTTP-слой живёт в openai_http:
# мокаем именно его build_opener.
+1 -1
View File
@@ -16,7 +16,7 @@ def provisioning_result_payload(
owner_state: str,
) -> dict[str, Any]:
"""Response shape for POST /api/v1/organizations. Never includes secrets,
tokens or invitation plaintext (SPEC-HUB-0021 §12/§13)."""
tokens or invitation plaintext."""
return {
"organization": {
"publicId": str(organization.public_id),
@@ -26,7 +26,7 @@ class ProvisioningCommand:
tax_profile: dict[str, Any] | None = None
def significant_fields(self) -> dict[str, Any]:
"""Canonical normalized values for request_hash (SPEC-HUB-0021 §11):
"""Canonical normalized values for request_hash:
transport metadata is excluded. Keys are sorted for a stable hash."""
data = asdict(self)
data["owner_email"] = data["owner_email"].strip().lower()
@@ -53,7 +53,7 @@ class ProvisioningResult:
def is_replay(provisioning: OrganizationProvisioning, command: ProvisioningCommand) -> bool:
"""COMPLETED/WAITING_FOR_OWNER with a matching request hash is an idempotent
replay; a differing hash is a conflict (SPEC-HUB-0021 §11)."""
replay; a differing hash is a conflict."""
return provisioning.request_hash == command.request_hash()
@@ -21,10 +21,10 @@ class ProvisioningStatus(models.TextChoices):
class OrganizationProvisioning(models.Model):
"""Platform-owned process record (SPEC-HUB-0021 §6). Idempotency key is
"""Platform-owned process record. Idempotency key is
globally unique; the same key + payload returns the existing result, while a
differing payload is a conflict. failure_details_safe must never contain
secrets, tokens or provider credentials (SPEC-HUB-0021 §6/§13)."""
secrets, tokens or provider credentials."""
idempotency_key = models.CharField(max_length=160, unique=True)
request_hash = models.CharField(max_length=128)
@@ -46,7 +46,7 @@ def provision_organization(
command: ProvisioningCommand,
operator: PlatformOperator,
) -> ProvisioningResult:
"""Single write boundary for tenant provisioning (SPEC-HUB-0021 §4).
"""Single write boundary for tenant provisioning.
Coordinates identity, audit and outbox in one
transaction (тариф удалён, ADR-CHATBALLS-0042 §2: организация создаётся без подписки). Tenant-owned rows are written under set_local_tenant(new_org.id)
@@ -201,7 +201,7 @@ def _provision_pending_owner(
command: ProvisioningCommand,
context: TenantContext,
) -> None:
# No password/dummy user is created (SPEC-HUB-0021 §8.2). An OWNER invitation
# No password/dummy user is created. An OWNER invitation
# is issued; the organization stays PENDING_OWNER until it is accepted.
issued = issue_invitation(
organization=org,
@@ -16,7 +16,7 @@ _REQUIRED_FIELDS = (
def parse_provisioning_body(
body: Any, *, idempotency_key: str, source: str
) -> tuple[ProvisioningCommand, str | None]:
"""Map the API body (SPEC-HUB-0021 §12) to a ProvisioningCommand. Returns
"""Map the API body to a ProvisioningCommand. Returns
(command, error_message). Validation is intentionally explicit (no DRF
serializers), mirroring identity/access_payloads.py."""
if not isinstance(body, dict):
+1 -1
View File
@@ -19,7 +19,7 @@ _IDEMPOTENCY_HEADER = "Idempotency-Key"
class OrganizationProvisionView(APIView):
"""POST <platform-host>/api/v1/organizations (SPEC-HUB-0021 §12).
"""POST <platform-host>/api/v1/organizations.
The first Platform API adapter: machine-to-machine provisioning via an
opaque platform token. Does not reuse tenant HasCapability (no membership
@@ -1,4 +1,4 @@
# C06 tenant provisioning DB boundary (SPEC-HUB-0021 §10, ADR-CHATBALLS-0029 §6).
# C06 tenant provisioning DB boundary (ADR-CHATBALLS-0029 §6).
#
# Provisioning runs on the platform connection (chatballs_runtime_platform) and must
# both INSERT a new Organization and create tenant-owned rows (departments, the
@@ -27,7 +27,7 @@ PLATFORM_POLICY = "chatballs_platform_tenant_provisioning"
def apply_grants(apps, schema_editor):
# 1. Platform role may create and update organizations (no RLS on this table;
# it has no organization_id column). DELETE stays with the schema/migration
# role per SPEC-HUB-0021 §14 (hard delete as compensation is forbidden).
# role (hard delete as compensation is forbidden).
schema_editor.execute(
"GRANT INSERT, UPDATE ON identity_organization TO chatballs_runtime_platform"
)
@@ -1,7 +1,7 @@
# Реестр демо-данных (identity_demodataset, identity_demorecord): tenant-таблицы
# с organization_id, RLS и гранты по образцу tenancy/0021. Платформенная роль
# получает те же права: мастер первого запуска ставит демо-набор в очередь на
# соединении platform (SPEC-HUB-0021 §10).
# соединении platform.
from django.db import migrations
TABLES = ("identity_demodataset", "identity_demorecord")
@@ -2,7 +2,7 @@
# после tenancy/0003, и ни одна миграция не выдала на них прав runtime-роли
# platform. В деплое backend-platform работает ролью chatballs_platform, поэтому
# уже проверка токена падала «permission denied for table platform_platformtoken»,
# а вместе с ней — весь POST /api/v1/organizations (SPEC-HUB-0021 §12). Тесты
# а вместе с ней — весь POST /api/v1/organizations. Тесты
# этого не видели: они ходят в базу владельцем кластера.
#
# Здесь роль platform получает DML на платформенные таблицы (без DELETE: токены
@@ -10,7 +10,7 @@
# нет»: проверку делает SECURITY DEFINER-функция от chatballs_schema (иначе
# политика ссылалась бы на свою же таблицу и Postgres отказал бы за рекурсию).
# После первой организации INSERT для app закрыт навсегда — создавать следующие
# по-прежнему может только роль platform (SPEC-HUB-0021 §10).
# по-прежнему может только роль platform.
from django.db import migrations
INGRESS_VIEWS = (
+2 -2
View File
@@ -57,7 +57,7 @@ class OrganizationStorageUsage(models.Model):
"""Authoritative storage_bytes usage counter for one organization.
``bytes_used`` is the committed total; ``reserved_bytes`` covers in-flight
uploads whose final size is not yet known (SPEC-HUB-0022 §10 reserve/finalize).
uploads whose final size is not yet known (reserve/finalize).
The effective usage against the quota is ``bytes_used + reserved_bytes``.
"""
@@ -88,7 +88,7 @@ class OrganizationStorageUsage(models.Model):
class StorageReservation(models.Model):
"""An in-flight storage_bytes reservation keyed by an idempotency token, so a
multi-step upload (SPEC-HUB-0022 §10) can reserve the expected size, finalize
multi-step upload can reserve the expected size, finalize
the actual size once the object is persisted, and release the reservation on
failure. A single reservation tracks one upload lifecycle.
"""
+2 -2
View File
@@ -1,7 +1,7 @@
"""Принудительный выбор соединения БД для блока кода.
Алиас ``platform`` (роль platform, права на создание организаций —
SPEC-HUB-0021) есть только у процессов, которым он нужен: у платформенной
Алиас ``platform`` (роль platform, права на создание
организаций) есть только у процессов, которым он нужен: у платформенной
поверхности и у воркера, захватывающего outbox всех организаций
(см. ``settings_database.build_databases``). У backend-app его нет: каталоги
входа и мастер первого запуска работают ролью app (tenancy/0032).
@@ -13,7 +13,7 @@ from chatballs.testing import system_tenant_context
class StorageReserveFinalizeReleaseTests(TestCase):
"""SPEC-HUB-0022 §10: reserve/finalize/release — идемпотентный технический
"""Reserve/finalize/release — идемпотентный технический
учёт занятого места. Лимитов больше нет (ADR-CHATBALLS-0042 §2): сервис никогда
не отказывает по квоте."""
@@ -208,9 +208,6 @@ CHATBALLS_AI_TURN_DEADLINE_SECONDS = int(
)
# Модель расшифровки голосовых (OpenAI-совместимый /audio/transcriptions).
# Managed-провайдер CustoAI удалён (ADR-CHATBALLS-0042 §3): AI — только через
# интеграцию организации (BYOK).
# Long-poll hold-time мессенджеров (сек). Держим малым: опрос идёт по
# подключениям последовательно в одном процессе, и hold-time каждого из них
# складывается в задержку приёма у остальных. Ответы AI от этого больше не
@@ -267,7 +264,7 @@ CHATBALLS_STORAGE_BACKEND, MEDIA_ROOT, STORAGES = build_storage_settings(
testing=TESTING,
)
# Публичный адрес Hub: абсолютные ссылки, уходящие клиентам (download вложений).
# Публичный адрес установки: абсолютные ссылки, уходящие клиентам (download вложений).
CHATBALLS_PUBLIC_BASE_URL = os.environ.get("CHATBALLS_PUBLIC_BASE_URL", "http://localhost:8000")
# Публичные порталы поддержки размещаются на отдельных хостах. Базовый домен
@@ -323,7 +320,7 @@ if (
or CHATBALLS_CALL_CONNECT_GRACE_SECONDS <= 0
or CHATBALLS_CALL_RECONNECT_GRACE_SECONDS <= 0
):
raise ImproperlyConfigured("HUB call token TTL values must be positive")
raise ImproperlyConfigured("Call token TTL values must be positive")
# ICE-серверы для WebRTC (SPEC-CHATBALLS-0013 §10): direct-first через STUN, TURN как
# fallback. Формат URL через запятую (stun:host:port / turn:host:3478?transport=udp).
@@ -633,7 +633,7 @@
background: var(--n-9);
}
/* Блокер «провайдер не выбран» (кадр G5, ADR-HUB-0017). */
/* Блокер «провайдер не выбран» (кадр G5). */
.agent-blocker {
display: flex;
@@ -53,7 +53,7 @@ import { useDialogKeyboardNav } from "./useDialogKeyboardNav";
import { useIncomingMessageSound } from "./useIncomingMessageSound";
import { t } from "../../i18n";
// Общий workspace диалогов (SPEC-HUB-0010 §8.2). Видимость inbox решает
// Общий workspace диалогов. Видимость inbox решает
// backend по группам (ADR-CHATBALLS-0043); страница параметризуется заголовком,
// placeholder поиска и правой панелью через render-prop. Список и история —
// серверные окна: ни то, ни другое целиком не запрашивается.
@@ -1,4 +1,4 @@
// Общие типы workspace диалогов (sales + support). SPEC-HUB-0010 §8.2:
// Общие типы workspace диалогов (sales + support):
// общий conversation workspace, не отдельная реализация под каждый отдел.
export type DialogMode = "ai" | "closed" | "operator" | "wait";
@@ -10,11 +10,11 @@ describe("webWidgetSnippet", () => {
});
it("builds the embed snippet from the current origin and channel code", async () => {
vi.stubGlobal("window", { location: { origin: "https://hub.example.com" } });
vi.stubGlobal("window", { location: { origin: "https://chatballs.example.com" } });
const { webWidgetSnippet } = await import("./model");
expect(webWidgetSnippet("wgt_demo")).toBe(
`<script src="https://hub.example.com/chat-widget.js" data-widget-key="wgt_demo" async></script>`,
`<script src="https://chatballs.example.com/chat-widget.js" data-widget-key="wgt_demo" async></script>`,
);
});
@@ -28,11 +28,11 @@ describe("webWidgetSnippet", () => {
});
it("uses the origin verbatim with a custom port", async () => {
vi.stubGlobal("window", { location: { origin: "https://hub.example.com:8443" } });
vi.stubGlobal("window", { location: { origin: "https://chatballs.example.com:8443" } });
const { webWidgetSnippet } = await import("./model");
expect(webWidgetSnippet("wgt_demo")).toBe(
`<script src="https://hub.example.com:8443/chat-widget.js" data-widget-key="wgt_demo" async></script>`,
`<script src="https://chatballs.example.com:8443/chat-widget.js" data-widget-key="wgt_demo" async></script>`,
);
});
});
@@ -109,7 +109,7 @@ export const fetchLlmProviders = () =>
response.items.filter((item) => item.kind === "LLM_PROVIDER")
);
// Разрешённые домены Web-виджета (SPEC-HUB-0010 §7.1). Пустой список в проде
// Разрешённые домены Web-виджета. Пустой список в проде
// запрещает все origin'ы, поэтому домены вводятся руками и обязательны.
// Принимаем три формы, которые понимает backend (webchat.services.origin_allowed):
// `example.com`, `*.example.com` и `https://example.com:8443`; `localhost` — для разработки.
@@ -135,7 +135,7 @@ export const formatAllowedOrigins = (origins: string[]): string => origins.join(
export const invalidAllowedOrigin = (origins: string[]): string | undefined =>
origins.find((item) => !ORIGIN_RULE.test(item));
// Публичный домен Hub для встраивания Web-виджета (SPEC-CHATBALLS-0003 §3).
// Публичный домен установки для встраивания Web-виджета (SPEC-CHATBALLS-0003 §3).
// Один frontend-образ работает на любом домене (ADR-CHATBALLS-0028 §runtime frontend):
// сниппет генерируется от текущего origin в рантайме, а не от build-time аргумента.
export function webWidgetSnippet(widgetKey: string): string {
+2 -2
View File
@@ -4,8 +4,8 @@ import { t } from "../i18n";
Общий словарь для всего приложения — раньше он лежал в демо-данных диалогов,
и продуктовые экраны каналов зависели от файла с примерами.
Провайдеры различаются точкой и коротким лейблом на своих токенах
(ADR-HUB-0013), а не бренд-логотипами. */
Провайдеры различаются точкой и коротким лейблом на своих токенах,
а не бренд-логотипами. */
export type ProviderKey = "EMAIL" | "MAX" | "TG" | "VK" | "WEB";
+2 -2
View File
@@ -14,8 +14,8 @@ body,
На основной странице виджета (встраиваемого) эти переменные не нужны, но экран
аудиозвонка из @chatballs/ui (audio-call.css) использует var(--font-sans) и радиусы,
которых в web-chat нет. Значения — системный font stack Ant Design и токены
радиусов из baseline tokens/typography.css + tokens/effects.css (ADR-HUB-0013:
внешние веб-шрифты не подключаются).
радиусов из baseline tokens/typography.css + tokens/effects.css (внешние
веб-шрифты не подключаются).
*/
:root {
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+1 -1
View File
@@ -3,7 +3,7 @@ import { defineConfig, loadEnv } from "vite";
export default defineConfig(({ mode }) => ({
plugins: [react()],
// Панель отдаётся с домена hub под путём /chat/ (nginx).
// Панель отдаётся с домена установки под путём /chat/ (nginx).
base: "/chat/",
build: {
rollupOptions: {
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# chatballs — единый deployment entrypoint Chatballs (ADR-HUB-0028 §CLI).
# chatballs — единый deployment entrypoint Chatballs (ADR-CHATBALLS-0028 §CLI).
# Команды этапа 1: doctor, deploy, status, logs.
# (install/setup-url/update/backup/restore/rollback — этап 2.)
#
+1 -1
View File
@@ -14,7 +14,7 @@ COPY apps/internal-ui ./apps/internal-ui
COPY apps/web-chat ./apps/web-chat
COPY packages ./packages
# Build-time домен не привязывается (ADR-HUB-0028 §10): один образ работает на любом
# Build-time домен не привязывается (ADR-CHATBALLS-0028 §10): один образ работает на любом
# домене; сниппет/WS/API выводятся от текущего origin в рантайме.
ARG VITE_API_BASE_URL=
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Клиент P2P-звонка: собственный WebSocket signaling Hub + RTCPeerConnection
* Клиент P2P-звонка: собственный WebSocket signaling + RTCPeerConnection
* (SPEC-CHATBALLS-0013 §9–10). Общий для internal-ui (оператор) и web-chat (клиент).
*
* Роли детерминированы: offer всегда создаёт STAFF (инициатор), CUSTOMER