feat: complete multimodal ticket intake
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -22,12 +22,24 @@ class TicketDraftAnswerIn(Schema):
|
||||
question: str = ""
|
||||
|
||||
|
||||
class TicketDraftImageOut(Schema):
|
||||
class TicketDraftMaterialOut(Schema):
|
||||
id: str
|
||||
file_name: str
|
||||
material_type: str
|
||||
extension: str
|
||||
claimed_mime_type: str
|
||||
mime_type: str
|
||||
file_size: int
|
||||
status: str
|
||||
scan_status: str
|
||||
scan_result: dict
|
||||
parse_status: str
|
||||
parse_error: str
|
||||
parsed_text_length: int
|
||||
|
||||
|
||||
# Compatibility schema name retained for the legacy images field.
|
||||
TicketDraftImageOut = TicketDraftMaterialOut
|
||||
|
||||
|
||||
class TicketDraftMessageOut(Schema):
|
||||
@@ -37,22 +49,39 @@ class TicketDraftMessageOut(Schema):
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TicketDraftAgentProgressOut(Schema):
|
||||
stage: str
|
||||
message: str
|
||||
current_step: str
|
||||
total_steps: int
|
||||
retry_count: int
|
||||
elapsed_ms: int
|
||||
slow: bool
|
||||
|
||||
|
||||
class TicketDraftOut(Schema):
|
||||
public_id: str
|
||||
status: str
|
||||
revision: int
|
||||
source_text: str
|
||||
problem_valid: bool | None = None
|
||||
invalid_reason: str
|
||||
fields: dict
|
||||
last_ai_fields: dict
|
||||
field_evidence: dict
|
||||
message_timeline: list[dict]
|
||||
missing_fields: list[str]
|
||||
questions: list[str]
|
||||
guided_rounds: int
|
||||
agent_run_public_id: str | None = None
|
||||
images: list[TicketDraftImageOut]
|
||||
agent_progress: TicketDraftAgentProgressOut
|
||||
materials: list[TicketDraftMaterialOut]
|
||||
images: list[TicketDraftMaterialOut]
|
||||
messages: list[TicketDraftMessageOut]
|
||||
decision: str
|
||||
error_code: str
|
||||
error_message: str
|
||||
created_ticket: dict | None = None
|
||||
confirmed_ticket: dict | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -64,3 +93,7 @@ class TicketDraftAvailabilityOut(Schema):
|
||||
reason: str
|
||||
vision_available: bool
|
||||
vision_reason: str
|
||||
max_file_size: int
|
||||
max_files: int
|
||||
max_total_size: int
|
||||
supported_extensions: list[str]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
554
backend/apps/tickets/evaluation.py
Normal file
554
backend/apps/tickets/evaluation.py
Normal file
@@ -0,0 +1,554 @@
|
||||
"""阶段 2 智能受理案例导入、逐案评审和质量指标计算。"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.tickets.models import (
|
||||
IntakeEvaluationCase,
|
||||
IntakeEvaluationResult,
|
||||
TicketDraft,
|
||||
)
|
||||
|
||||
SOURCE_KINDS = {"anonymized_real", "synthetic"}
|
||||
|
||||
|
||||
class IntakeEvaluationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_value(value):
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return " ".join(value.casefold().split())
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return str(value).casefold()
|
||||
|
||||
|
||||
def import_evaluation_cases(path: str | Path) -> tuple[int, int]:
|
||||
"""导入版本化案例清单;真实案例必须提供完整期望与材料清单。"""
|
||||
|
||||
source_path = Path(path)
|
||||
payload = json.loads(source_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict) or payload.get("schema_version") != 1:
|
||||
raise IntakeEvaluationError("案例文件 schema_version 必须为 1")
|
||||
cases = payload.get("cases") if isinstance(payload, dict) else None
|
||||
if not isinstance(cases, list):
|
||||
raise IntakeEvaluationError("案例文件必须包含 cases 数组")
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
for raw_case in cases:
|
||||
if not isinstance(raw_case, dict):
|
||||
raise IntakeEvaluationError("每个案例必须是对象")
|
||||
key = str(raw_case.get("key") or "").strip()
|
||||
source_kind = str(raw_case.get("source_kind") or "").strip()
|
||||
if not key or source_kind not in SOURCE_KINDS:
|
||||
raise IntakeEvaluationError("案例必须提供 key 和合法 source_kind")
|
||||
boolean_fields = {
|
||||
"valid_input",
|
||||
"expected_auto_created",
|
||||
"expected_similar_ticket",
|
||||
"enabled",
|
||||
}
|
||||
invalid_boolean_fields = sorted(
|
||||
field_name
|
||||
for field_name in boolean_fields
|
||||
if field_name in raw_case and not isinstance(raw_case[field_name], bool)
|
||||
)
|
||||
if invalid_boolean_fields:
|
||||
raise IntakeEvaluationError(
|
||||
f"案例 {key} 的布尔字段类型错误:{', '.join(invalid_boolean_fields)}"
|
||||
)
|
||||
input_manifest = raw_case.get("input_manifest") or {}
|
||||
expected_fields = raw_case.get("expected_fields") or {}
|
||||
expected_timeline = raw_case.get("expected_timeline") or []
|
||||
if not isinstance(input_manifest, dict):
|
||||
raise IntakeEvaluationError(f"案例 {key} 的 input_manifest 必须是对象")
|
||||
if not isinstance(expected_fields, dict):
|
||||
raise IntakeEvaluationError(f"案例 {key} 的 expected_fields 必须是对象")
|
||||
if not isinstance(expected_timeline, list) or not all(
|
||||
isinstance(item, dict) for item in expected_timeline
|
||||
):
|
||||
raise IntakeEvaluationError(f"案例 {key} 的 expected_timeline 必须是对象数组")
|
||||
if source_kind == "anonymized_real":
|
||||
required_keys = {
|
||||
"valid_input",
|
||||
"input_manifest",
|
||||
"expected_fields",
|
||||
"expected_timeline",
|
||||
"expected_auto_created",
|
||||
"expected_similar_ticket",
|
||||
}
|
||||
missing_keys = sorted(required_keys - set(raw_case))
|
||||
if missing_keys:
|
||||
raise IntakeEvaluationError(
|
||||
f"真实案例 {key} 缺少字段:{', '.join(missing_keys)}"
|
||||
)
|
||||
source_text = str(input_manifest.get("source_text") or "").strip()
|
||||
materials = input_manifest.get("materials") or []
|
||||
if not isinstance(materials, list) or not all(
|
||||
isinstance(item, dict) for item in materials
|
||||
):
|
||||
raise IntakeEvaluationError(f"真实案例 {key} 的 materials 必须是对象数组")
|
||||
if not source_text and not materials:
|
||||
raise IntakeEvaluationError(f"真实案例 {key} 必须包含文本或材料")
|
||||
_, created = IntakeEvaluationCase.objects.update_or_create(
|
||||
key=key,
|
||||
defaults={
|
||||
"name": str(raw_case.get("name") or key),
|
||||
"source_kind": source_kind,
|
||||
"valid_input": bool(raw_case.get("valid_input", True)),
|
||||
"input_manifest": input_manifest,
|
||||
"expected_fields": expected_fields,
|
||||
"expected_timeline": expected_timeline,
|
||||
"expected_auto_created": bool(raw_case.get("expected_auto_created", True)),
|
||||
"expected_similar_ticket": bool(
|
||||
raw_case.get("expected_similar_ticket", False)
|
||||
),
|
||||
"notes": str(raw_case.get("notes") or ""),
|
||||
"enabled": bool(raw_case.get("enabled", True)),
|
||||
},
|
||||
)
|
||||
created_count += int(created)
|
||||
updated_count += int(not created)
|
||||
return created_count, updated_count
|
||||
|
||||
|
||||
def _evaluate_timeline(expected_timeline: list, actual_timeline: list) -> tuple[list, int]:
|
||||
results = []
|
||||
correct_count = 0
|
||||
for index, expected in enumerate(expected_timeline):
|
||||
actual = actual_timeline[index] if index < len(actual_timeline) else None
|
||||
field_results = {}
|
||||
correct = isinstance(actual, dict)
|
||||
for field_name, expected_value in expected.items():
|
||||
actual_value = actual.get(field_name) if isinstance(actual, dict) else None
|
||||
field_correct = _normalize_value(actual_value) == _normalize_value(expected_value)
|
||||
correct = correct and field_correct
|
||||
field_results[field_name] = {
|
||||
"expected": expected_value,
|
||||
"actual": actual_value,
|
||||
"correct": field_correct,
|
||||
}
|
||||
correct_count += int(correct)
|
||||
results.append(
|
||||
{
|
||||
"sequence": index + 1,
|
||||
"correct": correct,
|
||||
"field_results": field_results,
|
||||
}
|
||||
)
|
||||
return results, correct_count
|
||||
|
||||
|
||||
def record_evaluation_result(
|
||||
case: IntakeEvaluationCase,
|
||||
draft: TicketDraft,
|
||||
*,
|
||||
reviewer=None,
|
||||
) -> IntakeEvaluationResult:
|
||||
"""把一次受理结果与案例期望逐字段比对,并保留人工评审证据。"""
|
||||
|
||||
expected_fields = case.expected_fields if isinstance(case.expected_fields, dict) else {}
|
||||
actual_fields = draft.current_fields if isinstance(draft.current_fields, dict) else {}
|
||||
field_results = {}
|
||||
correct_count = 0
|
||||
for field_name, expected in expected_fields.items():
|
||||
actual = actual_fields.get(field_name)
|
||||
correct = _normalize_value(actual) == _normalize_value(expected)
|
||||
correct_count += int(correct)
|
||||
field_results[field_name] = {
|
||||
"expected": expected,
|
||||
"actual": actual,
|
||||
"correct": correct,
|
||||
}
|
||||
evaluated_count = len(field_results)
|
||||
field_accuracy = correct_count / evaluated_count if evaluated_count else None
|
||||
field_evidence = draft.field_evidence if isinstance(draft.field_evidence, dict) else {}
|
||||
evidence_results = {}
|
||||
evidence_count = 0
|
||||
evidence_covered_count = 0
|
||||
for field_name, expected in expected_fields.items():
|
||||
if not _normalize_value(expected):
|
||||
continue
|
||||
evidence_count += 1
|
||||
evidence = field_evidence.get(field_name)
|
||||
covered = isinstance(evidence, dict) and any(
|
||||
bool(evidence.get(key))
|
||||
for key in (
|
||||
"source_refs",
|
||||
"excerpt",
|
||||
"location",
|
||||
"generated_from",
|
||||
"raw_value",
|
||||
)
|
||||
)
|
||||
evidence_covered_count += int(covered)
|
||||
evidence_results[field_name] = {
|
||||
"covered": covered,
|
||||
"evidence": evidence if isinstance(evidence, dict) else {},
|
||||
}
|
||||
expected_timeline = (
|
||||
case.expected_timeline if isinstance(case.expected_timeline, list) else []
|
||||
)
|
||||
actual_timeline = (
|
||||
draft.message_timeline if isinstance(draft.message_timeline, list) else []
|
||||
)
|
||||
timeline_results, correct_timeline_count = _evaluate_timeline(
|
||||
expected_timeline,
|
||||
actual_timeline,
|
||||
)
|
||||
timeline_correct = correct_timeline_count == len(expected_timeline)
|
||||
actual_auto_created = draft.decision == "auto_created" and bool(draft.confirmed_ticket_id)
|
||||
actual_similar_ticket = bool(
|
||||
draft.confirmed_ticket_id
|
||||
and draft.confirmed_ticket.similarity_suggestions.exists()
|
||||
)
|
||||
passed = (
|
||||
draft.problem_valid is case.valid_input
|
||||
and actual_auto_created == case.expected_auto_created
|
||||
and actual_similar_ticket == case.expected_similar_ticket
|
||||
and correct_count == evaluated_count
|
||||
and timeline_correct
|
||||
and evidence_covered_count == evidence_count
|
||||
)
|
||||
result, _ = IntakeEvaluationResult.objects.update_or_create(
|
||||
case=case,
|
||||
draft=draft,
|
||||
defaults={
|
||||
"ticket": draft.confirmed_ticket,
|
||||
"actual_fields": actual_fields,
|
||||
"actual_auto_created": actual_auto_created,
|
||||
"correct_field_count": correct_count,
|
||||
"evaluated_field_count": evaluated_count,
|
||||
"field_accuracy": field_accuracy,
|
||||
"passed": passed,
|
||||
"details": {
|
||||
"field_results": field_results,
|
||||
"evidence_results": evidence_results,
|
||||
"evidence_covered_count": evidence_covered_count,
|
||||
"evidence_field_count": evidence_count,
|
||||
"timeline_results": timeline_results,
|
||||
"correct_timeline_count": correct_timeline_count,
|
||||
"evaluated_timeline_count": len(expected_timeline),
|
||||
"timeline_correct": timeline_correct,
|
||||
"expected_problem_valid": case.valid_input,
|
||||
"actual_problem_valid": draft.problem_valid,
|
||||
"expected_similar_ticket": case.expected_similar_ticket,
|
||||
"actual_similar_ticket": actual_similar_ticket,
|
||||
},
|
||||
"reviewed_by": reviewer,
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _latest_evaluation_scope(*, include_synthetic: bool) -> tuple[list, list]:
|
||||
cases = IntakeEvaluationCase.objects.filter(enabled=True)
|
||||
if not include_synthetic:
|
||||
cases = cases.filter(source_kind="anonymized_real")
|
||||
case_list = list(cases.order_by("key"))
|
||||
queryset = (
|
||||
IntakeEvaluationResult.objects.select_related(
|
||||
"case",
|
||||
"draft",
|
||||
"ticket",
|
||||
"reviewed_by",
|
||||
)
|
||||
.filter(case__in=case_list)
|
||||
.order_by("case_id", "-updated_at", "-id")
|
||||
)
|
||||
latest_by_case = {}
|
||||
for result in queryset:
|
||||
latest_by_case.setdefault(result.case_id, result)
|
||||
return case_list, list(latest_by_case.values())
|
||||
|
||||
|
||||
def _build_modality_coverage(cases: list[IntakeEvaluationCase]) -> dict:
|
||||
image_types = {"jpg", "jpeg", "png", "webp", "image"}
|
||||
document_types = {
|
||||
"pdf",
|
||||
"txt",
|
||||
"log",
|
||||
"docx",
|
||||
"xlsx",
|
||||
"pptx",
|
||||
"document",
|
||||
"text",
|
||||
"spreadsheet",
|
||||
"presentation",
|
||||
}
|
||||
coverage = {
|
||||
"text_case_count": 0,
|
||||
"image_case_count": 0,
|
||||
"multi_image_case_count": 0,
|
||||
"document_case_count": 0,
|
||||
"mixed_text_image_case_count": 0,
|
||||
"valid_input_case_count": 0,
|
||||
"invalid_input_case_count": 0,
|
||||
}
|
||||
for case in cases:
|
||||
manifest = case.input_manifest if isinstance(case.input_manifest, dict) else {}
|
||||
has_text = bool(str(manifest.get("source_text") or "").strip())
|
||||
materials = manifest.get("materials")
|
||||
materials = materials if isinstance(materials, list) else []
|
||||
material_types = []
|
||||
for material in materials:
|
||||
if not isinstance(material, dict):
|
||||
continue
|
||||
raw_type = str(
|
||||
material.get("type")
|
||||
or material.get("extension")
|
||||
or Path(str(material.get("file_name") or "")).suffix.lstrip(".")
|
||||
)
|
||||
material_types.append(raw_type.casefold())
|
||||
image_count = sum(item in image_types for item in material_types)
|
||||
has_document = any(item in document_types for item in material_types)
|
||||
coverage["text_case_count"] += int(has_text)
|
||||
coverage["image_case_count"] += int(image_count > 0)
|
||||
coverage["multi_image_case_count"] += int(image_count > 1)
|
||||
coverage["document_case_count"] += int(has_document)
|
||||
coverage["mixed_text_image_case_count"] += int(has_text and image_count > 0)
|
||||
coverage["valid_input_case_count"] += int(case.valid_input)
|
||||
coverage["invalid_input_case_count"] += int(not case.valid_input)
|
||||
return coverage
|
||||
|
||||
|
||||
def build_intake_quality_metrics(*, include_synthetic: bool = False) -> dict:
|
||||
"""按每个案例最新评审结果计算覆盖率、自动建单率和字段准确率。"""
|
||||
|
||||
operational = TicketDraft.objects.filter(problem_valid=True).exclude(
|
||||
status__in={"rejected", "expired"}
|
||||
)
|
||||
operational_valid_count = operational.count()
|
||||
operational_auto_count = operational.filter(
|
||||
decision="auto_created",
|
||||
confirmed_ticket__isnull=False,
|
||||
).count()
|
||||
operational_auto_rate = (
|
||||
operational_auto_count / operational_valid_count if operational_valid_count else None
|
||||
)
|
||||
|
||||
cases, results = _latest_evaluation_scope(include_synthetic=include_synthetic)
|
||||
valid_results = [result for result in results if result.case.valid_input]
|
||||
evaluated_valid_count = len(valid_results)
|
||||
evaluated_auto_count = sum(result.actual_auto_created for result in valid_results)
|
||||
evaluated_auto_rate = (
|
||||
evaluated_auto_count / evaluated_valid_count if evaluated_valid_count else None
|
||||
)
|
||||
correct_fields = sum(result.correct_field_count for result in results)
|
||||
evaluated_fields = sum(result.evaluated_field_count for result in results)
|
||||
field_accuracy = correct_fields / evaluated_fields if evaluated_fields else None
|
||||
evidence_covered_fields = 0
|
||||
evidence_fields = 0
|
||||
correct_timeline_items = 0
|
||||
evaluated_timeline_items = 0
|
||||
for result in results:
|
||||
details = result.details
|
||||
if not isinstance(details, dict):
|
||||
continue
|
||||
evidence_covered_fields += int(details.get("evidence_covered_count") or 0)
|
||||
evidence_fields += int(details.get("evidence_field_count") or 0)
|
||||
correct_timeline_items += int(details.get("correct_timeline_count") or 0)
|
||||
evaluated_timeline_items += int(details.get("evaluated_timeline_count") or 0)
|
||||
reviewed_case_count = len(results)
|
||||
enabled_case_count = len(cases)
|
||||
coverage_complete = (
|
||||
enabled_case_count > 0 and reviewed_case_count == enabled_case_count
|
||||
)
|
||||
return {
|
||||
"include_synthetic": include_synthetic,
|
||||
"operational": {
|
||||
"valid_input_count": operational_valid_count,
|
||||
"auto_created_count": operational_auto_count,
|
||||
"auto_creation_rate": operational_auto_rate,
|
||||
},
|
||||
"evaluation": {
|
||||
"enabled_case_count": enabled_case_count,
|
||||
"reviewed_case_count": reviewed_case_count,
|
||||
"coverage_rate": (
|
||||
reviewed_case_count / enabled_case_count if enabled_case_count else None
|
||||
),
|
||||
"valid_input_count": evaluated_valid_count,
|
||||
"auto_created_count": evaluated_auto_count,
|
||||
"auto_creation_rate": evaluated_auto_rate,
|
||||
"correct_field_count": correct_fields,
|
||||
"evaluated_field_count": evaluated_fields,
|
||||
"field_accuracy": field_accuracy,
|
||||
"evidence_covered_field_count": evidence_covered_fields,
|
||||
"evidence_field_count": evidence_fields,
|
||||
"evidence_coverage_rate": (
|
||||
evidence_covered_fields / evidence_fields if evidence_fields else None
|
||||
),
|
||||
"correct_timeline_item_count": correct_timeline_items,
|
||||
"evaluated_timeline_item_count": evaluated_timeline_items,
|
||||
"timeline_accuracy": (
|
||||
correct_timeline_items / evaluated_timeline_items
|
||||
if evaluated_timeline_items
|
||||
else None
|
||||
),
|
||||
"result_selection": "latest_per_case",
|
||||
},
|
||||
"targets": {
|
||||
"auto_creation_rate": 0.70,
|
||||
"field_accuracy": 0.90,
|
||||
"coverage_complete": coverage_complete,
|
||||
"auto_creation_rate_met": (
|
||||
evaluated_auto_rate >= 0.70
|
||||
if coverage_complete and evaluated_auto_rate is not None
|
||||
else None
|
||||
),
|
||||
"field_accuracy_met": (
|
||||
field_accuracy >= 0.90
|
||||
if coverage_complete and field_accuracy is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_stage_2_acceptance_report(*, include_synthetic: bool = False) -> dict:
|
||||
"""生成阶段 2 退出报告;默认只允许脱敏真实案例作为验收依据。"""
|
||||
|
||||
cases, results = _latest_evaluation_scope(include_synthetic=include_synthetic)
|
||||
metrics = build_intake_quality_metrics(include_synthetic=include_synthetic)
|
||||
modality_coverage = _build_modality_coverage(cases)
|
||||
result_by_case = {result.case_id: result for result in results}
|
||||
case_results = []
|
||||
for case in cases:
|
||||
result = result_by_case.get(case.id)
|
||||
details = result.details if result is not None and isinstance(result.details, dict) else {}
|
||||
case_results.append(
|
||||
{
|
||||
"key": case.key,
|
||||
"name": case.name,
|
||||
"source_kind": case.source_kind,
|
||||
"valid_input": case.valid_input,
|
||||
"reviewed": result is not None,
|
||||
"passed": result.passed if result is not None else None,
|
||||
"draft_public_id": str(result.draft.public_id) if result is not None else None,
|
||||
"ticket_id": result.ticket_id if result is not None else None,
|
||||
"field_accuracy": (
|
||||
float(result.field_accuracy)
|
||||
if result is not None and result.field_accuracy is not None
|
||||
else None
|
||||
),
|
||||
"evidence_coverage_rate": (
|
||||
details.get("evidence_covered_count", 0)
|
||||
/ details.get("evidence_field_count", 0)
|
||||
if details.get("evidence_field_count", 0)
|
||||
else None
|
||||
),
|
||||
"timeline_accuracy": (
|
||||
details.get("correct_timeline_count", 0)
|
||||
/ details.get("evaluated_timeline_count", 0)
|
||||
if details.get("evaluated_timeline_count", 0)
|
||||
else None
|
||||
),
|
||||
"reviewed_at": result.updated_at.isoformat() if result is not None else None,
|
||||
}
|
||||
)
|
||||
|
||||
reasons = []
|
||||
readiness_state = "failed"
|
||||
if include_synthetic:
|
||||
readiness_state = "diagnostic"
|
||||
reasons.append(
|
||||
{
|
||||
"code": "synthetic_scope",
|
||||
"message": "合成案例只验证评估框架,不能作为阶段 2 业务退出证据。",
|
||||
}
|
||||
)
|
||||
elif not cases:
|
||||
readiness_state = "not_ready"
|
||||
reasons.append(
|
||||
{
|
||||
"code": "missing_real_cases",
|
||||
"message": "尚未导入启用的脱敏真实案例。",
|
||||
}
|
||||
)
|
||||
elif not metrics["targets"]["coverage_complete"]:
|
||||
readiness_state = "not_ready"
|
||||
reasons.append(
|
||||
{
|
||||
"code": "incomplete_review_coverage",
|
||||
"message": "仍有启用案例没有评审结果。",
|
||||
}
|
||||
)
|
||||
|
||||
if not include_synthetic and cases:
|
||||
required_modalities = {
|
||||
"image_case_count": "真实案例集未覆盖图片输入。",
|
||||
"mixed_text_image_case_count": "真实案例集未覆盖文本加图片混合输入。",
|
||||
"document_case_count": "真实案例集未覆盖文档或日志输入。",
|
||||
"invalid_input_case_count": "真实案例集未覆盖无效输入与人工追问路径。",
|
||||
}
|
||||
for key, message in required_modalities.items():
|
||||
if modality_coverage[key] == 0:
|
||||
if readiness_state == "failed":
|
||||
readiness_state = "not_ready"
|
||||
reasons.append({"code": f"missing_{key}", "message": message})
|
||||
|
||||
if metrics["targets"]["coverage_complete"] and not include_synthetic:
|
||||
if metrics["targets"]["auto_creation_rate_met"] is not True:
|
||||
reasons.append(
|
||||
{
|
||||
"code": "auto_creation_rate_below_target",
|
||||
"message": "有效输入自动建单率未达到 70%。",
|
||||
}
|
||||
)
|
||||
if metrics["targets"]["field_accuracy_met"] is not True:
|
||||
reasons.append(
|
||||
{
|
||||
"code": "field_accuracy_below_target",
|
||||
"message": "自动建单字段准确率未达到 90%。",
|
||||
}
|
||||
)
|
||||
evidence_count = metrics["evaluation"]["evidence_field_count"]
|
||||
evidence_rate = metrics["evaluation"]["evidence_coverage_rate"]
|
||||
if not evidence_count or evidence_rate != 1.0:
|
||||
reasons.append(
|
||||
{
|
||||
"code": "incomplete_field_evidence",
|
||||
"message": "非空评估字段尚未全部具备可追溯证据。",
|
||||
}
|
||||
)
|
||||
if metrics["evaluation"]["evaluated_timeline_item_count"] == 0:
|
||||
reasons.append(
|
||||
{
|
||||
"code": "missing_timeline_baseline",
|
||||
"message": "真实案例集没有可比对的期望消息时间线。",
|
||||
}
|
||||
)
|
||||
|
||||
exit_ready = not reasons and not include_synthetic
|
||||
if exit_ready:
|
||||
readiness_state = "passed"
|
||||
elif readiness_state == "failed" and any(
|
||||
reason["code"]
|
||||
in {
|
||||
"missing_real_cases",
|
||||
"incomplete_review_coverage",
|
||||
"missing_image_case_count",
|
||||
"missing_mixed_text_image_case_count",
|
||||
"missing_document_case_count",
|
||||
"missing_invalid_input_case_count",
|
||||
}
|
||||
for reason in reasons
|
||||
):
|
||||
readiness_state = "not_ready"
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"generated_at": timezone.now().isoformat(),
|
||||
"scope": "diagnostic_with_synthetic" if include_synthetic else "anonymized_real",
|
||||
"status": readiness_state,
|
||||
"exit_ready": exit_ready,
|
||||
"reasons": reasons,
|
||||
"modality_coverage": modality_coverage,
|
||||
"metrics": metrics,
|
||||
"case_results": case_results,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from apps.tickets.evaluation import IntakeEvaluationError, import_evaluation_cases
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "导入阶段 2 智能受理脱敏评估案例"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("path")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
try:
|
||||
created, updated = import_evaluation_cases(options["path"])
|
||||
except (IntakeEvaluationError, OSError, ValueError) as error:
|
||||
raise CommandError(str(error)) from error
|
||||
self.stdout.write(self.style.SUCCESS(f"案例导入完成:新增 {created},更新 {updated}"))
|
||||
@@ -0,0 +1,31 @@
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from apps.tickets.evaluation import record_evaluation_result
|
||||
from apps.tickets.models import IntakeEvaluationCase, TicketDraft
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "将一次真实受理结果与脱敏案例基准进行比对"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--case", required=True, dest="case_key")
|
||||
parser.add_argument("--draft", required=True, dest="draft_public_id")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
case = IntakeEvaluationCase.objects.filter(key=options["case_key"], enabled=True).first()
|
||||
if case is None:
|
||||
raise CommandError("评估案例不存在或已停用")
|
||||
draft = TicketDraft.objects.filter(public_id=options["draft_public_id"]).first()
|
||||
if draft is None:
|
||||
raise CommandError("受理记录不存在")
|
||||
result = record_evaluation_result(case, draft)
|
||||
accuracy = (
|
||||
f"{float(result.field_accuracy) * 100:.1f}%"
|
||||
if result.field_accuracy is not None
|
||||
else "无字段基准"
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"评估完成:passed={result.passed},field_accuracy={accuracy}"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from apps.tickets.evaluation import build_stage_2_acceptance_report
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "生成阶段 2 智能受理验收报告,并可用于 CI 阻断未满足的退出条件"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--include-synthetic",
|
||||
action="store_true",
|
||||
help="包含合成案例,仅用于诊断评估框架,永不判定阶段可退出",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
choices=["text", "json"],
|
||||
default="text",
|
||||
dest="output_format",
|
||||
)
|
||||
parser.add_argument("--output", help="可选 JSON 报告输出路径")
|
||||
parser.add_argument(
|
||||
"--fail-on-not-ready",
|
||||
action="store_true",
|
||||
help="阶段未满足退出条件时返回非零退出码",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
report = build_stage_2_acceptance_report(
|
||||
include_synthetic=options["include_synthetic"]
|
||||
)
|
||||
report_json = json.dumps(report, ensure_ascii=False, indent=2)
|
||||
output_path = options.get("output")
|
||||
if output_path:
|
||||
path = Path(output_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(f"{report_json}\n", encoding="utf-8")
|
||||
|
||||
if options["output_format"] == "json":
|
||||
self.stdout.write(report_json)
|
||||
else:
|
||||
metrics = report["metrics"]["evaluation"]
|
||||
auto_rate = metrics["auto_creation_rate"]
|
||||
field_accuracy = metrics["field_accuracy"]
|
||||
evidence_rate = metrics["evidence_coverage_rate"]
|
||||
self.stdout.write(
|
||||
f"阶段 2 验收状态:{report['status']};"
|
||||
f"案例覆盖 {metrics['reviewed_case_count']}/{metrics['enabled_case_count']};"
|
||||
f"自动建单率 {_percentage(auto_rate)};"
|
||||
f"字段准确率 {_percentage(field_accuracy)};"
|
||||
f"证据覆盖率 {_percentage(evidence_rate)}"
|
||||
)
|
||||
for reason in report["reasons"]:
|
||||
self.stdout.write(self.style.WARNING(f"- {reason['message']}"))
|
||||
if output_path:
|
||||
self.stdout.write(f"JSON 报告:{Path(output_path).resolve()}")
|
||||
|
||||
if options["fail_on_not_ready"] and not report["exit_ready"]:
|
||||
raise CommandError(f"阶段 2 尚不可退出:{report['status']}")
|
||||
|
||||
|
||||
def _percentage(value) -> str:
|
||||
return f"{value * 100:.1f}%" if value is not None else "无证据"
|
||||
@@ -2,7 +2,6 @@ from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from apps.tickets.draft_services import (
|
||||
confirm_draft,
|
||||
create_agent_run_for_draft,
|
||||
execute_draft_analysis,
|
||||
)
|
||||
@@ -29,38 +28,12 @@ class Command(BaseCommand):
|
||||
run = create_agent_run_for_draft(draft)
|
||||
execute_draft_analysis(draft.id, run.id)
|
||||
draft.refresh_from_db()
|
||||
if draft.status != "ready":
|
||||
if draft.status != "ticket_created" or draft.confirmed_ticket_id is None:
|
||||
raise CommandError(f"真实文本分析失败: {draft.error_code} {draft.error_message}")
|
||||
|
||||
fields = {
|
||||
**draft.current_fields,
|
||||
"title": draft.current_fields.get("title") or "生产采集任务未更新",
|
||||
"description": draft.current_fields.get("description") or draft.source_text,
|
||||
"category_code": "technical_issue",
|
||||
"priority": draft.current_fields.get("priority")
|
||||
if draft.current_fields.get("priority") in {"low", "medium", "high", "urgent"}
|
||||
else "high",
|
||||
"problem_performance": draft.current_fields.get("problem_performance")
|
||||
or "采集进度长时间未更新",
|
||||
"problem_object": draft.current_fields.get("problem_object") or "JOB-1024",
|
||||
"submitter_name": draft.current_fields.get("submitter_name") or "验收用户",
|
||||
"submitter_org": draft.current_fields.get("submitter_org") or "验收机构",
|
||||
"environment": draft.current_fields.get("environment") or "生产环境",
|
||||
"impact_scope": draft.current_fields.get("impact_scope") or "单个客户",
|
||||
"related_system": draft.current_fields.get("related_system") or "数据采集平台",
|
||||
}
|
||||
draft.current_fields = fields
|
||||
draft.revision += 1
|
||||
draft.save(update_fields=["current_fields", "revision", "updated_at"])
|
||||
revision = draft.revision
|
||||
first = confirm_draft(draft.public_id, revision, user)
|
||||
second = confirm_draft(draft.public_id, revision, user)
|
||||
if first.confirmed_ticket_id != second.confirmed_ticket_id:
|
||||
raise CommandError("重复确认创建了不同工单")
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"Ticket intake acceptance passed: "
|
||||
f"draft={draft.public_id} run={run.public_id} "
|
||||
f"ticket={first.confirmed_ticket.ticket_no} decision={first.decision}"
|
||||
f"ticket={draft.confirmed_ticket.ticket_no} decision={draft.decision}"
|
||||
)
|
||||
)
|
||||
|
||||
349
backend/apps/tickets/materials.py
Normal file
349
backend/apps/tickets/materials.py
Normal file
@@ -0,0 +1,349 @@
|
||||
"""受理材料的校验、恶意文件扫描、解析与对象存储编排。"""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import socket
|
||||
import struct
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from apps.tickets.models import TicketDraftMaterial
|
||||
from apps.tickets.storage import DraftImageError, get_object, prepare_image
|
||||
|
||||
SUPPORTED_EXTENSIONS = (
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"webp",
|
||||
"pdf",
|
||||
"txt",
|
||||
"log",
|
||||
"docx",
|
||||
"xlsx",
|
||||
"pptx",
|
||||
)
|
||||
IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "webp"}
|
||||
GENERIC_MIME_TYPES = {"", "application/octet-stream", "binary/octet-stream"}
|
||||
MIME_TYPES = {
|
||||
"jpg": {"image/jpeg"},
|
||||
"jpeg": {"image/jpeg"},
|
||||
"png": {"image/png"},
|
||||
"webp": {"image/webp"},
|
||||
"pdf": {"application/pdf"},
|
||||
"txt": {"text/plain"},
|
||||
"log": {"text/plain", "text/x-log"},
|
||||
"docx": {"application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
|
||||
"xlsx": {"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
|
||||
"pptx": {"application/vnd.openxmlformats-officedocument.presentationml.presentation"},
|
||||
}
|
||||
CANONICAL_MIME_TYPES = {
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"png": "image/png",
|
||||
"webp": "image/webp",
|
||||
"pdf": "application/pdf",
|
||||
"txt": "text/plain",
|
||||
"log": "text/plain",
|
||||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
}
|
||||
MATERIAL_TYPES = {
|
||||
**{extension: "image" for extension in IMAGE_EXTENSIONS},
|
||||
"pdf": "document",
|
||||
"txt": "text",
|
||||
"log": "log",
|
||||
"docx": "document",
|
||||
"xlsx": "spreadsheet",
|
||||
"pptx": "presentation",
|
||||
}
|
||||
OFFICE_SIGNATURES = {
|
||||
"docx": "word/document.xml",
|
||||
"xlsx": "xl/workbook.xml",
|
||||
"pptx": "ppt/presentation.xml",
|
||||
}
|
||||
|
||||
|
||||
class MaterialValidationError(ValueError):
|
||||
def __init__(self, status_code: int, message: str):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class MaterialScanError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class MaterialParseError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedMaterial:
|
||||
data: bytes
|
||||
file_name: str
|
||||
material_type: str
|
||||
extension: str
|
||||
claimed_mime_type: str
|
||||
mime_type: str
|
||||
sha256: str
|
||||
|
||||
|
||||
def _decode_text(raw: bytes) -> str:
|
||||
if b"\x00" in raw:
|
||||
raise MaterialValidationError(415, "文本或日志材料包含二进制内容")
|
||||
for encoding in ("utf-8-sig", "gb18030"):
|
||||
try:
|
||||
return raw.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
raise MaterialValidationError(415, "文本或日志材料编码无法识别")
|
||||
|
||||
|
||||
def _validate_office_signature(raw: bytes, extension: str) -> None:
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
|
||||
names = set(archive.namelist())
|
||||
except (OSError, zipfile.BadZipFile) as error:
|
||||
raise MaterialValidationError(415, "Office 文件签名无效") from error
|
||||
if OFFICE_SIGNATURES[extension] not in names:
|
||||
raise MaterialValidationError(415, f"文件内容与 .{extension} 扩展名不匹配")
|
||||
|
||||
|
||||
def prepare_material(
|
||||
raw: bytes,
|
||||
original_name: str,
|
||||
claimed_mime_type: str = "",
|
||||
) -> PreparedMaterial:
|
||||
"""校验大小、扩展名、MIME 和文件签名,返回可安全落库的材料元数据。"""
|
||||
|
||||
if not raw:
|
||||
raise MaterialValidationError(400, "不能上传空文件")
|
||||
max_bytes = settings.TICKET_MATERIAL_MAX_FILE_BYTES
|
||||
if len(raw) > max_bytes:
|
||||
raise MaterialValidationError(413, f"单个材料不能超过 {max_bytes // 1024 // 1024} MB")
|
||||
|
||||
safe_name = (original_name or "material").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
extension = Path(safe_name).suffix.lower().lstrip(".")
|
||||
if extension not in SUPPORTED_EXTENSIONS:
|
||||
supported = "、".join(item.upper() for item in SUPPORTED_EXTENSIONS)
|
||||
raise MaterialValidationError(415, f"仅支持 {supported}")
|
||||
|
||||
claimed = (claimed_mime_type or "").split(";", 1)[0].strip().lower()
|
||||
if claimed not in GENERIC_MIME_TYPES and claimed not in MIME_TYPES[extension]:
|
||||
raise MaterialValidationError(415, "文件 MIME 类型与扩展名不匹配")
|
||||
|
||||
if extension in IMAGE_EXTENSIONS:
|
||||
try:
|
||||
image = prepare_image(raw, safe_name)
|
||||
except DraftImageError as error:
|
||||
raise MaterialValidationError(error.status_code, str(error)) from error
|
||||
detected_group = {"jpg", "jpeg"} if image.extension == "jpg" else {image.extension}
|
||||
if extension not in detected_group:
|
||||
raise MaterialValidationError(415, "图片内容与扩展名不匹配")
|
||||
return PreparedMaterial(
|
||||
data=raw,
|
||||
file_name=safe_name,
|
||||
material_type="image",
|
||||
extension=extension,
|
||||
claimed_mime_type=claimed,
|
||||
mime_type=image.mime_type,
|
||||
sha256=hashlib.sha256(raw).hexdigest(),
|
||||
)
|
||||
|
||||
if extension == "pdf":
|
||||
if not raw.startswith(b"%PDF-"):
|
||||
raise MaterialValidationError(415, "PDF 文件签名无效")
|
||||
elif extension in {"txt", "log"}:
|
||||
_decode_text(raw)
|
||||
else:
|
||||
_validate_office_signature(raw, extension)
|
||||
|
||||
return PreparedMaterial(
|
||||
data=raw,
|
||||
file_name=safe_name,
|
||||
material_type=MATERIAL_TYPES[extension],
|
||||
extension=extension,
|
||||
claimed_mime_type=claimed,
|
||||
mime_type=CANONICAL_MIME_TYPES[extension],
|
||||
sha256=hashlib.sha256(raw).hexdigest(),
|
||||
)
|
||||
|
||||
|
||||
def scan_bytes(raw: bytes) -> dict:
|
||||
"""调用配置的扫描器;扫描不可用或响应异常时一律按失败处理。"""
|
||||
|
||||
backend = settings.TICKET_MATERIAL_SCANNER_BACKEND
|
||||
if backend == "test":
|
||||
return {"status": "clean", "engine": "test"}
|
||||
if backend != "clamav":
|
||||
raise MaterialScanError("未配置可用的恶意文件扫描器")
|
||||
|
||||
try:
|
||||
with socket.create_connection(
|
||||
(settings.CLAMAV_HOST, settings.CLAMAV_PORT),
|
||||
timeout=settings.CLAMAV_TIMEOUT_SECONDS,
|
||||
) as connection:
|
||||
connection.sendall(b"zINSTREAM\0")
|
||||
for offset in range(0, len(raw), 1024 * 1024):
|
||||
chunk = raw[offset : offset + 1024 * 1024]
|
||||
connection.sendall(struct.pack(">I", len(chunk)))
|
||||
connection.sendall(chunk)
|
||||
connection.sendall(struct.pack(">I", 0))
|
||||
response = b""
|
||||
while not response.endswith(b"\0"):
|
||||
block = connection.recv(4096)
|
||||
if not block:
|
||||
break
|
||||
response += block
|
||||
except OSError as error:
|
||||
raise MaterialScanError("恶意文件扫描服务暂时不可用") from error
|
||||
|
||||
message = response.rstrip(b"\0").decode("utf-8", errors="replace")
|
||||
if message.endswith(" OK"):
|
||||
return {"status": "clean", "engine": "clamav", "message": message}
|
||||
if message.endswith(" FOUND"):
|
||||
signature = message.rsplit(": ", 1)[-1].removesuffix(" FOUND")
|
||||
return {
|
||||
"status": "infected",
|
||||
"engine": "clamav",
|
||||
"signature": signature,
|
||||
"message": message,
|
||||
}
|
||||
raise MaterialScanError(f"恶意文件扫描未完成:{message or '无响应'}")
|
||||
|
||||
|
||||
def _parse_pdf(raw: bytes) -> str:
|
||||
from pypdf import PdfReader
|
||||
|
||||
reader = PdfReader(io.BytesIO(raw))
|
||||
return "\n\n".join((page.extract_text() or "").strip() for page in reader.pages).strip()
|
||||
|
||||
|
||||
def _parse_docx(raw: bytes) -> str:
|
||||
from docx import Document
|
||||
|
||||
document = Document(io.BytesIO(raw))
|
||||
return "\n".join(paragraph.text.strip() for paragraph in document.paragraphs if paragraph.text)
|
||||
|
||||
|
||||
def _parse_xlsx(raw: bytes) -> str:
|
||||
from openpyxl import load_workbook
|
||||
|
||||
workbook = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
|
||||
sections = []
|
||||
try:
|
||||
for sheet in workbook.worksheets:
|
||||
rows = []
|
||||
for row in sheet.iter_rows(values_only=True):
|
||||
values = [str(value).strip() for value in row if value not in (None, "")]
|
||||
if values:
|
||||
rows.append("\t".join(values))
|
||||
if rows:
|
||||
sections.append(f"[工作表:{sheet.title}]\n" + "\n".join(rows))
|
||||
finally:
|
||||
workbook.close()
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
def _parse_pptx(raw: bytes) -> str:
|
||||
from pptx import Presentation
|
||||
|
||||
presentation = Presentation(io.BytesIO(raw))
|
||||
slides = []
|
||||
for index, slide in enumerate(presentation.slides, start=1):
|
||||
texts = [
|
||||
shape.text.strip()
|
||||
for shape in slide.shapes
|
||||
if hasattr(shape, "text") and shape.text.strip()
|
||||
]
|
||||
if texts:
|
||||
slides.append(f"[幻灯片 {index}]\n" + "\n".join(texts))
|
||||
return "\n\n".join(slides)
|
||||
|
||||
|
||||
def parse_material(raw: bytes, extension: str) -> str:
|
||||
"""按已校验格式提取文本,解析失败时不伪造空成功结果。"""
|
||||
|
||||
try:
|
||||
if extension in {"txt", "log"}:
|
||||
text = _decode_text(raw)
|
||||
elif extension == "pdf":
|
||||
text = _parse_pdf(raw)
|
||||
elif extension == "docx":
|
||||
text = _parse_docx(raw)
|
||||
elif extension == "xlsx":
|
||||
text = _parse_xlsx(raw)
|
||||
elif extension == "pptx":
|
||||
text = _parse_pptx(raw)
|
||||
else:
|
||||
return ""
|
||||
except MaterialValidationError:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise MaterialParseError("材料内容解析失败") from error
|
||||
return text[: settings.TICKET_MATERIAL_MAX_PARSED_CHARS].strip()
|
||||
|
||||
|
||||
def process_material(material: TicketDraftMaterial, raw: bytes | None = None) -> None:
|
||||
"""扫描并解析单个材料,分别持久化扫描与解析终态。"""
|
||||
|
||||
raw = raw if raw is not None else get_object(material.storage_path)
|
||||
material.scan_status = "scanning"
|
||||
material.scan_result = {}
|
||||
material.parse_status = "pending"
|
||||
material.parse_error = ""
|
||||
material.save(
|
||||
update_fields=["scan_status", "scan_result", "parse_status", "parse_error"]
|
||||
)
|
||||
try:
|
||||
scan_result = scan_bytes(raw)
|
||||
except Exception as error:
|
||||
message = str(error) if isinstance(error, MaterialScanError) else "恶意文件扫描执行失败"
|
||||
material.scan_status = "failed"
|
||||
material.scan_result = {"status": "failed", "message": message}
|
||||
material.parse_status = "blocked"
|
||||
material.parse_error = "恶意文件扫描未通过,未执行解析"
|
||||
material.save(
|
||||
update_fields=["scan_status", "scan_result", "parse_status", "parse_error"]
|
||||
)
|
||||
return
|
||||
|
||||
material.scan_status = scan_result["status"]
|
||||
material.scan_result = scan_result
|
||||
if material.scan_status != "clean":
|
||||
material.parse_status = "blocked"
|
||||
material.parse_error = "材料被恶意文件扫描拦截,未执行解析"
|
||||
material.save(
|
||||
update_fields=["scan_status", "scan_result", "parse_status", "parse_error"]
|
||||
)
|
||||
return
|
||||
|
||||
if material.material_type == "image":
|
||||
material.parse_status = "not_applicable"
|
||||
material.parsed_text = ""
|
||||
material.save(
|
||||
update_fields=["scan_status", "scan_result", "parse_status", "parsed_text"]
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
material.parsed_text = parse_material(raw, material.extension)
|
||||
material.parse_status = "parsed"
|
||||
material.parse_error = ""
|
||||
except (MaterialParseError, MaterialValidationError) as error:
|
||||
material.parsed_text = ""
|
||||
material.parse_status = "failed"
|
||||
material.parse_error = str(error)
|
||||
material.save(
|
||||
update_fields=[
|
||||
"scan_status",
|
||||
"scan_result",
|
||||
"parse_status",
|
||||
"parse_error",
|
||||
"parsed_text",
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_intake_statuses(apps, schema_editor):
|
||||
TicketDraft = apps.get_model("tickets", "TicketDraft")
|
||||
TicketDraft.objects.filter(status="editing").update(status="collecting")
|
||||
TicketDraft.objects.filter(status="ready").update(status="needs_input")
|
||||
TicketDraft.objects.filter(status="confirmed").update(status="ticket_created")
|
||||
|
||||
|
||||
def restore_draft_statuses(apps, schema_editor):
|
||||
TicketDraft = apps.get_model("tickets", "TicketDraft")
|
||||
TicketDraft.objects.filter(status="collecting").update(status="editing")
|
||||
TicketDraft.objects.filter(status="needs_input").update(status="ready")
|
||||
TicketDraft.objects.filter(status="ticket_created").update(status="confirmed")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("tickets", "0005_ticketcollaborator_ticketfieldevidence_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="ticketdraft",
|
||||
name="field_evidence",
|
||||
field=models.JSONField(default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticketdraft",
|
||||
name="invalid_reason",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticketdraft",
|
||||
name="message_timeline",
|
||||
field=models.JSONField(default=list),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticketdraft",
|
||||
name="problem_valid",
|
||||
field=models.BooleanField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ticketdraft",
|
||||
name="status",
|
||||
field=models.CharField(default="collecting", max_length=24),
|
||||
),
|
||||
migrations.RunPython(migrate_intake_statuses, restore_draft_statuses),
|
||||
]
|
||||
100
backend/apps/tickets/migrations/0007_ticketdraftmaterial.py
Normal file
100
backend/apps/tickets/migrations/0007_ticketdraftmaterial.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("tickets", "0006_ticketdraft_intake_result"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="ticketattachment",
|
||||
name="parse_error",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticketattachment",
|
||||
name="parse_status",
|
||||
field=models.CharField(default="pending", max_length=24),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticketattachment",
|
||||
name="scan_result",
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticketattachment",
|
||||
name="scan_status",
|
||||
field=models.CharField(default="pending", max_length=24),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticketattachment",
|
||||
name="sha256",
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.RenameModel(
|
||||
old_name="TicketDraftImage",
|
||||
new_name="TicketDraftMaterial",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ticketdraftmaterial",
|
||||
name="draft",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="materials",
|
||||
to="tickets.ticketdraft",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="ticketdraftmaterial",
|
||||
name="confirmed_attachment",
|
||||
field=models.OneToOneField(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="source_draft_material",
|
||||
to="tickets.ticketattachment",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticketdraftmaterial",
|
||||
name="claimed_mime_type",
|
||||
field=models.CharField(blank=True, max_length=120),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticketdraftmaterial",
|
||||
name="extension",
|
||||
field=models.CharField(blank=True, max_length=16),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticketdraftmaterial",
|
||||
name="material_type",
|
||||
field=models.CharField(default="image", max_length=24),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticketdraftmaterial",
|
||||
name="parse_error",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticketdraftmaterial",
|
||||
name="parse_status",
|
||||
field=models.CharField(default="pending", max_length=24),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticketdraftmaterial",
|
||||
name="parsed_text",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticketdraftmaterial",
|
||||
name="scan_result",
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ticketdraftmaterial",
|
||||
name="scan_status",
|
||||
field=models.CharField(default="pending", max_length=24),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-27 02:44
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0007_ticketdraftmaterial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TicketMessageTimelineItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('sequence', models.PositiveIntegerField()),
|
||||
('speaker', models.CharField(blank=True, max_length=120)),
|
||||
('role', models.CharField(default='unknown', max_length=24)),
|
||||
('content', models.TextField()),
|
||||
('occurred_at', models.DateTimeField(blank=True, null=True)),
|
||||
('occurred_at_raw', models.CharField(blank=True, max_length=120)),
|
||||
('source_ref', models.CharField(blank=True, max_length=160)),
|
||||
('confidence', models.DecimalField(blank=True, decimal_places=4, max_digits=5, null=True)),
|
||||
('conflict', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('draft', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='timeline_items', to='tickets.ticketdraft')),
|
||||
('source_material', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='message_timeline_items', to='tickets.ticketdraftmaterial')),
|
||||
('ticket', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='message_timeline_items', to='tickets.ticket')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['sequence'],
|
||||
'indexes': [models.Index(fields=['ticket', 'sequence'], name='tickets_tic_ticket__20b0c4_idx'), models.Index(fields=['draft', 'sequence'], name='tickets_tic_draft_i_0adfa1_idx')],
|
||||
'constraints': [models.UniqueConstraint(fields=('draft', 'sequence'), name='unique_ticket_draft_timeline_sequence')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,83 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-27 03:03
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.operations import TrigramExtension
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0008_ticketmessagetimelineitem'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
TrigramExtension(),
|
||||
migrations.CreateModel(
|
||||
name='IntakeEvaluationCase',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key', models.SlugField(max_length=120, unique=True)),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('source_kind', models.CharField(default='synthetic', max_length=32)),
|
||||
('valid_input', models.BooleanField(default=True)),
|
||||
('input_manifest', models.JSONField(default=dict)),
|
||||
('expected_fields', models.JSONField(default=dict)),
|
||||
('expected_timeline', models.JSONField(default=list)),
|
||||
('expected_auto_created', models.BooleanField(default=True)),
|
||||
('expected_similar_ticket', models.BooleanField(default=False)),
|
||||
('notes', models.TextField(blank=True)),
|
||||
('enabled', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['key'],
|
||||
'indexes': [models.Index(fields=['source_kind', 'enabled'], name='tickets_int_source__74e817_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='IntakeEvaluationResult',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('actual_fields', models.JSONField(default=dict)),
|
||||
('actual_auto_created', models.BooleanField(default=False)),
|
||||
('correct_field_count', models.PositiveIntegerField(default=0)),
|
||||
('evaluated_field_count', models.PositiveIntegerField(default=0)),
|
||||
('field_accuracy', models.DecimalField(blank=True, decimal_places=5, max_digits=6, null=True)),
|
||||
('passed', models.BooleanField(default=False)),
|
||||
('details', models.JSONField(blank=True, default=dict)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('case', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='results', to='tickets.intakeevaluationcase')),
|
||||
('draft', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evaluation_results', to='tickets.ticketdraft')),
|
||||
('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_intake_evaluations', to=settings.AUTH_USER_MODEL)),
|
||||
('ticket', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='intake_evaluation_results', to='tickets.ticket')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
'indexes': [models.Index(fields=['case', 'created_at'], name='tickets_int_case_id_8c9c27_idx')],
|
||||
'constraints': [models.UniqueConstraint(fields=('case', 'draft'), name='unique_intake_case_draft_result')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TicketSimilaritySuggestion',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('score', models.DecimalField(decimal_places=5, max_digits=6)),
|
||||
('title_similarity', models.DecimalField(decimal_places=5, default=0, max_digits=6)),
|
||||
('keyword_score', models.DecimalField(decimal_places=5, default=0, max_digits=6)),
|
||||
('matched_excerpt', models.TextField(blank=True)),
|
||||
('algorithm_version', models.CharField(default='fts_trigram_v1', max_length=40)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('candidate_ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='suggested_for_tickets', to='tickets.ticket')),
|
||||
('source_ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='similarity_suggestions', to='tickets.ticket')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-score', 'candidate_ticket_id'],
|
||||
'indexes': [models.Index(fields=['source_ticket', 'algorithm_version', 'score'], name='tickets_tic_source__88b288_idx')],
|
||||
'constraints': [models.UniqueConstraint(fields=('source_ticket', 'candidate_ticket', 'algorithm_version'), name='unique_ticket_similarity_suggestion'), models.CheckConstraint(condition=models.Q(('source_ticket', models.F('candidate_ticket')), _negated=True), name='ticket_similarity_not_self')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-27 03:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0009_intakeevaluationcase_intakeevaluationresult_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ticket',
|
||||
name='attempted_actions',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticket',
|
||||
name='current_result',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticket',
|
||||
name='customer_request',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticket',
|
||||
name='error_information',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticket',
|
||||
name='module_name',
|
||||
field=models.CharField(blank=True, max_length=120),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticket',
|
||||
name='product_name',
|
||||
field=models.CharField(blank=True, max_length=120),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticket',
|
||||
name='tags',
|
||||
field=models.JSONField(blank=True, default=list),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ticketmessagetimelineitem',
|
||||
name='timezone',
|
||||
field=models.CharField(blank=True, max_length=80),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ticketsimilaritysuggestion',
|
||||
name='algorithm_version',
|
||||
field=models.CharField(default='fts_trigram_v2_context', max_length=40),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-27 03:55
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tickets', '0010_ticket_attempted_actions_ticket_current_result_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='intakeevaluationresult',
|
||||
options={'ordering': ['-updated_at']},
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name='intakeevaluationresult',
|
||||
name='tickets_int_case_id_8c9c27_idx',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='intakeevaluationresult',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='intakeevaluationresult',
|
||||
index=models.Index(
|
||||
fields=['case', 'updated_at'],
|
||||
name='tickets_int_case_id_79c7c4_idx',
|
||||
),
|
||||
),
|
||||
]
|
||||
250
backend/apps/tickets/similarity.py
Normal file
250
backend/apps/tickets/similarity.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""相似工单候选生成。
|
||||
|
||||
相似结果只用于非阻断提示,不合并工单,也不改变正式工单的创建和状态流转。
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector, TrigramSimilarity
|
||||
from django.db import connection, transaction
|
||||
from django.db.models import F, Q
|
||||
from django.db.models.functions import Greatest
|
||||
|
||||
from apps.tickets.models import Ticket, TicketSimilaritySuggestion
|
||||
|
||||
ALGORITHM_VERSION = "fts_trigram_v2_context"
|
||||
MIN_CANDIDATE_SCORE = 0.12
|
||||
|
||||
|
||||
def _normalize_text(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", value.casefold()).strip()
|
||||
|
||||
|
||||
def _terms(value: str) -> set[str]:
|
||||
normalized = _normalize_text(value)
|
||||
words = set(re.findall(r"[a-z0-9][a-z0-9_.:-]{1,}|[\u4e00-\u9fff]{2,}", normalized))
|
||||
chinese = "".join(re.findall(r"[\u4e00-\u9fff]", normalized))
|
||||
words.update(chinese[index : index + 2] for index in range(max(len(chinese) - 1, 0)))
|
||||
return {term for term in words if term}
|
||||
|
||||
|
||||
def _keyword_similarity(left: str, right: str) -> float:
|
||||
left_terms = _terms(left)
|
||||
right_terms = _terms(right)
|
||||
if not left_terms or not right_terms:
|
||||
return 0.0
|
||||
return len(left_terms & right_terms) / len(left_terms | right_terms)
|
||||
|
||||
|
||||
def _excerpt(text: str, query: str, limit: int = 180) -> str:
|
||||
normalized = text.strip()
|
||||
if len(normalized) <= limit:
|
||||
return normalized
|
||||
terms = sorted(_terms(query), key=len, reverse=True)
|
||||
position = next(
|
||||
(normalized.casefold().find(term) for term in terms if term in normalized.casefold()),
|
||||
0,
|
||||
)
|
||||
start = max(position - limit // 3, 0)
|
||||
end = min(start + limit, len(normalized))
|
||||
prefix = "…" if start else ""
|
||||
suffix = "…" if end < len(normalized) else ""
|
||||
return f"{prefix}{normalized[start:end]}{suffix}"
|
||||
|
||||
|
||||
def _serialize_candidate(
|
||||
ticket: Ticket,
|
||||
*,
|
||||
score: float,
|
||||
title_similarity: float,
|
||||
keyword_score: float,
|
||||
query: str,
|
||||
) -> dict:
|
||||
return {
|
||||
"ticket": ticket,
|
||||
"score": max(0.0, min(float(score), 1.0)),
|
||||
"title_similarity": max(0.0, min(float(title_similarity), 1.0)),
|
||||
"keyword_score": max(0.0, min(float(keyword_score), 1.0)),
|
||||
"matched_excerpt": _excerpt(ticket.description, query),
|
||||
}
|
||||
|
||||
|
||||
def _postgres_candidates(
|
||||
queryset,
|
||||
*,
|
||||
title: str,
|
||||
query_text: str,
|
||||
limit: int,
|
||||
) -> list[dict]:
|
||||
search_query = SearchQuery(query_text, config="simple", search_type="plain")
|
||||
search_vector = (
|
||||
SearchVector("title", config="simple", weight="A")
|
||||
+ SearchVector("description", config="simple", weight="B")
|
||||
+ SearchVector("problem_performance", config="simple", weight="B")
|
||||
+ SearchVector("error_information", config="simple", weight="B")
|
||||
+ SearchVector("product_name", config="simple", weight="C")
|
||||
+ SearchVector("module_name", config="simple", weight="C")
|
||||
+ SearchVector("related_system", config="simple", weight="C")
|
||||
+ SearchVector("attempted_actions", config="simple", weight="C")
|
||||
+ SearchVector("customer_request", config="simple", weight="C")
|
||||
+ SearchVector("current_result", config="simple", weight="C")
|
||||
)
|
||||
rows = (
|
||||
queryset.annotate(
|
||||
keyword_score=SearchRank(search_vector, search_query),
|
||||
title_similarity=TrigramSimilarity("title", title),
|
||||
)
|
||||
.annotate(score=Greatest(F("title_similarity"), F("keyword_score")))
|
||||
.filter(Q(title_similarity__gte=MIN_CANDIDATE_SCORE) | Q(keyword_score__gte=0.05))
|
||||
.order_by("-score", "-updated_at")[:limit]
|
||||
)
|
||||
return [
|
||||
_serialize_candidate(
|
||||
ticket,
|
||||
score=ticket.score,
|
||||
title_similarity=ticket.title_similarity,
|
||||
keyword_score=ticket.keyword_score,
|
||||
query=query_text,
|
||||
)
|
||||
for ticket in rows
|
||||
]
|
||||
|
||||
|
||||
def _portable_candidates(
|
||||
queryset: Iterable[Ticket],
|
||||
*,
|
||||
title: str,
|
||||
query_text: str,
|
||||
limit: int,
|
||||
) -> list[dict]:
|
||||
normalized_title = _normalize_text(title)
|
||||
candidates = []
|
||||
for ticket in queryset:
|
||||
title_similarity = SequenceMatcher(
|
||||
None,
|
||||
normalized_title,
|
||||
_normalize_text(ticket.title),
|
||||
).ratio()
|
||||
keyword_score = _keyword_similarity(
|
||||
query_text,
|
||||
" ".join(
|
||||
[
|
||||
ticket.title,
|
||||
ticket.description,
|
||||
ticket.problem_performance,
|
||||
ticket.error_information,
|
||||
ticket.product_name,
|
||||
ticket.module_name,
|
||||
ticket.related_system,
|
||||
ticket.attempted_actions,
|
||||
ticket.customer_request,
|
||||
ticket.current_result,
|
||||
*ticket.tags,
|
||||
]
|
||||
),
|
||||
)
|
||||
score = max(title_similarity, keyword_score)
|
||||
if score < MIN_CANDIDATE_SCORE:
|
||||
continue
|
||||
candidates.append(
|
||||
_serialize_candidate(
|
||||
ticket,
|
||||
score=score,
|
||||
title_similarity=title_similarity,
|
||||
keyword_score=keyword_score,
|
||||
query=query_text,
|
||||
)
|
||||
)
|
||||
return sorted(
|
||||
candidates,
|
||||
key=lambda item: (item["score"], item["ticket"].updated_at),
|
||||
reverse=True,
|
||||
)[:limit]
|
||||
|
||||
|
||||
def find_similar_tickets(ticket: Ticket, *, limit: int = 5) -> list[dict]:
|
||||
"""返回可解释的相似候选,不修改或合并任何工单。"""
|
||||
|
||||
title = ticket.title.strip() or ticket.description[:200]
|
||||
query_text = " ".join(
|
||||
value
|
||||
for value in [
|
||||
ticket.title,
|
||||
ticket.description,
|
||||
ticket.problem_performance,
|
||||
ticket.error_information,
|
||||
ticket.product_name,
|
||||
ticket.module_name,
|
||||
ticket.related_system,
|
||||
ticket.attempted_actions,
|
||||
ticket.customer_request,
|
||||
ticket.current_result,
|
||||
*ticket.tags,
|
||||
]
|
||||
if value
|
||||
).strip()
|
||||
if not query_text:
|
||||
return []
|
||||
queryset = Ticket.objects.filter(archived_at__isnull=True).exclude(id=ticket.id).select_related(
|
||||
"category"
|
||||
)
|
||||
if connection.vendor == "postgresql":
|
||||
return _postgres_candidates(
|
||||
queryset,
|
||||
title=title,
|
||||
query_text=query_text,
|
||||
limit=limit,
|
||||
)
|
||||
return _portable_candidates(
|
||||
queryset,
|
||||
title=title,
|
||||
query_text=query_text,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def refresh_ticket_similarity_suggestions(
|
||||
ticket: Ticket,
|
||||
*,
|
||||
limit: int = 5,
|
||||
) -> list[TicketSimilaritySuggestion]:
|
||||
"""原子替换指定工单的相似候选快照。"""
|
||||
|
||||
ticket = Ticket.objects.select_for_update().get(id=ticket.id)
|
||||
candidates = find_similar_tickets(ticket, limit=limit)
|
||||
TicketSimilaritySuggestion.objects.filter(
|
||||
source_ticket=ticket,
|
||||
algorithm_version=ALGORITHM_VERSION,
|
||||
).delete()
|
||||
TicketSimilaritySuggestion.objects.bulk_create(
|
||||
[
|
||||
TicketSimilaritySuggestion(
|
||||
source_ticket=ticket,
|
||||
candidate_ticket=item["ticket"],
|
||||
score=item["score"],
|
||||
title_similarity=item["title_similarity"],
|
||||
keyword_score=item["keyword_score"],
|
||||
matched_excerpt=item["matched_excerpt"],
|
||||
algorithm_version=ALGORITHM_VERSION,
|
||||
)
|
||||
for item in candidates
|
||||
]
|
||||
)
|
||||
return list(
|
||||
TicketSimilaritySuggestion.objects.filter(
|
||||
source_ticket=ticket,
|
||||
algorithm_version=ALGORITHM_VERSION,
|
||||
).select_related("candidate_ticket__category")
|
||||
)
|
||||
|
||||
|
||||
def schedule_ticket_similarity_refresh(ticket: Ticket) -> None:
|
||||
transaction.on_commit(
|
||||
lambda ticket_id=ticket.id: refresh_ticket_similarity_suggestions(
|
||||
Ticket.objects.get(id=ticket_id)
|
||||
),
|
||||
robust=True,
|
||||
)
|
||||
@@ -30,8 +30,9 @@ FORMATS = {
|
||||
|
||||
|
||||
def prepare_image(raw: bytes, original_name: str) -> PreparedImage:
|
||||
if len(raw) > 10 * 1024 * 1024:
|
||||
raise DraftImageError(413, "单张图片不能超过 10 MB")
|
||||
max_bytes = settings.TICKET_MATERIAL_MAX_FILE_BYTES
|
||||
if len(raw) > max_bytes:
|
||||
raise DraftImageError(413, f"单个材料不能超过 {max_bytes // 1024 // 1024} MB")
|
||||
try:
|
||||
with Image.open(io.BytesIO(raw)) as image:
|
||||
image.verify()
|
||||
|
||||
@@ -18,13 +18,13 @@ def execute_ticket_draft_analysis(draft_id: int, run_id: int) -> None:
|
||||
def cleanup_expired_ticket_drafts() -> int:
|
||||
queryset = TicketDraft.objects.filter(
|
||||
expires_at__lt=timezone.now(), confirmed_ticket__isnull=True
|
||||
).prefetch_related("images")
|
||||
).prefetch_related("materials")
|
||||
count = queryset.count()
|
||||
for draft in queryset:
|
||||
public_id = str(draft.public_id)
|
||||
for image in draft.images.all():
|
||||
for material in draft.materials.all():
|
||||
try:
|
||||
delete_object(image.storage_path)
|
||||
delete_object(material.storage_path)
|
||||
except Exception:
|
||||
pass
|
||||
OperationAuditLog.objects.create(
|
||||
@@ -36,7 +36,7 @@ def cleanup_expired_ticket_drafts() -> int:
|
||||
request_snapshot={
|
||||
"status": draft.status,
|
||||
"guided_rounds": draft.guided_rounds,
|
||||
"image_count": draft.images.count(),
|
||||
"material_count": draft.materials.count(),
|
||||
},
|
||||
result="success",
|
||||
)
|
||||
|
||||
341
backend/apps/tickets/tests/test_stage_2_3.py
Normal file
341
backend/apps/tickets/tests/test_stage_2_3.py
Normal file
@@ -0,0 +1,341 @@
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import call_command
|
||||
|
||||
from apps.agents.models import AgentRun
|
||||
from apps.agents.services import append_run_event, build_config_snapshot
|
||||
from apps.agents.tests.helpers import create_agent, create_deployment
|
||||
from apps.tickets.draft_services import (
|
||||
_mark_analysis_failed,
|
||||
_replace_message_timeline,
|
||||
create_agent_run_for_draft,
|
||||
execute_draft_analysis,
|
||||
)
|
||||
from apps.tickets.models import TicketCategory, TicketDraft
|
||||
from apps.tickets.services import update_ticket_fields
|
||||
from apps.workitems.models import WorkItem
|
||||
|
||||
|
||||
def publish_intake_agent():
|
||||
deployment = create_deployment()
|
||||
call_command("publish_trusted_ticket_intake", deployment_id=deployment.id)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_intake_persists_timeline_creates_unique_task_and_orders_events(monkeypatch):
|
||||
publish_intake_agent()
|
||||
user = get_user_model().objects.create_user(username="stage-23-owner")
|
||||
draft = TicketDraft.objects.create(
|
||||
created_by=user,
|
||||
source_text="客户反馈生产环境从十点开始无法登录",
|
||||
)
|
||||
run = create_agent_run_for_draft(draft)
|
||||
|
||||
def fake_execute(run_id, **_kwargs):
|
||||
AgentRun.objects.filter(id=run_id).update(
|
||||
status="succeeded",
|
||||
final_output={
|
||||
"problem_valid": True,
|
||||
"invalid_reason": "",
|
||||
"fields": {
|
||||
"description": "客户反馈生产环境从十点开始无法登录",
|
||||
"priority": "medium",
|
||||
},
|
||||
"field_evidence": {},
|
||||
"message_timeline": [
|
||||
{
|
||||
"speaker": "客户",
|
||||
"role": "customer",
|
||||
"content": "生产环境从十点开始无法登录",
|
||||
"occurred_at": "2026-07-27T10:00:00+08:00",
|
||||
"source_ref": "source_text",
|
||||
"confidence": 0.96,
|
||||
"conflict": "",
|
||||
}
|
||||
],
|
||||
"missing_fields": ["category_code", "environment"],
|
||||
"questions": ["请确认受影响机构。"],
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("apps.tickets.draft_services.execute_agent_run", fake_execute)
|
||||
|
||||
execute_draft_analysis(draft.id, run.id)
|
||||
execute_draft_analysis(draft.id, run.id)
|
||||
|
||||
draft.refresh_from_db()
|
||||
ticket = draft.confirmed_ticket
|
||||
timeline = draft.timeline_items.get()
|
||||
assert timeline.ticket == ticket
|
||||
assert timeline.role == "customer"
|
||||
assert timeline.occurred_at is not None
|
||||
assert float(timeline.confidence) == 0.96
|
||||
assert WorkItem.objects.filter(
|
||||
ticket=ticket,
|
||||
task_type="ticket_missing_info",
|
||||
status="active",
|
||||
).count() == 1
|
||||
task = WorkItem.objects.get(ticket=ticket, status="active")
|
||||
assert task.payload["questions"] == ["请确认受影响机构。"]
|
||||
assert task.payload["draft_public_id"] == str(draft.public_id)
|
||||
event_types = list(run.events.values_list("event_type", flat=True))
|
||||
required = ["queued", "parsing", "extracting", "creating_ticket", "completed"]
|
||||
positions = [event_types.index(event_type) for event_type in required]
|
||||
assert positions == sorted(positions)
|
||||
assert event_types.count("completed") == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_missing_info_task_completes_after_required_fields_are_filled():
|
||||
publish_intake_agent()
|
||||
user = get_user_model().objects.create_user(username="stage-23-editor")
|
||||
draft = TicketDraft.objects.create(created_by=user, source_text="无法登录")
|
||||
run = create_agent_run_for_draft(draft)
|
||||
from apps.tickets.draft_services import _auto_create_ticket
|
||||
|
||||
created = _auto_create_ticket(
|
||||
draft.id,
|
||||
run,
|
||||
fields={"description": "无法登录", "priority": "medium"},
|
||||
field_evidence={},
|
||||
confidence_by_field={},
|
||||
human_field_names=set(),
|
||||
)
|
||||
ticket = created.confirmed_ticket
|
||||
assert WorkItem.objects.get(ticket=ticket).status == "active"
|
||||
category, _ = TicketCategory.objects.get_or_create(
|
||||
code="technical_issue",
|
||||
defaults={"name": "技术问题"},
|
||||
)
|
||||
|
||||
update_ticket_fields(
|
||||
ticket,
|
||||
{
|
||||
"title": "生产环境无法登录",
|
||||
"category_code": category.code,
|
||||
"problem_performance": "登录提示服务异常",
|
||||
"problem_object": "统一身份平台",
|
||||
"submitter_name": "张三",
|
||||
"submitter_org": "客户机构",
|
||||
"environment": "生产环境",
|
||||
},
|
||||
operator=user,
|
||||
)
|
||||
|
||||
task = WorkItem.objects.get(ticket=ticket)
|
||||
ticket.refresh_from_db()
|
||||
assert task.status == "completed"
|
||||
assert task.completed_at is not None
|
||||
assert task.completed_by == user
|
||||
assert ticket.status == "pending"
|
||||
assert ticket.events.filter(
|
||||
event_type="status_change",
|
||||
from_value__status="pending_info",
|
||||
to_value__status="pending",
|
||||
source="system",
|
||||
).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_timeline_replacement_keeps_sequences_unique():
|
||||
user = get_user_model().objects.create_user(username="stage-23-timeline")
|
||||
draft = TicketDraft.objects.create(created_by=user, source_text="问题")
|
||||
_replace_message_timeline(
|
||||
draft,
|
||||
[
|
||||
{
|
||||
"sequence": 1,
|
||||
"speaker": "客户",
|
||||
"role": "customer",
|
||||
"content": "第一次描述",
|
||||
"occurred_at": "",
|
||||
"source_ref": "source_text",
|
||||
"confidence": 0.8,
|
||||
"conflict": "",
|
||||
}
|
||||
],
|
||||
)
|
||||
_replace_message_timeline(
|
||||
draft,
|
||||
[
|
||||
{
|
||||
"sequence": 1,
|
||||
"speaker": "支持",
|
||||
"role": "support",
|
||||
"content": "补充提问",
|
||||
"occurred_at": "",
|
||||
"source_ref": "source_text",
|
||||
"confidence": None,
|
||||
"conflict": "",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert draft.timeline_items.count() == 1
|
||||
assert draft.timeline_items.get().content == "补充提问"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sse_supports_cursor_and_enforces_run_ownership(client):
|
||||
owner = get_user_model().objects.create_user(username="stage-23-sse-owner")
|
||||
outsider = get_user_model().objects.create_user(username="stage-23-sse-outsider")
|
||||
_, version = create_agent(two_steps=False)
|
||||
run = AgentRun.objects.create(
|
||||
definition=version.definition,
|
||||
agent_version=version,
|
||||
input_payload={"problem": "无法登录"},
|
||||
config_snapshot=build_config_snapshot(version),
|
||||
created_by=owner,
|
||||
)
|
||||
append_run_event(run, "queued")
|
||||
append_run_event(run, "parsing")
|
||||
append_run_event(run, "completed")
|
||||
|
||||
client.force_login(owner)
|
||||
response = client.get(f"/api/agent-runs/{run.public_id}/events?after=1")
|
||||
assert response.status_code == 200
|
||||
body = b"".join(response.streaming_content).decode()
|
||||
assert "id: 1\n" not in body
|
||||
assert "id: 2\n" in body
|
||||
assert "id: 3\n" in body
|
||||
assert response["Cache-Control"] == "no-cache"
|
||||
|
||||
response = client.get(
|
||||
f"/api/agent-runs/{run.public_id}/events",
|
||||
HTTP_LAST_EVENT_ID="2",
|
||||
)
|
||||
body = b"".join(response.streaming_content).decode()
|
||||
assert "id: 2\n" not in body
|
||||
assert "id: 3\n" in body
|
||||
|
||||
client.force_login(outsider)
|
||||
response = client.get(f"/api/agent-runs/{run.public_id}/events")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_task_api_filters_by_ticket(admin_client):
|
||||
publish_intake_agent()
|
||||
user = get_user_model().objects.create_user(username="stage-23-task-api")
|
||||
draft = TicketDraft.objects.create(created_by=user, source_text="无法登录")
|
||||
run = create_agent_run_for_draft(draft)
|
||||
from apps.tickets.draft_services import _auto_create_ticket
|
||||
|
||||
created = _auto_create_ticket(
|
||||
draft.id,
|
||||
run,
|
||||
fields={"description": "无法登录"},
|
||||
field_evidence={},
|
||||
confidence_by_field={},
|
||||
human_field_names=set(),
|
||||
)
|
||||
|
||||
response = admin_client.get(f"/api/tasks?ticket_id={created.confirmed_ticket_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 1
|
||||
assert response.json()[0]["task_type"] == "ticket_missing_info"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_failed_intake_creates_one_takeover_task_and_retry_completes_it():
|
||||
publish_intake_agent()
|
||||
user = get_user_model().objects.create_user(username="stage-23-takeover")
|
||||
draft = TicketDraft.objects.create(created_by=user, source_text="生产环境无法登录")
|
||||
run = create_agent_run_for_draft(draft)
|
||||
|
||||
_mark_analysis_failed(
|
||||
draft,
|
||||
error_code="model_unavailable",
|
||||
error_message="模型服务暂不可用",
|
||||
run=run,
|
||||
)
|
||||
draft.refresh_from_db()
|
||||
_mark_analysis_failed(
|
||||
draft,
|
||||
error_code="model_unavailable",
|
||||
error_message="模型服务暂不可用",
|
||||
run=run,
|
||||
)
|
||||
|
||||
task = WorkItem.objects.get(
|
||||
task_type="ticket_intake_failed",
|
||||
object_type="ticket_draft",
|
||||
object_id=str(draft.public_id),
|
||||
)
|
||||
assert task.status == "active"
|
||||
assert task.payload["agent_run_public_id"] == str(run.public_id)
|
||||
assert task.payload["error_code"] == "model_unavailable"
|
||||
assert task.assignee == user
|
||||
|
||||
draft.refresh_from_db()
|
||||
retry_run = create_agent_run_for_draft(draft)
|
||||
|
||||
task.refresh_from_db()
|
||||
draft.refresh_from_db()
|
||||
assert task.status == "completed"
|
||||
assert task.completed_by == user
|
||||
assert draft.status == "analyzing"
|
||||
assert draft.latest_agent_run == retry_run
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cancel_ticket_draft_run_preserves_materials_and_cancelled_semantics(
|
||||
client,
|
||||
django_capture_on_commit_callbacks,
|
||||
monkeypatch,
|
||||
):
|
||||
publish_intake_agent()
|
||||
user = get_user_model().objects.create_superuser(
|
||||
username="stage-23-cancel",
|
||||
email="cancel@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
draft = TicketDraft.objects.create(
|
||||
created_by=user,
|
||||
source_text="生产环境无法登录",
|
||||
)
|
||||
run = create_agent_run_for_draft(draft)
|
||||
run.task_id = "celery-task-1"
|
||||
run.save(update_fields=["task_id"])
|
||||
revoked = []
|
||||
|
||||
class FakeAsyncResult:
|
||||
def __init__(self, task_id):
|
||||
self.task_id = task_id
|
||||
|
||||
def revoke(self, *, terminate):
|
||||
revoked.append((self.task_id, terminate))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"apps.tickets.draft_services.AsyncResult",
|
||||
FakeAsyncResult,
|
||||
)
|
||||
client.force_login(user)
|
||||
|
||||
with django_capture_on_commit_callbacks(execute=True):
|
||||
response = client.post(
|
||||
f"/api/ticket-drafts/{draft.public_id}/cancel",
|
||||
data={"expected_revision": draft.revision},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "failed"
|
||||
assert response.json()["error_code"] == "analysis_cancelled"
|
||||
draft.refresh_from_db()
|
||||
run.refresh_from_db()
|
||||
assert run.status == "cancelled"
|
||||
assert run.error_code == ""
|
||||
assert revoked == [("celery-task-1", False)]
|
||||
assert run.events.filter(event_type="cancelled").count() == 1
|
||||
assert WorkItem.objects.filter(
|
||||
task_type="ticket_intake_failed",
|
||||
object_id=str(draft.public_id),
|
||||
status="active",
|
||||
).count() == 1
|
||||
|
||||
execute_draft_analysis(draft.id, run.id)
|
||||
run.refresh_from_db()
|
||||
assert run.status == "cancelled"
|
||||
assert run.events.filter(event_type="cancelled").count() == 1
|
||||
516
backend/apps/tickets/tests/test_stage_2_4.py
Normal file
516
backend/apps/tickets/tests/test_stage_2_4.py
Normal file
@@ -0,0 +1,516 @@
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.tickets.evaluation import (
|
||||
IntakeEvaluationError,
|
||||
build_intake_quality_metrics,
|
||||
build_stage_2_acceptance_report,
|
||||
import_evaluation_cases,
|
||||
record_evaluation_result,
|
||||
)
|
||||
from apps.tickets.models import (
|
||||
IntakeEvaluationCase,
|
||||
IntakeEvaluationResult,
|
||||
Ticket,
|
||||
TicketDraft,
|
||||
TicketSimilaritySuggestion,
|
||||
)
|
||||
from apps.tickets.similarity import find_similar_tickets, refresh_ticket_similarity_suggestions
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_similar_ticket_candidates_are_scored_and_do_not_include_self():
|
||||
historical = Ticket.objects.create(
|
||||
ticket_no="TK202607270101",
|
||||
title="生产环境数据同步任务停止更新",
|
||||
description="同步任务报错 SYNC-504,进度长时间不更新。",
|
||||
status="resolved",
|
||||
)
|
||||
Ticket.objects.create(
|
||||
ticket_no="TK202607270102",
|
||||
title="用户咨询报表下载入口",
|
||||
description="需要确认报表下载菜单位置。",
|
||||
)
|
||||
current = Ticket.objects.create(
|
||||
ticket_no="TK202607270103",
|
||||
title="生产数据同步停止更新",
|
||||
description="生产环境同步任务出现 SYNC-504 后没有新进度。",
|
||||
)
|
||||
|
||||
candidates = find_similar_tickets(current)
|
||||
|
||||
assert candidates[0]["ticket"] == historical
|
||||
assert candidates[0]["score"] > 0.5
|
||||
assert all(item["ticket"].id != current.id for item in candidates)
|
||||
assert "SYNC-504" in candidates[0]["matched_excerpt"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_duplicate_problem_creates_independent_ticket_and_similarity_snapshot(admin_client):
|
||||
first = Ticket.objects.create(
|
||||
ticket_no="TK202607270111",
|
||||
title="回调任务持续超时",
|
||||
description="生产环境回调任务持续超时,错误码 CALLBACK-500。",
|
||||
status="resolved",
|
||||
)
|
||||
duplicate = Ticket.objects.create(
|
||||
ticket_no="TK202607270112",
|
||||
title="回调任务持续超时",
|
||||
description="生产环境回调任务仍然超时,错误码 CALLBACK-500。",
|
||||
)
|
||||
|
||||
suggestions = refresh_ticket_similarity_suggestions(duplicate)
|
||||
|
||||
assert first.id != duplicate.id
|
||||
assert Ticket.objects.count() == 2
|
||||
assert suggestions[0].candidate_ticket == first
|
||||
assert TicketSimilaritySuggestion.objects.filter(
|
||||
source_ticket=duplicate,
|
||||
candidate_ticket=first,
|
||||
).exists()
|
||||
response = admin_client.get(f"/api/tickets/{duplicate.id}/similar")
|
||||
assert response.status_code == 200
|
||||
assert response.json()[0]["ticket_id"] == first.id
|
||||
assert response.json()[0]["score"] > 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_real_case_metrics_use_weighted_field_accuracy_and_exclude_synthetic(admin_client):
|
||||
user = get_user_model().objects.create_user(username="stage-24-reviewer")
|
||||
ticket = Ticket.objects.create(
|
||||
ticket_no="TK202607270121",
|
||||
title="生产环境登录异常",
|
||||
description="统一登录提示服务异常",
|
||||
)
|
||||
draft = TicketDraft.objects.create(
|
||||
created_by=user,
|
||||
source_text="生产环境登录异常",
|
||||
problem_valid=True,
|
||||
current_fields={
|
||||
"title": "生产环境登录异常",
|
||||
"description": "统一登录提示服务异常",
|
||||
"environment": "生产环境",
|
||||
},
|
||||
decision="auto_created",
|
||||
confirmed_ticket=ticket,
|
||||
)
|
||||
real_case = IntakeEvaluationCase.objects.create(
|
||||
key="real-login-001",
|
||||
name="脱敏登录异常",
|
||||
source_kind="anonymized_real",
|
||||
expected_fields={
|
||||
"title": "生产环境登录异常",
|
||||
"description": "统一登录提示服务异常",
|
||||
"environment": "测试环境",
|
||||
},
|
||||
expected_auto_created=True,
|
||||
)
|
||||
synthetic_case = IntakeEvaluationCase.objects.create(
|
||||
key="synthetic-unused-001",
|
||||
name="合成样例",
|
||||
source_kind="synthetic",
|
||||
expected_fields={"title": "不计入默认指标"},
|
||||
)
|
||||
|
||||
result = record_evaluation_result(real_case, draft, reviewer=user)
|
||||
record_evaluation_result(synthetic_case, draft, reviewer=user)
|
||||
metrics = build_intake_quality_metrics()
|
||||
|
||||
assert result.correct_field_count == 2
|
||||
assert result.evaluated_field_count == 3
|
||||
assert float(result.field_accuracy) == pytest.approx(2 / 3, rel=1e-4)
|
||||
assert metrics["evaluation"]["enabled_case_count"] == 1
|
||||
assert metrics["evaluation"]["reviewed_case_count"] == 1
|
||||
assert metrics["evaluation"]["auto_creation_rate"] == 1.0
|
||||
assert metrics["evaluation"]["field_accuracy"] == pytest.approx(2 / 3)
|
||||
assert metrics["evaluation"]["evidence_coverage_rate"] == 0
|
||||
assert metrics["targets"]["coverage_complete"] is True
|
||||
assert metrics["targets"]["auto_creation_rate_met"] is True
|
||||
assert metrics["targets"]["field_accuracy_met"] is False
|
||||
response = admin_client.get("/api/tickets/intake-quality-metrics")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["include_synthetic"] is False
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_metrics_do_not_claim_success_without_real_case_results():
|
||||
IntakeEvaluationCase.objects.create(
|
||||
key="real-not-run-001",
|
||||
name="尚未执行的真实案例",
|
||||
source_kind="anonymized_real",
|
||||
expected_fields={"description": "待执行"},
|
||||
)
|
||||
|
||||
metrics = build_intake_quality_metrics()
|
||||
|
||||
assert metrics["evaluation"]["coverage_rate"] == 0
|
||||
assert metrics["evaluation"]["auto_creation_rate"] is None
|
||||
assert metrics["evaluation"]["field_accuracy"] is None
|
||||
assert metrics["targets"]["coverage_complete"] is False
|
||||
assert metrics["targets"]["auto_creation_rate_met"] is None
|
||||
assert metrics["targets"]["field_accuracy_met"] is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_sample_case_file_is_explicitly_synthetic():
|
||||
path = Path(settings.BASE_DIR) / "docs/product/acceptance/stage2-sample-cases.json"
|
||||
|
||||
created, updated = import_evaluation_cases(path)
|
||||
|
||||
assert created == 6
|
||||
assert updated == 0
|
||||
assert IntakeEvaluationCase.objects.count() == 6
|
||||
assert not IntakeEvaluationCase.objects.exclude(source_kind="synthetic").exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_real_case_template_is_valid_but_disabled():
|
||||
path = Path(settings.BASE_DIR) / "docs/product/acceptance/stage2-real-cases.template.json"
|
||||
|
||||
created, updated = import_evaluation_cases(path)
|
||||
|
||||
assert created == 4
|
||||
assert updated == 0
|
||||
assert IntakeEvaluationCase.objects.filter(source_kind="anonymized_real").count() == 4
|
||||
assert not IntakeEvaluationCase.objects.filter(enabled=True).exists()
|
||||
report = build_stage_2_acceptance_report()
|
||||
assert report["status"] == "not_ready"
|
||||
assert report["reasons"][0]["code"] == "missing_real_cases"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_real_case_import_rejects_missing_or_non_boolean_acceptance_fields(tmp_path):
|
||||
path = tmp_path / "invalid-real-cases.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"cases": [
|
||||
{
|
||||
"key": "real-invalid-schema-001",
|
||||
"source_kind": "anonymized_real",
|
||||
"valid_input": "false",
|
||||
"input_manifest": {"source_text": "脱敏问题", "materials": []},
|
||||
"expected_fields": {},
|
||||
"expected_timeline": [],
|
||||
"expected_auto_created": False,
|
||||
}
|
||||
],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with pytest.raises(IntakeEvaluationError, match="布尔字段类型错误"):
|
||||
import_evaluation_cases(path)
|
||||
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
payload["cases"][0]["valid_input"] = False
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
with pytest.raises(IntakeEvaluationError, match="expected_similar_ticket"):
|
||||
import_evaluation_cases(path)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_evaluation_audits_timeline_evidence_and_requires_full_case_coverage():
|
||||
user = get_user_model().objects.create_user(username="stage-24-evidence-reviewer")
|
||||
ticket = Ticket.objects.create(
|
||||
ticket_no="TK202607270131",
|
||||
title="订单接口超时",
|
||||
description="订单接口返回 TIMEOUT-504",
|
||||
)
|
||||
draft = TicketDraft.objects.create(
|
||||
created_by=user,
|
||||
source_text="10:00 客户反馈订单接口返回 TIMEOUT-504",
|
||||
problem_valid=True,
|
||||
current_fields={
|
||||
"description": "订单接口返回 TIMEOUT-504",
|
||||
"environment": "生产环境",
|
||||
},
|
||||
field_evidence={
|
||||
"description": {
|
||||
"source_refs": ["source_text"],
|
||||
"excerpt": "订单接口返回 TIMEOUT-504",
|
||||
},
|
||||
"environment": {
|
||||
"source_refs": ["source_text"],
|
||||
"excerpt": "生产环境",
|
||||
},
|
||||
},
|
||||
message_timeline=[
|
||||
{
|
||||
"sequence": 1,
|
||||
"role": "customer",
|
||||
"content": "订单接口返回 TIMEOUT-504",
|
||||
"occurred_at_raw": "10:00",
|
||||
"timezone": "",
|
||||
}
|
||||
],
|
||||
decision="auto_created",
|
||||
confirmed_ticket=ticket,
|
||||
)
|
||||
reviewed_case = IntakeEvaluationCase.objects.create(
|
||||
key="real-evidence-001",
|
||||
name="脱敏订单超时",
|
||||
source_kind="anonymized_real",
|
||||
expected_fields={
|
||||
"description": "订单接口返回 TIMEOUT-504",
|
||||
"environment": "生产环境",
|
||||
},
|
||||
expected_timeline=[
|
||||
{
|
||||
"role": "customer",
|
||||
"content": "订单接口返回 TIMEOUT-504",
|
||||
"occurred_at_raw": "10:00",
|
||||
}
|
||||
],
|
||||
)
|
||||
IntakeEvaluationCase.objects.create(
|
||||
key="real-evidence-pending-002",
|
||||
name="待评审案例",
|
||||
source_kind="anonymized_real",
|
||||
)
|
||||
|
||||
result = record_evaluation_result(reviewed_case, draft, reviewer=user)
|
||||
metrics = build_intake_quality_metrics()
|
||||
|
||||
assert result.passed is True
|
||||
assert result.details["timeline_correct"] is True
|
||||
assert result.details["evidence_covered_count"] == 2
|
||||
assert metrics["evaluation"]["evidence_coverage_rate"] == 1.0
|
||||
assert metrics["evaluation"]["timeline_accuracy"] == 1.0
|
||||
assert metrics["evaluation"]["coverage_rate"] == 0.5
|
||||
assert metrics["targets"]["coverage_complete"] is False
|
||||
assert metrics["targets"]["auto_creation_rate_met"] is None
|
||||
assert metrics["targets"]["field_accuracy_met"] is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_metrics_only_use_latest_reviewed_run_for_each_case():
|
||||
user = get_user_model().objects.create_user(username="stage-24-latest-run")
|
||||
case = IntakeEvaluationCase.objects.create(
|
||||
key="real-latest-001",
|
||||
name="最新运行口径",
|
||||
source_kind="anonymized_real",
|
||||
expected_fields={"description": "订单同步失败"},
|
||||
)
|
||||
old_ticket = Ticket.objects.create(
|
||||
ticket_no="TK202607270141",
|
||||
title="旧运行",
|
||||
description="错误结果",
|
||||
)
|
||||
old_draft = TicketDraft.objects.create(
|
||||
created_by=user,
|
||||
problem_valid=True,
|
||||
current_fields={"description": "错误结果"},
|
||||
decision="auto_created",
|
||||
confirmed_ticket=old_ticket,
|
||||
)
|
||||
record_evaluation_result(case, old_draft, reviewer=user)
|
||||
new_ticket = Ticket.objects.create(
|
||||
ticket_no="TK202607270142",
|
||||
title="新运行",
|
||||
description="订单同步失败",
|
||||
)
|
||||
new_draft = TicketDraft.objects.create(
|
||||
created_by=user,
|
||||
problem_valid=True,
|
||||
current_fields={"description": "订单同步失败"},
|
||||
field_evidence={
|
||||
"description": {
|
||||
"source_refs": ["source_text"],
|
||||
"excerpt": "订单同步失败",
|
||||
}
|
||||
},
|
||||
decision="auto_created",
|
||||
confirmed_ticket=new_ticket,
|
||||
)
|
||||
record_evaluation_result(case, new_draft, reviewer=user)
|
||||
|
||||
metrics = build_intake_quality_metrics()
|
||||
|
||||
assert metrics["evaluation"]["reviewed_case_count"] == 1
|
||||
assert metrics["evaluation"]["correct_field_count"] == 1
|
||||
assert metrics["evaluation"]["evaluated_field_count"] == 1
|
||||
assert metrics["evaluation"]["field_accuracy"] == 1.0
|
||||
assert metrics["evaluation"]["result_selection"] == "latest_per_case"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_rereviewed_draft_becomes_latest_case_result():
|
||||
user = get_user_model().objects.create_user(username="stage-24-rereview")
|
||||
case = IntakeEvaluationCase.objects.create(
|
||||
key="real-rereview-001",
|
||||
name="重新评审口径",
|
||||
source_kind="anonymized_real",
|
||||
expected_fields={"description": "订单同步失败"},
|
||||
)
|
||||
old_draft = TicketDraft.objects.create(
|
||||
created_by=user,
|
||||
problem_valid=True,
|
||||
current_fields={"description": "旧错误结果"},
|
||||
)
|
||||
old_result = record_evaluation_result(case, old_draft, reviewer=user)
|
||||
new_draft = TicketDraft.objects.create(
|
||||
created_by=user,
|
||||
problem_valid=True,
|
||||
current_fields={"description": "新错误结果"},
|
||||
)
|
||||
new_result = record_evaluation_result(case, new_draft, reviewer=user)
|
||||
stale_time = timezone.now() - timedelta(minutes=1)
|
||||
IntakeEvaluationResult.objects.filter(pk__in=[old_result.pk, new_result.pk]).update(
|
||||
updated_at=stale_time
|
||||
)
|
||||
old_draft.current_fields = {"description": "订单同步失败"}
|
||||
old_draft.field_evidence = {
|
||||
"description": {
|
||||
"source_refs": ["source_text"],
|
||||
"excerpt": "订单同步失败",
|
||||
}
|
||||
}
|
||||
old_draft.save(update_fields=["current_fields", "field_evidence", "updated_at"])
|
||||
|
||||
rereviewed = record_evaluation_result(case, old_draft, reviewer=user)
|
||||
metrics = build_intake_quality_metrics()
|
||||
|
||||
assert rereviewed.pk == old_result.pk
|
||||
assert rereviewed.updated_at > stale_time
|
||||
assert metrics["evaluation"]["reviewed_case_count"] == 1
|
||||
assert metrics["evaluation"]["correct_field_count"] == 1
|
||||
assert metrics["evaluation"]["field_accuracy"] == 1.0
|
||||
|
||||
|
||||
def _record_passing_report_case(
|
||||
*,
|
||||
user,
|
||||
key,
|
||||
manifest,
|
||||
valid_input=True,
|
||||
):
|
||||
expected_fields = {"description": f"{key} 问题"} if valid_input else {}
|
||||
expected_timeline = (
|
||||
[{"role": "customer", "content": f"{key} 问题"}] if valid_input else []
|
||||
)
|
||||
case = IntakeEvaluationCase.objects.create(
|
||||
key=key,
|
||||
name=key,
|
||||
source_kind="anonymized_real",
|
||||
valid_input=valid_input,
|
||||
input_manifest=manifest,
|
||||
expected_fields=expected_fields,
|
||||
expected_timeline=expected_timeline,
|
||||
expected_auto_created=valid_input,
|
||||
)
|
||||
ticket = None
|
||||
if valid_input:
|
||||
ticket = Ticket.objects.create(
|
||||
ticket_no=f"TK-{key}",
|
||||
title=key,
|
||||
description=f"{key} 问题",
|
||||
)
|
||||
draft = TicketDraft.objects.create(
|
||||
created_by=user,
|
||||
source_text=str(manifest.get("source_text") or ""),
|
||||
problem_valid=valid_input,
|
||||
current_fields=expected_fields,
|
||||
field_evidence=(
|
||||
{
|
||||
"description": {
|
||||
"source_refs": ["source_text"],
|
||||
"excerpt": f"{key} 问题",
|
||||
}
|
||||
}
|
||||
if valid_input
|
||||
else {}
|
||||
),
|
||||
message_timeline=(
|
||||
[{"role": "customer", "content": f"{key} 问题"}]
|
||||
if valid_input
|
||||
else []
|
||||
),
|
||||
decision="auto_created" if valid_input else "",
|
||||
confirmed_ticket=ticket,
|
||||
)
|
||||
record_evaluation_result(case, draft, reviewer=user)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_stage_2_report_requires_real_modality_coverage_and_can_pass():
|
||||
user = get_user_model().objects.create_user(username="stage-24-report")
|
||||
_record_passing_report_case(
|
||||
user=user,
|
||||
key="real-image-001",
|
||||
manifest={"source_text": "", "materials": [{"file_name": "one.png", "type": "png"}]},
|
||||
)
|
||||
_record_passing_report_case(
|
||||
user=user,
|
||||
key="real-mixed-002",
|
||||
manifest={
|
||||
"source_text": "补充说明",
|
||||
"materials": [
|
||||
{"file_name": "one.png", "type": "png"},
|
||||
{"file_name": "two.png", "type": "png"},
|
||||
],
|
||||
},
|
||||
)
|
||||
_record_passing_report_case(
|
||||
user=user,
|
||||
key="real-document-003",
|
||||
manifest={
|
||||
"source_text": "请查看日志",
|
||||
"materials": [{"file_name": "service.log", "type": "log"}],
|
||||
},
|
||||
)
|
||||
_record_passing_report_case(
|
||||
user=user,
|
||||
key="real-invalid-004",
|
||||
manifest={"source_text": "你好,在吗?", "materials": []},
|
||||
valid_input=False,
|
||||
)
|
||||
|
||||
report = build_stage_2_acceptance_report()
|
||||
|
||||
assert report["status"] == "passed"
|
||||
assert report["exit_ready"] is True
|
||||
assert report["reasons"] == []
|
||||
assert report["modality_coverage"]["multi_image_case_count"] == 1
|
||||
assert report["metrics"]["targets"]["auto_creation_rate_met"] is True
|
||||
assert report["metrics"]["targets"]["field_accuracy_met"] is True
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_synthetic_report_is_diagnostic_and_strict_command_fails(tmp_path):
|
||||
sample_path = Path(settings.BASE_DIR) / "docs/product/acceptance/stage2-sample-cases.json"
|
||||
import_evaluation_cases(sample_path)
|
||||
output_path = tmp_path / "stage2-report.json"
|
||||
stdout = StringIO()
|
||||
|
||||
call_command(
|
||||
"report_stage2_acceptance",
|
||||
include_synthetic=True,
|
||||
output_format="json",
|
||||
output=str(output_path),
|
||||
stdout=stdout,
|
||||
)
|
||||
payload = json.loads(stdout.getvalue())
|
||||
|
||||
assert payload["status"] == "diagnostic"
|
||||
assert payload["exit_ready"] is False
|
||||
assert payload["reasons"][0]["code"] == "synthetic_scope"
|
||||
assert json.loads(output_path.read_text(encoding="utf-8"))["scope"] == (
|
||||
"diagnostic_with_synthetic"
|
||||
)
|
||||
|
||||
IntakeEvaluationCase.objects.all().delete()
|
||||
with pytest.raises(CommandError, match="阶段 2 尚不可退出"):
|
||||
call_command("report_stage2_acceptance", fail_on_not_ready=True)
|
||||
492
backend/apps/tickets/tests/test_ticket_draft_trust.py
Normal file
492
backend/apps/tickets/tests/test_ticket_draft_trust.py
Normal file
@@ -0,0 +1,492 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import call_command
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.agents.models import AgentRun
|
||||
from apps.agents.tests.helpers import create_deployment
|
||||
from apps.knowledge.models import KnowledgeBase, KnowledgeItem
|
||||
from apps.knowledge.services import rebuild_item_chunks
|
||||
from apps.tickets.draft_services import (
|
||||
_auto_create_ticket,
|
||||
create_agent_run_for_draft,
|
||||
execute_draft_analysis,
|
||||
serialize_agent_progress,
|
||||
)
|
||||
from apps.tickets.models import (
|
||||
Ticket,
|
||||
TicketActorSource,
|
||||
TicketAttachment,
|
||||
TicketDraft,
|
||||
TicketFieldEvidence,
|
||||
TicketMessageTimelineItem,
|
||||
)
|
||||
|
||||
|
||||
def _publish_agent():
|
||||
deployment = create_deployment()
|
||||
call_command("publish_trusted_ticket_intake", deployment_id=deployment.id)
|
||||
|
||||
|
||||
def _user():
|
||||
return get_user_model().objects.create_user(username="trusted-draft-user")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_draft_context_contains_only_published_knowledge_items():
|
||||
_publish_agent()
|
||||
base = KnowledgeBase.objects.create(name="采集知识库")
|
||||
published = KnowledgeItem.objects.create(
|
||||
knowledge_base=base,
|
||||
title="采集任务停滞",
|
||||
problem="采集任务长时间没有更新",
|
||||
root_cause="调度状态异常",
|
||||
solution="核查调度状态后重新触发",
|
||||
status="published",
|
||||
)
|
||||
rebuild_item_chunks(published)
|
||||
draft_item = KnowledgeItem.objects.create(
|
||||
knowledge_base=base,
|
||||
title="未审核客户专属方案",
|
||||
problem="采集任务没有更新",
|
||||
solution="不得进入 Agent 的方案",
|
||||
status="draft",
|
||||
)
|
||||
rebuild_item_chunks(draft_item)
|
||||
draft = TicketDraft.objects.create(
|
||||
created_by=_user(), source_text="生产环境采集任务长时间没有更新"
|
||||
)
|
||||
|
||||
run = create_agent_run_for_draft(draft)
|
||||
|
||||
context = run.input_payload["knowledge_context"]
|
||||
assert [item["knowledge_item_id"] for item in context] == [published.id]
|
||||
assert context[0]["solution"] == "核查调度状态后重新触发"
|
||||
assert "不得进入 Agent 的方案" not in str(context)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_agent_input_contains_only_scanned_parsed_materials():
|
||||
_publish_agent()
|
||||
draft = TicketDraft.objects.create(created_by=_user(), source_text="")
|
||||
ready = draft.materials.create(
|
||||
file_name="service.log",
|
||||
material_type="log",
|
||||
extension="log",
|
||||
mime_type="text/plain",
|
||||
file_size=20,
|
||||
sha256="1" * 64,
|
||||
storage_path="ticket-drafts/service.log",
|
||||
scan_status="clean",
|
||||
parse_status="parsed",
|
||||
parsed_text="2026-07-25 ERROR timeout",
|
||||
)
|
||||
draft.materials.create(
|
||||
file_name="blocked.txt",
|
||||
material_type="text",
|
||||
extension="txt",
|
||||
mime_type="text/plain",
|
||||
file_size=10,
|
||||
sha256="2" * 64,
|
||||
storage_path="ticket-drafts/blocked.txt",
|
||||
scan_status="infected",
|
||||
parse_status="blocked",
|
||||
)
|
||||
|
||||
run = create_agent_run_for_draft(draft)
|
||||
|
||||
assert run.input_payload["material_texts"] == [
|
||||
{
|
||||
"source_ref": f"material:{ready.public_id}",
|
||||
"file_name": "service.log",
|
||||
"mime_type": "text/plain",
|
||||
"text": "2026-07-25 ERROR timeout",
|
||||
}
|
||||
]
|
||||
assert [item["file_name"] for item in run.input_payload["material_refs"]] == [
|
||||
"service.log"
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_analysis_rejects_unproven_entities_and_preserves_manual_fields(monkeypatch):
|
||||
_publish_agent()
|
||||
draft = TicketDraft.objects.create(
|
||||
created_by=_user(),
|
||||
source_text="张三反馈生产环境采集任务没有更新",
|
||||
current_fields={"submitter_org": "人工确认机构"},
|
||||
last_ai_fields={"submitter_org": "旧 AI 机构"},
|
||||
)
|
||||
run = create_agent_run_for_draft(draft)
|
||||
ai_fields = {
|
||||
"title": "生产采集任务未更新",
|
||||
"description": "张三反馈生产环境采集任务未更新。",
|
||||
"category_code": "technical_issue",
|
||||
"priority": "medium",
|
||||
"problem_performance": "采集任务未更新",
|
||||
"problem_object": "知识库客户企业",
|
||||
"submitter_name": "知识库联系人",
|
||||
"submitter_org": "知识库机构",
|
||||
"impact_scope": "",
|
||||
"environment": "生产环境",
|
||||
"related_system": "数据采集平台",
|
||||
}
|
||||
|
||||
def fake_execute(run_id, **_kwargs):
|
||||
AgentRun.objects.filter(id=run_id).update(
|
||||
status="succeeded",
|
||||
final_output={
|
||||
"problem_valid": True,
|
||||
"invalid_reason": "",
|
||||
"fields": ai_fields,
|
||||
"field_evidence": {
|
||||
"description": {
|
||||
"confidence": 0.95,
|
||||
"source_refs": ["source_text"],
|
||||
"excerpt": "采集任务没有更新",
|
||||
"location": "文本",
|
||||
"conflicts": [],
|
||||
}
|
||||
},
|
||||
"message_timeline": [],
|
||||
"missing_fields": [],
|
||||
"questions": [],
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("apps.tickets.draft_services.execute_agent_run", fake_execute)
|
||||
|
||||
execute_draft_analysis(draft.id, run.id)
|
||||
|
||||
draft.refresh_from_db()
|
||||
assert draft.status == "ticket_created"
|
||||
assert draft.confirmed_ticket.status == "pending_info"
|
||||
assert draft.current_fields["problem_object"] == ""
|
||||
assert draft.current_fields["submitter_name"] == ""
|
||||
assert draft.current_fields["submitter_org"] == "人工确认机构"
|
||||
assert set(draft.missing_fields) >= {"problem_object", "submitter_name"}
|
||||
assert TicketFieldEvidence.objects.get(
|
||||
ticket=draft.confirmed_ticket,
|
||||
field_name="submitter_org",
|
||||
).is_protected is True
|
||||
description_evidence = TicketFieldEvidence.objects.get(
|
||||
ticket=draft.confirmed_ticket,
|
||||
field_name="description",
|
||||
)
|
||||
assert description_evidence.source == TicketActorSource.AGENT
|
||||
assert float(description_evidence.confidence) == 0.95
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_analysis_keeps_invalid_problem_as_intake_without_ticket(monkeypatch):
|
||||
_publish_agent()
|
||||
draft = TicketDraft.objects.create(created_by=_user(), source_text="你好,在吗")
|
||||
run = create_agent_run_for_draft(draft)
|
||||
|
||||
def fake_execute(run_id, **_kwargs):
|
||||
AgentRun.objects.filter(id=run_id).update(
|
||||
status="succeeded",
|
||||
final_output={
|
||||
"problem_valid": False,
|
||||
"invalid_reason": "请补充具体问题现象",
|
||||
"fields": {"description": ""},
|
||||
"field_evidence": {},
|
||||
"message_timeline": [],
|
||||
"missing_fields": ["description"],
|
||||
"questions": ["请描述需要技术支持处理的具体问题。"],
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("apps.tickets.draft_services.execute_agent_run", fake_execute)
|
||||
|
||||
execute_draft_analysis(draft.id, run.id)
|
||||
|
||||
draft.refresh_from_db()
|
||||
assert draft.status == "needs_input"
|
||||
assert draft.problem_valid is False
|
||||
assert draft.invalid_reason == "请补充具体问题现象"
|
||||
assert draft.confirmed_ticket_id is None
|
||||
assert Ticket.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_analysis_persists_structured_context_business_times_and_timezone(monkeypatch):
|
||||
_publish_agent()
|
||||
draft = TicketDraft.objects.create(
|
||||
created_by=_user(),
|
||||
source_text=(
|
||||
"张三代表华南公司反馈财务系统报销模块的报销单在生产环境报错 E500,"
|
||||
"2026-07-27 10:00(北京时间)开始,已清理缓存但仍未恢复,希望尽快恢复报销。"
|
||||
),
|
||||
)
|
||||
run = create_agent_run_for_draft(draft)
|
||||
fields = {
|
||||
"title": "财务系统报销模块 E500",
|
||||
"description": "华南公司反馈财务系统报销模块持续报错 E500。",
|
||||
"category_code": "technical_issue",
|
||||
"priority": "medium",
|
||||
"problem_performance": "提交报销时持续报错 E500",
|
||||
"problem_object": "报销单",
|
||||
"submitter_name": "张三",
|
||||
"submitter_org": "华南公司",
|
||||
"impact_scope": "华南公司报销用户",
|
||||
"environment": "生产环境",
|
||||
"related_system": "财务系统",
|
||||
"product_name": "财务系统",
|
||||
"module_name": "报销模块",
|
||||
"tags": ["E500", "报销", "E500"],
|
||||
"attempted_actions": "已清理缓存",
|
||||
"error_information": "E500",
|
||||
"customer_request": "尽快恢复报销",
|
||||
"current_result": "清理缓存后仍未恢复",
|
||||
"problem_started_at": "2026-07-27T10:00:00+08:00",
|
||||
"customer_last_message_at": "2026-07-27T10:05:00+08:00",
|
||||
"support_first_response_at": "2026-07-27T10:02:00+08:00",
|
||||
"support_last_reply_at": "2026-07-27T10:04:00+08:00",
|
||||
}
|
||||
|
||||
def fake_execute(run_id, **_kwargs):
|
||||
AgentRun.objects.filter(id=run_id).update(
|
||||
status="succeeded",
|
||||
final_output={
|
||||
"problem_valid": True,
|
||||
"invalid_reason": "",
|
||||
"fields": fields,
|
||||
"field_evidence": {
|
||||
"problem_started_at": {
|
||||
"confidence": 0.96,
|
||||
"source_refs": ["source_text"],
|
||||
"excerpt": "2026-07-27 10:00(北京时间)开始",
|
||||
"location": "文本第 1 行",
|
||||
"raw_value": "2026-07-27 10:00",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"conflicts": [],
|
||||
}
|
||||
},
|
||||
"message_timeline": [
|
||||
{
|
||||
"speaker": "张三",
|
||||
"role": "customer",
|
||||
"content": "报销模块报错 E500",
|
||||
"occurred_at": "2026-07-27T10:00:00+08:00",
|
||||
"occurred_at_raw": "上午十点",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"source_ref": "source_text",
|
||||
"confidence": 0.92,
|
||||
"conflict": "",
|
||||
}
|
||||
],
|
||||
"missing_fields": [],
|
||||
"questions": [],
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("apps.tickets.draft_services.execute_agent_run", fake_execute)
|
||||
|
||||
execute_draft_analysis(draft.id, run.id)
|
||||
|
||||
draft.refresh_from_db()
|
||||
ticket = Ticket.objects.get(id=draft.confirmed_ticket_id)
|
||||
assert ticket.status == "pending"
|
||||
assert ticket.product_name == "财务系统"
|
||||
assert ticket.module_name == "报销模块"
|
||||
assert ticket.tags == ["E500", "报销"]
|
||||
assert ticket.attempted_actions == "已清理缓存"
|
||||
assert ticket.error_information == "E500"
|
||||
assert ticket.customer_request == "尽快恢复报销"
|
||||
assert ticket.current_result == "清理缓存后仍未恢复"
|
||||
assert ticket.problem_started_at.isoformat() == "2026-07-27T02:00:00+00:00"
|
||||
time_evidence = TicketFieldEvidence.objects.get(
|
||||
ticket=ticket,
|
||||
field_name="problem_started_at",
|
||||
)
|
||||
assert time_evidence.evidence["raw_value"] == "2026-07-27 10:00"
|
||||
assert time_evidence.evidence["timezone"] == "Asia/Shanghai"
|
||||
timeline = TicketMessageTimelineItem.objects.get(ticket=ticket)
|
||||
assert timeline.occurred_at_raw == "上午十点"
|
||||
assert timeline.timezone == "Asia/Shanghai"
|
||||
assert timeline.occurred_at.isoformat() == "2026-07-27T02:00:00+00:00"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_analysis_rejects_business_time_without_explicit_timezone(monkeypatch):
|
||||
_publish_agent()
|
||||
draft = TicketDraft.objects.create(
|
||||
created_by=_user(),
|
||||
source_text="张三代表华南公司反馈生产环境报销单无法提交。",
|
||||
)
|
||||
run = create_agent_run_for_draft(draft)
|
||||
|
||||
def fake_execute(run_id, **_kwargs):
|
||||
AgentRun.objects.filter(id=run_id).update(
|
||||
status="succeeded",
|
||||
final_output={
|
||||
"problem_valid": True,
|
||||
"invalid_reason": "",
|
||||
"fields": {
|
||||
"title": "报销单无法提交",
|
||||
"description": "华南公司反馈生产环境报销单无法提交。",
|
||||
"category_code": "technical_issue",
|
||||
"priority": "medium",
|
||||
"problem_performance": "报销单无法提交",
|
||||
"problem_object": "报销单",
|
||||
"submitter_name": "张三",
|
||||
"submitter_org": "华南公司",
|
||||
"environment": "生产环境",
|
||||
"problem_started_at": "2026-07-27T10:00:00",
|
||||
},
|
||||
"field_evidence": {},
|
||||
"message_timeline": [],
|
||||
"missing_fields": [],
|
||||
"questions": [],
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr("apps.tickets.draft_services.execute_agent_run", fake_execute)
|
||||
|
||||
execute_draft_analysis(draft.id, run.id)
|
||||
|
||||
draft.refresh_from_db()
|
||||
ticket = Ticket.objects.get(id=draft.confirmed_ticket_id)
|
||||
assert ticket.problem_started_at is None
|
||||
assert ticket.status == "pending_info"
|
||||
assert "problem_started_at" in draft.missing_fields
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_auto_create_is_idempotent_for_same_intake(monkeypatch):
|
||||
_publish_agent()
|
||||
draft = TicketDraft.objects.create(created_by=_user(), source_text="采集任务没有更新")
|
||||
run = create_agent_run_for_draft(draft)
|
||||
fields = {
|
||||
"description": "采集任务从上午十点起没有更新",
|
||||
"priority": "medium",
|
||||
}
|
||||
|
||||
first = _auto_create_ticket(
|
||||
draft.id,
|
||||
run,
|
||||
fields=fields,
|
||||
field_evidence={},
|
||||
confidence_by_field={},
|
||||
human_field_names=set(),
|
||||
)
|
||||
TicketDraft.objects.filter(id=draft.id).update(status="needs_input")
|
||||
second = _auto_create_ticket(
|
||||
draft.id,
|
||||
run,
|
||||
fields=fields,
|
||||
field_evidence={},
|
||||
confidence_by_field={},
|
||||
human_field_names=set(),
|
||||
)
|
||||
|
||||
assert first.confirmed_ticket_id == second.confirmed_ticket_id
|
||||
assert second.status == "ticket_created"
|
||||
assert Ticket.objects.count() == 1
|
||||
ticket = Ticket.objects.get()
|
||||
assert ticket.status == "pending_info"
|
||||
assert ticket.title.startswith("待补全问题:")
|
||||
monkeypatch.setattr(
|
||||
"apps.tickets.draft_services.execute_agent_run",
|
||||
lambda *_args, **_kwargs: pytest.fail("duplicate run must not call the model"),
|
||||
)
|
||||
execute_draft_analysis(draft.id, run.id)
|
||||
run.refresh_from_db()
|
||||
assert run.status == "succeeded"
|
||||
assert Ticket.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_auto_create_rolls_back_ticket_when_attachment_copy_fails(monkeypatch):
|
||||
_publish_agent()
|
||||
draft = TicketDraft.objects.create(created_by=_user(), source_text="采集任务没有更新")
|
||||
draft.materials.create(
|
||||
file_name="proof.png",
|
||||
mime_type="image/png",
|
||||
file_size=10,
|
||||
sha256="0" * 64,
|
||||
storage_path="ticket-drafts/proof.png",
|
||||
)
|
||||
run = create_agent_run_for_draft(draft)
|
||||
monkeypatch.setattr(
|
||||
"apps.tickets.draft_services.copy_object",
|
||||
lambda *_args: (_ for _ in ()).throw(RuntimeError("storage unavailable")),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="storage unavailable"):
|
||||
_auto_create_ticket(
|
||||
draft.id,
|
||||
run,
|
||||
fields={"description": "采集任务没有更新"},
|
||||
field_evidence={},
|
||||
confidence_by_field={},
|
||||
human_field_names=set(),
|
||||
)
|
||||
|
||||
assert Ticket.objects.count() == 0
|
||||
draft.refresh_from_db()
|
||||
assert draft.confirmed_ticket_id is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_auto_create_preserves_material_scan_and_parse_metadata(monkeypatch):
|
||||
_publish_agent()
|
||||
draft = TicketDraft.objects.create(created_by=_user(), source_text="日志显示请求超时")
|
||||
material = draft.materials.create(
|
||||
file_name="service.log",
|
||||
material_type="log",
|
||||
extension="log",
|
||||
mime_type="text/plain",
|
||||
file_size=24,
|
||||
sha256="a" * 64,
|
||||
storage_path="ticket-drafts/service.log",
|
||||
scan_status="clean",
|
||||
scan_result={"engine": "clamav", "status": "clean"},
|
||||
parse_status="parsed",
|
||||
parsed_text="ERROR upstream timeout",
|
||||
)
|
||||
run = create_agent_run_for_draft(draft)
|
||||
monkeypatch.setattr("apps.tickets.draft_services.copy_object", lambda *_args: None)
|
||||
|
||||
result = _auto_create_ticket(
|
||||
draft.id,
|
||||
run,
|
||||
fields={"description": "日志显示上游请求超时"},
|
||||
field_evidence={},
|
||||
confidence_by_field={},
|
||||
human_field_names=set(),
|
||||
)
|
||||
|
||||
attachment = TicketAttachment.objects.get(ticket=result.confirmed_ticket)
|
||||
assert attachment.file_type == "log"
|
||||
assert attachment.sha256 == "a" * 64
|
||||
assert attachment.scan_status == "clean"
|
||||
assert attachment.parse_status == "parsed"
|
||||
assert attachment.parsed_text == "ERROR upstream timeout"
|
||||
material.refresh_from_db()
|
||||
assert material.status == "attached"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_agent_progress_marks_long_running_draft_as_slow(settings):
|
||||
_publish_agent()
|
||||
settings.AI_SLOW_RUN_THRESHOLD_SECONDS = 20
|
||||
draft = TicketDraft.objects.create(created_by=_user(), source_text="采集任务没有更新")
|
||||
run = create_agent_run_for_draft(draft)
|
||||
run.status = "running"
|
||||
run.started_at = timezone.now() - timedelta(seconds=21)
|
||||
run.progress_stage = "extracting"
|
||||
run.progress_message = "正在解析沟通记录"
|
||||
run.save(
|
||||
update_fields=["status", "started_at", "progress_stage", "progress_message"]
|
||||
)
|
||||
|
||||
progress = serialize_agent_progress(draft)
|
||||
|
||||
assert progress["stage"] == "extracting"
|
||||
assert progress["message"] == "正在解析沟通记录"
|
||||
assert progress["elapsed_ms"] >= 20_000
|
||||
assert progress["slow"] is True
|
||||
@@ -1,4 +1,5 @@
|
||||
import io
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
@@ -95,6 +96,30 @@ def test_feature_requires_published_ticket_agent(admin_client, db):
|
||||
assert response.json()["vision_available"] is False
|
||||
|
||||
|
||||
def test_availability_uses_effective_step_vision_model(admin_client, db):
|
||||
agent = create_ticket_agent()
|
||||
version = agent.active_version
|
||||
provider = version.model_deployment.provider
|
||||
provider.image_egress_approved = True
|
||||
provider.save(update_fields=["image_egress_approved"])
|
||||
vision = ModelDeployment.objects.create(
|
||||
provider=provider,
|
||||
display_name="步骤视觉模型",
|
||||
model_name="ticket-vision-model",
|
||||
capability="vision_chat",
|
||||
)
|
||||
binding = version.steps.get()
|
||||
binding.model_deployment = vision
|
||||
binding.save(update_fields=["model_deployment"])
|
||||
|
||||
response = admin_client.get("/api/ticket-drafts/availability")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["available"] is True
|
||||
assert response.json()["vision_available"] is True
|
||||
assert response.json()["vision_reason"] == ""
|
||||
|
||||
|
||||
def test_revision_conflict_and_manual_edit(admin_client, db):
|
||||
create_ticket_agent()
|
||||
created = admin_client.post(
|
||||
@@ -145,9 +170,16 @@ def test_confirm_is_idempotent_and_records_accepted(admin_client, category):
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert first.json()["confirmed_ticket"]["id"] == second.json()["confirmed_ticket"]["id"]
|
||||
assert first.json()["created_ticket"]["id"] == first.json()["confirmed_ticket"]["id"]
|
||||
assert first.json()["decision"] == "accepted"
|
||||
assert Ticket.objects.count() == 1
|
||||
assert Ticket.objects.get().ai_solution == ""
|
||||
assert not hasattr(Ticket.objects.get(), "ai_solution")
|
||||
ticket_id = first.json()["created_ticket"]["id"]
|
||||
trace = admin_client.get(f"/api/tickets/{ticket_id}/intake-trace")
|
||||
assert trace.status_code == 200
|
||||
assert trace.json()["available"] is True
|
||||
assert trace.json()["status"] == "ticket_created"
|
||||
assert admin_client.get(f"/api/tickets/{ticket_id}/attachments").json() == []
|
||||
|
||||
|
||||
def test_confirm_records_modified_and_reject_records_rejected(admin_client, category):
|
||||
@@ -201,13 +233,14 @@ def test_image_upload_validates_content_and_never_audits_source_text(admin_clien
|
||||
monkeypatch.setattr("apps.tickets.draft_api.put_object", lambda *args: None)
|
||||
output = io.BytesIO()
|
||||
Image.new("RGB", (8, 8), color="red").save(output, format="PNG")
|
||||
upload = SimpleUploadedFile("proof.png", output.getvalue(), content_type="text/plain")
|
||||
upload = SimpleUploadedFile("proof.png", output.getvalue(), content_type="image/png")
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/ticket-drafts/{created['public_id']}/images", {"file": upload}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["images"][0]["mime_type"] == "image/png"
|
||||
assert response.json()["materials"][0]["scan_status"] == "clean"
|
||||
snapshots = list(
|
||||
OperationAuditLog.objects.filter(target_id=created["public_id"]).values_list(
|
||||
"request_snapshot", flat=True
|
||||
@@ -224,12 +257,14 @@ def test_text_model_rejects_image_analysis(admin_client, db):
|
||||
content_type="application/json",
|
||||
).json()
|
||||
draft = TicketDraft.objects.get(public_id=created["public_id"])
|
||||
draft.images.create(
|
||||
draft.materials.create(
|
||||
file_name="proof.png",
|
||||
mime_type="image/png",
|
||||
file_size=10,
|
||||
sha256="0" * 64,
|
||||
storage_path="ticket-drafts/proof.png",
|
||||
scan_status="clean",
|
||||
parse_status="not_applicable",
|
||||
)
|
||||
response = admin_client.post(
|
||||
f"/api/ticket-drafts/{created['public_id']}/analyze",
|
||||
@@ -240,6 +275,38 @@ def test_text_model_rejects_image_analysis(admin_client, db):
|
||||
assert "不支持图片" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_vision_intake_can_start_with_image_only(admin_client, db, monkeypatch):
|
||||
create_ticket_agent(capability="vision_chat", approved=True)
|
||||
created = admin_client.post(
|
||||
"/api/ticket-drafts",
|
||||
data={"source_text": ""},
|
||||
content_type="application/json",
|
||||
).json()
|
||||
draft = TicketDraft.objects.get(public_id=created["public_id"])
|
||||
draft.materials.create(
|
||||
file_name="proof.png",
|
||||
mime_type="image/png",
|
||||
file_size=10,
|
||||
sha256="0" * 64,
|
||||
storage_path="ticket-drafts/proof.png",
|
||||
scan_status="clean",
|
||||
parse_status="not_applicable",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"apps.tickets.draft_api.execute_ticket_draft_analysis.delay",
|
||||
lambda *_args: SimpleNamespace(id="task-1"),
|
||||
)
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/ticket-drafts/{created['public_id']}/analyze",
|
||||
data={"expected_revision": created["revision"]},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "analyzing"
|
||||
|
||||
|
||||
def test_cleanup_deletes_unconfirmed_details_but_keeps_confirmed_draft(
|
||||
admin_client, db, monkeypatch
|
||||
):
|
||||
@@ -251,7 +318,7 @@ def test_cleanup_deletes_unconfirmed_details_but_keeps_confirmed_draft(
|
||||
source_text="过期敏感原文",
|
||||
expires_at=timezone.now(),
|
||||
)
|
||||
expired.images.create(
|
||||
expired.materials.create(
|
||||
file_name="proof.png",
|
||||
mime_type="image/png",
|
||||
file_size=10,
|
||||
|
||||
277
backend/apps/tickets/tests/test_ticket_materials.py
Normal file
277
backend/apps/tickets/tests/test_ticket_materials.py
Normal file
@@ -0,0 +1,277 @@
|
||||
import io
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from PIL import Image
|
||||
|
||||
from apps.tickets.materials import MaterialParseError, scan_bytes
|
||||
from apps.tickets.models import TicketDraftMaterial
|
||||
from apps.tickets.tests.test_ticket_drafts import create_ticket_agent
|
||||
|
||||
|
||||
def _create_draft(admin_client):
|
||||
create_ticket_agent(capability="vision_chat", approved=True)
|
||||
response = admin_client.post(
|
||||
"/api/ticket-drafts",
|
||||
data={"source_text": "材料解析测试"},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
|
||||
def _docx_bytes():
|
||||
from docx import Document
|
||||
|
||||
output = io.BytesIO()
|
||||
document = Document()
|
||||
document.add_paragraph("采集任务从上午十点起没有更新")
|
||||
document.save(output)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _xlsx_bytes():
|
||||
from openpyxl import Workbook
|
||||
|
||||
output = io.BytesIO()
|
||||
workbook = Workbook()
|
||||
workbook.active.append(["任务编号", "问题"])
|
||||
workbook.active.append(["JOB-1024", "采集任务未更新"])
|
||||
workbook.save(output)
|
||||
workbook.close()
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _pptx_bytes():
|
||||
from pptx import Presentation
|
||||
|
||||
output = io.BytesIO()
|
||||
presentation = Presentation()
|
||||
slide = presentation.slides.add_slide(presentation.slide_layouts[5])
|
||||
slide.shapes.title.text = "生产采集任务未更新"
|
||||
presentation.save(output)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _pdf_bytes():
|
||||
from pypdf import PdfWriter
|
||||
|
||||
output = io.BytesIO()
|
||||
writer = PdfWriter()
|
||||
writer.add_blank_page(width=72, height=72)
|
||||
writer.write(output)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("file_name", "content_type", "content", "material_type"),
|
||||
[
|
||||
("chat.txt", "text/plain", b"job failed with error 500", "text"),
|
||||
("service.log", "text/x-log", b"2026-07-25 ERROR timeout", "log"),
|
||||
("report.pdf", "application/pdf", _pdf_bytes(), "document"),
|
||||
(
|
||||
"report.docx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
_docx_bytes(),
|
||||
"document",
|
||||
),
|
||||
(
|
||||
"report.xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
_xlsx_bytes(),
|
||||
"spreadsheet",
|
||||
),
|
||||
(
|
||||
"report.pptx",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
_pptx_bytes(),
|
||||
"presentation",
|
||||
),
|
||||
],
|
||||
ids=["txt", "log", "pdf", "docx", "xlsx", "pptx"],
|
||||
)
|
||||
def test_material_upload_scans_and_parses_supported_documents(
|
||||
admin_client,
|
||||
db,
|
||||
monkeypatch,
|
||||
file_name,
|
||||
content_type,
|
||||
content,
|
||||
material_type,
|
||||
):
|
||||
created = _create_draft(admin_client)
|
||||
monkeypatch.setattr("apps.tickets.draft_api.put_object", lambda *_args: None)
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/ticket-drafts/{created['public_id']}/materials",
|
||||
{"file": SimpleUploadedFile(file_name, content, content_type=content_type)},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
material = response.json()["materials"][0]
|
||||
assert material["material_type"] == material_type
|
||||
assert material["scan_status"] == "clean"
|
||||
assert material["parse_status"] == "parsed"
|
||||
assert TicketDraftMaterial.objects.get(public_id=material["id"]).storage_path
|
||||
|
||||
|
||||
def test_material_rejects_disguised_extension_and_mime(admin_client, db, monkeypatch):
|
||||
created = _create_draft(admin_client)
|
||||
stored = []
|
||||
monkeypatch.setattr(
|
||||
"apps.tickets.draft_api.put_object",
|
||||
lambda *args: stored.append(args),
|
||||
)
|
||||
|
||||
signature = admin_client.post(
|
||||
f"/api/ticket-drafts/{created['public_id']}/materials",
|
||||
{"file": SimpleUploadedFile("fake.pdf", b"MZ executable", content_type="application/pdf")},
|
||||
)
|
||||
output = io.BytesIO()
|
||||
Image.new("RGB", (8, 8), color="red").save(output, format="PNG")
|
||||
mime = admin_client.post(
|
||||
f"/api/ticket-drafts/{created['public_id']}/materials",
|
||||
{"file": SimpleUploadedFile("proof.png", output.getvalue(), content_type="text/plain")},
|
||||
)
|
||||
|
||||
assert signature.status_code == 415
|
||||
assert mime.status_code == 415
|
||||
assert stored == []
|
||||
assert TicketDraftMaterial.objects.count() == 0
|
||||
|
||||
|
||||
def test_infected_material_is_retained_and_not_parsed(admin_client, db, monkeypatch):
|
||||
created = _create_draft(admin_client)
|
||||
monkeypatch.setattr("apps.tickets.draft_api.put_object", lambda *_args: None)
|
||||
monkeypatch.setattr(
|
||||
"apps.tickets.materials.scan_bytes",
|
||||
lambda _raw: {
|
||||
"status": "infected",
|
||||
"engine": "clamav",
|
||||
"signature": "Eicar-Test-Signature",
|
||||
},
|
||||
)
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/ticket-drafts/{created['public_id']}/materials",
|
||||
{"file": SimpleUploadedFile("sample.txt", b"EICAR sample", content_type="text/plain")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
material = response.json()["materials"][0]
|
||||
assert material["scan_status"] == "infected"
|
||||
assert material["parse_status"] == "blocked"
|
||||
stored = TicketDraftMaterial.objects.get(public_id=material["id"])
|
||||
assert stored.storage_path
|
||||
assert stored.parsed_text == ""
|
||||
|
||||
|
||||
def test_clamav_instream_response_marks_eicar_as_infected(monkeypatch, settings):
|
||||
class FakeConnection:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
self.responses = [b"stream: Eicar-Test-Signature FOUND\0"]
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return None
|
||||
|
||||
def sendall(self, data):
|
||||
self.sent.append(data)
|
||||
|
||||
def recv(self, _size):
|
||||
return self.responses.pop(0) if self.responses else b""
|
||||
|
||||
connection = FakeConnection()
|
||||
settings.TICKET_MATERIAL_SCANNER_BACKEND = "clamav"
|
||||
monkeypatch.setattr(
|
||||
"apps.tickets.materials.socket.create_connection",
|
||||
lambda *_args, **_kwargs: connection,
|
||||
)
|
||||
|
||||
result = scan_bytes(b"standard antivirus test payload")
|
||||
|
||||
assert result["status"] == "infected"
|
||||
assert result["signature"] == "Eicar-Test-Signature"
|
||||
assert connection.sent[0] == b"zINSTREAM\0"
|
||||
|
||||
|
||||
def test_parse_failure_isolated_to_material_and_keeps_original(admin_client, db, monkeypatch):
|
||||
created = _create_draft(admin_client)
|
||||
monkeypatch.setattr("apps.tickets.draft_api.put_object", lambda *_args: None)
|
||||
monkeypatch.setattr(
|
||||
"apps.tickets.materials.parse_material",
|
||||
lambda *_args: (_ for _ in ()).throw(MaterialParseError("文档损坏")),
|
||||
)
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/ticket-drafts/{created['public_id']}/materials",
|
||||
{"file": SimpleUploadedFile("chat.txt", b"valid text", content_type="text/plain")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
material = response.json()["materials"][0]
|
||||
assert material["scan_status"] == "clean"
|
||||
assert material["parse_status"] == "failed"
|
||||
assert material["parse_error"] == "文档损坏"
|
||||
assert TicketDraftMaterial.objects.filter(public_id=material["id"]).exists()
|
||||
|
||||
|
||||
def test_material_limits_are_server_controlled(admin_client, db, monkeypatch, settings):
|
||||
settings.TICKET_MATERIAL_MAX_FILE_BYTES = 4
|
||||
settings.TICKET_MATERIAL_MAX_FILES = 1
|
||||
created = _create_draft(admin_client)
|
||||
monkeypatch.setattr("apps.tickets.draft_api.put_object", lambda *_args: None)
|
||||
|
||||
oversized = admin_client.post(
|
||||
f"/api/ticket-drafts/{created['public_id']}/materials",
|
||||
{"file": SimpleUploadedFile("large.txt", b"12345", content_type="text/plain")},
|
||||
)
|
||||
first = admin_client.post(
|
||||
f"/api/ticket-drafts/{created['public_id']}/materials",
|
||||
{"file": SimpleUploadedFile("one.txt", b"1234", content_type="text/plain")},
|
||||
)
|
||||
second = admin_client.post(
|
||||
f"/api/ticket-drafts/{created['public_id']}/materials",
|
||||
{"file": SimpleUploadedFile("two.txt", b"12", content_type="text/plain")},
|
||||
)
|
||||
|
||||
assert oversized.status_code == 413
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 413
|
||||
|
||||
|
||||
def test_parsed_document_can_start_intake_without_text(admin_client, db, monkeypatch):
|
||||
create_ticket_agent(capability="text_chat")
|
||||
created = admin_client.post(
|
||||
"/api/ticket-drafts",
|
||||
data={"source_text": ""},
|
||||
content_type="application/json",
|
||||
).json()
|
||||
monkeypatch.setattr("apps.tickets.draft_api.put_object", lambda *_args: None)
|
||||
uploaded = admin_client.post(
|
||||
f"/api/ticket-drafts/{created['public_id']}/materials",
|
||||
{
|
||||
"file": SimpleUploadedFile(
|
||||
"service.log",
|
||||
b"2026-07-25 ERROR timeout",
|
||||
content_type="text/plain",
|
||||
)
|
||||
},
|
||||
).json()
|
||||
monkeypatch.setattr(
|
||||
"apps.tickets.draft_api.execute_ticket_draft_analysis.delay",
|
||||
lambda *_args: SimpleNamespace(id="task-1"),
|
||||
)
|
||||
|
||||
response = admin_client.post(
|
||||
f"/api/ticket-drafts/{created['public_id']}/analyze",
|
||||
data={"expected_revision": uploaded["revision"]},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "analyzing"
|
||||
Reference in New Issue
Block a user