diff --git a/apps/backend/hub_platform/ai/api_errors.py b/apps/backend/hub_platform/ai/api_errors.py new file mode 100644 index 0000000..a36e089 --- /dev/null +++ b/apps/backend/hub_platform/ai/api_errors.py @@ -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) diff --git a/apps/backend/hub_platform/ai/category_views.py b/apps/backend/hub_platform/ai/category_views.py new file mode 100644 index 0000000..eca54ca --- /dev/null +++ b/apps/backend/hub_platform/ai/category_views.py @@ -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) diff --git a/apps/backend/hub_platform/ai/knowledge_api_inputs.py b/apps/backend/hub_platform/ai/knowledge_api_inputs.py new file mode 100644 index 0000000..8b4e19f --- /dev/null +++ b/apps/backend/hub_platform/ai/knowledge_api_inputs.py @@ -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", ""), + ) diff --git a/apps/backend/hub_platform/ai/knowledge_categories.py b/apps/backend/hub_platform/ai/knowledge_categories.py index a736fae..30f7dec 100644 --- a/apps/backend/hub_platform/ai/knowledge_categories.py +++ b/apps/backend/hub_platform/ai/knowledge_categories.py @@ -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) diff --git a/apps/backend/hub_platform/ai/knowledge_policy.py b/apps/backend/hub_platform/ai/knowledge_policy.py index 689cd45..695adad 100644 --- a/apps/backend/hub_platform/ai/knowledge_policy.py +++ b/apps/backend/hub_platform/ai/knowledge_policy.py @@ -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: diff --git a/apps/backend/hub_platform/ai/knowledge_services.py b/apps/backend/hub_platform/ai/knowledge_services.py new file mode 100644 index 0000000..36a4482 --- /dev/null +++ b/apps/backend/hub_platform/ai/knowledge_services.py @@ -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) diff --git a/apps/backend/hub_platform/ai/selectors.py b/apps/backend/hub_platform/ai/selectors.py index ec2cd63..9d5a710 100644 --- a/apps/backend/hub_platform/ai/selectors.py +++ b/apps/backend/hub_platform/ai/selectors.py @@ -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") ) diff --git a/apps/backend/hub_platform/ai/serializers.py b/apps/backend/hub_platform/ai/serializers.py index f0a17ad..209c330 100644 --- a/apps/backend/hub_platform/ai/serializers.py +++ b/apps/backend/hub_platform/ai/serializers.py @@ -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), diff --git a/apps/backend/hub_platform/ai/services.py b/apps/backend/hub_platform/ai/services.py index 131cd44..38b4fde 100644 --- a/apps/backend/hub_platform/ai/services.py +++ b/apps/backend/hub_platform/ai/services.py @@ -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, +) diff --git a/apps/backend/hub_platform/ai/test_knowledge_api_v2.py b/apps/backend/hub_platform/ai/test_knowledge_api_v2.py new file mode 100644 index 0000000..4dfe887 --- /dev/null +++ b/apps/backend/hub_platform/ai/test_knowledge_api_v2.py @@ -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) diff --git a/apps/backend/hub_platform/ai/test_knowledge_category_api.py b/apps/backend/hub_platform/ai/test_knowledge_category_api.py new file mode 100644 index 0000000..560c182 --- /dev/null +++ b/apps/backend/hub_platform/ai/test_knowledge_category_api.py @@ -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) diff --git a/apps/backend/hub_platform/ai/urls.py b/apps/backend/hub_platform/ai/urls.py index bd664a4..b5402d1 100644 --- a/apps/backend/hub_platform/ai/urls.py +++ b/apps/backend/hub_platform/ai/urls.py @@ -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//activate/", agent_views.AIAgentActivateView.as_view(), name="ai-agent-activate"), path("agents//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//", + category_views.KnowledgeCategoryDetailView.as_view(), + name="ai-knowledge-category-detail", + ), path("knowledge/import/", views.KnowledgeImportView.as_view(), name="ai-knowledge-import"), path("knowledge//", views.KnowledgeDetailView.as_view(), name="ai-knowledge-detail"), path("knowledge//attachments/", views.KnowledgeAttachmentUploadView.as_view(), name="ai-knowledge-attachment-upload"), diff --git a/apps/backend/hub_platform/ai/views.py b/apps/backend/hub_platform/ai/views.py index bf54f80..ecf3d4b 100644 --- a/apps/backend/hub_platform/ai/views.py +++ b/apps/backend/hub_platform/ai/views.py @@ -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)