mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 01:14:58 +03:00
📝 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:
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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
"""Извлечение текста из файловых вложений знаний (ADR-HUB-0023).
|
||||
"""Извлечение текста из файловых вложений знаний (ADR-CHATBALLS-0023).
|
||||
|
||||
Поддерживаются текстовые форматы (md, txt и любой text/*), PDF (pypdf) и DOCX
|
||||
(python-docx). Остальные форматы хранятся без индексации — extract_text вернёт
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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,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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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, а ссылка
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
"""Серверные таймауты звонков (SPEC-HUB-0013 §5): истечение приглашения и
|
||||
"""Серверные таймауты звонков (SPEC-CHATBALLS-0013 §5): истечение приглашения и
|
||||
зависшее соединение обрабатываются воркером, а не браузером клиента."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
@@ -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,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, поздние события игнорируются."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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")
|
||||
|
||||
@@ -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,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):
|
||||
|
||||
|
||||
@@ -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
Reference in new issue
Block a user