refactor(ai): упрощение агента и знаний (ADR-HUB-0023)

Знания: плоская сущность Knowledge (заголовок, описание, MD) + файловые
вложения с оригинальными именами (retrieval из md/txt/pdf/docx, публичная
ссылка для клиента). Агент: без релизов, инструкции из трёх частей
(персонализация/тон/инструкции) + выбор знаний из библиотеки; лимит
диалогов упразднён (остался dailyCostUsd). channel.system_prompt/model
удалены — AI-поведение только на агенте. Продукт — техническая запись
без summary/sales_description. Data-миграция переносит опубликованные
документы и активные релизы в новую модель. Internal-UI: раздел Знания,
новая карточка агента, упрощённые продукты, release-страницы удалены.
ADR-0005/0007/0017 → superseded; SPEC-HUB-0012 переписан.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Fable 5 committed 2026-07-10 17:08:50 +03:00
1 parent 179e4a6dec
commit a086675283
98 files changed
+2273 -4212

No files matched your search

+8
View File
@@ -171,6 +171,14 @@ SECURE_HSTS_PRELOAD = SECURE_HSTS_SECONDS > 0
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
# Файловые вложения знаний (ADR-HUB-0023). Файлы отдаются только через
# download-endpoint (FileResponse), прямого статик-роутинга MEDIA нет.
MEDIA_ROOT = Path(os.environ.get("HUB_MEDIA_ROOT", BASE_DIR / "media"))
MEDIA_URL = "media/"
# Публичный адрес Hub: абсолютные ссылки, уходящие клиентам (download вложений).
HUB_PUBLIC_BASE_URL = os.environ.get("HUB_PUBLIC_BASE_URL", "http://localhost:8000")
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
+14 -20
View File
@@ -1,6 +1,12 @@
from django.contrib import admin
from hub_platform.ai.models import AIAgent, ChannelAIRelease, KnowledgeDocument, PromptDocument
from hub_platform.ai.models import AIAgent, Knowledge, KnowledgeAttachment
class KnowledgeAttachmentInline(admin.TabularInline):
model = KnowledgeAttachment
extra = 0
readonly_fields = ("public_id", "size", "content_type", "created_at")
@admin.register(AIAgent)
@@ -8,24 +14,12 @@ class AIAgentAdmin(admin.ModelAdmin):
list_display = ("name", "channel", "model", "is_active")
list_filter = ("is_active",)
search_fields = ("name", "channel__code")
filter_horizontal = ("knowledge_items",)
@admin.register(KnowledgeDocument)
class KnowledgeDocumentAdmin(admin.ModelAdmin):
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", "scope", "product", "category", "is_enabled")
list_filter = ("scope", "category", "is_enabled")
search_fields = ("title", "code", "product__code")
@admin.register(ChannelAIRelease)
class ChannelAIReleaseAdmin(admin.ModelAdmin):
list_display = ("channel", "version", "status", "model", "published_at")
list_filter = ("status",)
search_fields = ("channel__code",)
@admin.register(Knowledge)
class KnowledgeAdmin(admin.ModelAdmin):
list_display = ("title", "organization", "is_enabled", "updated_at")
list_filter = ("is_enabled",)
search_fields = ("title", "description")
inlines = [KnowledgeAttachmentInline]
+112 -240
View File
@@ -1,93 +1,32 @@
"""Импорт AI-контента из content/*.md в Знания и агентов (ADR-HUB-0023).
Файлы контента размечены секциями `=== code ===` (SPEC-HUB-0012):
- prompt-секции (system-*, qualify-*, sales-*, handoff-*) заполняют пустые поля
persona/instructions агента соответствующего канала — уже заполненные поля
не перезаписываются (владелец правит их через UI);
- остальные секции становятся Знаниями (upsert по заголовку = коду секции)
и добавляются в выбор знаний агентов: глобальные — всем каналам,
продуктовые — каналам своего продукта.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from django.core.management.base import CommandError
from django.db import transaction
from hub_platform.ai import documents as document_service
from hub_platform.ai import indexing
from hub_platform.ai.models import (
DocumentScope,
InclusionMode,
KnowledgeCategory,
KnowledgeDocument,
KnowledgeDocumentVersion,
PromptCategory,
PromptDocument,
PromptDocumentVersion,
)
from hub_platform.products.models import Product
from hub_platform.ai.models import Knowledge
DocumentKind = Literal["prompt", "knowledge"]
_SECTION_RE = re.compile(r"^===\s*(?P<code>[a-z0-9-]+)\s*===\s*$", re.MULTILINE)
@dataclass(frozen=True)
class ContentSpec:
kind: DocumentKind
title: str
category: str
product_code: str | None = None
inclusion_mode: str = InclusionMode.RETRIEVAL
@dataclass(frozen=True)
class ImportResult:
prompts_created: int = 0
prompt_versions_created: int = 0
knowledge_created: int = 0
knowledge_versions_created: int = 0
fragments_created: int = 0
skipped_sections: int = 0
def add(self, other: ImportResult) -> ImportResult:
return ImportResult(
prompts_created=self.prompts_created + other.prompts_created,
prompt_versions_created=self.prompt_versions_created + other.prompt_versions_created,
knowledge_created=self.knowledge_created + other.knowledge_created,
knowledge_versions_created=self.knowledge_versions_created
+ other.knowledge_versions_created,
fragments_created=self.fragments_created + other.fragments_created,
skipped_sections=self.skipped_sections + other.skipped_sections,
)
PROMPT_CATEGORY_BY_PREFIX = {
"system": PromptCategory.SYSTEM,
"qualify": PromptCategory.QUALIFICATION,
"sales": PromptCategory.SALES_BEHAVIOR,
"handoff": PromptCategory.OPERATOR_HANDOFF,
}
KNOWLEDGE_CATEGORY_BY_SUFFIX = {
"overview": KnowledgeCategory.OVERVIEW,
"products": KnowledgeCategory.OVERVIEW,
"audience": KnowledgeCategory.AUDIENCE,
"tariffs": KnowledgeCategory.COMMERCIAL,
"catalog": KnowledgeCategory.COMMERCIAL,
"technical": KnowledgeCategory.TECHNICAL,
"faq": KnowledgeCategory.FAQ,
"objections": KnowledgeCategory.OBJECTIONS,
"limitations": KnowledgeCategory.LIMITATIONS,
}
MANDATORY_KNOWLEDGE_CODES = {
"company-overview",
"company-products",
"foxray-overview",
"foxray-tariffs",
"firepage-overview",
"firepage-catalog",
}
PRODUCT_CODE_BY_PREFIX = {
"foxray": "foxray",
"firepage": "firepage",
}
_PROMPT_PREFIXES = ("system", "qualify", "sales", "handoff")
# Суффикс prompt-секции -> код продукта канала (None — непродуктовый канал edevs).
_PRODUCT_BY_SUFFIX = {"edevs": None, "foxray": "foxray", "firepage": "firepage"}
# Префикс knowledge-секции -> код продукта (нет префикса в карте — глобальное знание).
_PRODUCT_BY_PREFIX = {"foxray": "foxray", "firepage": "firepage"}
CONTENT_SOURCES = (
"content/ai-content-company-filled.md",
@@ -96,6 +35,24 @@ CONTENT_SOURCES = (
)
@dataclass(frozen=True)
class ImportResult:
knowledge_created: int = 0
knowledge_updated: int = 0
fragments_created: int = 0
agents_updated: int = 0
skipped_sections: int = 0
def add(self, other: "ImportResult") -> "ImportResult":
return ImportResult(
knowledge_created=self.knowledge_created + other.knowledge_created,
knowledge_updated=self.knowledge_updated + other.knowledge_updated,
fragments_created=self.fragments_created + other.fragments_created,
agents_updated=self.agents_updated + other.agents_updated,
skipped_sections=self.skipped_sections + other.skipped_sections,
)
def parse_filled_content(text: str) -> dict[str, str]:
matches = list(_SECTION_RE.finditer(text))
sections: dict[str, str] = {}
@@ -109,182 +66,97 @@ def parse_filled_content(text: str) -> dict[str, str]:
return sections
def infer_content_spec(code: str) -> ContentSpec | None:
parts = code.split("-")
if len(parts) < 2:
return None
prompt_category = PROMPT_CATEGORY_BY_PREFIX.get(parts[0])
product_code = PRODUCT_CODE_BY_PREFIX.get(parts[-1])
if prompt_category:
return ContentSpec(
kind="prompt",
title=code,
category=prompt_category,
product_code=product_code,
)
knowledge_category = KNOWLEDGE_CATEGORY_BY_SUFFIX.get(parts[-1])
if knowledge_category is None:
return None
return ContentSpec(
kind="knowledge",
title=code,
category=knowledge_category,
product_code=PRODUCT_CODE_BY_PREFIX.get(parts[0]),
inclusion_mode=InclusionMode.MANDATORY
if code in MANDATORY_KNOWLEDGE_CODES
else InclusionMode.RETRIEVAL,
)
def _import_knowledge(*, organization, code: str, content: str) -> tuple[Knowledge, ImportResult]:
knowledge = Knowledge.objects.filter(organization=organization, title=code).first()
if knowledge is None:
knowledge = Knowledge.objects.create(organization=organization, title=code, content=content)
fragments = indexing.reindex_knowledge(knowledge)
return knowledge, ImportResult(knowledge_created=1, fragments_created=len(fragments))
if knowledge.content == content:
return knowledge, ImportResult()
knowledge.content = content
knowledge.save(update_fields=["content", "updated_at"])
fragments = indexing.reindex_knowledge(knowledge)
return knowledge, ImportResult(knowledge_updated=1, fragments_created=len(fragments))
def _next_version(version_model, document) -> int:
latest = version_model.objects.filter(document=document).order_by("-version").first()
return latest.version + 1 if latest else 1
def _agents_for_product(*, organization, product_code: str | None):
from hub_platform.ai.models import AIAgent
def _latest_published(version_model, document):
return (
version_model.objects.filter(document=document, status="PUBLISHED")
.order_by("-version")
.first()
)
def _resolve_product(*, organization, product_code: str | None) -> Product | None:
queryset = AIAgent.objects.select_related("channel").filter(channel__organization=organization)
if product_code is None:
return None
try:
return Product.objects.get(organization=organization, code=product_code)
except Product.DoesNotExist as error:
raise CommandError(
f"Product '{product_code}' is required before importing AI content"
) from error
return queryset.filter(channel__product__isnull=True)
return queryset.filter(channel__product__code=product_code)
def _apply_prompts(*, organization, prompts: dict[tuple[str | None, str], str]) -> int:
"""prompts: (product_code|None, kind) -> content. Заполняет только пустые поля."""
updated = 0
product_codes = {key[0] for key in prompts}
for product_code in product_codes:
persona = prompts.get((product_code, "system"), "")
instruction_parts = [
prompts[(product_code, kind)]
for kind in ("qualify", "sales", "handoff")
if (product_code, kind) in prompts
]
instructions = "\n\n".join(instruction_parts)
for agent in _agents_for_product(organization=organization, product_code=product_code):
fields = []
if persona and not agent.persona.strip():
agent.persona = persona
fields.append("persona")
if instructions and not agent.instructions.strip():
agent.instructions = instructions
fields.append("instructions")
if fields:
agent.save(update_fields=[*fields, "updated_at"])
updated += 1
return updated
def _assign_knowledge(*, organization, global_items: list[Knowledge], by_product: dict[str, list[Knowledge]]) -> None:
from hub_platform.ai.models import AIAgent
for agent in AIAgent.objects.select_related("channel__product").filter(channel__organization=organization):
items = list(global_items)
if agent.channel.product_id:
items += by_product.get(agent.channel.product.code, [])
if items:
agent.knowledge_items.add(*items)
@transaction.atomic
def _import_prompt(
*, organization, author, code: str, content: str, spec: ContentSpec
) -> ImportResult:
product = _resolve_product(organization=organization, product_code=spec.product_code)
scope = DocumentScope.PRODUCT if product else DocumentScope.GLOBAL
document, created = PromptDocument.objects.update_or_create(
organization=organization,
product=product,
code=code,
defaults={
"title": spec.title,
"category": spec.category,
"scope": scope,
"is_enabled": True,
},
)
latest = _latest_published(PromptDocumentVersion, document)
if latest and latest.content == content:
return ImportResult(prompts_created=int(created))
version = PromptDocumentVersion.objects.create(
document=document,
version=_next_version(PromptDocumentVersion, document),
content=content,
created_by=author,
)
document_service.publish_version(version=version)
return ImportResult(prompts_created=int(created), prompt_versions_created=1)
@transaction.atomic
def _import_knowledge(
*, organization, author, code: str, content: str, spec: ContentSpec
) -> ImportResult:
product = _resolve_product(organization=organization, product_code=spec.product_code)
scope = DocumentScope.PRODUCT if product else DocumentScope.GLOBAL
document, created = KnowledgeDocument.objects.update_or_create(
organization=organization,
product=product,
code=code,
defaults={
"title": spec.title,
"category": spec.category,
"scope": scope,
"is_enabled": True,
"inclusion_mode": spec.inclusion_mode,
},
)
latest = _latest_published(KnowledgeDocumentVersion, document)
if latest and latest.content == content:
return ImportResult(knowledge_created=int(created))
version = KnowledgeDocumentVersion.objects.create(
document=document,
version=_next_version(KnowledgeDocumentVersion, document),
content=content,
created_by=author,
)
document_service.publish_version(version=version)
fragments = indexing.reindex_knowledge_version(version)
return ImportResult(
knowledge_created=int(created),
knowledge_versions_created=1,
fragments_created=len(fragments),
)
def ensure_channel_system_prompts(*, organization, author) -> ImportResult:
from hub_platform.channels.models import Channel
def import_ai_content(*, base_dir: Path, organization) -> ImportResult:
result = ImportResult()
for channel in Channel.objects.filter(organization=organization).select_related("product"):
if not channel.system_prompt.strip():
continue
code = f"system-{channel.product.code}" if channel.product_id else "system-edevs"
product = channel.product if channel.product_id else None
if PromptDocument.objects.filter(
organization=organization, product=product, code=code
).exists():
continue
document = PromptDocument.objects.create(
organization=organization,
product=product,
code=code,
title=code,
category=PromptCategory.SYSTEM,
scope=DocumentScope.PRODUCT if product else DocumentScope.GLOBAL,
is_enabled=True,
)
version = PromptDocumentVersion.objects.create(
document=document,
version=1,
content=channel.system_prompt,
created_by=author,
)
document_service.publish_version(version=version)
result = result.add(ImportResult(prompts_created=1, prompt_versions_created=1))
return result
prompts: dict[tuple[str | None, str], str] = {}
global_items: list[Knowledge] = []
by_product: dict[str, list[Knowledge]] = {}
def import_ai_content(*, base_dir: Path, organization, author) -> ImportResult:
result = ImportResult()
for relative_path in CONTENT_SOURCES:
path = base_dir / relative_path
if not path.exists():
continue
for code, content in parse_filled_content(path.read_text(encoding="utf-8")).items():
spec = infer_content_spec(code)
if spec is None:
parts = code.split("-")
if len(parts) < 2:
result = result.add(ImportResult(skipped_sections=1))
continue
if spec.kind == "prompt":
section_result = _import_prompt(
organization=organization,
author=author,
code=code,
content=content,
spec=spec,
)
else:
section_result = _import_knowledge(
organization=organization,
author=author,
code=code,
content=content,
spec=spec,
)
if parts[0] in _PROMPT_PREFIXES:
suffix = parts[-1]
if suffix not in _PRODUCT_BY_SUFFIX:
result = result.add(ImportResult(skipped_sections=1))
continue
prompts[(_PRODUCT_BY_SUFFIX[suffix], parts[0])] = content
continue
knowledge, section_result = _import_knowledge(organization=organization, code=code, content=content)
result = result.add(section_result)
return result
product_code = _PRODUCT_BY_PREFIX.get(parts[0])
if product_code is None:
global_items.append(knowledge)
else:
by_product.setdefault(product_code, []).append(knowledge)
agents_updated = _apply_prompts(organization=organization, prompts=prompts)
_assign_knowledge(organization=organization, global_items=global_items, by_product=by_product)
return result.add(ImportResult(agents_updated=agents_updated))
-289
View File
@@ -1,289 +0,0 @@
from django.db.models import Q
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from hub_platform.ai import documents as doc_service
from hub_platform.ai.document_import import ImportReport, import_documents
from hub_platform.ai.models import (
DocumentScope,
InclusionMode,
KnowledgeCategory,
KnowledgeDocument,
KnowledgeDocumentVersion,
PromptCategory,
PromptDocument,
PromptDocumentVersion,
)
from hub_platform.ai.selectors import document_for_organization, documents_for_organization
from hub_platform.ai.serializers import knowledge_payload, prompt_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 _DocConfig(APIView):
permission_classes = [IsOwner]
document_model = None
version_model = None
payload = staticmethod(lambda document: {})
valid_categories: set = set()
supports_inclusion = False
audit_prefix = "ai.document"
def _org(self, request: Request):
return request.user.employee_profile.organization
def _document(self, request: Request, document_id: int):
return document_for_organization(
self.document_model, organization_id=self._org(request).id, document_id=document_id
)
def _audit(self, request: Request, action: str, document) -> None:
record_audit_event(
action=f"{self.audit_prefix}_{action}",
actor=request.user,
organization=self._org(request),
object_type=self.document_model.__name__,
object_id=str(document.id),
request=request,
)
def after_publish(self, version) -> None:
# Хук для типоспецифичного действия после публикации версии.
pass
class DocumentListCreateView(_DocConfig):
def get(self, request: Request) -> Response:
org = self._org(request)
documents = documents_for_organization(self.document_model, org.id, request.query_params.get("product"))
channel_code = request.query_params.get("channel")
if channel_code:
channel = Channel.objects.filter(organization=org, code=channel_code).first()
if channel is None:
documents = documents.none()
elif self.document_model is PromptDocument:
# Промпты — поведение конкретного канала: точное совпадение продукта канала.
documents = documents.filter(product=channel.product)
else:
# Знания — библиотека: глобальные + продукт канала.
documents = documents.filter(Q(scope=DocumentScope.GLOBAL) | Q(product=channel.product))
return Response({"items": [self.payload(document) for document in documents]})
def post(self, request: Request) -> Response:
body = request.data
org = self._org(request)
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()
category = str(body.get("category", ""))
if not code or not title:
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(organization=org, product=product, code=code).exists():
return Response({"detail": "Document code already exists"}, status=400)
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:
return Response({"detail": "Unknown inclusion mode"}, status=400)
fields["inclusion_mode"] = inclusion
document = doc_service.create_document(
document_model=self.document_model,
version_model=self.version_model,
content=str(body.get("content", "")),
author=request.user,
**fields,
)
document = self._document(request, document.id)
self._audit(request, "created", document)
return Response({"document": self.payload(document)}, status=201)
class DocumentDetailView(_DocConfig):
def get(self, request: Request, document_id: int) -> Response:
try:
document = self._document(request, document_id)
except self.document_model.DoesNotExist:
return Response({"detail": "Document not found"}, status=404)
return Response({"document": self.payload(document)})
class DocumentAddVersionView(_DocConfig):
def post(self, request: Request, document_id: int) -> Response:
try:
document = self._document(request, document_id)
except self.document_model.DoesNotExist:
return Response({"detail": "Document not found"}, status=404)
doc_service.add_version(
version_model=self.version_model,
document=document,
content=str(request.data.get("content", "")),
author=request.user,
)
document = self._document(request, document_id)
self._audit(request, "version_added", document)
return Response({"document": self.payload(document)}, status=201)
class DocumentPublishVersionView(_DocConfig):
def post(self, request: Request, document_id: int, version: int) -> Response:
try:
document = self._document(request, document_id)
except self.document_model.DoesNotExist:
return Response({"detail": "Document not found"}, status=404)
try:
target = self.version_model.objects.get(document=document, version=version)
except self.version_model.DoesNotExist:
return Response({"detail": "Version not found"}, status=404)
doc_service.publish_version(version=target)
self.after_publish(target)
document = self._document(request, document_id)
self._audit(request, "version_published", document)
return Response({"document": self.payload(document)})
class DocumentRollbackView(_DocConfig):
def post(self, request: Request, document_id: int) -> Response:
try:
document = self._document(request, document_id)
except self.document_model.DoesNotExist:
return Response({"detail": "Document not found"}, status=404)
try:
source = self.version_model.objects.get(document=document, version=int(request.data.get("version", 0)))
except (self.version_model.DoesNotExist, ValueError, TypeError):
return Response({"detail": "Version not found"}, status=404)
doc_service.rollback_document(
version_model=self.version_model, document=document, source_version=source, author=request.user
)
document = self._document(request, document_id)
self._audit(request, "rolled_back", document)
return Response({"document": self.payload(document)}, status=201)
class _DocumentStatusView(_DocConfig):
target_enabled: bool
def post(self, request: Request, document_id: int) -> Response:
try:
document = self._document(request, document_id)
except self.document_model.DoesNotExist:
return Response({"detail": "Document not found"}, status=404)
document.is_enabled = self.target_enabled
document.save(update_fields=["is_enabled", "updated_at"])
self._audit(request, "enabled" if self.target_enabled else "disabled", document)
return Response({"document": self.payload(document)})
class DocumentEnableView(_DocumentStatusView):
target_enabled = True
class DocumentDisableView(_DocumentStatusView):
target_enabled = False
class DocumentImportView(_DocConfig):
# POST {product?, documents:[{code,title,category,content,inclusionMode?}]}.
# Idempotent upsert + немедленная публикация. Ошибки per-doc копятся в failed[].
def post(self, request: Request) -> Response:
body = request.data
documents = body.get("documents")
if not isinstance(documents, list) or not documents:
return Response({"detail": "documents must be a non-empty list"}, status=400)
product_code = body.get("product")
if product_code is not None:
product_code = str(product_code).strip() or None
valid: list = []
pre_failed: list = []
for item in documents:
category = str(item.get("category", "")).strip() if isinstance(item, dict) else ""
if category in self.valid_categories:
valid.append(item)
else:
pre_failed.append({"code": str(item.get("code", "")) if isinstance(item, dict) else "", "detail": f"Неизвестная категория: {category}"})
if not valid:
return Response({"created": 0, "updated": 0, "unchanged": 0, "failed": pre_failed}, status=400)
report = import_documents(
organization=self._org(request),
author=request.user,
document_model=self.document_model,
version_model=self.version_model,
product_code=product_code,
documents=[dict(item) for item in valid],
supports_inclusion=self.supports_inclusion,
after_publish=self.after_publish,
)
if pre_failed:
report = ImportReport(created=report.created, updated=report.updated, unchanged=report.unchanged, failed=[*pre_failed, *report.failed])
self._audit_import(request, report)
return Response(report.as_payload(), status=201)
def _audit_import(self, request: Request, report) -> None:
record_audit_event(
action=f"{self.audit_prefix}_imported",
actor=request.user,
organization=self._org(request),
object_type=self.document_model.__name__,
object_id="",
payload=report.as_payload(),
request=request,
)
# --- Knowledge / prompt concrete configs ---
class _KnowledgeConfig:
document_model = KnowledgeDocument
version_model = KnowledgeDocumentVersion
payload = staticmethod(knowledge_payload)
valid_categories = set(KnowledgeCategory.values)
supports_inclusion = True
audit_prefix = "ai.knowledge"
def after_publish(self, version) -> None:
from hub_platform.ai.indexing import reindex_knowledge_version
reindex_knowledge_version(version)
class _PromptConfig:
document_model = PromptDocument
version_model = PromptDocumentVersion
payload = staticmethod(prompt_payload)
valid_categories = set(PromptCategory.values)
supports_inclusion = False
audit_prefix = "ai.prompt"
class KnowledgeListCreateView(_KnowledgeConfig, DocumentListCreateView): pass
class KnowledgeDetailView(_KnowledgeConfig, DocumentDetailView): pass
class KnowledgeAddVersionView(_KnowledgeConfig, DocumentAddVersionView): pass
class KnowledgePublishVersionView(_KnowledgeConfig, DocumentPublishVersionView): pass
class KnowledgeRollbackView(_KnowledgeConfig, DocumentRollbackView): pass
class KnowledgeEnableView(_KnowledgeConfig, DocumentEnableView): pass
class KnowledgeDisableView(_KnowledgeConfig, DocumentDisableView): pass
class KnowledgeImportView(_KnowledgeConfig, DocumentImportView): pass
class PromptListCreateView(_PromptConfig, DocumentListCreateView): pass
class PromptDetailView(_PromptConfig, DocumentDetailView): pass
class PromptAddVersionView(_PromptConfig, DocumentAddVersionView): pass
class PromptPublishVersionView(_PromptConfig, DocumentPublishVersionView): pass
class PromptRollbackView(_PromptConfig, DocumentRollbackView): pass
class PromptEnableView(_PromptConfig, DocumentEnableView): pass
class PromptDisableView(_PromptConfig, DocumentDisableView): pass
class PromptImportView(_PromptConfig, DocumentImportView): pass
@@ -1,188 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable
from django.db import transaction
from hub_platform.ai import documents as document_service
from hub_platform.ai.models import DocumentScope, InclusionMode
from hub_platform.products.models import Product
@dataclass(frozen=True)
class ImportReport:
created: int = 0
updated: int = 0
unchanged: int = 0
failed: list = field(default_factory=list) # [{code, detail}]
def add(self, other: "ImportReport") -> "ImportReport":
return ImportReport(
created=self.created + other.created,
updated=self.updated + other.updated,
unchanged=self.unchanged + other.unchanged,
failed=[*self.failed, *other.failed],
)
def as_payload(self) -> dict:
return {
"created": self.created,
"updated": self.updated,
"unchanged": self.unchanged,
"failed": list(self.failed),
}
def resolve_product(*, organization, product_code: str | None) -> Product | None:
# product_code None → глобальный скоуп (как в существующем импортёре).
if product_code is None:
return None
try:
return Product.objects.get(organization=organization, code=product_code)
except Product.DoesNotExist as error:
raise ValueError(f"Продукт «{product_code}» не найден") from error
def _latest_published(version_model, document):
return (
version_model.objects.filter(document=document, status="PUBLISHED")
.order_by("-version")
.first()
)
def _next_version(version_model, document) -> int:
latest = version_model.objects.filter(document=document).order_by("-version").first()
return latest.version + 1 if latest else 1
@dataclass(frozen=True)
class DocInput:
code: str
title: str
category: str
content: str
inclusion_mode: str = InclusionMode.RETRIEVAL
def import_documents(
*,
organization,
author,
document_model,
version_model,
product_code: str | None,
documents: list,
supports_inclusion: bool,
after_publish: Callable | None = None,
) -> ImportReport:
# Idempotent upsert по (org, product, code) + немедленная публикация.
# Контент не меняется → unchanged (нет дубль-версий); изменился → новая published-версия.
report = ImportReport()
failed: list = []
try:
product = resolve_product(organization=organization, product_code=product_code)
except ValueError as error:
return ImportReport(failed=[{"code": "", "detail": str(error)}])
scope = DocumentScope.PRODUCT if product else DocumentScope.GLOBAL
for item in documents:
try:
doc = _coerce_doc(item, supports_inclusion=supports_inclusion)
except ValueError as error:
failed.append({"code": str(item.get("code", "")) if isinstance(item, dict) else "", "detail": str(error)})
continue
report = report.add(_import_one(
organization=organization,
author=author,
document_model=document_model,
version_model=version_model,
product=product,
scope=scope,
doc=doc,
supports_inclusion=supports_inclusion,
after_publish=after_publish,
))
return ImportReport(created=report.created, updated=report.updated, unchanged=report.unchanged, failed=[*failed, *report.failed])
def _coerce_doc(item, *, supports_inclusion: bool) -> DocInput:
if not isinstance(item, dict):
raise ValueError("документ должен быть объектом")
code = str(item.get("code", "")).strip()
title = str(item.get("title", "")).strip()
category = str(item.get("category", "")).strip()
content = str(item.get("content", ""))
if not code or not title:
raise ValueError("code и title обязательны")
inclusion_mode = InclusionMode.RETRIEVAL
if supports_inclusion and item.get("inclusionMode"):
candidate = str(item.get("inclusionMode"))
if candidate not in InclusionMode.values:
raise ValueError(f"неизвестный inclusionMode: {candidate}")
inclusion_mode = candidate
return DocInput(code=code, title=title, category=category, content=content, inclusion_mode=inclusion_mode)
@transaction.atomic
def _import_one(
*,
organization,
author,
document_model,
version_model,
product,
scope,
doc: DocInput,
supports_inclusion: bool,
after_publish,
) -> ImportReport:
defaults = {
"title": doc.title,
"category": doc.category,
"scope": scope,
"is_enabled": True,
}
if supports_inclusion:
defaults["inclusion_mode"] = doc.inclusion_mode
document, created = document_model.objects.update_or_create(
organization=organization,
product=product,
code=doc.code,
defaults=defaults,
)
if created:
# Новый документ: первая версия будет создана ниже.
return _publish_version(
document=document,
version_model=version_model,
doc=doc,
author=author,
after_publish=after_publish,
report=ImportReport(created=1),
)
latest = _latest_published(version_model, document)
if latest and latest.content == doc.content:
return ImportReport(unchanged=1)
return _publish_version(
document=document,
version_model=version_model,
doc=doc,
author=author,
after_publish=after_publish,
report=ImportReport(updated=1),
)
def _publish_version(*, document, version_model, doc, author, after_publish, report):
version = version_model.objects.create(
document=document,
version=_next_version(version_model, document),
content=doc.content,
created_by=author,
)
document_service.publish_version(version=version)
if after_publish is not None:
after_publish(version)
return report
-51
View File
@@ -1,51 +0,0 @@
from django.db import transaction
from hub_platform.ai.models import DocumentStatus
def _next_version(version_model, document) -> int:
last = version_model.objects.filter(document=document).order_by("-version").first()
return last.version + 1 if last is not None else 1
@transaction.atomic
def create_document(*, document_model, version_model, content, author, **fields):
document = document_model.objects.create(**fields)
version_model.objects.create(
document=document, version=1, content=content, status=DocumentStatus.DRAFT, created_by=author
)
return document
@transaction.atomic
def add_version(*, version_model, document, content, author):
return version_model.objects.create(
document=document,
version=_next_version(version_model, document),
content=content,
status=DocumentStatus.DRAFT,
created_by=author,
)
@transaction.atomic
def publish_version(*, version):
# Один опубликованный документ за раз: прежние публикации архивируем.
type(version).objects.filter(document=version.document, status=DocumentStatus.PUBLISHED).exclude(
pk=version.pk
).update(status=DocumentStatus.ARCHIVED)
version.status = DocumentStatus.PUBLISHED
version.save(update_fields=["status"])
return version
@transaction.atomic
def rollback_document(*, version_model, document, source_version, author):
# Откат создаёт новый черновик из содержимого старой версии (ADR-HUB-0005/0007).
return version_model.objects.create(
document=document,
version=_next_version(version_model, document),
content=source_version.content,
status=DocumentStatus.DRAFT,
created_by=author,
)
@@ -0,0 +1,54 @@
"""Извлечение текста из файловых вложений знаний (ADR-HUB-0023).
Поддерживаются текстовые форматы (md, txt и любой text/*), PDF (pypdf) и DOCX
(python-docx). Остальные форматы хранятся без индексации — extract_text вернёт
пустую строку. Ошибки разбора не фатальны: вложение сохраняется, текст пустой.
"""
from __future__ import annotations
import io
import logging
from pathlib import PurePosixPath
logger = logging.getLogger(__name__)
_TEXT_EXTENSIONS = {".md", ".markdown", ".txt", ".csv", ".json", ".yaml", ".yml"}
def _decode(data: bytes) -> str:
return data.decode("utf-8", errors="replace")
def _extract_pdf(data: bytes) -> str:
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(data))
return "\n\n".join(filter(None, (page.extract_text() for page in reader.pages)))
def _extract_docx(data: bytes) -> str:
import docx
document = docx.Document(io.BytesIO(data))
parts = [paragraph.text for paragraph in document.paragraphs if paragraph.text.strip()]
for table in document.tables:
for row in table.rows:
cells = [cell.text.strip() for cell in row.cells if cell.text.strip()]
if cells:
parts.append(" | ".join(cells))
return "\n".join(parts)
def extract_text(*, filename: str, content_type: str, data: bytes) -> str:
suffix = PurePosixPath(filename.lower()).suffix
try:
if suffix in _TEXT_EXTENSIONS or content_type.startswith("text/"):
return _decode(data).strip()
if suffix == ".pdf" or content_type == "application/pdf":
return _extract_pdf(data).strip()
if suffix == ".docx" or content_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
return _extract_docx(data).strip()
except Exception: # noqa: BLE001 — формат-специфичные ошибки разбора не фатальны
logger.warning("Text extraction failed for %s (%s)", filename, content_type, exc_info=True)
return ""
+14 -8
View File
@@ -2,16 +2,22 @@ from django.conf import settings
from hub_platform.ai.chunking import chunk_text
from hub_platform.ai.invocation import embed_texts
from hub_platform.ai.models import KnowledgeDocumentVersion, KnowledgeFragment
from hub_platform.ai.models import Knowledge, KnowledgeAttachment, KnowledgeFragment
from hub_platform.ai.provider.base import ProviderError
def reindex_knowledge_version(version: KnowledgeDocumentVersion) -> list[KnowledgeFragment]:
"""Rebuild fragments (+ embeddings if available) for a published knowledge
version (ADR-HUB-0016). If the provider has no embeddings, fragments are
still stored for lexical (Postgres FTS) search."""
KnowledgeFragment.objects.filter(version=version).delete()
chunks = chunk_text(version.content)
def reindex_knowledge(knowledge: Knowledge) -> list[KnowledgeFragment]:
"""Rebuild fragments (+ embeddings if available) for a knowledge item
(ADR-HUB-0016/0023): content plus extracted text of its attachments.
If the provider has no embeddings, fragments are still stored for
lexical (Postgres FTS) search."""
KnowledgeFragment.objects.filter(knowledge=knowledge).delete()
sources = [knowledge.content]
# Явный запрос вместо knowledge.attachments.all(): related manager может
# держать устаревший prefetch-кэш (например, только что удалённое вложение).
for attachment in KnowledgeAttachment.objects.filter(knowledge=knowledge).exclude(extracted_text=""):
sources.append(f"Файл {attachment.original_name}:\n{attachment.extracted_text}")
chunks = [chunk for source in sources for chunk in chunk_text(source)]
if not chunks:
return []
try:
@@ -20,7 +26,7 @@ def reindex_knowledge_version(version: KnowledgeDocumentVersion) -> list[Knowled
except ProviderError:
vectors = [None] * len(chunks)
fragments = [
KnowledgeFragment(version=version, chunk_index=index, content=chunk, embedding=vector)
KnowledgeFragment(knowledge=knowledge, chunk_index=index, content=chunk, embedding=vector)
for index, (chunk, vector) in enumerate(zip(chunks, vectors))
]
return KnowledgeFragment.objects.bulk_create(fragments)
+3 -33
View File
@@ -12,7 +12,7 @@ from hub_platform.ai.provider.resilience import CircuitBreaker, call_with_resili
_breaker = CircuitBreaker()
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:
def invoke_chat(*, channel, messages: list[ChatMessage], purpose: str, model: str | None = None, params: dict | None = None, used_fragment_ids: list | None = None) -> ChatResult:
agent = channel.ai_agent
model = model or agent.model
@@ -20,7 +20,7 @@ def invoke_chat(*, channel, messages: list[ChatMessage], purpose: str, release=N
limits.assert_within_limits(channel, agent)
except limits.LimitExceeded as error:
LlmInvocation.objects.create(
channel=channel, product=channel.product, release=release, purpose=purpose, operation="chat", model=model,
channel=channel, product=channel.product, purpose=purpose, operation="chat", model=model,
status=LlmInvocationStatus.BLOCKED, error=str(error),
)
raise
@@ -29,37 +29,6 @@ def invoke_chat(*, channel, messages: list[ChatMessage], purpose: str, release=N
safe_messages = [ChatMessage(role=m.role, content=redact(m.content)) for m in messages]
provider = get_provider()
started = time.monotonic()
try:
result: ChatResult = call_with_resilience(
lambda: provider.chat(messages=safe_messages, model=model, params=params),
retries=settings.HUB_AI_MAX_RETRIES,
breaker=_breaker,
)
except ProviderError as error:
LlmInvocation.objects.create(
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(
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=result.cost_micros or pricing.cost_micros(result.model, result.prompt_tokens, result.completion_tokens),
latency_ms=int((time.monotonic() - started) * 1000), status=LlmInvocationStatus.SUCCESS,
used_fragment_ids=used_fragment_ids or [],
)
return result
def channel_chat(*, channel, messages: list[ChatMessage], purpose: str, model: str | None = None, params: dict | None = None) -> ChatResult:
"""Channel-anchored chat (M1.2a, ADR-HUB-0019): model from the channel, usage
accounted to the channel (and its product, if any)."""
model = model or channel.model
safe_messages = [ChatMessage(role=m.role, content=redact(m.content)) for m in messages]
provider = get_provider()
started = time.monotonic()
try:
result: ChatResult = call_with_resilience(
lambda: provider.chat(messages=safe_messages, model=model, params=params),
@@ -79,6 +48,7 @@ def channel_chat(*, channel, messages: list[ChatMessage], purpose: str, model: s
prompt_tokens=result.prompt_tokens, completion_tokens=result.completion_tokens, total_tokens=result.total_tokens,
cost_micros=result.cost_micros or pricing.cost_micros(result.model, result.prompt_tokens, result.completion_tokens),
latency_ms=int((time.monotonic() - started) * 1000), status=LlmInvocationStatus.SUCCESS,
used_fragment_ids=used_fragment_ids or [],
)
return result
@@ -0,0 +1,103 @@
# ADR-HUB-0023: плоские Знания с вложениями + инструкции агента из трёх частей.
# Добавляющая часть; перенос данных — 0003, снос старых моделей — 0004.
import uuid
import django.db.models.deletion
from django.db import migrations, models
import hub_platform.ai.models
class Migration(migrations.Migration):
dependencies = [
("ai", "0001_initial"),
("identity", "0006_alter_employeeprofile_totp_secret"),
]
operations = [
migrations.CreateModel(
name="Knowledge",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("title", models.CharField(max_length=255)),
("description", models.CharField(blank=True, max_length=500)),
("content", models.TextField(blank=True)),
("is_enabled", models.BooleanField(default=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"organization",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="knowledge_items",
to="identity.organization",
),
),
],
options={"ordering": ["title"], "verbose_name_plural": "knowledge"},
),
migrations.CreateModel(
name="KnowledgeAttachment",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
("file", models.FileField(max_length=512, upload_to=hub_platform.ai.models.attachment_upload_path)),
("original_name", models.CharField(max_length=255)),
("content_type", models.CharField(blank=True, max_length=128)),
("size", models.PositiveBigIntegerField(default=0)),
("extracted_text", models.TextField(blank=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"knowledge",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="attachments",
to="ai.knowledge",
),
),
],
options={"ordering": ["original_name"]},
),
migrations.AddConstraint(
model_name="knowledgeattachment",
constraint=models.UniqueConstraint(fields=("knowledge", "original_name"), name="uniq_attachment_knowledge_name"),
),
migrations.AddField(
model_name="aiagent",
name="persona",
field=models.TextField(blank=True, default=""),
preserve_default=False,
),
migrations.AddField(
model_name="aiagent",
name="tone",
field=models.TextField(blank=True, default=""),
preserve_default=False,
),
migrations.AddField(
model_name="aiagent",
name="instructions",
field=models.TextField(blank=True, default=""),
preserve_default=False,
),
migrations.AddField(
model_name="aiagent",
name="knowledge_items",
field=models.ManyToManyField(blank=True, related_name="agents", to="ai.knowledge"),
),
# Переходное поле: фрагменты перевешиваются с версии документа на знание
# в 0003; в 0004 поле становится обязательным, version удаляется.
migrations.AddField(
model_name="knowledgefragment",
name="knowledge",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="fragments",
to="ai.knowledge",
),
),
]
@@ -0,0 +1,88 @@
# ADR-HUB-0023: перенос данных из версионируемых документов и релизов в
# плоские Знания и поля агента.
#
# - KnowledgeDocument -> Knowledge (содержимое последней опубликованной версии,
# иначе последней; фрагменты этой версии перевешиваются на знание);
# - активный релиз канала -> выбор знаний агента (M2M) и склейка prompt-документов
# в поле instructions (system -> qualification -> sales_behavior -> operator_handoff);
# - каналы с живым system_prompt без агента получают агента, чтобы AI-поведение
# не потерялось при удалении channel.system_prompt (channels/0004).
from django.db import migrations
_PROMPT_ORDER = ("SYSTEM", "QUALIFICATION", "SALES_BEHAVIOR", "OPERATOR_HANDOFF")
def forwards(apps, schema_editor):
Knowledge = apps.get_model("ai", "Knowledge")
KnowledgeDocument = apps.get_model("ai", "KnowledgeDocument")
KnowledgeFragment = apps.get_model("ai", "KnowledgeFragment")
AIAgent = apps.get_model("ai", "AIAgent")
ChannelAIRelease = apps.get_model("ai", "ChannelAIRelease")
Channel = apps.get_model("channels", "Channel")
knowledge_by_document: dict[int, int] = {}
for document in KnowledgeDocument.objects.prefetch_related("versions"):
versions = sorted(document.versions.all(), key=lambda item: item.version, reverse=True)
version = next((item for item in versions if item.status == "PUBLISHED"), versions[0] if versions else None)
knowledge = Knowledge.objects.create(
organization_id=document.organization_id,
title=document.title,
content=version.content if version is not None else "",
is_enabled=document.is_enabled,
)
knowledge_by_document[document.id] = knowledge.id
if version is not None:
KnowledgeFragment.objects.filter(version_id=version.id).update(knowledge_id=knowledge.id)
for channel in Channel.objects.all():
release = (
ChannelAIRelease.objects.filter(channel_id=channel.id, status="PUBLISHED")
.order_by("-version")
.first()
)
agent = AIAgent.objects.filter(channel_id=channel.id).first()
system_prompt = (channel.system_prompt or "").strip()
if agent is None:
if release is None and not system_prompt:
continue
agent = AIAgent.objects.create(
channel_id=channel.id,
name=f"{channel.name} Agent",
is_active=True,
model=channel.model,
model_params=channel.model_params or {},
)
if release is not None:
knowledge_ids = [
knowledge_by_document[link.knowledge_version.document_id]
for link in release.knowledge_versions.select_related("knowledge_version")
if link.knowledge_version.document_id in knowledge_by_document
]
if knowledge_ids:
agent.knowledge_items.add(*knowledge_ids)
if not (agent.instructions or "").strip():
by_category = {
link.prompt_version.document.category: link.prompt_version.content
for link in release.prompt_versions.select_related("prompt_version__document")
}
agent.instructions = "\n\n".join(
by_category[category] for category in _PROMPT_ORDER if by_category.get(category, "").strip()
)
# Канал с опубликованным релизом был рабочим — агент остаётся включён.
agent.is_active = True
if not (agent.instructions or "").strip() and system_prompt:
agent.instructions = system_prompt
agent.save()
class Migration(migrations.Migration):
dependencies = [
("ai", "0002_knowledge_and_agent_instructions"),
("channels", "0003_channel_allow_anonymous_sessions_and_more"),
]
operations = [
migrations.RunPython(forwards, migrations.RunPython.noop),
]
@@ -0,0 +1,48 @@
# ADR-HUB-0023: снос версионируемых документов и релизов после переноса данных (0003).
import django.db.models.deletion
from django.db import migrations, models
def delete_unlinked_fragments(apps, schema_editor):
# Фрагменты не-активных версий не переносились — удаляем перед NOT NULL.
KnowledgeFragment = apps.get_model("ai", "KnowledgeFragment")
KnowledgeFragment.objects.filter(knowledge__isnull=True).delete()
class Migration(migrations.Migration):
dependencies = [
("ai", "0003_flatten_agent_config"),
]
operations = [
migrations.RunPython(delete_unlinked_fragments, migrations.RunPython.noop),
migrations.RemoveField(model_name="llminvocation", name="release"),
migrations.RemoveConstraint(model_name="knowledgefragment", name="uniq_fragment_version_chunk"),
migrations.RemoveField(model_name="knowledgefragment", name="version"),
migrations.AlterField(
model_name="knowledgefragment",
name="knowledge",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="fragments",
to="ai.knowledge",
),
),
migrations.AlterModelOptions(
name="knowledgefragment",
options={"ordering": ["knowledge_id", "chunk_index"]},
),
migrations.AddConstraint(
model_name="knowledgefragment",
constraint=models.UniqueConstraint(fields=("knowledge", "chunk_index"), name="uniq_fragment_knowledge_chunk"),
),
migrations.DeleteModel(name="ReleaseKnowledgeVersion"),
migrations.DeleteModel(name="ReleasePromptVersion"),
migrations.DeleteModel(name="ChannelAIRelease"),
migrations.DeleteModel(name="KnowledgeDocumentVersion"),
migrations.DeleteModel(name="PromptDocumentVersion"),
migrations.DeleteModel(name="KnowledgeDocument"),
migrations.DeleteModel(name="PromptDocument"),
]
+87 -190
View File
@@ -1,19 +1,103 @@
from django.conf import settings
from django.core.exceptions import ValidationError
import uuid
from django.db import models
from pgvector.django import VectorField
# Один основной агент на канал обработки (ADR-HUB-0007/0019).
# Один основной агент на канал обработки (ADR-HUB-0019, ADR-HUB-0023).
DEFAULT_AI_MODEL = "anthropic/claude-sonnet-4.6"
# --- Знания: плоская библиотека организации с вложениями (ADR-HUB-0023) ---
class Knowledge(models.Model):
organization = models.ForeignKey("identity.Organization", on_delete=models.PROTECT, related_name="knowledge_items")
title = models.CharField(max_length=255)
description = models.CharField(max_length=500, blank=True)
content = models.TextField(blank=True) # Markdown
is_enabled = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["title"]
verbose_name_plural = "knowledge"
def __str__(self) -> str:
return f"knowledge:{self.organization_id}/{self.title}"
def attachment_upload_path(instance: "KnowledgeAttachment", filename: str) -> str:
return f"knowledge/{instance.knowledge_id}/{instance.public_id}/{filename}"
class KnowledgeAttachment(models.Model):
knowledge = models.ForeignKey(Knowledge, on_delete=models.CASCADE, related_name="attachments")
# Непредсказуемый идентификатор публичной ссылки скачивания (ADR-HUB-0023):
# агент может отдать ссылку клиенту в мессенджер, где нет аутентификации Hub.
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
file = models.FileField(upload_to=attachment_upload_path, max_length=512)
# Оригинальное имя сохраняется и уникально в рамках знания: текст знания
# ссылается на вложение по имени.
original_name = models.CharField(max_length=255)
content_type = models.CharField(max_length=128, blank=True)
size = models.PositiveBigIntegerField(default=0)
# Текст, извлечённый из файла (md/txt/pdf/docx) для retrieval-индексации.
extracted_text = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["original_name"]
constraints = [models.UniqueConstraint(fields=["knowledge", "original_name"], name="uniq_attachment_knowledge_name")]
def __str__(self) -> str:
return f"attachment:{self.knowledge_id}/{self.original_name}"
def public_url(self) -> str:
# Абсолютная ссылка скачивания: уходит клиентам в мессенджеры, поэтому
# строится от публичного адреса Hub, а не от request.
from django.conf import settings
from django.urls import reverse
path = reverse("ai-attachment-download", kwargs={"public_id": self.public_id})
return settings.HUB_PUBLIC_BASE_URL.rstrip("/") + path
class KnowledgeFragment(models.Model):
# Чанк знания + его эмбеддинг (pgvector). ADR-HUB-0016. Перестраивается при
# каждом изменении содержимого или вложений знания.
knowledge = models.ForeignKey(Knowledge, on_delete=models.CASCADE, related_name="fragments")
chunk_index = models.PositiveIntegerField()
content = models.TextField()
# Размерность не фиксируется: совместимость локального и production embedding-провайдера.
embedding = VectorField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["knowledge_id", "chunk_index"]
constraints = [models.UniqueConstraint(fields=["knowledge", "chunk_index"], name="uniq_fragment_knowledge_chunk")]
def __str__(self) -> str:
return f"fragment:{self.knowledge_id}/{self.chunk_index}"
# --- Агент канала: одна сущность, без релизов (ADR-HUB-0023) ---
class AIAgent(models.Model):
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)
model_params = models.JSONField(default=dict, blank=True)
# Инструкции из трёх частей; системный промпт собирается в этом порядке.
persona = models.TextField(blank=True) # кто он и что он
tone = models.TextField(blank=True) # как он должен говорить
instructions = models.TextField(blank=True) # правила работы
# Выбор знаний из библиотеки организации.
knowledge_items = models.ManyToManyField(Knowledge, blank=True, related_name="agents")
allowed_tools = models.JSONField(default=list, blank=True)
# Единственный поддерживаемый лимит — дневной бюджет dailyCostUsd (центы USD).
limits = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -22,192 +106,6 @@ class AIAgent(models.Model):
return f"{self.channel.code}:agent"
# --- Knowledge & prompt documents (ADR-HUB-0005) ---
class DocumentStatus(models.TextChoices):
DRAFT = "DRAFT", "Черновик"
PUBLISHED = "PUBLISHED", "Опубликован"
ARCHIVED = "ARCHIVED", "Архив"
class DocumentScope(models.TextChoices):
GLOBAL = "GLOBAL", "Глобальное"
PRODUCT = "PRODUCT", "Продуктовое"
class KnowledgeCategory(models.TextChoices):
OVERVIEW = "OVERVIEW", "Обзор продукта"
AUDIENCE = "AUDIENCE", "Целевая аудитория"
COMMERCIAL = "COMMERCIAL", "Коммерческая модель"
TECHNICAL = "TECHNICAL", "Техническая информация"
FAQ = "FAQ", "FAQ"
OBJECTIONS = "OBJECTIONS", "Возражения"
LIMITATIONS = "LIMITATIONS", "Ограничения"
class PromptCategory(models.TextChoices):
SYSTEM = "SYSTEM", "Системный промпт"
QUALIFICATION = "QUALIFICATION", "Квалификация"
SALES_BEHAVIOR = "SALES_BEHAVIOR", "Поведение в продаже"
OPERATOR_HANDOFF = "OPERATOR_HANDOFF", "Передача оператору"
class InclusionMode(models.TextChoices):
MANDATORY = "MANDATORY", "Обязательное включение"
RETRIEVAL = "RETRIEVAL", "Доступно для retrieval"
class _BaseDocument(models.Model):
# Библиотека со 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)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
class KnowledgeDocument(_BaseDocument):
category = models.CharField(max_length=32, choices=KnowledgeCategory.choices)
inclusion_mode = models.CharField(max_length=16, choices=InclusionMode.choices, default=InclusionMode.RETRIEVAL)
class Meta:
constraints = [models.UniqueConstraint(fields=["organization", "product", "code"], name="uniq_knowledge_doc_org_product_code")]
def __str__(self) -> str:
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=["organization", "product", "code"], name="uniq_prompt_doc_org_product_code")]
def __str__(self) -> str:
return f"prompt:{self.organization_id}/{self.code}"
class _BaseDocumentVersion(models.Model):
version = models.PositiveIntegerField()
content = models.TextField(blank=True)
status = models.CharField(max_length=16, choices=DocumentStatus.choices, default=DocumentStatus.DRAFT)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
abstract = True
ordering = ["-version"]
def save(self, *args: object, **kwargs: object) -> None:
if self.pk:
current = type(self).objects.get(pk=self.pk)
if current.version != self.version or current.content != self.content:
raise ValidationError("Document version content is immutable")
super().save(*args, **kwargs)
class KnowledgeDocumentVersion(_BaseDocumentVersion):
document = models.ForeignKey(KnowledgeDocument, on_delete=models.CASCADE, related_name="versions")
class Meta(_BaseDocumentVersion.Meta):
constraints = [models.UniqueConstraint(fields=["document", "version"], name="uniq_knowledge_version")]
class KnowledgeFragment(models.Model):
# Чанк опубликованной версии знания + его эмбеддинг (pgvector). ADR-HUB-0016.
version = models.ForeignKey(KnowledgeDocumentVersion, on_delete=models.CASCADE, related_name="fragments")
chunk_index = models.PositiveIntegerField()
content = models.TextField()
# Размерность не фиксируется: совместимость локального и production embedding-провайдера.
embedding = VectorField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["version_id", "chunk_index"]
constraints = [models.UniqueConstraint(fields=["version", "chunk_index"], name="uniq_fragment_version_chunk")]
def __str__(self) -> str:
return f"fragment:{self.version_id}/{self.chunk_index}"
class PromptDocumentVersion(_BaseDocumentVersion):
document = models.ForeignKey(PromptDocument, on_delete=models.CASCADE, related_name="versions")
class Meta(_BaseDocumentVersion.Meta):
constraints = [models.UniqueConstraint(fields=["document", "version"], name="uniq_prompt_version")]
# --- ChannelAIRelease: атомарный immutable-снимок конфигурации канала (ADR-HUB-0007/0019) ---
class ReleaseStatus(models.TextChoices):
DRAFT = "DRAFT", "Черновик"
PUBLISHED = "PUBLISHED", "Опубликован"
ARCHIVED = "ARCHIVED", "Архив"
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
model = models.CharField(max_length=128)
model_params = models.JSONField(default=dict, blank=True)
allowed_tools = models.JSONField(default=list, blank=True)
limits = models.JSONField(default=dict, blank=True)
retrieval_index_version = models.CharField(max_length=64, blank=True)
notes = models.TextField(blank=True)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
created_at = models.DateTimeField(auto_now_add=True)
published_at = models.DateTimeField(null=True, blank=True)
_SNAPSHOT_FIELDS = ("channel_id", "version", "model", "model_params", "allowed_tools", "limits", "retrieval_index_version")
class Meta:
ordering = ["channel_id", "-version"]
constraints = [
models.UniqueConstraint(fields=["channel", "version"], name="uniq_release_channel_version"),
models.UniqueConstraint(
fields=["channel"],
condition=models.Q(status="PUBLISHED"),
name="uniq_active_release_per_channel",
),
]
def save(self, *args: object, **kwargs: object) -> None:
if 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.channel_id}/v{self.version}"
class ReleaseKnowledgeVersion(models.Model):
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:
constraints = [models.UniqueConstraint(fields=["release", "knowledge_version"], name="uniq_release_knowledge_version")]
class ReleasePromptVersion(models.Model):
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:
constraints = [models.UniqueConstraint(fields=["release", "prompt_version"], name="uniq_release_prompt_version")]
# --- LLM usage accounting (tokens, cost) ---
@@ -221,7 +119,6 @@ class LlmInvocation(models.Model):
# Учёт по каналу (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(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)
@@ -1,129 +0,0 @@
from rest_framework.request import Request
from rest_framework.response import Response
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 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
class _ReleaseBase(APIView):
permission_classes = [IsOwner]
def _org(self, request: Request):
return request.user.employee_profile.organization
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: ChannelAIRelease) -> None:
record_audit_event(
action=f"ai.release_{action}",
actor=request.user,
organization=self._org(request),
object_type="ChannelAIRelease",
object_id=str(release.id),
request=request,
)
class ReleaseListCreateView(_ReleaseBase):
def get(self, request: Request) -> Response:
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:
channel = Channel.objects.select_related("ai_agent").get(
organization=self._org(request), code=str(request.data.get("channel", ""))
)
except Channel.DoesNotExist:
return Response({"detail": "Channel not found"}, status=400)
release = release_service.create_draft_release(
channel=channel, author=request.user, notes=str(request.data.get("notes", ""))
)
release = self._release(request, release.id)
self._audit(request, "drafted", release)
return Response({"release": release_payload(release)}, status=201)
class ReleaseDetailView(_ReleaseBase):
def get(self, request: Request, release_id: int) -> Response:
try:
release = self._release(request, release_id)
except ChannelAIRelease.DoesNotExist:
return Response({"detail": "Release not found"}, status=404)
return Response({"release": release_payload(release)})
class ReleasePublishView(_ReleaseBase):
def post(self, request: Request, release_id: int) -> Response:
try:
release = self._release(request, release_id)
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)
release_service.publish_release(release=release)
release = self._release(request, release_id)
self._audit(request, "published", release)
return Response({"release": release_payload(release)})
class ReleaseRollbackView(_ReleaseBase):
def post(self, request: Request, release_id: int) -> Response:
try:
source = self._release(request, release_id)
except ChannelAIRelease.DoesNotExist:
return Response({"detail": "Release not found"}, status=404)
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)
class ReleaseTestChatView(_ReleaseBase):
def post(self, request: Request, release_id: int) -> Response:
try:
release = self._release(request, release_id)
except ChannelAIRelease.DoesNotExist:
return Response({"detail": "Release not found"}, status=404)
message = str(request.data.get("message", "")).strip()
if not message:
return Response({"detail": "Message is required"}, status=400)
history = request.data.get("history") or []
try:
outcome = run_test_chat(release=release, message=message, history=history)
except limits.LimitExceeded as error:
return Response({"detail": str(error)}, status=429)
except ProviderError as error:
return Response({"detail": str(error)}, status=502)
result = outcome.result
return Response(
{
"reply": result.text,
"model": result.model,
"promptTokens": result.prompt_tokens,
"completionTokens": result.completion_tokens,
"totalTokens": result.total_tokens,
"costMicros": pricing.cost_micros(result.model, result.prompt_tokens, result.completion_tokens),
"handoffSuggested": outcome.handoff_suggested,
"usedKnowledge": [
{
"document": fragment.version.document.code,
"version": fragment.version.version,
"chunkIndex": fragment.chunk_index,
"snippet": fragment.content[:200],
}
for fragment in outcome.fragments
],
"release": {"id": release.id, "version": release.version, "status": release.status},
}
)
-122
View File
@@ -1,122 +0,0 @@
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,
PromptDocumentVersion,
ReleaseKnowledgeVersion,
ReleasePromptVersion,
ReleaseStatus,
)
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(*, 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,
allowed_tools=allowed_tools,
limits=limits,
retrieval_index_version=retrieval_index_version,
notes=notes,
created_by=author,
)
ReleaseKnowledgeVersion.objects.bulk_create(
[ReleaseKnowledgeVersion(release=release, knowledge_version=version) for version in knowledge_versions]
)
ReleasePromptVersion.objects.bulk_create(
[ReleasePromptVersion(release=release, prompt_version=version) for version in prompt_versions]
)
return release
def create_draft_release(*, channel, author, notes: str = "") -> ChannelAIRelease:
# Снимок текущей конфигурации агента канала + опубликованных версий включённых документов.
agent = channel.ai_agent
organization_id = channel.organization_id
knowledge_versions = KnowledgeDocumentVersion.objects.filter(
_channel_scope_filter(channel),
document__organization_id=organization_id,
document__is_enabled=True,
status=DocumentStatus.PUBLISHED,
)
prompt_versions = PromptDocumentVersion.objects.filter(
_channel_scope_filter(channel),
document__organization_id=organization_id,
document__is_enabled=True,
status=DocumentStatus.PUBLISHED,
)
return _create_release(
channel=channel,
author=author,
model=agent.model,
model_params=agent.model_params,
allowed_tools=agent.allowed_tools,
limits=agent.limits,
retrieval_index_version="",
notes=notes,
knowledge_versions=list(knowledge_versions),
prompt_versions=list(prompt_versions),
)
def create_initial_draft_release(*, channel, author, model, model_params, allowed_tools, limits, knowledge_versions, prompt_versions) -> ChannelAIRelease:
return _create_release(
channel=channel,
author=author,
model=model,
model_params=model_params,
allowed_tools=allowed_tools,
limits=limits,
retrieval_index_version="",
notes="Первая версия агента",
knowledge_versions=knowledge_versions,
prompt_versions=prompt_versions,
)
@transaction.atomic
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
release.published_at = timezone.now()
release.save(update_fields=["status", "published_at"])
return release
def rollback_to_release(*, channel, source: ChannelAIRelease, author) -> ChannelAIRelease:
# Откат создаёт новый черновик из снимка старого release (ADR-HUB-0007).
return _create_release(
channel=channel,
author=author,
model=source.model,
model_params=source.model_params,
allowed_tools=source.allowed_tools,
limits=source.limits,
retrieval_index_version=source.retrieval_index_version,
notes=f"Откат к версии v{source.version}",
knowledge_versions=[link.knowledge_version for link in source.knowledge_versions.all()],
prompt_versions=[link.prompt_version for link in source.prompt_versions.all()],
)
+13 -12
View File
@@ -3,30 +3,31 @@ 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 ChannelAIRelease, KnowledgeFragment
from hub_platform.ai.models import AIAgent, KnowledgeFragment
from hub_platform.ai.provider.base import ProviderError
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 _agent_fragments(agent: AIAgent):
return KnowledgeFragment.objects.filter(
knowledge__agents=agent, knowledge__is_enabled=True
).select_related("knowledge")
def lexical_search(release: ChannelAIRelease, query: str, *, limit: int = 5) -> list[KnowledgeFragment]:
def lexical_search(agent: AIAgent, query: str, *, limit: int = 5) -> list[KnowledgeFragment]:
if not query.strip():
return []
search_query = SearchQuery(query, search_type="websearch")
return list(
_release_fragments(release)
_agent_fragments(agent)
.annotate(rank=SearchRank(SearchVector("content"), search_query))
.filter(rank__gt=0)
.order_by("-rank")[:limit]
)
def semantic_search(release: ChannelAIRelease, query_vector: list[float], *, limit: int = 5) -> list[KnowledgeFragment]:
def semantic_search(agent: AIAgent, query_vector: list[float], *, limit: int = 5) -> list[KnowledgeFragment]:
return list(
_release_fragments(release)
_agent_fragments(agent)
.filter(embedding__isnull=False)
.order_by(CosineDistance("embedding", query_vector))[:limit]
)
@@ -35,20 +36,20 @@ def semantic_search(release: ChannelAIRelease, query_vector: list[float], *, lim
class KnowledgeRetriever:
"""Hybrid retriever: semantic (pgvector) primary, lexical (Postgres FTS) complementary."""
def retrieve(self, *, release: ChannelAIRelease, query: str, limit: int = 5) -> list[KnowledgeFragment]:
def retrieve(self, *, agent: AIAgent, query: str, limit: int = 5) -> list[KnowledgeFragment]:
# Семантический поиск опционален: если провайдер не даёт эмбеддинги —
# работаем на лексическом (Postgres FTS), не падая.
try:
query_vector = embed_texts(
channel=release.channel,
channel=agent.channel,
texts=[query],
model=settings.HUB_AI_EMBEDDING_MODEL,
purpose="retrieval_query",
)[0].vector
semantic = semantic_search(release, query_vector, limit=limit)
semantic = semantic_search(agent, query_vector, limit=limit)
except ProviderError:
semantic = []
lexical = lexical_search(release, query, limit=limit)
lexical = lexical_search(agent, query, limit=limit)
seen = {fragment.id for fragment in semantic}
merged = semantic + [fragment for fragment in lexical if fragment.id not in seen]
return merged[:limit]
+36 -31
View File
@@ -1,19 +1,12 @@
from dataclasses import dataclass
from hub_platform.ai.invocation import invoke_chat
from hub_platform.ai.models import KnowledgeFragment, ChannelAIRelease, PromptCategory
from hub_platform.ai.models import AIAgent, KnowledgeFragment
from hub_platform.ai.provider.base import ChatMessage, ChatResult
from hub_platform.ai.retrieval import KnowledgeRetriever
_PROMPT_ORDER = [
PromptCategory.SYSTEM,
PromptCategory.QUALIFICATION,
PromptCategory.SALES_BEHAVIOR,
PromptCategory.OPERATOR_HANDOFF,
]
# Гард стиля для мессенджеров: гарантирует простой текст вне зависимости от
# того, что написано в авторских промптах.
# того, что написано в авторских инструкциях.
MESSENGER_STYLE_GUARD = (
"Пиши ответ простым текстом для мессенджера: без markdown-разметки — "
"никаких **, ##, маркированных списков с -, таблиц, ссылок вида [текст](url). "
@@ -34,37 +27,50 @@ HANDOFF_PROTOCOL = (
@dataclass(frozen=True)
class TestChatResult:
class AgentTurnResult:
result: ChatResult
fragments: list[KnowledgeFragment]
handoff_suggested: bool
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")
}
parts = [
by_category[category].content
for category in _PROMPT_ORDER
if category in by_category and by_category[category].content.strip()
]
def agent_system_prompt(agent: AIAgent) -> str:
# Порядок частей фиксирован (ADR-HUB-0023): Персонализация -> Тон -> Инструкции.
parts = [part.strip() for part in (agent.persona, agent.tone, agent.instructions) if part.strip()]
return "\n\n".join(parts)
def run_test_chat(*, release: ChannelAIRelease, message: str, history: list[dict] | None = None, style_guard: bool = False) -> TestChatResult:
fragments = KnowledgeRetriever().retrieve(release=release, query=message, limit=5)
def knowledge_catalog(agent: AIAgent) -> str:
"""Каталог выбранных знаний для системного промпта: заголовок, краткое
описание и публичные ссылки вложений (агент может отдать ссылку клиенту)."""
lines: list[str] = []
items = agent.knowledge_items.filter(is_enabled=True).prefetch_related("attachments")
for knowledge in items:
line = f"- {knowledge.title}"
if knowledge.description.strip():
line += f" — {knowledge.description.strip()}"
lines.append(line)
for attachment in knowledge.attachments.all():
lines.append(f" файл: {attachment.original_name} — {attachment.public_url()}")
if not lines:
return ""
return "Тебе доступны следующие знания (детали подтягиваются автоматически по запросу). Ссылки на файлы можно давать клиенту:\n" + "\n".join(lines)
def run_agent_turn(*, agent: AIAgent, message: str, history: list[dict] | None = None, style_guard: bool = True) -> AgentTurnResult:
fragments = KnowledgeRetriever().retrieve(agent=agent, query=message, limit=5)
messages: list[ChatMessage] = []
system_prompt = _release_system_prompt(release)
system_prompt = agent_system_prompt(agent)
if system_prompt:
messages.append(ChatMessage(role="system", content=system_prompt))
if style_guard:
messages.append(ChatMessage(role="system", content=MESSENGER_STYLE_GUARD + "\n\n" + HANDOFF_PROTOCOL))
catalog = knowledge_catalog(agent)
if catalog:
messages.append(ChatMessage(role="system", content=catalog))
if fragments:
knowledge = "\n\n".join(
f"[{fragment.version.document.code}#{fragment.chunk_index}] {fragment.content}" for fragment in fragments
f"[{fragment.knowledge.title}#{fragment.chunk_index}] {fragment.content}" for fragment in fragments
)
messages.append(ChatMessage(role="system", content="Отвечай только на основе этих знаний:\n" + knowledge))
for item in history or []:
@@ -72,14 +78,13 @@ def run_test_chat(*, release: ChannelAIRelease, message: str, history: list[dict
messages.append(ChatMessage(role="user", content=message))
result = invoke_chat(
channel=release.channel,
channel=agent.channel,
messages=messages,
purpose="test_chat",
release=release,
model=release.model,
params=release.model_params or None,
purpose="agent_chat",
model=agent.model,
params=agent.model_params or None,
used_fragment_ids=[fragment.id for fragment in fragments],
)
# ADR-HUB-0007: нет основания в знаниях -> передача оператору.
return TestChatResult(result=result, fragments=fragments, handoff_suggested=not fragments)
# Нет основания в знаниях -> кандидат на передачу оператору (ADR-HUB-0003).
return AgentTurnResult(result=result, fragments=fragments, handoff_suggested=not fragments)
@@ -1,134 +0,0 @@
from __future__ import annotations
from django.db import transaction
from hub_platform.ai.models import (
AIAgent,
ChannelAIRelease,
DocumentScope,
DocumentStatus,
KnowledgeDocumentVersion,
PromptDocumentVersion,
ReleaseKnowledgeVersion,
ReleasePromptVersion,
ReleaseStatus,
)
from hub_platform.ai.releases import publish_release
from hub_platform.channels.models import DEFAULT_CHANNEL_MODEL, Channel
from hub_platform.identity.models import HumanUser, Organization
def _release_version_ids(release: ChannelAIRelease) -> tuple[set[int], set[int]]:
knowledge_ids = {link.knowledge_version_id for link in release.knowledge_versions.all()}
prompt_ids = {link.prompt_version_id for link in release.prompt_versions.all()}
return knowledge_ids, prompt_ids
def _next_release_version(channel: Channel) -> int:
latest = ChannelAIRelease.objects.filter(channel=channel).order_by("-version").first()
return latest.version + 1 if latest else 1
def _published_knowledge_versions(channel: Channel) -> list[KnowledgeDocumentVersion]:
queryset = KnowledgeDocumentVersion.objects.filter(
document__organization=channel.organization,
document__is_enabled=True,
status=DocumentStatus.PUBLISHED,
)
if channel.product_id:
queryset = queryset.filter(document__scope=DocumentScope.GLOBAL) | queryset.filter(
document__product=channel.product
)
else:
queryset = queryset.filter(document__scope=DocumentScope.GLOBAL)
return list(queryset.distinct().order_by("document__code", "-version"))
def _published_prompt_versions(channel: Channel) -> list[PromptDocumentVersion]:
queryset = PromptDocumentVersion.objects.filter(
document__organization=channel.organization,
document__is_enabled=True,
status=DocumentStatus.PUBLISHED,
)
if channel.product_id:
queryset = queryset.filter(document__scope=DocumentScope.GLOBAL) | queryset.filter(
document__product=channel.product
)
else:
queryset = queryset.filter(document__scope=DocumentScope.GLOBAL)
selected: dict[str, PromptDocumentVersion] = {}
for version in (
queryset.select_related("document").distinct().order_by("document__category", "-version")
):
category = version.document.category
current = selected.get(category)
if current is None:
selected[category] = version
continue
current_priority = int(bool(current.document.product_id))
candidate_priority = int(bool(version.document.product_id))
if (candidate_priority, version.version) > (current_priority, current.version):
selected[category] = version
return list(selected.values())
@transaction.atomic
def _create_release_snapshot(
*, channel: Channel, agent: AIAgent, author: HumanUser | None
) -> ChannelAIRelease:
release = ChannelAIRelease.objects.create(
channel=channel,
version=_next_release_version(channel),
status=ReleaseStatus.DRAFT,
model=agent.model,
model_params=agent.model_params,
allowed_tools=agent.allowed_tools,
limits=agent.limits,
retrieval_index_version="",
notes="Production seed release",
created_by=author,
)
ReleaseKnowledgeVersion.objects.bulk_create(
[
ReleaseKnowledgeVersion(release=release, knowledge_version=version)
for version in _published_knowledge_versions(channel)
]
)
ReleasePromptVersion.objects.bulk_create(
[
ReleasePromptVersion(release=release, prompt_version=version)
for version in _published_prompt_versions(channel)
]
)
return release
def ensure_agents_and_releases(
*, organization: Organization, author: HumanUser | None
) -> tuple[int, int]:
agents_created = 0
releases_created = 0
for channel in Channel.objects.filter(organization=organization).order_by("code"):
agent, agent_created = AIAgent.objects.update_or_create(
channel=channel,
defaults={
"name": f"{channel.name} Agent",
"is_active": True,
"model": channel.model or DEFAULT_CHANNEL_MODEL,
},
)
agents_created += int(agent_created)
draft = _create_release_snapshot(channel=channel, agent=agent, author=author)
desired = _release_version_ids(draft)
current = (
ChannelAIRelease.objects.filter(channel=channel, status=ReleaseStatus.PUBLISHED)
.prefetch_related("knowledge_versions", "prompt_versions")
.order_by("-version")
.first()
)
if current and current.model == agent.model and _release_version_ids(current) == desired:
draft.delete()
continue
publish_release(release=draft)
releases_created += 1
return agents_created, releases_created
+11 -30
View File
@@ -1,11 +1,12 @@
from django.db.models import QuerySet
from django.db.models import Count, QuerySet
from hub_platform.ai.models import AIAgent, ChannelAIRelease
from hub_platform.ai.models import AIAgent, Knowledge
def agents_for_organization(organization_id: int) -> QuerySet[AIAgent]:
return (
AIAgent.objects.select_related("channel", "channel__product")
.prefetch_related("knowledge_items")
.filter(channel__organization_id=organization_id)
.order_by("channel__name")
)
@@ -15,34 +16,14 @@ def agent_for_organization(*, organization_id: int, agent_id: int) -> AIAgent:
return agents_for_organization(organization_id).get(id=agent_id)
def documents_for_organization(document_model, organization_id: int, product_code: str | None = None) -> QuerySet:
queryset = (
document_model.objects.select_related("product")
.prefetch_related("versions")
.filter(organization_id=organization_id)
def knowledge_for_organization(organization_id: int) -> QuerySet[Knowledge]:
return (
Knowledge.objects.filter(organization_id=organization_id)
.prefetch_related("attachments")
.annotate(agents_count=Count("agents", distinct=True))
.order_by("title")
)
if product_code:
queryset = queryset.filter(product__code=product_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, channel_code: str | None = None) -> QuerySet[ChannelAIRelease]:
queryset = (
ChannelAIRelease.objects.select_related("channel", "channel__product")
.prefetch_related(
"knowledge_versions__knowledge_version__document",
"prompt_versions__prompt_version__document",
)
.filter(channel__organization_id=organization_id)
)
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) -> ChannelAIRelease:
return releases_for_organization(organization_id).get(id=release_id)
def knowledge_item_for_organization(*, organization_id: int, knowledge_id: int) -> Knowledge:
return knowledge_for_organization(organization_id).get(id=knowledge_id)
+29 -54
View File
@@ -1,4 +1,4 @@
from hub_platform.ai.models import AIAgent, ChannelAIRelease, KnowledgeDocument, PromptDocument
from hub_platform.ai.models import AIAgent, Knowledge, KnowledgeAttachment
def _channel_ref(channel) -> dict[str, object]:
@@ -10,64 +10,32 @@ def _channel_ref(channel) -> dict[str, object]:
}
def _version_payload(version) -> dict[str, object]:
def attachment_payload(attachment: KnowledgeAttachment) -> dict[str, object]:
return {
"id": version.id,
"version": version.version,
"status": version.status,
"content": version.content,
"createdBy": version.created_by_id,
"createdAt": version.created_at.isoformat(),
"id": attachment.id,
"name": attachment.original_name,
"contentType": attachment.content_type,
"size": attachment.size,
"hasText": bool(attachment.extracted_text),
"url": attachment.public_url(),
"createdAt": attachment.created_at.isoformat(),
}
def _document_payload(document, extra: dict[str, object]) -> dict[str, object]:
return {
"id": document.id,
"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,
"isEnabled": document.is_enabled,
"versions": [_version_payload(version) for version in document.versions.all()],
"createdAt": document.created_at.isoformat(),
"updatedAt": document.updated_at.isoformat(),
**extra,
}
def knowledge_payload(document: KnowledgeDocument) -> dict[str, object]:
return _document_payload(document, {"inclusionMode": document.inclusion_mode})
def prompt_payload(document: PromptDocument) -> dict[str, object]:
return _document_payload(document, {})
def release_payload(release: ChannelAIRelease) -> dict[str, object]:
return {
"id": release.id,
"channel": _channel_ref(release.channel),
"version": release.version,
"status": release.status,
"model": release.model,
"modelParams": release.model_params,
"allowedTools": release.allowed_tools,
"limits": release.limits,
"retrievalIndexVersion": release.retrieval_index_version,
"notes": release.notes,
"knowledgeVersions": [
{"document": link.knowledge_version.document.code, "version": link.knowledge_version.version}
for link in release.knowledge_versions.all()
],
"promptVersions": [
{"document": link.prompt_version.document.code, "version": link.prompt_version.version}
for link in release.prompt_versions.all()
],
"createdAt": release.created_at.isoformat(),
"publishedAt": release.published_at.isoformat() if release.published_at else None,
def knowledge_payload(knowledge: Knowledge, *, include_content: bool = True) -> dict[str, object]:
payload: dict[str, object] = {
"id": knowledge.id,
"title": knowledge.title,
"description": knowledge.description,
"isEnabled": knowledge.is_enabled,
"attachments": [attachment_payload(attachment) for attachment in knowledge.attachments.all()],
"agentsCount": getattr(knowledge, "agents_count", None),
"createdAt": knowledge.created_at.isoformat(),
"updatedAt": knowledge.updated_at.isoformat(),
}
if include_content:
payload["content"] = knowledge.content
return payload
def agent_payload(agent: AIAgent) -> dict[str, object]:
@@ -80,6 +48,13 @@ def agent_payload(agent: AIAgent) -> dict[str, object]:
"modelParams": agent.model_params,
"allowedTools": agent.allowed_tools,
"limits": agent.limits,
"persona": agent.persona,
"tone": agent.tone,
"instructions": agent.instructions,
"knowledge": [
{"id": knowledge.id, "title": knowledge.title, "isEnabled": knowledge.is_enabled}
for knowledge in agent.knowledge_items.all()
],
"createdAt": agent.created_at.isoformat(),
"updatedAt": agent.updated_at.isoformat(),
}
+116 -75
View File
@@ -1,19 +1,12 @@
from dataclasses import dataclass
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import UploadedFile
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,
PromptCategory,
PromptDocument,
PromptDocumentVersion,
)
from hub_platform.ai.extraction import extract_text
from hub_platform.ai.indexing import reindex_knowledge
from hub_platform.ai.models import AIAgent, Knowledge, KnowledgeAttachment
from hub_platform.channels.models import Channel
@@ -24,14 +17,20 @@ class AgentInput:
model_params: dict
allowed_tools: list
limits: dict
persona: str
tone: str
instructions: str
knowledge_ids: list[int] | None # None -> выбор знаний не меняется
@dataclass(frozen=True)
class AgentCreateInput:
channel_code: str
model: str
system_prompt: str
knowledge_document_ids: list[int]
persona: str
tone: str
instructions: str
knowledge_ids: list[int]
# Единственный поддерживаемый лимит агента — дневной бюджет в целых центах USD
@@ -48,57 +47,15 @@ def _normalize_limits(raw: dict | None) -> dict:
return {"dailyCostUsd": cents} if cents > 0 else {}
START_PROMPTS: tuple[tuple[str, str, str], ...] = (
("system", "Системный prompt", PromptCategory.SYSTEM),
("qualification", "Квалификация", PromptCategory.QUALIFICATION),
("sales-behavior", "Поведение в продаже", PromptCategory.SALES_BEHAVIOR),
("operator-handoff", "Передача оператору", PromptCategory.OPERATOR_HANDOFF),
)
def _next_prompt_version(document: PromptDocument) -> int:
latest = document.versions.order_by("-version").first()
return latest.version + 1 if latest else 1
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(
organization=channel.organization,
product=channel.product,
code=code,
defaults={"title": title, "category": category, "scope": scope},
)
content = system_prompt if category == PromptCategory.SYSTEM else ""
versions.append(
PromptDocumentVersion.objects.create(
document=document,
version=_next_prompt_version(document),
content=content,
status=DocumentStatus.DRAFT,
created_by=author,
)
)
return 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 = []
for document in documents:
version = document.versions.order_by("-version").first()
if version is None:
raise ValidationError({"knowledgeDocumentIds": "Knowledge document has no versions"})
versions.append(version)
return versions
def _knowledge_for_ids(*, organization, knowledge_ids: list[int]) -> list[Knowledge]:
items = list(Knowledge.objects.filter(organization=organization, id__in=knowledge_ids))
if len(items) != len(set(knowledge_ids)):
raise ValidationError({"knowledgeIds": "Unknown knowledge item"})
return items
@transaction.atomic
def create_agent(*, organization, author, data: AgentCreateInput) -> tuple[AIAgent, object]:
def create_agent(*, organization, data: AgentCreateInput) -> AIAgent:
if not data.channel_code:
raise ValidationError({"channel": "Channel is required"})
if not data.model:
@@ -115,29 +72,29 @@ def create_agent(*, organization, author, data: AgentCreateInput) -> tuple[AIAge
name=f"{channel.name} Agent",
is_active=False,
model=data.model,
persona=data.persona,
tone=data.tone,
instructions=data.instructions,
)
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(
channel=channel,
author=author,
model=agent.model,
model_params=agent.model_params,
allowed_tools=agent.allowed_tools,
limits=agent.limits,
knowledge_versions=knowledge_versions,
prompt_versions=prompt_versions,
)
return agent, release
agent.knowledge_items.set(_knowledge_for_ids(organization=organization, knowledge_ids=data.knowledge_ids))
return agent
@transaction.atomic
def update_agent(*, agent: AIAgent, data: AgentInput) -> AIAgent:
agent.name = data.name
agent.model = data.model
agent.model_params = data.model_params
agent.allowed_tools = data.allowed_tools
agent.limits = _normalize_limits(data.limits)
agent.save(update_fields=["name", "model", "model_params", "allowed_tools", "limits", "updated_at"])
agent.persona = data.persona
agent.tone = data.tone
agent.instructions = data.instructions
agent.save(update_fields=["name", "model", "model_params", "allowed_tools", "limits", "persona", "tone", "instructions", "updated_at"])
if data.knowledge_ids is not None:
agent.knowledge_items.set(
_knowledge_for_ids(organization=agent.channel.organization, knowledge_ids=data.knowledge_ids)
)
return agent
@@ -145,3 +102,87 @@ def set_agent_active(*, agent: AIAgent, is_active: bool) -> AIAgent:
agent.is_active = is_active
agent.save(update_fields=["is_active", "updated_at"])
return agent
# --- Знания (ADR-HUB-0023) ---
@dataclass(frozen=True)
class KnowledgeInput:
title: str
description: str
content: str
is_enabled: bool
def create_knowledge(*, organization, data: KnowledgeInput) -> Knowledge:
if not data.title.strip():
raise ValidationError({"title": "Title is required"})
knowledge = Knowledge.objects.create(
organization=organization,
title=data.title.strip(),
description=data.description.strip(),
content=data.content,
is_enabled=data.is_enabled,
)
reindex_knowledge(knowledge)
return knowledge
def update_knowledge(*, knowledge: Knowledge, data: KnowledgeInput) -> Knowledge:
if not data.title.strip():
raise ValidationError({"title": "Title is required"})
content_changed = knowledge.content != data.content
knowledge.title = data.title.strip()
knowledge.description = data.description.strip()
knowledge.content = data.content
knowledge.is_enabled = data.is_enabled
knowledge.save(update_fields=["title", "description", "content", "is_enabled", "updated_at"])
if content_changed:
reindex_knowledge(knowledge)
return knowledge
def delete_knowledge(*, knowledge: Knowledge) -> None:
# Файлы вложений удаляются вместе со знанием: сначала с диска, потом запись.
for attachment in knowledge.attachments.all():
attachment.file.delete(save=False)
knowledge.delete()
_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
@transaction.atomic
def add_attachment(*, knowledge: Knowledge, upload: UploadedFile) -> KnowledgeAttachment:
original_name = (upload.name or "").strip()
if not original_name:
raise ValidationError({"file": "File name is required"})
if upload.size and upload.size > _MAX_ATTACHMENT_BYTES:
raise ValidationError({"file": "File is too large (max 25 MB)"})
# Повторная загрузка с тем же именем заменяет файл (ADR-HUB-0023: без версий).
existing = knowledge.attachments.filter(original_name=original_name).first()
if existing is not None:
existing.file.delete(save=False)
existing.delete()
data = upload.read()
content_type = upload.content_type or ""
attachment = KnowledgeAttachment(
knowledge=knowledge,
original_name=original_name,
content_type=content_type,
size=len(data),
extracted_text=extract_text(filename=original_name, content_type=content_type, data=data),
)
from django.core.files.base import ContentFile
attachment.file.save(original_name, ContentFile(data), save=True)
reindex_knowledge(knowledge)
return attachment
def delete_attachment(*, attachment: KnowledgeAttachment) -> None:
knowledge = attachment.knowledge
attachment.file.delete(save=False)
attachment.delete()
reindex_knowledge(knowledge)
+272 -305
View File
@@ -1,20 +1,24 @@
import json
import tempfile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from hub_platform.ai.models import AIAgent
from hub_platform.ai.models import AIAgent, Knowledge, KnowledgeFragment
from hub_platform.channels.models import Channel
from hub_platform.identity.bootstrap import bootstrap_edevs_owner
from hub_platform.identity.models import EmployeeProfile, EmployeeRole, HumanUser, Organization
from hub_platform.products.models import Product
_MEDIA_ROOT = tempfile.mkdtemp(prefix="hub-test-media-")
def make_channel_with_agent(organization, *, code, name, product=None, model="openai/gpt-4o-mini"):
"""Канал обработки + его агент (ADR-HUB-0019). Bootstrap больше не сидит
"""Канал обработки + его агент (ADR-HUB-0019/0023). Bootstrap не сидит
каналы/агентов — в проде это делает seed_channels, в тестах — этот helper."""
channel = Channel.objects.create(organization=organization, code=code, name=name, product=product, model=model)
agent = AIAgent.objects.create(channel=channel, name=f"{name} Agent", model=model)
channel = Channel.objects.create(organization=organization, code=code, name=name, product=product)
agent = AIAgent.objects.create(channel=channel, name=f"{name} Agent", model=model, is_active=True)
return channel, agent
@@ -39,12 +43,10 @@ class AIAgentInvariantTests(TestCase):
self.organization, code="firepage-sales", name="FirePage — продажи",
product=Product.objects.get(code="firepage"),
)
owner = HumanUser.objects.get(email="owner@edevs.tech")
with self.assertRaises(ValidationError):
create_agent(
organization=self.organization,
author=owner,
data=AgentCreateInput(channel_code=channel.code, model="gpt-4o-mini", system_prompt="", knowledge_document_ids=[]),
data=AgentCreateInput(channel_code=channel.code, model="gpt-4o-mini", persona="", tone="", instructions="", knowledge_ids=[]),
)
def test_new_product_does_not_get_an_agent_automatically(self) -> None:
@@ -67,14 +69,10 @@ class AIAgentApiTests(TestCase):
codes = {item["channel"]["product"]["code"] for item in response.json()["items"]}
self.assertEqual(codes, {"firepage", "foxray"})
def test_owner_creates_agent_for_channel_without_agent(self) -> None:
def test_owner_creates_agent_with_instructions_and_knowledge(self) -> None:
academy = Product.objects.create(organization=self.organization, code="academy", name="Academy")
Channel.objects.create(organization=self.organization, code="academy-sales", name="Academy", product=academy)
knowledge_response = self.client.post(
"/api/v1/ai/knowledge/",
data=json.dumps({"code": "faq", "title": "FAQ", "category": "FAQ", "content": "v1"}),
content_type="application/json",
)
knowledge = Knowledge.objects.create(organization=self.organization, title="FAQ", content="v1")
response = self.client.post(
"/api/v1/ai/agents/",
@@ -82,34 +80,37 @@ class AIAgentApiTests(TestCase):
{
"channel": "academy-sales",
"model": "gpt-4o-mini",
"systemPrompt": "Отвечай по делу.",
"knowledgeDocumentIds": [knowledge_response.json()["document"]["id"]],
"persona": "Ты — ассистент Academy.",
"tone": "Коротко и по делу.",
"instructions": "Отвечай по делу.",
"knowledgeIds": [knowledge.id],
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
body = response.json()
self.assertEqual(body["agent"]["channel"]["code"], "academy-sales")
self.assertFalse(body["agent"]["isActive"])
self.assertEqual(body["release"]["status"], "DRAFT")
body = response.json()["agent"]
self.assertEqual(body["channel"]["code"], "academy-sales")
self.assertFalse(body["isActive"])
self.assertEqual(body["persona"], "Ты — ассистент Academy.")
self.assertEqual([item["id"] for item in body["knowledge"]], [knowledge.id])
def test_owner_cannot_create_second_agent_for_channel(self) -> None:
response = self.client.post(
"/api/v1/ai/agents/",
data=json.dumps({"channel": "firepage-sales", "model": "gpt-4o-mini", "knowledgeDocumentIds": []}),
data=json.dumps({"channel": "firepage-sales", "model": "gpt-4o-mini", "knowledgeIds": []}),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
def test_owner_updates_agent_model(self) -> None:
def test_owner_updates_agent_model_and_instructions(self) -> None:
agent = AIAgent.objects.get(channel__code="firepage-sales")
response = self.client.patch(
f"/api/v1/ai/agents/{agent.id}/update/",
data=json.dumps({"model": "anthropic/claude-3.5", "modelParams": {"temperature": 0.3}}),
data=json.dumps({"model": "anthropic/claude-3.5", "modelParams": {"temperature": 0.3}, "tone": "Дружелюбно."}),
content_type="application/json",
)
@@ -117,6 +118,34 @@ class AIAgentApiTests(TestCase):
agent.refresh_from_db()
self.assertEqual(agent.model, "anthropic/claude-3.5")
self.assertEqual(agent.model_params, {"temperature": 0.3})
self.assertEqual(agent.tone, "Дружелюбно.")
def test_owner_updates_agent_knowledge_selection(self) -> None:
agent = AIAgent.objects.get(channel__code="firepage-sales")
knowledge = Knowledge.objects.create(organization=self.organization, title="FAQ", content="v1")
response = self.client.patch(
f"/api/v1/ai/agents/{agent.id}/update/",
data=json.dumps({"knowledgeIds": [knowledge.id]}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(list(agent.knowledge_items.values_list("id", flat=True)), [knowledge.id])
# Пустой список снимает выбор; отсутствие ключа — не трогает.
self.client.patch(
f"/api/v1/ai/agents/{agent.id}/update/",
data=json.dumps({"name": "Renamed"}),
content_type="application/json",
)
self.assertEqual(agent.knowledge_items.count(), 1)
self.client.patch(
f"/api/v1/ai/agents/{agent.id}/update/",
data=json.dumps({"knowledgeIds": []}),
content_type="application/json",
)
self.assertEqual(agent.knowledge_items.count(), 0)
def test_owner_deactivates_and_activates_agent(self) -> None:
agent = AIAgent.objects.get(channel__code="foxray-sales")
@@ -162,174 +191,174 @@ class AIAgentPermissionTests(TestCase):
self.assertEqual(response.status_code, 403)
class KnowledgeDocumentApiTests(TestCase):
class KnowledgeApiTests(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.client = APIClient()
self.client.login(username="owner@edevs.tech", password="temporary-password")
def _create(self, **overrides):
payload = {"code": "faq", "title": "FAQ", "category": "FAQ", "content": "v1"}
payload = {"title": "FAQ", "description": "Ответы на вопросы", "content": "Refund policy details here."}
payload.update(overrides)
return self.client.post("/api/v1/ai/knowledge/", data=json.dumps(payload), content_type="application/json")
def test_create_makes_first_draft_version(self) -> None:
def test_create_builds_fragments_immediately(self) -> None:
response = self._create()
self.assertEqual(response.status_code, 201)
document = response.json()["document"]
self.assertEqual(document["category"], "FAQ")
self.assertEqual(document["inclusionMode"], "RETRIEVAL")
self.assertEqual(len(document["versions"]), 1)
self.assertEqual(document["versions"][0]["status"], "DRAFT")
self.assertEqual(document["versions"][0]["content"], "v1")
knowledge = response.json()["knowledge"]
self.assertEqual(knowledge["title"], "FAQ")
self.assertEqual(knowledge["description"], "Ответы на вопросы")
self.assertTrue(KnowledgeFragment.objects.filter(knowledge_id=knowledge["id"]).exists())
def test_add_version_then_publish_archives_previous(self) -> None:
document_id = self._create().json()["document"]["id"]
self.client.post(
f"/api/v1/ai/knowledge/{document_id}/versions/",
data=json.dumps({"content": "v2"}),
def test_create_requires_title(self) -> None:
self.assertEqual(self._create(title=" ").status_code, 400)
def test_update_content_reindexes_fragments(self) -> None:
knowledge_id = self._create().json()["knowledge"]["id"]
before = list(KnowledgeFragment.objects.filter(knowledge_id=knowledge_id).values_list("content", flat=True))
response = self.client.patch(
f"/api/v1/ai/knowledge/{knowledge_id}/",
data=json.dumps({"content": "Совсем другой текст про доставку."}),
content_type="application/json",
)
self.client.post(f"/api/v1/ai/knowledge/{document_id}/versions/1/publish/")
response = self.client.post(f"/api/v1/ai/knowledge/{document_id}/versions/2/publish/")
self.assertEqual(response.status_code, 200)
versions = {v["version"]: v["status"] for v in response.json()["document"]["versions"]}
self.assertEqual(versions[2], "PUBLISHED")
self.assertEqual(versions[1], "ARCHIVED")
def test_rollback_creates_new_draft_from_old_content(self) -> None:
document_id = self._create().json()["document"]["id"]
self.client.post(
f"/api/v1/ai/knowledge/{document_id}/versions/",
data=json.dumps({"content": "v2"}),
content_type="application/json",
)
response = self.client.post(
f"/api/v1/ai/knowledge/{document_id}/rollback/",
data=json.dumps({"version": 1}),
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
versions = response.json()["document"]["versions"]
latest = max(versions, key=lambda v: v["version"])
self.assertEqual(latest["version"], 3)
self.assertEqual(latest["status"], "DRAFT")
self.assertEqual(latest["content"], "v1")
def test_create_rejects_unknown_category(self) -> None:
self.assertEqual(self._create(category="NOPE").status_code, 400)
after = list(KnowledgeFragment.objects.filter(knowledge_id=knowledge_id).values_list("content", flat=True))
self.assertNotEqual(before, after)
def test_disable_and_enable(self) -> None:
document_id = self._create().json()["document"]["id"]
disabled = self.client.post(f"/api/v1/ai/knowledge/{document_id}/disable/")
self.assertFalse(disabled.json()["document"]["isEnabled"])
enabled = self.client.post(f"/api/v1/ai/knowledge/{document_id}/enable/")
self.assertTrue(enabled.json()["document"]["isEnabled"])
def test_create_for_foreign_product_is_rejected(self) -> None:
# scope=PRODUCT с несуществующим в организации продуктом → 400.
response = self._create(scope="PRODUCT", product="missing")
self.assertEqual(response.status_code, 400)
class PromptDocumentApiTests(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.client = APIClient()
self.client.login(username="owner@edevs.tech", password="temporary-password")
def test_create_prompt_without_inclusion_mode(self) -> None:
response = self.client.post(
"/api/v1/ai/prompts/",
data=json.dumps({"code": "system", "title": "System", "category": "SYSTEM", "content": "be helpful"}),
knowledge_id = self._create().json()["knowledge"]["id"]
disabled = self.client.patch(
f"/api/v1/ai/knowledge/{knowledge_id}/",
data=json.dumps({"isEnabled": False}),
content_type="application/json",
)
self.assertFalse(disabled.json()["knowledge"]["isEnabled"])
self.assertEqual(response.status_code, 201)
document = response.json()["document"]
self.assertEqual(document["category"], "SYSTEM")
self.assertNotIn("inclusionMode", document)
self.assertEqual(document["versions"][0]["content"], "be helpful")
def test_delete_removes_knowledge_and_fragments(self) -> None:
knowledge_id = self._create().json()["knowledge"]["id"]
def test_prompt_rejects_knowledge_category(self) -> None:
response = self.client.post(
"/api/v1/ai/prompts/",
data=json.dumps({"code": "x", "title": "X", "category": "FAQ", "content": ""}),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
response = self.client.delete(f"/api/v1/ai/knowledge/{knowledge_id}/")
self.assertEqual(response.status_code, 204)
self.assertFalse(Knowledge.objects.filter(id=knowledge_id).exists())
self.assertFalse(KnowledgeFragment.objects.filter(knowledge_id=knowledge_id).exists())
def test_list_omits_content(self) -> None:
self._create()
response = self.client.get("/api/v1/ai/knowledge/")
self.assertEqual(response.status_code, 200)
self.assertNotIn("content", response.json()["items"][0])
class ChannelAIReleaseApiTests(TestCase):
@override_settings(MEDIA_ROOT=_MEDIA_ROOT)
class KnowledgeAttachmentTests(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.channel, _ = make_channel_with_agent(
self.organization, code="firepage-sales", name="FirePage — продажи",
product=Product.objects.get(code="firepage"),
)
self.client = APIClient()
self.client.login(username="owner@edevs.tech", password="temporary-password")
document_id = self.client.post(
"/api/v1/ai/knowledge/",
data=json.dumps({"scope": "PRODUCT", "product": "firepage", "code": "faq", "title": "FAQ", "category": "FAQ", "content": "v1"}),
content_type="application/json",
).json()["document"]["id"]
self.client.post(f"/api/v1/ai/knowledge/{document_id}/versions/1/publish/")
self.knowledge = Knowledge.objects.create(organization=self.organization, title="FAQ", content="Основной текст.")
def _create_release(self):
def _upload(self, name="price.txt", data=b"Full price list contents", content_type="text/plain"):
return self.client.post(
"/api/v1/ai/releases/",
data=json.dumps({"channel": "firepage-sales"}),
f"/api/v1/ai/knowledge/{self.knowledge.id}/attachments/",
data={"file": SimpleUploadedFile(name, data, content_type=content_type)},
format="multipart",
)
def test_upload_keeps_original_name_and_indexes_text(self) -> None:
response = self._upload()
self.assertEqual(response.status_code, 201)
attachment = response.json()["attachment"]
self.assertEqual(attachment["name"], "price.txt")
self.assertTrue(attachment["hasText"])
self.assertIn("/api/v1/ai/files/", attachment["url"])
contents = " ".join(KnowledgeFragment.objects.filter(knowledge=self.knowledge).values_list("content", flat=True))
self.assertIn("price list", contents)
def test_reupload_same_name_replaces_attachment(self) -> None:
self._upload()
response = self._upload(data=b"Updated price list")
self.assertEqual(response.status_code, 201)
self.assertEqual(self.knowledge.attachments.count(), 1)
def test_public_download_without_auth(self) -> None:
attachment_url = self._upload().json()["attachment"]["url"]
path = attachment_url.split("localhost:8000")[-1]
anonymous = APIClient()
response = anonymous.get(path)
self.assertEqual(response.status_code, 200)
self.assertEqual(b"".join(response.streaming_content), b"Full price list contents")
self.assertIn("price.txt", response["Content-Disposition"])
def test_delete_attachment_reindexes(self) -> None:
attachment_id = self._upload().json()["attachment"]["id"]
response = self.client.delete(f"/api/v1/ai/knowledge/{self.knowledge.id}/attachments/{attachment_id}/")
self.assertEqual(response.status_code, 204)
contents = " ".join(KnowledgeFragment.objects.filter(knowledge=self.knowledge).values_list("content", flat=True))
self.assertNotIn("price list", contents)
class KnowledgeImportTests(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.client = APIClient()
self.client.login(username="owner@edevs.tech", password="temporary-password")
def _import(self, documents):
return self.client.post(
"/api/v1/ai/knowledge/import/",
data=json.dumps({"documents": documents}),
content_type="application/json",
)
def test_draft_release_snapshots_published_knowledge_and_agent_config(self) -> None:
response = self._create_release()
def test_creates_new_knowledge(self) -> None:
response = self._import([{"title": "Обзор", "description": "Что это", "content": "Текст обзора"}])
self.assertEqual(response.status_code, 201)
release = response.json()["release"]
self.assertEqual(release["status"], "DRAFT")
self.assertEqual(release["version"], 1)
self.assertEqual(release["model"], "openai/gpt-4o-mini")
self.assertIn({"document": "faq", "version": 1}, release["knowledgeVersions"])
body = response.json()
self.assertEqual(body["created"], 1)
knowledge = Knowledge.objects.get(organization=self.organization, title="Обзор")
self.assertEqual(knowledge.content, "Текст обзора")
self.assertTrue(knowledge.fragments.exists())
def test_publishing_a_release_archives_the_previous_active_one(self) -> None:
first = self._create_release().json()["release"]
self.client.post(f"/api/v1/ai/releases/{first['id']}/publish/")
second = self._create_release().json()["release"]
self.client.post(f"/api/v1/ai/releases/{second['id']}/publish/")
def test_reimport_same_content_is_unchanged(self) -> None:
self._import([{"title": "Обзор", "content": "Текст"}])
first_detail = self.client.get(f"/api/v1/ai/releases/{first['id']}/").json()["release"]
second_detail = self.client.get(f"/api/v1/ai/releases/{second['id']}/").json()["release"]
self.assertEqual(first_detail["status"], "ARCHIVED")
self.assertEqual(second_detail["status"], "PUBLISHED")
response = self._import([{"title": "Обзор", "content": "Текст"}])
def test_rollback_creates_new_draft_from_snapshot(self) -> None:
first = self._create_release().json()["release"]
self.client.post(f"/api/v1/ai/releases/{first['id']}/publish/")
self.assertEqual(response.json()["unchanged"], 1)
self.assertEqual(Knowledge.objects.filter(title="Обзор").count(), 1)
response = self.client.post(f"/api/v1/ai/releases/{first['id']}/rollback/")
def test_changed_content_updates_existing(self) -> None:
self._import([{"title": "Обзор", "content": "v1"}])
response = self._import([{"title": "Обзор", "content": "v2"}])
self.assertEqual(response.json()["updated"], 1)
self.assertEqual(Knowledge.objects.get(title="Обзор").content, "v2")
def test_missing_title_goes_to_failed(self) -> None:
response = self._import([
{"title": "Хороший", "content": "ок"},
{"title": " ", "content": "плохо"},
])
self.assertEqual(response.status_code, 201)
rolled = response.json()["release"]
self.assertEqual(rolled["status"], "DRAFT")
self.assertGreater(rolled["version"], first["version"])
self.assertEqual(rolled["knowledgeVersions"], first["knowledgeVersions"])
def test_release_snapshot_is_immutable(self) -> None:
from django.core.exceptions import ValidationError
from hub_platform.ai.models import ChannelAIRelease
release_id = self._create_release().json()["release"]["id"]
release = ChannelAIRelease.objects.get(id=release_id)
release.model = "anthropic/claude-3.5"
with self.assertRaises(ValidationError):
release.save()
body = response.json()
self.assertEqual(body["created"], 1)
self.assertEqual(len(body["failed"]), 1)
class PiiRedactionTests(TestCase):
@@ -410,7 +439,7 @@ class ChatInvocationTests(TestCase):
result = invoke_chat(
channel=self.channel,
messages=[ChatMessage(role="user", content="hello there")],
purpose="test_chat",
purpose="agent_chat",
)
self.assertTrue(result.text)
@@ -439,7 +468,7 @@ class ChatInvocationTests(TestCase):
invoke_chat(
channel=self.channel,
messages=[ChatMessage(role="user", content="email me a@b.com")],
purpose="test_chat",
purpose="agent_chat",
)
self.assertNotIn("a@b.com", captured["messages"][0].content)
@@ -459,7 +488,7 @@ class ChatInvocationTests(TestCase):
)
with self.assertRaises(ai_limits.LimitExceeded):
invoke_chat(channel=self.channel, messages=[ChatMessage(role="user", content="hi")], purpose="test_chat")
invoke_chat(channel=self.channel, messages=[ChatMessage(role="user", content="hi")], purpose="agent_chat")
self.assertTrue(LlmInvocation.objects.filter(channel=self.channel, status=LlmInvocationStatus.BLOCKED).exists())
def test_invocation_records_used_fragment_ids(self) -> None:
@@ -470,7 +499,7 @@ class ChatInvocationTests(TestCase):
invoke_chat(
channel=self.channel,
messages=[ChatMessage(role="user", content="hi")],
purpose="test_chat",
purpose="agent_chat",
used_fragment_ids=[11, 22],
)
invocation = LlmInvocation.objects.get(channel=self.channel, operation="chat")
@@ -487,196 +516,134 @@ class ChunkingTests(TestCase):
self.assertTrue(all(len(chunk) <= 700 for chunk in chunks))
class TextExtractionTests(TestCase):
def test_extracts_plain_text(self) -> None:
from hub_platform.ai.extraction import extract_text
self.assertEqual(extract_text(filename="a.md", content_type="", data="# Заголовок".encode()), "# Заголовок")
def test_unknown_format_returns_empty(self) -> None:
from hub_platform.ai.extraction import extract_text
self.assertEqual(extract_text(filename="a.bin", content_type="application/octet-stream", data=b"\x00\x01"), "")
def test_broken_pdf_is_not_fatal(self) -> None:
from hub_platform.ai.extraction import extract_text
self.assertEqual(extract_text(filename="a.pdf", content_type="application/pdf", data=b"not a pdf"), "")
class KnowledgeRetrievalTests(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.channel, _ = make_channel_with_agent(
self.channel, self.agent = make_channel_with_agent(
self.organization, code="firepage-sales", name="FirePage — продажи",
product=Product.objects.get(code="firepage"),
)
self.client = APIClient()
self.client.login(username="owner@edevs.tech", password="temporary-password")
document_id = self.client.post(
"/api/v1/ai/knowledge/",
data=json.dumps(
{
"scope": "PRODUCT",
"product": "firepage",
"code": "faq",
"title": "FAQ",
"category": "FAQ",
"content": "Refund policy details here.\n\nDelivery and shipping information.",
}
from hub_platform.ai.services import KnowledgeInput, create_knowledge
self.knowledge = create_knowledge(
organization=self.organization,
data=KnowledgeInput(
title="FAQ",
description="Возвраты и доставка",
content="Refund policy details here.\n\nDelivery and shipping information.",
is_enabled=True,
),
content_type="application/json",
).json()["document"]["id"]
self.client.post(f"/api/v1/ai/knowledge/{document_id}/versions/1/publish/")
)
self.agent.knowledge_items.add(self.knowledge)
from hub_platform.ai import releases
owner = HumanUser.objects.get(email="owner@edevs.tech")
self.release = releases.create_draft_release(channel=self.channel, author=owner)
def test_publish_builds_fragments_with_embeddings(self) -> None:
from hub_platform.ai.models import KnowledgeFragment
fragments = KnowledgeFragment.objects.filter(version__document__code="faq")
def test_create_builds_fragments_with_embeddings(self) -> None:
fragments = KnowledgeFragment.objects.filter(knowledge=self.knowledge)
self.assertGreaterEqual(fragments.count(), 1)
self.assertTrue(all(fragment.embedding is not None for fragment in fragments))
def test_retriever_returns_release_scoped_fragments(self) -> None:
def test_retriever_returns_agent_scoped_fragments(self) -> None:
from hub_platform.ai.retrieval import KnowledgeRetriever
results = KnowledgeRetriever().retrieve(release=self.release, query="refund", limit=5)
results = KnowledgeRetriever().retrieve(agent=self.agent, query="refund", limit=5)
self.assertGreaterEqual(len(results), 1)
self.assertLessEqual(len(results), 5)
def test_retriever_skips_unselected_and_disabled_knowledge(self) -> None:
from hub_platform.ai.retrieval import lexical_search
self.knowledge.is_enabled = False
self.knowledge.save(update_fields=["is_enabled"])
self.assertEqual(lexical_search(self.agent, "refund", limit=5), [])
def test_lexical_search_matches_content(self) -> None:
from hub_platform.ai.retrieval import lexical_search
results = lexical_search(self.release, "Delivery", limit=5)
results = lexical_search(self.agent, "Delivery", limit=5)
self.assertTrue(any("delivery" in fragment.content.lower() for fragment in results))
class TestChatRuntimeTests(TestCase):
class AgentRuntimeTests(TestCase):
def setUp(self) -> None:
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.channel, _ = make_channel_with_agent(
self.channel, self.agent = make_channel_with_agent(
self.organization, code="firepage-sales", name="FirePage — продажи",
product=Product.objects.get(code="firepage"),
)
self.client = APIClient()
self.client.login(username="owner@edevs.tech", password="temporary-password")
document_id = self.client.post(
"/api/v1/ai/knowledge/",
data=json.dumps(
{"scope": "PRODUCT", "product": "firepage", "code": "faq", "title": "FAQ", "category": "FAQ", "content": "Refund policy details here."}
),
content_type="application/json",
).json()["document"]["id"]
self.client.post(f"/api/v1/ai/knowledge/{document_id}/versions/1/publish/")
self.agent.persona = "Ты — ассистент FirePage."
self.agent.tone = "Коротко."
self.agent.instructions = "Отвечай только по знаниям."
self.agent.save()
from hub_platform.ai.services import KnowledgeInput, create_knowledge
from hub_platform.ai import releases
self.knowledge = create_knowledge(
organization=self.organization,
data=KnowledgeInput(title="FAQ", description="Возвраты", content="Refund policy details here.", is_enabled=True),
)
self.agent.knowledge_items.add(self.knowledge)
owner = HumanUser.objects.get(email="owner@edevs.tech")
self.release = releases.create_draft_release(channel=self.channel, author=owner)
def test_system_prompt_joins_three_parts_in_order(self) -> None:
from hub_platform.ai.runtime import agent_system_prompt
def test_test_chat_returns_reply_and_records_used_knowledge(self) -> None:
prompt = agent_system_prompt(self.agent)
self.assertEqual(prompt.index("Ты — ассистент"), 0)
self.assertLess(prompt.index("Коротко."), prompt.index("Отвечай только"))
def test_knowledge_catalog_lists_titles_and_attachment_links(self) -> None:
from hub_platform.ai.runtime import knowledge_catalog
catalog = knowledge_catalog(self.agent)
self.assertIn("FAQ", catalog)
self.assertIn("Возвраты", catalog)
def test_agent_turn_returns_reply_and_fragments(self) -> None:
from hub_platform.ai.models import LlmInvocation
from hub_platform.ai.runtime import run_agent_turn
response = self.client.post(
f"/api/v1/ai/releases/{self.release.id}/test-chat/",
result = run_agent_turn(agent=self.agent, message="refund")
self.assertTrue(result.result.text)
self.assertGreaterEqual(len(result.fragments), 1)
self.assertFalse(result.handoff_suggested)
invocation = LlmInvocation.objects.get(channel=self.channel, purpose="agent_chat", operation="chat")
self.assertTrue(invocation.used_fragment_ids)
def test_channel_turn_requires_active_agent(self) -> None:
from hub_platform.ai.provider.base import ProviderError
from hub_platform.channels.runtime import run_channel_turn
self.agent.is_active = False
self.agent.save(update_fields=["is_active"])
with self.assertRaises(ProviderError):
run_channel_turn(channel=self.channel, message="hi")
def test_channel_test_chat_endpoint(self) -> None:
client = APIClient()
client.login(username="owner@edevs.tech", password="temporary-password")
response = client.post(
f"/api/v1/channels/{self.channel.id}/test-chat/",
data=json.dumps({"message": "refund"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertTrue(body["reply"])
self.assertGreater(body["totalTokens"], 0)
self.assertFalse(body["handoffSuggested"])
self.assertGreaterEqual(len(body["usedKnowledge"]), 1)
invocation = LlmInvocation.objects.get(release=self.release, purpose="test_chat", operation="chat")
self.assertTrue(invocation.used_fragment_ids)
def test_test_chat_suggests_handoff_without_knowledge(self) -> None:
from hub_platform.ai import releases
owner = HumanUser.objects.get(email="owner@edevs.tech")
empty_channel, _ = make_channel_with_agent(
self.organization, code="foxray-sales", name="FoxRay — продажи",
product=Product.objects.get(code="foxray"),
)
empty_release = releases.create_draft_release(channel=empty_channel, author=owner)
response = self.client.post(
f"/api/v1/ai/releases/{empty_release.id}/test-chat/",
data=json.dumps({"message": "anything"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json()["handoffSuggested"])
self.assertEqual(response.json()["usedKnowledge"], [])
def test_test_chat_requires_message(self) -> None:
response = self.client.post(
f"/api/v1/ai/releases/{self.release.id}/test-chat/",
data=json.dumps({"message": " "}),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
class DocumentImportTests(TestCase):
def setUp(self) -> None:
from hub_platform.ai.models import KnowledgeDocument, KnowledgeDocumentVersion
self.KnowledgeDocument = KnowledgeDocument
self.KnowledgeDocumentVersion = KnowledgeDocumentVersion
bootstrap_edevs_owner(email="owner@edevs.tech", password="temporary-password")
self.organization = Organization.objects.get(slug="edevs")
self.client = APIClient()
self.client.login(username="owner@edevs.tech", password="temporary-password")
def _import(self, *, product=None, documents):
body = {**( {"product": product} if product else {}), "documents": documents}
return self.client.post("/api/v1/ai/knowledge/import/", data=json.dumps(body), content_type="application/json")
def test_creates_new_documents_and_publishes(self) -> None:
response = self._import(documents=[{"code": "overview", "title": "Обзор", "category": "OVERVIEW", "content": "Текст обзора"}])
self.assertEqual(response.status_code, 201)
body = response.json()
self.assertEqual(body["created"], 1)
self.assertEqual(body["updated"], 0)
self.assertEqual(body["unchanged"], 0)
doc = self.KnowledgeDocument.objects.get(organization=self.organization, code="overview")
self.assertEqual(doc.title, "Обзор")
published = doc.versions.filter(status="PUBLISHED").first()
self.assertIsNotNone(published)
self.assertEqual(published.content, "Текст обзора")
def test_reimport_same_content_is_unchanged(self) -> None:
self._import(documents=[{"code": "overview", "title": "Обзор", "category": "OVERVIEW", "content": "Текст"}])
versions_before = self.KnowledgeDocumentVersion.objects.filter(document__code="overview").count()
response = self._import(documents=[{"code": "overview", "title": "Обзор", "category": "OVERVIEW", "content": "Текст"}])
self.assertEqual(response.json()["unchanged"], 1)
versions_after = self.KnowledgeDocumentVersion.objects.filter(document__code="overview").count()
self.assertEqual(versions_after, versions_before)
def test_changed_content_creates_new_published_version(self) -> None:
self._import(documents=[{"code": "overview", "title": "Обзор", "category": "OVERVIEW", "content": "v1"}])
response = self._import(documents=[{"code": "overview", "title": "Обзор", "category": "OVERVIEW", "content": "v2"}])
self.assertEqual(response.json()["updated"], 1)
versions = list(self.KnowledgeDocumentVersion.objects.filter(document__code="overview").order_by("version"))
self.assertEqual(len(versions), 2)
self.assertEqual(versions[-1].content, "v2")
self.assertEqual(versions[-1].status, "PUBLISHED")
def test_invalid_category_goes_to_failed(self) -> None:
response = self._import(documents=[
{"code": "good", "title": "Хороший", "category": "OVERVIEW", "content": "ок"},
{"code": "bad", "title": "Плохой", "category": "NOPE", "content": "плохо"},
])
self.assertEqual(response.status_code, 201)
body = response.json()
self.assertEqual(body["created"], 1)
self.assertEqual(len(body["failed"]), 1)
self.assertEqual(body["failed"][0]["code"], "bad")
self.assertTrue(self.KnowledgeDocument.objects.filter(code="good").exists())
self.assertFalse(self.KnowledgeDocument.objects.filter(code="bad").exists())
def test_missing_product_is_reported(self) -> None:
response = self._import(product="ghost", documents=[{"code": "overview", "title": "Обзор", "category": "OVERVIEW", "content": "текст"}])
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()["created"], 0)
self.assertEqual(len(response.json()["failed"]), 1)
self.assertTrue(response.json()["reply"])
+10 -52
View File
@@ -1,63 +1,21 @@
from django.urls import path
from hub_platform.ai import doc_views as docs
from hub_platform.ai import release_views as releases
from hub_platform.ai import views
def _document_routes(prefix: str, view_set) -> list:
return [
path(f"{prefix}/", view_set["list_create"].as_view(), name=f"ai-{prefix}-list"),
path(f"{prefix}/<int:document_id>/", view_set["detail"].as_view(), name=f"ai-{prefix}-detail"),
path(f"{prefix}/<int:document_id>/versions/", view_set["add_version"].as_view(), name=f"ai-{prefix}-add-version"),
path(
f"{prefix}/<int:document_id>/versions/<int:version>/publish/",
view_set["publish"].as_view(),
name=f"ai-{prefix}-publish",
),
path(f"{prefix}/<int:document_id>/rollback/", view_set["rollback"].as_view(), name=f"ai-{prefix}-rollback"),
path(f"{prefix}/<int:document_id>/enable/", view_set["enable"].as_view(), name=f"ai-{prefix}-enable"),
path(f"{prefix}/<int:document_id>/disable/", view_set["disable"].as_view(), name=f"ai-{prefix}-disable"),
]
urlpatterns = [
path("agents/", views.AIAgentListView.as_view(), name="ai-agent-list"),
path("agents/<int:agent_id>/", views.AIAgentDetailView.as_view(), name="ai-agent-detail"),
path("agents/<int:agent_id>/update/", views.AIAgentUpdateView.as_view(), name="ai-agent-update"),
path("agents/<int:agent_id>/activate/", views.AIAgentActivateView.as_view(), name="ai-agent-activate"),
path("agents/<int:agent_id>/deactivate/", views.AIAgentDeactivateView.as_view(), name="ai-agent-deactivate"),
path("releases/", releases.ReleaseListCreateView.as_view(), name="ai-release-list"),
path("releases/<int:release_id>/", releases.ReleaseDetailView.as_view(), name="ai-release-detail"),
path("releases/<int:release_id>/publish/", releases.ReleasePublishView.as_view(), name="ai-release-publish"),
path("releases/<int:release_id>/rollback/", releases.ReleaseRollbackView.as_view(), name="ai-release-rollback"),
path("releases/<int:release_id>/test-chat/", releases.ReleaseTestChatView.as_view(), name="ai-release-test-chat"),
path("knowledge/", views.KnowledgeListCreateView.as_view(), name="ai-knowledge-list"),
path("knowledge/import/", views.KnowledgeImportView.as_view(), name="ai-knowledge-import"),
path("knowledge/<int:knowledge_id>/", views.KnowledgeDetailView.as_view(), name="ai-knowledge-detail"),
path("knowledge/<int:knowledge_id>/attachments/", views.KnowledgeAttachmentUploadView.as_view(), name="ai-knowledge-attachment-upload"),
path(
"knowledge/<int:knowledge_id>/attachments/<int:attachment_id>/",
views.KnowledgeAttachmentDeleteView.as_view(),
name="ai-knowledge-attachment-delete",
),
path("files/<uuid:public_id>/", views.AttachmentDownloadView.as_view(), name="ai-attachment-download"),
]
urlpatterns += _document_routes(
"knowledge",
{
"list_create": docs.KnowledgeListCreateView,
"detail": docs.KnowledgeDetailView,
"add_version": docs.KnowledgeAddVersionView,
"publish": docs.KnowledgePublishVersionView,
"rollback": docs.KnowledgeRollbackView,
"enable": docs.KnowledgeEnableView,
"disable": docs.KnowledgeDisableView,
},
)
urlpatterns.append(path("knowledge/import/", docs.KnowledgeImportView.as_view(), name="ai-knowledge-import"))
urlpatterns += _document_routes(
"prompts",
{
"list_create": docs.PromptListCreateView,
"detail": docs.PromptDetailView,
"add_version": docs.PromptAddVersionView,
"publish": docs.PromptPublishVersionView,
"rollback": docs.PromptRollbackView,
"enable": docs.PromptEnableView,
"disable": docs.PromptDisableView,
},
)
urlpatterns.append(path("prompts/import/", docs.PromptImportView.as_view(), name="ai-prompts-import"))
+214 -11
View File
@@ -1,12 +1,32 @@
from django.core.exceptions import ValidationError
from django.http import FileResponse, Http404
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from hub_platform.ai.models import AIAgent
from hub_platform.ai.selectors import agent_for_organization, agents_for_organization
from hub_platform.ai.serializers import agent_payload, release_payload
from hub_platform.ai.services import AgentCreateInput, AgentInput, create_agent, set_agent_active, update_agent
from hub_platform.ai.models import AIAgent, Knowledge, KnowledgeAttachment
from hub_platform.ai.selectors import (
agent_for_organization,
agents_for_organization,
knowledge_for_organization,
knowledge_item_for_organization,
)
from hub_platform.ai.serializers import agent_payload, attachment_payload, knowledge_payload
from hub_platform.ai.services import (
AgentCreateInput,
AgentInput,
KnowledgeInput,
add_attachment,
create_agent,
create_knowledge,
delete_attachment,
delete_knowledge,
set_agent_active,
update_agent,
update_knowledge,
)
from hub_platform.api.permissions import IsOwner
from hub_platform.identity.audit import record_audit_event
@@ -15,18 +35,25 @@ def _agent_input(body: dict[str, object], *, current: AIAgent) -> AgentInput:
model_params = body.get("modelParams", current.model_params)
allowed_tools = body.get("allowedTools", current.allowed_tools)
limits = body.get("limits", current.limits)
knowledge_ids = body.get("knowledgeIds")
if not isinstance(model_params, dict):
raise ValidationError({"modelParams": "Object required"})
if not isinstance(allowed_tools, list):
raise ValidationError({"allowedTools": "List required"})
if not isinstance(limits, dict):
raise ValidationError({"limits": "Object required"})
if knowledge_ids is not None and (not isinstance(knowledge_ids, list) or not all(isinstance(item, int) for item in knowledge_ids)):
raise ValidationError({"knowledgeIds": "List of ids required"})
return AgentInput(
name=str(body.get("name", current.name)).strip() or current.name,
model=str(body.get("model", current.model)).strip() or current.model,
model_params=model_params,
allowed_tools=allowed_tools,
limits=limits,
persona=str(body.get("persona", current.persona)),
tone=str(body.get("tone", current.tone)),
instructions=str(body.get("instructions", current.instructions)),
knowledge_ids=knowledge_ids,
)
@@ -43,18 +70,19 @@ class AIAgentListView(APIView):
return Response({"items": [agent_payload(agent) for agent in agents]})
def post(self, request: Request) -> Response:
knowledge_ids = request.data.get("knowledgeDocumentIds", [])
knowledge_ids = request.data.get("knowledgeIds", [])
if not isinstance(knowledge_ids, list) or not all(isinstance(item, int) for item in knowledge_ids):
return Response({"detail": "knowledgeDocumentIds must be a list of ids"}, status=400)
return Response({"detail": "knowledgeIds must be a list of ids"}, status=400)
try:
agent, release = create_agent(
agent = create_agent(
organization=request.user.employee_profile.organization,
author=request.user,
data=AgentCreateInput(
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,
persona=str(request.data.get("persona", "")),
tone=str(request.data.get("tone", "")),
instructions=str(request.data.get("instructions", "")),
knowledge_ids=knowledge_ids,
),
)
except ValidationError as error:
@@ -67,7 +95,7 @@ class AIAgentListView(APIView):
object_id=str(agent.id),
request=request,
)
return Response({"agent": agent_payload(agent), "release": release_payload(release)}, status=201)
return Response({"agent": agent_payload(agent)}, status=201)
class AIAgentDetailView(APIView):
@@ -102,6 +130,7 @@ class AIAgentUpdateView(APIView):
object_id=str(agent.id),
request=request,
)
agent = agent_for_organization(organization_id=organization_id, agent_id=agent_id)
return Response({"agent": agent_payload(agent)})
@@ -133,3 +162,177 @@ class AIAgentActivateView(_AIAgentStatusView):
class AIAgentDeactivateView(_AIAgentStatusView):
target_active = False
# --- Знания (ADR-HUB-0023) ---
def _knowledge_input(body: dict[str, object], *, current: Knowledge | None = None) -> KnowledgeInput:
is_enabled = body.get("isEnabled", current.is_enabled if current else True)
return KnowledgeInput(
title=str(body.get("title", current.title if current else "")),
description=str(body.get("description", current.description if current else "")),
content=str(body.get("content", current.content if current else "")),
is_enabled=bool(is_enabled),
)
class _KnowledgeBaseView(APIView):
permission_classes = [IsOwner]
def _org(self, request: Request):
return request.user.employee_profile.organization
def _knowledge(self, request: Request, knowledge_id: int) -> Knowledge:
return knowledge_item_for_organization(organization_id=self._org(request).id, knowledge_id=knowledge_id)
def _audit(self, request: Request, action: str, knowledge: Knowledge) -> None:
record_audit_event(
action=f"ai.knowledge_{action}",
actor=request.user,
organization=self._org(request),
object_type="Knowledge",
object_id=str(knowledge.id),
request=request,
)
class KnowledgeListCreateView(_KnowledgeBaseView):
def get(self, request: Request) -> Response:
items = knowledge_for_organization(self._org(request).id)
return Response({"items": [knowledge_payload(item, include_content=False) for item in items]})
def post(self, request: Request) -> Response:
try:
knowledge = create_knowledge(organization=self._org(request), data=_knowledge_input(request.data))
except ValidationError as error:
return _validation_error(error)
knowledge = self._knowledge(request, knowledge.id)
self._audit(request, "created", knowledge)
return Response({"knowledge": knowledge_payload(knowledge)}, status=201)
class KnowledgeDetailView(_KnowledgeBaseView):
def get(self, request: Request, knowledge_id: int) -> Response:
try:
knowledge = self._knowledge(request, knowledge_id)
except Knowledge.DoesNotExist:
return Response({"detail": "Knowledge not found"}, status=404)
return Response({"knowledge": knowledge_payload(knowledge)})
def patch(self, request: Request, knowledge_id: int) -> Response:
try:
knowledge = self._knowledge(request, knowledge_id)
except Knowledge.DoesNotExist:
return Response({"detail": "Knowledge not found"}, status=404)
try:
knowledge = update_knowledge(knowledge=knowledge, data=_knowledge_input(request.data, current=knowledge))
except ValidationError as error:
return _validation_error(error)
knowledge = self._knowledge(request, knowledge_id)
self._audit(request, "updated", knowledge)
return Response({"knowledge": knowledge_payload(knowledge)})
def delete(self, request: Request, knowledge_id: int) -> Response:
try:
knowledge = self._knowledge(request, knowledge_id)
except Knowledge.DoesNotExist:
return Response({"detail": "Knowledge not found"}, status=404)
self._audit(request, "deleted", knowledge)
delete_knowledge(knowledge=knowledge)
return Response(status=204)
class KnowledgeImportView(_KnowledgeBaseView):
# POST {documents: [{title, description?, content}]} — идемпотентный upsert
# по заголовку (SPEC-HUB-0012). Ошибки per-doc копятся в failed[].
def post(self, request: Request) -> Response:
documents = request.data.get("documents")
if not isinstance(documents, list) or not documents:
return Response({"detail": "documents must be a non-empty list"}, status=400)
organization = self._org(request)
created = updated = unchanged = 0
failed: list[dict[str, str]] = []
for index, item in enumerate(documents):
title = str(item.get("title", "")).strip() if isinstance(item, dict) else ""
if not title:
failed.append({"title": "", "detail": f"Документ #{index + 1}: пустой заголовок"})
continue
description = str(item.get("description", "") or "").strip()
content = str(item.get("content", "") or "")
knowledge = Knowledge.objects.filter(organization=organization, title=title).first()
if knowledge is None:
create_knowledge(
organization=organization,
data=KnowledgeInput(title=title, description=description, content=content, is_enabled=True),
)
created += 1
elif knowledge.content == content and knowledge.description == (description or knowledge.description):
unchanged += 1
else:
update_knowledge(
knowledge=knowledge,
data=KnowledgeInput(
title=title,
description=description or knowledge.description,
content=content,
is_enabled=knowledge.is_enabled,
),
)
updated += 1
record_audit_event(
action="ai.knowledge_imported",
actor=request.user,
organization=organization,
object_type="Knowledge",
object_id="",
payload={"created": created, "updated": updated, "unchanged": unchanged, "failed": failed},
request=request,
)
return Response({"created": created, "updated": updated, "unchanged": unchanged, "failed": failed}, status=201)
class KnowledgeAttachmentUploadView(_KnowledgeBaseView):
parser_classes = [MultiPartParser, FormParser, JSONParser]
def post(self, request: Request, knowledge_id: int) -> Response:
try:
knowledge = self._knowledge(request, knowledge_id)
except Knowledge.DoesNotExist:
return Response({"detail": "Knowledge not found"}, status=404)
upload = request.FILES.get("file")
if upload is None:
return Response({"detail": "file is required (multipart/form-data)"}, status=400)
try:
attachment = add_attachment(knowledge=knowledge, upload=upload)
except ValidationError as error:
return _validation_error(error)
self._audit(request, "attachment_added", knowledge)
return Response({"attachment": attachment_payload(attachment)}, status=201)
class KnowledgeAttachmentDeleteView(_KnowledgeBaseView):
def delete(self, request: Request, knowledge_id: int, attachment_id: int) -> Response:
try:
knowledge = self._knowledge(request, knowledge_id)
except Knowledge.DoesNotExist:
return Response({"detail": "Knowledge not found"}, status=404)
attachment = knowledge.attachments.filter(id=attachment_id).first()
if attachment is None:
return Response({"detail": "Attachment not found"}, status=404)
delete_attachment(attachment=attachment)
self._audit(request, "attachment_deleted", knowledge)
return Response(status=204)
class AttachmentDownloadView(APIView):
# Публичная ссылка (ADR-HUB-0023): уходит клиентам в мессенджеры, где нет
# аутентификации Hub. Защита — непредсказуемый UUID.
permission_classes = [AllowAny]
authentication_classes: list = []
def get(self, request: Request, public_id) -> FileResponse:
attachment = KnowledgeAttachment.objects.filter(public_id=public_id).first()
if attachment is None:
raise Http404
return FileResponse(attachment.file.open("rb"), as_attachment=True, filename=attachment.original_name)
+1 -1
View File
@@ -5,6 +5,6 @@ from hub_platform.channels.models import Channel
@admin.register(Channel)
class ChannelAdmin(admin.ModelAdmin):
list_display = ("name", "code", "product", "department", "model", "is_active")
list_display = ("name", "code", "product", "department", "is_active")
list_filter = ("is_active",)
search_fields = ("name", "code")
@@ -1,7 +1,7 @@
"""Seed processing channels (M1.2a, ADR-HUB-0019). Idempotent.
"""Seed processing channels (ADR-HUB-0019/0023). Idempotent.
Creates the company site channel "edevs" (no product) plus product channels for
FoxRay and FirePage, each with a live system prompt and the Sonnet 4.6 model,
FoxRay and FirePage, each with an active AI agent (persona/tone/instructions),
bound to the configured OpenRouter provider integration if present.
"""
@@ -10,42 +10,38 @@ 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.ai.models import DEFAULT_AI_MODEL, AIAgent
from hub_platform.channels.models import Channel
from hub_platform.identity.models import Department, Organization
from hub_platform.integrations.models import Integration, IntegrationProvider
from hub_platform.products.models import Product
# Общий стиль для мессенджера: простой текст без markdown и сдержанная передача оператору.
STYLE = (
" Пиши простым текстом для мессенджера: без markdown-разметки (никаких **, ##, маркированных "
# Тон общения (поле tone агента): простой текст для мессенджера.
TONE = (
"Пиши простым текстом для мессенджера: без markdown-разметки (никаких **, ##, маркированных "
"списков и разделителей ---), короткими абзацами, на русском, по делу. "
"Не предлагай оператора по умолчанию: передавай человеку только если клиент сам просит человека, "
"вопрос выходит за рамки твоих знаний, или это жалоба/спорная ситуация."
)
EDEVS_PROMPT = (
EDEVS_PERSONA = (
"Ты — AI-ассистент компании Edevs на её главном сайте. "
"Компания делает два продукта: FirePage — готовые нишевые сайты на собственной CMS (разовая лицензия), "
"и FoxRay — сервис для ортодонтов по подписке (тарифы Бесплатный/Про/Макс). "
"и FoxRay — сервис для ортодонтов по подписке (тарифы Бесплатный/Про/Макс)."
)
EDEVS_INSTRUCTIONS = (
"Помогай посетителю разобраться, какой продукт ему подходит, отвечай на вопросы о компании и продуктах. "
"Оплату и оформление ты не проводишь." + STYLE
)
FOXRAY_PROMPT = (
"Ты — AI sales-ассистент продукта FoxRay (сервис для ортодонтов, подписка: Бесплатный/Про/Макс). "
"Помогай подобрать тариф и ответить на вопросы. Оплату и оформление не проводишь." + STYLE
)
FIREPAGE_PROMPT = (
"Ты — AI sales-ассистент продукта FirePage (готовые нишевые сайты, разовая лицензия). "
"Помогай выбрать сайт и ответить на вопросы. Оплату и оформление не проводишь." + STYLE
"Оплату и оформление ты не проводишь."
)
FOXRAY_PERSONA = "Ты — AI sales-ассистент продукта FoxRay (сервис для ортодонтов, подписка: Бесплатный/Про/Макс)."
FOXRAY_INSTRUCTIONS = "Помогай подобрать тариф и ответить на вопросы. Оплату и оформление не проводишь."
FIREPAGE_PERSONA = "Ты — AI sales-ассистент продукта FirePage (готовые нишевые сайты, разовая лицензия)."
FIREPAGE_INSTRUCTIONS = "Помогай выбрать сайт и ответить на вопросы. Оплату и оформление не проводишь."
# (code, name, product_code|None, system_prompt)
# (code, name, product_code|None, persona, instructions)
CHANNELS = [
("edevs", "Edevs — главный сайт", None, EDEVS_PROMPT),
("foxray-sales", "FoxRay — продажи", "foxray", FOXRAY_PROMPT),
("firepage-sales", "FirePage — продажи", "firepage", FIREPAGE_PROMPT),
("edevs", "Edevs — главный сайт", None, EDEVS_PERSONA, EDEVS_INSTRUCTIONS),
("foxray-sales", "FoxRay — продажи", "foxray", FOXRAY_PERSONA, FOXRAY_INSTRUCTIONS),
("firepage-sales", "FirePage — продажи", "firepage", FIREPAGE_PERSONA, FIREPAGE_INSTRUCTIONS),
]
@@ -65,11 +61,9 @@ 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:
for code, name, product_code, persona, instructions in CHANNELS:
product = Product.objects.filter(organization=organization, code=product_code).first() if product_code else None
channel, was_created = Channel.objects.update_or_create(
organization=organization,
@@ -79,27 +73,21 @@ class Command(BaseCommand):
"department": sales,
"product": product,
"provider_integration": provider,
"model": DEFAULT_CHANNEL_MODEL,
"system_prompt": prompt,
"is_active": True,
},
)
created += int(was_created)
# Агент канала + опубликованный релиз (ADR-HUB-0007/0019).
# Агент канала (ADR-HUB-0023): одна сущность, без релизов.
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=[],
),
AIAgent.objects.create(
channel=channel,
name=f"{name} Agent",
is_active=True,
model=DEFAULT_AI_MODEL,
persona=persona,
tone=TONE,
instructions=instructions,
)
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(
@@ -0,0 +1,18 @@
# ADR-HUB-0023: AI-поведение канала (модель, промпт) переехало на агента.
# Выполняется после ai/0003, которая переносит system_prompt/model в агентов.
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("channels", "0003_channel_allow_anonymous_sessions_and_more"),
("ai", "0003_flatten_agent_config"),
]
operations = [
migrations.RemoveField(model_name="channel", name="system_prompt"),
migrations.RemoveField(model_name="channel", name="model"),
migrations.RemoveField(model_name="channel", name="model_params"),
]
+3 -11
View File
@@ -1,11 +1,8 @@
from django.db import models
# Канал обработки — якорь AI-контекста (ADR-HUB-0019). Опциональный продукт,
# отдел перехвата, ссылка на провайдер-интеграцию и модель. Подключения
# (боты/виджеты) и release канала прикручиваются на следующих шагах M1.
# Основная модель компании (ADR: Sonnet 4.6 на OpenRouter).
DEFAULT_CHANNEL_MODEL = "anthropic/claude-sonnet-4.6"
# отдел перехвата, ссылка на провайдер-интеграцию. Поведение AI (модель,
# инструкции, знания) живёт на агенте канала (ADR-HUB-0023).
class Channel(models.Model):
@@ -16,13 +13,8 @@ class Channel(models.Model):
department = models.ForeignKey("identity.Department", on_delete=models.PROTECT, related_name="channels", null=True, blank=True)
# Продукт опционален: непродуктовый канал — главный сайт edevs.
product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="channels", null=True, blank=True)
# LLM-провайдер канала (ADR-HUB-0020) и выбранная модель.
# LLM-провайдер канала (ADR-HUB-0020).
provider_integration = models.ForeignKey("integrations.Integration", on_delete=models.PROTECT, related_name="channels", null=True, blank=True)
model = models.CharField(max_length=128, default=DEFAULT_CHANNEL_MODEL)
model_params = models.JSONField(default=dict, blank=True)
# Живой системный промпт канала (M1.2a). Полноценные релизы знаний/промптов
# на уровне канала придут в M1.2b (ADR-HUB-0007/0005).
system_prompt = models.TextField(blank=True)
is_active = models.BooleanField(default=True)
# Политика канала (SPEC-HUB-0010 §4.2). Значения по умолчанию соответствуют
# поведению публичных sales-каналов; support-каналы переключают флаги при seed.
+10 -23
View File
@@ -1,28 +1,15 @@
from hub_platform.ai.invocation import channel_chat
from hub_platform.ai.models import ChannelAIRelease, ReleaseStatus
from hub_platform.ai.provider.base import ChatMessage, ChatResult
from hub_platform.ai.runtime import run_test_chat
from hub_platform.ai.provider.base import ChatResult, ProviderError
from hub_platform.ai.runtime import run_agent_turn
def run_channel_turn(*, channel, message: str, history: list[dict] | None = None) -> ChatResult:
"""One AI turn for a processing channel (ADR-HUB-0019).
"""One AI turn for a processing channel (ADR-HUB-0019/0023).
Prefers the channel's published release (system prompts + knowledge retrieval,
ADR-HUB-0007/0016). Falls back to the channel's live system prompt if no
release is published yet.
AI-поведение канала целиком определяет его агент. Без активного агента
AI-ответа нет: вызывающий код (ingest/support) обрабатывает ProviderError
как «AI недоступен» и передаёт диалог оператору.
"""
release = (
ChannelAIRelease.objects.filter(channel=channel, status=ReleaseStatus.PUBLISHED)
.order_by("-version")
.first()
)
if release is not None:
return run_test_chat(release=release, message=message, history=history, style_guard=True).result
messages: list[ChatMessage] = []
if channel.system_prompt.strip():
messages.append(ChatMessage(role="system", content=channel.system_prompt))
for item in history or []:
messages.append(ChatMessage(role=str(item.get("role", "user")), content=str(item.get("content", ""))))
messages.append(ChatMessage(role="user", content=message))
return channel_chat(channel=channel, messages=messages, purpose="channel_chat")
agent = getattr(channel, "ai_agent", None)
if agent is None or not agent.is_active:
raise ProviderError("Channel has no active AI agent")
return run_agent_turn(agent=agent, message=message, history=history, style_guard=True).result
@@ -8,8 +8,6 @@ def channel_payload(channel: Channel) -> dict[str, object]:
"name": channel.name,
"product": {"code": channel.product.code, "name": channel.product.name} if channel.product_id else None,
"department": channel.department.code if channel.department_id else None,
"model": channel.model,
"systemPrompt": channel.system_prompt,
"providerIntegrationId": channel.provider_integration_id,
"agentId": channel.ai_agent.id if hasattr(channel, "ai_agent") else None,
"isActive": channel.is_active,
@@ -4,8 +4,9 @@
спецификации каналов/продуктов — данные, логика наполнения — в команде.
"""
STYLE = (
" Пиши простым текстом для мессенджера: без markdown-разметки, короткими абзацами, "
# Тон общения (поле tone агента, ADR-HUB-0023): простой текст для мессенджера.
TONE = (
"Пиши простым текстом для мессенджера: без markdown-разметки, короткими абзацами, "
"на русском, по делу. Не проводи оплату и не обещай условия вне базы знаний."
)
@@ -15,7 +16,8 @@ CHANNEL_SPECS = (
"name": "Edevs — главный сайт",
"product_code": None,
"department": "sales",
"system_prompt": "Ты — AI-ассистент компании Edevs на её главном сайте." + STYLE,
"persona": "Ты — AI-ассистент компании Edevs на её главном сайте.",
"instructions": "",
"policy": None, # публичный канал: дефолтные sales-флаги
},
{
@@ -23,7 +25,8 @@ CHANNEL_SPECS = (
"name": "FoxRay — продажи",
"product_code": "foxray",
"department": "sales",
"system_prompt": "Ты — AI sales-ассистент продукта FoxRay для ортодонтов." + STYLE,
"persona": "Ты — AI sales-ассистент продукта FoxRay для ортодонтов.",
"instructions": "",
"policy": None,
},
{
@@ -31,9 +34,8 @@ CHANNEL_SPECS = (
"name": "FirePage — продажи",
"product_code": "firepage",
"department": "sales",
"system_prompt": (
"Ты — AI sales-ассистент продукта FirePage: готовые нишевые сайты." + STYLE
),
"persona": "Ты — AI sales-ассистент продукта FirePage: готовые нишевые сайты.",
"instructions": "",
"policy": None,
},
# Support-каналы (SPEC-HUB-0010 §4.2): authenticated in-product чат,
@@ -43,10 +45,10 @@ CHANNEL_SPECS = (
"name": "FoxRay — поддержка",
"product_code": "foxray",
"department": "support",
"system_prompt": (
"Ты — AI поддержки продукта FoxRay. Помогай по использованию, учитывай"
" verified product context. Не запрашивай имя/email, не продавай и не"
" обещай изменения аккаунта/оплаты без оператора." + STYLE
"persona": "Ты — AI поддержки продукта FoxRay.",
"instructions": (
"Помогай по использованию, учитывай verified product context. Не запрашивай"
" имя/email, не продавай и не обещай изменения аккаунта/оплаты без оператора."
),
"policy": {
"requires_authenticated_product_identity": True,
@@ -61,10 +63,10 @@ CHANNEL_SPECS = (
"name": "FirePage — поддержка",
"product_code": "firepage",
"department": "support",
"system_prompt": (
"Ты — AI поддержки продукта FirePage. Помогай по использованию, учитывай"
" verified product context. Не запрашивай имя/email, не продавай и не"
" обещай изменения аккаунта/оплаты без оператора." + STYLE
"persona": "Ты — AI поддержки продукта FirePage.",
"instructions": (
"Помогай по использованию, учитывай verified product context. Не запрашивай"
" имя/email, не продавай и не обещай изменения аккаунта/оплаты без оператора."
),
"policy": {
"requires_authenticated_product_identity": True,
@@ -81,12 +83,10 @@ PRODUCT_SPECS = (
"code": "firepage",
"name": "FirePage",
"site_url": "https://firepage.ru",
"summary": "Готовые нишевые сайты на собственной CMS с разовой лицензией.",
},
{
"code": "foxray",
"name": "FoxRay",
"site_url": "https://foxray.pro",
"summary": "Онлайн-сервис для цефалометрического анализа ТРГ.",
},
)
@@ -10,9 +10,9 @@ from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils import timezone
from hub_platform.ai.content_importer import ensure_channel_system_prompts, import_ai_content
from hub_platform.ai.seed_releases import ensure_agents_and_releases
from hub_platform.channels.models import DEFAULT_CHANNEL_MODEL, Channel
from hub_platform.ai.content_importer import import_ai_content
from hub_platform.ai.models import DEFAULT_AI_MODEL, AIAgent
from hub_platform.channels.models import Channel
from hub_platform.identity.audit import record_audit_event
from hub_platform.identity.models import (
Department,
@@ -23,7 +23,7 @@ from hub_platform.identity.models import (
)
from hub_platform.products.models import Product, ProductDepartment
from ._seed_specs import CHANNEL_SPECS, PRODUCT_SPECS
from ._seed_specs import CHANNEL_SPECS, PRODUCT_SPECS, TONE
@dataclass(frozen=True)
@@ -63,7 +63,6 @@ def _seed_core(*, owner_email: str, owner_password: str, owner_name: str) -> Cor
defaults={
"name": spec["name"],
"site_url": spec["site_url"],
"summary": spec["summary"],
},
)
ProductDepartment.objects.get_or_create(product=product, department=sales_department)
@@ -129,8 +128,9 @@ def _seed_core(*, owner_email: str, owner_password: str, owner_name: str) -> Cor
)
def _seed_channels(*, organization: Organization) -> int:
def _seed_channels(*, organization: Organization) -> tuple[int, int]:
created = 0
agents_created = 0
for spec in CHANNEL_SPECS:
product = (
Product.objects.get(organization=organization, code=spec["product_code"])
@@ -146,21 +146,31 @@ def _seed_channels(*, organization: Organization) -> int:
"allow_sales_attribution": True,
"allow_checkout_actions": True,
}
_, was_created = Channel.objects.update_or_create(
channel, was_created = Channel.objects.update_or_create(
organization=organization,
code=spec["code"],
defaults={
"name": spec["name"],
"department": department,
"product": product,
"model": DEFAULT_CHANNEL_MODEL,
"system_prompt": spec["system_prompt"],
"is_active": True,
**policy,
},
)
created += int(was_created)
return created
# Агент канала (ADR-HUB-0023): одна сущность, без релизов.
if not AIAgent.objects.filter(channel=channel).exists():
AIAgent.objects.create(
channel=channel,
name=f"{spec['name']} Agent",
is_active=True,
model=DEFAULT_AI_MODEL,
persona=spec["persona"],
tone=TONE,
instructions=spec["instructions"],
)
agents_created += 1
return created, agents_created
class Command(BaseCommand):
@@ -195,41 +205,27 @@ class Command(BaseCommand):
owner_email=owner_email, owner_password=owner_password, owner_name=owner_name
)
call_command("seed_catalog", verbosity=0)
channels_created = _seed_channels(organization=core.organization)
channels_created, agents_created = _seed_channels(organization=core.organization)
from hub_platform.support.seed_support import seed_support_reference
support_stats = seed_support_reference(organization=core.organization)
content_result = import_ai_content(
base_dir=Path(__file__).resolve().parents[6],
organization=core.organization,
author=core.owner,
)
content_result = content_result.add(
ensure_channel_system_prompts(organization=core.organization, author=core.owner)
)
agents_created, releases_created = ensure_agents_and_releases(
organization=core.organization, author=core.owner
)
owner_state = "created" if core.created_owner else "ready"
prompt_stats = (
f"{content_result.prompts_created}/{content_result.prompt_versions_created} versions"
)
knowledge_stats = (
f"{content_result.knowledge_created}/"
f"{content_result.knowledge_versions_created} versions"
)
self.stdout.write(
self.style.SUCCESS(
"initial data seeded: "
f"owner={owner_state}, "
f"channels +{channels_created}, "
f"support {support_stats}, "
f"agents +{agents_created}, "
f"releases +{releases_created}, "
f"prompts +{prompt_stats}, "
f"knowledge +{knowledge_stats}, "
f"support {support_stats}, "
f"knowledge +{content_result.knowledge_created}"
f"/{content_result.knowledge_updated} updated, "
f"fragments +{content_result.fragments_created}, "
f"agents filled {content_result.agents_updated}, "
f"skipped {content_result.skipped_sections}"
)
)
@@ -0,0 +1,16 @@
# ADR-HUB-0023: содержательное описание продукта живёт в Знаниях; продукт —
# техническая запись-якорь (токены, офферы, каналы).
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("products", "0007_product_support_token_secret"),
]
operations = [
migrations.RemoveField(model_name="product", name="summary"),
migrations.RemoveField(model_name="product", name="sales_description"),
]
+2 -2
View File
@@ -18,8 +18,8 @@ class Product(models.Model):
name = models.CharField(max_length=255)
status = models.CharField(max_length=32, choices=ProductStatus.choices, default=ProductStatus.ACTIVE)
site_url = models.URLField(blank=True)
summary = models.CharField(max_length=500, blank=True)
sales_description = models.TextField(blank=True)
# Содержательное описание продукта живёт в Знаниях (ADR-HUB-0023): продукт —
# техническая запись-якорь для заказов, поддержки и каталога offer/price.
# SHA-256 токена бэкенда продукта для вебхука заказов (ADR-HUB-0018). Сам токен
# не хранится — выдаётся один раз командой issue_product_ingest_token.
ingest_token_hash = models.CharField(max_length=64, blank=True, db_index=True)
@@ -38,8 +38,6 @@ def product_payload(product: Product) -> dict[str, object]:
"name": product.name,
"status": product.status,
"siteUrl": product.site_url,
"summary": product.summary,
"salesDescription": product.sales_description,
"departments": [
{"id": link.department_id, "code": link.department.code, "name": link.department.name}
for link in product.department_links.all()
@@ -14,8 +14,6 @@ class ProductInput:
code: str
name: str
site_url: str = ""
summary: str = ""
sales_description: str = ""
department_ids: tuple[int, ...] = ()
@@ -36,8 +34,6 @@ def create_product(*, organization: Organization, data: ProductInput) -> Product
name=data.name.strip(),
status=ProductStatus.ACTIVE,
site_url=data.site_url.strip(),
summary=data.summary.strip(),
sales_description=data.sales_description.strip(),
)
product.full_clean()
product.save()
@@ -50,10 +46,8 @@ def create_product(*, organization: Organization, data: ProductInput) -> Product
def update_product(*, product: Product, data: ProductInput) -> Product:
product.name = data.name.strip()
product.site_url = data.site_url.strip()
product.summary = data.summary.strip()
product.sales_description = data.sales_description.strip()
product.full_clean(exclude=["code"])
product.save(update_fields=["name", "site_url", "summary", "sales_description", "updated_at"])
product.save(update_fields=["name", "site_url", "updated_at"])
departments = _departments(product.organization, data.department_ids)
product.department_links.exclude(department__in=departments).delete()
for department in departments:
@@ -37,8 +37,6 @@ class ProductApiTests(TestCase):
"code": "academy",
"name": "Academy",
"siteUrl": "https://academy.edevs.tech",
"summary": "Обучающий продукт",
"salesDescription": "Описание для продаж",
"departmentIds": [self.sales.id],
}
),
@@ -27,8 +27,6 @@ def _input(body: dict[str, object], *, current: Product | None = None) -> Produc
code=str(body.get("code", current.code if current else "")),
name=str(body.get("name", current.name if current else "")),
site_url=str(body.get("siteUrl", current.site_url if current else "")),
summary=str(body.get("summary", current.summary if current else "")),
sales_description=str(body.get("salesDescription", current.sales_description if current else "")),
department_ids=_department_ids(body.get("departmentIds", current_departments)),
)
+2
View File
@@ -3,6 +3,8 @@ djangorestframework>=3.16,<3.17
cryptography>=43,<45
pgvector>=0.3,<0.4
psycopg[binary]>=3.2,<3.3
pypdf>=5.1,<6
python-docx>=1.1,<2
redis>=5.2,<5.3
uvicorn[standard]>=0.34,<0.35
gunicorn>=23.0,<24.0
+11 -16
View File
@@ -9,7 +9,7 @@ import { AuthChangePassword, AuthLogin, AuthPasswordRecovery, AuthResetPassword,
import { Shell } from "./layout/Shell";
import { pathFromRoute, routeFromPath } from "./router";
import { ErrorScreen, LoadingScreen, PermissionScreen } from "./shared/ui";
import type { AiAgent, AiRelease } from "./features/ai/model";
import type { AiAgent } from "./features/ai/model";
import type { AppData, AuthChallenge, Department, Employee, Product, RouteKey, SessionUser } from "./types";
export function App() {
@@ -24,11 +24,11 @@ export function App() {
const [selectedProductId, setSelectedProductId] = useState<number | null>(initialRoute.productId);
const [selectedProductCode, setSelectedProductCode] = useState<string | null>(initialRoute.productCode);
const [selectedAgentId, setSelectedAgentId] = useState<number | null>(initialRoute.agentId);
const [selectedReleaseId, setSelectedReleaseId] = useState<number | null>(initialRoute.releaseId);
const [selectedKnowledgeId, setSelectedKnowledgeId] = useState<number | null>(initialRoute.knowledgeId);
const [selectedConversationId, setSelectedConversationId] = useState<number | null>(null);
const [selectedClientId, setSelectedClientId] = useState<number | null>(initialRoute.clientId);
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(initialRoute.orderId);
const [data, setData] = useState<AppData>({ employees: [], departments: [], products: [], agents: [], releases: [] });
const [data, setData] = useState<AppData>({ employees: [], departments: [], products: [], agents: [] });
const [dataError, setDataError] = useState(false);
const navigate = useCallback((nextRoute: RouteKey, entityId: number | null = null, replace = false, productCode: string | null = null) => {
@@ -36,7 +36,7 @@ export function App() {
const nextProductId = nextRoute === "productDetail" ? entityId : null;
const nextProductCode = nextRoute === "aiAgentCreate" ? productCode : null;
const nextAgentId = nextRoute === "aiAgentDetail" ? entityId : null;
const nextReleaseId = nextRoute === "aiRelease" ? entityId : null;
const nextKnowledgeId = nextRoute === "aiKnowledgeDetail" ? entityId : null;
const nextConversationId = nextRoute === "salesDialogs" || nextRoute === "supportDialogs" ? entityId : null;
const nextClientId = nextRoute === "salesClientDetail" ? entityId : null;
const nextOrderId = nextRoute === "salesOrderDetail" ? entityId : null;
@@ -46,12 +46,12 @@ export function App() {
setSelectedProductId(nextProductId);
setSelectedProductCode(nextProductCode);
setSelectedAgentId(nextAgentId);
setSelectedReleaseId(nextReleaseId);
setSelectedKnowledgeId(nextKnowledgeId);
setSelectedConversationId(nextConversationId);
setSelectedClientId(nextClientId);
setSelectedOrderId(nextOrderId);
if (`${window.location.pathname}${window.location.search}` !== nextPath) {
const state = { route: nextRoute, employeeId: nextEmployeeId, productId: nextProductId, productCode: nextProductCode, agentId: nextAgentId, releaseId: nextReleaseId };
const state = { route: nextRoute, employeeId: nextEmployeeId, productId: nextProductId, productCode: nextProductCode, agentId: nextAgentId, knowledgeId: nextKnowledgeId };
if (replace) {
window.history.replaceState(state, "", nextPath);
} else {
@@ -69,16 +69,11 @@ export function App() {
api<{ items: Product[] }>("/api/v1/company/products/"),
]);
let agents: AiAgent[] = [];
let releases: AiRelease[] = [];
if (user?.role === "OWNER") {
const [agentsResponse, releasesResponse] = await Promise.all([
api<{ items: AiAgent[] }>("/api/v1/ai/agents/"),
api<{ items: AiRelease[] }>("/api/v1/ai/releases/"),
]);
const agentsResponse = await api<{ items: AiAgent[] }>("/api/v1/ai/agents/");
agents = agentsResponse.items;
releases = releasesResponse.items;
}
setData({ employees: employees.items, departments: departments.items, products: products.items, agents, releases });
setData({ employees: employees.items, departments: departments.items, products: products.items, agents });
} catch {
setDataError(true);
}
@@ -102,7 +97,7 @@ export function App() {
setSelectedProductId(nextRoute.productId);
setSelectedProductCode(nextRoute.productCode);
setSelectedAgentId(nextRoute.agentId);
setSelectedReleaseId(nextRoute.releaseId);
setSelectedKnowledgeId(nextRoute.knowledgeId);
setSelectedClientId(nextRoute.clientId);
setSelectedOrderId(nextRoute.orderId);
};
@@ -124,7 +119,7 @@ export function App() {
setUser(null);
setTotpChallenge(null);
navigate("command", null, true);
setData({ employees: [], departments: [], products: [], agents: [], releases: [] });
setData({ employees: [], departments: [], products: [], agents: [] });
}
if (resetting) {
@@ -156,7 +151,7 @@ export function App() {
) : !canAccess(user.role, route, user.department) ? (
<PermissionScreen onReturn={() => navigate(defaultRoute(user.role, user.department), null, true)} />
) : (
<Shell route={route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={selectedEmployeeId} selectedProductId={selectedProductId} selectedProductCode={selectedProductCode} selectedAgentId={selectedAgentId} selectedReleaseId={selectedReleaseId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedOrderId={selectedOrderId} openOrderRoute={(orderId) => navigate("salesOrderDetail", orderId)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openProductRoute={(productId) => navigate("productDetail", productId)} openAgentCreateRoute={(productCode) => navigate("aiAgentCreate", null, false, productCode)} openAgentRoute={(agentId) => navigate("aiAgentDetail", agentId)} openReleaseRoute={(releaseId) => navigate("aiRelease", releaseId)} openConversationRoute={(conversationId) => navigate("salesDialogs", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={setUser} onLogout={logout} />
<Shell route={route} setRoute={(nextRoute) => navigate(nextRoute)} selectedEmployeeId={selectedEmployeeId} selectedProductId={selectedProductId} selectedProductCode={selectedProductCode} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClientRoute={(clientId) => navigate("salesClientDetail", clientId)} selectedOrderId={selectedOrderId} openOrderRoute={(orderId) => navigate("salesOrderDetail", orderId)} openEmployeeRoute={(employeeId) => navigate("employeeDetail", employeeId)} openProductRoute={(productId) => navigate("productDetail", productId)} openAgentCreateRoute={(productCode) => navigate("aiAgentCreate", null, false, productCode)} openAgentRoute={(agentId) => navigate("aiAgentDetail", agentId)} openKnowledgeRoute={(knowledgeId) => navigate("aiKnowledgeDetail", knowledgeId)} openConversationRoute={(conversationId) => navigate("salesDialogs", conversationId)} user={user} data={data} reload={loadData} onUserUpdated={setUser} onLogout={logout} />
)}
</ConfigProvider>
);
+11
View File
@@ -35,3 +35,14 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
if (response.status === 204 || response.status === 205) return undefined as T;
return response.json() as Promise<T>;
}
// Multipart-загрузка (вложения знаний): Content-Type выставляет браузер (boundary).
export async function apiUpload<T>(path: string, form: FormData): Promise<T> {
const headers = new Headers({ Accept: "application/json", "X-CSRFToken": getCookie("csrftoken") });
const response = await fetch(resolveApiUrl(path), { method: "POST", body: form, credentials: "include", headers });
if (!response.ok) {
const payload = await response.json().catch(() => ({ detail: "Ошибка запроса" }));
throw new Error(payload.detail ?? "Ошибка запроса");
}
return response.json() as Promise<T>;
}
@@ -2,26 +2,22 @@ import { Dropdown } from "antd";
import { Icon } from "../../shared/icons";
import { StatusPill } from "../../shared/ui";
import { MonoLink, ToneBadge } from "../../shared/ui-controls";
import { ToneBadge } from "../../shared/ui-controls";
import { productAccent } from "../../shared/utils";
import { publishedRelease, releaseName, type AiAgent, type AiRelease } from "./model";
import type { AiAgent } from "./model";
export function AgentsTable({
agents,
releases,
menuId,
setMenuId,
toggleActive,
openAgent,
openRelease,
}: {
agents: AiAgent[];
releases: AiRelease[];
menuId: number | null;
setMenuId: (id: number | null) => void;
toggleActive: (agent: AiAgent) => void;
openAgent: (agentId: number) => void;
openRelease: (releaseId: number) => void;
}) {
return (
<div className="table-card">
@@ -32,7 +28,7 @@ export function AgentsTable({
<th>АГЕНТ</th>
<th>КАНАЛ</th>
<th>МОДЕЛЬ</th>
<th>АКТИВНАЯ ВЕРСИЯ</th>
<th className="numeric">ЗНАНИЯ</th>
<th>СТАТУС</th>
<th className="numeric">ДИАЛОГИ СЕЙЧАС</th>
<th className="numeric">КОНВЕРСИЯ</th>
@@ -42,7 +38,6 @@ export function AgentsTable({
<tbody>
{agents.map((agent) => {
const accent = productAccent(agent.channel.code);
const release = publishedRelease(releases, agent.channel.code);
const menuItems = [
{ key: "open", label: <button type="button" onClick={() => { setMenuId(null); openAgent(agent.id); }}><Icon name="external" size={15} />Открыть агента</button> },
{ type: "divider" as const },
@@ -69,13 +64,7 @@ export function AgentsTable({
</td>
<td><ToneBadge bg={accent.bg} color={accent.color}>{agent.channel.name}</ToneBadge></td>
<td><code className="ai-mono">{agent.model}</code></td>
<td>
{release ? (
<span className="ai-release-cell"><MonoLink onClick={() => openRelease(release.id)}>{releaseName(release)}</MonoLink><ToneBadge bg="#f6ffed" color="#389e0d">Опубликована</ToneBadge></span>
) : (
<span className="product-empty-value">—</span>
)}
</td>
<td className="numeric">{agent.knowledge.length > 0 ? agent.knowledge.length : <span className="product-empty-value">—</span>}</td>
<td><StatusPill status={agent.isActive ? "active" : "disabled"} /></td>
<td className="numeric"><span className="product-empty-value">—</span></td>
<td className="numeric"><span className="product-empty-value">—</span></td>
@@ -100,7 +89,7 @@ export function AgentsTable({
</div>
<div className="ai-table-footer">
<span>{agents.length} агента</span>
<span>В первой итерации — один агент на канал обработки</span>
<span>Один агент на канал обработки</span>
</div>
</div>
);
@@ -5,9 +5,9 @@ import { PageHeader } from "../../shared/ui";
import { Button } from "../../shared/ui-controls";
import { AgentsKpiStrip } from "./AgentsKpiStrip";
import { AgentsTable } from "./AgentsTable";
import type { AiAgent, AiRelease } from "./model";
import type { AiAgent } from "./model";
export function AiAgentsPage({ agents, releases, reload, openAgentCreate, openAgent, openRelease }: { agents: AiAgent[]; releases: AiRelease[]; reload: () => void; openAgentCreate: (productCode: string | null) => void; openAgent: (agentId: number) => void; openRelease: (releaseId: number) => void }) {
export function AiAgentsPage({ agents, reload, openAgentCreate, openAgent }: { agents: AiAgent[]; reload: () => void; openAgentCreate: (productCode: string | null) => void; openAgent: (agentId: number) => void }) {
const [menuId, setMenuId] = useState<number | null>(null);
async function toggleActive(agent: AiAgent) {
@@ -19,9 +19,9 @@ export function AiAgentsPage({ agents, releases, reload, openAgentCreate, openAg
return (
<div className="ai-page">
<PageHeader title="AI-агенты" text="Sales-агенты продуктов · один агент на продукт" action={<Button variant="primary" icon="plus" onClick={() => openAgentCreate(null)}>Создать агента</Button>} />
<PageHeader title="AI-агенты" text="Один агент на канал обработки · изменения применяются сразу" action={<Button variant="primary" icon="plus" onClick={() => openAgentCreate(null)}>Создать агента</Button>} />
<AgentsKpiStrip agents={agents} />
<AgentsTable agents={agents} releases={releases} menuId={menuId} setMenuId={setMenuId} toggleActive={toggleActive} openAgent={openAgent} openRelease={openRelease} />
<AgentsTable agents={agents} menuId={menuId} setMenuId={setMenuId} toggleActive={toggleActive} openAgent={openAgent} />
</div>
);
}
@@ -3,6 +3,7 @@ import { UnderlineTabs } from "../../shared/ui-controls";
const ITEMS: Array<{ key: RouteKey; label: string; disabled?: boolean }> = [
{ key: "aiAgents", label: "AI-агенты" },
{ key: "aiKnowledge", label: "Знания" },
{ key: "aiUsage", label: "Использование AI", disabled: true },
];
@@ -4,22 +4,25 @@ import { api } from "../../../api/client";
import { Icon } from "../../../shared/icons";
import { LoadingState } from "../../../shared/ui";
import type { RouteKey } from "../../../types";
import { fetchKnowledgeList, type KnowledgeItem } from "../knowledge/model";
import { useAiAgents } from "../useAiAgents";
import { CreateAgentFooter } from "./CreateAgentFooter";
import { KnowledgeStep } from "./KnowledgeStep";
import { ModelStep } from "./ModelStep";
import { ProductChoiceStep } from "./ProductChoiceStep";
import { PromptStep } from "./PromptStep";
import { startSystemPrompt, type ChannelOption, type CreateAgentResponse, type KnowledgeDocument } from "./model";
import { startInstructions, startPersona, startTone, type ChannelOption, type CreateAgentResponse } from "./model";
export function AiAgentCreatePage({ selectedProductCode, reload, setRoute, openAgent, openRelease }: { selectedProductCode: string | null; reload: () => void; setRoute: (route: RouteKey) => void; openAgent: (agentId: number) => void; openRelease: (releaseId: number) => void }) {
export function AiAgentCreatePage({ selectedProductCode, reload, setRoute, openAgent }: { selectedProductCode: string | null; reload: () => void; setRoute: (route: RouteKey) => void; openAgent: (agentId: number) => void }) {
const { agents, loading: agentsLoading, reload: reloadAgents } = useAiAgents();
const [channels, setChannels] = useState<ChannelOption[]>([]);
const [channelsLoading, setChannelsLoading] = useState(true);
const [channelCode, setChannelCode] = useState<string | null>(selectedProductCode);
const [model, setModel] = useState("anthropic/claude-sonnet-4.6");
const [systemPrompt, setSystemPrompt] = useState(startSystemPrompt);
const [knowledge, setKnowledge] = useState<KnowledgeDocument[]>([]);
const [persona, setPersona] = useState(startPersona);
const [tone, setTone] = useState(startTone);
const [instructions, setInstructions] = useState(startInstructions);
const [knowledge, setKnowledge] = useState<KnowledgeItem[]>([]);
const [selectedKnowledgeIds, setSelectedKnowledgeIds] = useState<number[]>([]);
const [knowledgeLoading, setKnowledgeLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
@@ -35,8 +38,8 @@ export function AiAgentCreatePage({ selectedProductCode, reload, setRoute, openA
// Знания — общая библиотека организации (выбор опционален).
useEffect(() => {
setKnowledgeLoading(true);
api<{ items: KnowledgeDocument[] }>("/api/v1/ai/knowledge/")
.then((payload) => setKnowledge(payload.items.filter((document) => document.versions.length > 0)))
fetchKnowledgeList()
.then((payload) => setKnowledge(payload.items))
.catch(() => setKnowledge([]))
.finally(() => setKnowledgeLoading(false));
}, []);
@@ -62,12 +65,11 @@ export function AiAgentCreatePage({ selectedProductCode, reload, setRoute, openA
try {
const response = await api<CreateAgentResponse>("/api/v1/ai/agents/", {
method: "POST",
body: JSON.stringify({ channel: selectedChannel.code, model, systemPrompt, knowledgeDocumentIds: selectedKnowledgeIds }),
body: JSON.stringify({ channel: selectedChannel.code, model, persona, tone, instructions, knowledgeIds: selectedKnowledgeIds }),
});
reload();
reloadAgents();
if (response.agent) openAgent(response.agent.id);
else if (response.release) openRelease(response.release.id);
else setRoute("aiAgents");
} catch {
setError(true);
@@ -77,7 +79,7 @@ export function AiAgentCreatePage({ selectedProductCode, reload, setRoute, openA
}
const summary = ready && selectedChannel
? `Будет создан агент для канала «${selectedChannel.name}» · модель ${model} · знаний: ${selectedKnowledgeIds.length}. Агент не начнёт отвечать, пока вы не опубликуете первую версию.`
? `Будет создан агент для канала «${selectedChannel.name}» · модель ${model} · знаний: ${selectedKnowledgeIds.length}. Агент создаётся выключенным — запустите его из карточки.`
: "Выберите канал без агента, чтобы продолжить.";
if (agentsLoading || channelsLoading) return <div className="ai-create-page"><LoadingState /></div>;
@@ -87,14 +89,14 @@ export function AiAgentCreatePage({ selectedProductCode, reload, setRoute, openA
<div className="ai-create-container">
<div className="ai-create-header">
<h1>Создание AI-агента</h1>
<p>Один агент на канал обработки. Агент создаётся для канала без агента и начинает работать только после публикации первой версии.</p>
<p>Один агент на канал обработки. Агент создаётся для канала без агента; начнёт отвечать после запуска.</p>
</div>
<div className="ai-create-provider"><Icon name="check" size={16} />Модель по умолчанию <b>Sonnet 4.6</b> · провайдер OpenRouter (раздел «Интеграции»)</div>
{error && <div className="ai-create-error">Не удалось создать агента. Проверьте выбранный канал.</div>}
<ProductChoiceStep channels={availableChannels} selectedChannelCode={channelCode} channelsWithAgents={channelsWithAgents} onSelect={setChannelCode} />
<ModelStep model={model} setModel={setModel} />
<PromptStep systemPrompt={systemPrompt} setSystemPrompt={setSystemPrompt} />
<KnowledgeStep documents={knowledge} selectedIds={selectedKnowledgeIds} loading={knowledgeLoading} toggle={toggleKnowledge} />
<PromptStep persona={persona} tone={tone} instructions={instructions} setPersona={setPersona} setTone={setTone} setInstructions={setInstructions} />
<KnowledgeStep items={knowledge} selectedIds={selectedKnowledgeIds} loading={knowledgeLoading} toggle={toggleKnowledge} />
</div>
<CreateAgentFooter summary={summary} ready={ready} submitting={submitting} onCancel={() => setRoute("aiAgents")} onSubmit={submit} />
</div>
@@ -1,27 +1,26 @@
import { Icon } from "../../../shared/icons";
import { LoadingState } from "../../../shared/ui";
import { knowledgeMeta, type KnowledgeDocument } from "./model";
import type { KnowledgeItem } from "../knowledge/model";
import { CreateAgentStepCard } from "./CreateAgentStepCard";
export function KnowledgeStep({ documents, selectedIds, loading, toggle }: { documents: KnowledgeDocument[]; selectedIds: number[]; loading: boolean; toggle: (id: number) => void }) {
export function KnowledgeStep({ items, selectedIds, loading, toggle }: { items: KnowledgeItem[]; selectedIds: number[]; loading: boolean; toggle: (id: number) => void }) {
return (
<CreateAgentStepCard number={4} title="Обязательные знания" text="Выберите материалы из базы знаний продукта. Они войдут в retrieval index первой версии.">
<CreateAgentStepCard number={4} title="Знания агента" text="Выберите знания из общей библиотеки. Изменить выбор можно в карточке агента в любой момент.">
<div className="ai-create-field-offset ai-create-knowledge">
{loading ? (
<LoadingState variant="inline" />
) : (
documents.map((document) => {
const selected = selectedIds.includes(document.id);
items.map((item) => {
const selected = selectedIds.includes(item.id);
return (
<button className={selected ? "is-selected" : ""} type="button" onClick={() => toggle(document.id)} key={document.id}>
<button className={selected ? "is-selected" : ""} type="button" onClick={() => toggle(item.id)} key={item.id}>
<span>{selected && <Icon name="check" size={12} />}</span>
<strong>{document.title}</strong>
<small>{knowledgeMeta(document)}</small>
<strong>{item.title}</strong>
<small>{item.description || (item.attachments.length > 0 ? `${item.attachments.length} влож.` : "")}</small>
</button>
);
})
)}
<button className="ai-create-knowledge-new" type="button"><Icon name="plus" size={14} />Создать новый материал знаний</button>
</div>
</CreateAgentStepCard>
);
@@ -1,18 +1,15 @@
import { Icon } from "../../../shared/icons";
import { CreateAgentStepCard } from "./CreateAgentStepCard";
const chips = ["Квалификация", "Поведение в продаже", "Ограничения"];
export function PromptStep({ systemPrompt, setSystemPrompt }: { systemPrompt: string; setSystemPrompt: (value: string) => void }) {
export function PromptStep({ persona, tone, instructions, setPersona, setTone, setInstructions }: { persona: string; tone: string; instructions: string; setPersona: (value: string) => void; setTone: (value: string) => void; setInstructions: (value: string) => void }) {
return (
<CreateAgentStepCard number={3} title="Стартовые инструкции" text="Создадим черновые prompt-документы. Доработать их можно в карточке агента до публикации.">
<CreateAgentStepCard number={3} title="Инструкции агента" text="Три части инструкций: кто он, как говорит и по каким правилам работает. Доработать можно в карточке агента.">
<div className="ai-create-field-offset">
<label className="ai-create-label">Системный prompt</label>
<textarea value={systemPrompt} onChange={(event) => setSystemPrompt(event.target.value)} rows={4} />
<div className="ai-create-chips">
{chips.map((chip) => <span key={chip}><Icon name="check" size={12} />{chip}</span>)}
<small>— будут созданы как черновики</small>
</div>
<label className="ai-create-label">Персонализация — кто он и что он</label>
<textarea value={persona} onChange={(event) => setPersona(event.target.value)} rows={3} />
<label className="ai-create-label">Тон общения — как он должен говорить</label>
<textarea value={tone} onChange={(event) => setTone(event.target.value)} rows={3} />
<label className="ai-create-label">Инструкции — правила работы</label>
<textarea value={instructions} onChange={(event) => setInstructions(event.target.value)} rows={4} />
</div>
</CreateAgentStepCard>
);
@@ -1,17 +1,9 @@
import type { AiAgent, AiRelease } from "../model";
import type { AiAgent } from "../model";
export type ChannelOption = { id: number; code: string; name: string; product: { code: string; name: string } | null };
export type KnowledgeDocument = {
id: number;
title: string;
category: string;
versions: Array<{ version: number; status: string }>;
};
export type CreateAgentResponse = {
agent?: AiAgent;
release?: AiRelease;
};
export const modelOptions = [
@@ -19,7 +11,9 @@ export const modelOptions = [
{ value: "openai/gpt-4o-mini", label: "gpt-4o-mini · дешевле" },
];
export const startSystemPrompt = "Ты — агент канала обработки. Отвечай дружелюбно и по делу, на русском. Квалифицируй потребность клиента и помогай довести до результата. Не обещай возможности вне базы знаний. При запросе человека или нестандартной ситуации — передавай диалог оператору.";
export const startPersona = "Ты — AI-ассистент канала обработки. Представляй компанию и помогай клиенту разобраться в продукте.";
export const startTone = "Отвечай дружелюбно и по делу, на русском, простым текстом без markdown-разметки, короткими абзацами.";
export const startInstructions = "Квалифицируй потребность клиента и помогай довести до результата. Не обещай возможности вне базы знаний. При запросе человека или нестандартной ситуации — передавай диалог оператору.";
export function channelDescription(channel: ChannelOption): string {
return channel.product ? `Без агента · продукт ${channel.product.name}` : "Без агента · без продукта";
@@ -28,8 +22,3 @@ export function channelDescription(channel: ChannelOption): string {
export function channelMark(channel: ChannelOption): string {
return (channel.name[0] || channel.code[0] || "").toUpperCase();
}
export function knowledgeMeta(document: KnowledgeDocument): string {
const version = document.versions[0]?.version ?? 1;
return `${document.category} · v${version}`;
}
@@ -1,24 +1,10 @@
import { Icon } from "../../../shared/icons";
import { StatusPill } from "../../../shared/ui";
import { Button } from "../../../shared/ui-controls";
import { productAccent } from "../../../shared/utils";
import { dailyBudget, publishedRelease, releaseDisplayName, type AiAgentDetail, type AiReleaseFull } from "./model";
import { dailyBudget, type AiAgentDetail } from "./model";
export function AiAgentDetailHeader({
agent,
releases,
openRelease,
createRelease,
onEditChannel,
}: {
agent: AiAgentDetail;
releases: AiReleaseFull[];
openRelease: (releaseId: number) => void;
createRelease: () => void;
onEditChannel: () => void;
}) {
export function AiAgentDetailHeader({ agent, onEditChannel }: { agent: AiAgentDetail; onEditChannel: () => void }) {
const accent = productAccent(agent.channel.code);
const release = publishedRelease(releases);
return (
<section className="ai-agent-header">
<div className="ai-agent-heading">
@@ -31,14 +17,11 @@ export function AiAgentDetailHeader({
<div className="ai-agent-meta">
<div><span>Канал</span><button type="button" className="ai-agent-meta-link" onClick={onEditChannel}>{agent.channel.name}</button></div>
<div><span>Модель</span><code className="ai-mono">{agent.model}</code></div>
<div><span>Текущая версия</span>{release ? <button type="button" className="ai-agent-meta-link ai-mono" onClick={() => openRelease(release.id)}>{releaseDisplayName(release)}</button> : <b className="ai-agent-meta-empty">—</b>}</div>
<div><span>Знания</span><b>{agent.knowledge.length > 0 ? `${agent.knowledge.length} выбрано` : "—"}</b></div>
<div><span>Лимиты</span><b>{dailyBudget(agent.limits)}</b></div>
</div>
</div>
</div>
<div className="ai-agent-header-actions">
<Button variant="primary" icon="plus" onClick={createRelease}>Создать черновик версии</Button>
</div>
</section>
);
}
@@ -1,9 +1,8 @@
import { useEffect, useState } from "react";
import { api } from "../../../api/client";
import { Icon } from "../../../shared/icons";
import { EmptyState, LoadingState } from "../../../shared/ui";
import { Button, UnderlineTabs } from "../../../shared/ui-controls";
import { UnderlineTabs } from "../../../shared/ui-controls";
import { AiAgentDetailHeader } from "./AiAgentDetailHeader";
import { AgentEditForm } from "./AgentEditForm";
import { ChannelEditForm } from "./ChannelEditForm";
@@ -11,12 +10,11 @@ import { AiAgentInstructionsTab } from "./AiAgentInstructionsTab";
import { AiAgentKnowledgeTab } from "./AiAgentKnowledgeTab";
import { AiAgentMetricsTab } from "./AiAgentMetricsTab";
import { AiAgentOverviewTab } from "./AiAgentOverviewTab";
import { AiAgentReleasesTab } from "./AiAgentReleasesTab";
import { agentTabs, type AgentTab, type AiReleaseFull } from "./model";
import { agentTabs, type AgentTab } from "./model";
import { useAiAgentDetail } from "./useAiAgentDetail";
export function AiAgentDetailPage({ agentId, openRelease, onAgentLoaded }: { agentId: number | null; openRelease: (releaseId: number) => void; onAgentLoaded: (name: string | null) => void }) {
const { agent, releases, knowledge, prompts, loading, error, reload } = useAiAgentDetail(agentId);
export function AiAgentDetailPage({ agentId, openKnowledge, onAgentLoaded }: { agentId: number | null; openKnowledge: (knowledgeId: number) => void; onAgentLoaded: (name: string | null) => void }) {
const { agent, library, loading, error, reload } = useAiAgentDetail(agentId);
const [tab, setTab] = useState<AgentTab>("overview");
const [editOpen, setEditOpen] = useState(false);
const [channelEdit, setChannelEdit] = useState(false);
@@ -33,35 +31,16 @@ export function AiAgentDetailPage({ agentId, openRelease, onAgentLoaded }: { age
reload();
}
async function createRelease() {
if (!agent) return;
try {
const { release } = await api<{ release: AiReleaseFull }>("/api/v1/ai/releases/", { method: "POST", body: JSON.stringify({ channel: agent.channel.code }) });
reload();
openRelease(release.id);
} catch {
reload();
}
}
if (loading) return <div className="ai-page"><LoadingState /></div>;
if (error || !agent) return <div className="ai-page"><EmptyState title="Не удалось загрузить агента" /></div>;
return (
<div className="ai-agent-page">
<AiAgentDetailHeader agent={agent} releases={releases} openRelease={openRelease} createRelease={createRelease} onEditChannel={() => setChannelEdit(true)} />
<AiAgentDetailHeader agent={agent} onEditChannel={() => setChannelEdit(true)} />
<UnderlineTabs className="ai-agent-tabs" items={agentTabs} value={tab} onChange={setTab} />
{(tab === "instructions" || tab === "knowledge") && (
<div className="ai-notice ai-golive-notice">
<Icon name="warning" size={17} />
<span>Изменения вступят в силу после публикации новой версии канала. Опубликуйте release, чтобы выкатить правки в работу.</span>
<Button variant="primary" icon="bolt" onClick={createRelease}>Создать версию канала</Button>
</div>
)}
{tab === "overview" && <AiAgentOverviewTab agent={agent} toggleActive={toggleActive} onEdit={() => setEditOpen(true)} />}
{tab === "instructions" && <AiAgentInstructionsTab prompts={prompts} product={agent.channel.product} onChanged={reload} />}
{tab === "knowledge" && <AiAgentKnowledgeTab knowledge={knowledge} channelName={agent.channel.name} product={agent.channel.product} onChanged={reload} />}
{tab === "releases" && <AiAgentReleasesTab openRelease={openRelease} releases={releases} />}
{tab === "instructions" && <AiAgentInstructionsTab agent={agent} onChanged={reload} />}
{tab === "knowledge" && <AiAgentKnowledgeTab agent={agent} library={library} openKnowledge={openKnowledge} onChanged={reload} />}
{tab === "metrics" && <AiAgentMetricsTab />}
{editOpen && <AgentEditForm agent={agent} onClose={() => setEditOpen(false)} onSaved={() => { setEditOpen(false); reload(); }} />}
{channelEdit && <ChannelEditForm channel={agent.channel} onClose={() => setChannelEdit(false)} onSaved={() => { setChannelEdit(false); reload(); }} />}
@@ -1,28 +1,57 @@
import { EmptyState } from "../../../shared/ui";
import { DocCard } from "./DocCard";
import { DocCreateForm } from "./DocCreateForm";
import { DocImportForm } from "./DocImportForm";
import { promptCategoryLabel, promptCategoryOptions, type PromptDoc } from "./model";
import { useState } from "react";
import { api } from "../../../api/client";
import { Button } from "../../../shared/ui-controls";
import type { AiAgentDetail } from "./model";
const FIELDS: Array<{ key: "persona" | "tone" | "instructions"; title: string; hint: string }> = [
{ key: "persona", title: "Персонализация", hint: "Кто он и что он: имя, роль, продукт, рамки ответственности." },
{ key: "tone", title: "Тон общения", hint: "Как он должен говорить: стиль, формат, язык." },
{ key: "instructions", title: "Инструкции", hint: "Правила работы: что делать, чего не делать, когда передавать оператору." },
];
export function AiAgentInstructionsTab({ agent, onChanged }: { agent: AiAgentDetail; onChanged: () => void }) {
const [values, setValues] = useState({ persona: agent.persona, tone: agent.tone, instructions: agent.instructions });
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const dirty = values.persona !== agent.persona || values.tone !== agent.tone || values.instructions !== agent.instructions;
async function save() {
setBusy(true);
setError(null);
try {
await api(`/api/v1/ai/agents/${agent.id}/update/`, { method: "PATCH", body: JSON.stringify(values) });
onChanged();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось сохранить");
} finally {
setBusy(false);
}
}
export function AiAgentInstructionsTab({ prompts, product, onChanged }: { prompts: PromptDoc[]; product: { code: string; name: string } | null; onChanged: () => void }) {
return (
<div className="ai-instructions">
{prompts.length === 0 && <EmptyState title="Промпты ещё не заданы" />}
{prompts.map((prompt, index) => (
<DocCard
key={prompt.id}
kind="prompts"
index={index + 1}
id={prompt.id}
title={prompt.title}
category={promptCategoryLabel(prompt.category)}
isEnabled={prompt.isEnabled}
versions={prompt.versions}
onChanged={onChanged}
/>
{FIELDS.map((field, index) => (
<section className="ai-card ai-doc-card" key={field.key}>
<div className="ai-instruction-head">
<span className="ai-instruction-index">{index + 1}</span>
<h3>{field.title}</h3>
<small>{field.hint}</small>
</div>
<textarea
className="ai-doc-textarea"
rows={field.key === "instructions" ? 10 : 5}
value={values[field.key]}
onChange={(event) => setValues((current) => ({ ...current, [field.key]: event.target.value }))}
/>
</section>
))}
<DocCreateForm kind="prompts" categories={promptCategoryOptions} product={product} onCreated={onChanged} />
<DocImportForm kind="prompts" onImported={onChanged} />
{error && <div className="ai-doc-error">{error}</div>}
<div className="ai-doc-actions">
<Button variant="primary" icon="save" disabled={!dirty || busy} onClick={save}>{busy ? "Сохранение…" : "Сохранить инструкции"}</Button>
<span className="ai-doc-updated">Изменения применяются к следующему ответу агента сразу после сохранения.</span>
</div>
</div>
);
}
@@ -1,37 +1,69 @@
import { useState } from "react";
import { api } from "../../../api/client";
import { Icon } from "../../../shared/icons";
import { EmptyState } from "../../../shared/ui";
import { DocCard } from "./DocCard";
import { DocCreateForm } from "./DocCreateForm";
import { DocImportForm } from "./DocImportForm";
import { knowledgeCategoryLabel, knowledgeCategoryOptions, type KnowledgeDoc } from "./model";
import { Button } from "../../../shared/ui-controls";
import type { KnowledgeItem } from "../knowledge/model";
import type { AiAgentDetail } from "./model";
export function AiAgentKnowledgeTab({ agent, library, openKnowledge, onChanged }: { agent: AiAgentDetail; library: KnowledgeItem[]; openKnowledge: (knowledgeId: number) => void; onChanged: () => void }) {
const [selected, setSelected] = useState<number[]>(agent.knowledge.map((item) => item.id));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const initial = agent.knowledge.map((item) => item.id).sort().join(",");
const dirty = [...selected].sort().join(",") !== initial;
function toggle(id: number) {
setSelected((ids) => (ids.includes(id) ? ids.filter((item) => item !== id) : [...ids, id]));
}
async function save() {
setBusy(true);
setError(null);
try {
await api(`/api/v1/ai/agents/${agent.id}/update/`, { method: "PATCH", body: JSON.stringify({ knowledgeIds: selected }) });
onChanged();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось сохранить");
} finally {
setBusy(false);
}
}
export function AiAgentKnowledgeTab({ knowledge, channelName, product, onChanged }: { knowledge: KnowledgeDoc[]; channelName: string; product: { code: string; name: string } | null; onChanged: () => void }) {
return (
<div className="ai-instructions">
<div className="ai-knowledge-headline">База знаний · {channelName} · {knowledge.length} материалов</div>
{knowledge.length === 0 && <EmptyState title="Материалы знаний ещё не добавлены" />}
{knowledge.map((doc, index) => (
<DocCard
key={doc.id}
kind="knowledge"
index={index + 1}
id={doc.id}
title={doc.title}
category={knowledgeCategoryLabel(doc.category)}
isEnabled={doc.isEnabled}
versions={doc.versions}
meta={
<>
<span className={`ai-doc-chip ${doc.scope === "GLOBAL" ? "scope-global" : "scope-product"}`}>
{doc.scope === "GLOBAL" ? "Глобально" : doc.product?.name ?? "Продукт"}
</span>
<span className="ai-doc-chip plain">{doc.inclusionMode === "MANDATORY" ? "Всегда в контексте" : "Поиск"}</span>
</>
}
onChanged={onChanged}
/>
))}
<DocCreateForm kind="knowledge" categories={knowledgeCategoryOptions} product={product} withInclusion onCreated={onChanged} />
<DocImportForm kind="knowledge" onImported={onChanged} />
<div className="ai-knowledge-headline">Выбор знаний · {agent.channel.name} · выбрано {selected.length} из {library.length}</div>
{library.length === 0 && <EmptyState title="В библиотеке пока нет знаний — создайте их в разделе «Знания»" />}
{library.length > 0 && (
<section className="ai-card">
<ul className="agent-knowledge-list">
{library.map((item) => {
const checked = selected.includes(item.id);
return (
<li key={item.id} className={checked ? "is-selected" : ""}>
<label>
<input type="checkbox" checked={checked} onChange={() => toggle(item.id)} />
<span className="agent-knowledge-text">
<strong>{item.title}</strong>
<small>{item.description || "—"}</small>
</span>
</label>
{!item.isEnabled && <span className="ai-doc-chip off">Выключено</span>}
{item.attachments.length > 0 && <span className="ai-doc-chip plain"><Icon name="paperclip" size={12} /> {item.attachments.length}</span>}
<button type="button" className="row-menu-button" aria-label="Открыть знание" onClick={() => openKnowledge(item.id)}><Icon name="external" size={15} /></button>
</li>
);
})}
</ul>
{error && <div className="ai-doc-error">{error}</div>}
<div className="ai-doc-actions">
<Button variant="primary" icon="save" disabled={!dirty || busy} onClick={save}>{busy ? "Сохранение…" : "Сохранить выбор"}</Button>
<span className="ai-doc-updated">Агент использует только выбранные и включённые знания.</span>
</div>
</section>
)}
</div>
);
}
@@ -6,8 +6,8 @@ export function AiAgentOverviewTab({ agent, toggleActive, onEdit }: { agent: AiA
<div className="ai-agent-overview">
<div className="ai-agent-overview-main">
<section className="ai-card">
<h3>Назначение агента</h3>
<p className="ai-agent-purpose">—</p>
<h3>Персонализация</h3>
<p className="ai-agent-purpose">{agent.persona.trim() || "—"}</p>
</section>
<section className="ai-card">
<h3>Runtime сейчас</h3>
@@ -21,8 +21,6 @@ export function AiAgentOverviewTab({ agent, toggleActive, onEdit }: { agent: AiA
<div className="ai-agent-overview-side">
<section className="ai-card">
<h3>Лимиты</h3>
<div className="ai-limit-row"><span>Диалогов в день</span><b>—</b></div>
<div className="ai-limit-bar"><i style={{ width: "0%" }} /></div>
<div className="ai-limit-row"><span>Бюджет в день</span><b>{dailyBudget(agent.limits)}</b></div>
<div className="ai-limit-bar"><i style={{ width: "0%" }} /></div>
</section>
@@ -1,43 +0,0 @@
import { EmptyState } from "../../../shared/ui";
import { MonoLink, ToneBadge } from "../../../shared/ui-controls";
import { formatDate } from "../../../shared/utils";
import { releaseDisplayName, releaseTone, type AiReleaseFull } from "./model";
export function AiAgentReleasesTab({ openRelease, releases }: { openRelease: (releaseId: number) => void; releases: AiReleaseFull[] }) {
if (releases.length === 0) {
return <EmptyState title="Версии ещё не создавались" />;
}
return (
<div className="table-card">
<div className="ai-agents-scroll">
<table className="baseline-table">
<thead>
<tr>
<th>ВЕРСИЯ</th>
<th>СТАТУС</th>
<th>ДАТА</th>
<th>АВТОР</th>
<th className="numeric">ДИАЛОГИ</th>
<th className="numeric">КОНВЕРСИЯ</th>
</tr>
</thead>
<tbody>
{releases.map((release) => {
const tone = releaseTone(release.status);
return (
<tr key={release.id}>
<td><MonoLink onClick={() => openRelease(release.id)}>{releaseDisplayName(release)}</MonoLink></td>
<td><ToneBadge bg={tone.bg} color={tone.color}>{tone.label}</ToneBadge></td>
<td>{formatDate(release.publishedAt ?? release.createdAt)}</td>
<td><span className="product-empty-value">—</span></td>
<td className="numeric"><span className="product-empty-value">—</span></td>
<td className="numeric"><span className="product-empty-value">—</span></td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
@@ -1,103 +0,0 @@
import { useState, type ReactNode } from "react";
import { Icon } from "../../../shared/icons";
import { Button } from "../../../shared/ui-controls";
import { formatDate } from "../../../shared/utils";
import { hasUnpublishedDraft, latestVersion, publishVersion, saveDocVersion, setDocEnabled, type DocKind } from "./docApi";
import { publishedVersion, type DocVersion } from "./model";
type DocCardProps = {
kind: DocKind;
index: number;
id: number;
title: string;
category: string;
isEnabled: boolean;
versions: DocVersion[];
meta?: ReactNode;
onChanged: () => void;
};
export function DocCard({ kind, index, id, title, category, isEnabled, versions, meta, onChanged }: DocCardProps) {
const published = publishedVersion(versions);
const latest = latestVersion(versions);
const pendingDraft = hasUnpublishedDraft(versions);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
function startEdit() {
setDraft(latest?.content ?? "");
setError(null);
setEditing(true);
}
async function run(action: () => Promise<void>) {
setBusy(true);
setError(null);
try {
await action();
setEditing(false);
onChanged();
} catch (err) {
setError(err instanceof Error ? err.message : "Не удалось сохранить");
} finally {
setBusy(false);
}
}
return (
<section className="ai-card ai-doc-card">
<div className="ai-instruction-head">
<span className="ai-instruction-index">{index}</span>
<h3>{title}</h3>
<small>{category}</small>
<div className="ai-doc-status">
{meta}
{published ? (
<span className="ai-doc-chip on">Опубликовано v{published.version}</span>
) : (
<span className="ai-doc-chip off">Не опубликовано</span>
)}
{pendingDraft && <span className="ai-doc-chip draft">Черновик v{latest?.version}</span>}
{!isEnabled && <span className="ai-doc-chip off">Выключено</span>}
</div>
</div>
{editing ? (
<>
<textarea className="ai-doc-textarea" value={draft} rows={10} onChange={(event) => setDraft(event.target.value)} />
{error && <div className="ai-doc-error">{error}</div>}
<div className="ai-doc-actions">
<Button variant="secondary" disabled={busy} onClick={() => setEditing(false)}>Отмена</Button>
<Button variant="secondary" icon="save" disabled={busy} onClick={() => run(() => saveDocVersion(kind, id, draft, false))}>
Сохранить черновик
</Button>
<Button variant="primary" icon="check" disabled={busy} onClick={() => run(() => saveDocVersion(kind, id, draft, true))}>
Сохранить и опубликовать
</Button>
</div>
</>
) : (
<>
<pre className="ai-prompt-block">{(published ?? latest)?.content?.trim() || "—"}</pre>
<div className="ai-doc-actions">
<Button variant="secondary" icon="edit" onClick={startEdit}>Редактировать</Button>
{pendingDraft && latest && (
<Button variant="primary" icon="check" disabled={busy} onClick={() => run(() => publishVersion(kind, id, latest.version))}>
Опубликовать черновик v{latest.version}
</Button>
)}
<Button variant="secondary" icon={isEnabled ? "eyeOff" : "eye"} disabled={busy} onClick={() => run(() => setDocEnabled(kind, id, !isEnabled))}>
{isEnabled ? "Выключить" : "Включить"}
</Button>
{latest && <span className="ai-doc-updated">обновлено {formatDate(latest.createdAt)}</span>}
</div>
{error && <div className="ai-doc-error">{error}</div>}
</>
)}
</section>
);
}
@@ -1,119 +0,0 @@
import { useState } from "react";
import { Button } from "../../../shared/ui-controls";
import { createDoc, type CreateDocInput, type DocKind } from "./docApi";
type DocCreateFormProps = {
kind: DocKind;
categories: Array<{ value: string; label: string }>;
product: { code: string; name: string } | null;
withInclusion?: boolean;
onCreated: () => void;
};
export function DocCreateForm({ kind, categories, product, withInclusion = false, onCreated }: DocCreateFormProps) {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [category, setCategory] = useState(categories[0]?.value ?? "");
const [content, setContent] = useState("");
// Знания можно делать глобальными или привязать к продукту канала; промпт всегда в скоупе канала.
const [scope, setScope] = useState<"GLOBAL" | "PRODUCT">(product ? "PRODUCT" : "GLOBAL");
const [inclusion, setInclusion] = useState<"MANDATORY" | "RETRIEVAL">("RETRIEVAL");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
function reset() {
setTitle("");
setCategory(categories[0]?.value ?? "");
setContent("");
setScope(product ? "PRODUCT" : "GLOBAL");
setInclusion("RETRIEVAL");
setError(null);
}
async function submit() {
if (!title.trim()) {
setError("Укажите название");
return;
}
// Промпт всегда привязан к продукту канала (или глобальный, если у канала нет продукта).
const effectiveScope = kind === "prompts" ? (product ? "PRODUCT" : "GLOBAL") : scope;
const input: CreateDocInput = {
title: title.trim(),
category,
content,
scope: effectiveScope,
...(effectiveScope === "PRODUCT" && product ? { product: product.code } : {}),
...(withInclusion ? { inclusionMode: inclusion } : {}),
};
setBusy(true);
setError(null);
try {
await createDoc(kind, input);
reset();
setOpen(false);
onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : "Не удалось создать");
} finally {
setBusy(false);
}
}
if (!open) {
return (
<div className="ai-doc-add">
<Button variant="secondary" icon="plus" onClick={() => setOpen(true)}>
{kind === "knowledge" ? "Добавить материал" : "Добавить инструкцию"}
</Button>
</div>
);
}
return (
<section className="ai-card ai-doc-form">
<div className="ai-doc-form-row">
<label>
<span>Название</span>
<input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Например, Тарифы и оплата" />
</label>
<label>
<span>Категория</span>
<select value={category} onChange={(event) => setCategory(event.target.value)}>
{categories.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
</div>
{(kind === "knowledge" && product) || withInclusion ? (
<div className="ai-doc-form-row">
{kind === "knowledge" && product && (
<label>
<span>Область</span>
<select value={scope} onChange={(event) => setScope(event.target.value as "GLOBAL" | "PRODUCT")}>
<option value="PRODUCT">{product.name}</option>
<option value="GLOBAL">Глобально (вся компания)</option>
</select>
</label>
)}
{withInclusion && (
<label>
<span>Включение</span>
<select value={inclusion} onChange={(event) => setInclusion(event.target.value as "MANDATORY" | "RETRIEVAL")}>
<option value="RETRIEVAL">По релевантности (поиск)</option>
<option value="MANDATORY">Всегда в контексте</option>
</select>
</label>
)}
</div>
) : null}
<textarea className="ai-doc-textarea" value={content} rows={8} placeholder="Содержимое" onChange={(event) => setContent(event.target.value)} />
{error && <div className="ai-doc-error">{error}</div>}
<div className="ai-doc-actions">
<Button variant="secondary" disabled={busy} onClick={() => { reset(); setOpen(false); }}>Отмена</Button>
<Button variant="primary" icon="plus" disabled={busy} onClick={submit}>Создать черновик</Button>
</div>
</section>
);
}
@@ -1,105 +0,0 @@
import { useState } from "react";
import { Button } from "../../../shared/ui-controls";
import { importDocs, type DocKind, type ImportResult } from "./docApi";
import { parseDocYaml } from "./parseDocYaml";
type DocImportFormProps = {
kind: DocKind;
onImported: () => void;
};
export function DocImportForm({ kind, onImported }: DocImportFormProps) {
const [open, setOpen] = useState(false);
const [preview, setPreview] = useState<{ count: number; product: string | null } | null>(null);
const [parsed, setParsed] = useState<ReturnType<typeof parseDocYaml> | null>(null);
const [result, setResult] = useState<ImportResult | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
function reset() {
setPreview(null);
setParsed(null);
setResult(null);
setError(null);
}
async function onFile(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setError(null);
setResult(null);
try {
const text = await file.text();
const data = parseDocYaml(text);
setParsed(data);
setPreview({ count: data.documents.length, product: data.product });
} catch (caught) {
setParsed(null);
setPreview(null);
setError(caught instanceof Error ? caught.message : "Не удалось прочитать файл");
}
}
async function submit() {
if (!parsed) return;
setBusy(true);
setError(null);
try {
const report = await importDocs(kind, parsed.product, parsed.documents);
setResult(report);
setParsed(null);
setPreview(null);
onImported();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось импортировать");
} finally {
setBusy(false);
}
}
if (!open) {
return (
<div className="ai-doc-add">
<Button variant="secondary" icon="download" onClick={() => setOpen(true)}>
Импорт YAML
</Button>
</div>
);
}
return (
<section className="ai-card ai-doc-form">
<div className="ai-doc-form-row">
<label>
<span>Файл YAML</span>
<input type="file" accept=".yaml,.yml" onChange={(event) => void onFile(event)} />
</label>
</div>
{preview && (
<div className="ai-doc-import-preview">
К импорту: <b>{preview.count}</b> документ(ов){preview.product ? `, продукт «${preview.product}»` : ", глобально"}.
</div>
)}
{result && (
<div className="ai-doc-import-report">
<div className="ai-doc-import-summary">
Создано: <b>{result.created}</b> · Обновлено: <b>{result.updated}</b> · Без изменений: <b>{result.unchanged}</b>
</div>
{result.failed.length > 0 && (
<ul className="ai-doc-import-failed">
{result.failed.map((item, index) => (
<li key={index}>{item.code ? `«${item.code}»: ` : ""}{item.detail}</li>
))}
</ul>
)}
</div>
)}
{error && <div className="ai-doc-error">{error}</div>}
<div className="ai-doc-actions">
<Button variant="secondary" disabled={busy} onClick={() => { reset(); setOpen(false); }}>Закрыть</Button>
<Button variant="primary" icon="download" disabled={!parsed || busy} onClick={submit}>{busy ? "Импорт…" : "Импорт"}</Button>
</div>
</section>
);
}
@@ -1,99 +0,0 @@
import { api } from "../../../api/client";
import type { DocVersion, KnowledgeDoc, PromptDoc } from "./model";
// Жизненный цикл документов знаний/промптов (бэкенд: hub_platform/ai/doc_views.py).
// kind совпадает с REST-префиксом: knowledge | prompts.
export type DocKind = "knowledge" | "prompts";
type DocPayload<K extends DocKind> = K extends "knowledge" ? KnowledgeDoc : PromptDoc;
function base(kind: DocKind): string {
return `/api/v1/ai/${kind}`;
}
export function latestVersion(versions: DocVersion[]): DocVersion | undefined {
return [...versions].sort((a, b) => b.version - a.version)[0];
}
export function hasUnpublishedDraft(versions: DocVersion[]): boolean {
const latest = latestVersion(versions);
return latest !== undefined && latest.status === "DRAFT";
}
async function addVersion<K extends DocKind>(kind: K, id: number, content: string): Promise<DocPayload<K>> {
const { document } = await api<{ document: DocPayload<K> }>(`${base(kind)}/${id}/versions/`, {
method: "POST",
body: JSON.stringify({ content }),
});
return document;
}
export async function publishVersion(kind: DocKind, id: number, version: number): Promise<void> {
await api(`${base(kind)}/${id}/versions/${version}/publish/`, { method: "POST" });
}
// Сохранить новый черновик; при publish — сразу опубликовать только что созданную версию.
export async function saveDocVersion<K extends DocKind>(
kind: K,
id: number,
content: string,
publish: boolean,
): Promise<void> {
const document = await addVersion(kind, id, content);
if (publish) {
const created = latestVersion(document.versions);
if (created) await publishVersion(kind, id, created.version);
}
}
export async function setDocEnabled(kind: DocKind, id: number, enabled: boolean): Promise<void> {
await api(`${base(kind)}/${id}/${enabled ? "enable" : "disable"}/`, { method: "POST" });
}
export type CreateDocInput = {
title: string;
category: string;
content: string;
scope: "GLOBAL" | "PRODUCT";
product?: string;
inclusionMode?: "MANDATORY" | "RETRIEVAL";
};
export type ImportDocInput = {
code: string;
title: string;
category: string;
content: string;
inclusionMode?: "MANDATORY" | "RETRIEVAL";
};
export type ImportResult = {
created: number;
updated: number;
unchanged: number;
failed: Array<{ code: string; detail: string }>;
};
function slugCode(title: string): string {
// SlugField хранит ASCII; кириллица отбрасывается → fallback на "doc".
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/gi, "-")
.replace(/(^-|-$)/g, "")
.slice(0, 40);
return `${slug || "doc"}-${Math.random().toString(36).slice(2, 6)}`;
}
export async function createDoc(kind: DocKind, input: CreateDocInput): Promise<void> {
await api(`${base(kind)}/`, {
method: "POST",
body: JSON.stringify({ ...input, code: slugCode(input.title) }),
});
}
export async function importDocs(kind: DocKind, product: string | null, documents: ImportDocInput[]): Promise<ImportResult> {
return api<ImportResult>(`${base(kind)}/import/`, {
method: "POST",
body: JSON.stringify({ ...(product ? { product } : {}), documents }),
});
}
@@ -1,133 +1,19 @@
import type { ChannelRef } from "../model";
import type { AiAgent } from "../model";
export type AiAgentDetail = {
id: number;
channel: ChannelRef;
name: string;
isActive: boolean;
model: string;
modelParams: Record<string, unknown>;
allowedTools: unknown[];
limits: Record<string, unknown>;
export type AiAgentDetail = AiAgent & {
createdAt: string;
updatedAt: string;
};
export type DocVersion = {
id: number;
version: number;
status: "DRAFT" | "PUBLISHED" | "ARCHIVED";
content: string;
createdBy: number | null;
createdAt: string;
};
export type KnowledgeDoc = {
id: number;
scope: "GLOBAL" | "PRODUCT";
product: { code: string; name: string } | null;
code: string;
title: string;
category: string;
isEnabled: boolean;
inclusionMode: "MANDATORY" | "RETRIEVAL";
versions: DocVersion[];
createdAt: string;
updatedAt: string;
};
export type PromptDoc = {
id: number;
scope: "GLOBAL" | "PRODUCT";
product: { code: string; name: string } | null;
code: string;
title: string;
category: string;
isEnabled: boolean;
versions: DocVersion[];
createdAt: string;
updatedAt: string;
};
export type AiReleaseFull = {
id: number;
channel: ChannelRef;
version: number;
status: "DRAFT" | "PUBLISHED" | "ARCHIVED";
model: string;
modelParams: Record<string, unknown>;
allowedTools: unknown[];
limits: Record<string, unknown>;
notes: string;
retrievalIndexVersion: string;
knowledgeVersions: Array<{ document: string; version: number }>;
promptVersions: Array<{ document: string; version: number }>;
createdAt: string;
publishedAt: string | null;
};
export type AgentTab = "overview" | "instructions" | "knowledge" | "releases" | "metrics";
export type AgentTab = "overview" | "instructions" | "knowledge" | "metrics";
export const agentTabs: Array<{ key: AgentTab; label: string }> = [
{ key: "overview", label: "Обзор" },
{ key: "instructions", label: "Инструкции" },
{ key: "knowledge", label: "Знания" },
{ key: "releases", label: "История версий" },
{ key: "metrics", label: "Метрики" },
];
const RELEASE_TONE: Record<AiReleaseFull["status"], { bg: string; color: string; label: string }> = {
PUBLISHED: { bg: "#f6ffed", color: "#389e0d", label: "Опубликована" },
DRAFT: { bg: "#fff7e6", color: "#d48806", label: "Черновик версии" },
ARCHIVED: { bg: "#f5f5f5", color: "#8c8c8c", label: "Архив" },
};
export function releaseTone(status: AiReleaseFull["status"]) {
return RELEASE_TONE[status];
}
const KNOWLEDGE_CATEGORY: Record<string, string> = {
OVERVIEW: "Обзор продукта",
AUDIENCE: "Целевая аудитория",
COMMERCIAL: "Коммерческая модель",
TECHNICAL: "Техническая информация",
FAQ: "FAQ",
OBJECTIONS: "Возражения",
LIMITATIONS: "Ограничения",
};
const PROMPT_CATEGORY: Record<string, string> = {
SYSTEM: "Системный промпт",
QUALIFICATION: "Квалификация",
SALES_BEHAVIOR: "Поведение в продаже",
OPERATOR_HANDOFF: "Передача оператору",
};
export function knowledgeCategoryLabel(category: string): string {
return KNOWLEDGE_CATEGORY[category] ?? category;
}
export function promptCategoryLabel(category: string): string {
return PROMPT_CATEGORY[category] ?? category;
}
export const knowledgeCategoryOptions = Object.entries(KNOWLEDGE_CATEGORY).map(([value, label]) => ({ value, label }));
export const promptCategoryOptions = Object.entries(PROMPT_CATEGORY).map(([value, label]) => ({ value, label }));
export function publishedVersion(versions: DocVersion[]): DocVersion | undefined {
return versions.find((version) => version.status === "PUBLISHED") ?? versions[0];
}
export function publishedRelease(releases: AiReleaseFull[]): AiReleaseFull | undefined {
return releases.find((release) => release.status === "PUBLISHED");
}
export function releaseDisplayName(release: AiReleaseFull): string {
const letters = release.channel.name.match(/[A-ZА-ЯЁ]/g)?.join("");
const prefix = letters && letters.length >= 2 ? letters.slice(0, 2) : release.channel.code.slice(0, 2);
return `REL-${prefix.toUpperCase()}-v${release.version}`;
}
// agent.limits — единый поддерживаемый лимит: дневной бюджет в целых центах USD (dailyCostUsd).
export function dailyBudget(limits: Record<string, unknown>): string {
const cents = limits?.dailyCostUsd;
@@ -1,72 +0,0 @@
import { describe, expect, it } from "vitest";
import { parseDocYaml } from "./parseDocYaml";
describe("parseDocYaml", () => {
it("parses a valid YAML into product + documents", () => {
const text = `
product: foxray
documents:
- code: foxray-overview
title: Обзор продукта
category: OVERVIEW
inclusionMode: MANDATORY
content: |
Многострочный
текст
- code: foxray-faq
title: FAQ
category: FAQ
content: Вопрос?
`;
const result = parseDocYaml(text);
expect(result.product).toBe("foxray");
expect(result.documents).toHaveLength(2);
expect(result.documents[0]).toEqual({ code: "foxray-overview", title: "Обзор продукта", category: "OVERVIEW", content: "Многострочный\nтекст\n", inclusionMode: "MANDATORY" });
expect(result.documents[1].inclusionMode).toBeUndefined();
});
it("treats absent product as global (null)", () => {
const text = `
documents:
- code: global-overview
title: Обзор
category: OVERVIEW
content: текст
`;
expect(parseDocYaml(text).product).toBeNull();
});
it("throws on invalid YAML syntax", () => {
expect(() => parseDocYaml("product: foxray\n documents: [unclosed")).toThrow(/YAML/);
});
it("throws when documents is missing or empty", () => {
expect(() => parseDocYaml("product: foxray")).toThrow(/documents/);
expect(() => parseDocYaml("documents: []")).toThrow(/documents/);
});
it("throws when a required field is empty", () => {
const text = `
documents:
- code: ""
title: Обзор
category: OVERVIEW
content: текст
`;
expect(() => parseDocYaml(text)).toThrow(/code/);
});
it("throws on an invalid inclusionMode", () => {
const text = `
documents:
- code: foxray-overview
title: Обзор
category: OVERVIEW
inclusionMode: MAYBE
content: текст
`;
expect(() => parseDocYaml(text)).toThrow(/inclusionMode/);
});
});
@@ -1,60 +0,0 @@
import { load as parseYaml } from "js-yaml";
import type { ImportDocInput } from "./docApi";
export type ParsedDocYaml = {
product: string | null;
documents: ImportDocInput[];
};
const REQUIRED_FIELDS = ["code", "title", "category", "content"] as const;
// Парсит YAML импорта знаний/инструкций и структурно валидирует.
// Бросает Error с человекочитаемым сообщением при невалидном YAML или структуре.
export function parseDocYaml(text: string): ParsedDocYaml {
let data: unknown;
try {
data = parseYaml(text);
} catch (error) {
throw new Error(error instanceof Error ? `Невалидный YAML: ${error.message}` : "Невалидный YAML");
}
if (!data || typeof data !== "object") {
throw new Error("Ожидается объект верхнего уровня с ключом documents");
}
const root = data as { product?: unknown; documents?: unknown };
const product = root.product === undefined || root.product === null ? null : String(root.product).trim();
if (product && !/^[a-z0-9-]+$/i.test(product)) {
throw new Error(`product должен быть ASCII-slug, получено: «${product}»`);
}
if (!Array.isArray(root.documents) || root.documents.length === 0) {
throw new Error("documents должен быть непустым списком");
}
const documents: ImportDocInput[] = [];
root.documents.forEach((item, index) => {
if (!item || typeof item !== "object") {
throw new Error(`Документ #${index + 1}: должен быть объектом`);
}
const record = item as Record<string, unknown>;
for (const field of REQUIRED_FIELDS) {
const value = record[field];
if (value === undefined || value === null || String(value).trim() === "") {
throw new Error(`Документ #${index + 1}: обязательное поле «${field}» пустое`);
}
}
const doc: ImportDocInput = {
code: String(record.code).trim(),
title: String(record.title).trim(),
category: String(record.category).trim(),
content: String(record.content),
};
if (record.inclusionMode !== undefined && record.inclusionMode !== null) {
const inclusion = String(record.inclusionMode).trim();
if (inclusion !== "MANDATORY" && inclusion !== "RETRIEVAL") {
throw new Error(`Документ #${index + 1}: inclusionMode должен быть MANDATORY или RETRIEVAL`);
}
doc.inclusionMode = inclusion;
}
documents.push(doc);
});
return { product: product || null, documents };
}
@@ -629,3 +629,44 @@
font-size: 12px;
}
/* Выбор знаний агента (ADR-HUB-0023): чекбокс-список из общей библиотеки. */
.agent-knowledge-list {
list-style: none;
margin: 0 0 12px;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.agent-knowledge-list li {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.agent-knowledge-list li.is-selected {
border-color: #91caff;
background: #f0f8ff;
}
.agent-knowledge-list li label {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
cursor: pointer;
}
.agent-knowledge-text {
display: flex;
flex-direction: column;
}
.agent-knowledge-text small {
color: #8c8c8c;
}
@@ -1,17 +1,16 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "../../../api/client";
import type { AiAgentDetail, AiReleaseFull, KnowledgeDoc, PromptDoc } from "./model";
import { fetchKnowledgeList, type KnowledgeItem } from "../knowledge/model";
import type { AiAgentDetail } from "./model";
type AgentDetailData = {
agent: AiAgentDetail | null;
releases: AiReleaseFull[];
knowledge: KnowledgeDoc[];
prompts: PromptDoc[];
library: KnowledgeItem[];
};
export function useAiAgentDetail(agentId: number | null) {
const [data, setData] = useState<AgentDetailData>({ agent: null, releases: [], knowledge: [], prompts: [] });
const [data, setData] = useState<AgentDetailData>({ agent: null, library: [] });
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
@@ -24,14 +23,11 @@ export function useAiAgentDetail(agentId: number | null) {
setLoading(true);
setError(false);
try {
const { agent } = await api<{ agent: AiAgentDetail }>(`/api/v1/ai/agents/${agentId}/`);
const channel = encodeURIComponent(agent.channel.code);
const [releases, knowledge, prompts] = await Promise.all([
api<{ items: AiReleaseFull[] }>(`/api/v1/ai/releases/?channel=${channel}`),
api<{ items: KnowledgeDoc[] }>(`/api/v1/ai/knowledge/?channel=${channel}`),
api<{ items: PromptDoc[] }>(`/api/v1/ai/prompts/?channel=${channel}`),
const [{ agent }, library] = await Promise.all([
api<{ agent: AiAgentDetail }>(`/api/v1/ai/agents/${agentId}/`),
fetchKnowledgeList(),
]);
setData({ agent, releases: releases.items, knowledge: knowledge.items, prompts: prompts.items });
setData({ agent, library: library.items });
} catch {
setError(true);
} finally {
@@ -0,0 +1,157 @@
import { Modal } from "antd";
import { useEffect, useRef, useState } from "react";
import { Icon } from "../../../shared/icons";
import { FormField } from "../../../shared/form-controls";
import { EmptyState, LoadingState, StatusPill } from "../../../shared/ui";
import { Button } from "../../../shared/ui-controls";
import { formatDate } from "../../../shared/utils";
import type { RouteKey } from "../../../types";
import {
deleteAttachment,
deleteKnowledgeItem,
fetchKnowledgeItem,
formatSize,
updateKnowledgeItem,
uploadAttachment,
type KnowledgeItem,
} from "./model";
export function KnowledgeDetailPage({ knowledgeId, setRoute, onLoaded }: { knowledgeId: number | null; setRoute: (route: RouteKey) => void; onLoaded: (title: string | null) => void }) {
const [item, setItem] = useState<KnowledgeItem | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [content, setContent] = useState("");
const [busy, setBusy] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const fileInput = useRef<HTMLInputElement>(null);
async function load() {
if (!knowledgeId) {
setError(true);
setLoading(false);
return;
}
try {
const { knowledge } = await fetchKnowledgeItem(knowledgeId);
setItem(knowledge);
setTitle(knowledge.title);
setDescription(knowledge.description);
setContent(knowledge.content ?? "");
} catch {
setError(true);
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [knowledgeId]);
useEffect(() => {
onLoaded(item?.title ?? null);
return () => onLoaded(null);
}, [item, onLoaded]);
if (loading) return <div className="ai-page"><LoadingState /></div>;
if (error || !item) return <div className="ai-page"><EmptyState title="Не удалось загрузить знание" /></div>;
const dirty = title !== item.title || description !== item.description || content !== (item.content ?? "");
async function run(action: () => Promise<unknown>) {
setBusy(true);
setSaveError(null);
try {
await action();
await load();
} catch (caught) {
setSaveError(caught instanceof Error ? caught.message : "Не удалось сохранить");
} finally {
setBusy(false);
}
}
async function onUpload(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file || !knowledgeId) return;
await run(() => uploadAttachment(knowledgeId, file));
}
return (
<div className="ai-page knowledge-detail">
<section className="ai-card">
<div className="knowledge-detail-head">
<h3>Знание</h3>
<div className="knowledge-detail-status">
<StatusPill status={item.isEnabled ? "active" : "disabled"} />
<span className="ai-doc-updated">обновлено {formatDate(item.updatedAt)}</span>
</div>
</div>
<div className="knowledge-detail-fields">
<FormField label="Заголовок" value={title} onChange={setTitle} />
<FormField label="Краткое описание" value={description} onChange={setDescription} placeholder="о чём это знание — агент видит описание в каталоге" />
<label className="knowledge-content-field">
<span>Содержимое (Markdown)</span>
<textarea value={content} rows={16} onChange={(event) => setContent(event.target.value)} placeholder="Текст знания. Можно ссылаться на вложения по имени файла." />
</label>
</div>
{saveError && <div className="ai-doc-error">{saveError}</div>}
<div className="ai-doc-actions">
<Button variant="primary" icon="save" disabled={!dirty || !title.trim() || busy} onClick={() => run(() => updateKnowledgeItem(item.id, { title: title.trim(), description: description.trim(), content }))}>
Сохранить
</Button>
<Button variant="secondary" icon={item.isEnabled ? "eyeOff" : "eye"} disabled={busy} onClick={() => run(() => updateKnowledgeItem(item.id, { isEnabled: !item.isEnabled }))}>
{item.isEnabled ? "Выключить" : "Включить"}
</Button>
<Button variant="secondary" icon="trash" disabled={busy} onClick={() => setDeleteOpen(true)}>Удалить</Button>
</div>
</section>
<section className="ai-card">
<div className="knowledge-detail-head">
<h3>Вложения</h3>
<Button variant="secondary" icon="paperclip" disabled={busy} onClick={() => fileInput.current?.click()}>Загрузить файл</Button>
<input ref={fileInput} type="file" hidden onChange={(event) => void onUpload(event)} />
</div>
<p className="knowledge-attachments-hint">
Оригинальное имя файла сохраняется — ссылайтесь на него в тексте знания. Текст из md, txt, pdf и docx попадает в поиск знаний.
По публичной ссылке агент может отправить файл клиенту.
</p>
{item.attachments.length === 0 ? (
<EmptyState title="Вложений нет" />
) : (
<ul className="knowledge-attachments">
{item.attachments.map((attachment) => (
<li key={attachment.id}>
<Icon name="paperclip" size={15} />
<a href={attachment.url} target="_blank" rel="noreferrer">{attachment.name}</a>
<small>{formatSize(attachment.size)}{attachment.hasText ? " · текст в поиске" : ""}</small>
<button type="button" className="row-menu-button" aria-label="Удалить вложение" disabled={busy} onClick={() => run(() => deleteAttachment(item.id, attachment.id))}>
<Icon name="trash" size={15} />
</button>
</li>
))}
</ul>
)}
</section>
{deleteOpen && (
<Modal open title="Удалить знание?" onCancel={() => setDeleteOpen(false)} footer={null} destroyOnClose>
<div className="integration-form">
<p>Знание «{item.title}» и его вложения будут удалены. У агентов, использующих это знание, оно исчезнет из выбора.</p>
<div className="integration-form-actions">
<Button variant="secondary" onClick={() => setDeleteOpen(false)}>Отмена</Button>
<Button variant="primary" disabled={busy} onClick={() => { setBusy(true); void deleteKnowledgeItem(item.id).then(() => setRoute("aiKnowledge")).catch(() => setBusy(false)); }}>Удалить</Button>
</div>
</div>
</Modal>
)}
</div>
);
}
@@ -0,0 +1,213 @@
import { Modal } from "antd";
import { useEffect, useMemo, useState } from "react";
import { Icon } from "../../../shared/icons";
import { FormField } from "../../../shared/form-controls";
import { EmptyState, LoadingState, PageHeader, StatusPill } from "../../../shared/ui";
import { Button, SearchInput } from "../../../shared/ui-controls";
import { formatDate } from "../../../shared/utils";
import { createKnowledgeItem, fetchKnowledgeList, formatSize, importKnowledge, type KnowledgeImportReport, type KnowledgeItem } from "./model";
import { parseKnowledgeYaml, type ParsedKnowledgeYaml } from "./parseKnowledgeYaml";
export function KnowledgePage({ openKnowledge }: { openKnowledge: (knowledgeId: number) => void }) {
const [items, setItems] = useState<KnowledgeItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [query, setQuery] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
async function load() {
setError(false);
try {
const payload = await fetchKnowledgeList();
setItems(payload.items);
} catch {
setError(true);
} finally {
setLoading(false);
}
}
useEffect(() => {
void load();
}, []);
const filtered = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return items;
return items.filter((item) => item.title.toLowerCase().includes(needle) || item.description.toLowerCase().includes(needle));
}, [items, query]);
if (loading) return <div className="ai-page"><LoadingState /></div>;
return (
<div className="ai-page">
<PageHeader
title="Знания"
text="Общая библиотека знаний · агент выбирает нужные знания в своей карточке"
action={(
<div className="knowledge-header-actions">
<Button variant="secondary" icon="download" onClick={() => setImportOpen(true)}>Импорт YAML</Button>
<Button variant="primary" icon="plus" onClick={() => setCreateOpen(true)}>Создать знание</Button>
</div>
)}
/>
<div className="knowledge-toolbar">
<SearchInput placeholder="Поиск по заголовку и описанию" value={query} onChange={setQuery} />
</div>
{error && <EmptyState title="Не удалось загрузить знания" />}
{!error && filtered.length === 0 && <EmptyState title={query ? "Ничего не найдено" : "Знаний пока нет"} />}
{!error && filtered.length > 0 && (
<div className="table-card">
<table className="baseline-table">
<thead>
<tr>
<th>ЗНАНИЕ</th>
<th className="numeric">ВЛОЖЕНИЯ</th>
<th className="numeric">АГЕНТЫ</th>
<th>СТАТУС</th>
<th>ОБНОВЛЕНО</th>
</tr>
</thead>
<tbody>
{filtered.map((item) => (
<tr key={item.id} className="knowledge-row" onClick={() => openKnowledge(item.id)}>
<td>
<div className="product-cell">
<span className="product-icon knowledge-icon"><Icon name="list" size={19} /></span>
<span>
<button className="ai-agent-name-link" type="button" onClick={(event) => { event.stopPropagation(); openKnowledge(item.id); }}>{item.title}</button>
<small>{item.description || "—"}</small>
</span>
</div>
</td>
<td className="numeric">
{item.attachments.length > 0
? `${item.attachments.length} · ${formatSize(item.attachments.reduce((sum, attachment) => sum + attachment.size, 0))}`
: <span className="product-empty-value">—</span>}
</td>
<td className="numeric">{item.agentsCount || <span className="product-empty-value">—</span>}</td>
<td><StatusPill status={item.isEnabled ? "active" : "disabled"} /></td>
<td>{formatDate(item.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
<div className="ai-table-footer">
<span>{filtered.length} знаний</span>
<span>Изменения знаний применяются к агентам сразу</span>
</div>
</div>
)}
{createOpen && <KnowledgeCreateModal onClose={() => setCreateOpen(false)} onCreated={(id) => { setCreateOpen(false); void load(); openKnowledge(id); }} />}
{importOpen && <KnowledgeImportModal onClose={() => setImportOpen(false)} onImported={() => void load()} />}
</div>
);
}
function KnowledgeCreateModal({ onClose, onCreated }: { onClose: () => void; onCreated: (id: number) => void }) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [content, setContent] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit() {
if (!title.trim()) return;
setBusy(true);
setError(null);
try {
const { knowledge } = await createKnowledgeItem({ title: title.trim(), description: description.trim(), content });
onCreated(knowledge.id);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось создать знание");
} finally {
setBusy(false);
}
}
return (
<Modal open title="Создать знание" onCancel={onClose} footer={null} destroyOnClose>
<div className="integration-form">
<FormField label="Заголовок" value={title} onChange={setTitle} placeholder="например, FoxRay — тарифы" />
<FormField label="Краткое описание" value={description} onChange={setDescription} placeholder="о чём это знание — агент видит описание в каталоге" />
<label className="knowledge-content-field">
<span>Содержимое (Markdown)</span>
<textarea value={content} rows={10} onChange={(event) => setContent(event.target.value)} placeholder="Текст знания. Можно ссылаться на вложения по имени файла." />
</label>
{error && <div className="integration-form-error">{error}</div>}
<div className="integration-form-actions">
<Button variant="secondary" onClick={onClose}>Отмена</Button>
<Button variant="primary" disabled={!title.trim() || busy} onClick={submit}>{busy ? "Создание…" : "Создать"}</Button>
</div>
</div>
</Modal>
);
}
function KnowledgeImportModal({ onClose, onImported }: { onClose: () => void; onImported: () => void }) {
const [parsed, setParsed] = useState<ParsedKnowledgeYaml | null>(null);
const [result, setResult] = useState<KnowledgeImportReport | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onFile(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setError(null);
setResult(null);
try {
setParsed(parseKnowledgeYaml(await file.text()));
} catch (caught) {
setParsed(null);
setError(caught instanceof Error ? caught.message : "Не удалось прочитать файл");
}
}
async function submit() {
if (!parsed) return;
setBusy(true);
setError(null);
try {
setResult(await importKnowledge(parsed.documents));
setParsed(null);
onImported();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось импортировать");
} finally {
setBusy(false);
}
}
return (
<Modal open title="Импорт знаний из YAML" onCancel={onClose} footer={null} destroyOnClose>
<div className="integration-form">
<label className="knowledge-import-file">
<span>Файл YAML · формат: documents: [{"{"} title, description?, content {"}"}]</span>
<input type="file" accept=".yaml,.yml" onChange={(event) => void onFile(event)} />
</label>
{parsed && <div className="ai-doc-import-preview">К импорту: <b>{parsed.documents.length}</b> знаний. Совпадение по заголовку обновит существующее знание.</div>}
{result && (
<div className="ai-doc-import-report">
<div className="ai-doc-import-summary">
Создано: <b>{result.created}</b> · Обновлено: <b>{result.updated}</b> · Без изменений: <b>{result.unchanged}</b>
</div>
{result.failed.length > 0 && (
<ul className="ai-doc-import-failed">
{result.failed.map((item, index) => (
<li key={index}>{item.title ? `«${item.title}»: ` : ""}{item.detail}</li>
))}
</ul>
)}
</div>
)}
{error && <div className="ai-doc-error">{error}</div>}
<div className="integration-form-actions">
<Button variant="secondary" disabled={busy} onClick={onClose}>Закрыть</Button>
<Button variant="primary" icon="download" disabled={!parsed || busy} onClick={submit}>{busy ? "Импорт…" : "Импорт"}</Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,70 @@
import { api, apiUpload } from "../../../api/client";
export type KnowledgeAttachment = {
id: number;
name: string;
contentType: string;
size: number;
hasText: boolean;
url: string;
createdAt: string;
};
export type KnowledgeItem = {
id: number;
title: string;
description: string;
content?: string;
isEnabled: boolean;
attachments: KnowledgeAttachment[];
agentsCount: number | null;
createdAt: string;
updatedAt: string;
};
export type KnowledgeImportReport = {
created: number;
updated: number;
unchanged: number;
failed: Array<{ title: string; detail: string }>;
};
export function fetchKnowledgeList() {
return api<{ items: KnowledgeItem[] }>("/api/v1/ai/knowledge/");
}
export function fetchKnowledgeItem(id: number) {
return api<{ knowledge: KnowledgeItem }>(`/api/v1/ai/knowledge/${id}/`);
}
export function createKnowledgeItem(data: { title: string; description: string; content: string }) {
return api<{ knowledge: KnowledgeItem }>("/api/v1/ai/knowledge/", { method: "POST", body: JSON.stringify(data) });
}
export function updateKnowledgeItem(id: number, data: Partial<{ title: string; description: string; content: string; isEnabled: boolean }>) {
return api<{ knowledge: KnowledgeItem }>(`/api/v1/ai/knowledge/${id}/`, { method: "PATCH", body: JSON.stringify(data) });
}
export function deleteKnowledgeItem(id: number) {
return api<void>(`/api/v1/ai/knowledge/${id}/`, { method: "DELETE" });
}
export function uploadAttachment(knowledgeId: number, file: File) {
const form = new FormData();
form.append("file", file, file.name);
return apiUpload<{ attachment: KnowledgeAttachment }>(`/api/v1/ai/knowledge/${knowledgeId}/attachments/`, form);
}
export function deleteAttachment(knowledgeId: number, attachmentId: number) {
return api<void>(`/api/v1/ai/knowledge/${knowledgeId}/attachments/${attachmentId}/`, { method: "DELETE" });
}
export function importKnowledge(documents: Array<{ title: string; description?: string; content: string }>) {
return api<KnowledgeImportReport>("/api/v1/ai/knowledge/import/", { method: "POST", body: JSON.stringify({ documents }) });
}
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
}
@@ -0,0 +1,45 @@
import { load as parseYaml } from "js-yaml";
export type ParsedKnowledgeYaml = {
documents: Array<{ title: string; description?: string; content: string }>;
};
// Парсит YAML импорта знаний (SPEC-HUB-0012, плоский формат ADR-HUB-0023):
// { documents: [{ title, description?, content }] }. Бросает Error с
// человекочитаемым сообщением при невалидном YAML или структуре.
export function parseKnowledgeYaml(text: string): ParsedKnowledgeYaml {
let data: unknown;
try {
data = parseYaml(text);
} catch (error) {
throw new Error(error instanceof Error ? `Невалидный YAML: ${error.message}` : "Невалидный YAML");
}
if (!data || typeof data !== "object") {
throw new Error("Ожидается объект верхнего уровня с ключом documents");
}
const root = data as { documents?: unknown };
if (!Array.isArray(root.documents) || root.documents.length === 0) {
throw new Error("documents должен быть непустым списком");
}
const documents: ParsedKnowledgeYaml["documents"] = [];
root.documents.forEach((item, index) => {
if (!item || typeof item !== "object") {
throw new Error(`Документ #${index + 1}: должен быть объектом`);
}
const record = item as Record<string, unknown>;
const title = record.title === undefined || record.title === null ? "" : String(record.title).trim();
if (!title) {
throw new Error(`Документ #${index + 1}: обязательное поле «title» пустое`);
}
const content = record.content === undefined || record.content === null ? "" : String(record.content);
if (!content.trim()) {
throw new Error(`Документ #${index + 1}: обязательное поле «content» пустое`);
}
const doc: ParsedKnowledgeYaml["documents"][number] = { title, content };
if (record.description !== undefined && record.description !== null && String(record.description).trim()) {
doc.description = String(record.description).trim();
}
documents.push(doc);
});
return { documents };
}
@@ -0,0 +1,124 @@
/* Знания (ADR-HUB-0023): библиотека + карточка знания. Построено на shared
компонентах (baseline-table, ai-card); здесь только специфичные детали. */
.knowledge-header-actions {
display: flex;
gap: 10px;
}
.knowledge-toolbar {
margin-bottom: 14px;
max-width: 360px;
}
.knowledge-row {
cursor: pointer;
}
.knowledge-icon {
background: #f0f5ff;
color: #2f54eb;
}
.knowledge-content-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.knowledge-content-field span {
font-size: 12.5px;
font-weight: 600;
color: #595959;
}
.knowledge-content-field textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid #d9d9d9;
border-radius: 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
line-height: 1.55;
resize: vertical;
}
.knowledge-content-field textarea:focus {
outline: none;
border-color: #1677ff;
}
.knowledge-detail {
display: flex;
flex-direction: column;
gap: 16px;
}
.knowledge-detail-head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.knowledge-detail-head h3 {
margin: 0;
margin-right: auto;
}
.knowledge-detail-status {
display: flex;
align-items: center;
gap: 10px;
}
.knowledge-detail-fields {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 14px;
}
.knowledge-attachments-hint {
margin: 0 0 12px;
color: #8c8c8c;
font-size: 12.5px;
}
.knowledge-attachments {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.knowledge-attachments li {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid #f0f0f0;
border-radius: 8px;
color: #595959;
}
.knowledge-attachments li a {
font-weight: 600;
color: #1677ff;
text-decoration: none;
}
.knowledge-attachments li small {
margin-right: auto;
color: #8c8c8c;
}
.knowledge-import-file {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12.5px;
color: #595959;
}
+6 -17
View File
@@ -1,5 +1,7 @@
export type ChannelRef = { id: number; code: string; name: string; product: { code: string; name: string } | null };
export type AgentKnowledgeRef = { id: number; title: string; isEnabled: boolean };
export type AiAgent = {
id: number;
channel: ChannelRef;
@@ -9,21 +11,8 @@ export type AiAgent = {
modelParams: Record<string, unknown>;
allowedTools: unknown[];
limits: Record<string, unknown>;
persona: string;
tone: string;
instructions: string;
knowledge: AgentKnowledgeRef[];
};
export type AiRelease = {
id: number;
channel: ChannelRef;
version: number;
status: "DRAFT" | "PUBLISHED" | "ARCHIVED";
};
export function publishedRelease(releases: AiRelease[], channelCode: string): AiRelease | undefined {
return releases.find((release) => release.channel.code === channelCode && release.status === "PUBLISHED");
}
export function releaseName(release: AiRelease): string {
const letters = release.channel.name.match(/[A-ZА-ЯЁ]/g)?.join("");
const prefix = letters && letters.length >= 2 ? letters.slice(0, 2) : release.channel.code.slice(0, 2);
return `REL-${prefix.toUpperCase()}-v${release.version}`;
}
@@ -1,44 +0,0 @@
import { Icon } from "../../../shared/icons";
import { Button } from "../../../shared/ui-controls";
import type { AiReleaseFull } from "../detail/model";
import { formatDateTime, releaseLabel, releaseState, type ReleaseCheck } from "./model";
export function ProductAIReleaseHeader({
canPublish,
checks,
release,
startPublish,
}: {
canPublish: boolean;
checks: ReleaseCheck[];
release: AiReleaseFull;
startPublish: () => void;
}) {
const state = releaseState(checks, release.status === "PUBLISHED");
const label = releaseLabel(release);
return (
<section className="release-status-bar">
<div className="release-title-group">
<span className="release-icon"><Icon name="robot" size={21} /></span>
<div>
<div className="release-title-row">
<h1>{label}</h1>
<span className={`release-status-badge ${state.className}`}>
<span />
{state.label}
</span>
</div>
<div className="release-subtitle">
{release.channel.name} · черновик создан {formatDateTime(release.createdAt)}
</div>
</div>
</div>
<div className="release-actions">
<Button variant="secondary" icon="expand">Сравнить с опубликованной</Button>
<Button variant="primary" icon="send" disabled={!canPublish} onClick={startPublish}>
{release.status === "PUBLISHED" ? "Опубликована" : "Опубликовать"}
</Button>
</div>
</section>
);
}
@@ -1,65 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { api } from "../../../api/client";
import { EmptyState, LoadingState } from "../../../shared/ui";
import { ProductAIReleaseHeader } from "./ProductAIReleaseHeader";
import { PublishReleaseModal } from "./PublishReleaseModal";
import { ReleaseComposition } from "./ReleaseComposition";
import { ReleaseSidePanel, ReleaseValidationBanner } from "./ReleaseSidePanel";
import { buildChanges, buildChecks, canPublishRelease, findPublishedPeer, releaseLabel } from "./model";
import { useProductAIRelease } from "./useProductAIRelease";
export function ProductAIReleasePage({
onReleaseLoaded,
releaseId,
}: {
onReleaseLoaded: (name: string | null) => void;
releaseId: number | null;
}) {
const { release, releases, knowledge, prompts, loading, error, reload } = useProductAIRelease(releaseId);
const [modalOpen, setModalOpen] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [publishing, setPublishing] = useState(false);
useEffect(() => {
onReleaseLoaded(release ? `${release.channel.name}|${releaseLabel(release)}` : null);
return () => onReleaseLoaded(null);
}, [onReleaseLoaded, release]);
const checks = useMemo(() => release ? buildChecks(release) : [], [release]);
const published = useMemo(() => release ? findPublishedPeer(release, releases) : undefined, [release, releases]);
const changes = useMemo(() => release ? buildChanges(release, published) : [], [release, published]);
const canPublish = release ? canPublishRelease(release, checks) : false;
async function publish() {
if (!release || !confirmed) return;
setPublishing(true);
try {
await api(`/api/v1/ai/releases/${release.id}/publish/`, { method: "POST" });
setModalOpen(false);
setConfirmed(false);
reload();
} catch {
// API returns validation details; modal stays open so the user can retry after checks are refreshed.
} finally {
setPublishing(false);
}
}
if (loading) return <div className="ai-release-state"><LoadingState /></div>;
if (error || !release) return <div className="ai-release-state"><EmptyState title="Не удалось загрузить версию" /></div>;
return (
<div className="ai-release-page">
<ProductAIReleaseHeader canPublish={canPublish} checks={checks} release={release} startPublish={() => setModalOpen(true)} />
<div className="ai-release-body">
<ReleaseValidationBanner checks={checks} runChecks={reload} />
<div className="release-layout">
<ReleaseComposition knowledge={knowledge} prompts={prompts} published={published} release={release} />
<ReleaseSidePanel changes={changes} checks={checks} published={published} />
</div>
</div>
{modalOpen && <PublishReleaseModal checked={confirmed} close={() => setModalOpen(false)} confirm={publish} publishing={publishing} release={release} setChecked={setConfirmed} />}
</div>
);
}
@@ -1,47 +0,0 @@
import { Icon } from "../../../shared/icons";
import { Button } from "../../../shared/ui-controls";
import type { AiReleaseFull } from "../detail/model";
import { releaseLabel } from "./model";
export function PublishReleaseModal({
checked,
close,
confirm,
publishing,
release,
setChecked,
}: {
checked: boolean;
close: () => void;
confirm: () => void;
publishing: boolean;
release: AiReleaseFull;
setChecked: (checked: boolean) => void;
}) {
return (
<div className="release-modal-backdrop">
<div className="release-modal">
<div className="release-modal-body">
<div className="release-modal-title">
<span><Icon name="warning" size={22} /></span>
<div>
<h3>Опубликовать {releaseLabel(release)}?</h3>
<p>Атомарная публикация конфигурации канала {release.channel.name}</p>
</div>
</div>
<div className="release-modal-warning">
Новые ответы во <b>всех активных диалогах</b> сразу будут использовать эту версию. Текущая опубликованная версия перейдёт в архив. Уже отправленные ответы не изменятся.
</div>
<button className="release-confirm-line" type="button" onClick={() => setChecked(!checked)}>
<span className={checked ? "release-confirm-box checked" : "release-confirm-box"}>{checked && <Icon name="check" size={12} />}</span>
<span>Понимаю, что активные диалоги переключатся на новую версию</span>
</button>
</div>
<div className="release-modal-footer">
<Button variant="secondary" onClick={close}>Отмена</Button>
<Button variant="primary" icon="send" disabled={!checked || publishing} onClick={confirm}>Опубликовать</Button>
</div>
</div>
</div>
);
}
@@ -1,109 +0,0 @@
import type { ReactNode } from "react";
import { Icon } from "../../../shared/icons";
import { dailyBudget, type AiReleaseFull, type KnowledgeDoc, type PromptDoc } from "../detail/model";
import { formatParam, isPromptChanged, knowledgeTitle, promptTitle, promptVersionDate, toolLabel } from "./model";
function ReleaseCard({ children, className = "", title, icon }: { children: ReactNode; className?: string; icon?: ReactNode; title: string }) {
return (
<section className={`release-card ${className}`.trim()}>
<div className="release-card-title">
{icon}
<h3>{title}</h3>
</div>
{children}
</section>
);
}
function VersionRow({ changed, date, title, version }: { changed?: boolean; date?: string; title: string; version: number }) {
return (
<div className="release-row">
<span>{title}</span>
{changed && <b className="release-changed-badge">изменён</b>}
{date && <small className="release-row-date">{date}</small>}
<code className="ai-mono">{`v${version}`}</code>
</div>
);
}
export function ReleaseComposition({ knowledge, prompts, published, release }: { knowledge: KnowledgeDoc[]; prompts: PromptDoc[]; published?: AiReleaseFull; release: AiReleaseFull }) {
const modelParams = release.modelParams ?? {};
const tools = release.allowedTools.map(toolLabel).filter((tool) => tool !== "—");
const budget = dailyBudget(release.limits ?? {});
return (
<div className="release-composition">
<h2>Состав release</h2>
<ReleaseCard title="Модель и параметры" icon={<Icon name="robot" size={17} />}>
<div className="release-param-grid">
<div><span>Модель</span><code className="ai-mono">{release.model || <EmptyMarker />}</code></div>
<div><span>Temperature</span><code className="ai-mono">{formatParam(modelParams.temperature)}</code></div>
<div><span>Max tokens</span><code className="ai-mono">{formatParam(modelParams.maxTokens)}</code></div>
<div><span>Top-p</span><code className="ai-mono">{formatParam(modelParams.topP)}</code></div>
</div>
</ReleaseCard>
<ReleaseCard title="Версии prompts" icon={<Icon name="list" size={17} />} className="release-card--flush">
{release.promptVersions.length === 0 ? (
<EmptyRow />
) : (
release.promptVersions.map((prompt) => (
<VersionRow
changed={isPromptChanged(published, prompt.document, prompt.version)}
date={promptVersionDate(prompts, prompt.document, prompt.version)}
key={`${prompt.document}-${prompt.version}`}
title={promptTitle(prompts, prompt.document)}
version={prompt.version}
/>
))
)}
</ReleaseCard>
<ReleaseCard title="Обязательные знания" icon={<Icon name="paperclip" size={17} />} className="release-card--flush release-knowledge-card">
<div className="release-index-line">retrieval index · <b>{release.retrievalIndexVersion ? "собран" : "не собран"}</b></div>
{release.knowledgeVersions.length === 0 ? (
<EmptyRow />
) : (
release.knowledgeVersions.map((document) => (
<div className="release-row release-knowledge-row" key={`${document.document}-${document.version}`}>
<Icon name="check" size={15} />
<span>{knowledgeTitle(knowledge, document.document)}</span>
<code className="ai-mono">{`v${document.version}`}</code>
</div>
))
)}
</ReleaseCard>
<div className="release-double-grid">
<ReleaseCard title="Инструменты">
{tools.length === 0 ? (
<EmptyRow compact />
) : (
<div className="release-tools">
{tools.map((tool) => <div key={tool}><Icon name="check" size={14} /><code className="ai-mono">{tool}</code></div>)}
</div>
)}
</ReleaseCard>
<ReleaseCard title="Лимиты">
{budget === "—" ? (
<EmptyRow compact />
) : (
<div className="release-limit-line">
<span>Бюджет в день</span>
<b>{budget}</b>
</div>
)}
</ReleaseCard>
</div>
</div>
);
}
function EmptyMarker() {
return <span className="release-empty-marker">—</span>;
}
function EmptyRow({ compact = false }: { compact?: boolean }) {
return <div className={compact ? "release-empty-line compact" : "release-empty-line"}><EmptyMarker /></div>;
}
@@ -1,58 +0,0 @@
import type { AiReleaseFull } from "../detail/model";
import type { ReleaseChange, ReleaseCheck } from "./model";
import { checksSummary, releaseLabel, validationBanner } from "./model";
function toneClass(tone: string) {
return `release-tone-${tone}`;
}
export function ReleaseValidationBanner({ checks, runChecks }: { checks: ReleaseCheck[]; runChecks: () => void }) {
const banner = validationBanner(checks);
const issue = checks.find((check) => check.tone === banner.tone) ?? checks[0];
return (
<div className={`release-validation ${toneClass(banner.tone)}`}>
<span>{issue?.icon}</span>
<div>
<strong>{banner.title}</strong>
<p>{banner.note}</p>
</div>
<button type="button" onClick={runChecks}>Запустить проверку</button>
</div>
);
}
export function ReleaseSidePanel({ changes, checks, published }: { changes: ReleaseChange[]; checks: ReleaseCheck[]; published?: AiReleaseFull }) {
const summary = checksSummary(checks);
return (
<aside className="release-side">
<section className="release-card release-card--flush">
<div className="release-panel-head">
<h3>Проверки</h3>
<span className={toneClass(summary.tone)}>{summary.label}</span>
</div>
{checks.map((check) => (
<div className="release-check-row" key={check.label}>
<span className={toneClass(check.tone)}>{check.icon}</span>
<div>
<strong>{check.label}</strong>
{check.detail && <small>{check.detail}</small>}
</div>
</div>
))}
</section>
<section className="release-card release-card--flush">
<div className="release-panel-head release-panel-head--stack">
<h3>Изменения</h3>
<small>Относительно {published ? `${releaseLabel(published)} (опубликованная)` : "опубликованной версии"}</small>
</div>
{changes.map((change) => (
<div className="release-change-row" key={change.text}>
<span className={toneClass(change.tone)}>{change.sign}</span>
<p>{change.text}</p>
</div>
))}
</section>
</aside>
);
}
@@ -1,167 +0,0 @@
.release-composition {
display: flex;
flex-direction: column;
gap: 16px;
}
.release-composition h2 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #595959;
}
.release-card {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
padding: 16px 18px;
}
.release-card--flush {
padding: 0;
overflow: hidden;
}
.release-card--flush .release-card-title {
padding: 14px 18px 11px;
margin: 0;
}
.release-card-title,
.release-panel-head {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 13px;
}
.release-card-title h3,
.release-panel-head h3 {
margin: 0;
font-size: 14px;
font-weight: 700;
color: #1f1f1f;
}
.release-param-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
.release-param-grid span,
.release-limit-line span {
display: block;
font-size: 11px;
color: #8c8c8c;
}
.release-param-grid code {
display: block;
margin-top: 3px;
font-size: 13px;
font-weight: 600;
color: #262626;
}
.release-row {
display: flex;
align-items: center;
gap: 12px;
padding: 11px 18px;
border-top: 1px solid #f5f5f5;
}
.release-row span {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 500;
color: #262626;
}
.release-row-date {
width: 90px;
flex: none;
color: #bfbfbf;
font-size: 11.5px;
text-align: right;
}
.release-index-line {
margin: 0;
padding: 0 18px 11px;
font-size: 11.5px;
color: #8c8c8c;
}
.release-index-line b {
color: #389e0d;
}
.release-double-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.release-tools {
display: flex;
flex-direction: column;
gap: 8px;
}
.release-tools div {
display: flex;
align-items: center;
gap: 8px;
color: #389e0d;
}
.release-knowledge-row svg {
flex: none;
color: #389e0d;
}
.release-changed-badge {
padding: 1px 8px;
border-radius: 5px;
background: #fff7e6;
color: #d48806;
font-size: 10.5px;
font-weight: 600;
}
.release-limit-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 5px 0;
}
.release-limit-line b {
font-size: 12.5px;
font-weight: 600;
color: #262626;
}
.release-empty-line {
padding: 10px 18px;
font-size: 13px;
color: #bfbfbf;
}
.release-empty-line.compact {
padding: 10px 0;
}
.release-empty-marker {
display: inline-flex;
align-items: center;
min-height: 18px;
color: #bfbfbf;
font-size: 13px;
}
@@ -1,106 +0,0 @@
.release-modal-backdrop {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(0, 0, 0, 0.45);
}
.release-modal {
width: 480px;
max-width: 100%;
overflow: hidden;
background: #fff;
border-radius: 14px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.release-modal-body {
padding: 22px 24px 18px;
}
.release-modal-title {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.release-modal-title > span {
width: 42px;
height: 42px;
border-radius: 11px;
display: flex;
align-items: center;
justify-content: center;
flex: none;
background: #fff7e6;
color: #d48806;
}
.release-modal-title h3 {
margin: 0;
font-size: 17px;
font-weight: 700;
color: #1f1f1f;
}
.release-modal-title p {
margin: 2px 0 0;
font-size: 12.5px;
color: #8c8c8c;
}
.release-modal-warning,
.release-confirm-line {
border: 1px solid #ffe58f;
border-radius: 10px;
background: #fffbe6;
color: #7c5a00;
font-size: 13px;
line-height: 1.55;
padding: 13px 15px;
}
.release-confirm-line {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
margin-top: 16px;
background: #fafafa;
border-color: #f0f0f0;
color: #262626;
cursor: pointer;
text-align: left;
}
.release-confirm-box {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
flex: none;
color: #fff;
background: #fff;
border: 1.5px solid #d9d9d9;
border-radius: 5px;
}
.release-confirm-box.checked {
background: #1677ff;
border-color: #1677ff;
}
.release-modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 24px;
border-top: 1px solid #f5f5f5;
background: #fafbfc;
}
@@ -1,62 +0,0 @@
import { describe, expect, it } from "vitest";
import type { AiReleaseFull } from "../detail/model";
import { buildChecks, canPublishRelease } from "./model";
const channel = { id: 1, code: "edeves", name: "Edeves", product: null };
function buildRelease(overrides: Partial<AiReleaseFull> = {}): AiReleaseFull {
return {
id: 1,
channel,
version: 1,
status: "DRAFT",
model: "anthropic/claude-sonnet-4.6",
modelParams: {},
allowedTools: [{ name: "handoff" }],
limits: { dailyCostUsd: 500 },
notes: "",
retrievalIndexVersion: "",
knowledgeVersions: [{ document: "overview", version: 1 }],
promptVersions: [
{ document: "system", version: 1 },
{ document: "sales", version: 1 },
],
createdAt: "2026-07-01T00:00:00Z",
publishedAt: null,
...overrides,
};
}
describe("release publish gate", () => {
it("publishes a draft without a built retrieval index", () => {
const release = buildRelease({ retrievalIndexVersion: "" });
const checks = buildChecks(release);
expect(checks.some((check) => check.label.includes("Retrieval"))).toBe(false);
expect(canPublishRelease(release, checks)).toBe(true);
});
it("publishes a draft without a sales prompt (sales-behavior gate removed)", () => {
const release = buildRelease({ promptVersions: [{ document: "system", version: 1 }] });
const checks = buildChecks(release);
expect(checks.some((check) => check.label.includes("Sales"))).toBe(false);
expect(canPublishRelease(release, checks)).toBe(true);
});
it("stays blocked when the release is not a draft", () => {
const release = buildRelease({ status: "PUBLISHED" });
expect(canPublishRelease(release, buildChecks(release))).toBe(false);
});
it("stays blocked when a required section is missing", () => {
const release = buildRelease({ promptVersions: [] });
expect(canPublishRelease(release, buildChecks(release))).toBe(false);
});
it("stays blocked when tools or limits are empty", () => {
const release = buildRelease({ allowedTools: [], limits: {} });
expect(canPublishRelease(release, buildChecks(release))).toBe(false);
});
});
@@ -1,133 +0,0 @@
import { createElement, type ReactNode } from "react";
import { Icon } from "../../../shared/icons";
import { type AiReleaseFull, type KnowledgeDoc, type PromptDoc } from "../detail/model";
export type ReleaseCheck = {
detail?: string;
icon: ReactNode;
label: string;
tone: "ok" | "warn" | "error";
};
export type ReleaseChange = {
sign: "+" | "~" | "—";
text: string;
tone: "add" | "change" | "neutral";
};
export function releaseLabel(release: AiReleaseFull): string {
return `REL-${releasePrefix(release.channel)}-v${release.version}`;
}
function releasePrefix(channel: AiReleaseFull["channel"]): string {
const letters = channel.name.match(/[A-ZА-ЯЁ]/g)?.join("");
if (letters && letters.length >= 2) return letters.slice(0, 2).toUpperCase();
const code = channel.code.replace(/[^a-zA-Zа-яА-ЯёЁ0-9]/g, "");
return (code.slice(0, 2) || "AI").toUpperCase();
}
export function formatDateTime(value: string | null): string {
if (!value) return "—";
return new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }).format(new Date(value));
}
export function formatShortDate(value: string | null | undefined): string {
if (!value) return "—";
const date = new Date(value);
const today = new Date();
if (date.getFullYear() === today.getFullYear() && date.getMonth() === today.getMonth() && date.getDate() === today.getDate()) return "сегодня";
return new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "long" }).format(date);
}
export function formatParam(value: unknown): string {
if (value === undefined || value === null || value === "") return "—";
if (typeof value === "number") return value.toLocaleString("ru-RU");
if (typeof value === "boolean") return value ? "true" : "false";
return String(value);
}
export function toolLabel(tool: unknown): string {
if (typeof tool === "string") return tool;
if (tool && typeof tool === "object" && "name" in tool) return String((tool as { name: unknown }).name);
return "—";
}
function checkIcon(tone: ReleaseCheck["tone"]) {
const name = tone === "ok" ? "check" : tone === "error" ? "xCircle" : "warning";
return createElement(Icon, { name, size: 16 });
}
export function buildChecks(release: AiReleaseFull): ReleaseCheck[] {
return [
{ label: "Системный prompt задан", tone: release.promptVersions.length > 0 ? "ok" : "warn", detail: release.promptVersions.length > 0 ? undefined : "нет версий prompt", icon: checkIcon(release.promptVersions.length > 0 ? "ok" : "warn") },
{ label: "Обязательные знания в индексе", tone: release.knowledgeVersions.length > 0 ? "ok" : "warn", detail: release.knowledgeVersions.length > 0 ? undefined : "нет версий знаний", icon: checkIcon(release.knowledgeVersions.length > 0 ? "ok" : "warn") },
{ label: "Инструменты сконфигурированы", tone: release.allowedTools.length > 0 ? "ok" : "warn", detail: release.allowedTools.length > 0 ? undefined : "нет разрешённых инструментов", icon: checkIcon(release.allowedTools.length > 0 ? "ok" : "warn") },
{ label: "Лимиты в допустимых границах", tone: Object.keys(release.limits).length > 0 ? "ok" : "warn", detail: Object.keys(release.limits).length > 0 ? undefined : "лимиты не заданы", icon: checkIcon(Object.keys(release.limits).length > 0 ? "ok" : "warn") },
];
}
export function checksSummary(checks: ReleaseCheck[]): { label: string; tone: ReleaseCheck["tone"] } {
const errors = checks.filter((check) => check.tone === "error").length;
const warnings = checks.filter((check) => check.tone === "warn").length;
if (errors > 0) return { label: `${errors} ошибка`, tone: "error" };
if (warnings > 0) return { label: `${warnings} предупреждение`, tone: "warn" };
return { label: "Все пройдены", tone: "ok" };
}
export function canPublishRelease(release: AiReleaseFull, checks: ReleaseCheck[]): boolean {
return release.status === "DRAFT" && checks.every((check) => check.tone === "ok");
}
export function validationBanner(checks: ReleaseCheck[]) {
const summary = checksSummary(checks);
if (summary.tone === "error") return { title: "Найдены ошибки валидации", note: "Исправьте отмеченные пункты в составе версии, затем перезапустите проверку.", tone: "error" as const };
if (summary.tone === "warn") return { title: "Черновик версии неполный", note: "Заполните обязательные разделы, чтобы перейти к проверке.", tone: "warn" as const };
return { title: "Готово к публикации", note: "Все проверки пройдены. Можно публиковать.", tone: "ok" as const };
}
export function releaseState(checks: ReleaseCheck[], published: boolean): { className: string; label: string } {
const summary = checksSummary(checks);
if (published) return { className: "published", label: "Опубликован" };
if (summary.tone === "error") return { className: "errors", label: "Ошибки валидации" };
if (summary.tone === "warn") return { className: "incomplete", label: "Неполный черновик" };
return { className: "ready-publish", label: "Готов к публикации" };
}
export function findPublishedPeer(release: AiReleaseFull, releases: AiReleaseFull[]): AiReleaseFull | undefined {
return releases.find((item) => item.channel.code === release.channel.code && item.status === "PUBLISHED" && item.id !== release.id);
}
export function buildChanges(release: AiReleaseFull, published: AiReleaseFull | undefined): ReleaseChange[] {
if (!published) return [{ sign: "+", tone: "add", text: "Опубликованная версия отсутствует: черновик станет первой опубликованной версией." }];
const changes: ReleaseChange[] = [];
if (release.model !== published.model) changes.push({ sign: "~", tone: "change", text: "Модель и параметры отличаются от опубликованной версии." });
if (JSON.stringify(release.promptVersions) !== JSON.stringify(published.promptVersions)) changes.push({ sign: "~", tone: "change", text: "Версии инструкций отличаются от опубликованной версии." });
if (JSON.stringify(release.knowledgeVersions) !== JSON.stringify(published.knowledgeVersions)) changes.push({ sign: "~", tone: "change", text: "Состав знаний отличается от опубликованной версии." });
if (release.retrievalIndexVersion !== published.retrievalIndexVersion) changes.push({ sign: "~", tone: "change", text: "Индекс поиска знаний отличается от опубликованной версии." });
if (JSON.stringify(release.allowedTools) !== JSON.stringify(published.allowedTools)) changes.push({ sign: "~", tone: "change", text: "Набор инструментов отличается от опубликованной версии." });
if (JSON.stringify(release.limits) !== JSON.stringify(published.limits)) changes.push({ sign: "~", tone: "change", text: "Лимиты отличаются от опубликованной версии." });
return changes.length > 0 ? changes : [{ sign: "—", tone: "neutral", text: "Отличий от опубликованной версии не найдено." }];
}
export function promptTitle(prompts: PromptDoc[], code: string): string {
return prompts.find((prompt) => prompt.code === code)?.title ?? code;
}
export function knowledgeTitle(knowledge: KnowledgeDoc[], code: string): string {
return knowledge.find((document) => document.code === code)?.title ?? code;
}
export function promptVersionDate(prompts: PromptDoc[], code: string, version: number): string {
const prompt = prompts.find((item) => item.code === code);
const docVersion = prompt?.versions.find((item) => item.version === version);
return formatShortDate(docVersion?.createdAt ?? prompt?.updatedAt);
}
export function isPromptChanged(published: AiReleaseFull | undefined, code: string, version: number): boolean {
return published ? !published.promptVersions.some((item) => item.document === code && item.version === version) : true;
}
export function isKnowledgeChanged(published: AiReleaseFull | undefined, code: string, version: number): boolean {
return published ? !published.knowledgeVersions.some((item) => item.document === code && item.version === version) : true;
}
@@ -1,94 +0,0 @@
.release-side {
display: flex;
flex-direction: column;
gap: 16px;
}
.release-panel-head {
justify-content: space-between;
padding: 14px 18px 11px;
margin: 0;
}
.release-panel-head--stack {
display: block;
}
.release-panel-head small {
display: block;
margin-top: 2px;
font-size: 11.5px;
color: #bfbfbf;
}
.release-check-row,
.release-change-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 18px;
border-top: 1px solid #f5f5f5;
}
.release-check-row strong {
display: block;
font-size: 12.5px;
font-weight: 500;
color: #262626;
}
.release-check-row small {
display: block;
margin-top: 1px;
font-size: 11px;
color: #8c8c8c;
}
.release-change-row > span {
width: 18px;
height: 18px;
border-radius: 5px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
font-size: 13px;
font-weight: 700;
line-height: 1;
}
.release-change-row p {
margin: 0;
font-size: 12.5px;
color: #262626;
line-height: 1.4;
}
.release-tone-ok {
color: #389e0d;
background: #f6ffed;
border-color: #b7eb8f;
}
.release-tone-warn,
.release-tone-change {
color: #d48806;
background: #fffbe6;
border-color: #ffe58f;
}
.release-tone-error {
color: #cf1322;
background: #fff2f0;
border-color: #ffccc7;
}
.release-tone-add {
color: #389e0d;
background: #f6ffed;
}
.release-tone-neutral {
color: #8c8c8c;
background: #f5f5f5;
}
@@ -1,166 +0,0 @@
.ai-release-page {
min-height: 100%;
}
.ai-release-state {
padding: 24px 28px;
}
.release-status-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 14px 28px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
}
.ai-release-body {
max-width: 1180px;
margin: 0 auto;
padding: 20px 28px 40px;
}
.release-title-group {
display: flex;
align-items: center;
gap: 13px;
min-width: 0;
}
.release-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex: none;
background: #f9f0ff;
color: #722ed1;
}
.release-title-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.release-title-row h1 {
margin: 0;
font-size: 17px;
font-weight: 700;
color: #1f1f1f;
font-family: "SF Mono", ui-monospace, Menlo, monospace;
}
.release-status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 11px;
border: 1px solid;
border-radius: 14px;
font-size: 11.5px;
font-weight: 600;
white-space: nowrap;
}
.release-status-badge span {
width: 6px;
height: 6px;
border-radius: 50%;
}
.release-status-badge.ready-publish,
.release-status-badge.published {
color: #389e0d;
background: #f6ffed;
border-color: #b7eb8f;
}
.release-status-badge.ready-publish span,
.release-status-badge.published span {
background: #52c41a;
}
.release-status-badge.incomplete {
color: #8c8c8c;
background: #f5f5f5;
border-color: #e8e8e8;
}
.release-status-badge.incomplete span {
background: #bfbfbf;
}
.release-status-badge.errors {
color: #cf1322;
background: #fff2f0;
border-color: #ffccc7;
}
.release-status-badge.errors span {
background: #ff4d4f;
}
.release-subtitle {
margin-top: 2px;
font-size: 12px;
color: #8c8c8c;
}
.release-actions {
display: flex;
align-items: center;
gap: 9px;
flex: none;
}
.release-validation {
display: flex;
align-items: center;
gap: 11px;
padding: 12px 16px;
border-radius: 10px;
margin-bottom: 18px;
border: 1px solid;
}
.release-validation > div {
flex: 1;
min-width: 0;
}
.release-validation strong {
display: block;
font-size: 13px;
font-weight: 600;
}
.release-validation p {
margin: 1px 0 0;
font-size: 12px;
color: #595959;
}
.release-validation button {
height: 32px;
padding: 0 13px;
border-radius: 8px;
border: 1px solid #d9d9d9;
background: #fff;
color: #262626;
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
}
.release-layout {
display: grid;
grid-template-columns: 1fr 320px;
gap: 18px;
align-items: start;
}
@@ -1,47 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "../../../api/client";
import type { AiReleaseFull, KnowledgeDoc, PromptDoc } from "../detail/model";
type ReleaseData = {
knowledge: KnowledgeDoc[];
prompts: PromptDoc[];
release: AiReleaseFull | null;
releases: AiReleaseFull[];
};
export function useProductAIRelease(releaseId: number | null) {
const [data, setData] = useState<ReleaseData>({ knowledge: [], prompts: [], release: null, releases: [] });
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const load = useCallback(async () => {
if (!releaseId) {
setError(true);
setLoading(false);
return;
}
setLoading(true);
setError(false);
try {
const { release } = await api<{ release: AiReleaseFull }>(`/api/v1/ai/releases/${releaseId}/`);
const channel = encodeURIComponent(release.channel.code);
const [releases, knowledge, prompts] = await Promise.all([
api<{ items: AiReleaseFull[] }>(`/api/v1/ai/releases/?channel=${channel}`),
api<{ items: KnowledgeDoc[] }>(`/api/v1/ai/knowledge/?channel=${channel}`),
api<{ items: PromptDoc[] }>(`/api/v1/ai/prompts/?channel=${channel}`),
]);
setData({ release, releases: releases.items, knowledge: knowledge.items, prompts: prompts.items });
} catch {
setError(true);
} finally {
setLoading(false);
}
}, [releaseId]);
useEffect(() => {
void load();
}, [load]);
return { ...data, loading, error, reload: load };
}
@@ -1,11 +1,10 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "../../api/client";
import type { AiAgent, AiRelease } from "./model";
import type { AiAgent } from "./model";
export function useAiAgents() {
const [agents, setAgents] = useState<AiAgent[]>([]);
const [releases, setReleases] = useState<AiRelease[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
@@ -13,12 +12,8 @@ export function useAiAgents() {
setLoading(true);
setError(false);
try {
const [agentsResponse, releasesResponse] = await Promise.all([
api<{ items: AiAgent[] }>("/api/v1/ai/agents/"),
api<{ items: AiRelease[] }>("/api/v1/ai/releases/"),
]);
const agentsResponse = await api<{ items: AiAgent[] }>("/api/v1/ai/agents/");
setAgents(agentsResponse.items);
setReleases(releasesResponse.items);
} catch {
setError(true);
} finally {
@@ -30,5 +25,5 @@ export function useAiAgents() {
void load();
}, [load]);
return { agents, releases, loading, error, reload: load };
return { agents, loading, error, reload: load };
}
@@ -2,7 +2,7 @@ import { Modal } from "antd";
import { useEffect, useState } from "react";
import { api } from "../../api/client";
import { FormField, SelectField, TextAreaField } from "../../shared/form-controls";
import { FormField, SelectField } from "../../shared/form-controls";
import { Button } from "../../shared/ui-controls";
import type { Department, Product } from "../../types";
@@ -10,12 +10,10 @@ type ProductForm = {
code: string;
name: string;
siteUrl: string;
summary: string;
salesDescription: string;
departmentId: string;
};
const emptyForm: ProductForm = { code: "", name: "", siteUrl: "", summary: "", salesDescription: "", departmentId: "" };
const emptyForm: ProductForm = { code: "", name: "", siteUrl: "", departmentId: "" };
export function ProductFormModal({ departments, open, product, onClose, onSaved }: { departments: Department[]; open: boolean; product?: Product; onClose: () => void; onSaved: () => void }) {
const [form, setForm] = useState<ProductForm>(emptyForm);
@@ -28,8 +26,6 @@ export function ProductFormModal({ departments, open, product, onClose, onSaved
code: product.code,
name: product.name,
siteUrl: product.siteUrl,
summary: product.summary,
salesDescription: product.salesDescription,
departmentId: String(product.departments[0]?.id ?? departments[0]?.id ?? ""),
} : { ...emptyForm, departmentId: String(departments[0]?.id ?? "") });
setError("");
@@ -51,8 +47,6 @@ export function ProductFormModal({ departments, open, product, onClose, onSaved
code: form.code,
name: form.name,
siteUrl: form.siteUrl,
summary: form.summary,
salesDescription: form.salesDescription,
departmentIds: form.departmentId ? [Number(form.departmentId)] : [],
});
await api(product ? `/api/v1/company/products/${product.id}/update/` : "/api/v1/company/products/create/", { method: product ? "PATCH" : "POST", body });
@@ -71,8 +65,6 @@ export function ProductFormModal({ departments, open, product, onClose, onSaved
<FormField label="Название" value={form.name} onChange={(value) => update("name", value)} />
<FormField label="Код" value={form.code} onChange={product ? undefined : (value) => update("code", value)} disabled={Boolean(product)} mono />
<FormField label="Сайт" value={form.siteUrl} onChange={(value) => update("siteUrl", value)} wide />
<FormField label="Краткое описание" value={form.summary} onChange={(value) => update("summary", value)} wide />
<TextAreaField label="Описание для продаж" value={form.salesDescription} onChange={(value) => update("salesDescription", value)} />
<SelectField label="Отдел" value={form.departmentId} onChange={(value) => update("departmentId", value)} options={departments.map((department) => [String(department.id), department.name])} />
</div>
{error && <div className="product-form-error">{error}</div>}
@@ -40,7 +40,7 @@ export function ProductAgentCard({ product, openAgentCreate, openAgent }: { prod
<span className="product-agent-icon is-empty"><Icon name="robot" size={17} /></span>
<div>
<h3>Sales-agent не создан</h3>
<p>Создайте агента для этого продукта. Он начнёт работать только после публикации первой версии.</p>
<p>Создайте агента для этого продукта. Он начнёт отвечать после запуска из карточки агента.</p>
</div>
</div>
<Button variant="primary" className="product-agent-action" icon="plus" onClick={() => openAgentCreate(product.code)}>Создать агента</Button>
@@ -13,7 +13,7 @@ export function ProductDetailHeader({ product, onEdit }: { product: Product; onE
<span className="product-detail-icon" style={{ background: accent.bg, color: accent.color }}><Icon name="box" size={26} /></span>
<div className="product-detail-title">
<div><h1>{product.name}</h1><span className={`product-detail-status ${product.status.toLowerCase()}`}><i />{product.status === "ACTIVE" ? "Активен" : "Неактивен"}</span>{kind !== "—" && <b style={{ background: accent.bg, color: accent.color }}>{kind}</b>}</div>
<p>{product.summary || "—"}</p>
<p>{product.siteUrl || "—"}</p>
</div>
<Button variant="secondary" icon="edit" onClick={onEdit}>Редактировать</Button>
</div>
@@ -6,8 +6,10 @@ export function ProductOverviewTab({ product, openAgentCreate, openAgent }: { pr
return (
<div className="product-overview-grid">
<div className="product-overview-main">
<section className="product-detail-card"><h3>Описание для продаж</h3><p>{product.salesDescription || "—"}</p></section>
<section className="product-detail-card"><h3>Ключевые тезисы для AI</h3><p className="product-detail-empty">—</p></section>
<section className="product-detail-card">
<h3>Описание и знания</h3>
<p className="product-detail-empty">Содержательная информация о продукте живёт в разделе AI → Знания и подключается к агентам каналов.</p>
</section>
</div>
<aside className="product-overview-side">
<section className="product-detail-card product-parameters">
@@ -9,8 +9,6 @@ const product: Product = {
name: "Foxray",
status: "ACTIVE",
siteUrl: "https://foxray.pro",
summary: "SaaS",
salesDescription: "",
departments: [],
createdAt: "2026-06-19T00:00:00Z",
updatedAt: "2026-06-19T00:00:00Z",
@@ -2,7 +2,7 @@ import type { Product } from "../../types";
export function productDetails(product: Product) {
return {
sub: product.summary || product.siteUrl || product.code,
sub: product.siteUrl || product.code,
offers: product.offers.filter((offer) => offer.isActive).map((offer) => ({
name: offer.name,
type: offer.fulfillmentType === "SUPPORT_EXTENSION" ? "продление" : offer.paymentType === "SUBSCRIPTION" ? "подписка" : "разовая",
+4 -4
View File
@@ -12,7 +12,7 @@ import { SupportSidebar } from "./SupportSidebar";
import { ShellRouteContent } from "./ShellRouteContent";
import { TopBar } from "./TopBar";
export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId, selectedProductCode, selectedAgentId, selectedReleaseId, selectedConversationId, selectedClientId, selectedOrderId, openEmployeeRoute, openProductRoute, openAgentCreateRoute, openAgentRoute, openReleaseRoute, openConversationRoute, openClientRoute, openOrderRoute, user, data, reload, onUserUpdated, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; selectedEmployeeId: number | null; selectedProductId: number | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedReleaseId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedOrderId: number | null; openEmployeeRoute: (employeeId: number) => void; openProductRoute: (productId: number) => void; openAgentCreateRoute: (productCode: string | null) => void; openAgentRoute: (agentId: number) => void; openReleaseRoute: (releaseId: number) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openOrderRoute: (orderId: number) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) {
export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId, selectedProductCode, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, selectedOrderId, openEmployeeRoute, openProductRoute, openAgentCreateRoute, openAgentRoute, openKnowledgeRoute, openConversationRoute, openClientRoute, openOrderRoute, user, data, reload, onUserUpdated, onLogout }: { route: RouteKey; setRoute: (route: RouteKey) => void; selectedEmployeeId: number | null; selectedProductId: number | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; selectedOrderId: number | null; openEmployeeRoute: (employeeId: number) => void; openProductRoute: (productId: number) => void; openAgentCreateRoute: (productCode: string | null) => void; openAgentRoute: (agentId: number) => void; openKnowledgeRoute: (knowledgeId: number) => void; openConversationRoute: (conversationId: number) => void; openClientRoute: (clientId: number) => void; openOrderRoute: (orderId: number) => void; user: SessionUser; data: AppData; reload: () => void; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) {
const [agentName, setAgentName] = useState<string | null>(null);
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
@@ -92,8 +92,8 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId,
const isSalesOrderDetail = route === "salesOrderDetail";
const isSalesOrders = route === "salesOrderDetail" || route === "salesOrders";
// Подменю AI показываем только там, где оно есть в baseline.
const isAiSection = route === "aiAgents" || route === "aiUsage";
const isAiFullWidth = route === "aiAgentCreate" || route === "aiRelease";
const isAiSection = route === "aiAgents" || route === "aiKnowledge" || route === "aiUsage";
const isAiFullWidth = route === "aiAgentCreate";
// OPERATOR работает в пространстве своего отдела (SPEC-HUB-0004 §9 + §0010 §10):
// sales operator → sales-sidebar, support operator → support-sidebar.
const showSalesSidebar = isSalesWorkspace || (user.role === "OPERATOR" && user.department !== "support" && !isSupportWorkspace);
@@ -106,7 +106,7 @@ export function Shell({ route, setRoute, selectedEmployeeId, selectedProductId,
{isAiSection && <AiSubnav route={route} setRoute={setRoute} />}
<main className={`hub-scroll ${isDialogsWorkspace ? "sales-dialogs-scroll" : ""} ${isAiFullWidth ? "ai-fullwidth-scroll" : ""}`}>
<div className={`hub-page ${isSalesWorkspace || isSupportWorkspace ? "sales-workspace-page" : ""} ${isDialogsWorkspace ? "sales-dialogs-page" : ""} ${isSalesClients ? "sales-clients-page" : ""} ${isSalesClientDetail ? "sales-client-detail-page" : ""} ${isSalesOrderDetail ? "sales-order-detail-page" : ""} ${isSalesOrders ? "sales-orders-page" : ""} ${isAiFullWidth ? "ai-fullwidth-page" : ""}`}>
<ShellRouteContent route={route} data={data} currentEmployee={currentEmployee} currentProduct={currentProduct} selectedProductCode={selectedProductCode} selectedAgentId={selectedAgentId} selectedReleaseId={selectedReleaseId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedOrderId={selectedOrderId} openOrder={openOrderRoute} openEmployee={openEmployee} openProduct={openProduct} openAgentCreate={openAgentCreateRoute} openAgent={openAgentRoute} openRelease={openReleaseRoute} onAgentLoaded={setAgentName} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} />
<ShellRouteContent route={route} data={data} currentEmployee={currentEmployee} currentProduct={currentProduct} selectedProductCode={selectedProductCode} selectedAgentId={selectedAgentId} selectedKnowledgeId={selectedKnowledgeId} selectedConversationId={selectedConversationId} selectedClientId={selectedClientId} openClient={openClientRoute} selectedOrderId={selectedOrderId} openOrder={openOrderRoute} openEmployee={openEmployee} openProduct={openProduct} openAgentCreate={openAgentCreateRoute} openAgent={openAgentRoute} openKnowledge={openKnowledgeRoute} onAgentLoaded={setAgentName} reload={reload} setRoute={setRoute} user={user} onUserUpdated={onUserUpdated} onLogout={onLogout} />
</div>
</main>
</div>
@@ -1,7 +1,8 @@
import { AiAgentsPage } from "../features/ai/AiAgentsPage";
import { AiAgentCreatePage } from "../features/ai/create/AiAgentCreatePage";
import { AiAgentDetailPage } from "../features/ai/detail/AiAgentDetailPage";
import { ProductAIReleasePage } from "../features/ai/release/ProductAIReleasePage";
import { KnowledgeDetailPage } from "../features/ai/knowledge/KnowledgeDetailPage";
import { KnowledgePage } from "../features/ai/knowledge/KnowledgePage";
import { CommandCenter } from "../features/command/CommandCenter";
import { IntegrationsPage } from "../features/integrations/IntegrationsPage";
import { DepartmentsPage } from "../features/departments/DepartmentsPage";
@@ -19,7 +20,7 @@ import { SupportDialogsPage } from "../features/support/SupportDialogsPage";
import { SupportOverviewPage } from "../features/support/SupportOverviewPage";
import type { AppData, Employee, Product, RouteKey, SessionUser } from "../types";
export function ShellRouteContent({ route, data, currentEmployee, currentProduct, selectedProductCode, selectedAgentId, selectedReleaseId, selectedConversationId, selectedClientId, openClient, selectedOrderId, openOrder, openEmployee, openProduct, openAgentCreate, openAgent, openRelease, onAgentLoaded, reload, setRoute, user, onUserUpdated, onLogout }: { route: RouteKey; data: AppData; currentEmployee: Employee | null; currentProduct: Product | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedReleaseId: number | null; selectedConversationId: number | null; selectedClientId: number | null; openClient: (clientId: number) => void; selectedOrderId: number | null; openOrder: (orderId: number) => void; openEmployee: (employee: Employee) => void; openProduct: (product: Product) => void; openAgentCreate: (productCode: string | null) => void; openAgent: (agentId: number) => void; openRelease: (releaseId: number) => void; onAgentLoaded: (name: string | null) => void; reload: () => void; setRoute: (route: RouteKey) => void; user: SessionUser; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) {
export function ShellRouteContent({ route, data, currentEmployee, currentProduct, selectedProductCode, selectedAgentId, selectedKnowledgeId, selectedConversationId, selectedClientId, openClient, selectedOrderId, openOrder, openEmployee, openProduct, openAgentCreate, openAgent, openKnowledge, onAgentLoaded, reload, setRoute, user, onUserUpdated, onLogout }: { route: RouteKey; data: AppData; currentEmployee: Employee | null; currentProduct: Product | null; selectedProductCode: string | null; selectedAgentId: number | null; selectedKnowledgeId: number | null; selectedConversationId: number | null; selectedClientId: number | null; openClient: (clientId: number) => void; selectedOrderId: number | null; openOrder: (orderId: number) => void; openEmployee: (employee: Employee) => void; openProduct: (product: Product) => void; openAgentCreate: (productCode: string | null) => void; openAgent: (agentId: number) => void; openKnowledge: (knowledgeId: number) => void; onAgentLoaded: (name: string | null) => void; reload: () => void; setRoute: (route: RouteKey) => void; user: SessionUser; onUserUpdated: (user: SessionUser) => void; onLogout: () => void }) {
return (
<>
{route === "command" && <CommandCenter data={data} setRoute={setRoute} />}
@@ -39,10 +40,11 @@ export function ShellRouteContent({ route, data, currentEmployee, currentProduct
{route === "salesDialogs" && <SalesDialogsPage initialConversationId={selectedConversationId} />}
{route === "salesOrderDetail" && <SalesOrderDetailPage orderId={selectedOrderId} setRoute={setRoute} />}
{route === "salesOrders" && <SalesOrdersPage setRoute={setRoute} openOrder={openOrder} />}
{route === "aiAgents" && <AiAgentsPage agents={data.agents} releases={data.releases} reload={reload} openAgentCreate={openAgentCreate} openAgent={openAgent} openRelease={openRelease} />}
{route === "aiAgentCreate" && <AiAgentCreatePage selectedProductCode={selectedProductCode} reload={reload} setRoute={setRoute} openAgent={openAgent} openRelease={openRelease} />}
{route === "aiAgentDetail" && <AiAgentDetailPage agentId={selectedAgentId} openRelease={openRelease} onAgentLoaded={onAgentLoaded} />}
{route === "aiRelease" && <ProductAIReleasePage releaseId={selectedReleaseId} onReleaseLoaded={onAgentLoaded} />}
{route === "aiAgents" && <AiAgentsPage agents={data.agents} reload={reload} openAgentCreate={openAgentCreate} openAgent={openAgent} />}
{route === "aiAgentCreate" && <AiAgentCreatePage selectedProductCode={selectedProductCode} reload={reload} setRoute={setRoute} openAgent={openAgent} />}
{route === "aiAgentDetail" && <AiAgentDetailPage agentId={selectedAgentId} openKnowledge={openKnowledge} onAgentLoaded={onAgentLoaded} />}
{route === "aiKnowledge" && <KnowledgePage openKnowledge={openKnowledge} />}
{route === "aiKnowledgeDetail" && <KnowledgeDetailPage knowledgeId={selectedKnowledgeId} setRoute={setRoute} onLoaded={onAgentLoaded} />}
{route === "integrations" && <IntegrationsPage />}
</>
);
+2 -3
View File
@@ -6,9 +6,8 @@ import { Icon } from "../shared/icons";
export function TopBar({ route, user, currentEmployee, currentProduct, currentAgentName, setRoute, unreadCount = 0, onOpenNotifications }: { route: RouteKey; user: SessionUser; currentEmployee?: Employee | null; currentProduct?: Product | null; currentAgentName?: string | null; setRoute: (route: RouteKey) => void; unreadCount?: number; onOpenNotifications?: () => void }) {
const st = commandCenterModel("today").st;
const isCommand = route === "command";
const isAiDetail = route === "aiAgentDetail" || route === "aiRelease";
const isAiDetail = route === "aiAgentDetail" || route === "aiKnowledgeDetail";
const isAi = route.startsWith("ai") && !isAiDetail && route !== "aiAgentCreate";
const [releaseAgentName, releaseName] = route === "aiRelease" && currentAgentName?.includes("|") ? currentAgentName.split("|") : [currentAgentName, null];
const isSalesWorkspace = route === "salesOverview" || route === "salesClientDetail" || route === "salesClients" || route === "salesDialogs" || route === "salesOrderDetail" || route === "salesOrders";
const isSupportWorkspace = route === "supportOverview" || route === "supportDialogs";
return (
@@ -25,7 +24,7 @@ export function TopBar({ route, user, currentEmployee, currentProduct, currentAg
{isAi && <><button type="button" onClick={() => setRoute("aiAgents")}>AI</button><i>/</i><strong>{routes[route]}</strong></>}
{route === "aiAgentCreate" && <><button type="button" onClick={() => setRoute("aiAgents")}>AI</button><i>/</i><button type="button" onClick={() => setRoute("aiAgents")}>AI-агенты</button><i>/</i><strong>{routes[route]}</strong></>}
{route === "aiAgentDetail" && <><button type="button" onClick={() => setRoute("aiAgents")}>AI</button><i>/</i><button type="button" onClick={() => setRoute("aiAgents")}>AI-агенты</button><i>/</i><strong>{currentAgentName || routes[route]}</strong></>}
{route === "aiRelease" && <><button type="button" onClick={() => setRoute("aiAgents")}>AI</button><i>/</i><button type="button" onClick={() => setRoute("aiAgents")}>{releaseAgentName || "AI-агенты"}</button><i>/</i><strong className="topbar-mono">{releaseName || routes[route]}</strong></>}
{route === "aiKnowledgeDetail" && <><button type="button" onClick={() => setRoute("aiAgents")}>AI</button><i>/</i><button type="button" onClick={() => setRoute("aiKnowledge")}>Знания</button><i>/</i><strong>{currentAgentName || routes[route]}</strong></>}
{route !== "employeeDetail" && route !== "productDetail" && route !== "aiAgentCreate" && !isSalesWorkspace && !isSupportWorkspace && !isAi && !isAiDetail && <strong>{routes[route]}</strong>}
</div>
<div className="topbar-actions">
+14 -12
View File
@@ -4,7 +4,7 @@ import { pathFromRoute, routeFromPath } from "./router";
describe("product routes", () => {
it("parses a product detail URL", () => {
expect(routeFromPath("/products/42")).toEqual({ route: "productDetail", employeeId: null, productId: 42, productCode: null, agentId: null, releaseId: null, clientId: null, orderId: null });
expect(routeFromPath("/products/42")).toEqual({ route: "productDetail", employeeId: null, productId: 42, productCode: null, agentId: null, knowledgeId: null, clientId: null, orderId: null });
});
it("creates a product detail URL", () => {
@@ -14,11 +14,11 @@ describe("product routes", () => {
describe("sales detail routes", () => {
it("parses a client detail URL", () => {
expect(routeFromPath("/departments/sales/clients/15")).toEqual({ route: "salesClientDetail", employeeId: null, productId: null, productCode: null, agentId: null, releaseId: null, clientId: 15, orderId: null });
expect(routeFromPath("/departments/sales/clients/15")).toEqual({ route: "salesClientDetail", employeeId: null, productId: null, productCode: null, agentId: null, knowledgeId: null, clientId: 15, orderId: null });
});
it("parses an order detail URL", () => {
expect(routeFromPath("/departments/sales/orders/8")).toEqual({ route: "salesOrderDetail", employeeId: null, productId: null, productCode: null, agentId: null, releaseId: null, clientId: null, orderId: 8 });
expect(routeFromPath("/departments/sales/orders/8")).toEqual({ route: "salesOrderDetail", employeeId: null, productId: null, productCode: null, agentId: null, knowledgeId: null, clientId: null, orderId: 8 });
});
it("creates client and order detail URLs", () => {
@@ -29,7 +29,7 @@ describe("sales detail routes", () => {
describe("ai agent routes", () => {
it("parses an AI agent detail URL", () => {
expect(routeFromPath("/ai/agents/7")).toEqual({ route: "aiAgentDetail", employeeId: null, productId: null, productCode: null, agentId: 7, releaseId: null, clientId: null, orderId: null });
expect(routeFromPath("/ai/agents/7")).toEqual({ route: "aiAgentDetail", employeeId: null, productId: null, productCode: null, agentId: 7, knowledgeId: null, clientId: null, orderId: null });
});
it("creates an AI agent detail URL", () => {
@@ -37,7 +37,7 @@ describe("ai agent routes", () => {
});
it("parses an AI agent creation URL", () => {
expect(routeFromPath("/ai/agents/new", "?product=academy")).toEqual({ route: "aiAgentCreate", employeeId: null, productId: null, productCode: "academy", agentId: null, releaseId: null, clientId: null, orderId: null });
expect(routeFromPath("/ai/agents/new", "?product=academy")).toEqual({ route: "aiAgentCreate", employeeId: null, productId: null, productCode: "academy", agentId: null, knowledgeId: null, clientId: null, orderId: null });
});
it("creates an AI agent creation URL", () => {
@@ -45,20 +45,22 @@ describe("ai agent routes", () => {
});
});
describe("ai release routes", () => {
it("parses a release URL", () => {
expect(routeFromPath("/ai/releases/12")).toEqual({ route: "aiRelease", employeeId: null, productId: null, productCode: null, agentId: null, releaseId: 12, clientId: null, orderId: null });
describe("ai knowledge routes", () => {
it("parses knowledge list and detail URLs", () => {
expect(routeFromPath("/ai/knowledge")).toEqual({ route: "aiKnowledge", employeeId: null, productId: null, productCode: null, agentId: null, knowledgeId: null, clientId: null, orderId: null });
expect(routeFromPath("/ai/knowledge/12")).toEqual({ route: "aiKnowledgeDetail", employeeId: null, productId: null, productCode: null, agentId: null, knowledgeId: 12, clientId: null, orderId: null });
});
it("creates a release URL", () => {
expect(pathFromRoute("aiRelease", 12)).toBe("/ai/releases/12");
it("creates knowledge URLs", () => {
expect(pathFromRoute("aiKnowledge")).toBe("/ai/knowledge");
expect(pathFromRoute("aiKnowledgeDetail", 12)).toBe("/ai/knowledge/12");
});
});
describe("support routes", () => {
it("parses support overview and dialogs URLs", () => {
expect(routeFromPath("/departments/support")).toEqual({ route: "supportOverview", employeeId: null, productId: null, productCode: null, agentId: null, releaseId: null, clientId: null, orderId: null });
expect(routeFromPath("/departments/support/dialogs")).toEqual({ route: "supportDialogs", employeeId: null, productId: null, productCode: null, agentId: null, releaseId: null, clientId: null, orderId: null });
expect(routeFromPath("/departments/support")).toEqual({ route: "supportOverview", employeeId: null, productId: null, productCode: null, agentId: null, knowledgeId: null, clientId: null, orderId: null });
expect(routeFromPath("/departments/support/dialogs")).toEqual({ route: "supportDialogs", employeeId: null, productId: null, productCode: null, agentId: null, knowledgeId: null, clientId: null, orderId: null });
});
it("creates support overview and dialogs URLs", () => {
+7 -5
View File
@@ -6,14 +6,14 @@ export type RouteState = {
productId: number | null;
productCode: string | null;
agentId: number | null;
releaseId: number | null;
knowledgeId: number | null;
clientId: number | null;
orderId: number | null;
};
export function routeFromPath(pathname: string, search = ""): RouteState {
const path = pathname.replace(/\/+$/, "") || "/";
const base = { employeeId: null, productId: null, productCode: null, agentId: null, releaseId: null, clientId: null, orderId: null };
const base = { employeeId: null, productId: null, productCode: null, agentId: null, knowledgeId: null, clientId: null, orderId: null };
if (path === "/" || path === "/command") return { route: "command", ...base };
if (path === "/departments") return { route: "departments", ...base };
if (path === "/departments/sales") return { route: "salesOverview", ...base };
@@ -49,9 +49,10 @@ export function routeFromPath(pathname: string, search = ""): RouteState {
const id = Number(path.split("/")[3]);
return Number.isInteger(id) && id > 0 ? { ...base, route: "aiAgentDetail", agentId: id } : { route: "aiAgents", ...base };
}
if (path.startsWith("/ai/releases/")) {
if (path === "/ai/knowledge") return { route: "aiKnowledge", ...base };
if (path.startsWith("/ai/knowledge/")) {
const id = Number(path.split("/")[3]);
return Number.isInteger(id) && id > 0 ? { ...base, route: "aiRelease", releaseId: id } : { route: "aiAgents", ...base };
return Number.isInteger(id) && id > 0 ? { ...base, route: "aiKnowledgeDetail", knowledgeId: id } : { route: "aiKnowledge", ...base };
}
if (path === "/ai/usage") return { route: "aiUsage", ...base };
if (path === "/integrations") return { route: "integrations", ...base };
@@ -78,7 +79,8 @@ export function pathFromRoute(route: RouteKey, entityId: number | null = null, p
if (route === "aiAgentCreate") return productCode ? `/ai/agents/new?product=${encodeURIComponent(productCode)}` : "/ai/agents/new";
if (route === "aiUsage") return "/ai/usage";
if (route === "aiAgentDetail") return entityId ? `/ai/agents/${entityId}` : "/ai/agents";
if (route === "aiRelease") return entityId ? `/ai/releases/${entityId}` : "/ai/agents";
if (route === "aiKnowledge") return "/ai/knowledge";
if (route === "aiKnowledgeDetail") return entityId ? `/ai/knowledge/${entityId}` : "/ai/knowledge";
if (route === "integrations") return "/integrations";
if (route === "profile") return "/profile";
return "/profile";
+2 -1
View File
@@ -19,7 +19,8 @@ export const routes: Record<RouteKey, string> = {
aiAgents: "AI-агенты",
aiAgentCreate: "Создание агента",
aiAgentDetail: "Карточка агента",
aiRelease: "Версия продукта",
aiKnowledge: "Знания",
aiKnowledgeDetail: "Знание",
aiUsage: "Использование AI",
integrations: "Интеграции",
};
+1 -4
View File
@@ -20,10 +20,7 @@
@import "./features/ai/styles.css";
@import "./features/ai/create/styles.css";
@import "./features/ai/detail/styles.css";
@import "./features/ai/release/styles.css";
@import "./features/ai/release/composition.css";
@import "./features/ai/release/side.css";
@import "./features/ai/release/modal.css";
@import "./features/ai/knowledge/styles.css";
@import "./features/integrations/styles.css";
@import "./features/notifications/styles.css";
@import "./shared/state.css";
+2 -5
View File
@@ -1,4 +1,4 @@
import type { AiAgent, AiRelease } from "./features/ai/model";
import type { AiAgent } from "./features/ai/model";
export type Role = "OWNER" | "OPERATOR";
export type ProductStatus = "ACTIVE" | "DISABLED";
@@ -58,8 +58,6 @@ export type Product = {
name: string;
status: ProductStatus;
siteUrl: string;
summary: string;
salesDescription: string;
departments: Array<{ id: number; code: string; name: string }>;
offers: ProductOffer[];
createdAt: string;
@@ -92,12 +90,11 @@ export type ProductOffer = {
prices: ProductPrice[];
};
export type RouteKey = "command" | "departments" | "employeeDetail" | "employees" | "productDetail" | "products" | "profile" | "salesClientDetail" | "salesClients" | "salesDialogs" | "salesOrderDetail" | "salesOrders" | "salesOverview" | "supportOverview" | "supportDialogs" | "aiAgents" | "aiAgentCreate" | "aiAgentDetail" | "aiRelease" | "aiUsage" | "integrations";
export type RouteKey = "command" | "departments" | "employeeDetail" | "employees" | "productDetail" | "products" | "profile" | "salesClientDetail" | "salesClients" | "salesDialogs" | "salesOrderDetail" | "salesOrders" | "salesOverview" | "supportOverview" | "supportDialogs" | "aiAgents" | "aiAgentCreate" | "aiAgentDetail" | "aiKnowledge" | "aiKnowledgeDetail" | "aiUsage" | "integrations";
export type AppData = {
employees: Employee[];
departments: Department[];
products: Product[];
agents: AiAgent[];
releases: AiRelease[];
};
+5
View File
@@ -37,6 +37,9 @@ services:
--bind 0.0.0.0:8000
--workers $${HUB_GUNICORN_WORKERS:-3}
--timeout $${HUB_GUNICORN_TIMEOUT:-60}"
volumes:
# Файловые вложения знаний (ADR-HUB-0023).
- ./data/media:/app/apps/backend/media
depends_on:
postgres:
condition: service_healthy
@@ -61,6 +64,8 @@ services:
env_file:
- .env.production
command: python manage.py run_worker
volumes:
- ./data/media:/app/apps/backend/media
depends_on:
backend:
condition: service_healthy
+2 -2
View File
@@ -11,7 +11,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-edevs_hub}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-edevs_hub}
ports:
- "5432:5432"
- "${POSTGRES_HOST_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
@@ -23,7 +23,7 @@ services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
- "${REDIS_HOST_PORT:-6379}:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s