Files
2026-07-27 19:01:19 +08:00

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