feat: complete multimodal ticket intake

This commit is contained in:
hailin
2026-07-27 18:59:28 +08:00
parent 96452934a3
commit 93065c0a00
39 changed files with 6443 additions and 267 deletions

View File

@@ -22,14 +22,20 @@ from apps.tickets.draft_services import (
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.models import TicketDraft, TicketDraftImage
from apps.tickets.storage import DraftImageError, delete_object, prepare_image, put_object
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"])
@@ -39,7 +45,7 @@ def get_owned_draft(request, public_id: str, *, allow_admin_view: bool = False)
require_permission(request, "ticket.ai_draft")
queryset = TicketDraft.objects.select_related(
"latest_agent_run", "confirmed_ticket", "created_by"
).prefetch_related("images", "messages")
).prefetch_related("materials", "messages")
draft = queryset.filter(public_id=public_id).first()
if draft is None:
raise HttpError(404, "草稿不存在")
@@ -68,7 +74,7 @@ def list_ticket_drafts(request):
drafts = (
TicketDraft.objects.filter(created_by=request.user, status__in=ACTIVE_DRAFT_STATUSES)
.select_related("latest_agent_run", "confirmed_ticket")
.prefetch_related("images", "messages")[:20]
.prefetch_related("materials", "messages")[:20]
)
return [serialize_draft(draft, ticket_serializer=serialize_ticket) for draft in drafts]
@@ -107,7 +113,7 @@ def update_ticket_draft(request, public_id: str, payload: TicketDraftPatchIn):
assert_revision(draft, payload.expected_revision)
except TicketDraftError as error:
raise_draft_error(error)
if draft.status not in {"editing", "ready", "failed"}:
if draft.status not in {"collecting", "needs_input", "failed", "editing", "ready"}:
raise HttpError(400, "当前状态不能编辑")
draft.current_fields = payload.fields
draft.revision += 1
@@ -118,70 +124,121 @@ def update_ticket_draft(request, public_id: str, payload: TicketDraftPatchIn):
return serialize_draft(draft, ticket_serializer=serialize_ticket)
@router.post("/{public_id}/images", response=TicketDraftOut)
def upload_ticket_draft_image(request, public_id: str, file: File[UploadedFile]):
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 {"editing", "ready", "failed"}:
raise HttpError(400, "当前状态不能上传图片")
if draft.images.count() >= 4:
raise HttpError(413, "每个草稿最多上传 4 张图片")
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_image(raw, file.name or "image")
except DraftImageError as error:
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
current_total = sum(image.file_size for image in draft.images.all())
if current_total + len(prepared.data) > 20 * 1024 * 1024:
raise HttpError(413, "草稿图片总大小不能超过 20 MB")
image = TicketDraftImage(
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="",
)
image.storage_path = (
material.storage_path = (
f"ticket-drafts/{draft.created_by_id}/{draft.public_id}/"
f"{image.public_id}.{prepared.extension}"
f"{material.public_id}.{prepared.extension}"
)
try:
put_object(image.storage_path, prepared.data, prepared.mime_type)
put_object(material.storage_path, prepared.data, prepared.mime_type)
except Exception as error:
raise HttpError(503, "图片存储暂时不可用") from error
image.save()
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_image_uploaded")
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):
draft = get_owned_draft(request, public_id)
image = draft.images.filter(public_id=image_id, status="temporary").first()
if image is None:
raise HttpError(404, "图片不存在")
try:
delete_object(image.storage_path)
except Exception as error:
raise HttpError(503, "图片存储暂时不可用") from error
image.delete()
draft.revision += 1
draft.save(update_fields=["revision", "updated_at"])
audit_draft(draft, "draft_image_deleted")
return serialize_draft(get_owned_draft(request, public_id), ticket_serializer=serialize_ticket)
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 {"editing", "ready", "failed"}:
if draft.status not in {"collecting", "needs_input", "failed", "editing", "ready"}:
raise TicketDraftError(400, "当前状态不能启动分析")
if not draft.source_text.strip() and not draft.current_fields:
raise TicketDraftError(400, "请先输入问题描述")
if draft.images.exists() and not draft_availability()["vision_available"]:
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:
@@ -202,6 +259,26 @@ def analyze_ticket_draft(request, public_id: str, payload: TicketDraftActionIn):
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)