from django.db import transaction from django.http import HttpResponse from django.utils.http import content_disposition_header from ninja import File, Router from ninja.errors import HttpError from ninja.files import UploadedFile from apps.accounts.api import require_permission from apps.knowledge.documents import create_uploaded_document, prepare_document from apps.knowledge.embeddings import KnowledgeEmbeddingError from apps.knowledge.governance import ( AssetGovernanceError, approve_asset_version, compare_asset_versions, create_asset_revision, ensure_governed_asset, offline_asset, reject_asset_version, rollback_asset, submit_asset_version, update_asset_version, ) from apps.knowledge.models import ( GovernedAsset, GovernedAssetType, GovernedAssetVersion, KnowledgeBase, KnowledgeDocument, KnowledgeItem, TroubleshootingPlan, ) from apps.knowledge.schemas import ( AssetImpactOut, AssetReviewIn, AssetRevisionIn, AssetRollbackIn, AssetVersionComparisonOut, AssetVersionUpdateIn, GovernedAssetOut, GovernedAssetVersionOut, KnowledgeBaseIn, KnowledgeBaseOut, KnowledgeDocumentIn, KnowledgeDocumentOut, KnowledgeItemIn, KnowledgeItemOut, KnowledgeSearchIn, KnowledgeSearchResultOut, TroubleshootingPlanIn, TroubleshootingPlanOut, ) from apps.knowledge.services import ingest_document_text, retrieve_knowledge from apps.knowledge.tasks import dispatch_knowledge_document_processing from apps.solutions.models import TicketSolutionLink from apps.tickets.materials import MaterialValidationError from apps.tickets.models import TicketCategory, TicketSupportAnalysis from apps.tickets.storage import get_object router = Router(tags=["knowledge"]) def serialize_asset_version(version: GovernedAssetVersion) -> dict: return { "id": version.id, "version": version.version, "status": version.status, "content": version.content, "change_summary": version.change_summary, "source_version": version.source_version.version if version.source_version_id else None, "created_by_id": version.created_by_id, "submitted_by_id": version.submitted_by_id, "reviewed_by_id": version.reviewed_by_id, "review_comment": version.review_comment, "created_at": version.created_at, "updated_at": version.updated_at, "submitted_at": version.submitted_at, "reviewed_at": version.reviewed_at, "published_at": version.published_at, "offline_at": version.offline_at, } def serialize_governed_asset(asset: GovernedAsset) -> dict: versions = list(asset.versions.select_related("source_version").all()) return { "public_id": asset.public_id, "asset_type": asset.asset_type, "object_id": asset.object_id, "current_published_version": ( asset.current_published_version.version if asset.current_published_version_id else None ), "versions": [serialize_asset_version(version) for version in versions], "created_at": asset.created_at, "updated_at": asset.updated_at, } def require_asset_permission(request, asset_type: str, action: str) -> None: module = "solution" if asset_type == GovernedAssetType.SOLUTION else "knowledge" require_permission(request, f"{module}.{action}") @router.post("/bases", response=KnowledgeBaseOut) def create_knowledge_base(request, payload: KnowledgeBaseIn): require_permission(request, "knowledge.manage") return KnowledgeBase.objects.create( name=payload.name, description=payload.description, scope=payload.scope, enabled=payload.enabled, created_by=request.user if request.user.is_authenticated else None, ) @router.get("/bases", response=list[KnowledgeBaseOut]) def list_knowledge_bases(request, enabled: bool | None = None): require_permission(request, "knowledge.view") queryset = KnowledgeBase.objects.all() if enabled is not None: queryset = queryset.filter(enabled=enabled) return queryset[:100] @router.post("/items", response=KnowledgeItemOut) def create_knowledge_item(request, payload: KnowledgeItemIn): require_permission(request, "knowledge.manage") category_code = payload.category_code.strip() if category_code and not TicketCategory.objects.filter( code=category_code, enabled=True, ).exists(): raise HttpError(400, "知识适用分类不存在或已停用") try: with transaction.atomic(): item = KnowledgeItem.objects.create( knowledge_base_id=payload.knowledge_base_id, title=payload.title, problem=payload.problem, applicable_scope=payload.applicable_scope, category_code=category_code, related_system=payload.related_system, environment_scope=payload.environment_scope, error_code=payload.error_code, root_cause=payload.root_cause, troubleshooting_steps=payload.troubleshooting_steps, solution=payload.solution, risk_note=payload.risk_note, status="draft", created_by=request.user if request.user.is_authenticated else None, ) ensure_governed_asset( GovernedAssetType.KNOWLEDGE_ITEM, item, actor=request.user, ) except KnowledgeEmbeddingError as error: raise HttpError(503, str(error)) from error return item @router.post("/troubleshooting-plans", response=TroubleshootingPlanOut) def create_troubleshooting_plan(request, payload: TroubleshootingPlanIn): require_permission(request, "knowledge.manage") category_code = payload.category_code.strip() if category_code and not TicketCategory.objects.filter( code=category_code, enabled=True, ).exists(): raise HttpError(400, "排查方案适用分类不存在或已停用") with transaction.atomic(): plan = TroubleshootingPlan.objects.create( knowledge_base_id=payload.knowledge_base_id, title=payload.title, problem_pattern=payload.problem_pattern, applicable_scope=payload.applicable_scope, category_code=category_code, related_system=payload.related_system, environment_scope=payload.environment_scope, decision_rules=payload.decision_rules, troubleshooting_steps=payload.troubleshooting_steps, risk_note=payload.risk_note, source_ticket_id=payload.source_ticket_id, created_by=request.user, ) ensure_governed_asset( GovernedAssetType.TROUBLESHOOTING_PLAN, plan, actor=request.user, ) return plan @router.get("/troubleshooting-plans", response=list[TroubleshootingPlanOut]) def list_troubleshooting_plans(request, status: str | None = None): require_permission(request, "knowledge.view") queryset = TroubleshootingPlan.objects.all() if status: queryset = queryset.filter(status=status) return queryset[:100] @router.get("/assets", response=list[GovernedAssetOut]) def list_governed_assets( request, asset_type: str | None = None, status: str | None = None, ): if asset_type: require_asset_permission(request, asset_type, "view") else: require_permission(request, "knowledge.view") require_permission(request, "solution.view") queryset = GovernedAsset.objects.select_related("current_published_version") if asset_type: queryset = queryset.filter(asset_type=asset_type) if status: queryset = queryset.filter(versions__status=status).distinct() return [serialize_governed_asset(asset) for asset in queryset[:200]] @router.get("/assets/{public_id}", response=GovernedAssetOut) def get_governed_asset(request, public_id: str): asset = GovernedAsset.objects.select_related("current_published_version").filter( public_id=public_id ).first() if asset is None: raise HttpError(404, "知识资产不存在") require_asset_permission(request, asset.asset_type, "view") return serialize_governed_asset(asset) @router.post("/assets/{public_id}/revisions", response=GovernedAssetVersionOut) def create_governed_asset_revision( request, public_id: str, payload: AssetRevisionIn, ): asset = GovernedAsset.objects.filter(public_id=public_id).first() if asset is None: raise HttpError(404, "知识资产不存在") require_asset_permission(request, asset.asset_type, "manage") try: return serialize_asset_version( create_asset_revision( asset, actor=request.user, change_summary=payload.change_summary, ) ) except AssetGovernanceError as error: raise HttpError(400, str(error)) from error def _asset_version(public_id: str, version_number: int): return ( GovernedAssetVersion.objects.select_related("asset", "source_version") .filter(asset__public_id=public_id, version=version_number) .first() ) @router.patch( "/assets/{public_id}/versions/{version_number}", response=GovernedAssetVersionOut, ) def edit_governed_asset_version( request, public_id: str, version_number: int, payload: AssetVersionUpdateIn, ): version = _asset_version(public_id, version_number) if version is None: raise HttpError(404, "资产版本不存在") require_asset_permission(request, version.asset.asset_type, "manage") try: return serialize_asset_version( update_asset_version( version, payload.content, actor=request.user, change_summary=payload.change_summary, ) ) except AssetGovernanceError as error: raise HttpError(400, str(error)) from error @router.post( "/assets/{public_id}/versions/{version_number}/submit", response=GovernedAssetVersionOut, ) def submit_governed_asset_version(request, public_id: str, version_number: int): version = _asset_version(public_id, version_number) if version is None: raise HttpError(404, "资产版本不存在") require_asset_permission(request, version.asset.asset_type, "submit") try: return serialize_asset_version( submit_asset_version(version, actor=request.user) ) except AssetGovernanceError as error: raise HttpError(400, str(error)) from error @router.post( "/assets/{public_id}/versions/{version_number}/approve", response=GovernedAssetVersionOut, ) def approve_governed_asset_version( request, public_id: str, version_number: int, payload: AssetReviewIn, ): version = _asset_version(public_id, version_number) if version is None: raise HttpError(404, "资产版本不存在") require_asset_permission(request, version.asset.asset_type, "review") try: return serialize_asset_version( approve_asset_version( version, reviewer=request.user, comment=payload.comment, ) ) except AssetGovernanceError as error: raise HttpError(400, str(error)) from error @router.post( "/assets/{public_id}/versions/{version_number}/reject", response=GovernedAssetVersionOut, ) def reject_governed_asset_version( request, public_id: str, version_number: int, payload: AssetReviewIn, ): version = _asset_version(public_id, version_number) if version is None: raise HttpError(404, "资产版本不存在") require_asset_permission(request, version.asset.asset_type, "review") try: return serialize_asset_version( reject_asset_version( version, reviewer=request.user, comment=payload.comment, ) ) except AssetGovernanceError as error: raise HttpError(400, str(error)) from error @router.post("/assets/{public_id}/offline", response=GovernedAssetVersionOut) def offline_governed_asset( request, public_id: str, payload: AssetReviewIn, ): asset = GovernedAsset.objects.filter(public_id=public_id).first() if asset is None: raise HttpError(404, "知识资产不存在") require_asset_permission(request, asset.asset_type, "review") try: return serialize_asset_version( offline_asset(asset, actor=request.user, comment=payload.comment) ) except AssetGovernanceError as error: raise HttpError(400, str(error)) from error @router.post( "/assets/{public_id}/rollback", response=GovernedAssetVersionOut, ) def rollback_governed_asset( request, public_id: str, payload: AssetRollbackIn, ): asset = GovernedAsset.objects.filter(public_id=public_id).first() if asset is None: raise HttpError(404, "知识资产不存在") require_asset_permission(request, asset.asset_type, "manage") source = asset.versions.filter(version=payload.source_version).first() if source is None: raise HttpError(404, "回滚来源版本不存在") try: return serialize_asset_version( rollback_asset( asset, source, actor=request.user, change_summary=payload.change_summary, ) ) except AssetGovernanceError as error: raise HttpError(400, str(error)) from error @router.get( "/assets/{public_id}/compare", response=AssetVersionComparisonOut, ) def compare_governed_asset_versions( request, public_id: str, left: int, right: int, ): asset = GovernedAsset.objects.filter(public_id=public_id).first() if asset is None: raise HttpError(404, "知识资产不存在") require_asset_permission(request, asset.asset_type, "view") left_version = asset.versions.filter(version=left).first() right_version = asset.versions.filter(version=right).first() if left_version is None or right_version is None: raise HttpError(404, "比较版本不存在") return compare_asset_versions(left_version, right_version) @router.get("/assets/{public_id}/impact", response=AssetImpactOut) def get_governed_asset_impact(request, public_id: str): asset = GovernedAsset.objects.filter(public_id=public_id).first() if asset is None: raise HttpError(404, "知识资产不存在") require_asset_permission(request, asset.asset_type, "view") source_type = ( "solution" if asset.asset_type == GovernedAssetType.SOLUTION else "knowledge" ) analyses = [] for analysis in TicketSupportAnalysis.objects.select_related("run").all()[:1000]: if any( reference.get("source_type") == source_type and reference.get("source_id") == asset.object_id for reference in analysis.references ): analyses.append(analysis) ticket_reference_count = len({analysis.ticket_id for analysis in analyses}) adoption_count = ( TicketSolutionLink.objects.filter( solution_id=asset.object_id, link_type="adopted", ).count() if asset.asset_type == GovernedAssetType.SOLUTION else 0 ) return { "ticket_reference_count": ticket_reference_count, "ticket_adoption_count": adoption_count, "support_analysis_count": len(analyses), "agent_run_ids": [str(analysis.run.public_id) for analysis in analyses[:20]], } @router.get("/items", response=list[KnowledgeItemOut]) def list_knowledge_items( request, knowledge_base_id: int | None = None, status: str | None = None, ): require_permission(request, "knowledge.view") queryset = KnowledgeItem.objects.all() if knowledge_base_id: queryset = queryset.filter(knowledge_base_id=knowledge_base_id) if status: queryset = queryset.filter(status=status) return queryset[:100] @router.post("/documents", response=KnowledgeDocumentOut) def create_knowledge_document(request, payload: KnowledgeDocumentIn): require_permission(request, "knowledge.manage") try: with transaction.atomic(): return ingest_document_text( knowledge_base_id=payload.knowledge_base_id, file_name=payload.file_name, file_type=payload.file_type, content=payload.content, operator=request.user if request.user.is_authenticated else None, ) except KnowledgeEmbeddingError as error: raise HttpError(503, str(error)) from error @router.get("/documents", response=list[KnowledgeDocumentOut]) def list_knowledge_documents( request, knowledge_base_id: int | None = None, parse_status: str | None = None, ): require_permission(request, "knowledge.view") queryset = KnowledgeDocument.objects.all() if knowledge_base_id: queryset = queryset.filter(knowledge_base_id=knowledge_base_id) if parse_status: queryset = queryset.filter(parse_status=parse_status) return queryset[:100] @router.post("/documents/upload", response=KnowledgeDocumentOut) def upload_knowledge_document( request, knowledge_base_id: int, file: File[UploadedFile], ): require_permission(request, "knowledge.manage") if not KnowledgeBase.objects.filter(id=knowledge_base_id).exists(): raise HttpError(404, "知识库不存在") raw = file.read() try: prepared = prepare_document( raw, file.name or "document", file.content_type or "", ) except MaterialValidationError as error: raise HttpError(error.status_code, str(error)) from error try: document = create_uploaded_document( knowledge_base_id=knowledge_base_id, prepared=prepared, operator=request.user if request.user.is_authenticated else None, ) except Exception as error: raise HttpError(503, "知识文档存储暂时不可用") from error dispatch_knowledge_document_processing(document.id) document.refresh_from_db() return document @router.post("/documents/{public_id}/retry", response=KnowledgeDocumentOut) def retry_knowledge_document(request, public_id: str): require_permission(request, "knowledge.manage") document = KnowledgeDocument.objects.filter(public_id=public_id).first() if document is None: raise HttpError(404, "知识文档不存在") if document.scan_status == "infected": raise HttpError(400, "恶意文件扫描未通过的文档不能重试") updated = KnowledgeDocument.objects.filter( id=document.id, parse_status="failed", ).update( scan_status="pending", scan_result={}, parse_status="pending", parse_error="", task_id="", ) if not updated: raise HttpError(400, "只有处理失败的知识文档可以重试") dispatch_knowledge_document_processing(document.id) document.refresh_from_db() return document @router.get("/documents/{public_id}/download") def download_knowledge_document(request, public_id: str): require_permission(request, "knowledge.view") document = KnowledgeDocument.objects.filter(public_id=public_id).first() if document is None or not document.storage_path: raise HttpError(404, "知识文档原件不存在") if document.scan_status == "infected": raise HttpError(423, "恶意文件扫描未通过,原件已隔离") try: raw = get_object(document.storage_path) except Exception as error: raise HttpError(503, "知识文档存储暂时不可用") from error response = HttpResponse(raw, content_type=document.mime_type or "application/octet-stream") response["Content-Disposition"] = content_disposition_header( True, document.file_name, ) return response @router.post("/search", response=list[KnowledgeSearchResultOut]) def search_knowledge(request, payload: KnowledgeSearchIn): require_permission(request, "knowledge.view") try: return retrieve_knowledge( query=payload.query, knowledge_base_id=payload.knowledge_base_id, limit=min(max(payload.limit, 1), 20), ) except KnowledgeEmbeddingError as error: raise HttpError(503, str(error)) from error