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

@@ -17,9 +17,15 @@ CELERY_BROKER_URL=redis://redis:6379/0
CELERY_RESULT_BACKEND=redis://redis:6379/1 CELERY_RESULT_BACKEND=redis://redis:6379/1
AI_CREDENTIAL_ENCRYPTION_KEY= AI_CREDENTIAL_ENCRYPTION_KEY=
AI_RUN_RETENTION_DAYS=90 AI_RUN_RETENTION_DAYS=90
TICKET_DRAFT_RETENTION_HOURS=24
MINIO_ROOT_USER=minio MINIO_ROOT_USER=minio
MINIO_ROOT_PASSWORD=minio123 MINIO_ROOT_PASSWORD=minio123
MINIO_ENDPOINT=minio:9000
MINIO_ACCESS_KEY=minio
MINIO_SECRET_KEY=minio123
MINIO_BUCKET=ai-ticket-platform
MINIO_SECURE=false
VITE_API_BASE_URL=/api VITE_API_BASE_URL=/api
API_PROXY_TARGET=http://backend:8000 API_PROXY_TARGET=http://backend:8000

View File

@@ -11,6 +11,7 @@ class PermissionSpec:
PERMISSIONS: tuple[PermissionSpec, ...] = ( PERMISSIONS: tuple[PermissionSpec, ...] = (
PermissionSpec("ticket.view", "查看工单", "工单"), PermissionSpec("ticket.view", "查看工单", "工单"),
PermissionSpec("ticket.create", "创建工单", "工单"), PermissionSpec("ticket.create", "创建工单", "工单"),
PermissionSpec("ticket.ai_draft", "使用 AI 建单草稿", "工单"),
PermissionSpec("ticket.edit", "编辑工单", "工单"), PermissionSpec("ticket.edit", "编辑工单", "工单"),
PermissionSpec("ticket.export", "导出工单", "工单"), PermissionSpec("ticket.export", "导出工单", "工单"),
PermissionSpec("ticket.assign", "分派工单", "工单"), PermissionSpec("ticket.assign", "分派工单", "工单"),
@@ -50,6 +51,7 @@ DEFAULT_GROUPS: dict[str, tuple[str, ...]] = {
"技术支持": ( "技术支持": (
"ticket.view", "ticket.view",
"ticket.create", "ticket.create",
"ticket.ai_draft",
"ticket.edit", "ticket.edit",
"ticket.assign", "ticket.assign",
"ticket.close", "ticket.close",
@@ -60,6 +62,7 @@ DEFAULT_GROUPS: dict[str, tuple[str, ...]] = {
"普通建单人": ( "普通建单人": (
"ticket.view", "ticket.view",
"ticket.create", "ticket.create",
"ticket.ai_draft",
"knowledge.view", "knowledge.view",
"solution.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): class AgentRunStep(models.Model):
run = models.ForeignKey(AgentRun, on_delete=models.CASCADE, related_name="steps") run = models.ForeignKey(AgentRun, on_delete=models.CASCADE, related_name="steps")
code = models.CharField(max_length=80) code = models.CharField(max_length=80)

View File

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

View File

@@ -128,6 +128,37 @@ def test_cancellation_after_model_response_stops_following_steps(db, monkeypatch
assert run.final_output is None 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): def test_soft_time_limit_marks_run_timed_out_and_audits(db, monkeypatch):
_, version = create_agent() _, version = create_agent()
run = create_run(version) run = create_run(version)

View File

@@ -1,4 +1,5 @@
from django.db import IntegrityError from django.db import IntegrityError
from django.utils import timezone
from ninja import Router from ninja import Router
from ninja.errors import HttpError from ninja.errors import HttpError
@@ -31,6 +32,14 @@ def serialize_provider(provider: ModelProvider) -> dict:
"default_timeout_seconds": provider.default_timeout_seconds, "default_timeout_seconds": provider.default_timeout_seconds,
"credential_set": bool(provider.api_key_encrypted), "credential_set": bool(provider.api_key_encrypted),
"credential_masked": mask_secret(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, "created_at": provider.created_at,
"updated_at": provider.updated_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]) @router.get("/providers", response=list[ModelProviderOut])
def list_providers(request): def list_providers(request):
require_permission(request, "model.manage") 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) @router.post("/providers", response=ModelProviderOut)
@@ -81,6 +93,9 @@ def create_provider(request, payload: ModelProviderIn):
api_key_encrypted=encrypted, api_key_encrypted=encrypted,
enabled=payload.enabled, enabled=payload.enabled,
default_timeout_seconds=payload.default_timeout_seconds, 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, created_by=request.user,
) )
except CredentialEncryptionError as error: except CredentialEncryptionError as error:
@@ -97,6 +112,7 @@ def update_provider(request, provider_id: int, payload: ModelProviderUpdateIn):
provider = ModelProvider.objects.get(id=provider_id) provider = ModelProvider.objects.get(id=provider_id)
values = payload.dict(exclude_unset=True) values = payload.dict(exclude_unset=True)
api_key = values.pop("api_key", None) api_key = values.pop("api_key", None)
image_egress_approved = values.pop("image_egress_approved", None)
if api_key: if api_key:
try: try:
provider.api_key_encrypted = encrypt_secret(api_key) 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") raise HttpError(400, "Timeout must be between 1 and 300 seconds")
for field, value in values.items(): for field, value in values.items():
setattr(provider, field, value.rstrip("/") if field == "base_url" else value) 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() provider.save()
audit( audit(
request.user, request.user,
"update", "update",
"model_provider", "model_provider",
str(provider.id), 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) return serialize_provider(provider)
@@ -133,6 +158,8 @@ def create_deployment(request, payload: ModelDeploymentIn):
raise HttpError(400, "Temperature must be between 0 and 2") raise HttpError(400, "Temperature must be between 0 and 2")
if not 1 <= payload.max_output_tokens <= 32768: if not 1 <= payload.max_output_tokens <= 32768:
raise HttpError(400, "Max output tokens are out of range") 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.create(**payload.dict())
deployment = ModelDeployment.objects.select_related("provider").get(id=deployment.id) deployment = ModelDeployment.objects.select_related("provider").get(id=deployment.id)
audit( audit(
@@ -150,6 +177,8 @@ def update_deployment(request, deployment_id: int, payload: ModelDeploymentUpdat
require_permission(request, "model.manage") require_permission(request, "model.manage")
deployment = ModelDeployment.objects.select_related("provider").get(id=deployment_id) deployment = ModelDeployment.objects.select_related("provider").get(id=deployment_id)
values = payload.dict(exclude_unset=True) 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(): for field, value in values.items():
setattr(deployment, field, value) setattr(deployment, field, value)
deployment.save() 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) api_key_encrypted = models.TextField(blank=True)
enabled = models.BooleanField(default=True) enabled = models.BooleanField(default=True)
default_timeout_seconds = models.PositiveIntegerField(default=60) 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( created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
null=True, null=True,

View File

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

View File

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

View File

@@ -24,7 +24,13 @@ from apps.tickets.schemas import (
TicketStatusIn, TicketStatusIn,
TicketUpdateIn, 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"]) router = Router(tags=["tickets"])
@@ -101,37 +107,10 @@ def serialize_ticket_event(event: TicketEvent) -> dict:
@router.post("", response=TicketOut) @router.post("", response=TicketOut)
def create_ticket(request, payload: TicketIn): def create_ticket(request, payload: TicketIn):
require_permission(request, "ticket.create") require_permission(request, "ticket.create")
assignee = None try:
if payload.assignee_id: ticket = create_ticket_from_fields(payload.dict(), request.user)
assignee = get_user_model().objects.filter(id=payload.assignee_id).first() except TicketDataError as error:
category = None raise HttpError(400, str(error)) from error
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,
)
return serialize_ticket(ticket) 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.conf import settings
from django.db import models 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): class TicketStatus(models.TextChoices):
@@ -132,6 +140,91 @@ class TicketAttachment(models.Model):
created_at = models.DateTimeField(auto_now_add=True) 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): class TicketEvent(models.Model):
ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="events") ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="events")
event_type = models.CharField(max_length=80) 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 django.utils import timezone
from apps.audit.models import OperationAuditLog 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): class TicketStatusError(ValueError):
pass 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]] = { STANDARD_TRANSITIONS: dict[str, set[str]] = {
"pending_info": {"pending", "closed"}, "pending_info": {"pending", "closed"},
"pending": {"in_progress", "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_CREDENTIAL_ENCRYPTION_KEY = os.getenv("AI_CREDENTIAL_ENCRYPTION_KEY", "")
AI_RUN_RETENTION_DAYS = int(os.getenv("AI_RUN_RETENTION_DAYS", "90")) 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 = { CELERY_BEAT_SCHEDULE = {
"cleanup-expired-agent-runs": { "cleanup-expired-agent-runs": {
"task": "apps.agents.tasks.cleanup_expired_agent_runs", "task": "apps.agents.tasks.cleanup_expired_agent_runs",
"schedule": 86400.0, "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.solutions.api import router as solutions_router
from apps.system.api import router as system_router from apps.system.api import router as system_router
from apps.tickets.api import router as tickets_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 = NinjaAPI(title="AI Ticket Platform API", version="0.1.0")
api.add_router("/accounts", accounts_router) api.add_router("/accounts", accounts_router)
api.add_router("/system", system_router) api.add_router("/system", system_router)
api.add_router("/audit", audit_router) api.add_router("/audit", audit_router)
api.add_router("/tickets", tickets_router) api.add_router("/tickets", tickets_router)
api.add_router("/ticket-drafts", ticket_drafts_router)
api.add_router("/solutions", solutions_router) api.add_router("/solutions", solutions_router)
api.add_router("/knowledge", knowledge_router) api.add_router("/knowledge", knowledge_router)
api.add_router("/modelhub", modelhub_router) api.add_router("/modelhub", modelhub_router)

View File

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