feat: add trusted ticket intake backend

This commit is contained in:
hailin
2026-07-16 19:37:32 +08:00
parent b592dc219d
commit bcd6de4609
28 changed files with 1966 additions and 42 deletions

View File

@@ -11,6 +11,7 @@ class PermissionSpec:
PERMISSIONS: tuple[PermissionSpec, ...] = (
PermissionSpec("ticket.view", "查看工单", "工单"),
PermissionSpec("ticket.create", "创建工单", "工单"),
PermissionSpec("ticket.ai_draft", "使用 AI 建单草稿", "工单"),
PermissionSpec("ticket.edit", "编辑工单", "工单"),
PermissionSpec("ticket.export", "导出工单", "工单"),
PermissionSpec("ticket.assign", "分派工单", "工单"),
@@ -50,6 +51,7 @@ DEFAULT_GROUPS: dict[str, tuple[str, ...]] = {
"技术支持": (
"ticket.view",
"ticket.create",
"ticket.ai_draft",
"ticket.edit",
"ticket.assign",
"ticket.close",
@@ -60,6 +62,7 @@ DEFAULT_GROUPS: dict[str, tuple[str, ...]] = {
"普通建单人": (
"ticket.view",
"ticket.create",
"ticket.ai_draft",
"knowledge.view",
"solution.view",
),

View File

@@ -0,0 +1,159 @@
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils import timezone
from apps.agents.models import AgentDefinition, AgentStepBinding, AgentVersion
from apps.modelhub.models import ModelDeployment
from apps.skills.models import SkillDefinition, SkillVersion
TICKET_FIELDS = {
"type": "object",
"properties": {
"title": {"type": "string"},
"description": {"type": "string"},
"category_code": {"type": "string"},
"priority": {"type": "string", "enum": ["low", "medium", "high", "urgent"]},
"problem_performance": {"type": "string"},
"problem_object": {"type": "string"},
"submitter_name": {"type": "string"},
"submitter_org": {"type": "string"},
"impact_scope": {"type": "string"},
"environment": {"type": "string"},
"related_system": {"type": "string"},
},
"required": [
"title",
"description",
"category_code",
"priority",
"problem_performance",
"problem_object",
"submitter_name",
"submitter_org",
"impact_scope",
"environment",
"related_system",
],
"additionalProperties": False,
}
AGENT_INPUT_SCHEMA = {
"type": "object",
"properties": {
"source_text": {"type": "string"},
"current_fields": {"type": "object"},
"messages": {"type": "array", "items": {"type": "object"}},
"image_refs": {"type": "array", "items": {"type": "object"}},
},
"required": ["source_text", "current_fields", "messages", "image_refs"],
}
OUTPUT_SCHEMA = {
"type": "object",
"properties": {
"fields": TICKET_FIELDS,
"missing_fields": {"type": "array", "items": {"type": "string"}},
"questions": {"type": "array", "items": {"type": "string"}},
},
"required": ["fields", "missing_fields", "questions"],
"additionalProperties": False,
}
class Command(BaseCommand):
help = "Create and publish the single-step ticket intake Agent and Skill."
def add_arguments(self, parser):
parser.add_argument("--deployment-id", type=int)
@transaction.atomic
def handle(self, *args, **options):
deployments = ModelDeployment.objects.filter(enabled=True, provider__enabled=True)
deployment_id = options.get("deployment_id")
deployment = (
deployments.filter(id=deployment_id).first() if deployment_id else deployments.first()
)
if deployment is None:
raise CommandError("请先配置并启用模型渠道和模型配置")
skill, _ = SkillDefinition.objects.get_or_create(
code="ticket-draft",
defaults={"name": "可信建单草稿", "description": "从描述与追问答案生成工单草稿。"},
)
skill_version = skill.versions.filter(status="published").order_by("-version").first()
if skill_version is None:
next_version = (
skill.versions.order_by("-version").values_list("version", flat=True).first() or 0
) + 1
skill_version = SkillVersion.objects.create(
definition=skill,
version=next_version,
status="published",
prompt_template=(
"根据用户原始描述 {{ input.source_text }}、"
"当前字段 {{ input.current_fields }}、"
"逐问记录 {{ input.messages }} 以及随消息提供的可选图片,生成完整工单草稿。"
"分类代码只能使用 business_issue、operation_issue、"
"data_issue、technical_issue。"
"无法可靠判断的值使用空字符串并列入 missing_fields,同时为缺失项生成简洁问题。"
"只输出符合 Schema 的 JSON。"
),
input_schema=AGENT_INPUT_SCHEMA,
output_schema=OUTPUT_SCHEMA,
example_input={
"source_text": "生产环境采集任务两小时没有更新",
"current_fields": {},
"messages": [],
"image_refs": [],
},
published_at=timezone.now(),
)
skill.active_version = skill_version
skill.save(update_fields=["active_version", "updated_at"])
agent, _ = AgentDefinition.objects.get_or_create(
code="ticket-intake",
defaults={"name": "可信建单 Agent", "description": "单步生成可审核的结构化工单草稿。"},
)
version = agent.versions.filter(status="published").order_by("-version").first()
if version is None:
next_version = (
agent.versions.order_by("-version").values_list("version", flat=True).first() or 0
) + 1
version = AgentVersion.objects.create(
definition=agent,
version=next_version,
status="published",
model_deployment=deployment,
system_prompt=(
"你是企业技术支持建单助手。不得创建工单,只生成待用户审核的 JSON 草稿;"
"不得臆造机构、联系人、环境或影响范围。"
),
input_schema=AGENT_INPUT_SCHEMA,
example_input={
"source_text": "生产环境采集任务两小时没有更新",
"current_fields": {},
"messages": [],
"image_refs": [],
},
published_at=timezone.now(),
)
AgentStepBinding.objects.create(
agent_version=version,
code="draft",
order=1,
skill_version=skill_version,
input_mapping={
"source_text": "$input.source_text",
"current_fields": "$input.current_fields",
"messages": "$input.messages",
"image_refs": "$input.image_refs",
},
)
agent.active_version = version
agent.save(update_fields=["active_version", "updated_at"])
self.stdout.write(
self.style.SUCCESS(
f"Published ticket-intake v{version.version} with deployment {deployment.id}."
)
)

View File

@@ -0,0 +1,62 @@
# Generated by Django 5.2.15 on 2026-07-16 11:13
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("agents", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="AgentArtifact",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
("artifact_type", models.CharField(max_length=80)),
("schema_version", models.PositiveIntegerField(default=1)),
("payload", models.JSONField(default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="agent_artifacts",
to=settings.AUTH_USER_MODEL,
),
),
(
"run",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="artifacts",
to="agents.agentrun",
),
),
],
options={
"ordering": ["-created_at"],
"indexes": [
models.Index(
fields=["artifact_type", "created_at"],
name="agents_agen_artifac_55ea60_idx",
)
],
},
),
]

View File

@@ -158,6 +158,32 @@ class AgentRun(models.Model):
]
class AgentArtifact(models.Model):
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
run = models.ForeignKey(
AgentRun,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="artifacts",
)
artifact_type = models.CharField(max_length=80)
schema_version = models.PositiveIntegerField(default=1)
payload = models.JSONField(default=dict)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="agent_artifacts",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
indexes = [models.Index(fields=["artifact_type", "created_at"])]
class AgentRunStep(models.Model):
run = models.ForeignKey(AgentRun, on_delete=models.CASCADE, related_name="steps")
code = models.CharField(max_length=80)

View File

