feat: complete multimodal ticket intake

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

View File

@@ -22,14 +22,20 @@ from apps.tickets.draft_services import (
add_answer,
assert_revision,
audit_draft,
cancel_draft_analysis,
confirm_draft,
create_agent_run_for_draft,
draft_availability,
reject_draft,
serialize_draft,
)
from apps.tickets.models import TicketDraft, TicketDraftImage
from apps.tickets.storage import DraftImageError, delete_object, prepare_image, put_object
from apps.tickets.materials import (
MaterialValidationError,
prepare_material,
process_material,
)
from apps.tickets.models import TicketDraft, TicketDraftMaterial
from apps.tickets.storage import delete_object, put_object
from apps.tickets.tasks import execute_ticket_draft_analysis
router = Router(tags=["ticket-drafts"])
@@ -39,7 +45,7 @@ def get_owned_draft(request, public_id: str, *, allow_admin_view: bool = False)
require_permission(request, "ticket.ai_draft")
queryset = TicketDraft.objects.select_related(
"latest_agent_run", "confirmed_ticket", "created_by"
).prefetch_related("images", "messages")
).prefetch_related("materials", "messages")
draft = queryset.filter(public_id=public_id).first()
if draft is None:
raise HttpError(404, "草稿不存在")
@@ -68,7 +74,7 @@ def list_ticket_drafts(request):
drafts = (
TicketDraft.objects.filter(created_by=request.user, status__in=ACTIVE_DRAFT_STATUSES)
.select_related("latest_agent_run", "confirmed_ticket")
.prefetch_related("images", "messages")[:20]
.prefetch_related("materials", "messages")[:20]
)
return [serialize_draft(draft, ticket_serializer=serialize_ticket) for draft in drafts]
@@ -107,7 +113,7 @@ def update_ticket_draft(request, public_id: str, payload: TicketDraftPatchIn):
assert_revision(draft, payload.expected_revision)
except TicketDraftError as error:
raise_draft_error(error)
if draft.status not in {"editing", "ready", "failed"}:
if draft.status not in {"collecting", "needs_input", "failed", "editing", "ready"}:
raise HttpError(400, "当前状态不能编辑")
draft.current_fields = payload.fields
draft.revision += 1
@@ -118,70 +124,121 @@ def update_ticket_draft(request, public_id: str, payload: TicketDraftPatchIn):
return serialize_draft(draft, ticket_serializer=serialize_ticket)
@router.post("/{public_id}/images", response=TicketDraftOut)
def upload_ticket_draft_image(request, public_id: str, file: File[UploadedFile]):
def _upload_material(
request,
public_id: str,
file: UploadedFile,
*,
images_only: bool = False,
):
draft = get_owned_draft(request, public_id)
if draft.status not in {"editing", "ready", "failed"}:
raise HttpError(400, "当前状态不能上传图片")
if draft.images.count() >= 4:
raise HttpError(413, "每个草稿最多上传 4 张图片")
if draft.status not in {"collecting", "needs_input", "failed", "editing", "ready"}:
raise HttpError(400, "当前状态不能上传材料")
if draft.materials.count() >= settings.TICKET_MATERIAL_MAX_FILES:
raise HttpError(413, f"每次受理最多上传 {settings.TICKET_MATERIAL_MAX_FILES} 个材料")
raw = file.read()
try:
prepared = prepare_image(raw, file.name or "image")
except DraftImageError as error:
prepared = prepare_material(raw, file.name or "material", file.content_type or "")
except MaterialValidationError as error:
raise HttpError(error.status_code, str(error)) from error
current_total = sum(image.file_size for image in draft.images.all())
if current_total + len(prepared.data) > 20 * 1024 * 1024:
raise HttpError(413, "草稿图片总大小不能超过 20 MB")
image = TicketDraftImage(
if images_only and prepared.material_type != "image":
raise HttpError(415, "旧图片接口仅支持 JPEG、PNG 和 WebP")
current_total = sum(material.file_size for material in draft.materials.all())
if current_total + len(prepared.data) > settings.TICKET_MATERIAL_MAX_TOTAL_BYTES:
max_mb = settings.TICKET_MATERIAL_MAX_TOTAL_BYTES // 1024 // 1024
raise HttpError(413, f"单次受理材料总大小不能超过 {max_mb} MB")
material = TicketDraftMaterial(
draft=draft,
file_name=prepared.file_name,
material_type=prepared.material_type,
extension=prepared.extension,
claimed_mime_type=prepared.claimed_mime_type,
mime_type=prepared.mime_type,
file_size=len(prepared.data),
sha256=prepared.sha256,
storage_path="",
)
image.storage_path = (
material.storage_path = (
f"ticket-drafts/{draft.created_by_id}/{draft.public_id}/"
f"{image.public_id}.{prepared.extension}"
f"{material.public_id}.{prepared.extension}"
)
try:
put_object(image.storage_path, prepared.data, prepared.mime_type)
put_object(material.storage_path, prepared.data, prepared.mime_type)
except Exception as error:
raise HttpError(503, "图片存储暂时不可用") from error
image.save()
raise HttpError(503, "材料存储暂时不可用") from error
try:
material.save()
except Exception:
try:
delete_object(material.storage_path)
except Exception:
pass
raise
process_material(material, prepared.data)
draft.revision += 1
draft.expires_at = timezone.now() + timedelta(hours=settings.TICKET_DRAFT_RETENTION_HOURS)
draft.save(update_fields=["revision", "expires_at", "updated_at"])
audit_draft(draft, "draft_image_uploaded")
audit_draft(draft, "draft_material_uploaded")
return serialize_draft(get_owned_draft(request, public_id), ticket_serializer=serialize_ticket)
@router.post("/{public_id}/materials", response=TicketDraftOut)
def upload_ticket_draft_material(request, public_id: str, file: File[UploadedFile]):
return _upload_material(request, public_id, file)
@router.post("/{public_id}/images", response=TicketDraftOut)
def upload_ticket_draft_image(request, public_id: str, file: File[UploadedFile]):
return _upload_material(request, public_id, file, images_only=True)
def _delete_material(request, public_id: str, material_id: str, *, images_only: bool = False):
draft = get_owned_draft(request, public_id)
materials = draft.materials.filter(public_id=material_id, status="temporary")
if images_only:
materials = materials.filter(material_type="image")
material = materials.first()
if material is None:
raise HttpError(404, "材料不存在")
try:
delete_object(material.storage_path)
except Exception as error:
raise HttpError(503, "材料存储暂时不可用") from error
material.delete()
draft.revision += 1
draft.save(update_fields=["revision", "updated_at"])
audit_draft(draft, "draft_material_deleted")
return serialize_draft(get_owned_draft(request, public_id), ticket_serializer=serialize_ticket)
@router.delete("/{public_id}/materials/{material_id}", response=TicketDraftOut)
def delete_ticket_draft_material(request, public_id: str, material_id: str):
return _delete_material(request, public_id, material_id)
@router.delete("/{public_id}/images/{image_id}", response=TicketDraftOut)
def delete_ticket_draft_image(request, public_id: str, image_id: str):
draft = get_owned_draft(request, public_id)
image = draft.images.filter(public_id=image_id, status="temporary").first()
if image is None:
raise HttpError(404, "图片不存在")
try:
delete_object(image.storage_path)
except Exception as error:
raise HttpError(503, "图片存储暂时不可用") from error
image.delete()
draft.revision += 1
draft.save(update_fields=["revision", "updated_at"])
audit_draft(draft, "draft_image_deleted")
return serialize_draft(get_owned_draft(request, public_id), ticket_serializer=serialize_ticket)
return _delete_material(request, public_id, image_id, images_only=True)
def start_analysis(draft: TicketDraft, expected_revision: int) -> TicketDraft:
try:
assert_revision(draft, expected_revision)
if draft.status not in {"editing", "ready", "failed"}:
if draft.status not in {"collecting", "needs_input", "failed", "editing", "ready"}:
raise TicketDraftError(400, "当前状态不能启动分析")
if not draft.source_text.strip() and not draft.current_fields:
raise TicketDraftError(400, "请先输入问题描述")
if draft.images.exists() and not draft_availability()["vision_available"]:
ready_materials = draft.materials.filter(scan_status="clean")
usable_documents = ready_materials.filter(
parse_status="parsed"
).exclude(parsed_text="")
ready_images = ready_materials.filter(material_type="image")
if (
not draft.source_text.strip()
and not draft.current_fields
and not ready_images.exists()
and not usable_documents.exists()
):
raise TicketDraftError(400, "请先输入问题描述或上传可用材料")
if ready_images.exists() and not draft_availability()["vision_available"]:
raise TicketDraftError(422, draft_availability()["vision_reason"])
run = create_agent_run_for_draft(draft)
except TicketDraftError as error:
@@ -202,6 +259,26 @@ def analyze_ticket_draft(request, public_id: str, payload: TicketDraftActionIn):
return serialize_draft(draft, ticket_serializer=serialize_ticket)
@router.post("/{public_id}/cancel", response=TicketDraftOut)
def cancel_ticket_draft_analysis(
request,
public_id: str,
payload: TicketDraftActionIn,
):
require_permission(request, "ticket.ai_draft")
try:
draft = cancel_draft_analysis(
public_id,
payload.expected_revision,
request.user,
)
except TicketDraft.DoesNotExist as error:
raise HttpError(404, "草稿不存在") from error
except TicketDraftError as error:
raise_draft_error(error)
return serialize_draft(draft, ticket_serializer=serialize_ticket)
@router.post("/{public_id}/answers", response=TicketDraftOut)
def answer_ticket_draft(request, public_id: str, payload: TicketDraftAnswerIn):
draft = get_owned_draft(request, public_id)

View File

@@ -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

View 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,
}

View File

@@ -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}"))

View File

@@ -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}"
)
)

View File

@@ -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 "无证据"

View File

@@ -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}"
)
)

View 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",
]
)

View File

@@ -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),
]

View 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),
),
]

View File

@@ -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')],
},
),
]

View File

@@ -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')],
},
),
]

View File

@@ -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),
),
]

View File

@@ -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',
),
),
]

View 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,
)

View File

@@ -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()

View File

@@ -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",
)

View 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

View 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)

View 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

View File

@@ -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,

View 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"