refactor(ai): M1.2b backend — re-anchor agent/release/knowledge to channel

Reseed: AIAgent and ChannelAIRelease (was ProductAIRelease) now belong to a
Channel; knowledge/prompt documents become an org library with scope
GLOBAL/PRODUCT (+ optional product); LlmInvocation, limits, retrieval, runtime,
selectors, services, views and admin re-anchored from product to channel. ai
migrations reset (pgvector extension re-created in 0001). bootstrap no longer
seeds product agents; seed_channels creates each channel's agent + published
release. Verified: channels answer on Sonnet 4.6, agents/releases seeded 3/3.

Note: ai/tests.py and the AI UI section still reference the product anchor and
are updated next (frontend M1.2b).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 4.8 committed 2026-06-28 01:33:18 +03:00
1 parent e0febb892f
commit c3f2eddb5f
23 files changed
+400 -437

No files matched your search

+11 -11
View File
@@ -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",)
+12 -6
View File
@@ -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:
+1 -1
View File
@@ -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",
+8 -8
View File
@@ -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,
)
+7 -7
View File
@@ -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")
@@ -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'),
),
]
@@ -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)]
@@ -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'),
),
]
@@ -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'),
),
]
@@ -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')],
},
),
]
@@ -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')],
},
),
]
@@ -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'),
),
]
+33 -25
View File
@@ -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}"
+16 -16
View File
@@ -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:
+36 -19
View File
@@ -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,
+6 -6
View File
@@ -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",
+4 -4
View File
@@ -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,
+13 -13
View File
@@ -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)
+15 -5
View File
@@ -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,
+22 -21
View File
@@ -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,
+1 -1
View File
@@ -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,
@@ -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'})"
)
)
@@ -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),