@@ -196,16 +196,29 @@ def _invoke_for_step(
step: AgentRunStep,
prompt: str,
attempt_type: str,
image_parts: list[dict] | None = None,
) -> ModelInvocation:
version = run.agent_version
messages = []
if version.system_prompt:
messages.append({"role": "system", "content": version.system_prompt})
messages.append({"role": "user", "content": prompt})
user_content: str | list[dict] = prompt
if image_parts:
user_content = [{"type": "text", "text": prompt}, *image_parts]
messages.append({"role": "user", "content": user_content})
snapshot_messages = [
{"role": item["role"], "content": prompt if item["role"] == "user" else item["content"]}
for item in messages
]
invocation = ModelInvocation.objects.create(
deployment=version.model_deployment,
invocation_type="agent_step",
input_snapshot={"messages": messages, "run_id": str(run.public_id), "step": step.code},
input_snapshot={
"messages": snapshot_messages,
"image_refs": run.input_payload.get("image_refs", []) if image_parts else [],
"run_id": str(run.public_id),
"step": step.code,
},
created_by=run.created_by,
)
execute_invocation(
@@ -250,7 +263,7 @@ def _repair_output(
return invocation
def execute_agent_run(run_id: int) -> AgentRun:
def execute_agent_run(run_id: int, *, image_parts: list[dict] | None = None) -> AgentRun:
run = AgentRun.objects.select_related(
"definition",
"agent_version__model_deployment__provider",
@@ -294,7 +307,7 @@ def execute_agent_run(run_id: int) -> AgentRun:
step.input_payload = step_input
step.save(update_fields=["input_payload"])
prompt = render_prompt(binding.skill_version.prompt_template, step_input)
invocation = _invoke_for_step(run, step, prompt, "initial")
invocation = _invoke_for_step(run, step, prompt, "initial", image_parts)
if invocation.status != "succeeded":
raise AgentValidationError(invocation.error_message or "模型调用失败")
try:

View File

@@ -128,6 +128,37 @@ def test_cancellation_after_model_response_stops_following_steps(db, monkeypatch
assert run.final_output is None
def test_multimodal_request_keeps_base64_out_of_database_snapshots(db, monkeypatch):
_, version = create_agent(two_steps=False)
captured = {}
def request(deployment, messages, **kwargs):
captured["messages"] = messages
return CompletionResult('{"summary":"识别到截图"}', 8, 3)
monkeypatch.setattr("apps.modelhub.services._request_completion", request)
run = create_run(version)
run.input_payload["image_refs"] = [
{"object_path": "ticket-drafts/proof.png", "mime_type": "image/png"}
]
run.save(update_fields=["input_payload"])
execute_agent_run(
run.id,
image_parts=[
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64,c2Vuc2l0aXZl"},
}
],
)
invocation = run.steps.get().attempts.get(attempt_type="initial").model_invocation
assert "base64" in str(captured["messages"])
assert "base64" not in str(invocation.input_snapshot)
assert invocation.input_snapshot["image_refs"][0]["object_path"].endswith("proof.png")
def test_soft_time_limit_marks_run_timed_out_and_audits(db, monkeypatch):
_, version = create_agent()
run = create_run(version)

View File

@@ -1,4 +1,5 @@
from django.db import IntegrityError
from django.utils import timezone
from ninja import Router
from ninja.errors import HttpError
@@ -31,6 +32,14 @@ def serialize_provider(provider: ModelProvider) -> dict:
"default_timeout_seconds": provider.default_timeout_seconds,
"credential_set": bool(provider.api_key_encrypted),
"credential_masked": mask_secret(provider.api_key_encrypted),
"image_egress_approved": provider.image_egress_approved,
"image_egress_approved_by": (
provider.image_egress_approved_by.get_full_name()
or provider.image_egress_approved_by.username
if provider.image_egress_approved_by_id
else None
),
"image_egress_approved_at": provider.image_egress_approved_at,
"created_at": provider.created_at,
"updated_at": provider.updated_at,
}
@@ -65,7 +74,10 @@ def audit(user, action: str, target_type: str, target_id: str, snapshot: dict) -
@router.get("/providers", response=list[ModelProviderOut])
def list_providers(request):
require_permission(request, "model.manage")
return [serialize_provider(provider) for provider in ModelProvider.objects.all()]
return [
serialize_provider(provider)
for provider in ModelProvider.objects.select_related("image_egress_approved_by")
]
@router.post("/providers", response=ModelProviderOut)
@@ -81,6 +93,9 @@ def create_provider(request, payload: ModelProviderIn):
api_key_encrypted=encrypted,
enabled=payload.enabled,
default_timeout_seconds=payload.default_timeout_seconds,
image_egress_approved=payload.image_egress_approved,
image_egress_approved_by=request.user if payload.image_egress_approved else None,
image_egress_approved_at=timezone.now() if payload.image_egress_approved else None,
created_by=request.user,
)
except CredentialEncryptionError as error:
@@ -97,6 +112,7 @@ def update_provider(request, provider_id: int, payload: ModelProviderUpdateIn):
provider = ModelProvider.objects.get(id=provider_id)
values = payload.dict(exclude_unset=True)
api_key = values.pop("api_key", None)
image_egress_approved = values.pop("image_egress_approved", None)
if api_key:
try:
provider.api_key_encrypted = encrypt_secret(api_key)
@@ -106,13 +122,22 @@ def update_provider(request, provider_id: int, payload: ModelProviderUpdateIn):
raise HttpError(400, "Timeout must be between 1 and 300 seconds")
for field, value in values.items():
setattr(provider, field, value.rstrip("/") if field == "base_url" else value)
if image_egress_approved is not None:
provider.image_egress_approved = image_egress_approved
provider.image_egress_approved_by = request.user if image_egress_approved else None
provider.image_egress_approved_at = timezone.now() if image_egress_approved else None
provider.save()
audit(
request.user,
"update",
"model_provider",
str(provider.id),
{"fields": sorted(values), "credential_replaced": bool(api_key)},
{
"fields": sorted(
[*values, *(["image_egress_approved"] if image_egress_approved is not None else [])]
),
"credential_replaced": bool(api_key),
},
)
return serialize_provider(provider)
@@ -133,6 +158,8 @@ def create_deployment(request, payload: ModelDeploymentIn):
raise HttpError(400, "Temperature must be between 0 and 2")
if not 1 <= payload.max_output_tokens <= 32768:
raise HttpError(400, "Max output tokens are out of range")
if payload.capability not in {"text_chat", "vision_chat"}:
raise HttpError(400, "Unknown model capability")
deployment = ModelDeployment.objects.create(**payload.dict())
deployment = ModelDeployment.objects.select_related("provider").get(id=deployment.id)
audit(
@@ -150,6 +177,8 @@ def update_deployment(request, deployment_id: int, payload: ModelDeploymentUpdat
require_permission(request, "model.manage")
deployment = ModelDeployment.objects.select_related("provider").get(id=deployment_id)
values = payload.dict(exclude_unset=True)
if "capability" in values and values["capability"] not in {"text_chat", "vision_chat"}:
raise HttpError(400, "Unknown model capability")
for field, value in values.items():
setattr(deployment, field, value)
deployment.save()

View File

@@ -0,0 +1,36 @@
# Generated by Django 5.2.15 on 2026-07-16 11:13
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("modelhub", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name="modelprovider",
name="image_egress_approved",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="modelprovider",
name="image_egress_approved_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="modelprovider",
name="image_egress_approved_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="approved_image_model_providers",
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@@ -10,6 +10,15 @@ class ModelProvider(models.Model):
api_key_encrypted = models.TextField(blank=True)
enabled = models.BooleanField(default=True)
default_timeout_seconds = models.PositiveIntegerField(default=60)
image_egress_approved = models.BooleanField(default=False)
image_egress_approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="approved_image_model_providers",
)
image_egress_approved_at = models.DateTimeField(null=True, blank=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,

View File

@@ -11,6 +11,7 @@ class ModelProviderIn(Schema):
api_key: str
enabled: bool = True
default_timeout_seconds: int = 60
image_egress_approved: bool = False
class ModelProviderUpdateIn(Schema):
@@ -19,6 +20,7 @@ class ModelProviderUpdateIn(Schema):
api_key: str | None = None
enabled: bool | None = None
default_timeout_seconds: int | None = None
image_egress_approved: bool | None = None
class ModelProviderOut(Schema):
@@ -29,6 +31,9 @@ class ModelProviderOut(Schema):
default_timeout_seconds: int
credential_set: bool
credential_masked: str
image_egress_approved: bool
image_egress_approved_by: str | None
image_egress_approved_at: datetime | None
created_at: datetime
updated_at: datetime
@@ -37,6 +42,7 @@ class ModelDeploymentIn(Schema):
provider_id: int
display_name: str
model_name: str
capability: str = "text_chat"
default_temperature: float = 0.2
max_output_tokens: int = 2048
enabled: bool = True
@@ -45,6 +51,7 @@ class ModelDeploymentIn(Schema):
class ModelDeploymentUpdateIn(Schema):
display_name: str | None = None
model_name: str | None = None
capability: str | None = None
default_temperature: float | None = None
max_output_tokens: int | None = None
enabled: bool | None = None

View File

@@ -1,5 +1,6 @@
from dataclasses import dataclass
from time import monotonic, sleep
from typing import Any
from django.utils import timezone
from openai import (
@@ -33,7 +34,7 @@ class ModelGatewayError(RuntimeError):
def _request_completion(
deployment: ModelDeployment,
messages: list[dict[str, str]],
messages: list[dict[str, Any]],
*,
temperature: float,
max_output_tokens: int,
@@ -79,7 +80,7 @@ def _error_details(error: Exception) -> tuple[str, str, bool]:
def execute_invocation(
invocation: ModelInvocation,
messages: list[dict[str, str]],
messages: list[dict[str, Any]],
*,
temperature: float | None = None,
max_output_tokens: int | None = None,
@@ -101,6 +102,7 @@ def execute_invocation(
"base_url": deployment.provider.base_url,
"deployment_id": deployment.id,
"model_name": deployment.model_name,
"capability": deployment.capability,
"temperature": temperature if temperature is not None else deployment.default_temperature,
"max_output_tokens": max_output_tokens or deployment.max_output_tokens,
}

View File

@@ -24,7 +24,13 @@ from apps.tickets.schemas import (
TicketStatusIn,
TicketUpdateIn,
)
from apps.tickets.services import TicketStatusError, transition_requires_remark, transition_ticket
from apps.tickets.services import (
TicketDataError,
TicketStatusError,
create_ticket_from_fields,
transition_requires_remark,
transition_ticket,
)
router = Router(tags=["tickets"])
@@ -101,37 +107,10 @@ def serialize_ticket_event(event: TicketEvent) -> dict:
@router.post("", response=TicketOut)
def create_ticket(request, payload: TicketIn):
require_permission(request, "ticket.create")
assignee = None
if payload.assignee_id:
assignee = get_user_model().objects.filter(id=payload.assignee_id).first()
category = None
if payload.category_code:
category = TicketCategory.objects.filter(code=payload.category_code, enabled=True).first()
if category is None:
raise HttpError(400, "Unknown ticket category")
ticket = Ticket.objects.create(
ticket_no=next_ticket_no(),
title=payload.title,
description=payload.description,
category=category,
priority=payload.priority,
problem_performance=payload.problem_performance,
problem_object=payload.problem_object,
submitter_name=payload.submitter_name,
submitter_org=payload.submitter_org,
impact_scope=payload.impact_scope,
environment=payload.environment,
related_system=payload.related_system,
assignee=assignee,
requester=request.user if request.user.is_authenticated else None,
created_by=request.user if request.user.is_authenticated else None,
)
TicketEvent.objects.create(
ticket=ticket,
event_type="created",
to_value={"status": ticket.status, "category_code": payload.category_code},
operator=request.user if request.user.is_authenticated else None,
)
try:
ticket = create_ticket_from_fields(payload.dict(), request.user)
except TicketDataError as error:
raise HttpError(400, str(error)) from error
return serialize_ticket(ticket)

View File

@@ -0,0 +1,244 @@
from datetime import timedelta
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from ninja import File, Router, UploadedFile
from ninja.errors import HttpError
from apps.accounts.api import require_permission
from apps.tickets.api import serialize_ticket
from apps.tickets.draft_schemas import (
TicketDraftActionIn,
TicketDraftAnswerIn,
TicketDraftAvailabilityOut,
TicketDraftCreateIn,
TicketDraftOut,
TicketDraftPatchIn,
)
from apps.tickets.draft_services import (
ACTIVE_DRAFT_STATUSES,
TicketDraftError,
add_answer,
assert_revision,
audit_draft,
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.tasks import execute_ticket_draft_analysis
router = Router(tags=["ticket-drafts"])
def get_owned_draft(request, public_id: str, *, allow_admin_view: bool = False) -> TicketDraft:
require_permission(request, "ticket.ai_draft")
queryset = TicketDraft.objects.select_related(
"latest_agent_run", "confirmed_ticket", "created_by"
).prefetch_related("images", "messages")
draft = queryset.filter(public_id=public_id).first()
if draft is None:
raise HttpError(404, "草稿不存在")
if draft.created_by_id != request.user.id:
can_view = allow_admin_view and (
request.user.is_superuser or request.user.has_perm("system.agent.run.view")
)
if not can_view:
raise HttpError(403, "不能访问其他人的草稿")
return draft
def raise_draft_error(error: TicketDraftError) -> None:
raise HttpError(error.status_code, str(error)) from error
@router.get("/availability", response=TicketDraftAvailabilityOut)
def get_availability(request):
require_permission(request, "ticket.ai_draft")
return draft_availability()
@router.get("", response=list[TicketDraftOut])
def list_ticket_drafts(request):
require_permission(request, "ticket.ai_draft")
drafts = (
TicketDraft.objects.filter(created_by=request.user, status__in=ACTIVE_DRAFT_STATUSES)
.select_related("latest_agent_run", "confirmed_ticket")
.prefetch_related("images", "messages")[:20]
)
return [serialize_draft(draft, ticket_serializer=serialize_ticket) for draft in drafts]
@router.post("", response=TicketDraftOut)
def create_ticket_draft(request, payload: TicketDraftCreateIn):
require_permission(request, "ticket.ai_draft")
availability = draft_availability()
if not availability["available"]:
raise HttpError(422, availability["reason"])
source_text = payload.source_text.strip()
draft = TicketDraft.objects.create(created_by=request.user, source_text=source_text)
audit_draft(draft, "draft_created")
return serialize_draft(draft, ticket_serializer=serialize_ticket)
@router.get("/{public_id}", response=TicketDraftOut)
def get_ticket_draft(request, public_id: str):
draft = get_owned_draft(request, public_id, allow_admin_view=True)
return serialize_draft(draft, ticket_serializer=serialize_ticket)
@router.patch("/{public_id}", response=TicketDraftOut)
def update_ticket_draft(request, public_id: str, payload: TicketDraftPatchIn):
with transaction.atomic():
draft = (
TicketDraft.objects.select_for_update()
.filter(public_id=public_id, created_by=request.user)
.first()
)
if draft is None:
require_permission(request, "ticket.ai_draft")
raise HttpError(404, "草稿不存在")
require_permission(request, "ticket.ai_draft")
try:
assert_revision(draft, payload.expected_revision)
except TicketDraftError as error:
raise_draft_error(error)
if draft.status not in {"editing", "ready", "failed"}:
raise HttpError(400, "当前状态不能编辑")
draft.current_fields = payload.fields
draft.revision += 1
draft.expires_at = timezone.now() + timedelta(hours=settings.TICKET_DRAFT_RETENTION_HOURS)
draft.save()
audit_draft(draft, "draft_updated")
draft = get_owned_draft(request, public_id)
return serialize_draft(draft, ticket_serializer=serialize_ticket)
@router.post("/{public_id}/images", response=TicketDraftOut)
def upload_ticket_draft_image(request, public_id: str, file: File[UploadedFile]):
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 张图片")
raw = file.read()
try:
prepared = prepare_image(raw, file.name or "image")
except DraftImageError 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(
draft=draft,
file_name=prepared.file_name,
mime_type=prepared.mime_type,
file_size=len(prepared.data),
sha256=prepared.sha256,
storage_path="",
)
image.storage_path = (
f"ticket-drafts/{draft.created_by_id}/{draft.public_id}/"
f"{image.public_id}.{prepared.extension}"
)
try:
put_object(image.storage_path, prepared.data, prepared.mime_type)
except Exception as error:
raise HttpError(503, "图片存储暂时不可用") from error
image.save()
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")
return serialize_draft(get_owned_draft(request, public_id), ticket_serializer=serialize_ticket)
@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)
def start_analysis(draft: TicketDraft, expected_revision: int) -> TicketDraft:
try:
assert_revision(draft, expected_revision)
if draft.status not in {"editing", "ready", "failed"}:
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"]:
raise TicketDraftError(422, draft_availability()["vision_reason"])
run = create_agent_run_for_draft(draft)
except TicketDraftError as error:
raise_draft_error(error)
result = execute_ticket_draft_analysis.delay(draft.id, run.id)
run.refresh_from_db()
if not run.task_id:
run.task_id = result.id or ""
run.save(update_fields=["task_id"])
draft.refresh_from_db()
return draft
@router.post("/{public_id}/analyze", response=TicketDraftOut)
def analyze_ticket_draft(request, public_id: str, payload: TicketDraftActionIn):
draft = get_owned_draft(request, public_id)
draft = start_analysis(draft, payload.expected_revision)
return serialize_draft(draft, ticket_serializer=serialize_ticket)
@router.post("/{public_id}/answers", response=TicketDraftOut)
def answer_ticket_draft(request, public_id: str, payload: TicketDraftAnswerIn):
draft = get_owned_draft(request, public_id)
try:
assert_revision(draft, payload.expected_revision)
if not payload.answer.strip():
raise TicketDraftError(400, "回答不能为空")
add_answer(draft, payload.answer, payload.question)
except TicketDraftError as error:
raise_draft_error(error)
draft.refresh_from_db()
draft = start_analysis(draft, draft.revision)
return serialize_draft(draft, ticket_serializer=serialize_ticket)
@router.post("/{public_id}/confirm", response=TicketDraftOut)
def confirm_ticket_draft(request, public_id: str, payload: TicketDraftActionIn):
require_permission(request, "ticket.ai_draft")
require_permission(request, "ticket.create")
try:
draft = confirm_draft(public_id, payload.expected_revision, request.user)
except TicketDraft.DoesNotExist as error:
raise HttpError(404, "草稿不存在") from error
except TicketDraftError as error:
raise_draft_error(error)
draft = get_owned_draft(request, public_id)
return serialize_draft(draft, ticket_serializer=serialize_ticket)
@router.post("/{public_id}/reject", response=TicketDraftOut)
def reject_ticket_draft(request, public_id: str, payload: TicketDraftActionIn):
require_permission(request, "ticket.ai_draft")
try:
draft = reject_draft(public_id, payload.expected_revision, request.user)
except TicketDraft.DoesNotExist as error:
raise HttpError(404, "草稿不存在") from error
except TicketDraftError as error:
raise_draft_error(error)
draft = get_owned_draft(request, public_id)
return serialize_draft(draft, ticket_serializer=serialize_ticket)

View File

@@ -0,0 +1,66 @@
from datetime import datetime
from ninja import Schema
class TicketDraftCreateIn(Schema):
source_text: str = ""
class TicketDraftPatchIn(Schema):
expected_revision: int
fields: dict
class TicketDraftActionIn(Schema):
expected_revision: int
class TicketDraftAnswerIn(Schema):
expected_revision: int
answer: str
question: str = ""
class TicketDraftImageOut(Schema):
id: str
file_name: str
mime_type: str
file_size: int
status: str
class TicketDraftMessageOut(Schema):
role: str
content: str
round_number: int
created_at: datetime
class TicketDraftOut(Schema):
public_id: str
status: str
revision: int
source_text: str
fields: dict
last_ai_fields: dict
missing_fields: list[str]
questions: list[str]
guided_rounds: int
agent_run_public_id: str | None = None
images: list[TicketDraftImageOut]
messages: list[TicketDraftMessageOut]
decision: str
error_code: str
error_message: str
confirmed_ticket: dict | None = None
created_at: datetime
updated_at: datetime
expires_at: datetime
class TicketDraftAvailabilityOut(Schema):
available: bool
reason: str
vision_available: bool
vision_reason: str

View File

@@ -0,0 +1,380 @@
import base64
from datetime import timedelta
from django.conf import settings
from django.db import transaction
from django.utils import timezone
from apps.agents.models import AgentArtifact, AgentDefinition, AgentRun
from apps.agents.services import build_config_snapshot, execute_agent_run
from apps.audit.models import OperationAuditLog
from apps.tickets.models import (
TicketAttachment,
TicketDraft,
TicketDraftImage,
TicketDraftMessage,
TicketEvent,
)
from apps.tickets.services import TicketDataError, create_ticket_from_fields
from apps.tickets.storage import copy_object, delete_object, get_object
ACTIVE_DRAFT_STATUSES = {"editing", "analyzing", "ready", "failed"}
FINAL_DRAFT_STATUSES = {"confirmed", "rejected", "expired"}
ALLOWED_FIELD_CODES = {
"title",
"description",
"category_code",
"priority",
"problem_performance",
"problem_object",
"submitter_name",
"submitter_org",
"impact_scope",
"environment",
"related_system",
"assignee_id",
}
class TicketDraftError(ValueError):
def __init__(self, status_code: int, message: str):
super().__init__(message)
self.status_code = status_code
def audit_draft(draft: TicketDraft, action: str, result: str = "success") -> None:
OperationAuditLog.objects.create(
user=draft.created_by,
module="tickets",
action=action,
target_type="ticket_draft",
target_id=str(draft.public_id),
request_snapshot={
"status": draft.status,
"revision": draft.revision,
"guided_rounds": draft.guided_rounds,
"image_count": draft.images.count(),
"result": result,
},
result=result,
)
def get_ticket_intake_agent() -> AgentDefinition | None:
return (
AgentDefinition.objects.select_related("active_version__model_deployment__provider")
.filter(code="ticket-intake", active_version__status="published")
.first()
)
def draft_availability() -> dict:
definition = get_ticket_intake_agent()
if definition is None or definition.active_version is None:
return {
"available": False,
"reason": "尚未发布建单 Agent",
"vision_available": False,
"vision_reason": "尚未发布建单 Agent",
}
deployment = definition.active_version.model_deployment
available = bool(deployment.enabled and deployment.provider.enabled)
vision_available = bool(
available
and deployment.capability == "vision_chat"
and deployment.provider.image_egress_approved
)
if not available:
reason = "建单 Agent 的模型配置未启用"
else:
reason = ""
if deployment.capability != "vision_chat":
vision_reason = "当前建单模型不支持图片理解"
elif not deployment.provider.image_egress_approved:
vision_reason = "管理员尚未授权图片发送至模型渠道"
elif not available:
vision_reason = reason
else:
vision_reason = ""
return {
"available": available,
"reason": reason,
"vision_available": vision_available,
"vision_reason": vision_reason,
}
def assert_revision(draft: TicketDraft, expected_revision: int) -> None:
if draft.revision != expected_revision:
raise TicketDraftError(409, "草稿已在其他位置更新,请刷新后重试")
def serialize_image(image: TicketDraftImage) -> dict:
return {
"id": str(image.public_id),
"file_name": image.file_name,
"mime_type": image.mime_type,
"file_size": image.file_size,
"status": image.status,
}
def serialize_draft(draft: TicketDraft, *, ticket_serializer=None) -> dict:
confirmed = None
if draft.confirmed_ticket_id and ticket_serializer:
confirmed = ticket_serializer(draft.confirmed_ticket)
return {
"public_id": str(draft.public_id),
"status": draft.status,
"revision": draft.revision,
"source_text": draft.source_text,
"fields": draft.current_fields,
"last_ai_fields": draft.last_ai_fields,
"missing_fields": draft.missing_fields,
"questions": draft.questions,
"guided_rounds": draft.guided_rounds,
"agent_run_public_id": str(draft.latest_agent_run.public_id)
if draft.latest_agent_run_id
else None,
"images": [serialize_image(image) for image in draft.images.all()],
"messages": [
{
"role": message.role,
"content": message.content,
"round_number": message.round_number,
"created_at": message.created_at,
}
for message in draft.messages.all()
],
"decision": draft.decision,
"error_code": draft.error_code,
"error_message": draft.error_message,
"confirmed_ticket": confirmed,
"created_at": draft.created_at,
"updated_at": draft.updated_at,
"expires_at": draft.expires_at,
}
def create_agent_run_for_draft(draft: TicketDraft) -> AgentRun:
definition = get_ticket_intake_agent()
if definition is None or definition.active_version is None:
raise TicketDraftError(422, "尚未发布可用的建单 Agent")
version = definition.active_version
image_refs = [
{"object_path": image.storage_path, "mime_type": image.mime_type}
for image in draft.images.all()
]
messages = [
{"role": item.role, "content": item.content, "round": item.round_number}
for item in draft.messages.all()
]
run = AgentRun.objects.create(
definition=definition,
agent_version=version,
sandbox=False,
input_payload={
"draft_public_id": str(draft.public_id),
"source_text": draft.source_text,
"current_fields": draft.current_fields,
"messages": messages,
"image_refs": image_refs,
},
config_snapshot=build_config_snapshot(version),
total_steps=version.steps.count(),
created_by=draft.created_by,
)
draft.latest_agent_run = run
draft.status = "analyzing"
draft.revision += 1
draft.error_code = ""
draft.error_message = ""
draft.expires_at = timezone.now() + timedelta(hours=settings.TICKET_DRAFT_RETENTION_HOURS)
draft.save()
audit_draft(draft, "draft_analysis_started")
return run
def _image_parts(draft: TicketDraft) -> list[dict]:
parts = []
for image in draft.images.all():
encoded = base64.b64encode(get_object(image.storage_path)).decode("ascii")
parts.append(
{
"type": "image_url",
"image_url": {"url": f"data:{image.mime_type};base64,{encoded}"},
}
)
return parts
def execute_draft_analysis(draft_id: int, run_id: int) -> None:
draft = (
TicketDraft.objects.select_related("created_by").prefetch_related("images").get(id=draft_id)
)
run = AgentRun.objects.select_related("agent_version__model_deployment__provider").get(
id=run_id
)
deployment = run.agent_version.model_deployment
image_parts = []
if draft.images.exists():
if deployment.capability != "vision_chat" or not deployment.provider.image_egress_approved:
draft.status = "failed"
draft.error_code = "vision_not_allowed"
draft.error_message = "当前模型或渠道不允许图片输入"
draft.revision += 1
draft.save()
run.status = "failed"
run.error_code = draft.error_code
run.error_message = draft.error_message
run.finished_at = timezone.now()
run.save()
audit_draft(draft, "draft_analysis_failed", "failed")
return
image_parts = _image_parts(draft)
execute_agent_run(run.id, image_parts=image_parts)
run.refresh_from_db()
draft.refresh_from_db()
if run.status != "succeeded" or not isinstance(run.final_output, dict):
draft.status = "failed"
draft.error_code = run.error_code or "analysis_failed"
draft.error_message = run.error_message or "AI 分析失败,请重试或转为普通表单"
draft.revision += 1
draft.save()
audit_draft(draft, "draft_analysis_failed", "failed")
return
fields = run.final_output.get("fields")
missing_fields = run.final_output.get("missing_fields")
questions = run.final_output.get("questions")
if (
not isinstance(fields, dict)
or not isinstance(missing_fields, list)
or not isinstance(questions, list)
):
draft.status = "failed"
draft.error_code = "invalid_artifact"
draft.error_message = "AI 产物结构不完整"
draft.revision += 1
draft.save()
audit_draft(draft, "draft_analysis_failed", "failed")
return
fields = {code: value for code, value in fields.items() if code in ALLOWED_FIELD_CODES}
artifact = AgentArtifact.objects.create(
run=run,
artifact_type="ticket_draft",
schema_version=1,
payload={
"draft_public_id": str(draft.public_id),
"fields": fields,
"missing_fields": [str(item) for item in missing_fields],
"questions": [str(item) for item in questions],
},
created_by=draft.created_by,
)
draft.current_fields = fields
draft.last_ai_fields = fields
draft.missing_fields = [str(item) for item in missing_fields]
draft.questions = [str(item) for item in questions]
draft.latest_artifact = artifact
draft.status = "ready"
draft.error_code = ""
draft.error_message = ""
draft.revision += 1
draft.save()
audit_draft(draft, "draft_analysis_succeeded")
@transaction.atomic
def confirm_draft(draft_public_id, expected_revision: int, user) -> TicketDraft:
draft = (
TicketDraft.objects.select_for_update()
.select_related("created_by")
.prefetch_related("images")
.get(public_id=draft_public_id, created_by=user)
)
if draft.confirmed_ticket_id:
return draft
assert_revision(draft, expected_revision)
if draft.status != "ready":
raise TicketDraftError(400, "只有审核就绪的草稿可以确认")
try:
ticket = create_ticket_from_fields(draft.current_fields, user)
except TicketDataError as error:
raise TicketDraftError(400, str(error)) from error
decision = "accepted" if draft.current_fields == draft.last_ai_fields else "modified"
for image in draft.images.all():
source_path = image.storage_path
target_path = f"tickets/{ticket.id}/{image.public_id}-{image.file_name}"
copy_object(source_path, target_path)
attachment = TicketAttachment.objects.create(
ticket=ticket,
file_name=image.file_name,
file_type="image",
mime_type=image.mime_type,
file_size=image.file_size,
storage_path=target_path,
created_by=user,
)
image.status = "confirmed"
image.confirmed_attachment = attachment
image.storage_path = target_path
image.save(update_fields=["status", "confirmed_attachment", "storage_path"])
transaction.on_commit(lambda path=source_path: delete_object(path), robust=True)
draft.confirmed_ticket = ticket
draft.status = "confirmed"
draft.decision = decision
draft.confirmed_at = timezone.now()
draft.revision += 1
draft.save()
TicketEvent.objects.create(
ticket=ticket,
event_type="ai_draft_confirmed",
to_value={
"draft_public_id": str(draft.public_id),
"decision": decision,
"agent_run_public_id": str(draft.latest_agent_run.public_id)
if draft.latest_agent_run_id
else None,
},
operator=user,
)
audit_draft(draft, "draft_confirmed")
return draft
@transaction.atomic
def reject_draft(draft_public_id, expected_revision: int, user) -> TicketDraft:
draft = TicketDraft.objects.select_for_update().get(public_id=draft_public_id, created_by=user)
assert_revision(draft, expected_revision)
if draft.status in FINAL_DRAFT_STATUSES:
if draft.status == "rejected":
return draft
raise TicketDraftError(400, "草稿已经结束")
draft.status = "rejected"
draft.decision = "rejected"
draft.revision += 1
draft.expires_at = timezone.now() + timedelta(hours=settings.TICKET_DRAFT_RETENTION_HOURS)
draft.save()
audit_draft(draft, "draft_rejected")
return draft
def add_answer(draft: TicketDraft, answer: str, question: str) -> None:
if draft.guided_rounds >= 5:
raise TicketDraftError(400, "逐问模式最多进行 5 轮,请手工补齐剩余字段")
round_number = draft.guided_rounds + 1
if question.strip():
TicketDraftMessage.objects.create(
draft=draft,
role="assistant",
content=question.strip(),
round_number=round_number,
)
TicketDraftMessage.objects.create(
draft=draft,
role="user",
content=answer.strip(),
round_number=round_number,
)
draft.guided_rounds = round_number
draft.save(update_fields=["guided_rounds", "updated_at"])

View File

@@ -0,0 +1,66 @@
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,
)
from apps.tickets.models import TicketDraft
class Command(BaseCommand):
help = "Run the fixed real-text ticket intake acceptance workflow."
def add_arguments(self, parser):
parser.add_argument("--username", default="admin")
def handle(self, *args, **options):
user = get_user_model().objects.filter(username=options["username"]).first()
if user is None:
raise CommandError("验收用户不存在")
draft = TicketDraft.objects.create(
created_by=user,
source_text=(
"生产环境中,客户 A 的采集任务 JOB-1024 从上午 10 点起没有更新,"
"影响单个客户,请技术支持排查。"
),
)
run = create_agent_run_for_draft(draft)
execute_draft_analysis(draft.id, run.id)
draft.refresh_from_db()
if draft.status != "ready":
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}"
)
)

View File

@@ -0,0 +1,169 @@
# Generated by Django 5.2.15 on 2026-07-16 11:13
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import apps.tickets.models
class Migration(migrations.Migration):
dependencies = [
("agents", "0002_agentartifact"),
("tickets", "0003_repair_closed_resolved_flags"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="TicketDraft",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
("status", models.CharField(default="editing", max_length=24)),
("revision", models.PositiveIntegerField(default=1)),
("source_text", models.TextField(blank=True)),
("current_fields", models.JSONField(default=dict)),
("last_ai_fields", models.JSONField(default=dict)),
("missing_fields", models.JSONField(default=list)),
("questions", models.JSONField(default=list)),
("guided_rounds", models.PositiveSmallIntegerField(default=0)),
("decision", models.CharField(blank=True, max_length=24)),
("error_code", models.CharField(blank=True, max_length=80)),
("error_message", models.TextField(blank=True)),
(
"expires_at",
models.DateTimeField(default=apps.tickets.models.default_ticket_draft_expiry),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("confirmed_at", models.DateTimeField(blank=True, null=True)),
(
"confirmed_ticket",
models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="source_draft",
to="tickets.ticket",
),
),
(
"created_by",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ticket_drafts",
to=settings.AUTH_USER_MODEL,
),
),
(
"latest_agent_run",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="ticket_drafts",
to="agents.agentrun",
),
),
(
"latest_artifact",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="ticket_drafts",
to="agents.agentartifact",
),
),
],
options={
"ordering": ["-updated_at"],
},
),
migrations.CreateModel(
name="TicketDraftImage",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("public_id", models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
("file_name", models.CharField(max_length=255)),
("mime_type", models.CharField(max_length=120)),
("file_size", models.PositiveBigIntegerField()),
("sha256", models.CharField(max_length=64)),
("storage_path", models.CharField(max_length=500)),
("status", models.CharField(default="temporary", max_length=24)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"confirmed_attachment",
models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="source_draft_image",
to="tickets.ticketattachment",
),
),
(
"draft",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="images",
to="tickets.ticketdraft",
),
),
],
options={
"ordering": ["created_at"],
},
),
migrations.CreateModel(
name="TicketDraftMessage",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("role", models.CharField(max_length=24)),
("content", models.TextField()),
("round_number", models.PositiveSmallIntegerField(default=0)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"draft",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="messages",
to="tickets.ticketdraft",
),
),
],
options={
"ordering": ["created_at"],
},
),
migrations.AddIndex(
model_name="ticketdraft",
index=models.Index(
fields=["created_by", "status", "updated_at"], name="tickets_tic_created_424ee8_idx"
),
),
migrations.AddIndex(
model_name="ticketdraft",
index=models.Index(
fields=["status", "expires_at"], name="tickets_tic_status_3e6dd8_idx"
),
),
]

