📝 docs: идентификаторы проектных документов приведены к CHATBALLS

Документация переработана: действующее отделено от истории, отменённые
контуры (продажи, биллинг, managed AI, отделы, сущность Product) убраны из
действующих документов в архив.

Здесь — только кодовая часть: ссылки на документы в комментариях. Ссылки на
действующие документы переименованы ADR/SPEC/ARCH/BUS-HUB-NNNN →
*-CHATBALLS-NNNN (274 ссылки в 173 файлах). Ссылки на документы, ушедшие в
архив, намеренно сохранили прежний идентификатор: он совпадает с именем
архивного файла.

Логика не менялась — правки только в комментариях, докстрингах и одном
описании теста.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-08 18:13:33 +03:00
1 parent b09fcb6f10
commit e088ead06f
173 files changed
+2187 -1216

No files matched your search

@@ -3,7 +3,7 @@
Обе библиотеки — знания организации и статьи портала поддержки — прикрепляются
по одному правилу: недоступные агенту по отделу элементы не прикрепляются, но и
не роняют операцию, а возвращаются в `skipped_ids`. Так массовое действие над
смешанной выборкой остаётся предсказуемым (SPEC-HUB-0026 §5).
смешанной выборкой остаётся предсказуемым (SPEC-CHATBALLS-0026 §5).
"""
from collections.abc import Iterable
+3 -3
View File
@@ -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); код генерируется из имени и неизменен.
+1 -1
View File
@@ -1,4 +1,4 @@
"""Роуты единой сущности «Агент» (ADR-HUB-0041 §4)."""
"""Роуты единой сущности «Агент» (ADR-CHATBALLS-0041 §4)."""
from django.urls import path
@@ -1,4 +1,4 @@
"""HTTP-слой карточек агентов (/api/v1/agents/, ADR-HUB-0041 §4)."""
"""HTTP-слой карточек агентов (/api/v1/agents/, ADR-CHATBALLS-0041 §4)."""
from __future__ import annotations
+1 -1
View File
@@ -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): агент использует
# только явно выбранные и включённые знания, областей видимости нет.
+1 -1
View File
@@ -1,4 +1,4 @@
"""Извлечение текста из файловых вложений знаний (ADR-HUB-0023).
"""Извлечение текста из файловых вложений знаний (ADR-CHATBALLS-0023).
Поддерживаются текстовые форматы (md, txt и любой text/*), PDF (pypdf) и DOCX
(python-docx). Остальные форматы хранятся без индексации — extract_text вернёт
+1 -1
View File
@@ -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]
+1 -1
View File
@@ -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
@@ -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).
@@ -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:
@@ -1,4 +1,4 @@
# ADR-HUB-0023: плоские Знания с вложениями + инструкции агента из трёх частей.
# ADR-CHATBALLS-0023: плоские Знания с вложениями + инструкции агента из трёх частей.
# Добавляющая часть; перенос данных — 0003, снос старых моделей — 0004.
import uuid
@@ -1,4 +1,4 @@
# ADR-HUB-0023: перенос данных из версионируемых документов и релизов в
# ADR-CHATBALLS-0023: перенос данных из версионируемых документов и релизов в
# плоские Знания и поля агента.
#
# - KnowledgeDocument -> Knowledge (содержимое последней опубликованной версии,
@@ -1,4 +1,4 @@
# ADR-HUB-0023: снос версионируемых документов и релизов после переноса данных (0003).
# ADR-CHATBALLS-0023: снос версионируемых документов и релизов после переноса данных (0003).
import django.db.models.deletion
from django.db import migrations, models
@@ -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
@@ -1,4 +1,4 @@
# ADR-HUB-0041 §4: агент и канал — одна сущность. Каждому каналу без AIAgent
# ADR-CHATBALLS-0041 §4: агент и канал — одна сущность. Каждому каналу без AIAgent
# создаётся DRAFT-агент (AI не отвечает, поведение канала не меняется), чтобы
# карточка агента существовала для всех исторических каналов.
from django.db import migrations
@@ -1,4 +1,4 @@
# ADR-HUB-0042 §3: managed-режим CustoAI удалён вместе с тарифным контуром.
# ADR-CHATBALLS-0042 §3: managed-режим CustoAI удалён вместе с тарифным контуром.
# BYOK — единственный режим; поле credential_mode больше не нужно.
from django.db import migrations
+514 -257
View File
@@ -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}"
+1 -1
View File
@@ -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")
+32 -16
View File
@@ -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 <key>,␍
response with choices[0].message.content and usage). The only difference␍
from OpenRouter is the absence of a model catalog — the model identifier␍
is supplied by the integration owner as free text and read at runtime␍
(ADR-HUB-0034 §4). The distinct name lets routing and accounting tell the␍
two BYOK modes apart.␍
"""␍
␍
name = "custom"␍
from chatballs.ai.provider.openrouter import OpenRouterProvider
class CustomProvider(OpenRouterProvider):
"""Generic OpenAI-compatible BYOK adapter (ADR-CHATBALLS-0034).
Reuses the OpenRouter HTTP layer verbatim: the contract is identical
(POST /chat/completions, POST /embeddings, Authorization: Bearer <key>,
response with choices[0].message.content and usage). The only difference
from OpenRouter is the absence of a model catalog — the model identifier
is supplied by the integration owner as free text and read at runtime
(ADR-CHATBALLS-0034 §4). The distinct name lets routing and accounting tell the
two BYOK modes apart.
"""
name = "custom"
@@ -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
+216 -108
View File
@@ -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 <key>.␍
␍
This module owns the HTTP transport and response parsing so the three adapters␍
do not duplicate it. Adapters stay responsible for their own product semantics␍
(name, cost handling, catalog). Stdlib only — no third-party HTTP client.␍
"""␍
␍
from __future__ import annotations␍
␍
import http.client␍
import json␍
import urllib.error␍
import urllib.request␍
␍
from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, ProviderError␍
from chatballs.integrations.proxy import build_opener␍
␍
␍
def post_json(*, base_url: str, path: str, api_key: str, payload: dict, timeout: float, proxy_url: str = "") -> dict:␍
"""POST a JSON body to {base_url}{path} with Bearer auth; return parsed JSON.␍
␍
Translates transport errors into ProviderError so callers can apply the␍
circuit breaker uniformly.␍
"""␍
request = urllib.request.Request(␍
f"{base_url.rstrip('/')}{path}",␍
data=json.dumps(payload).encode("utf-8"),␍
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},␍
method="POST",␍
)␍
try:␍
with build_opener(proxy_url).open(request, timeout=timeout) as response:␍
return json.loads(response.read().decode("utf-8"))␍
# http.client.HTTPException covers IncompleteRead/BadStatusLine (dropped reply)␍
# — those are not OSError, so they would slip past ProviderError otherwise.␍
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:␍
raise ProviderError(f"{type(error).__name__}: {error}") from error␍
␍
␍
def get_json(*, base_url: str, path: str, api_key: str, timeout: float, proxy_url: str = "") -> dict:␍
"""GET {base_url}{path} with Bearer auth; return parsed JSON (used for /models)."""␍
request = urllib.request.Request(␍
f"{base_url.rstrip('/')}{path}",␍
headers={"Authorization": f"Bearer {api_key}"},␍
method="GET",␍
)␍
try:␍
with build_opener(proxy_url).open(request, timeout=timeout) as response:␍
body = response.read().decode("utf-8")␍
return json.loads(body) if body else {}␍
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:␍
raise ProviderError(f"{type(error).__name__}: {error}") from error␍
␍
␍
def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage], model: str,␍
timeout: float, proxy_url: str = "", params: dict | None = None,␍
include_cost: bool = False) -> ChatResult:␍
"""POST /chat/completions and parse the OpenAI-shaped response.␍
␍
`include_cost=True` requests the OpenRouter-style usage.include flag and reads␍
usage.cost (USD, converted to micros). Providers that do not report cost␍
(Custom, CustoAI) leave cost_micros=0; ai/pricing.py computes a fallback.␍
"""␍
payload: dict = {␍
"model": model,␍
"messages": [{"role": m.role, "content": m.content} for m in messages],␍
**(params or {}),␍
}␍
if include_cost:␍
payload["usage"] = {"include": True}␍
data = post_json(base_url=base_url, path="/chat/completions", api_key=api_key,␍
payload=payload, timeout=timeout, proxy_url=proxy_url)␍
try:␍
text = data["choices"][0]["message"]["content"]␍
except (KeyError, IndexError, TypeError) as error:␍
raise ProviderError(f"Unexpected provider response: {error}") from error␍
usage = data.get("usage") or {}␍
cost = usage.get("cost")␍
return ChatResult(␍
text=text,␍
model=data.get("model", model),␍
prompt_tokens=int(usage.get("prompt_tokens", 0)),␍
completion_tokens=int(usage.get("completion_tokens", 0)),␍
cost_micros=round(float(cost) * 1_000_000) if cost is not None else 0,␍
)␍
␍
␍
def embeddings(*, base_url: str, api_key: str, texts: list[str], model: str,␍
timeout: float, proxy_url: str = "") -> list[EmbeddingResult]:␍
"""POST /embeddings and parse the OpenAI-shaped response."""␍
data = post_json(base_url=base_url, path="/embeddings", api_key=api_key,␍
payload={"model": model, "input": texts}, timeout=timeout, proxy_url=proxy_url)␍
try:␍
items = data["data"]␍
except (KeyError, TypeError) as error:␍
raise ProviderError(f"Unexpected provider response: {error}") from error␍
usage = data.get("usage") or {}␍
per_text = int(usage.get("prompt_tokens", 0)) // max(1, len(texts))␍
return [EmbeddingResult(vector=item["embedding"], model=data.get("model", model), tokens=per_text) for item in items]␍
"""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 <key>.
This module owns the HTTP transport and response parsing so the three adapters
do not duplicate it. Adapters stay responsible for their own product semantics
(name, cost handling, catalog). Stdlib only — no third-party HTTP client.
"""
from __future__ import annotations
import http.client
import json
import urllib.error
import urllib.request
from chatballs.ai.provider.base import ChatMessage, ChatResult, EmbeddingResult, ProviderError
from chatballs.integrations.proxy import build_opener
def post_json(*, base_url: str, path: str, api_key: str, payload: dict, timeout: float, proxy_url: str = "") -> dict:
"""POST a JSON body to {base_url}{path} with Bearer auth; return parsed JSON.
Translates transport errors into ProviderError so callers can apply the
circuit breaker uniformly.
"""
request = urllib.request.Request(
f"{base_url.rstrip('/')}{path}",
data=json.dumps(payload).encode("utf-8"),
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
method="POST",
)
try:
with build_opener(proxy_url).open(request, timeout=timeout) as response:
return json.loads(response.read().decode("utf-8"))
# http.client.HTTPException covers IncompleteRead/BadStatusLine (dropped reply)
# — those are not OSError, so they would slip past ProviderError otherwise.
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
raise ProviderError(f"{type(error).__name__}: {error}") from error
def get_json(*, base_url: str, path: str, api_key: str, timeout: float, proxy_url: str = "") -> dict:
"""GET {base_url}{path} with Bearer auth; return parsed JSON (used for /models)."""
request = urllib.request.Request(
f"{base_url.rstrip('/')}{path}",
headers={"Authorization": f"Bearer {api_key}"},
method="GET",
)
try:
with build_opener(proxy_url).open(request, timeout=timeout) as response:
body = response.read().decode("utf-8")
return json.loads(body) if body else {}
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException, json.JSONDecodeError) as error:
raise ProviderError(f"{type(error).__name__}: {error}") from error
def chat_completions(*, base_url: str, api_key: str, messages: list[ChatMessage], model: str,
timeout: float, proxy_url: str = "", params: dict | None = None,
include_cost: bool = False) -> ChatResult:
"""POST /chat/completions and parse the OpenAI-shaped response.
`include_cost=True` requests the OpenRouter-style usage.include flag and reads
usage.cost (USD, converted to micros). Providers that do not report cost
(Custom, CustoAI) leave cost_micros=0; ai/pricing.py computes a fallback.
"""
payload: dict = {
"model": model,
"messages": [{"role": m.role, "content": m.content} for m in messages],
**(params or {}),
}
if include_cost:
payload["usage"] = {"include": True}
data = post_json(base_url=base_url, path="/chat/completions", api_key=api_key,
payload=payload, timeout=timeout, proxy_url=proxy_url)
try:
text = data["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as error:
raise ProviderError(f"Unexpected provider response: {error}") from error
usage = data.get("usage") or {}
cost = usage.get("cost")
return ChatResult(
text=text,
model=data.get("model", model),
prompt_tokens=int(usage.get("prompt_tokens", 0)),
completion_tokens=int(usage.get("completion_tokens", 0)),
cost_micros=round(float(cost) * 1_000_000) if cost is not None else 0,
)
def embeddings(*, base_url: str, api_key: str, texts: list[str], model: str,
timeout: float, proxy_url: str = "") -> list[EmbeddingResult]:
"""POST /embeddings and parse the OpenAI-shaped response."""
data = post_json(base_url=base_url, path="/embeddings", api_key=api_key,
payload={"model": model, "input": texts}, timeout=timeout, proxy_url=proxy_url)
try:
items = data["data"]
except (KeyError, TypeError) as error:
raise ProviderError(f"Unexpected provider response: {error}") from error
usage = data.get("usage") or {}
per_text = int(usage.get("prompt_tokens", 0)) // max(1, len(texts))
return [EmbeddingResult(vector=item["embedding"], model=data.get("model", model), tokens=per_text) for item in items]
@@ -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.
"""
@@ -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: индексация знаний пишет
фрагменты без эмбеддингов, ретривер работает лексически.
"""
@@ -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.
+1 -1
View File
@@ -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"))
+3 -3
View File
@@ -20,7 +20,7 @@ MESSENGER_STYLE_GUARD = (
)
# Протокол передачи оператору: модель добавляет технический токен, система его
# ловит, ставит диалог в очередь и уведомляет операторов (ADR-HUB-0003).
# ловит, ставит диалог в очередь и уведомляет операторов (ADR-CHATBALLS-0003).
HANDOFF_TOKEN = "<<HANDOFF>>"
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)
+1 -1
View File
@@ -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"})
@@ -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/")
@@ -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")
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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
+1 -1
View File
@@ -1,4 +1,4 @@
"""WebSocket signaling звонков (SPEC-HUB-0013 §9).
"""WebSocket signaling звонков (SPEC-CHATBALLS-0013 §9).
Правила:
- аутентификация первым сообщением {"type": "auth", "token": <call access token>}
@@ -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, а ссылка
+1 -1
View File
@@ -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 "Клиент принял приглашение на звонок"
+1 -1
View File
@@ -1,4 +1,4 @@
"""Серверные таймауты звонков (SPEC-HUB-0013 §5): истечение приглашения и
"""Серверные таймауты звонков (SPEC-CHATBALLS-0013 §5): истечение приглашения и
зависшее соединение обрабатываются воркером, а не браузером клиента."""
from datetime import timedelta
+1 -1
View File
@@ -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 или медиапоток. Позволяет считать
+1 -1
View File
@@ -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 —
@@ -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, поздние события игнорируются."""
+1 -1
View File
@@ -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 = "<expiry_unix_ts>:<label>"
@@ -1,4 +1,4 @@
"""Авторизация операций над каналами (SPEC-HUB-0031 §3).
"""Авторизация операций над каналами (SPEC-CHATBALLS-0031 §3).
После упразднения отделов и scope-модели проверки сведены к роли: OWNER и
ADMIN управляют каналами, EMPLOYEE их не видит и не меняет. Названия helpers
@@ -1,4 +1,4 @@
# ADR-HUB-0023: AI-поведение канала (модель, промпт) переехало на агента.
# ADR-CHATBALLS-0023: AI-поведение канала (модель, промпт) переехало на агента.
# Выполняется после ai/0003, которая переносит system_prompt/model в агентов.
from django.db import migrations
@@ -6,7 +6,7 @@
#
# Решение владельца по унаследованным каналам: выключить checkout и
# attribution. Продуктовая часть инвариантов (P3-P5) снята вместе с сущностью
# Product (ADR-HUB-0041).
# Product (ADR-CHATBALLS-0041).
from django.db import migrations, models
+4 -4
View File
@@ -2,21 +2,21 @@ from django.db import models
# Канал обработки — якорь AI-контекста (ADR-HUB-0019). Группа видимости и
# ссылка на провайдер-интеграцию. Поведение AI (модель, инструкции, знания)
# живёт на агенте канала (ADR-HUB-0023).
# живёт на агенте канала (ADR-CHATBALLS-0023).
class Channel(models.Model):
organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="channels")
code = models.SlugField(max_length=64)
name = models.CharField(max_length=255)
# Группа видимости (ADR-HUB-0043): новые диалоги канала попадают в неё.
# Группа видимости (ADR-CHATBALLS-0043): новые диалоги канала попадают в неё.
# NULL — диалоги видны всем сотрудникам.
group = models.ForeignKey("identity.EmployeeGroup", on_delete=models.SET_NULL, related_name="channels", null=True, blank=True)
# LLM-провайдер канала (ADR-HUB-0020).
# LLM-провайдер канала (ADR-CHATBALLS-0020).
provider_integration = models.ForeignKey("integrations.Integration", on_delete=models.PROTECT, related_name="channels", null=True, blank=True)
is_active = models.BooleanField(default=True)
# Политика канала: остаток от домена продаж — коммерческие флаги всегда
# выключены (ADR-HUB-0041/0045), анонимные сессии и самозаявленный контакт
# выключены (ADR-CHATBALLS-0041/0045), анонимные сессии и самозаявленный контакт
# используются веб-виджетом и порталом.
allow_anonymous_sessions = models.BooleanField(default=True)
allow_self_reported_contact = models.BooleanField(default=True)
+206 -103
View File
@@ -1,103 +1,206 @@
"""Политика канала и её инварианты (SPEC-HUB-0027 §3.2, ADR-HUB-0037 §7).␍
␍
Источник истины — булевы поля `Channel`. Продуктовой идентичности больше нет␍
(ADR-HUB-0045: сущность `Product` удалена), поэтому коммерческие действия и␍
attribution запрещены безусловно. Проверяются по итоговому состоянию, а не по␍
переданным полям, — частичное применение запрещено.␍
"""␍
␍
from __future__ import annotations␍
␍
from dataclasses import dataclass, fields, replace␍
␍
from django.core.exceptions import ValidationError␍
␍
from chatballs.channels.models import Channel␍
␍
POLICY_FIELDS = (␍
"allow_anonymous_sessions",␍
"allow_self_reported_contact",␍
"allow_sales_attribution",␍
"allow_checkout_actions",␍
)␍
␍
# Ключ payload -> имя поля модели (SPEC §6.1).␍
POLICY_API_FIELDS = {␍
"allowAnonymousSessions": "allow_anonymous_sessions",␍
"allowSelfReportedContact": "allow_self_reported_contact",␍
"allowSalesAttribution": "allow_sales_attribution",␍
"allowCheckoutActions": "allow_checkout_actions",␍
}␍
␍
␍
@dataclass(frozen=True, slots=True)␍
class ChannelPolicy:␍
allow_anonymous_sessions: bool␍
allow_self_reported_contact: bool␍
allow_sales_attribution: bool␍
allow_checkout_actions: bool␍
␍
@classmethod␍
def from_channel(cls, channel: Channel) -> ChannelPolicy:␍
return cls(**{name: getattr(channel, name) for name in POLICY_FIELDS})␍
␍
def replace_fields(self, changes: dict[str, bool]) -> ChannelPolicy:␍
return replace(self, **changes)␍
␍
def as_model_fields(self) -> dict[str, bool]:␍
return {field.name: getattr(self, field.name) for field in fields(self)}␍
␍
def as_payload(self) -> dict[str, bool]:␍
return {key: getattr(self, name) for key, name in POLICY_API_FIELDS.items()}␍
␍
␍
@dataclass(frozen=True, slots=True)␍
class PolicyViolation:␍
rule: str␍
field: str␍
detail: str␍
␍
def payload(self) -> dict[str, str]:␍
return {"rule": self.rule, "field": self.field, "detail": self.detail}␍
␍
␍
def policy_violations(*, policy: ChannelPolicy) -> tuple[PolicyViolation, ...]:␍
"""Нарушения P1-P2 для итогового состояния канала (SPEC §3.2)."""␍
violations: list[PolicyViolation] = []␍
if policy.allow_checkout_actions:␍
violations.append(␍
PolicyViolation(␍
"P1",␍
"allowCheckoutActions",␍
"Коммерческие действия недоступны",␍
)␍
)␍
if policy.allow_sales_attribution:␍
violations.append(␍
PolicyViolation(␍
"P2",␍
"allowSalesAttribution",␍
"Attribution недоступна",␍
)␍
)␍
return tuple(violations)␍
␍
␍
def require_valid_policy(*, policy: ChannelPolicy) -> None:␍
violations = policy_violations(policy=policy)␍
if violations:␍
raise PolicyInvariantError(violations)␍
␍
␍
class PolicyInvariantError(ValidationError):␍
"""Отклонение целиком: канал не сохраняется ни в каком виде."""␍
␍
def __init__(self, violations: tuple[PolicyViolation, ...]) -> None:␍
self.violations = violations␍
super().__init__("; ".join(violation.detail for violation in violations))␍
␍
def payload(self) -> dict[str, object]:␍
return {␍
"detail": "; ".join(violation.detail for violation in self.violations),␍
"violations": [violation.payload() for violation in self.violations],␍
}␍
"""Политика канала и её инварианты (SPEC-HUB-0027 §3.2, ADR-HUB-0037 §7).
Источник истины — булевы поля `Channel`. Продуктовой идентичности больше нет
(ADR-CHATBALLS-0045: сущность `Product` удалена), поэтому коммерческие действия и
attribution запрещены безусловно. Проверяются по итоговому состоянию, а не по
переданным полям, — частичное применение запрещено.
"""
from __future__ import annotations
from dataclasses import dataclass, fields, replace
from django.core.exceptions import ValidationError
from chatballs.channels.models import Channel
POLICY_FIELDS = (
"allow_anonymous_sessions",
"allow_self_reported_contact",
"allow_sales_attribution",
"allow_checkout_actions",
)
# Ключ payload -> имя поля модели (SPEC §6.1).
POLICY_API_FIELDS = {
"allowAnonymousSessions": "allow_anonymous_sessions",
"allowSelfReportedContact": "allow_self_reported_contact",
"allowSalesAttribution": "allow_sales_attribution",
"allowCheckoutActions": "allow_checkout_actions",
}
@dataclass(frozen=True, slots=True)
class ChannelPolicy:
allow_anonymous_sessions: bool
allow_self_reported_contact: bool
allow_sales_attribution: bool
allow_checkout_actions: bool
@classmethod
def from_channel(cls, channel: Channel) -> ChannelPolicy:
return cls(**{name: getattr(channel, name) for name in POLICY_FIELDS})
def replace_fields(self, changes: dict[str, bool]) -> ChannelPolicy:
return replace(self, **changes)
def as_model_fields(self) -> dict[str, bool]:
return {field.name: getattr(self, field.name) for field in fields(self)}
def as_payload(self) -> dict[str, bool]:
return {key: getattr(self, name) for key, name in POLICY_API_FIELDS.items()}
@dataclass(frozen=True, slots=True)
class PolicyViolation:
rule: str
field: str
detail: str
def payload(self) -> dict[str, str]:
return {"rule": self.rule, "field": self.field, "detail": self.detail}
def policy_violations(*, policy: ChannelPolicy) -> tuple[PolicyViolation, ...]:
"""Нарушения P1-P2 для итогового состояния канала (SPEC §3.2)."""
violations: list[PolicyViolation] = []
if policy.allow_checkout_actions:
violations.append(
PolicyViolation(
"P1",
"allowCheckoutActions",
"Коммерческие действия недоступны",
)
)
if policy.allow_sales_attribution:
violations.append(
PolicyViolation(
"P2",
"allowSalesAttribution",
"Attribution недоступна",
)
)
return tuple(violations)
def require_valid_policy(*, policy: ChannelPolicy) -> None:
violations = policy_violations(policy=policy)
if violations:
raise PolicyInvariantError(violations)
class PolicyInvariantError(ValidationError):
"""Отклонение целиком: канал не сохраняется ни в каком виде."""
def __init__(self, violations: tuple[PolicyViolation, ...]) -> None:
self.violations = violations
super().__init__("; ".join(violation.detail for violation in violations))
def payload(self) -> dict[str, object]:
return {
"detail": "; ".join(violation.detail for violation in self.violations),
"violations": [violation.payload() for violation in self.violations],
}
+1 -1
View File
@@ -40,7 +40,7 @@ def channels_in_organization(context: TenantContext) -> QuerySet[Channel]:
def channels_for_context(
context: TenantContext, *, capability: str = CHANNELS_VIEW
) -> QuerySet[Channel]:
"""Каналы организации, видимые актору: доступ ролевой (SPEC-HUB-0031 §3),
"""Каналы организации, видимые актору: доступ ролевой (SPEC-CHATBALLS-0031 §3),
у EMPLOYEE нет channels.view — список пуст."""
queryset = _with_relations(
Channel.objects.filter(organization_id=context.organization_id)
@@ -1,7 +1,7 @@
"""Real clients list (contacts + their conversations).
A "client" is a Contact. Commerce data was removed with the sales domain
(ADR-HUB-0041) — no orders or revenue here.
(ADR-CHATBALLS-0041) — no orders or revenue here.
"""
from __future__ import annotations
@@ -175,7 +175,7 @@ def client_detail(organization_id: int, contact_id: int) -> dict:
"externalUserId": identity.external_user_id,
"username": identity.username,
"createdAt": identity.created_at.isoformat(),
# Подтверждённой считается идентичность, отдавшая телефон (ADR-HUB-0006).
# Подтверждённой считается идентичность, отдавшая телефон (ADR-CHATBALLS-0006).
"phoneVerifiedAt": identity.phone_verified_at.isoformat() if identity.phone_verified_at else None,
}
for identity in identity_qs.order_by("created_at")
@@ -271,7 +271,7 @@ def _merges(organization_id: int, contact: Contact) -> list[dict]:
def _duplicate_candidate(organization_id: int, contact: Contact) -> dict | None:
"""Другой контакт с тем же телефоном. Автоматически ничего не объединяем
(ADR-HUB-0006) — это только предложение владельцу."""
(ADR-CHATBALLS-0006) — это только предложение владельцу."""
if not contact.phone:
return None
other = (
@@ -292,6 +292,6 @@ def _duplicate_candidate(organization_id: int, contact: Contact) -> dict | None:
"sources": sorted({identity.connection.provider for identity in identities}),
"phone": other.phone,
# Однозначным совпадение считается, только если телефон подтверждён
# подключением хотя бы у одной стороны (ADR-HUB-0006).
# подключением хотя бы у одной стороны (ADR-CHATBALLS-0006).
"phoneVerified": any(identity.phone_verified_at is not None for identity in identities),
}
@@ -1,4 +1,4 @@
"""Объединение и разъединение контактов (ADR-HUB-0006).
"""Объединение и разъединение контактов (ADR-CHATBALLS-0006).
Автоматически идентичности разных подключений не объединяются. Объединение —
ручная операция владельца: требует причины, переносит идентичности и диалоги,
@@ -206,7 +206,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
if contact.phone != inbound.phone:
contact.phone = inbound.phone
contact.save(update_fields=["phone"])
# Телефон подтвердило именно это подключение (ADR-HUB-0006).
# Телефон подтвердило именно это подключение (ADR-CHATBALLS-0006).
if identity.phone_verified_at is None:
identity.phone_verified_at = timezone.now()
identity.save(update_fields=["phone_verified_at"])
@@ -223,13 +223,13 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
)
is_new = conversation is None
if conversation is None:
# ADR-HUB-0002: новое сообщение после закрытия создаёт новый диалог,
# ADR-CHATBALLS-0002: новое сообщение после закрытия создаёт новый диалог,
# связанный с предыдущим для навигации по истории.
previous = Conversation.objects.filter(channel=channel, contact=contact).order_by("-created_at").first()
conversation = Conversation.objects.create(
organization=channel.organization,
channel=channel,
# Диалог наследует группу канала при создании (ADR-HUB-0043 §3).
# Диалог наследует группу канала при создании (ADR-CHATBALLS-0043 §3).
group=channel.group,
connection=integration,
contact=contact,
@@ -274,7 +274,7 @@ def ingest_inbound(integration, inbound: InboundMessage) -> None:
update_fields.extend(["control_mode", "expected_responder"])
if inbound.thread_meta:
# Email: Message-ID последнего входящего — для ответа в тред;
# тема диалога фиксируется по первому письму (ADR-HUB-0035).
# тема диалога фиксируется по первому письму (ADR-CHATBALLS-0035).
current = conversation.transport_meta or {}
conversation.transport_meta = {
**current,
@@ -1,25 +1,50 @@
import logging
from datetime import timedelta
from django.utils import timezone
from chatballs.conversations.models import Conversation, LifecycleState
logger = logging.getLogger(__name__)
# ADR-HUB-0002: семь дней без активности переводят OPEN в CLOSED.
AUTOCLOSE_DAYS = 7
def close_stale_conversations(context, days: int = AUTOCLOSE_DAYS) -> int:
cutoff = timezone.now() - timedelta(days=days)
closed = Conversation.objects.filter(
organization=context.organization,
lifecycle=LifecycleState.OPEN,
last_activity_at__lte=cutoff,
).update(
lifecycle=LifecycleState.CLOSED
)
if closed:
logger.info("Auto-closed %s stale conversations (>%sd inactive)", closed, days)
return closed
import logging
from datetime import timedelta
from django.utils import timezone
from chatballs.conversations.models import Conversation, LifecycleState
logger = logging.getLogger(__name__)
# ADR-CHATBALLS-0002: семь дней без активности переводят OPEN в CLOSED.
AUTOCLOSE_DAYS = 7
def close_stale_conversations(context, days: int = AUTOCLOSE_DAYS) -> int:
cutoff = timezone.now() - timedelta(days=days)
closed = Conversation.objects.filter(
organization=context.organization,
lifecycle=LifecycleState.OPEN,
last_activity_at__lte=cutoff,
).update(
lifecycle=LifecycleState.CLOSED
)
if closed:
logger.info("Auto-closed %s stale conversations (>%sd inactive)", closed, days)
return closed
@@ -25,7 +25,7 @@ class Contact(models.Model):
description = models.TextField(blank=True, default="")
company = models.CharField(max_length=160, blank=True, default="")
city = models.CharField(max_length=120, blank=True, default="")
# Контакт, в который этот был объединён (ADR-HUB-0006). Строка не удаляется:
# Контакт, в который этот был объединён (ADR-CHATBALLS-0006). Строка не удаляется:
# объединение обратимо, поэтому исходный контакт остаётся для разъединения.
merged_into = models.ForeignKey(
"self",
@@ -41,7 +41,7 @@ class Contact(models.Model):
class ContactMerge(models.Model):
"""Журнал объединения контактов (ADR-HUB-0006).
"""Журнал объединения контактов (ADR-CHATBALLS-0006).
Хранит, что именно переехало, чтобы объединение можно было развернуть
обратно: перенесённые идентичности и диалоги и поля карточки, которые были
@@ -70,7 +70,7 @@ class ContactMerge(models.Model):
class ConnectionIdentity(TenantRelationModel):
tenant_relation_fields = ("contact", "connection")
# Устойчивая идентичность контакта внутри конкретного подключения (ADR-HUB-0006).
# Устойчивая идентичность контакта внутри конкретного подключения (ADR-CHATBALLS-0006).
contact = models.ForeignKey(Contact, on_delete=models.CASCADE, related_name="identities")
connection = models.ForeignKey("integrations.Integration", on_delete=models.PROTECT, related_name="identities")
external_user_id = models.CharField(max_length=128)
@@ -79,7 +79,7 @@ class ConnectionIdentity(TenantRelationModel):
username = models.CharField(max_length=128, blank=True)
# Когда подключение отдало подтверждённый телефон (кнопка «поделиться
# контактом»). Только такая идентичность считается подтверждённой
# (ADR-HUB-0006) — колонка «Статус» на вкладке «Идентификаторы».
# (ADR-CHATBALLS-0006) — колонка «Статус» на вкладке «Идентификаторы».
phone_verified_at = models.DateTimeField(null=True, blank=True, db_default=None)
created_at = models.DateTimeField(auto_now_add=True)
@@ -149,17 +149,17 @@ class Conversation(models.Model):
connection = models.ForeignKey("integrations.Integration", on_delete=models.PROTECT, related_name="conversations", null=True, blank=True)
# Единственный источник identity диалога — контакт. Авторизованный
# in-product клиент (SupportIdentitySnapshot) удалён вместе с сущностью
# Product (ADR-HUB-0045).
# Product (ADR-CHATBALLS-0045).
contact = models.ForeignKey(Contact, on_delete=models.PROTECT, related_name="conversations", null=True, blank=True)
# Внешний идентификатор чата (для отправки ответа в канал).
external_chat_id = models.CharField(max_length=128, blank=True)
# Транспортная мета диалога (ADR-HUB-0035): для email — тема исходного
# Транспортная мета диалога (ADR-CHATBALLS-0035): для email — тема исходного
# письма и Message-ID последнего входящего (тредирование Re:/In-Reply-To).
transport_meta = models.JSONField(default=dict, blank=True)
lifecycle = models.CharField(max_length=16, choices=LifecycleState.choices, default=LifecycleState.OPEN)
control_mode = models.CharField(max_length=16, choices=ControlMode.choices, default=ControlMode.AI)
expected_responder = models.CharField(max_length=16, choices=ExpectedResponder.choices, default=ExpectedResponder.AI)
# Группа видимости (ADR-HUB-0043): наследуется от group агента/канала при
# Группа видимости (ADR-CHATBALLS-0043): наследуется от group агента/канала при
# создании, переносится вручную. NULL — диалог виден всем сотрудникам.
group = models.ForeignKey(
"identity.EmployeeGroup",
@@ -168,7 +168,7 @@ class Conversation(models.Model):
null=True,
blank=True,
)
# «Ответственный» (ADR-HUB-0043): видит диалог независимо от групп.
# «Ответственный» (ADR-CHATBALLS-0043): видит диалог независимо от групп.
assigned_operator = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="assigned_conversations")
# Дизайн-базлайн v2: приоритет, метки и заметка оператора.
priority = models.CharField(
@@ -70,7 +70,7 @@ class ClientDetailView(ConversationViewBase):
class ClientMergeView(ConversationViewBase):
"""Объединение контактов и обратное разъединение (ADR-HUB-0006).
"""Объединение контактов и обратное разъединение (ADR-CHATBALLS-0006).
Доступно только владельцу, требует причины и полностью аудируется;
предложение объединения на карточке видят и администраторы.
@@ -27,7 +27,7 @@ def conversations_for_context(context: TenantContext) -> QuerySet[Conversation]:
def apply_conversation_visibility(
queryset: QuerySet[Conversation], context: TenantContext
) -> QuerySet[Conversation]:
"""Видимость диалогов (ADR-HUB-0043 §4): OWNER/ADMIN — все; сотрудник —
"""Видимость диалогов (ADR-CHATBALLS-0043 §4): OWNER/ADMIN — все; сотрудник —
диалоги своих групп + без группы + где он ответственный."""
scope = conversation_visibility(context.membership)
if scope is None:
@@ -102,7 +102,7 @@ def _contact_email(conversation: Conversation) -> str:
def _conversation_history(conversation: Conversation) -> list[Conversation]:
# Цепочка прошлых обращений того же контакта (ADR-HUB-0002).
# Цепочка прошлых обращений того же контакта (ADR-CHATBALLS-0002).
qs = Conversation.objects.filter(contact_id=conversation.contact_id)
return list(
qs.exclude(id=conversation.id)
+410 -205
View File
@@ -1,205 +1,410 @@
from django.db import transaction
from django.utils import timezone
from chatballs.conversations import transports
from chatballs.conversations.models import (
ConnectionIdentity,
Conversation,
ControlMode,
ExpectedResponder,
LifecycleState,
Message,
MessageAuthor,
MessageKind,
)
from chatballs.identity.models import EmployeeRole
from chatballs.integrations.models import IntegrationProvider
from chatballs.tenancy.context import TenantContext
CONTACT_REQUEST_TEXT = "Поделитесь, пожалуйста, контактом — нажмите кнопку ниже."
CONTACT_REQUEST_TEXT_WEB = "Поделитесь, пожалуйста, номером телефона."
class ClaimError(Exception):
pass
def _require_open(conversation: Conversation) -> None:
if conversation.lifecycle != LifecycleState.OPEN:
raise ClaimError("Диалог закрыт")
def _operator_label(operator) -> str:
return getattr(operator, "full_name", "") or operator.email
@transaction.atomic
def claim_conversation(*, context: TenantContext, conversation_id: int) -> Conversation:
# Атомарный перехват у AI (ADR-HUB-0003): только один оператор забирает диалог.
conversation = Conversation.objects.select_for_update().get(
id=conversation_id, organization=context.organization
)
return claim_locked_conversation(context=context, conversation=conversation)
def claim_locked_conversation(*, context: TenantContext, conversation: Conversation) -> Conversation:
"""Claim an already locked conversation inside the caller's transaction."""
operator = context.actor_user
if operator is None or conversation.organization_id != context.organization_id:
raise ClaimError("Диалог недоступен")
_require_open(conversation)
# Владелец (OWNER) может перехватить диалог у любого оператора; прочие сотрудники
# не могут забрать диалог, уже назначенный другому оператору.
is_owner = context.membership is not None and context.membership.role == EmployeeRole.OWNER
if (
not is_owner
and conversation.control_mode == ControlMode.HUMAN
and conversation.assigned_operator_id
and conversation.assigned_operator_id != operator.id
):
raise ClaimError("Диалог уже ведёт другой оператор")
conversation.control_mode = ControlMode.HUMAN
conversation.assigned_operator = operator
conversation.expected_responder = ExpectedResponder.OPERATOR
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
text=f"Оператор {_operator_label(operator)} перехватил диалог",
)
return conversation
@transaction.atomic
def release_to_ai(*, context: TenantContext, conversation_id: int) -> Conversation:
conversation = Conversation.objects.select_for_update().get(
id=conversation_id, organization=context.organization
)
_require_open(conversation)
agent = getattr(conversation.channel, "ai_agent", None)
if agent is None or not agent.is_active:
raise ClaimError("У канала нет активного AI-агента")
conversation.control_mode = ControlMode.AI
conversation.assigned_operator = None
conversation.expected_responder = ExpectedResponder.AI
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="Диалог возвращён AI")
return conversation
@transaction.atomic
def return_to_queue(*, context: TenantContext, conversation_id: int) -> Conversation:
# Оператор возвращает диалог в общую очередь (ADR-HUB-0003): снят с себя, ждёт оператора.
conversation = Conversation.objects.select_for_update().get(
id=conversation_id, organization=context.organization
)
_require_open(conversation)
conversation.control_mode = ControlMode.PAUSED
conversation.assigned_operator = None
conversation.expected_responder = ExpectedResponder.OPERATOR
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="Диалог возвращён в очередь")
return conversation
def post_operator_message(
*, context: TenantContext, conversation: Conversation, text: str
) -> Message:
operator = context.actor_user
if operator is None or conversation.organization_id != context.organization_id:
raise Conversation.DoesNotExist
_require_open(conversation)
message = Message.objects.create(
conversation=conversation, author_type=MessageAuthor.OPERATOR, author_user=operator, text=text
)
conversation.last_activity_at = timezone.now()
conversation.expected_responder = ExpectedResponder.CUSTOMER
conversation.save(update_fields=["last_activity_at", "expected_responder"])
# Отправляем в тот же мессенджер, откуда пришёл клиент.
if conversation.connection_id:
identity = ConnectionIdentity.objects.filter(
connection=conversation.connection, contact=conversation.contact
).first()
transports.send_reply(
conversation.connection,
chat_id=conversation.external_chat_id,
user_id=identity.external_user_id if identity else "",
text=text,
)
return message
def request_contact(*, context: TenantContext, conversation: Conversation) -> Message:
"""Запрос контакта у клиента: TG/MAX — сообщение с кнопкой «Поделиться
контактом», Web — виджет рисует форму телефона по kind=contact_request."""
operator = context.actor_user
if operator is None or conversation.organization_id != context.organization_id:
raise Conversation.DoesNotExist
_require_open(conversation)
is_web = conversation.connection_id and conversation.connection.provider == IntegrationProvider.WEB
text = CONTACT_REQUEST_TEXT_WEB if is_web else CONTACT_REQUEST_TEXT
message = Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.OPERATOR,
author_user=operator,
kind=MessageKind.CONTACT_REQUEST,
text=text,
)
conversation.last_activity_at = timezone.now()
conversation.expected_responder = ExpectedResponder.CUSTOMER
conversation.save(update_fields=["last_activity_at", "expected_responder"])
if conversation.connection_id:
identity = ConnectionIdentity.objects.filter(
connection=conversation.connection, contact=conversation.contact
).first()
transports.send_contact_request(
conversation.connection,
chat_id=conversation.external_chat_id,
user_id=identity.external_user_id if identity else "",
text=text,
)
return message
@transaction.atomic
def close_conversation(*, context: TenantContext, conversation_id: int) -> Conversation:
conversation = Conversation.objects.select_for_update().get(
id=conversation_id, organization=context.organization
)
_require_open(conversation)
conversation.lifecycle = LifecycleState.CLOSED
conversation.control_mode = ControlMode.PAUSED
conversation.assigned_operator = None
conversation.expected_responder = ExpectedResponder.NOBODY
conversation.save(
update_fields=[
"lifecycle",
"control_mode",
"assigned_operator",
"expected_responder",
]
)
return conversation
@transaction.atomic
def mark_conversation_as_spam(
*, context: TenantContext, conversation_id: int
) -> Conversation:
conversation = Conversation.objects.select_for_update().get(
id=conversation_id, organization=context.organization
)
_require_open(conversation)
conversation.lifecycle = LifecycleState.SPAM
conversation.control_mode = ControlMode.PAUSED
conversation.assigned_operator = None
conversation.expected_responder = ExpectedResponder.NOBODY
conversation.save(
update_fields=[
"lifecycle",
"control_mode",
"assigned_operator",
"expected_responder",
]
)
return conversation
from django.db import transaction
from django.utils import timezone
from chatballs.conversations import transports
from chatballs.conversations.models import (
ConnectionIdentity,
Conversation,
ControlMode,
ExpectedResponder,
LifecycleState,
Message,
MessageAuthor,
MessageKind,
)
from chatballs.identity.models import EmployeeRole
from chatballs.integrations.models import IntegrationProvider
from chatballs.tenancy.context import TenantContext
CONTACT_REQUEST_TEXT = "Поделитесь, пожалуйста, контактом — нажмите кнопку ниже."
CONTACT_REQUEST_TEXT_WEB = "Поделитесь, пожалуйста, номером телефона."
class ClaimError(Exception):
pass
def _require_open(conversation: Conversation) -> None:
if conversation.lifecycle != LifecycleState.OPEN:
raise ClaimError("Диалог закрыт")
def _operator_label(operator) -> str:
return getattr(operator, "full_name", "") or operator.email
@transaction.atomic
def claim_conversation(*, context: TenantContext, conversation_id: int) -> Conversation:
# Атомарный перехват у AI (ADR-CHATBALLS-0003): только один оператор забирает диалог.
conversation = Conversation.objects.select_for_update().get(
id=conversation_id, organization=context.organization
)
return claim_locked_conversation(context=context, conversation=conversation)
def claim_locked_conversation(*, context: TenantContext, conversation: Conversation) -> Conversation:
"""Claim an already locked conversation inside the caller's transaction."""
operator = context.actor_user
if operator is None or conversation.organization_id != context.organization_id:
raise ClaimError("Диалог недоступен")
_require_open(conversation)
# Владелец (OWNER) может перехватить диалог у любого оператора; прочие сотрудники
# не могут забрать диалог, уже назначенный другому оператору.
is_owner = context.membership is not None and context.membership.role == EmployeeRole.OWNER
if (
not is_owner
and conversation.control_mode == ControlMode.HUMAN
and conversation.assigned_operator_id
and conversation.assigned_operator_id != operator.id
):
raise ClaimError("Диалог уже ведёт другой оператор")
conversation.control_mode = ControlMode.HUMAN
conversation.assigned_operator = operator
conversation.expected_responder = ExpectedResponder.OPERATOR
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.SYSTEM,
text=f"Оператор {_operator_label(operator)} перехватил диалог",
)
return conversation
@transaction.atomic
def release_to_ai(*, context: TenantContext, conversation_id: int) -> Conversation:
conversation = Conversation.objects.select_for_update().get(
id=conversation_id, organization=context.organization
)
_require_open(conversation)
agent = getattr(conversation.channel, "ai_agent", None)
if agent is None or not agent.is_active:
raise ClaimError("У канала нет активного AI-агента")
conversation.control_mode = ControlMode.AI
conversation.assigned_operator = None
conversation.expected_responder = ExpectedResponder.AI
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="Диалог возвращён AI")
return conversation
@transaction.atomic
def return_to_queue(*, context: TenantContext, conversation_id: int) -> Conversation:
# Оператор возвращает диалог в общую очередь (ADR-CHATBALLS-0003): снят с себя, ждёт оператора.
conversation = Conversation.objects.select_for_update().get(
id=conversation_id, organization=context.organization
)
_require_open(conversation)
conversation.control_mode = ControlMode.PAUSED
conversation.assigned_operator = None
conversation.expected_responder = ExpectedResponder.OPERATOR
conversation.save(update_fields=["control_mode", "assigned_operator", "expected_responder"])
Message.objects.create(conversation=conversation, author_type=MessageAuthor.SYSTEM, text="Диалог возвращён в очередь")
return conversation
def post_operator_message(
*, context: TenantContext, conversation: Conversation, text: str
) -> Message:
operator = context.actor_user
if operator is None or conversation.organization_id != context.organization_id:
raise Conversation.DoesNotExist
_require_open(conversation)
message = Message.objects.create(
conversation=conversation, author_type=MessageAuthor.OPERATOR, author_user=operator, text=text
)
conversation.last_activity_at = timezone.now()
conversation.expected_responder = ExpectedResponder.CUSTOMER
conversation.save(update_fields=["last_activity_at", "expected_responder"])
# Отправляем в тот же мессенджер, откуда пришёл клиент.
if conversation.connection_id:
identity = ConnectionIdentity.objects.filter(
connection=conversation.connection, contact=conversation.contact
).first()
transports.send_reply(
conversation.connection,
chat_id=conversation.external_chat_id,
user_id=identity.external_user_id if identity else "",
text=text,
)
return message
def request_contact(*, context: TenantContext, conversation: Conversation) -> Message:
"""Запрос контакта у клиента: TG/MAX — сообщение с кнопкой «Поделиться
контактом», Web — виджет рисует форму телефона по kind=contact_request."""
operator = context.actor_user
if operator is None or conversation.organization_id != context.organization_id:
raise Conversation.DoesNotExist
_require_open(conversation)
is_web = conversation.connection_id and conversation.connection.provider == IntegrationProvider.WEB
text = CONTACT_REQUEST_TEXT_WEB if is_web else CONTACT_REQUEST_TEXT
message = Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.OPERATOR,
author_user=operator,
kind=MessageKind.CONTACT_REQUEST,
text=text,
)
conversation.last_activity_at = timezone.now()
conversation.expected_responder = ExpectedResponder.CUSTOMER
conversation.save(update_fields=["last_activity_at", "expected_responder"])
if conversation.connection_id:
identity = ConnectionIdentity.objects.filter(
connection=conversation.connection, contact=conversation.contact
).first()
transports.send_contact_request(
conversation.connection,
chat_id=conversation.external_chat_id,
user_id=identity.external_user_id if identity else "",
text=text,
)
return message
@transaction.atomic
def close_conversation(*, context: TenantContext, conversation_id: int) -> Conversation:
conversation = Conversation.objects.select_for_update().get(
id=conversation_id, organization=context.organization
)
_require_open(conversation)
conversation.lifecycle = LifecycleState.CLOSED
conversation.control_mode = ControlMode.PAUSED
conversation.assigned_operator = None
conversation.expected_responder = ExpectedResponder.NOBODY
conversation.save(
update_fields=[
"lifecycle",
"control_mode",
"assigned_operator",
"expected_responder",
]
)
return conversation
@transaction.atomic
def mark_conversation_as_spam(
*, context: TenantContext, conversation_id: int
) -> Conversation:
conversation = Conversation.objects.select_for_update().get(
id=conversation_id, organization=context.organization
)
_require_open(conversation)
conversation.lifecycle = LifecycleState.SPAM
conversation.control_mode = ControlMode.PAUSED
conversation.assigned_operator = None
conversation.expected_responder = ExpectedResponder.NOBODY
conversation.save(
update_fields=[
"lifecycle",
"control_mode",
"assigned_operator",
"expected_responder",
]
)
return conversation
@@ -1,6 +1,6 @@
"""Real group overview aggregates (no fabricated numbers).
Commerce metrics were removed with the sales domain (ADR-HUB-0041). Everything
Commerce metrics were removed with the sales domain (ADR-CHATBALLS-0041). Everything
here is derived from real conversations, messages and LLM usage.
"""
@@ -13,7 +13,7 @@ from chatballs.identity.models import (
class ConversationVisibilityTests(TestCase):
"""Видимость диалогов по группам (ADR-HUB-0043 §4): диалоги групп сотрудника
"""Видимость диалогов по группам (ADR-CHATBALLS-0043 §4): диалоги групп сотрудника
+ диалоги без группы + назначенные ему; OWNER/ADMIN видят всё."""
def setUp(self) -> None:
@@ -1,4 +1,4 @@
"""Объединение и разъединение контактов (ADR-HUB-0006)."""
"""Объединение и разъединение контактов (ADR-CHATBALLS-0006)."""
from django.core.exceptions import ValidationError
from django.test import TestCase
@@ -143,7 +143,7 @@ class ContactsMergeTests(TestCase):
class ContactsMergeApiTests(TestCase):
"""Объединять и разъединять может только владелец (ADR-HUB-0006)."""
"""Объединять и разъединять может только владелец (ADR-CHATBALLS-0006)."""
def setUp(self) -> None:
self.organization = Organization.objects.create(name="Ателье", slug="atelie-api")
@@ -1,4 +1,4 @@
"""Email-транспорт: разбор писем, UID-курсор, тредирование (SPEC-HUB-0025 §5)."""
"""Email-транспорт: разбор писем, UID-курсор, тредирование (SPEC-CHATBALLS-0025 §5)."""
@@ -401,7 +401,7 @@ class WebchatContactTests(TestCase):
self,
) -> None:
# Агент без BYOK-интеграции: IntegrationNotConfigured (ProviderError)
# переводит диалог оператору вместо 500 (ADR-HUB-0042 §3).
# переводит диалог оператору вместо 500 (ADR-CHATBALLS-0042 §3).
admin = HumanUser.objects.create_user(email="admin@example.com", password="temporary")
OrganizationMembership.objects.create(
user=admin,
@@ -39,7 +39,7 @@ _CONTACT_ACK = {
}
# Приглашение на онлайн-звонок: сообщение с кнопкой-ссылкой /calls/<token>
# (SPEC-HUB-0013 §7.2). Web доставляется поллингом виджета, отправки нет.
# (SPEC-CHATBALLS-0013 §7.2). Web доставляется поллингом виджета, отправки нет.
_CALL_INVITE = {
IntegrationProvider.MAX: _max.send_call_invite,
IntegrationProvider.TELEGRAM: _telegram.send_call_invite,
@@ -50,7 +50,7 @@ class InboundMessage:
# обязательным fallback для AI, поиска, уведомлений и превью.
content_html: str = ""
# Транспортная мета для тредирования ответа (email: subject/last_message_id).
# Пишется в Conversation.transport_meta при ingest (ADR-HUB-0035).
# Пишется в Conversation.transport_meta при ingest (ADR-CHATBALLS-0035).
thread_meta: dict | None = None
# Голосовое сообщение (дизайн-базлайн v2, кадр H): идентификатор файла у
# провайдера (TG file_id) ИЛИ прямой URL (MAX), длительность и mime.
@@ -1,4 +1,4 @@
"""Email (IMAP/SMTP) transport (ADR-HUB-0035, SPEC-HUB-0025 §3.3–3.4).
"""Email (IMAP/SMTP) transport (ADR-CHATBALLS-0035, SPEC-CHATBALLS-0025 §3.3–3.4).
Polling IMAP with a UID cursor in poll_marker («uidvalidity:last_uid»), replies
via SMTP into the same thread (Re:/In-Reply-To/References from the dialog's
@@ -137,7 +137,7 @@ def poll_updates(integration) -> tuple[list[InboundMessage], str]:
known_validity, last_uid = _parse_marker(integration.poll_marker)
if validity != known_validity:
# Первый запуск или смена UIDVALIDITY: курсор — на текущий конец
# ящика, история не импортируется (SPEC-HUB-0025 §3.3).
# ящика, история не импортируется (SPEC-CHATBALLS-0025 §3.3).
return [], f"{validity}:{max(next_uid - 1, 0)}"
_, found = client.uid("SEARCH", None, f"UID {last_uid + 1}:*")
# IMAP-диапазон N:* всегда включает старшее письмо — отсекаем уже виденные.
@@ -1,4 +1,4 @@
"""MAX bot transport (M2, ADR-HUB-0020).
"""MAX bot transport (M2, ADR-CHATBALLS-0020).
Long polling for inbound updates and text sending. MAX (TamTam heritage) field
naming is not fully documented, so inbound parsing is defensive and the raw
@@ -1,4 +1,4 @@
"""Telegram bot transport (M2, ADR-HUB-0020).
"""Telegram bot transport (M2, ADR-CHATBALLS-0020).
Long polling via getUpdates (offset cursor) and sendMessage. Token goes in the
path. Optional per-connection proxy via config["proxy_url"] (Telegram is often
@@ -207,7 +207,7 @@ class ConversationReturnQueueView(ConversationViewBase):
def claim_for_reply(view: ConversationViewBase, request: Request, conversation: Conversation) -> Conversation | Response:
"""Одно действие взятия (дизайн-базлайн v2, решение 2; ADR-HUB-0003): первая
"""Одно действие взятия (дизайн-базлайн v2, решение 2; ADR-CHATBALLS-0003): первая
реплика сотрудника (текст или файл) атомарно перехватывает диалог у
AI/очереди. Возвращает диалог или готовый ответ с ошибкой."""
if conversation.control_mode != ControlMode.HUMAN:
@@ -333,7 +333,7 @@ class ConversationSpamView(ConversationViewBase):
class ConversationGroupView(ConversationViewBase):
"""Перенос диалога в группу и снятие группы (ADR-HUB-0043 §3)."""
"""Перенос диалога в группу и снятие группы (ADR-CHATBALLS-0043 §3)."""
required_capability = "conversations.operate"
@@ -363,7 +363,7 @@ class ConversationGroupView(ConversationViewBase):
class ConversationAssigneeView(ConversationViewBase):
"""Назначение и переназначение ответственного (ADR-HUB-0043 §3)."""
"""Назначение и переназначение ответственного (ADR-CHATBALLS-0043 §3)."""
required_capability = "conversations.operate"
@@ -285,7 +285,7 @@ def _audit_actors(base) -> list[dict[str, object]]:
class LaunchChecklistView(APIView):
"""Чек-лист «Запуск» (SPEC-HUB-0031 §5, дизайн-базлайн v2): три шага с
"""Чек-лист «Запуск» (SPEC-CHATBALLS-0031 §5, дизайн-базлайн v2): три шага с
автоотметкой по факту. Скрытие блока — предпочтение клиента (localStorage)."""
permission_classes = [HasCapability]
@@ -205,7 +205,7 @@ class ChangeTemporaryPasswordView(APIView):
class ProfileAppearanceView(APIView):
"""Тема и акцентный цвет — глобальные настройки пользователя
(SPEC-HUB-0031 §7, дизайн-базлайн v2)."""
(SPEC-CHATBALLS-0031 §7, дизайн-базлайн v2)."""
permission_classes = [IsAuthenticated]
+1 -1
View File
@@ -34,7 +34,7 @@ def bootstrap_owner(*, email: str, password: str, full_name: str = "") -> Bootst
from chatballs.ai.knowledge_categories import ensure_uncategorized_category
ensure_uncategorized_category(organization)
# Группы сотрудников (ADR-HUB-0043): не обязательны для запуска, но дают
# Группы сотрудников (ADR-CHATBALLS-0043): не обязательны для запуска, но дают
# локальному контуру и тестам готовое разделение потоков.
operators_group, _ = EmployeeGroup.objects.get_or_create(
organization=organization, name="Операторы"
@@ -1,9 +1,9 @@
from __future__ import annotations
# Возможности как словарь операций backend'а (ADR-HUB-0041, SPEC-HUB-0031 §3).
# Возможности как словарь операций backend'а (ADR-CHATBALLS-0041, SPEC-CHATBALLS-0031 §3).
# Права выводятся ТОЛЬКО из роли: OWNER и ADMIN идентичны и получают всё;
# EMPLOYEE получает фиксированный набор для работы в чате. Профили доступа,
# scope-модель и отделы упразднены (ADR-HUB-0043).
# scope-модель и отделы упразднены (ADR-CHATBALLS-0043).
ALL_CAPABILITIES: frozenset[str] = frozenset(
{
@@ -48,5 +48,5 @@ EMPLOYEE_CAPABILITIES: frozenset[str] = frozenset(
}
)
# Операции, доступные только владельцу (SPEC-HUB-0031 §3: передача владения).
# Операции, доступные только владельцу (SPEC-CHATBALLS-0031 §3: передача владения).
OWNER_ONLY_CAPABILITIES: frozenset[str] = frozenset({"ownership.transfer"})
@@ -66,7 +66,7 @@ def load(context: TenantContext, refs: DemoRefs) -> None:
organization=organization, title=item["title"], defaults={"text": item["text"]}
)
# Объединение контактов (ADR-HUB-0006): демо показывает и историю слияний.
# Объединение контактов (ADR-CHATBALLS-0006): демо показывает и историю слияний.
for item in data.get("contactMerges", []):
_merge_contacts(context, refs, item)
@@ -39,7 +39,7 @@ def employee_payload(
"totpEnabled": profile.user.totp_enabled,
}
# Backend — источник истины для того, какие действия над сотрудником доступны
# запрашивающему (SPEC-HUB-0031 §3): фронтенд скрывает недоступное.
# запрашивающему (SPEC-CHATBALLS-0031 §3): фронтенд скрывает недоступное.
if actor is not None:
payload["permissions"] = employee_management_flags(actor, profile)
if include_detail:
@@ -1,4 +1,4 @@
"""Target-aware governance для управления сотрудниками (SPEC-HUB-0031 §3).
"""Target-aware governance для управления сотрудниками (SPEC-CHATBALLS-0031 §3).
OWNER и ADMIN идентичны по правам: оба управляют любыми сотрудниками, включая
других администраторов. Отличия ровно два:
@@ -27,7 +27,7 @@ class EmployeeAction:
TRANSFER_OWNERSHIP = "transfer_ownership"
# Действия, запрещённые над владельцем для всех (SPEC-HUB-0031 §3);
# Действия, запрещённые над владельцем для всех (SPEC-CHATBALLS-0031 §3);
# смена его роли возможна только через ownership flow.
_OWNER_PROTECTED_ACTIONS = frozenset(
{
@@ -73,7 +73,7 @@ def can_manage_employee(
target: OrganizationMembership | None,
action: str,
) -> bool:
"""Может ли actor выполнить action над target (SPEC-HUB-0031 §3)."""
"""Может ли actor выполнить action над target (SPEC-CHATBALLS-0031 §3)."""
if not _is_active_manager(actor):
return False
@@ -7,7 +7,7 @@ from django.db.models.functions import Lower
from chatballs.identity.models import Organization, OrganizationMembership
from chatballs.tenancy.models import TenantRelationModel
# Настраиваемые группы сотрудников (ADR-HUB-0043): граница видимости диалогов
# Настраиваемые группы сотрудников (ADR-CHATBALLS-0043): граница видимости диалогов
# и ничего больше — без прав, знаний, должностей и иерархии. Организация сама
# решает, какие группы ей нужны; групп может не быть вообще.
@@ -12,7 +12,7 @@ from chatballs.identity.audit import record_audit_event
from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember
from chatballs.identity.models import OrganizationMembership
# Группы сотрудников (ADR-HUB-0043): имя + состав, только граница видимости
# Группы сотрудников (ADR-CHATBALLS-0043): имя + состав, только граница видимости
# диалогов. Управляют OWNER/ADMIN; сотрудник видит свои группы в session payload.
@@ -1,4 +1,4 @@
# ADR-HUB-0045: сущность Product удалена целиком. Миграция сохранена пустой —
# ADR-CHATBALLS-0045: сущность Product удалена целиком. Миграция сохранена пустой —
# на неё ссылаются следующие миграции identity.
from django.db import migrations
@@ -1,6 +1,6 @@
# ADR-HUB-0027 / SPEC-HUB-0018 фаза M2 (этап 1): после явного заполнения должностей
# включается обязательность. Оставшиеся незаполненные значения нормализуются в "";
# непустая должность гарантируется application contract (SPEC-HUB-0016 §5).
# непустая должность гарантируется application contract (SPEC-CHATBALLS-0016 §5).
from django.db import migrations, models
@@ -1,4 +1,4 @@
# ADR-HUB-0041: домен продаж удалён — capabilities sales.* исключаются из
# ADR-CHATBALLS-0041: домен продаж удалён — capabilities sales.* исключаются из
# реестра. Строки профилей с этими кодами удаляются до установки нового
# check-constraint, иначе constraint невыполним на существующих данных.
from django.db import migrations, models
+4 -4
View File
@@ -41,7 +41,7 @@ class HumanUserManager(UserManager):
class UiTheme(models.TextChoices):
# Персональная тема интерфейса (дизайн-базлайн v2, SPEC-HUB-0031 §7).
# Персональная тема интерфейса (дизайн-базлайн v2, SPEC-CHATBALLS-0031 §7).
LIGHT = "LIGHT", "Светлая"
DARK = "DARK", "Тёмная"
SYSTEM = "SYSTEM", "Как в системе"
@@ -150,7 +150,7 @@ class Organization(models.Model):
super().save(*args, **kwargs)
# SPEC-HUB-0016 §5: лимит должности задаётся backend-константой.
# SPEC-CHATBALLS-0016 §5: лимит должности задаётся backend-константой.
POSITION_TITLE_MAX_LENGTH = 120
@@ -172,7 +172,7 @@ class OrganizationMembership(models.Model):
related_name="memberships",
)
role = models.CharField(max_length=32, choices=EmployeeRole.choices)
# Должность вводится вручную; обязательна для новых записей (SPEC-HUB-0016 §5).
# Должность вводится вручную; обязательна для новых записей (SPEC-CHATBALLS-0016 §5).
# Пустая строка допускается на уровне БД только для legacy-записей до backfill.
position_title = models.CharField(max_length=POSITION_TITLE_MAX_LENGTH, blank=True, default="")
phone = models.CharField(max_length=32, blank=True)
@@ -188,7 +188,7 @@ class OrganizationMembership(models.Model):
fields=["user", "organization"],
name="uniq_membership_user_organization",
),
# В организации ровно один владелец (SPEC-HUB-0031 §3).
# В организации ровно один владелец (SPEC-CHATBALLS-0031 §3).
models.UniqueConstraint(
fields=["organization"],
condition=Q(role=EmployeeRole.OWNER),
+2 -2
View File
@@ -9,7 +9,7 @@ from chatballs.identity.capabilities import (
)
from chatballs.identity.models import EmployeeRole, OrganizationMembership
# Ролевая авторизация (SPEC-HUB-0031 §3, ADR-HUB-0043): OWNER и ADMIN идентичны
# Ролевая авторизация (SPEC-CHATBALLS-0031 §3, ADR-CHATBALLS-0043): OWNER и ADMIN идентичны
# (кроме ownership.transfer и невозможности удалить/заблокировать владельца —
# это проверяют employee-сервисы), EMPLOYEE ограничен чатом. Deny-by-default
# сохраняется; scope-модель и отделы упразднены.
@@ -60,7 +60,7 @@ def can_administer_access(actor) -> bool:
def conversation_visibility(actor) -> dict | None:
"""Видимость диалогов (ADR-HUB-0043 §4).
"""Видимость диалогов (ADR-CHATBALLS-0043 §4).
None — без ограничений (OWNER/ADMIN). Иначе словарь для построения фильтра:
диалоги групп сотрудника + диалоги без группы + назначенные ему.
+1 -1
View File
@@ -1,4 +1,4 @@
"""Мастер первого запуска (SPEC-HUB-0031, open source: «развернуть за минуту»).
"""Мастер первого запуска (SPEC-CHATBALLS-0031, open source: «развернуть за минуту»).
Пока в инстансе нет ни одной организации, публичный эндпоинт /api/v1/setup/
принимает одну форму: название организации, имя, e-mail и пароль владельца.
@@ -17,7 +17,7 @@ from chatballs.identity.policy import (
class RolePolicyTests(TestCase):
"""Ролевая авторизация SPEC-HUB-0031 §3 + видимость по группам ADR-HUB-0043."""
"""Ролевая авторизация SPEC-CHATBALLS-0031 §3 + видимость по группам ADR-CHATBALLS-0043."""
def setUp(self) -> None:
self.organization = Organization.objects.create(name="Example", slug="example")
+6 -6
View File
@@ -41,7 +41,7 @@ class BootstrapOwnerTests(TestCase):
self.assertTrue(result.created_owner)
self.assertEqual(Organization.objects.get().slug, "demo")
# Seed создаёт стартовые группы (ADR-HUB-0043).
# Seed создаёт стартовые группы (ADR-CHATBALLS-0043).
self.assertEqual(
set(EmployeeGroup.objects.values_list("name", flat=True)),
{"Операторы", "Поддержка"},
@@ -291,7 +291,7 @@ class AuthEndpointTests(TestCase):
self.assertTrue(AuditEvent.objects.filter(action="identity.profile_updated").exists())
def test_profile_appearance_saves_theme_and_accent(self) -> None:
"""Тема и акцент — глобальные настройки пользователя (SPEC-HUB-0031 §7)."""
"""Тема и акцент — глобальные настройки пользователя (SPEC-CHATBALLS-0031 §7)."""
self.client.login(username="owner@example.com", password="temporary-password")
response = self.client.post(
@@ -731,7 +731,7 @@ class ThrottlingTests(TestCase):
class EmployeeModelInvariantTests(TestCase):
"""ADR-HUB-0027 / SPEC-HUB-0016 §5,§7 — инварианты модели сотрудника после
"""ADR-HUB-0027 / SPEC-CHATBALLS-0016 §5,§7 — инварианты модели сотрудника после
миграции этапа 1: роли OWNER/ADMIN/EMPLOYEE, обязательная должность,
размещение владельца на уровне компании и ровно один владелец на организацию."""
@@ -784,7 +784,7 @@ class EmployeeModelInvariantTests(TestCase):
class EmployeeGovernanceTests(TestCase):
"""ADR-HUB-0027 этап 2 / SPEC-HUB-0016 §8,§12: административная иерархия
"""ADR-HUB-0027 этап 2 / SPEC-CHATBALLS-0016 §8,§12: административная иерархия
OWNER/ADMIN/EMPLOYEE, target-aware управление и передача владения."""
def setUp(self) -> None:
@@ -831,7 +831,7 @@ class EmployeeGovernanceTests(TestCase):
self.assertTrue(AuditEvent.objects.filter(action="identity.employee_created").exists())
def test_admin_creates_admin(self) -> None:
# SPEC-HUB-0031 §3: ADMIN идентичен OWNER и может создавать админов.
# SPEC-CHATBALLS-0031 §3: ADMIN идентичен OWNER и может создавать админов.
self._make("admin@example.com", EmployeeRole.ADMIN)
response = self._create(self._client("admin@example.com"), "admin2@example.com", EmployeeRole.ADMIN)
self.assertEqual(response.status_code, 201)
@@ -867,7 +867,7 @@ class EmployeeGovernanceTests(TestCase):
self.assertTrue(emp.memberships.get().is_blocked)
def test_admin_blocks_another_admin(self) -> None:
# SPEC-HUB-0031 §3: админы управляют друг другом; защищён только владелец.
# SPEC-CHATBALLS-0031 §3: админы управляют друг другом; защищён только владелец.
other = self._make("admin2@example.com", EmployeeRole.ADMIN)
self._make("admin@example.com", EmployeeRole.ADMIN)
response = self._client("admin@example.com").post(f"/api/v1/employees/{other.id}/block/")
@@ -1,4 +1,4 @@
"""Connectivity checks for integrations (ADR-HUB-0020).
"""Connectivity checks for integrations (ADR-CHATBALLS-0020).
Stdlib-only HTTP. Each provider has a DIFFERENT API — auth, base URL and the
identity method are not interchangeable:
@@ -70,7 +70,7 @@ def check_openrouter(*, secret: str, base_url: str, proxy_url: str = "") -> Chec
def check_custom(*, secret: str, base_url: str, proxy_url: str = "") -> CheckResult:
"""Connectivity check for a generic OpenAI-compatible endpoint (ADR-HUB-0034).
"""Connectivity check for a generic OpenAI-compatible endpoint (ADR-CHATBALLS-0034).
Unlike OpenRouter there is no /key identity endpoint and no model catalog we
can trust as authoritative; we only verify the endpoint speaks the OpenAI
@@ -88,8 +88,8 @@ def check_custom(*, secret: str, base_url: str, proxy_url: str = "") -> CheckRes
if status != 200:
return False, f"Эндпоинт ответил {status}", {}
# OpenAI shape: {"data": [{"id": "..."}, ...]}. Каталог не является
# разрешительным списком (ADR-HUB-0020:89), ответственность за model
# identifier лежит на владельце (ADR-HUB-0034 §4).
# разрешительным списком (ADR-CHATBALLS-0020:89), ответственность за model
# identifier лежит на владельце (ADR-CHATBALLS-0034 §4).
count = len(data.get("data") or [])
return True, f"Эндпоинт отвечает: {count} моделей", {}
@@ -123,7 +123,7 @@ def _describe_mail_error(error: Exception) -> str:
def check_email(*, secret: str, config: dict) -> CheckResult:
"""Email-подключение (ADR-HUB-0035): проверка проходит только если успешны
"""Email-подключение (ADR-CHATBALLS-0035): проверка проходит только если успешны
ОБЕ стороны — IMAP (login + SELECT INBOX) и SMTP (EHLO + login)."""
address = str(config.get("email", "")).strip().lower()
imap_host = str(config.get("imap_host", "")).strip()
@@ -2,7 +2,7 @@ from django.db import models
from chatballs.identity.crypto import EncryptedCharField
# Интеграции: провайдеры (LLM) и подключения (боты/виджеты). ADR-HUB-0020.
# Интеграции: провайдеры (LLM) и подключения (боты/виджеты). ADR-CHATBALLS-0020.
# Привязка подключения к каналу обработки появляется в M1 (ADR-HUB-0019).
@@ -31,13 +31,13 @@ class IntegrationStatus(models.TextChoices):
# Какой провайдер к какому роду относится.
PROVIDER_KIND = {
IntegrationProvider.OPENROUTER: IntegrationKind.LLM_PROVIDER,
# Custom — generic BYOK для любого OpenAI-compatible endpoint (ADR-HUB-0034).
# Custom — generic BYOK для любого OpenAI-compatible endpoint (ADR-CHATBALLS-0034).
IntegrationProvider.CUSTOM: IntegrationKind.LLM_PROVIDER,
IntegrationProvider.DEMO: IntegrationKind.LLM_PROVIDER,
IntegrationProvider.MAX: IntegrationKind.MESSENGER,
IntegrationProvider.TELEGRAM: IntegrationKind.MESSENGER,
IntegrationProvider.WEB: IntegrationKind.MESSENGER,
# Email-ящик — транспорт диалогов наравне с ботами (ADR-HUB-0035).
# Email-ящик — транспорт диалогов наравне с ботами (ADR-CHATBALLS-0035).
IntegrationProvider.EMAIL: IntegrationKind.MESSENGER,
}
+1 -1
View File
@@ -1,4 +1,4 @@
"""Единый HTTP-opener с поддержкой прокси для всех интеграций (ADR-HUB-0020).
"""Единый HTTP-opener с поддержкой прокси для всех интеграций (ADR-CHATBALLS-0020).
Поддерживаемые схемы proxy_url:
- http://[user:pass@]host:port
@@ -38,7 +38,7 @@ def integration_payload(integration: Integration) -> dict[str, object]:
"quickReplies": integration.config.get("quick_replies", []),
"consentText": integration.config.get("consent_text", ""),
"consentVersion": integration.config.get("consent_version", ""),
# Email-подключение (ADR-HUB-0035).
# Email-подключение (ADR-CHATBALLS-0035).
"email": integration.config.get("email", ""),
"imapHost": integration.config.get("imap_host", ""),
"imapPort": integration.config.get("imap_port", 993),
@@ -51,7 +51,7 @@ def _resolve_channel(
def _email_config(config: dict) -> dict:
"""Email-подключение (ADR-HUB-0035): адрес и хосты IMAP/SMTP обязательны,
"""Email-подключение (ADR-CHATBALLS-0035): адрес и хосты IMAP/SMTP обязательны,
порты/SSL имеют значения по умолчанию, purpose не поддерживается."""
if str(config.get("purpose", "")).strip():
raise ValidationError({"config": "Email cannot be a notifications bot"})
@@ -118,7 +118,7 @@ def _normalized_config(provider: str, config: dict) -> dict:
raise ValidationError({"config": f"Proxy URL: {error}"}) from error
# LLM-провайдеры (OpenRouter, Custom) хранят модель по умолчанию свободным текстом.
# Для OpenRouter поле исторически декоративно (SPEC-HUB-0005:388); для Custom оно
# читается в рантайме (ADR-HUB-0034 §4). Версионирование модели — дорожка ADR-0034.
# читается в рантайме (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()
if default_model:
@@ -1,4 +1,4 @@
"""Email-подключение: конфигурация, проверка и сериализация (SPEC-HUB-0025 §5)."""
"""Email-подключение: конфигурация, проверка и сериализация (SPEC-CHATBALLS-0025 §5)."""
@@ -116,7 +116,7 @@ class EmailConfigTests(TestCase):
def test_notifications_purpose_rejected(self) -> None:
# Email не может быть сервисным ботом уведомлений (ADR-HUB-0035, границы).
# Email не может быть сервисным ботом уведомлений (ADR-CHATBALLS-0035, границы).
with self.assertRaises(ValidationError):
+2 -2
View File
@@ -514,7 +514,7 @@ class BuildOpenerSocksTests(TestCase):
class CustomIntegrationTests(TestCase):
"""Generic OpenAI-compatible BYOK provider (ADR-HUB-0034, SPEC-HUB-0024 §5).
"""Generic OpenAI-compatible BYOK provider (ADR-CHATBALLS-0034, SPEC-CHATBALLS-0024 §5).
@@ -566,7 +566,7 @@ class CustomIntegrationTests(TestCase):
)
# Модель — отдельное рабочее поле (ADR-HUB-0034 §4), читается в рантайме.
# Модель — отдельное рабочее поле (ADR-CHATBALLS-0034 §4), читается в рантайме.
self.assertEqual(integration.config["base_url"], "https://api.example.com/v1")
@@ -1,4 +1,4 @@
# ADR-HUB-0041: типы уведомлений о платежах удалены вместе с доменом продаж.
# ADR-CHATBALLS-0041: типы уведомлений о платежах удалены вместе с доменом продаж.
from django.db import migrations, models
@@ -9,7 +9,7 @@ from chatballs.platform.tokens import authenticate_token
class PlatformTokenAuthentication(BaseAuthentication):
"""Machine-to-machine auth via `Authorization: Token <opaque>`.
The platform surface is non-browser (ADR-HUB-0031 §4): there is no CORS and
The platform surface is non-browser (ADR-CHATBALLS-0031 §4): there is no CORS and
session cookies are not used. On success, request.platform_operator and the
authenticating PlatformToken are attached for capability checks and audit.
"""
@@ -10,7 +10,7 @@ class PlatformCapabilitySpec:
description: str
# Global platform capabilities (ADR-HUB-0031 §9). Distinct from the tenant
# Global platform capabilities (ADR-CHATBALLS-0031 §9). Distinct from the tenant
# capability registry (identity/capabilities.py): a platform capability is held
# by a PlatformOperator/token and is never derived from an OrganizationMembership.
_PLATFORM_CAPABILITIES = (
@@ -4,7 +4,7 @@ from django.db import models
class PlatformOperator(models.Model):
"""Global platform principal (ADR-HUB-0031 §9). Not derived from tenant
"""Global platform principal (ADR-CHATBALLS-0031 §9). Not derived from tenant
membership; never becomes an Organization OWNER. Authenticated via
PlatformToken (machine-to-machine)."""
@@ -47,7 +47,7 @@ def provision_organization(
"""Single write boundary for tenant provisioning (SPEC-HUB-0021 §4).
Coordinates identity, audit and outbox in one
transaction (тариф удалён, ADR-HUB-0042 §2: организация создаётся без подписки). Tenant-owned rows are written under set_local_tenant(new_org.id)
transaction (тариф удалён, ADR-CHATBALLS-0042 §2: организация создаётся без подписки). Tenant-owned rows are written under set_local_tenant(new_org.id)
via tenant_atomic. No email/provider calls happen before commit (SPEC §4).
"""
with transaction.atomic():
@@ -36,7 +36,7 @@ class PlatformAuthTests(TestCase):
self.assertEqual(response.status_code, 201, response.content)
self.assertIn("organization", response.json())
self.assertIn("publicId", response.json()["organization"])
# Тарифный контур удалён (ADR-HUB-0042): ответ без ключа subscription.
# Тарифный контур удалён (ADR-CHATBALLS-0042): ответ без ключа subscription.
self.assertNotIn("subscription", response.json())
def test_missing_token_is_unauthenticated(self) -> None:
@@ -165,7 +165,7 @@ def publish_revision(
article.full_clean()
article.save(update_fields=["published_revision", "status", "updated_at"])
# Агенты отвечают по опубликованной ревизии, поэтому индекс перестраивается
# ровно в момент публикации (ADR-HUB-0016).
# ровно в момент публикации (ADR-CHATBALLS-0016).
reindex_portal_article(article)
return article
@@ -33,7 +33,7 @@ class SupportPortal(TenantRelationModel):
choices=PortalStatus.choices,
default=PortalStatus.DRAFT,
)
# Тема хранится идентификатором из каталога фронтенда (ADR-HUB-0044):
# Тема хранится идентификатором из каталога фронтенда (ADR-CHATBALLS-0044):
# список тем в БД не фиксируется, неизвестное значение деградирует до
# темы по умолчанию при рендере публичной страницы.
theme = models.CharField(max_length=64, default=DEFAULT_PORTAL_THEME)
@@ -243,7 +243,7 @@ class PortalArticleFile(TenantRelationModel):
Ссылка публичная и защищена непредсказуемым UUID: файл открывается
посетителем портала, у которого нет аутентификации хаба (как у вложений
знаний, ADR-HUB-0023).
знаний, ADR-CHATBALLS-0023).
"""
tenant_relation_fields = ("article",)
@@ -112,7 +112,7 @@ class PortalListView(PortalBaseView):
def get(self, request: Request) -> Response:
portals = list(portals_for_context(request.tenant_context))
counts = portal_content_counts(request.tenant_context)
# Тарифные лимиты порталов удалены (ADR-HUB-0042 §2): создание доступно всегда.
# Тарифные лимиты порталов удалены (ADR-CHATBALLS-0042 §2): создание доступно всегда.
active_count = sum(item.status != "ARCHIVED" for item in portals)
return Response(
{
@@ -48,7 +48,7 @@ class SupportPortalManagementTests(SupportPortalTestCase):
self.assertTrue(config.json()["available"])
def test_multiple_active_portals_without_limits(self) -> None:
# Лимитов на порталы нет (ADR-HUB-0042): creation всегда canCreate=true.
# Лимитов на порталы нет (ADR-CHATBALLS-0042): creation всегда canCreate=true.
first = self.create_portal()
self.assertEqual(first.status_code, 201, first.content)
self.assertEqual(
@@ -1,4 +1,4 @@
"""Визуальные темы порталов (SPEC-HUB-0028 §6, ADR-HUB-0044).
"""Визуальные темы порталов (SPEC-CHATBALLS-0028 §6, ADR-CHATBALLS-0044).
Бэкенд хранит только идентификатор темы, выбранную цветовую схему и
произвольные параметры темы. Каталог тем живёт в коде фронтенда
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more