322 lines
13 KiB
Python
322 lines
13 KiB
Python
from datetime import timedelta
|
|
|
|
from django.conf import settings
|
|
from django.db import transaction
|
|
from django.utils import timezone
|
|
from ninja import File, Router, UploadedFile
|
|
from ninja.errors import HttpError
|
|
|
|
from apps.accounts.api import require_permission
|
|
from apps.tickets.api import serialize_ticket
|
|
from apps.tickets.draft_schemas import (
|
|
TicketDraftActionIn,
|
|
TicketDraftAnswerIn,
|
|
TicketDraftAvailabilityOut,
|
|
TicketDraftCreateIn,
|
|
TicketDraftOut,
|
|
TicketDraftPatchIn,
|
|
)
|
|
from apps.tickets.draft_services import (
|
|
ACTIVE_DRAFT_STATUSES,
|
|
TicketDraftError,
|
|
add_answer,
|
|
assert_revision,
|
|
audit_draft,
|
|
cancel_draft_analysis,
|
|
confirm_draft,
|
|
create_agent_run_for_draft,
|
|
draft_availability,
|
|
reject_draft,
|
|
serialize_draft,
|
|
)
|
|
from apps.tickets.materials import (
|
|
MaterialValidationError,
|
|
prepare_material,
|
|
process_material,
|
|
)
|
|
from apps.tickets.models import TicketDraft, TicketDraftMaterial
|
|
from apps.tickets.storage import delete_object, put_object
|
|
from apps.tickets.tasks import execute_ticket_draft_analysis
|
|
|
|
router = Router(tags=["ticket-drafts"])
|
|
|
|
|
|
def get_owned_draft(request, public_id: str, *, allow_admin_view: bool = False) -> TicketDraft:
|
|
require_permission(request, "ticket.ai_draft")
|
|
queryset = TicketDraft.objects.select_related(
|
|
"latest_agent_run", "confirmed_ticket", "created_by"
|
|
).prefetch_related("materials", "messages")
|
|
draft = queryset.filter(public_id=public_id).first()
|
|
if draft is None:
|
|
raise HttpError(404, "草稿不存在")
|
|
if draft.created_by_id != request.user.id:
|
|
can_view = allow_admin_view and (
|
|
request.user.is_superuser or request.user.has_perm("system.agent.run.view")
|
|
)
|
|
if not can_view:
|
|
raise HttpError(403, "不能访问其他人的草稿")
|
|
return draft
|
|
|
|
|
|
def raise_draft_error(error: TicketDraftError) -> None:
|
|
raise HttpError(error.status_code, str(error)) from error
|
|
|
|
|
|
@router.get("/availability", response=TicketDraftAvailabilityOut)
|
|
def get_availability(request):
|
|
require_permission(request, "ticket.ai_draft")
|
|
return draft_availability()
|
|
|
|
|
|
@router.get("", response=list[TicketDraftOut])
|
|
def list_ticket_drafts(request):
|
|
require_permission(request, "ticket.ai_draft")
|
|
drafts = (
|
|
TicketDraft.objects.filter(created_by=request.user, status__in=ACTIVE_DRAFT_STATUSES)
|
|
.select_related("latest_agent_run", "confirmed_ticket")
|
|
.prefetch_related("materials", "messages")[:20]
|
|
)
|
|
return [serialize_draft(draft, ticket_serializer=serialize_ticket) for draft in drafts]
|
|
|
|
|
|
@router.post("", response=TicketDraftOut)
|
|
def create_ticket_draft(request, payload: TicketDraftCreateIn):
|
|
require_permission(request, "ticket.ai_draft")
|
|
availability = draft_availability()
|
|
if not availability["available"]:
|
|
raise HttpError(422, availability["reason"])
|
|
source_text = payload.source_text.strip()
|
|
draft = TicketDraft.objects.create(created_by=request.user, source_text=source_text)
|
|
audit_draft(draft, "draft_created")
|
|
return serialize_draft(draft, ticket_serializer=serialize_ticket)
|
|
|
|
|
|
@router.get("/{public_id}", response=TicketDraftOut)
|
|
def get_ticket_draft(request, public_id: str):
|
|
draft = get_owned_draft(request, public_id, allow_admin_view=True)
|
|
return serialize_draft(draft, ticket_serializer=serialize_ticket)
|
|
|
|
|
|
@router.patch("/{public_id}", response=TicketDraftOut)
|
|
def update_ticket_draft(request, public_id: str, payload: TicketDraftPatchIn):
|
|
with transaction.atomic():
|
|
draft = (
|
|
TicketDraft.objects.select_for_update()
|
|
.filter(public_id=public_id, created_by=request.user)
|
|
.first()
|
|
)
|
|
if draft is None:
|
|
require_permission(request, "ticket.ai_draft")
|
|
raise HttpError(404, "草稿不存在")
|
|
require_permission(request, "ticket.ai_draft")
|
|
try:
|
|
assert_revision(draft, payload.expected_revision)
|
|
except TicketDraftError as error:
|
|
raise_draft_error(error)
|
|
if draft.status not in {"collecting", "needs_input", "failed", "editing", "ready"}:
|
|
raise HttpError(400, "当前状态不能编辑")
|
|
draft.current_fields = payload.fields
|
|
draft.revision += 1
|
|
draft.expires_at = timezone.now() + timedelta(hours=settings.TICKET_DRAFT_RETENTION_HOURS)
|
|
draft.save()
|
|
audit_draft(draft, "draft_updated")
|
|
draft = get_owned_draft(request, public_id)
|
|
return serialize_draft(draft, ticket_serializer=serialize_ticket)
|
|
|
|
|
|
def _upload_material(
|
|
request,
|
|
public_id: str,
|
|
file: UploadedFile,
|
|
*,
|
|
images_only: bool = False,
|
|
):
|
|
draft = get_owned_draft(request, public_id)
|
|
if draft.status not in {"collecting", "needs_input", "failed", "editing", "ready"}:
|
|
raise HttpError(400, "当前状态不能上传材料")
|
|
if draft.materials.count() >= settings.TICKET_MATERIAL_MAX_FILES:
|
|
raise HttpError(413, f"每次受理最多上传 {settings.TICKET_MATERIAL_MAX_FILES} 个材料")
|
|
raw = file.read()
|
|
try:
|
|
prepared = prepare_material(raw, file.name or "material", file.content_type or "")
|
|
except MaterialValidationError as error:
|
|
raise HttpError(error.status_code, str(error)) from error
|
|
if images_only and prepared.material_type != "image":
|
|
raise HttpError(415, "旧图片接口仅支持 JPEG、PNG 和 WebP")
|
|
current_total = sum(material.file_size for material in draft.materials.all())
|
|
if current_total + len(prepared.data) > settings.TICKET_MATERIAL_MAX_TOTAL_BYTES:
|
|
max_mb = settings.TICKET_MATERIAL_MAX_TOTAL_BYTES // 1024 // 1024
|
|
raise HttpError(413, f"单次受理材料总大小不能超过 {max_mb} MB")
|
|
material = TicketDraftMaterial(
|
|
draft=draft,
|
|
file_name=prepared.file_name,
|
|
material_type=prepared.material_type,
|
|
extension=prepared.extension,
|
|
claimed_mime_type=prepared.claimed_mime_type,
|
|
mime_type=prepared.mime_type,
|
|
file_size=len(prepared.data),
|
|
sha256=prepared.sha256,
|
|
storage_path="",
|
|
)
|
|
material.storage_path = (
|
|
f"ticket-drafts/{draft.created_by_id}/{draft.public_id}/"
|
|
f"{material.public_id}.{prepared.extension}"
|
|
)
|
|
try:
|
|
put_object(material.storage_path, prepared.data, prepared.mime_type)
|
|
except Exception as error:
|
|
raise HttpError(503, "材料存储暂时不可用") from error
|
|
try:
|
|
material.save()
|
|
except Exception:
|
|
try:
|
|
delete_object(material.storage_path)
|
|
except Exception:
|
|
pass
|
|
raise
|
|
process_material(material, prepared.data)
|
|
draft.revision += 1
|
|
draft.expires_at = timezone.now() + timedelta(hours=settings.TICKET_DRAFT_RETENTION_HOURS)
|
|
draft.save(update_fields=["revision", "expires_at", "updated_at"])
|
|
audit_draft(draft, "draft_material_uploaded")
|
|
return serialize_draft(get_owned_draft(request, public_id), ticket_serializer=serialize_ticket)
|
|
|
|
|
|
@router.post("/{public_id}/materials", response=TicketDraftOut)
|
|
def upload_ticket_draft_material(request, public_id: str, file: File[UploadedFile]):
|
|
return _upload_material(request, public_id, file)
|
|
|
|
|
|
@router.post("/{public_id}/images", response=TicketDraftOut)
|
|
def upload_ticket_draft_image(request, public_id: str, file: File[UploadedFile]):
|
|
return _upload_material(request, public_id, file, images_only=True)
|
|
|
|
|
|
def _delete_material(request, public_id: str, material_id: str, *, images_only: bool = False):
|
|
draft = get_owned_draft(request, public_id)
|
|
materials = draft.materials.filter(public_id=material_id, status="temporary")
|
|
if images_only:
|
|
materials = materials.filter(material_type="image")
|
|
material = materials.first()
|
|
if material is None:
|
|
raise HttpError(404, "材料不存在")
|
|
try:
|
|
delete_object(material.storage_path)
|
|
except Exception as error:
|
|
raise HttpError(503, "材料存储暂时不可用") from error
|
|
material.delete()
|
|
draft.revision += 1
|
|
draft.save(update_fields=["revision", "updated_at"])
|
|
audit_draft(draft, "draft_material_deleted")
|
|
return serialize_draft(get_owned_draft(request, public_id), ticket_serializer=serialize_ticket)
|
|
|
|
|
|
@router.delete("/{public_id}/materials/{material_id}", response=TicketDraftOut)
|
|
def delete_ticket_draft_material(request, public_id: str, material_id: str):
|
|
return _delete_material(request, public_id, material_id)
|
|
|
|
|
|
@router.delete("/{public_id}/images/{image_id}", response=TicketDraftOut)
|
|
def delete_ticket_draft_image(request, public_id: str, image_id: str):
|
|
return _delete_material(request, public_id, image_id, images_only=True)
|
|
|
|
|
|
def start_analysis(draft: TicketDraft, expected_revision: int) -> TicketDraft:
|
|
try:
|
|
assert_revision(draft, expected_revision)
|
|
if draft.status not in {"collecting", "needs_input", "failed", "editing", "ready"}:
|
|
raise TicketDraftError(400, "当前状态不能启动分析")
|
|
ready_materials = draft.materials.filter(scan_status="clean")
|
|
usable_documents = ready_materials.filter(
|
|
parse_status="parsed"
|
|
).exclude(parsed_text="")
|
|
ready_images = ready_materials.filter(material_type="image")
|
|
if (
|
|
not draft.source_text.strip()
|
|
and not draft.current_fields
|
|
and not ready_images.exists()
|
|
and not usable_documents.exists()
|
|
):
|
|
raise TicketDraftError(400, "请先输入问题描述或上传可用材料")
|
|
if ready_images.exists() and not draft_availability()["vision_available"]:
|
|
raise TicketDraftError(422, draft_availability()["vision_reason"])
|
|
run = create_agent_run_for_draft(draft)
|
|
except TicketDraftError as error:
|
|
raise_draft_error(error)
|
|
result = execute_ticket_draft_analysis.delay(draft.id, run.id)
|
|
run.refresh_from_db()
|
|
if not run.task_id:
|
|
run.task_id = result.id or ""
|
|
run.save(update_fields=["task_id"])
|
|
draft.refresh_from_db()
|
|
return draft
|
|
|
|
|
|
@router.post("/{public_id}/analyze", response=TicketDraftOut)
|
|
def analyze_ticket_draft(request, public_id: str, payload: TicketDraftActionIn):
|
|
draft = get_owned_draft(request, public_id)
|
|
draft = start_analysis(draft, payload.expected_revision)
|
|
return serialize_draft(draft, ticket_serializer=serialize_ticket)
|
|
|
|
|
|
@router.post("/{public_id}/cancel", response=TicketDraftOut)
|
|
def cancel_ticket_draft_analysis(
|
|
request,
|
|
public_id: str,
|
|
payload: TicketDraftActionIn,
|
|
):
|
|
require_permission(request, "ticket.ai_draft")
|
|
try:
|
|
draft = cancel_draft_analysis(
|
|
public_id,
|
|
payload.expected_revision,
|
|
request.user,
|
|
)
|
|
except TicketDraft.DoesNotExist as error:
|
|
raise HttpError(404, "草稿不存在") from error
|
|
except TicketDraftError as error:
|
|
raise_draft_error(error)
|
|
return serialize_draft(draft, ticket_serializer=serialize_ticket)
|
|
|
|
|
|
@router.post("/{public_id}/answers", response=TicketDraftOut)
|
|
def answer_ticket_draft(request, public_id: str, payload: TicketDraftAnswerIn):
|
|
draft = get_owned_draft(request, public_id)
|
|
try:
|
|
assert_revision(draft, payload.expected_revision)
|
|
if not payload.answer.strip():
|
|
raise TicketDraftError(400, "回答不能为空")
|
|
add_answer(draft, payload.answer, payload.question)
|
|
except TicketDraftError as error:
|
|
raise_draft_error(error)
|
|
draft.refresh_from_db()
|
|
draft = start_analysis(draft, draft.revision)
|
|
return serialize_draft(draft, ticket_serializer=serialize_ticket)
|
|
|
|
|
|
@router.post("/{public_id}/confirm", response=TicketDraftOut)
|
|
def confirm_ticket_draft(request, public_id: str, payload: TicketDraftActionIn):
|
|
require_permission(request, "ticket.ai_draft")
|
|
require_permission(request, "ticket.create")
|
|
try:
|
|
draft = confirm_draft(public_id, payload.expected_revision, request.user)
|
|
except TicketDraft.DoesNotExist as error:
|
|
raise HttpError(404, "草稿不存在") from error
|
|
except TicketDraftError as error:
|
|
raise_draft_error(error)
|
|
draft = get_owned_draft(request, public_id)
|
|
return serialize_draft(draft, ticket_serializer=serialize_ticket)
|
|
|
|
|
|
@router.post("/{public_id}/reject", response=TicketDraftOut)
|
|
def reject_ticket_draft(request, public_id: str, payload: TicketDraftActionIn):
|
|
require_permission(request, "ticket.ai_draft")
|
|
try:
|
|
draft = reject_draft(public_id, payload.expected_revision, request.user)
|
|
except TicketDraft.DoesNotExist as error:
|
|
raise HttpError(404, "草稿不存在") from error
|
|
except TicketDraftError as error:
|
|
raise_draft_error(error)
|
|
draft = get_owned_draft(request, public_id)
|
|
return serialize_draft(draft, ticket_serializer=serialize_ticket)
|