mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-09 19:04:59 +03:00
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:
1 parent
e0febb892f
commit
c3f2eddb5f
23 files changed
+400
-437
No files matched your search
@@ -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",)
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)]
|
||||
-92
@@ -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'),
|
||||
),
|
||||
]
|
||||
-70
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
-37
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
-26
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user