View File

@@ -1,5 +1,13 @@
import uuid
from datetime import timedelta
from django.conf import settings
from django.db import models
from django.utils import timezone
def default_ticket_draft_expiry():
return timezone.now() + timedelta(hours=24)
class TicketStatus(models.TextChoices):
@@ -132,6 +140,91 @@ class TicketAttachment(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
class TicketDraft(models.Model):
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="ticket_drafts",
)
status = models.CharField(max_length=24, default="editing")
revision = models.PositiveIntegerField(default=1)
source_text = models.TextField(blank=True)
current_fields = models.JSONField(default=dict)
last_ai_fields = models.JSONField(default=dict)
missing_fields = models.JSONField(default=list)
questions = models.JSONField(default=list)
guided_rounds = models.PositiveSmallIntegerField(default=0)
latest_agent_run = models.ForeignKey(
"agents.AgentRun",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="ticket_drafts",
)
latest_artifact = models.ForeignKey(
"agents.AgentArtifact",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="ticket_drafts",
)
confirmed_ticket = models.OneToOneField(
Ticket,
null=True,
blank=True,
on_delete=models.PROTECT,
related_name="source_draft",
)
decision = models.CharField(max_length=24, blank=True)
error_code = models.CharField(max_length=80, blank=True)
error_message = models.TextField(blank=True)
expires_at = models.DateTimeField(default=default_ticket_draft_expiry)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
confirmed_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-updated_at"]
indexes = [
models.Index(fields=["created_by", "status", "updated_at"]),
models.Index(fields=["status", "expires_at"]),
]
class TicketDraftMessage(models.Model):
draft = models.ForeignKey(TicketDraft, on_delete=models.CASCADE, related_name="messages")
role = models.CharField(max_length=24)
content = models.TextField()
round_number = models.PositiveSmallIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["created_at"]
class TicketDraftImage(models.Model):
public_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
draft = models.ForeignKey(TicketDraft, on_delete=models.CASCADE, related_name="images")
file_name = models.CharField(max_length=255)
mime_type = models.CharField(max_length=120)
file_size = models.PositiveBigIntegerField()
sha256 = models.CharField(max_length=64)
storage_path = models.CharField(max_length=500)
status = models.CharField(max_length=24, default="temporary")
confirmed_attachment = models.OneToOneField(
TicketAttachment,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="source_draft_image",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["created_at"]
class TicketEvent(models.Model):
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="events")
event_type = models.CharField(max_length=80)

