✨ feat(ai): add category and scoped knowledge APIs

This commit is contained in:
Andrey committed 2026-07-19 01:56:23 +03:00
1 parent 2196012875
commit 2e40eff807
13 files changed
+881 -207

No files matched your search

@@ -0,0 +1,14 @@
from django.core.exceptions import ValidationError
from rest_framework.response import Response
def validation_error_response(error: ValidationError) -> Response:
if hasattr(error, "message_dict"):
detail = "; ".join(
message
for messages in error.message_dict.values()
for message in messages
)
else:
detail = "; ".join(error.messages)
return Response({"detail": detail}, status=400)
@@ -0,0 +1,148 @@
from django.core.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from hub_platform.ai.api_errors import validation_error_response
from hub_platform.ai.knowledge_categories import (
create_category,
delete_category,
update_category,
)
from hub_platform.ai.knowledge_policy import require_category_manage
from hub_platform.ai.models import KnowledgeCategory
from hub_platform.ai.selectors import category_tree_for_employee
from hub_platform.ai.serializers import category_payload
from hub_platform.api.permissions import HasCapability, HasEntitlement
from hub_platform.identity.audit import record_audit_event
def _integer(value: object, field: str) -> int:
if isinstance(value, bool):
raise ValidationError({field: "Integer required"})
try:
return int(value)
except (TypeError, ValueError) as error:
raise ValidationError({field: "Integer required"}) from error
def _category(request: Request, category_id: int) -> KnowledgeCategory:
return KnowledgeCategory.objects.select_related("parent").get(
organization_id=request.tenant_context.organization_id,
id=category_id,
)
def _parent(
request: Request, value: object, *, field_present: bool
) -> KnowledgeCategory | None:
if not field_present or value is None:
return None
parent_id = _integer(value, "parentId")
try:
return _category(request, parent_id)
except KnowledgeCategory.DoesNotExist as error:
raise ValidationError({"parentId": "Parent category not found"}) from error
def _audit(
request: Request,
action: str,
category: KnowledgeCategory,
*,
object_id: int | None = None,
) -> None:
record_audit_event(
action=f"ai.knowledge_category_{action}",
actor=request.user,
organization=request.tenant_context.organization,
object_type="KnowledgeCategory",
object_id=str(object_id if object_id is not None else category.id),
request=request,
)
def _category_payload(request: Request, category_id: int) -> dict[str, object]:
category = next(
item
for item in category_tree_for_employee(context=request.tenant_context)
if item.id == category_id
)
return category_payload(category)
class KnowledgeCategoryListCreateView(APIView):
permission_classes = [HasEntitlement, HasCapability]
required_entitlement = "knowledge_base"
required_capabilities = {"GET": "ai.view", "POST": "ai.manage"}
def get(self, request: Request) -> Response:
categories = category_tree_for_employee(context=request.tenant_context)
return Response({"items": [category_payload(item) for item in categories]})
def post(self, request: Request) -> Response:
try:
require_category_manage(context=request.tenant_context)
parent = _parent(
request,
request.data.get("parentId"),
field_present="parentId" in request.data,
)
category = create_category(
context=request.tenant_context,
name=str(request.data.get("name", "")),
parent=parent,
sort_order=_integer(request.data.get("sortOrder", 0), "sortOrder"),
)
except ValidationError as error:
return validation_error_response(error)
_audit(request, "created", category)
return Response(
{"category": _category_payload(request, category.id)}, status=201
)
class KnowledgeCategoryDetailView(APIView):
permission_classes = [HasEntitlement, HasCapability]
required_entitlement = "knowledge_base"
required_capability = "ai.manage"
def patch(self, request: Request, category_id: int) -> Response:
try:
require_category_manage(context=request.tenant_context)
category = _category(request, category_id)
except KnowledgeCategory.DoesNotExist:
return Response({"detail": "Category not found"}, status=404)
try:
parent = (
_parent(request, request.data.get("parentId"), field_present=True)
if "parentId" in request.data
else category.parent
)
updated = update_category(
context=request.tenant_context,
category=category,
name=str(request.data.get("name", category.name)),
parent=parent,
sort_order=_integer(
request.data.get("sortOrder", category.sort_order), "sortOrder"
),
)
except ValidationError as error:
return validation_error_response(error)
_audit(request, "updated", updated)
return Response({"category": _category_payload(request, updated.id)})
def delete(self, request: Request, category_id: int) -> Response:
try:
require_category_manage(context=request.tenant_context)
category = _category(request, category_id)
except KnowledgeCategory.DoesNotExist:
return Response({"detail": "Category not found"}, status=404)
try:
deleted_id = category.id
delete_category(context=request.tenant_context, category=category)
except ValidationError as error:
return validation_error_response(error)
_audit(request, "deleted", category, object_id=deleted_id)
return Response(status=204)
@@ -0,0 +1,116 @@
from django.core.exceptions import ValidationError
from rest_framework.request import Request
from hub_platform.ai.knowledge_types import KnowledgeVisibility
from hub_platform.ai.knowledge_services import KnowledgeInput
from hub_platform.ai.models import Knowledge
from hub_platform.ai.selectors import KnowledgeFilters
def _positive_id(value: object, field: str) -> int:
if isinstance(value, bool):
raise ValidationError({field: "Positive integer id required"})
try:
parsed = int(value)
except (TypeError, ValueError) as error:
raise ValidationError({field: "Integer id required"}) from error
if parsed <= 0:
raise ValidationError({field: "Positive integer id required"})
return parsed
def _optional_query_id(value: str | None, field: str) -> int | None:
return None if value in (None, "") else _positive_id(value, field)
def _category_id(body: dict[str, object], current: Knowledge | None) -> int | None:
if "categoryId" in body:
return _positive_id(body["categoryId"], "category")
if "category" not in body:
return current.category_id if current else None
raw = body["category"]
if isinstance(raw, dict):
raw = raw.get("id")
return _positive_id(raw, "category")
def _department_ids(body: dict[str, object], current: Knowledge | None) -> tuple[int, ...]:
if "departmentIds" in body:
raw_items = body["departmentIds"]
elif "departments" in body:
raw_items = body["departments"]
elif current is not None:
return tuple(
current.department_links.values_list("department_id", flat=True)
)
else:
return ()
if not isinstance(raw_items, list):
raise ValidationError({"departments": "List of department ids required"})
normalized: list[int] = []
for item in raw_items:
raw_id = item.get("id") if isinstance(item, dict) else item
department_id = _positive_id(raw_id, "departments")
if department_id not in normalized:
normalized.append(department_id)
return tuple(normalized)
def knowledge_input(
body: dict[str, object], *, current: Knowledge | None = None
) -> KnowledgeInput:
raw_enabled = body.get("isEnabled", current.is_enabled if current else True)
if not isinstance(raw_enabled, bool):
raise ValidationError({"isEnabled": "Boolean required"})
visibility = str(
body.get(
"visibility",
current.visibility if current else KnowledgeVisibility.ORGANIZATION,
)
)
if visibility not in KnowledgeVisibility.values:
raise ValidationError({"visibility": "Unknown knowledge visibility"})
department_ids = _department_ids(body, current)
if (
visibility == KnowledgeVisibility.ORGANIZATION
and "departmentIds" not in body
and "departments" not in body
):
department_ids = ()
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=raw_enabled,
category_id=_category_id(body, current),
visibility=visibility,
department_ids=department_ids,
)
def knowledge_filters(request: Request) -> KnowledgeFilters:
visibility = request.query_params.get("visibility") or None
if visibility is not None and visibility not in KnowledgeVisibility.values:
raise ValidationError({"visibility": "Unknown knowledge visibility"})
raw_enabled = request.query_params.get("isEnabled")
if raw_enabled in (None, ""):
is_enabled = None
elif raw_enabled.lower() == "true":
is_enabled = True
elif raw_enabled.lower() == "false":
is_enabled = False
else:
raise ValidationError({"isEnabled": "Boolean required"})
return KnowledgeFilters(
category_id=_optional_query_id(
request.query_params.get("category"), "category"
),
department_id=_optional_query_id(
request.query_params.get("department"), "department"
),
visibility=visibility,
is_enabled=is_enabled,
search=request.query_params.get("search", ""),
)
@@ -92,6 +92,33 @@ def move_category(
return locked
@transaction.atomic
def update_category(
*,
context: TenantContext,
category: KnowledgeCategory,
name: str,
parent: KnowledgeCategory | None,
sort_order: int,
) -> KnowledgeCategory:
_validate_category_context(context=context, category=category)
_validate_parent(context=context, parent=parent)
if category.is_system:
raise ValidationError({"category": "System category is immutable"})
locked_categories = {
item.pk: item
for item in KnowledgeCategory.objects.select_for_update().filter(
organization_id=context.organization_id
)
}
locked = locked_categories[category.pk]
locked.name = name
locked.parent = locked_categories[parent.pk] if parent is not None else None
locked.sort_order = sort_order
locked.save(update_fields=["name", "parent", "sort_order"])
return locked
@transaction.atomic
def delete_category(*, context: TenantContext, category: KnowledgeCategory) -> None:
_validate_category_context(context=context, category=category)
@@ -143,6 +143,11 @@ def require_knowledge_create(
raise PermissionDenied("Knowledge scope is not manageable")
def require_category_manage(*, context: TenantContext) -> None:
if not employee_can_manage_categories(context=context):
raise PermissionDenied("Organization-scoped ai.manage is required")
def knowledge_is_available_to_agent(
*, knowledge: Knowledge, agent: AIAgent
) -> bool:
@@ -0,0 +1,195 @@
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.extraction import extract_text
from hub_platform.ai.indexing import reindex_knowledge
from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
from hub_platform.ai.knowledge_types import KnowledgeVisibility
from hub_platform.ai.knowledge_visibility import replace_knowledge_visibility
from hub_platform.ai.models import (
Knowledge,
KnowledgeAttachment,
KnowledgeCategory,
)
from hub_platform.tenancy.context import TenantContext
from hub_platform.tenancy.storage import adjust_storage_usage
from hub_platform.tenancy.storage_quota import (
finalize_storage,
release_storage,
reserve_storage,
)
@dataclass(frozen=True)
class KnowledgeInput:
title: str
description: str
content: str
is_enabled: bool
category_id: int | None = None
visibility: str | None = None
department_ids: tuple[int, ...] | None = None
def _knowledge_category(
*, context: TenantContext, category_id: int | None
) -> KnowledgeCategory:
if category_id is None:
return ensure_uncategorized_category(context.organization)
try:
return KnowledgeCategory.objects.get(
organization_id=context.organization_id,
id=category_id,
)
except KnowledgeCategory.DoesNotExist as error:
raise ValidationError({"category": "Category not found"}) from error
@transaction.atomic
def create_knowledge(*, context: TenantContext, data: KnowledgeInput) -> Knowledge:
if not data.title.strip():
raise ValidationError({"title": "Title is required"})
visibility = data.visibility or KnowledgeVisibility.ORGANIZATION
department_ids = data.department_ids or ()
knowledge = Knowledge.objects.create(
organization=context.organization,
category=_knowledge_category(context=context, category_id=data.category_id),
title=data.title.strip(),
description=data.description.strip(),
content=data.content,
is_enabled=data.is_enabled,
visibility=visibility,
)
knowledge = replace_knowledge_visibility(
context=context,
knowledge=knowledge,
visibility=visibility,
department_ids=department_ids,
)
reindex_knowledge(knowledge)
return knowledge
@transaction.atomic
def update_knowledge(
*, context: TenantContext, knowledge: Knowledge, data: KnowledgeInput
) -> Knowledge:
if knowledge.organization_id != context.organization_id:
raise ValidationError({"knowledge": "Knowledge belongs to another organization"})
if not data.title.strip():
raise ValidationError({"title": "Title is required"})
locked = Knowledge.objects.select_for_update().get(pk=knowledge.pk)
content_changed = locked.content != data.content
locked.title = data.title.strip()
locked.description = data.description.strip()
locked.content = data.content
locked.is_enabled = data.is_enabled
if data.category_id is not None:
locked.category = _knowledge_category(
context=context,
category_id=data.category_id,
)
locked.save(
update_fields=[
"title",
"description",
"content",
"is_enabled",
"category",
"updated_at",
]
)
if data.visibility is not None or data.department_ids is not None:
locked = replace_knowledge_visibility(
context=context,
knowledge=locked,
visibility=data.visibility or locked.visibility,
department_ids=(
data.department_ids
if data.department_ids is not None
else tuple(
locked.department_links.values_list("department_id", flat=True)
)
),
)
if content_changed:
reindex_knowledge(locked)
return locked
def delete_knowledge(*, context: TenantContext, knowledge: Knowledge) -> None:
if knowledge.organization_id != context.organization_id:
raise ValidationError({"knowledge": "Knowledge belongs to another organization"})
attachments = list(knowledge.attachments.all())
released_bytes = sum(attachment.size for attachment in attachments)
for attachment in attachments:
attachment.file.delete(save=False)
knowledge.delete()
if released_bytes:
adjust_storage_usage(context=context, delta_bytes=-released_bytes)
_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
@transaction.atomic
def add_attachment(
*, context: TenantContext, knowledge: Knowledge, upload: UploadedFile
) -> KnowledgeAttachment:
if knowledge.organization_id != context.organization_id:
raise ValidationError({"knowledge": "Knowledge belongs to another organization"})
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)"})
existing = knowledge.attachments.filter(original_name=original_name).first()
existing_size = existing.size if existing is not None else 0
data = upload.read()
reservation_key = f"attachment:{knowledge.id}:{original_name}"
reserve_storage(
context=context, expected_bytes=len(data), idempotency_key=reservation_key
)
try:
if existing is not None:
existing.file.delete(save=False)
existing.delete()
if existing_size:
adjust_storage_usage(context=context, delta_bytes=-existing_size)
content_type = upload.content_type or ""
attachment = KnowledgeAttachment(
organization=context.organization,
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)
except Exception:
release_storage(context=context, idempotency_key=reservation_key)
raise
finalize_storage(
context=context, idempotency_key=reservation_key, actual_bytes=len(data)
)
reindex_knowledge(knowledge)
return attachment
def delete_attachment(*, context: TenantContext, attachment: KnowledgeAttachment) -> None:
if attachment.knowledge.organization_id != context.organization_id:
raise ValidationError({"attachment": "Attachment belongs to another organization"})
knowledge = attachment.knowledge
released_bytes = attachment.size
attachment.file.delete(save=False)
attachment.delete()
if released_bytes:
adjust_storage_usage(context=context, delta_bytes=-released_bytes)
reindex_knowledge(knowledge)
+9 -2
View File
@@ -1,11 +1,12 @@
from dataclasses import dataclass
from django.db.models import Count, Q, QuerySet
from django.db.models import Count, Prefetch, Q, QuerySet
from hub_platform.ai.knowledge_policy import readable_knowledge, writable_knowledge
from hub_platform.ai.knowledge_types import KnowledgeVisibility
from hub_platform.ai.models import AIAgent, Knowledge, KnowledgeCategory
from hub_platform.channels.models import Channel
from hub_platform.identity.models import Department
from hub_platform.identity.policy import accessible_department_ids
from hub_platform.tenancy.context import TenantContext
@@ -67,7 +68,13 @@ def _knowledge_base(context: TenantContext) -> QuerySet[Knowledge]:
def _with_knowledge_relations(queryset: QuerySet[Knowledge]) -> QuerySet[Knowledge]:
return (
queryset.select_related("category")
.prefetch_related("attachments", "departments")
.prefetch_related(
"attachments",
Prefetch(
"departments",
queryset=Department.objects.order_by("name", "id"),
),
)
.annotate(agents_count=Count("agents", distinct=True))
.order_by("title")
)
+38 -1
View File
@@ -1,4 +1,10 @@
from hub_platform.ai.models import AIAgent, Knowledge, KnowledgeAttachment
from hub_platform.ai.models import (
AIAgent,
Knowledge,
KnowledgeAttachment,
KnowledgeCategory,
)
from hub_platform.identity.models import Department
def _channel_ref(channel) -> dict[str, object]:
@@ -23,11 +29,42 @@ def attachment_payload(attachment: KnowledgeAttachment) -> dict[str, object]:
}
def category_ref_payload(category: KnowledgeCategory) -> dict[str, object]:
return {
"id": category.id,
"name": category.name,
"parentId": category.parent_id,
}
def category_payload(category: KnowledgeCategory) -> dict[str, object]:
return {
**category_ref_payload(category),
"sortOrder": category.sort_order,
"isSystem": category.is_system,
"knowledgeCount": getattr(category, "knowledge_count", None),
}
def department_ref_payload(department: Department) -> dict[str, object]:
return {
"id": department.id,
"code": department.code,
"name": department.name,
}
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,
"category": category_ref_payload(knowledge.category),
"visibility": knowledge.visibility,
"departments": [
department_ref_payload(department)
for department in knowledge.departments.all()
],
"isEnabled": knowledge.is_enabled,
"attachments": [attachment_payload(attachment) for attachment in knowledge.attachments.all()],
"agentsCount": getattr(knowledge, "agents_count", None),
+8 -134
View File
@@ -1,27 +1,16 @@
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.extraction import extract_text
from hub_platform.ai.indexing import reindex_knowledge
from hub_platform.ai.knowledge_categories import ensure_uncategorized_category
from hub_platform.ai.models import (
AIAgent,
AIAgentStatus,
Knowledge,
KnowledgeAttachment,
)
from hub_platform.ai.provider_selection import configure_agent_provider
from hub_platform.channels.models import Channel
from hub_platform.tenancy.context import TenantContext
from hub_platform.tenancy.storage import adjust_storage_usage
from hub_platform.tenancy.storage_quota import (
finalize_storage,
release_storage,
reserve_storage,
)
@dataclass(frozen=True)
@@ -153,126 +142,11 @@ def set_agent_active(*, context: TenantContext, agent: AIAgent, is_active: bool)
)
# --- Знания (ADR-HUB-0023) ---
@dataclass(frozen=True)
class KnowledgeInput:
title: str
description: str
content: str
is_enabled: bool
def create_knowledge(*, context: TenantContext, data: KnowledgeInput) -> Knowledge:
if not data.title.strip():
raise ValidationError({"title": "Title is required"})
knowledge = Knowledge.objects.create(
organization=context.organization,
category=ensure_uncategorized_category(context.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(*, context: TenantContext, knowledge: Knowledge, data: KnowledgeInput) -> Knowledge:
if knowledge.organization_id != context.organization_id:
raise ValidationError({"knowledge": "Knowledge belongs to another organization"})
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(*, context: TenantContext, knowledge: Knowledge) -> None:
if knowledge.organization_id != context.organization_id:
raise ValidationError({"knowledge": "Knowledge belongs to another organization"})
# Файлы вложений удаляются вместе со знанием: сначала с диска, потом запись.
attachments = list(knowledge.attachments.all())
released_bytes = sum(attachment.size for attachment in attachments)
for attachment in attachments:
attachment.file.delete(save=False)
knowledge.delete()
if released_bytes:
adjust_storage_usage(context=context, delta_bytes=-released_bytes)
_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
@transaction.atomic
def add_attachment(
*, context: TenantContext, knowledge: Knowledge, upload: UploadedFile
) -> KnowledgeAttachment:
if knowledge.organization_id != context.organization_id:
raise ValidationError({"knowledge": "Knowledge belongs to another organization"})
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()
existing_size = existing.size if existing is not None else 0
data = upload.read()
# C07 storage gate (SPEC §10): reserve the expected size before the write so a
# concurrent upload cannot exceed storage_bytes; finalize with the actual size
# after the object is persisted, or release on failure. Reads are never gated.
# Deterministic key makes a retried upload of the same file idempotent; a prior
# finalized/released reservation is ignored by the active-reservation filter.
reservation_key = f"attachment:{knowledge.id}:{original_name}"
reserve_storage(
context=context, expected_bytes=len(data), idempotency_key=reservation_key
)
try:
if existing is not None:
existing.file.delete(save=False)
existing.delete()
if existing_size:
adjust_storage_usage(context=context, delta_bytes=-existing_size)
content_type = upload.content_type or ""
attachment = KnowledgeAttachment(
organization=context.organization,
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)
except Exception:
# Upload failed after reservation: release the reserved bytes (SPEC §10.4).
release_storage(context=context, idempotency_key=reservation_key)
raise
finalize_storage(
context=context, idempotency_key=reservation_key, actual_bytes=len(data)
)
reindex_knowledge(knowledge)
return attachment
def delete_attachment(*, context: TenantContext, attachment: KnowledgeAttachment) -> None:
if attachment.knowledge.organization_id != context.organization_id:
raise ValidationError({"attachment": "Attachment belongs to another organization"})
knowledge = attachment.knowledge
released_bytes = attachment.size
attachment.file.delete(save=False)
attachment.delete()
if released_bytes:
adjust_storage_usage(context=context, delta_bytes=-released_bytes)
reindex_knowledge(knowledge)
from hub_platform.ai.knowledge_services import ( # noqa: E402, F401
KnowledgeInput,
add_attachment,
create_knowledge,
delete_attachment,
delete_knowledge,
update_knowledge,
)
@@ -0,0 +1,170 @@
import json
from hub_platform.ai.knowledge_categories import create_category
from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
from hub_platform.ai.knowledge_types import KnowledgeVisibility
from hub_platform.ai.models import Knowledge
from hub_platform.identity.models import Department, DepartmentStatus
from hub_platform.subscriptions.testing import create_test_subscription
from hub_platform.testing import TenantAPIClient
class HierarchicalKnowledgeApiTests(KnowledgePolicyTestBase):
def setUp(self) -> None:
super().setUp()
create_test_subscription(self.organization)
self.client = TenantAPIClient()
self.client.force_authenticate(self.organization_manager.user)
self.guides = create_category(
context=self.system_context,
parent=self.products,
name="Guides",
)
self.disabled_department = Department.objects.create(
organization=self.organization,
code="disabled-api",
name="Disabled",
status=DepartmentStatus.DISABLED,
)
def _post(self, payload: dict[str, object]):
return self.client.post(
"/api/v1/ai/knowledge/",
data=json.dumps(payload),
content_type="application/json",
)
def test_create_and_detail_return_complete_scope_payload(self) -> None:
response = self._post(
{
"title": "Scoped guide",
"description": "For two teams",
"content": "Guide content",
"categoryId": self.guides.id,
"visibility": KnowledgeVisibility.DEPARTMENTS,
"departmentIds": [self.support.id, self.sales.id],
}
)
self.assertEqual(response.status_code, 201)
payload = response.json()["knowledge"]
self.assertEqual(
payload["category"],
{"id": self.guides.id, "name": "Guides", "parentId": self.products.id},
)
self.assertEqual(payload["visibility"], KnowledgeVisibility.DEPARTMENTS)
self.assertEqual(
[department["id"] for department in payload["departments"]],
[self.sales.id, self.support.id],
)
detail = self.client.get(f"/api/v1/ai/knowledge/{payload['id']}/")
self.assertEqual(detail.json()["knowledge"]["content"], "Guide content")
def test_update_scope_category_and_metadata_is_atomic(self) -> None:
knowledge = self.sales_only
response = self.client.patch(
f"/api/v1/ai/knowledge/{knowledge.id}/",
data=json.dumps(
{
"title": "Moved playbook",
"category": {"id": self.guides.id},
"visibility": KnowledgeVisibility.DEPARTMENTS,
"departments": [{"id": self.sales.id}, {"id": self.support.id}],
}
),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
payload = response.json()["knowledge"]
self.assertEqual(payload["category"]["id"], self.guides.id)
self.assertEqual(
{item["id"] for item in payload["departments"]},
{self.sales.id, self.support.id},
)
invalid = self.client.patch(
f"/api/v1/ai/knowledge/{knowledge.id}/",
data=json.dumps(
{
"title": "Must roll back",
"departmentIds": [self.disabled_department.id],
}
),
content_type="application/json",
)
self.assertEqual(invalid.status_code, 400)
knowledge.refresh_from_db()
self.assertEqual(knowledge.title, "Moved playbook")
self.assertEqual(
set(knowledge.departments.values_list("id", flat=True)),
{self.sales.id, self.support.id},
)
organization_scope = self.client.patch(
f"/api/v1/ai/knowledge/{knowledge.id}/",
data=json.dumps({"visibility": KnowledgeVisibility.ORGANIZATION}),
content_type="application/json",
)
self.assertEqual(organization_scope.status_code, 200)
self.assertEqual(
organization_scope.json()["knowledge"]["departments"], []
)
def test_department_manager_can_create_own_scope_but_cannot_expand_it(self) -> None:
self.client.force_authenticate(self.sales_employee.user)
created = self._post(
{
"title": "Sales only API",
"categoryId": self.guides.id,
"visibility": KnowledgeVisibility.DEPARTMENTS,
"departmentIds": [self.sales.id],
}
)
self.assertEqual(created.status_code, 201)
knowledge_id = created.json()["knowledge"]["id"]
denied = self.client.patch(
f"/api/v1/ai/knowledge/{knowledge_id}/",
data=json.dumps({"departmentIds": [self.sales.id, self.support.id]}),
content_type="application/json",
)
self.assertEqual(denied.status_code, 403)
self.assertEqual(
set(
Knowledge.objects.get(id=knowledge_id).departments.values_list(
"id", flat=True
)
),
{self.sales.id},
)
def test_list_filters_category_department_visibility_and_status(self) -> None:
created = self._post(
{
"title": "Disabled filtered guide",
"description": "Unique filter phrase",
"categoryId": self.guides.id,
"visibility": KnowledgeVisibility.DEPARTMENTS,
"departmentIds": [self.sales.id],
"isEnabled": False,
}
).json()["knowledge"]
response = self.client.get(
"/api/v1/ai/knowledge/",
{
"category": self.guides.id,
"department": self.sales.id,
"visibility": KnowledgeVisibility.DEPARTMENTS,
"isEnabled": "false",
"search": "unique filter",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(
[item["id"] for item in response.json()["items"]], [created["id"]]
)
invalid = self.client.get(
"/api/v1/ai/knowledge/", {"visibility": "PUBLIC"}
)
self.assertEqual(invalid.status_code, 400)
@@ -0,0 +1,109 @@
import json
from hub_platform.ai.knowledge_policy_test_base import KnowledgePolicyTestBase
from hub_platform.ai.models import KnowledgeCategory
from hub_platform.subscriptions.testing import create_test_subscription
from hub_platform.testing import TenantAPIClient
class KnowledgeCategoryApiTests(KnowledgePolicyTestBase):
def setUp(self) -> None:
super().setUp()
create_test_subscription(self.organization)
self.client = TenantAPIClient()
self.client.force_authenticate(self.organization_manager.user)
def _create(
self, name: str, *, parent_id: int | None = None, sort_order: int = 0
):
payload = {"name": name, "sortOrder": sort_order}
if parent_id is not None:
payload["parentId"] = parent_id
return self.client.post(
"/api/v1/ai/knowledge/categories/",
data=json.dumps(payload),
content_type="application/json",
)
def test_tree_is_preordered_and_counts_only_authorized_knowledge(self) -> None:
child = self._create("Child", parent_id=self.products.id).json()["category"]
response = self.client.get("/api/v1/ai/knowledge/categories/")
self.assertEqual(response.status_code, 200)
items = response.json()["items"]
ids = [item["id"] for item in items]
self.assertLess(ids.index(self.products.id), ids.index(child["id"]))
products = next(item for item in items if item["id"] == self.products.id)
self.assertEqual(products["knowledgeCount"], 5)
self.client.force_authenticate(self.sales_employee.user)
restricted = self.client.get("/api/v1/ai/knowledge/categories/")
products = next(
item
for item in restricted.json()["items"]
if item["id"] == self.products.id
)
self.assertEqual(products["knowledgeCount"], 4)
denied = self._create("Forbidden")
self.assertEqual(denied.status_code, 403)
def test_create_update_move_reorder_and_cycle_validation(self) -> None:
root = self._create("Root", sort_order=10)
self.assertEqual(root.status_code, 201)
root_id = root.json()["category"]["id"]
child_id = self._create("Child", parent_id=root_id).json()["category"]["id"]
updated = self.client.patch(
f"/api/v1/ai/knowledge/categories/{child_id}/",
data=json.dumps(
{"name": "Renamed", "parentId": None, "sortOrder": 25}
),
content_type="application/json",
)
self.assertEqual(updated.status_code, 200)
self.assertEqual(updated.json()["category"]["name"], "Renamed")
self.assertIsNone(updated.json()["category"]["parentId"])
self.assertEqual(updated.json()["category"]["sortOrder"], 25)
self.client.patch(
f"/api/v1/ai/knowledge/categories/{child_id}/",
data=json.dumps({"parentId": root_id}),
content_type="application/json",
)
cycle = self.client.patch(
f"/api/v1/ai/knowledge/categories/{root_id}/",
data=json.dumps({"parentId": child_id}),
content_type="application/json",
)
self.assertEqual(cycle.status_code, 400)
self.assertIsNone(KnowledgeCategory.objects.get(id=root_id).parent_id)
def test_delete_is_limited_to_empty_non_system_leaf(self) -> None:
empty_id = self._create("Empty").json()["category"]["id"]
deleted = self.client.delete(
f"/api/v1/ai/knowledge/categories/{empty_id}/"
)
self.assertEqual(deleted.status_code, 204)
nonempty = self.client.delete(
f"/api/v1/ai/knowledge/categories/{self.products.id}/"
)
self.assertEqual(nonempty.status_code, 400)
system = KnowledgeCategory.objects.get(
organization=self.organization, is_system=True
)
protected = self.client.delete(
f"/api/v1/ai/knowledge/categories/{system.id}/"
)
self.assertEqual(protected.status_code, 400)
other_parent = KnowledgeCategory.objects.get(
organization=self.other_organization, is_system=True
)
cross_tenant = self.client.post(
"/api/v1/ai/knowledge/categories/",
data=json.dumps({"name": "Invalid", "parentId": other_parent.id}),
content_type="application/json",
)
self.assertEqual(cross_tenant.status_code, 400)
+11 -1
View File
@@ -1,6 +1,6 @@
from django.urls import path
from hub_platform.ai import agent_views, views
from hub_platform.ai import agent_views, category_views, views
urlpatterns = [
path("agents/", agent_views.AIAgentListView.as_view(), name="ai-agent-list"),
@@ -9,6 +9,16 @@ urlpatterns = [
path("agents/<int:agent_id>/activate/", agent_views.AIAgentActivateView.as_view(), name="ai-agent-activate"),
path("agents/<int:agent_id>/deactivate/", agent_views.AIAgentDeactivateView.as_view(), name="ai-agent-deactivate"),
path("knowledge/", views.KnowledgeListCreateView.as_view(), name="ai-knowledge-list"),
path(
"knowledge/categories/",
category_views.KnowledgeCategoryListCreateView.as_view(),
name="ai-knowledge-category-list",
),
path(
"knowledge/categories/<int:category_id>/",
category_views.KnowledgeCategoryDetailView.as_view(),
name="ai-knowledge-category-detail",
),
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"),
+31 -69
View File
@@ -4,19 +4,13 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from hub_platform.ai.models import Knowledge
from hub_platform.ai.knowledge_policy import require_knowledge_create
from hub_platform.ai.knowledge_types import KnowledgeVisibility
from hub_platform.ai.selectors import (
KnowledgeFilters,
apply_knowledge_filters,
knowledge_for_context,
knowledge_item_for_context,
writable_knowledge_for_employee,
writable_knowledge_item_for_employee,
from hub_platform.ai.api_errors import validation_error_response
from hub_platform.ai.knowledge_api_inputs import knowledge_filters, knowledge_input
from hub_platform.ai.knowledge_policy import (
employee_can_write_knowledge,
require_knowledge_create,
)
from hub_platform.ai.serializers import attachment_payload, knowledge_payload
from hub_platform.ai.services import (
from hub_platform.ai.knowledge_services import (
KnowledgeInput,
add_attachment,
create_knowledge,
@@ -24,62 +18,21 @@ from hub_platform.ai.services import (
delete_knowledge,
update_knowledge,
)
from hub_platform.ai.knowledge_types import KnowledgeVisibility
from hub_platform.ai.models import Knowledge
from hub_platform.ai.selectors import (
apply_knowledge_filters,
knowledge_for_context,
knowledge_item_for_context,
writable_knowledge_for_employee,
writable_knowledge_item_for_employee,
)
from hub_platform.ai.serializers import attachment_payload, knowledge_payload
from hub_platform.api.permissions import HasCapability, HasEntitlement
from hub_platform.identity.audit import record_audit_event
def _validation_error(error: ValidationError) -> Response:
detail = "; ".join(message for messages in error.message_dict.values() for message in messages) if hasattr(error, "message_dict") else "; ".join(error.messages)
return Response({"detail": detail}, status=400)
# --- Знания (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),
)
def _optional_id(value: str | None, field: str) -> int | None:
if value in (None, ""):
return None
try:
parsed = int(value)
except (TypeError, ValueError) as error:
raise ValidationError({field: "Integer id required"}) from error
if parsed <= 0:
raise ValidationError({field: "Positive integer id required"})
return parsed
def _knowledge_filters(request: Request) -> KnowledgeFilters:
visibility = request.query_params.get("visibility") or None
if visibility is not None and visibility not in KnowledgeVisibility.values:
raise ValidationError({"visibility": "Unknown knowledge visibility"})
raw_enabled = request.query_params.get("isEnabled")
if raw_enabled in (None, ""):
is_enabled = None
elif raw_enabled.lower() == "true":
is_enabled = True
elif raw_enabled.lower() == "false":
is_enabled = False
else:
raise ValidationError({"isEnabled": "Boolean required"})
return KnowledgeFilters(
category_id=_optional_id(request.query_params.get("category"), "category"),
department_id=_optional_id(
request.query_params.get("department"), "department"
),
visibility=visibility,
is_enabled=is_enabled,
search=request.query_params.get("search", ""),
)
_validation_error = validation_error_response
class _KnowledgeBaseView(APIView):
@@ -118,7 +71,7 @@ class _KnowledgeBaseView(APIView):
class KnowledgeListCreateView(_KnowledgeBaseView):
def get(self, request: Request) -> Response:
try:
filters = _knowledge_filters(request)
filters = knowledge_filters(request)
except ValidationError as error:
return _validation_error(error)
items = apply_knowledge_filters(
@@ -128,14 +81,15 @@ class KnowledgeListCreateView(_KnowledgeBaseView):
def post(self, request: Request) -> Response:
try:
data = knowledge_input(request.data)
require_knowledge_create(
context=request.tenant_context,
visibility=KnowledgeVisibility.ORGANIZATION,
department_ids=[],
visibility=data.visibility or KnowledgeVisibility.ORGANIZATION,
department_ids=data.department_ids or (),
)
knowledge = create_knowledge(
context=request.tenant_context,
data=_knowledge_input(request.data),
data=data,
)
except ValidationError as error:
return _validation_error(error)
@@ -158,10 +112,18 @@ class KnowledgeDetailView(_KnowledgeBaseView):
except Knowledge.DoesNotExist:
return Response({"detail": "Knowledge not found"}, status=404)
try:
data = knowledge_input(request.data, current=knowledge)
if not employee_can_write_knowledge(
context=request.tenant_context,
knowledge=knowledge,
visibility=data.visibility,
department_ids=data.department_ids,
):
raise PermissionDenied("Knowledge scope is not manageable")
knowledge = update_knowledge(
context=request.tenant_context,
knowledge=knowledge,
data=_knowledge_input(request.data, current=knowledge),
data=data,
)
except ValidationError as error:
return _validation_error(error)