diff --git a/apps/backend/hub_platform/ai/admin.py b/apps/backend/hub_platform/ai/admin.py index 55cd281..7791c2c 100644 --- a/apps/backend/hub_platform/ai/admin.py +++ b/apps/backend/hub_platform/ai/admin.py @@ -1,31 +1,31 @@ from django.contrib import admin -from hub_platform.ai.models import AIAgent, KnowledgeDocument, ProductAIRelease, PromptDocument +from hub_platform.ai.models import AIAgent, ChannelAIRelease, KnowledgeDocument, PromptDocument @admin.register(AIAgent) class AIAgentAdmin(admin.ModelAdmin): - list_display = ("name", "product", "model", "is_active") + list_display = ("name", "channel", "model", "is_active") list_filter = ("is_active",) - search_fields = ("name", "product__code") + search_fields = ("name", "channel__code") @admin.register(KnowledgeDocument) class KnowledgeDocumentAdmin(admin.ModelAdmin): - list_display = ("title", "product", "category", "inclusion_mode", "is_enabled") - list_filter = ("category", "inclusion_mode", "is_enabled") + list_display = ("title", "scope", "product", "category", "inclusion_mode", "is_enabled") + list_filter = ("scope", "category", "inclusion_mode", "is_enabled") search_fields = ("title", "code", "product__code") @admin.register(PromptDocument) class PromptDocumentAdmin(admin.ModelAdmin): - list_display = ("title", "product", "category", "is_enabled") - list_filter = ("category", "is_enabled") + list_display = ("title", "scope", "product", "category", "is_enabled") + list_filter = ("scope", "category", "is_enabled") search_fields = ("title", "code", "product__code") -@admin.register(ProductAIRelease) -class ProductAIReleaseAdmin(admin.ModelAdmin): - list_display = ("product", "version", "status", "model", "published_at") +@admin.register(ChannelAIRelease) +class ChannelAIReleaseAdmin(admin.ModelAdmin): + list_display = ("channel", "version", "status", "model", "published_at") list_filter = ("status",) - search_fields = ("product__code",) + search_fields = ("channel__code",) diff --git a/apps/backend/hub_platform/ai/doc_views.py b/apps/backend/hub_platform/ai/doc_views.py index 6a335f3..761f9eb 100644 --- a/apps/backend/hub_platform/ai/doc_views.py +++ b/apps/backend/hub_platform/ai/doc_views.py @@ -4,6 +4,7 @@ from rest_framework.views import APIView from hub_platform.ai import documents as doc_service from hub_platform.ai.models import ( + DocumentScope, InclusionMode, KnowledgeCategory, KnowledgeDocument, @@ -61,10 +62,15 @@ class DocumentListCreateView(_DocConfig): def post(self, request: Request) -> Response: body = request.data org = self._org(request) - try: - product = Product.objects.get(organization=org, code=str(body.get("product", ""))) - except Product.DoesNotExist: - return Response({"detail": "Product not found"}, status=400) + scope = str(body.get("scope", DocumentScope.GLOBAL)) + if scope not in DocumentScope.values: + return Response({"detail": "Unknown scope"}, status=400) + product = None + if scope == DocumentScope.PRODUCT: + try: + product = Product.objects.get(organization=org, code=str(body.get("product", ""))) + except Product.DoesNotExist: + return Response({"detail": "Product not found"}, status=400) code = str(body.get("code", "")).strip() title = str(body.get("title", "")).strip() @@ -73,10 +79,10 @@ class DocumentListCreateView(_DocConfig): return Response({"detail": "Code and title are required"}, status=400) if category not in self.valid_categories: return Response({"detail": "Unknown category"}, status=400) - if self.document_model.objects.filter(product=product, code=code).exists(): + if self.document_model.objects.filter(organization=org, product=product, code=code).exists(): return Response({"detail": "Document code already exists"}, status=400) - fields = {"product": product, "code": code, "title": title, "category": category} + fields = {"organization": org, "scope": scope, "product": product, "code": code, "title": title, "category": category} if self.supports_inclusion: inclusion = str(body.get("inclusionMode", InclusionMode.RETRIEVAL)) if inclusion not in InclusionMode.values: diff --git a/apps/backend/hub_platform/ai/indexing.py b/apps/backend/hub_platform/ai/indexing.py index b62edb9..54971fe 100644 --- a/apps/backend/hub_platform/ai/indexing.py +++ b/apps/backend/hub_platform/ai/indexing.py @@ -12,7 +12,7 @@ def reindex_knowledge_version(version: KnowledgeDocumentVersion) -> list[Knowled if not chunks: return [] embeddings = embed_texts( - product=version.document.product, + channel=None, texts=chunks, model=settings.HUB_AI_EMBEDDING_MODEL, purpose="knowledge_index", diff --git a/apps/backend/hub_platform/ai/invocation.py b/apps/backend/hub_platform/ai/invocation.py index c5546ad..a78a4da 100644 --- a/apps/backend/hub_platform/ai/invocation.py +++ b/apps/backend/hub_platform/ai/invocation.py @@ -12,15 +12,15 @@ from hub_platform.ai.provider.resilience import CircuitBreaker, call_with_resili _breaker = CircuitBreaker() -def invoke_chat(*, product, messages: list[ChatMessage], purpose: str, release=None, model: str | None = None, params: dict | None = None, used_fragment_ids: list | None = None) -> ChatResult: - agent = product.ai_agent +def invoke_chat(*, channel, messages: list[ChatMessage], purpose: str, release=None, model: str | None = None, params: dict | None = None, used_fragment_ids: list | None = None) -> ChatResult: + agent = channel.ai_agent model = model or agent.model try: - limits.assert_within_limits(product, agent) + limits.assert_within_limits(channel, agent) except limits.LimitExceeded as error: LlmInvocation.objects.create( - product=product, release=release, purpose=purpose, operation="chat", model=model, + channel=channel, product=channel.product, release=release, purpose=purpose, operation="chat", model=model, status=LlmInvocationStatus.BLOCKED, error=str(error), ) raise @@ -37,14 +37,14 @@ def invoke_chat(*, product, messages: list[ChatMessage], purpose: str, release=N ) except ProviderError as error: LlmInvocation.objects.create( - product=product, release=release, purpose=purpose, operation="chat", model=model, + channel=channel, product=channel.product, release=release, purpose=purpose, operation="chat", model=model, status=LlmInvocationStatus.ERROR, error=str(error)[:1000], latency_ms=int((time.monotonic() - started) * 1000), ) raise LlmInvocation.objects.create( - product=product, release=release, purpose=purpose, operation="chat", model=result.model, + channel=channel, product=channel.product, release=release, purpose=purpose, operation="chat", model=result.model, prompt_tokens=result.prompt_tokens, completion_tokens=result.completion_tokens, total_tokens=result.total_tokens, cost_micros=pricing.cost_micros(result.model, result.prompt_tokens, result.completion_tokens), latency_ms=int((time.monotonic() - started) * 1000), status=LlmInvocationStatus.SUCCESS, @@ -83,7 +83,7 @@ def channel_chat(*, channel, messages: list[ChatMessage], purpose: str, model: s return result -def embed_texts(*, product, texts: list[str], model: str, purpose: str = "retrieval") -> list[EmbeddingResult]: +def embed_texts(*, channel=None, texts: list[str], model: str, purpose: str = "retrieval") -> list[EmbeddingResult]: # Знания авторские (не клиентские PII), поэтому redaction не требуется. provider = get_provider() results: list[EmbeddingResult] = call_with_resilience( @@ -93,7 +93,7 @@ def embed_texts(*, product, texts: list[str], model: str, purpose: str = "retrie ) tokens = sum(result.tokens for result in results) LlmInvocation.objects.create( - product=product, purpose=purpose, operation="embedding", model=model, + channel=channel, product=(channel.product if channel else None), purpose=purpose, operation="embedding", model=model, prompt_tokens=tokens, total_tokens=tokens, cost_micros=pricing.cost_micros(model, tokens, 0), status=LlmInvocationStatus.SUCCESS, ) diff --git a/apps/backend/hub_platform/ai/limits.py b/apps/backend/hub_platform/ai/limits.py index 6b5da84..e9ae220 100644 --- a/apps/backend/hub_platform/ai/limits.py +++ b/apps/backend/hub_platform/ai/limits.py @@ -14,17 +14,17 @@ def _day_start(): return now.replace(hour=0, minute=0, second=0, microsecond=0) -def daily_cost_micros(product=None) -> int: +def daily_cost_micros(channel=None) -> int: queryset = LlmInvocation.objects.filter(created_at__gte=_day_start(), status=LlmInvocationStatus.SUCCESS) - if product is not None: - queryset = queryset.filter(product=product) + if channel is not None: + queryset = queryset.filter(channel=channel) return queryset.aggregate(total=Sum("cost_micros"))["total"] or 0 -def assert_within_limits(product, agent) -> None: +def assert_within_limits(channel, agent) -> None: global_limit = settings.HUB_AI_GLOBAL_DAILY_COST_LIMIT_MICROS if global_limit and daily_cost_micros() >= global_limit: raise LimitExceeded("Global daily AI cost limit reached") - product_limit = (agent.limits or {}).get("dailyCostMicros") - if product_limit and daily_cost_micros(product) >= product_limit: - raise LimitExceeded("Product daily AI cost limit reached") + channel_limit = (agent.limits or {}).get("dailyCostMicros") + if channel_limit and daily_cost_micros(channel) >= channel_limit: + raise LimitExceeded("Channel daily AI cost limit reached") diff --git a/apps/backend/hub_platform/ai/migrations/0001_initial.py b/apps/backend/hub_platform/ai/migrations/0001_initial.py index f70831b..16d57b4 100644 --- a/apps/backend/hub_platform/ai/migrations/0001_initial.py +++ b/apps/backend/hub_platform/ai/migrations/0001_initial.py @@ -1,7 +1,10 @@ -# Generated by Django 5.2.15 on 2026-06-23 20:57 +# Generated by Django 5.2.15 on 2026-06-27 22:30 import django.db.models.deletion +import pgvector.django.vector +from django.conf import settings from django.db import migrations, models +from pgvector.django import VectorExtension class Migration(migrations.Migration): @@ -9,23 +12,206 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('products', '0004_remove_price_uniq_price_offer_version_and_more'), + ('channels', '0002_channel_system_prompt'), + ('identity', '0006_alter_employeeprofile_totp_secret'), + ('products', '0005_remove_price_price_amount_positive_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ + VectorExtension(), migrations.CreateModel( name='AIAgent', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=255)), ('is_active', models.BooleanField(default=True)), - ('model', models.CharField(default='openai/gpt-4o-mini', max_length=128)), + ('model', models.CharField(default='anthropic/claude-sonnet-4.6', max_length=128)), ('model_params', models.JSONField(blank=True, default=dict)), ('allowed_tools', models.JSONField(blank=True, default=list)), ('limits', models.JSONField(blank=True, default=dict)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), - ('product', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='ai_agent', to='products.product')), + ('channel', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='ai_agent', to='channels.channel')), ], ), + migrations.CreateModel( + name='ChannelAIRelease', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.PositiveIntegerField()), + ('status', models.CharField(choices=[('DRAFT', 'Черновик'), ('PUBLISHED', 'Опубликован'), ('ARCHIVED', 'Архив')], default='DRAFT', max_length=16)), + ('model', models.CharField(max_length=128)), + ('model_params', models.JSONField(blank=True, default=dict)), + ('allowed_tools', models.JSONField(blank=True, default=list)), + ('limits', models.JSONField(blank=True, default=dict)), + ('retrieval_index_version', models.CharField(blank=True, max_length=64)), + ('notes', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('published_at', models.DateTimeField(blank=True, null=True)), + ('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ai_releases', to='channels.channel')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['channel_id', '-version'], + }, + ), + migrations.CreateModel( + name='KnowledgeDocument', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('scope', models.CharField(choices=[('GLOBAL', 'Глобальное'), ('PRODUCT', 'Продуктовое')], default='GLOBAL', max_length=16)), + ('code', models.SlugField(max_length=64)), + ('title', models.CharField(max_length=255)), + ('is_enabled', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('category', models.CharField(choices=[('OVERVIEW', 'Обзор продукта'), ('AUDIENCE', 'Целевая аудитория'), ('COMMERCIAL', 'Коммерческая модель'), ('TECHNICAL', 'Техническая информация'), ('FAQ', 'FAQ'), ('OBJECTIONS', 'Возражения'), ('LIMITATIONS', 'Ограничения')], max_length=32)), + ('inclusion_mode', models.CharField(choices=[('MANDATORY', 'Обязательное включение'), ('RETRIEVAL', 'Доступно для retrieval')], default='RETRIEVAL', max_length=16)), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='identity.organization')), + ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='products.product')), + ], + ), + migrations.CreateModel( + name='KnowledgeDocumentVersion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.PositiveIntegerField()), + ('content', models.TextField(blank=True)), + ('status', models.CharField(choices=[('DRAFT', 'Черновик'), ('PUBLISHED', 'Опубликован'), ('ARCHIVED', 'Архив')], default='DRAFT', max_length=16)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='ai.knowledgedocument')), + ], + options={ + 'ordering': ['-version'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='KnowledgeFragment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('chunk_index', models.PositiveIntegerField()), + ('content', models.TextField()), + ('embedding', pgvector.django.vector.VectorField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fragments', to='ai.knowledgedocumentversion')), + ], + options={ + 'ordering': ['version_id', 'chunk_index'], + }, + ), + migrations.CreateModel( + name='LlmInvocation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('purpose', models.CharField(max_length=64)), + ('operation', models.CharField(max_length=16)), + ('model', models.CharField(blank=True, max_length=128)), + ('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(default='USD', max_length=3)), + ('latency_ms', models.PositiveIntegerField(default=0)), + ('status', models.CharField(choices=[('SUCCESS', 'Успех'), ('ERROR', 'Ошибка'), ('BLOCKED', 'Заблокировано лимитом')], default='SUCCESS', max_length=16)), + ('error', models.TextField(blank=True)), + ('used_fragment_ids', models.JSONField(blank=True, default=list)), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('channel', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ai_invocations', to='channels.channel')), + ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ai_invocations', to='products.product')), + ('release', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invocations', to='ai.channelairelease')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='PromptDocument', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('scope', models.CharField(choices=[('GLOBAL', 'Глобальное'), ('PRODUCT', 'Продуктовое')], default='GLOBAL', max_length=16)), + ('code', models.SlugField(max_length=64)), + ('title', models.CharField(max_length=255)), + ('is_enabled', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('category', models.CharField(choices=[('SYSTEM', 'Системный промпт'), ('QUALIFICATION', 'Квалификация'), ('SALES_BEHAVIOR', 'Поведение в продаже'), ('OPERATOR_HANDOFF', 'Передача оператору')], max_length=32)), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='identity.organization')), + ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='products.product')), + ], + ), + migrations.CreateModel( + name='PromptDocumentVersion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('version', models.PositiveIntegerField()), + ('content', models.TextField(blank=True)), + ('status', models.CharField(choices=[('DRAFT', 'Черновик'), ('PUBLISHED', 'Опубликован'), ('ARCHIVED', 'Архив')], default='DRAFT', max_length=16)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='ai.promptdocument')), + ], + options={ + 'ordering': ['-version'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ReleaseKnowledgeVersion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('knowledge_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='ai.knowledgedocumentversion')), + ('release', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='knowledge_versions', to='ai.channelairelease')), + ], + ), + migrations.CreateModel( + name='ReleasePromptVersion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('prompt_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='ai.promptdocumentversion')), + ('release', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='prompt_versions', to='ai.channelairelease')), + ], + ), + migrations.AddConstraint( + model_name='channelairelease', + constraint=models.UniqueConstraint(fields=('channel', 'version'), name='uniq_release_channel_version'), + ), + migrations.AddConstraint( + model_name='channelairelease', + constraint=models.UniqueConstraint(condition=models.Q(('status', 'PUBLISHED')), fields=('channel',), name='uniq_active_release_per_channel'), + ), + migrations.AddConstraint( + model_name='knowledgedocument', + constraint=models.UniqueConstraint(fields=('organization', 'product', 'code'), name='uniq_knowledge_doc_org_product_code'), + ), + migrations.AddConstraint( + model_name='knowledgedocumentversion', + constraint=models.UniqueConstraint(fields=('document', 'version'), name='uniq_knowledge_version'), + ), + migrations.AddConstraint( + model_name='knowledgefragment', + constraint=models.UniqueConstraint(fields=('version', 'chunk_index'), name='uniq_fragment_version_chunk'), + ), + migrations.AddIndex( + model_name='llminvocation', + index=models.Index(fields=['channel', 'created_at'], name='ai_llminvoc_channel_dbf239_idx'), + ), + migrations.AddConstraint( + model_name='promptdocument', + constraint=models.UniqueConstraint(fields=('organization', 'product', 'code'), name='uniq_prompt_doc_org_product_code'), + ), + migrations.AddConstraint( + model_name='promptdocumentversion', + constraint=models.UniqueConstraint(fields=('document', 'version'), name='uniq_prompt_version'), + ), + migrations.AddConstraint( + model_name='releaseknowledgeversion', + constraint=models.UniqueConstraint(fields=('release', 'knowledge_version'), name='uniq_release_knowledge_version'), + ), + migrations.AddConstraint( + model_name='releasepromptversion', + constraint=models.UniqueConstraint(fields=('release', 'prompt_version'), name='uniq_release_prompt_version'), + ), ] diff --git a/apps/backend/hub_platform/ai/migrations/0002_seed_sales_agents.py b/apps/backend/hub_platform/ai/migrations/0002_seed_sales_agents.py deleted file mode 100644 index 65aa823..0000000 --- a/apps/backend/hub_platform/ai/migrations/0002_seed_sales_agents.py +++ /dev/null @@ -1,22 +0,0 @@ -from django.db import migrations - - -def seed_agents(apps, schema_editor): - Product = apps.get_model("products", "Product") - AIAgent = apps.get_model("ai", "AIAgent") - for product in Product.objects.all(): - AIAgent.objects.get_or_create(product=product, defaults={"name": f"{product.name} Sales Agent"}) - - -def remove_agents(apps, schema_editor): - apps.get_model("ai", "AIAgent").objects.all().delete() - - -class Migration(migrations.Migration): - - dependencies = [ - ("ai", "0001_initial"), - ("products", "0001_move_product_state"), - ] - - operations = [migrations.RunPython(seed_agents, remove_agents)] diff --git a/apps/backend/hub_platform/ai/migrations/0003_knowledgedocument_knowledgedocumentversion_and_more.py b/apps/backend/hub_platform/ai/migrations/0003_knowledgedocument_knowledgedocumentversion_and_more.py deleted file mode 100644 index 339b1aa..0000000 --- a/apps/backend/hub_platform/ai/migrations/0003_knowledgedocument_knowledgedocumentversion_and_more.py +++ /dev/null @@ -1,92 +0,0 @@ -# Generated by Django 5.2.15 on 2026-06-23 21:12 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ai', '0002_seed_sales_agents'), - ('products', '0004_remove_price_uniq_price_offer_version_and_more'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='KnowledgeDocument', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('code', models.SlugField(max_length=64)), - ('title', models.CharField(max_length=255)), - ('is_enabled', models.BooleanField(default=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('category', models.CharField(choices=[('OVERVIEW', 'Обзор продукта'), ('AUDIENCE', 'Целевая аудитория'), ('COMMERCIAL', 'Коммерческая модель'), ('TECHNICAL', 'Техническая информация'), ('FAQ', 'FAQ'), ('OBJECTIONS', 'Возражения'), ('LIMITATIONS', 'Ограничения')], max_length=32)), - ('inclusion_mode', models.CharField(choices=[('MANDATORY', 'Обязательное включение'), ('RETRIEVAL', 'Доступно для retrieval')], default='RETRIEVAL', max_length=16)), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='products.product')), - ], - ), - migrations.CreateModel( - name='KnowledgeDocumentVersion', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('version', models.PositiveIntegerField()), - ('content', models.TextField(blank=True)), - ('status', models.CharField(choices=[('DRAFT', 'Черновик'), ('PUBLISHED', 'Опубликован'), ('ARCHIVED', 'Архив')], default='DRAFT', max_length=16)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='ai.knowledgedocument')), - ], - options={ - 'ordering': ['-version'], - 'abstract': False, - }, - ), - migrations.CreateModel( - name='PromptDocument', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('code', models.SlugField(max_length=64)), - ('title', models.CharField(max_length=255)), - ('is_enabled', models.BooleanField(default=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('category', models.CharField(choices=[('SYSTEM', 'Системный промпт'), ('QUALIFICATION', 'Квалификация'), ('SALES_BEHAVIOR', 'Поведение в продаже'), ('OPERATOR_HANDOFF', 'Передача оператору')], max_length=32)), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='%(class)ss', to='products.product')), - ], - ), - migrations.CreateModel( - name='PromptDocumentVersion', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('version', models.PositiveIntegerField()), - ('content', models.TextField(blank=True)), - ('status', models.CharField(choices=[('DRAFT', 'Черновик'), ('PUBLISHED', 'Опубликован'), ('ARCHIVED', 'Архив')], default='DRAFT', max_length=16)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='ai.promptdocument')), - ], - options={ - 'ordering': ['-version'], - 'abstract': False, - }, - ), - migrations.AddConstraint( - model_name='knowledgedocument', - constraint=models.UniqueConstraint(fields=('product', 'code'), name='uniq_knowledge_doc_product_code'), - ), - migrations.AddConstraint( - model_name='knowledgedocumentversion', - constraint=models.UniqueConstraint(fields=('document', 'version'), name='uniq_knowledge_version'), - ), - migrations.AddConstraint( - model_name='promptdocument', - constraint=models.UniqueConstraint(fields=('product', 'code'), name='uniq_prompt_doc_product_code'), - ), - migrations.AddConstraint( - model_name='promptdocumentversion', - constraint=models.UniqueConstraint(fields=('document', 'version'), name='uniq_prompt_version'), - ), - ] diff --git a/apps/backend/hub_platform/ai/migrations/0004_productairelease_releaseknowledgeversion_and_more.py b/apps/backend/hub_platform/ai/migrations/0004_productairelease_releaseknowledgeversion_and_more.py deleted file mode 100644 index eff59cc..0000000 --- a/apps/backend/hub_platform/ai/migrations/0004_productairelease_releaseknowledgeversion_and_more.py +++ /dev/null @@ -1,70 +0,0 @@ -# Generated by Django 5.2.15 on 2026-06-23 21:19 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ai', '0003_knowledgedocument_knowledgedocumentversion_and_more'), - ('products', '0004_remove_price_uniq_price_offer_version_and_more'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='ProductAIRelease', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('version', models.PositiveIntegerField()), - ('status', models.CharField(choices=[('DRAFT', 'Черновик'), ('PUBLISHED', 'Опубликован'), ('ARCHIVED', 'Архив')], default='DRAFT', max_length=16)), - ('model', models.CharField(max_length=128)), - ('model_params', models.JSONField(blank=True, default=dict)), - ('allowed_tools', models.JSONField(blank=True, default=list)), - ('limits', models.JSONField(blank=True, default=dict)), - ('retrieval_index_version', models.CharField(blank=True, max_length=64)), - ('notes', models.TextField(blank=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('published_at', models.DateTimeField(blank=True, null=True)), - ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ai_releases', to='products.product')), - ], - options={ - 'ordering': ['product_id', '-version'], - }, - ), - migrations.CreateModel( - name='ReleaseKnowledgeVersion', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('knowledge_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='ai.knowledgedocumentversion')), - ('release', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='knowledge_versions', to='ai.productairelease')), - ], - ), - migrations.CreateModel( - name='ReleasePromptVersion', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('prompt_version', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='ai.promptdocumentversion')), - ('release', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='prompt_versions', to='ai.productairelease')), - ], - ), - migrations.AddConstraint( - model_name='productairelease', - constraint=models.UniqueConstraint(fields=('product', 'version'), name='uniq_release_product_version'), - ), - migrations.AddConstraint( - model_name='productairelease', - constraint=models.UniqueConstraint(condition=models.Q(('status', 'PUBLISHED')), fields=('product',), name='uniq_active_release_per_product'), - ), - migrations.AddConstraint( - model_name='releaseknowledgeversion', - constraint=models.UniqueConstraint(fields=('release', 'knowledge_version'), name='uniq_release_knowledge_version'), - ), - migrations.AddConstraint( - model_name='releasepromptversion', - constraint=models.UniqueConstraint(fields=('release', 'prompt_version'), name='uniq_release_prompt_version'), - ), - ] diff --git a/apps/backend/hub_platform/ai/migrations/0005_llminvocation.py b/apps/backend/hub_platform/ai/migrations/0005_llminvocation.py deleted file mode 100644 index 7fc2b37..0000000 --- a/apps/backend/hub_platform/ai/migrations/0005_llminvocation.py +++ /dev/null @@ -1,39 +0,0 @@ -# Generated by Django 5.2.15 on 2026-06-23 21:41 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ai', '0004_productairelease_releaseknowledgeversion_and_more'), - ('products', '0004_remove_price_uniq_price_offer_version_and_more'), - ] - - operations = [ - migrations.CreateModel( - name='LlmInvocation', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('purpose', models.CharField(max_length=64)), - ('operation', models.CharField(max_length=16)), - ('model', models.CharField(blank=True, max_length=128)), - ('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(default='USD', max_length=3)), - ('latency_ms', models.PositiveIntegerField(default=0)), - ('status', models.CharField(choices=[('SUCCESS', 'Успех'), ('ERROR', 'Ошибка'), ('BLOCKED', 'Заблокировано лимитом')], default='SUCCESS', max_length=16)), - ('error', models.TextField(blank=True)), - ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), - ('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='ai_invocations', to='products.product')), - ('release', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='invocations', to='ai.productairelease')), - ], - options={ - 'ordering': ['-created_at'], - 'indexes': [models.Index(fields=['product', 'created_at'], name='ai_llminvoc_product_2936b3_idx')], - }, - ), - ] diff --git a/apps/backend/hub_platform/ai/migrations/0006_llminvocation_used_fragment_ids_knowledgefragment.py b/apps/backend/hub_platform/ai/migrations/0006_llminvocation_used_fragment_ids_knowledgefragment.py deleted file mode 100644 index c2cc344..0000000 --- a/apps/backend/hub_platform/ai/migrations/0006_llminvocation_used_fragment_ids_knowledgefragment.py +++ /dev/null @@ -1,37 +0,0 @@ -# Generated by Django 5.2.15 on 2026-06-24 16:40 - -import django.db.models.deletion -import pgvector.django.vector -from django.db import migrations, models -from pgvector.django import VectorExtension - - -class Migration(migrations.Migration): - - dependencies = [ - ('ai', '0005_llminvocation'), - ] - - operations = [ - VectorExtension(), - migrations.AddField( - model_name='llminvocation', - name='used_fragment_ids', - field=models.JSONField(blank=True, default=list), - ), - migrations.CreateModel( - name='KnowledgeFragment', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('chunk_index', models.PositiveIntegerField()), - ('content', models.TextField()), - ('embedding', pgvector.django.vector.VectorField(blank=True, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('version', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fragments', to='ai.knowledgedocumentversion')), - ], - options={ - 'ordering': ['version_id', 'chunk_index'], - 'constraints': [models.UniqueConstraint(fields=('version', 'chunk_index'), name='uniq_fragment_version_chunk')], - }, - ), - ] diff --git a/apps/backend/hub_platform/ai/migrations/0007_llminvocation_channel_alter_llminvocation_product.py b/apps/backend/hub_platform/ai/migrations/0007_llminvocation_channel_alter_llminvocation_product.py deleted file mode 100644 index b8ab2e2..0000000 --- a/apps/backend/hub_platform/ai/migrations/0007_llminvocation_channel_alter_llminvocation_product.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.2.15 on 2026-06-27 22:16 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('ai', '0006_llminvocation_used_fragment_ids_knowledgefragment'), - ('channels', '0002_channel_system_prompt'), - ('products', '0005_remove_price_price_amount_positive_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='llminvocation', - name='channel', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='ai_invocations', to='channels.channel'), - ), - migrations.AlterField( - model_name='llminvocation', - name='product', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ai_invocations', to='products.product'), - ), - ] diff --git a/apps/backend/hub_platform/ai/models.py b/apps/backend/hub_platform/ai/models.py index 62aaf58..f328501 100644 --- a/apps/backend/hub_platform/ai/models.py +++ b/apps/backend/hub_platform/ai/models.py @@ -3,12 +3,12 @@ from django.core.exceptions import ValidationError from django.db import models from pgvector.django import VectorField -# Один основной sales-агент на продукт (ADR-HUB-0007). -DEFAULT_AI_MODEL = "openai/gpt-4o-mini" +# Один основной агент на канал обработки (ADR-HUB-0007/0019). +DEFAULT_AI_MODEL = "anthropic/claude-sonnet-4.6" class AIAgent(models.Model): - product = models.OneToOneField("products.Product", on_delete=models.PROTECT, related_name="ai_agent") + channel = models.OneToOneField("channels.Channel", on_delete=models.CASCADE, related_name="ai_agent") name = models.CharField(max_length=255) is_active = models.BooleanField(default=True) model = models.CharField(max_length=128, default=DEFAULT_AI_MODEL) @@ -19,7 +19,7 @@ class AIAgent(models.Model): updated_at = models.DateTimeField(auto_now=True) def __str__(self) -> str: - return f"{self.product.code}:agent" + return f"{self.channel.code}:agent" # --- Knowledge & prompt documents (ADR-HUB-0005) --- @@ -31,6 +31,11 @@ class DocumentStatus(models.TextChoices): ARCHIVED = "ARCHIVED", "Архив" +class DocumentScope(models.TextChoices): + GLOBAL = "GLOBAL", "Глобальное" + PRODUCT = "PRODUCT", "Продуктовое" + + class KnowledgeCategory(models.TextChoices): OVERVIEW = "OVERVIEW", "Обзор продукта" AUDIENCE = "AUDIENCE", "Целевая аудитория" @@ -54,7 +59,10 @@ class InclusionMode(models.TextChoices): class _BaseDocument(models.Model): - product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="%(class)ss") + # Библиотека со scope: GLOBAL или PRODUCT (ADR-HUB-0005/0019). + organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="%(class)ss") + scope = models.CharField(max_length=16, choices=DocumentScope.choices, default=DocumentScope.GLOBAL) + product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="%(class)ss", null=True, blank=True) code = models.SlugField(max_length=64) title = models.CharField(max_length=255) is_enabled = models.BooleanField(default=True) @@ -70,20 +78,20 @@ class KnowledgeDocument(_BaseDocument): inclusion_mode = models.CharField(max_length=16, choices=InclusionMode.choices, default=InclusionMode.RETRIEVAL) class Meta: - constraints = [models.UniqueConstraint(fields=["product", "code"], name="uniq_knowledge_doc_product_code")] + constraints = [models.UniqueConstraint(fields=["organization", "product", "code"], name="uniq_knowledge_doc_org_product_code")] def __str__(self) -> str: - return f"knowledge:{self.product_id}/{self.code}" + return f"knowledge:{self.organization_id}/{self.code}" class PromptDocument(_BaseDocument): category = models.CharField(max_length=32, choices=PromptCategory.choices) class Meta: - constraints = [models.UniqueConstraint(fields=["product", "code"], name="uniq_prompt_doc_product_code")] + constraints = [models.UniqueConstraint(fields=["organization", "product", "code"], name="uniq_prompt_doc_org_product_code")] def __str__(self) -> str: - return f"prompt:{self.product_id}/{self.code}" + return f"prompt:{self.organization_id}/{self.code}" class _BaseDocumentVersion(models.Model): @@ -136,7 +144,7 @@ class PromptDocumentVersion(_BaseDocumentVersion): constraints = [models.UniqueConstraint(fields=["document", "version"], name="uniq_prompt_version")] -# --- ProductAIRelease: атомарный immutable-снимок конфигурации (ADR-HUB-0007) --- +# --- ChannelAIRelease: атомарный immutable-снимок конфигурации канала (ADR-HUB-0007/0019) --- class ReleaseStatus(models.TextChoices): @@ -145,8 +153,8 @@ class ReleaseStatus(models.TextChoices): ARCHIVED = "ARCHIVED", "Архив" -class ProductAIRelease(models.Model): - product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="ai_releases") +class ChannelAIRelease(models.Model): + channel = models.ForeignKey("channels.Channel", on_delete=models.CASCADE, related_name="ai_releases") version = models.PositiveIntegerField() status = models.CharField(max_length=16, choices=ReleaseStatus.choices, default=ReleaseStatus.DRAFT) # immutable snapshot of the working configuration @@ -160,32 +168,32 @@ class ProductAIRelease(models.Model): created_at = models.DateTimeField(auto_now_add=True) published_at = models.DateTimeField(null=True, blank=True) - _SNAPSHOT_FIELDS = ("product_id", "version", "model", "model_params", "allowed_tools", "limits", "retrieval_index_version") + _SNAPSHOT_FIELDS = ("channel_id", "version", "model", "model_params", "allowed_tools", "limits", "retrieval_index_version") class Meta: - ordering = ["product_id", "-version"] + ordering = ["channel_id", "-version"] constraints = [ - models.UniqueConstraint(fields=["product", "version"], name="uniq_release_product_version"), + models.UniqueConstraint(fields=["channel", "version"], name="uniq_release_channel_version"), models.UniqueConstraint( - fields=["product"], + fields=["channel"], condition=models.Q(status="PUBLISHED"), - name="uniq_active_release_per_product", + name="uniq_active_release_per_channel", ), ] def save(self, *args: object, **kwargs: object) -> None: if self.pk: - current = ProductAIRelease.objects.get(pk=self.pk) + current = ChannelAIRelease.objects.get(pk=self.pk) if any(getattr(current, field) != getattr(self, field) for field in self._SNAPSHOT_FIELDS): raise ValidationError("Published release configuration is immutable") super().save(*args, **kwargs) def __str__(self) -> str: - return f"release:{self.product_id}/v{self.version}" + return f"release:{self.channel_id}/v{self.version}" class ReleaseKnowledgeVersion(models.Model): - release = models.ForeignKey(ProductAIRelease, on_delete=models.CASCADE, related_name="knowledge_versions") + release = models.ForeignKey(ChannelAIRelease, on_delete=models.CASCADE, related_name="knowledge_versions") knowledge_version = models.ForeignKey(KnowledgeDocumentVersion, on_delete=models.PROTECT, related_name="+") class Meta: @@ -193,7 +201,7 @@ class ReleaseKnowledgeVersion(models.Model): class ReleasePromptVersion(models.Model): - release = models.ForeignKey(ProductAIRelease, on_delete=models.CASCADE, related_name="prompt_versions") + release = models.ForeignKey(ChannelAIRelease, on_delete=models.CASCADE, related_name="prompt_versions") prompt_version = models.ForeignKey(PromptDocumentVersion, on_delete=models.PROTECT, related_name="+") class Meta: @@ -210,10 +218,10 @@ class LlmInvocationStatus(models.TextChoices): class LlmInvocation(models.Model): - # Учёт по каналу (M1.2a) и/или продукту (исторический product-якорь). + # Учёт по каналу (ADR-HUB-0019) и/или продукту, если канал продуктовый. channel = models.ForeignKey("channels.Channel", on_delete=models.SET_NULL, null=True, blank=True, related_name="ai_invocations") product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="ai_invocations", null=True, blank=True) - release = models.ForeignKey(ProductAIRelease, on_delete=models.SET_NULL, null=True, blank=True, related_name="invocations") + release = models.ForeignKey(ChannelAIRelease, on_delete=models.SET_NULL, null=True, blank=True, related_name="invocations") purpose = models.CharField(max_length=64) operation = models.CharField(max_length=16) # chat | embedding model = models.CharField(max_length=128, blank=True) @@ -230,7 +238,7 @@ class LlmInvocation(models.Model): class Meta: ordering = ["-created_at"] - indexes = [models.Index(fields=["product", "created_at"])] + indexes = [models.Index(fields=["channel", "created_at"])] def __str__(self) -> str: - return f"llm:{self.product_id}/{self.operation}/{self.status}" + return f"llm:{self.channel_id}/{self.operation}/{self.status}" diff --git a/apps/backend/hub_platform/ai/release_views.py b/apps/backend/hub_platform/ai/release_views.py index 0ef777a..7a62873 100644 --- a/apps/backend/hub_platform/ai/release_views.py +++ b/apps/backend/hub_platform/ai/release_views.py @@ -4,14 +4,14 @@ from rest_framework.views import APIView from hub_platform.ai import limits, pricing from hub_platform.ai import releases as release_service -from hub_platform.ai.models import ProductAIRelease, ReleaseStatus +from hub_platform.ai.models import ChannelAIRelease, ReleaseStatus from hub_platform.ai.provider.base import ProviderError from hub_platform.ai.runtime import run_test_chat from hub_platform.ai.selectors import release_for_organization, releases_for_organization from hub_platform.ai.serializers import release_payload from hub_platform.api.permissions import IsOwner +from hub_platform.channels.models import Channel from hub_platform.identity.audit import record_audit_event -from hub_platform.products.models import Product class _ReleaseBase(APIView): @@ -20,15 +20,15 @@ class _ReleaseBase(APIView): def _org(self, request: Request): return request.user.employee_profile.organization - def _release(self, request: Request, release_id: int) -> ProductAIRelease: + def _release(self, request: Request, release_id: int) -> ChannelAIRelease: return release_for_organization(organization_id=self._org(request).id, release_id=release_id) - def _audit(self, request: Request, action: str, release: ProductAIRelease) -> None: + def _audit(self, request: Request, action: str, release: ChannelAIRelease) -> None: record_audit_event( action=f"ai.release_{action}", actor=request.user, organization=self._org(request), - object_type="ProductAIRelease", + object_type="ChannelAIRelease", object_id=str(release.id), request=request, ) @@ -36,18 +36,18 @@ class _ReleaseBase(APIView): class ReleaseListCreateView(_ReleaseBase): def get(self, request: Request) -> Response: - releases = releases_for_organization(self._org(request).id, request.query_params.get("product")) + releases = releases_for_organization(self._org(request).id, request.query_params.get("channel")) return Response({"items": [release_payload(release) for release in releases]}) def post(self, request: Request) -> Response: try: - product = Product.objects.select_related("ai_agent").get( - organization=self._org(request), code=str(request.data.get("product", "")) + channel = Channel.objects.select_related("ai_agent").get( + organization=self._org(request), code=str(request.data.get("channel", "")) ) - except Product.DoesNotExist: - return Response({"detail": "Product not found"}, status=400) + except Channel.DoesNotExist: + return Response({"detail": "Channel not found"}, status=400) release = release_service.create_draft_release( - product=product, author=request.user, notes=str(request.data.get("notes", "")) + channel=channel, author=request.user, notes=str(request.data.get("notes", "")) ) release = self._release(request, release.id) self._audit(request, "drafted", release) @@ -58,7 +58,7 @@ class ReleaseDetailView(_ReleaseBase): def get(self, request: Request, release_id: int) -> Response: try: release = self._release(request, release_id) - except ProductAIRelease.DoesNotExist: + except ChannelAIRelease.DoesNotExist: return Response({"detail": "Release not found"}, status=404) return Response({"release": release_payload(release)}) @@ -67,7 +67,7 @@ class ReleasePublishView(_ReleaseBase): def post(self, request: Request, release_id: int) -> Response: try: release = self._release(request, release_id) - except ProductAIRelease.DoesNotExist: + except ChannelAIRelease.DoesNotExist: return Response({"detail": "Release not found"}, status=404) if release.status == ReleaseStatus.PUBLISHED: return Response({"detail": "Release is already published"}, status=400) @@ -81,9 +81,9 @@ class ReleaseRollbackView(_ReleaseBase): def post(self, request: Request, release_id: int) -> Response: try: source = self._release(request, release_id) - except ProductAIRelease.DoesNotExist: + except ChannelAIRelease.DoesNotExist: return Response({"detail": "Release not found"}, status=404) - draft = release_service.rollback_to_release(product=source.product, source=source, author=request.user) + draft = release_service.rollback_to_release(channel=source.channel, source=source, author=request.user) draft = self._release(request, draft.id) self._audit(request, "rolled_back", draft) return Response({"release": release_payload(draft)}, status=201) @@ -93,7 +93,7 @@ class ReleaseTestChatView(_ReleaseBase): def post(self, request: Request, release_id: int) -> Response: try: release = self._release(request, release_id) - except ProductAIRelease.DoesNotExist: + except ChannelAIRelease.DoesNotExist: return Response({"detail": "Release not found"}, status=404) message = str(request.data.get("message", "")).strip() if not message: diff --git a/apps/backend/hub_platform/ai/releases.py b/apps/backend/hub_platform/ai/releases.py index 6a00712..d701611 100644 --- a/apps/backend/hub_platform/ai/releases.py +++ b/apps/backend/hub_platform/ai/releases.py @@ -1,10 +1,12 @@ from django.db import transaction +from django.db.models import Q from django.utils import timezone from hub_platform.ai.models import ( + ChannelAIRelease, + DocumentScope, DocumentStatus, KnowledgeDocumentVersion, - ProductAIRelease, PromptDocumentVersion, ReleaseKnowledgeVersion, ReleasePromptVersion, @@ -12,16 +14,24 @@ from hub_platform.ai.models import ( ) -def _next_version(product) -> int: - last = ProductAIRelease.objects.filter(product=product).order_by("-version").first() +def _next_version(channel) -> int: + last = ChannelAIRelease.objects.filter(channel=channel).order_by("-version").first() return last.version + 1 if last is not None else 1 +def _channel_scope_filter(channel) -> Q: + # Документы канала: глобальные + относящиеся к продукту канала (если он есть). + scope = Q(document__scope=DocumentScope.GLOBAL) + if channel.product_id: + scope |= Q(document__product_id=channel.product_id) + return scope + + @transaction.atomic -def _create_release(*, product, author, model, model_params, allowed_tools, limits, retrieval_index_version, notes, knowledge_versions, prompt_versions) -> ProductAIRelease: - release = ProductAIRelease.objects.create( - product=product, - version=_next_version(product), +def _create_release(*, channel, author, model, model_params, allowed_tools, limits, retrieval_index_version, notes, knowledge_versions, prompt_versions) -> ChannelAIRelease: + release = ChannelAIRelease.objects.create( + channel=channel, + version=_next_version(channel), status=ReleaseStatus.DRAFT, model=model, model_params=model_params, @@ -40,17 +50,24 @@ def _create_release(*, product, author, model, model_params, allowed_tools, limi return release -def create_draft_release(*, product, author, notes: str = "") -> ProductAIRelease: - # Снимок текущей конфигурации агента + опубликованных версий включённых документов. - agent = product.ai_agent +def create_draft_release(*, channel, author, notes: str = "") -> ChannelAIRelease: + # Снимок текущей конфигурации агента канала + опубликованных версий включённых документов. + agent = channel.ai_agent + organization_id = channel.organization_id knowledge_versions = KnowledgeDocumentVersion.objects.filter( - document__product=product, document__is_enabled=True, status=DocumentStatus.PUBLISHED + _channel_scope_filter(channel), + document__organization_id=organization_id, + document__is_enabled=True, + status=DocumentStatus.PUBLISHED, ) prompt_versions = PromptDocumentVersion.objects.filter( - document__product=product, document__is_enabled=True, status=DocumentStatus.PUBLISHED + _channel_scope_filter(channel), + document__organization_id=organization_id, + document__is_enabled=True, + status=DocumentStatus.PUBLISHED, ) return _create_release( - product=product, + channel=channel, author=author, model=agent.model, model_params=agent.model_params, @@ -63,9 +80,9 @@ def create_draft_release(*, product, author, notes: str = "") -> ProductAIReleas ) -def create_initial_draft_release(*, product, author, model, model_params, allowed_tools, limits, knowledge_versions, prompt_versions) -> ProductAIRelease: +def create_initial_draft_release(*, channel, author, model, model_params, allowed_tools, limits, knowledge_versions, prompt_versions) -> ChannelAIRelease: return _create_release( - product=product, + channel=channel, author=author, model=model, model_params=model_params, @@ -79,8 +96,8 @@ def create_initial_draft_release(*, product, author, model, model_params, allowe @transaction.atomic -def publish_release(*, release: ProductAIRelease) -> ProductAIRelease: - ProductAIRelease.objects.filter(product=release.product, status=ReleaseStatus.PUBLISHED).exclude( +def publish_release(*, release: ChannelAIRelease) -> ChannelAIRelease: + ChannelAIRelease.objects.filter(channel=release.channel, status=ReleaseStatus.PUBLISHED).exclude( pk=release.pk ).update(status=ReleaseStatus.ARCHIVED, published_at=timezone.now()) release.status = ReleaseStatus.PUBLISHED @@ -89,10 +106,10 @@ def publish_release(*, release: ProductAIRelease) -> ProductAIRelease: return release -def rollback_to_release(*, product, source: ProductAIRelease, author) -> ProductAIRelease: +def rollback_to_release(*, channel, source: ChannelAIRelease, author) -> ChannelAIRelease: # Откат создаёт новый черновик из снимка старого release (ADR-HUB-0007). return _create_release( - product=product, + channel=channel, author=author, model=source.model, model_params=source.model_params, diff --git a/apps/backend/hub_platform/ai/retrieval.py b/apps/backend/hub_platform/ai/retrieval.py index b8847c1..758563e 100644 --- a/apps/backend/hub_platform/ai/retrieval.py +++ b/apps/backend/hub_platform/ai/retrieval.py @@ -3,15 +3,15 @@ from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector from pgvector.django import CosineDistance from hub_platform.ai.invocation import embed_texts -from hub_platform.ai.models import KnowledgeFragment, ProductAIRelease +from hub_platform.ai.models import KnowledgeFragment, ChannelAIRelease -def _release_fragments(release: ProductAIRelease): +def _release_fragments(release: ChannelAIRelease): version_ids = release.knowledge_versions.values_list("knowledge_version_id", flat=True) return KnowledgeFragment.objects.filter(version_id__in=version_ids).select_related("version__document") -def lexical_search(release: ProductAIRelease, query: str, *, limit: int = 5) -> list[KnowledgeFragment]: +def lexical_search(release: ChannelAIRelease, query: str, *, limit: int = 5) -> list[KnowledgeFragment]: if not query.strip(): return [] search_query = SearchQuery(query, search_type="websearch") @@ -23,7 +23,7 @@ def lexical_search(release: ProductAIRelease, query: str, *, limit: int = 5) -> ) -def semantic_search(release: ProductAIRelease, query_vector: list[float], *, limit: int = 5) -> list[KnowledgeFragment]: +def semantic_search(release: ChannelAIRelease, query_vector: list[float], *, limit: int = 5) -> list[KnowledgeFragment]: return list( _release_fragments(release) .filter(embedding__isnull=False) @@ -34,9 +34,9 @@ def semantic_search(release: ProductAIRelease, query_vector: list[float], *, lim class KnowledgeRetriever: """Hybrid retriever: semantic (pgvector) primary, lexical (Postgres FTS) complementary.""" - def retrieve(self, *, release: ProductAIRelease, query: str, limit: int = 5) -> list[KnowledgeFragment]: + def retrieve(self, *, release: ChannelAIRelease, query: str, limit: int = 5) -> list[KnowledgeFragment]: query_vector = embed_texts( - product=release.product, + channel=release.channel, texts=[query], model=settings.HUB_AI_EMBEDDING_MODEL, purpose="retrieval_query", diff --git a/apps/backend/hub_platform/ai/runtime.py b/apps/backend/hub_platform/ai/runtime.py index 4e85e29..8581d17 100644 --- a/apps/backend/hub_platform/ai/runtime.py +++ b/apps/backend/hub_platform/ai/runtime.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from hub_platform.ai.invocation import invoke_chat -from hub_platform.ai.models import KnowledgeFragment, ProductAIRelease, PromptCategory +from hub_platform.ai.models import KnowledgeFragment, ChannelAIRelease, PromptCategory from hub_platform.ai.provider.base import ChatMessage, ChatResult from hub_platform.ai.retrieval import KnowledgeRetriever @@ -20,7 +20,7 @@ class TestChatResult: handoff_suggested: bool -def _release_system_prompt(release: ProductAIRelease) -> str: +def _release_system_prompt(release: ChannelAIRelease) -> str: by_category = { link.prompt_version.document.category: link.prompt_version for link in release.prompt_versions.select_related("prompt_version__document") @@ -33,7 +33,7 @@ def _release_system_prompt(release: ProductAIRelease) -> str: return "\n\n".join(parts) -def run_test_chat(*, release: ProductAIRelease, message: str, history: list[dict] | None = None) -> TestChatResult: +def run_test_chat(*, release: ChannelAIRelease, message: str, history: list[dict] | None = None) -> TestChatResult: fragments = KnowledgeRetriever().retrieve(release=release, query=message, limit=5) messages: list[ChatMessage] = [] @@ -50,7 +50,7 @@ def run_test_chat(*, release: ProductAIRelease, message: str, history: list[dict messages.append(ChatMessage(role="user", content=message)) result = invoke_chat( - product=release.product, + channel=release.channel, messages=messages, purpose="test_chat", release=release, diff --git a/apps/backend/hub_platform/ai/selectors.py b/apps/backend/hub_platform/ai/selectors.py index bf94542..ae89b58 100644 --- a/apps/backend/hub_platform/ai/selectors.py +++ b/apps/backend/hub_platform/ai/selectors.py @@ -1,13 +1,13 @@ from django.db.models import QuerySet -from hub_platform.ai.models import AIAgent, ProductAIRelease +from hub_platform.ai.models import AIAgent, ChannelAIRelease def agents_for_organization(organization_id: int) -> QuerySet[AIAgent]: return ( - AIAgent.objects.select_related("product") - .filter(product__organization_id=organization_id) - .order_by("product__name") + AIAgent.objects.select_related("channel", "channel__product") + .filter(channel__organization_id=organization_id) + .order_by("channel__name") ) @@ -19,30 +19,30 @@ def documents_for_organization(document_model, organization_id: int, product_cod queryset = ( document_model.objects.select_related("product") .prefetch_related("versions") - .filter(product__organization_id=organization_id) + .filter(organization_id=organization_id) ) if product_code: queryset = queryset.filter(product__code=product_code) - return queryset.order_by("product__name", "category", "code") + return queryset.order_by("scope", "category", "code") def document_for_organization(document_model, *, organization_id: int, document_id: int): return documents_for_organization(document_model, organization_id).get(id=document_id) -def releases_for_organization(organization_id: int, product_code: str | None = None) -> QuerySet[ProductAIRelease]: +def releases_for_organization(organization_id: int, channel_code: str | None = None) -> QuerySet[ChannelAIRelease]: queryset = ( - ProductAIRelease.objects.select_related("product") + ChannelAIRelease.objects.select_related("channel", "channel__product") .prefetch_related( "knowledge_versions__knowledge_version__document", "prompt_versions__prompt_version__document", ) - .filter(product__organization_id=organization_id) + .filter(channel__organization_id=organization_id) ) - if product_code: - queryset = queryset.filter(product__code=product_code) - return queryset.order_by("product__name", "-version") + if channel_code: + queryset = queryset.filter(channel__code=channel_code) + return queryset.order_by("channel__name", "-version") -def release_for_organization(*, organization_id: int, release_id: int) -> ProductAIRelease: +def release_for_organization(*, organization_id: int, release_id: int) -> ChannelAIRelease: return releases_for_organization(organization_id).get(id=release_id) diff --git a/apps/backend/hub_platform/ai/serializers.py b/apps/backend/hub_platform/ai/serializers.py index db5b395..2e4d612 100644 --- a/apps/backend/hub_platform/ai/serializers.py +++ b/apps/backend/hub_platform/ai/serializers.py @@ -1,4 +1,13 @@ -from hub_platform.ai.models import AIAgent, KnowledgeDocument, ProductAIRelease, PromptDocument +from hub_platform.ai.models import AIAgent, ChannelAIRelease, KnowledgeDocument, PromptDocument + + +def _channel_ref(channel) -> dict[str, object]: + return { + "id": channel.id, + "code": channel.code, + "name": channel.name, + "product": {"code": channel.product.code, "name": channel.product.name} if channel.product_id else None, + } def _version_payload(version) -> dict[str, object]: @@ -15,7 +24,8 @@ def _version_payload(version) -> dict[str, object]: def _document_payload(document, extra: dict[str, object]) -> dict[str, object]: return { "id": document.id, - "product": {"code": document.product.code, "name": document.product.name}, + "scope": document.scope, + "product": {"code": document.product.code, "name": document.product.name} if document.product_id else None, "code": document.code, "title": document.title, "category": document.category, @@ -35,10 +45,10 @@ def prompt_payload(document: PromptDocument) -> dict[str, object]: return _document_payload(document, {}) -def release_payload(release: ProductAIRelease) -> dict[str, object]: +def release_payload(release: ChannelAIRelease) -> dict[str, object]: return { "id": release.id, - "product": {"code": release.product.code, "name": release.product.name}, + "channel": _channel_ref(release.channel), "version": release.version, "status": release.status, "model": release.model, @@ -63,7 +73,7 @@ def release_payload(release: ProductAIRelease) -> dict[str, object]: def agent_payload(agent: AIAgent) -> dict[str, object]: return { "id": agent.id, - "product": {"code": agent.product.code, "name": agent.product.name}, + "channel": _channel_ref(agent.channel), "name": agent.name, "isActive": agent.is_active, "model": agent.model, diff --git a/apps/backend/hub_platform/ai/services.py b/apps/backend/hub_platform/ai/services.py index ee5493e..c358aed 100644 --- a/apps/backend/hub_platform/ai/services.py +++ b/apps/backend/hub_platform/ai/services.py @@ -6,6 +6,7 @@ from django.db import transaction from hub_platform.ai import releases as release_service from hub_platform.ai.models import ( AIAgent, + DocumentScope, DocumentStatus, KnowledgeDocument, KnowledgeDocumentVersion, @@ -13,7 +14,7 @@ from hub_platform.ai.models import ( PromptDocument, PromptDocumentVersion, ) -from hub_platform.products.models import Product +from hub_platform.channels.models import Channel @dataclass(frozen=True) @@ -27,7 +28,7 @@ class AgentInput: @dataclass(frozen=True) class AgentCreateInput: - product_code: str + channel_code: str model: str system_prompt: str knowledge_document_ids: list[int] @@ -46,13 +47,15 @@ def _next_prompt_version(document: PromptDocument) -> int: return latest.version + 1 if latest else 1 -def _create_prompt_versions(*, product: Product, author, system_prompt: str) -> list[PromptDocumentVersion]: +def _create_prompt_versions(*, channel: Channel, author, system_prompt: str) -> list[PromptDocumentVersion]: + scope = DocumentScope.PRODUCT if channel.product_id else DocumentScope.GLOBAL versions = [] for code, title, category in START_PROMPTS: document, _ = PromptDocument.objects.get_or_create( - product=product, + organization=channel.organization, + product=channel.product, code=code, - defaults={"title": title, "category": category}, + defaults={"title": title, "category": category, "scope": scope}, ) content = system_prompt if category == PromptCategory.SYSTEM else "" versions.append( @@ -67,8 +70,8 @@ def _create_prompt_versions(*, product: Product, author, system_prompt: str) -> return versions -def _selected_knowledge_versions(*, product: Product, document_ids: list[int]) -> list[KnowledgeDocumentVersion]: - documents = KnowledgeDocument.objects.filter(product=product, id__in=document_ids, is_enabled=True).prefetch_related("versions") +def _selected_knowledge_versions(*, organization, document_ids: list[int]) -> list[KnowledgeDocumentVersion]: + documents = KnowledgeDocument.objects.filter(organization=organization, id__in=document_ids, is_enabled=True).prefetch_related("versions") if documents.count() != len(set(document_ids)): raise ValidationError({"knowledgeDocumentIds": "Unknown knowledge document"}) versions = [] @@ -82,29 +85,27 @@ def _selected_knowledge_versions(*, product: Product, document_ids: list[int]) - @transaction.atomic def create_agent(*, organization, author, data: AgentCreateInput) -> tuple[AIAgent, object]: - if not data.product_code: - raise ValidationError({"product": "Product is required"}) + if not data.channel_code: + raise ValidationError({"channel": "Channel is required"}) if not data.model: raise ValidationError({"model": "Model is required"}) - if not data.knowledge_document_ids: - raise ValidationError({"knowledgeDocumentIds": "At least one knowledge document is required"}) try: - product = Product.objects.get(organization=organization, code=data.product_code) - except Product.DoesNotExist as error: - raise ValidationError({"product": "Product not found"}) from error - if AIAgent.objects.filter(product=product).exists(): - raise ValidationError({"product": "Product already has an AI agent"}) + channel = Channel.objects.get(organization=organization, code=data.channel_code) + except Channel.DoesNotExist as error: + raise ValidationError({"channel": "Channel not found"}) from error + if AIAgent.objects.filter(channel=channel).exists(): + raise ValidationError({"channel": "Channel already has an AI agent"}) agent = AIAgent.objects.create( - product=product, - name=f"{product.name} Sales", + channel=channel, + name=f"{channel.name} Agent", is_active=False, model=data.model, ) - knowledge_versions = _selected_knowledge_versions(product=product, document_ids=data.knowledge_document_ids) - prompt_versions = _create_prompt_versions(product=product, author=author, system_prompt=data.system_prompt) + knowledge_versions = _selected_knowledge_versions(organization=organization, document_ids=data.knowledge_document_ids) + prompt_versions = _create_prompt_versions(channel=channel, author=author, system_prompt=data.system_prompt) release = release_service.create_initial_draft_release( - product=product, + channel=channel, author=author, model=agent.model, model_params=agent.model_params, diff --git a/apps/backend/hub_platform/ai/views.py b/apps/backend/hub_platform/ai/views.py index 9b669e4..e251bf0 100644 --- a/apps/backend/hub_platform/ai/views.py +++ b/apps/backend/hub_platform/ai/views.py @@ -51,7 +51,7 @@ class AIAgentListView(APIView): organization=request.user.employee_profile.organization, author=request.user, data=AgentCreateInput( - product_code=str(request.data.get("product", "")).strip(), + channel_code=str(request.data.get("channel", "")).strip(), model=str(request.data.get("model", "")).strip(), system_prompt=str(request.data.get("systemPrompt", "")).strip(), knowledge_document_ids=knowledge_ids, diff --git a/apps/backend/hub_platform/channels/management/commands/seed_channels.py b/apps/backend/hub_platform/channels/management/commands/seed_channels.py index f0fca71..86936b1 100644 --- a/apps/backend/hub_platform/channels/management/commands/seed_channels.py +++ b/apps/backend/hub_platform/channels/management/commands/seed_channels.py @@ -10,6 +10,9 @@ from __future__ import annotations from django.core.management.base import BaseCommand from django.db import transaction +from hub_platform.ai import releases as release_service +from hub_platform.ai.models import AIAgent +from hub_platform.ai.services import AgentCreateInput, create_agent from hub_platform.channels.models import DEFAULT_CHANNEL_MODEL, Channel from hub_platform.identity.models import Department, Organization from hub_platform.integrations.models import Integration, IntegrationProvider @@ -57,10 +60,13 @@ class Command(BaseCommand): .order_by("id") .first() ) + owner = organization.employees.filter(role="OWNER").first() + author = owner.user if owner else None created = 0 + agents_created = 0 for code, name, product_code, prompt in CHANNELS: product = Product.objects.filter(organization=organization, code=product_code).first() if product_code else None - _, was_created = Channel.objects.update_or_create( + channel, was_created = Channel.objects.update_or_create( organization=organization, code=code, defaults={ @@ -74,8 +80,24 @@ class Command(BaseCommand): }, ) created += int(was_created) + # Агент канала + опубликованный релиз (ADR-HUB-0007/0019). + if not AIAgent.objects.filter(channel=channel).exists(): + agent, release = create_agent( + organization=organization, + author=author, + data=AgentCreateInput( + channel_code=code, + model=DEFAULT_CHANNEL_MODEL, + system_prompt=prompt, + knowledge_document_ids=[], + ), + ) + release_service.publish_release(release=release) + agent.is_active = True + agent.save(update_fields=["is_active", "updated_at"]) + agents_created += 1 self.stdout.write( self.style.SUCCESS( - f"channels seeded: +{created} new (provider={'set' if provider else 'none'})" + f"channels seeded: +{created} channels, +{agents_created} agents (provider={'set' if provider else 'none'})" ) ) diff --git a/apps/backend/hub_platform/identity/bootstrap.py b/apps/backend/hub_platform/identity/bootstrap.py index 65189a2..1b2c0b2 100644 --- a/apps/backend/hub_platform/identity/bootstrap.py +++ b/apps/backend/hub_platform/identity/bootstrap.py @@ -2,7 +2,6 @@ from dataclasses import dataclass from django.db import transaction -from hub_platform.ai.models import AIAgent from hub_platform.identity.audit import record_audit_event from hub_platform.identity.models import ( Department, @@ -40,7 +39,7 @@ def bootstrap_edevs_owner(*, email: str, password: str, full_name: str = "") -> for code, name in (("firepage", "FirePage"), ("foxray", "Foxray")): product, _ = Product.objects.get_or_create(organization=organization, code=code, defaults={"name": name}) ProductDepartment.objects.get_or_create(product=product, department=sales_department) - AIAgent.objects.get_or_create(product=product, defaults={"name": f"{product.name} Sales"}) + # AI-агенты теперь на уровне канала обработки (ADR-HUB-0019) — см. seed_channels. owner, created_owner = HumanUser.objects.get_or_create( email=HumanUser.objects.normalize_email(email),