View File

@@ -1,13 +1,98 @@
from django.contrib.auth import get_user_model
from django.utils import timezone
from apps.audit.models import OperationAuditLog
from apps.tickets.models import Ticket, TicketEvent
from apps.tickets.models import Ticket, TicketCategory, TicketEvent, TicketPriority
class TicketStatusError(ValueError):
pass
class TicketDataError(ValueError):
pass
REQUIRED_TICKET_FIELDS = (
"title",
"description",
"category_code",
"priority",
"problem_performance",
"problem_object",
"submitter_name",
"submitter_org",
"environment",
)
def validate_ticket_fields(fields: dict) -> tuple[dict, TicketCategory]:
normalized = {
"title": str(fields.get("title") or "").strip(),
"description": str(fields.get("description") or "").strip(),
"category_code": str(fields.get("category_code") or "").strip(),
"priority": str(fields.get("priority") or "medium").strip(),
"problem_performance": str(fields.get("problem_performance") or "").strip(),
"problem_object": str(fields.get("problem_object") or "").strip(),
"submitter_name": str(fields.get("submitter_name") or "").strip(),
"submitter_org": str(fields.get("submitter_org") or "").strip(),
"impact_scope": str(fields.get("impact_scope") or "").strip(),
"environment": str(fields.get("environment") or "").strip(),
"related_system": str(fields.get("related_system") or "").strip(),
"assignee_id": fields.get("assignee_id"),
}
missing = [code for code in REQUIRED_TICKET_FIELDS if not normalized.get(code)]
if missing:
raise TicketDataError(f"缺少必填字段: {', '.join(missing)}")
if normalized["priority"] not in TicketPriority.values:
raise TicketDataError("工单优先级无效")
category = TicketCategory.objects.filter(code=normalized["category_code"], enabled=True).first()
if category is None:
raise TicketDataError("工单分类无效或已停用")
if normalized["assignee_id"]:
assignee = get_user_model().objects.filter(id=normalized["assignee_id"]).first()
if assignee is None:
raise TicketDataError("处理人不存在")
else:
assignee = None
normalized["assignee"] = assignee
return normalized, category
def next_ticket_no() -> str:
prefix = timezone.localdate().strftime("TK%Y%m%d")
count = Ticket.objects.filter(ticket_no__startswith=prefix).count() + 1
return f"{prefix}{count:04d}"
def create_ticket_from_fields(fields: dict, user) -> Ticket:
normalized, category = validate_ticket_fields(fields)
ticket = Ticket.objects.create(
ticket_no=next_ticket_no(),
title=normalized["title"],
description=normalized["description"],
category=category,
priority=normalized["priority"],
problem_performance=normalized["problem_performance"],
problem_object=normalized["problem_object"],
submitter_name=normalized["submitter_name"],
submitter_org=normalized["submitter_org"],
impact_scope=normalized["impact_scope"],
environment=normalized["environment"],
related_system=normalized["related_system"],
assignee=normalized["assignee"],
requester=user,
created_by=user,
)
TicketEvent.objects.create(
ticket=ticket,
event_type="created",
to_value={"status": ticket.status, "category_code": normalized["category_code"]},
operator=user,
)
return ticket
STANDARD_TRANSITIONS: dict[str, set[str]] = {
"pending_info": {"pending", "closed"},
"pending": {"in_progress", "closed"},

View File

@@ -0,0 +1,112 @@
import hashlib
import io
from dataclasses import dataclass
from django.conf import settings
from minio import Minio
from PIL import Image, UnidentifiedImageError
class DraftImageError(ValueError):
def __init__(self, status_code: int, message: str):
super().__init__(message)
self.status_code = status_code
@dataclass(frozen=True)
class PreparedImage:
data: bytes
file_name: str
mime_type: str
extension: str
sha256: str
FORMATS = {
"JPEG": ("image/jpeg", "jpg"),
"PNG": ("image/png", "png"),
"WEBP": ("image/webp", "webp"),
}
def prepare_image(raw: bytes, original_name: str) -> PreparedImage:
if len(raw) > 10 * 1024 * 1024:
raise DraftImageError(413, "单张图片不能超过 10 MB")
try:
with Image.open(io.BytesIO(raw)) as image:
image.verify()
with Image.open(io.BytesIO(raw)) as image:
if image.format not in FORMATS or getattr(image, "is_animated", False):
raise DraftImageError(415, "仅支持 JPEG、PNG 和 WebP 静态图片")
mime_type, extension = FORMATS[image.format]
if image.mode not in {"RGB", "RGBA", "L"}:
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
output = io.BytesIO()
save_format = "JPEG" if extension == "jpg" else image.format
if save_format == "JPEG" and image.mode != "RGB":
image = image.convert("RGB")
image.save(output, format=save_format, quality=90, optimize=True)
except DraftImageError:
raise
except (UnidentifiedImageError, OSError, ValueError) as error:
raise DraftImageError(415, "图片内容无法识别或格式不受支持") from error
data = output.getvalue()
stem = original_name.rsplit(".", 1)[0].strip() or "image"
return PreparedImage(
data=data,
file_name=f"{stem}.{extension}",
mime_type=mime_type,
extension=extension,
sha256=hashlib.sha256(data).hexdigest(),
)
def client() -> Minio:
return Minio(
settings.MINIO_ENDPOINT,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
secure=settings.MINIO_SECURE,
)
def ensure_bucket(storage: Minio) -> None:
if not storage.bucket_exists(settings.MINIO_BUCKET):
storage.make_bucket(settings.MINIO_BUCKET)
def put_object(path: str, data: bytes, mime_type: str) -> None:
storage = client()
ensure_bucket(storage)
storage.put_object(
settings.MINIO_BUCKET,
path,
io.BytesIO(data),
len(data),
content_type=mime_type,
)
def get_object(path: str) -> bytes:
response = client().get_object(settings.MINIO_BUCKET, path)
try:
return response.read()
finally:
response.close()
response.release_conn()
def delete_object(path: str) -> None:
client().remove_object(settings.MINIO_BUCKET, path)
def copy_object(source_path: str, target_path: str) -> None:
from minio.commonconfig import CopySource
storage = client()
ensure_bucket(storage)
storage.copy_object(
settings.MINIO_BUCKET,
target_path,
CopySource(settings.MINIO_BUCKET, source_path),
)

View File

@@ -0,0 +1,55 @@
from celery import shared_task
from django.utils import timezone
from apps.agents.models import AgentArtifact, AgentRun
from apps.audit.models import OperationAuditLog
from apps.modelhub.models import ModelInvocation
from apps.tickets.draft_services import execute_draft_analysis
from apps.tickets.models import TicketDraft
from apps.tickets.storage import delete_object
@shared_task(name="apps.tickets.tasks.execute_ticket_draft_analysis")
def execute_ticket_draft_analysis(draft_id: int, run_id: int) -> None:
execute_draft_analysis(draft_id, run_id)
@shared_task(name="apps.tickets.tasks.cleanup_expired_ticket_drafts")
def cleanup_expired_ticket_drafts() -> int:
queryset = TicketDraft.objects.filter(
expires_at__lt=timezone.now(), confirmed_ticket__isnull=True
).prefetch_related("images")
count = queryset.count()
for draft in queryset:
public_id = str(draft.public_id)
for image in draft.images.all():
try:
delete_object(image.storage_path)
except Exception:
pass
OperationAuditLog.objects.create(
user=draft.created_by,
module="tickets",
action="draft_expired",
target_type="ticket_draft",
target_id=str(draft.public_id),
request_snapshot={
"status": draft.status,
"guided_rounds": draft.guided_rounds,
"image_count": draft.images.count(),
},
result="success",
)
runs = AgentRun.objects.filter(input_payload__draft_public_id=public_id)
invocation_ids = list(
ModelInvocation.objects.filter(agent_attempts__step__run__in=runs)
.values_list("id", flat=True)
.distinct()
)
AgentArtifact.objects.filter(
artifact_type="ticket_draft", payload__draft_public_id=public_id
).delete()
runs.delete()
ModelInvocation.objects.filter(id__in=invocation_ids).delete()
draft.delete()
return count

View File

@@ -0,0 +1,277 @@
import io
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils import timezone
from PIL import Image
from apps.agents.models import AgentDefinition, AgentStepBinding, AgentVersion
from apps.audit.models import OperationAuditLog
from apps.modelhub.crypto import encrypt_secret
from apps.modelhub.models import ModelDeployment, ModelProvider
from apps.skills.models import SkillDefinition, SkillVersion
from apps.tickets.draft_services import TicketDraftError, add_answer
from apps.tickets.models import Ticket, TicketCategory, TicketDraft, TicketDraftMessage
from apps.tickets.tasks import cleanup_expired_ticket_drafts
def ticket_fields(**overrides):
fields = {
"title": "生产采集任务未更新",
"description": "生产环境采集任务已两小时没有更新",
"category_code": "technical_issue",
"priority": "high",
"problem_performance": "采集进度停滞",
"problem_object": "采集任务 A",
"submitter_name": "张三",
"submitter_org": "华东分公司",
"impact_scope": "单个任务",
"environment": "生产环境",
"related_system": "数据中台",
}
fields.update(overrides)
return fields
def create_ticket_agent(*, capability="text_chat", approved=False):
provider = ModelProvider.objects.create(
name="建单渠道",
base_url="https://models.example.com/v1",
api_key_encrypted=encrypt_secret("secret"),
image_egress_approved=approved,
)
deployment = ModelDeployment.objects.create(
provider=provider,
display_name="建单模型",
model_name="ticket-model",
capability=capability,
)
skill = SkillDefinition.objects.create(code="ticket-draft", name="建单")
skill_version = SkillVersion.objects.create(
definition=skill,
version=1,
status="published",
prompt_template="{{ input.source_text }}",
input_schema={
"type": "object",
"properties": {"source_text": {"type": "string"}},
"required": ["source_text"],
},
output_schema={"type": "object"},
)
agent = AgentDefinition.objects.create(code="ticket-intake", name="建单 Agent")
version = AgentVersion.objects.create(
definition=agent,
version=1,
status="published",
model_deployment=deployment,
input_schema={"type": "object"},
)
AgentStepBinding.objects.create(
agent_version=version,
code="draft",
order=1,
skill_version=skill_version,
input_mapping={"source_text": "$input.source_text"},
)
agent.active_version = version
agent.save(update_fields=["active_version"])
return agent
@pytest.fixture
def category(db):
return TicketCategory.objects.get(code="technical_issue")
def test_feature_requires_published_ticket_agent(admin_client, db):
response = admin_client.get("/api/ticket-drafts/availability")
assert response.status_code == 200
assert response.json()["available"] is False
create_ticket_agent()
response = admin_client.get("/api/ticket-drafts/availability")
assert response.json()["available"] is True
assert response.json()["vision_available"] is False
def test_revision_conflict_and_manual_edit(admin_client, db):
create_ticket_agent()
created = admin_client.post(
"/api/ticket-drafts",
data={"source_text": "任务未更新"},
content_type="application/json",
).json()
response = admin_client.patch(
f"/api/ticket-drafts/{created['public_id']}",
data={"expected_revision": created["revision"], "fields": ticket_fields()},
content_type="application/json",
)
assert response.status_code == 200
assert response.json()["revision"] == 2
conflict = admin_client.patch(
f"/api/ticket-drafts/{created['public_id']}",
data={"expected_revision": 1, "fields": ticket_fields()},
content_type="application/json",
)
assert conflict.status_code == 409
def test_confirm_is_idempotent_and_records_accepted(admin_client, category):
from django.contrib.auth import get_user_model
creator = get_user_model().objects.filter(is_superuser=True).first()
fields = ticket_fields()
draft = TicketDraft.objects.create(
created_by=creator,
status="ready",
current_fields=fields,
last_ai_fields=fields,
)
first = admin_client.post(
f"/api/ticket-drafts/{draft.public_id}/confirm",
data={"expected_revision": draft.revision},
content_type="application/json",
)
second = admin_client.post(
f"/api/ticket-drafts/{draft.public_id}/confirm",
data={"expected_revision": draft.revision},
content_type="application/json",
)
assert first.status_code == 200
assert second.status_code == 200
assert first.json()["confirmed_ticket"]["id"] == second.json()["confirmed_ticket"]["id"]
assert first.json()["decision"] == "accepted"
assert Ticket.objects.count() == 1
assert Ticket.objects.get().ai_solution == ""
def test_confirm_records_modified_and_reject_records_rejected(admin_client, category):
from django.contrib.auth import get_user_model
creator = get_user_model().objects.filter(is_superuser=True).first()
draft = TicketDraft.objects.create(
created_by=creator,
status="ready",
current_fields=ticket_fields(title="用户修改后的标题"),
last_ai_fields=ticket_fields(),
)
response = admin_client.post(
f"/api/ticket-drafts/{draft.public_id}/confirm",
data={"expected_revision": 1},
content_type="application/json",
)
assert response.json()["decision"] == "modified"
rejected = TicketDraft.objects.create(created_by=creator, source_text="放弃")
response = admin_client.post(
f"/api/ticket-drafts/{rejected.public_id}/reject",
data={"expected_revision": 1},
content_type="application/json",
)
assert response.json()["decision"] == "rejected"
def test_guided_answers_stop_after_five_rounds(admin_client, db):
from django.contrib.auth import get_user_model
draft = TicketDraft.objects.create(
created_by=get_user_model().objects.filter(is_superuser=True).first(),
guided_rounds=4,
)
add_answer(draft, "第五轮回答", "第五轮问题")
draft.refresh_from_db()
assert draft.guided_rounds == 5
assert TicketDraftMessage.objects.filter(draft=draft).count() == 2
with pytest.raises(TicketDraftError, match="最多进行 5 轮"):
add_answer(draft, "第六轮", "")
def test_image_upload_validates_content_and_never_audits_source_text(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()
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")
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"
snapshots = list(
OperationAuditLog.objects.filter(target_id=created["public_id"]).values_list(
"request_snapshot", flat=True
)
)
assert "包含敏感客户信息的原文" not in str(snapshots)
def test_text_model_rejects_image_analysis(admin_client, db):
create_ticket_agent(capability="text_chat")
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.images.create(
file_name="proof.png",
mime_type="image/png",
file_size=10,
sha256="0" * 64,
storage_path="ticket-drafts/proof.png",
)
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 == 422
assert "不支持图片" in response.json()["detail"]
def test_cleanup_deletes_unconfirmed_details_but_keeps_confirmed_draft(
admin_client, db, monkeypatch
):
from django.contrib.auth import get_user_model
creator = get_user_model().objects.filter(is_superuser=True).first()
expired = TicketDraft.objects.create(
created_by=creator,
source_text="过期敏感原文",
expires_at=timezone.now(),
)
expired.images.create(
file_name="proof.png",
mime_type="image/png",
file_size=10,
sha256="0" * 64,
storage_path="ticket-drafts/proof.png",
)
confirmed = TicketDraft.objects.create(
created_by=creator,
status="confirmed",
expires_at=timezone.now(),
confirmed_ticket=Ticket.objects.create(
ticket_no="TK202607160001", title="已确认", description="保留"
),
)
deleted_paths = []
monkeypatch.setattr("apps.tickets.tasks.delete_object", lambda path: deleted_paths.append(path))
assert cleanup_expired_ticket_drafts() == 1
assert not TicketDraft.objects.filter(id=expired.id).exists()
assert TicketDraft.objects.filter(id=confirmed.id).exists()
assert deleted_paths == ["ticket-drafts/proof.png"]
audit = OperationAuditLog.objects.get(action="draft_expired", target_id=str(expired.public_id))
assert "过期敏感原文" not in str(audit.request_snapshot)

View File

@@ -112,10 +112,21 @@ CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:63
AI_CREDENTIAL_ENCRYPTION_KEY = os.getenv("AI_CREDENTIAL_ENCRYPTION_KEY", "")
AI_RUN_RETENTION_DAYS = int(os.getenv("AI_RUN_RETENTION_DAYS", "90"))
TICKET_DRAFT_RETENTION_HOURS = int(os.getenv("TICKET_DRAFT_RETENTION_HOURS", "24"))
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9000")
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", os.getenv("MINIO_ROOT_USER", "minio"))
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", os.getenv("MINIO_ROOT_PASSWORD", "minio123"))
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "ai-ticket-platform")
MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true"
CELERY_BEAT_SCHEDULE = {
"cleanup-expired-agent-runs": {
"task": "apps.agents.tasks.cleanup_expired_agent_runs",
"schedule": 86400.0,
}
},
"cleanup-expired-ticket-drafts": {
"task": "apps.tickets.tasks.cleanup_expired_ticket_drafts",
"schedule": 3600.0,
},
}

View File

@@ -12,12 +12,14 @@ from apps.skills.api import router as skills_router
from apps.solutions.api import router as solutions_router
from apps.system.api import router as system_router
from apps.tickets.api import router as tickets_router
from apps.tickets.draft_api import router as ticket_drafts_router
api = NinjaAPI(title="AI Ticket Platform API", version="0.1.0")
api.add_router("/accounts", accounts_router)
api.add_router("/system", system_router)
api.add_router("/audit", audit_router)
api.add_router("/tickets", tickets_router)
api.add_router("/ticket-drafts", ticket_drafts_router)
api.add_router("/solutions", solutions_router)
api.add_router("/knowledge", knowledge_router)
api.add_router("/modelhub", modelhub_router)

View File

@@ -15,6 +15,8 @@ dependencies = [
"openai>=1.50,<3.0",
"cryptography>=43.0,<47.0",
"jsonschema>=4.23,<5.0",
"minio>=7.2,<8.0",
"Pillow>=11.0,<12.0",
]
[build-system]