mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
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:
1 parent
179e4a6dec
commit
a086675283
98 files changed
+2273
-4212
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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},
|
||||
}
|
||||
)
|
||||
@@ -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()],
|
||||
)
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
@@ -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"))
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" ? "подписка" : "разовая",
|
||||
|
||||
@@ -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 />}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -19,7 +19,8 @@ export const routes: Record<RouteKey, string> = {
|
||||
aiAgents: "AI-агенты",
|
||||
aiAgentCreate: "Создание агента",
|
||||
aiAgentDetail: "Карточка агента",
|
||||
aiRelease: "Версия продукта",
|
||||
aiKnowledge: "Знания",
|
||||
aiKnowledgeDetail: "Знание",
|
||||
aiUsage: "Использование AI",
|
||||
integrations: "Интеграции",
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user