599 lines
20 KiB
Python
599 lines
20 KiB
Python
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
|