diff --git a/.env.example b/.env.example index 7380dde..aaa27d1 100644 --- a/.env.example +++ b/.env.example @@ -17,9 +17,15 @@ CELERY_BROKER_URL=redis://redis:6379/0 CELERY_RESULT_BACKEND=redis://redis:6379/1 AI_CREDENTIAL_ENCRYPTION_KEY= AI_RUN_RETENTION_DAYS=90 +TICKET_DRAFT_RETENTION_HOURS=24 MINIO_ROOT_USER=minio 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 API_PROXY_TARGET=http://backend:8000 diff --git a/backend/apps/accounts/permissions.py b/backend/apps/accounts/permissions.py index e5e8c52..b23326a 100644 --- a/backend/apps/accounts/permissions.py +++ b/backend/apps/accounts/permissions.py @@ -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", ), diff --git a/backend/apps/agents/management/commands/seed_ticket_intake.py b/backend/apps/agents/management/commands/seed_ticket_intake.py new file mode 100644 index 0000000..a6b16f8 --- /dev/null +++ b/backend/apps/agents/management/commands/seed_ticket_intake.py @@ -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}." + ) + ) diff --git a/backend/apps/agents/migrations/0002_agentartifact.py b/backend/apps/agents/migrations/0002_agentartifact.py new file mode 100644 index 0000000..b4bd3b9 --- /dev/null +++ b/backend/apps/agents/migrations/0002_agentartifact.py @@ -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", + ) + ], + }, + ), + ] diff --git a/backend/apps/agents/models.py b/backend/apps/agents/models.py index a356d64..b4f5b29 100644 --- a/backend/apps/agents/models.py +++ b/backend/apps/agents/models.py @@ -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) diff --git a/backend/apps/agents/services.py b/backend/apps/agents/services.py index d6774c9..0866b11 100644 --- a/backend/apps/agents/services.py +++ b/backend/apps/agents/services.py @@ -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: diff --git a/backend/apps/agents/tests/test_runtime.py b/backend/apps/agents/tests/test_runtime.py index e9251d5..75358ff 100644 --- a/backend/apps/agents/tests/test_runtime.py +++ b/backend/apps/agents/tests/test_runtime.py @@ -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) diff --git a/backend/apps/modelhub/api.py b/backend/apps/modelhub/api.py index c9f63c1..f757fd2 100644 --- a/backend/apps/modelhub/api.py +++ b/backend/apps/modelhub/api.py @@ -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() diff --git a/backend/apps/modelhub/migrations/0002_modelprovider_image_egress_approved_and_more.py b/backend/apps/modelhub/migrations/0002_modelprovider_image_egress_approved_and_more.py new file mode 100644 index 0000000..3bbc4a9 --- /dev/null +++ b/backend/apps/modelhub/migrations/0002_modelprovider_image_egress_approved_and_more.py @@ -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, + ), + ), + ] diff --git a/backend/apps/modelhub/models.py b/backend/apps/modelhub/models.py index 5d7cc6c..417641f 100644 --- a/backend/apps/modelhub/models.py +++ b/backend/apps/modelhub/models.py @@ -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, diff --git a/backend/apps/modelhub/schemas.py b/backend/apps/modelhub/schemas.py index b8b6d2d..f9df432 100644 --- a/backend/apps/modelhub/schemas.py +++ b/backend/apps/modelhub/schemas.py @@ -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 diff --git a/backend/apps/modelhub/services.py b/backend/apps/modelhub/services.py index 4c56de4..93c8f22 100644 --- a/backend/apps/modelhub/services.py +++ b/backend/apps/modelhub/services.py @@ -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, } diff --git a/backend/apps/tickets/api.py b/backend/apps/tickets/api.py index ff28ef6..a29f3da 100644 --- a/backend/apps/tickets/api.py +++ b/backend/apps/tickets/api.py @@ -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) diff --git a/backend/apps/tickets/draft_api.py b/backend/apps/tickets/draft_api.py new file mode 100644 index 0000000..f4e4fc5 --- /dev/null +++ b/backend/apps/tickets/draft_api.py @@ -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) diff --git a/backend/apps/tickets/draft_schemas.py b/backend/apps/tickets/draft_schemas.py new file mode 100644 index 0000000..e7cb796 --- /dev/null +++ b/backend/apps/tickets/draft_schemas.py @@ -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 diff --git a/backend/apps/tickets/draft_services.py b/backend/apps/tickets/draft_services.py new file mode 100644 index 0000000..da3eab0 --- /dev/null +++ b/backend/apps/tickets/draft_services.py @@ -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"]) diff --git a/backend/apps/tickets/management/__init__.py b/backend/apps/tickets/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/tickets/management/commands/__init__.py b/backend/apps/tickets/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/tickets/management/commands/verify_ticket_intake.py b/backend/apps/tickets/management/commands/verify_ticket_intake.py new file mode 100644 index 0000000..0f3d355 --- /dev/null +++ b/backend/apps/tickets/management/commands/verify_ticket_intake.py @@ -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}" + ) + ) diff --git a/backend/apps/tickets/migrations/0004_ticketdraft_ticketdraftimage_ticketdraftmessage_and_more.py b/backend/apps/tickets/migrations/0004_ticketdraft_ticketdraftimage_ticketdraftmessage_and_more.py new file mode 100644 index 0000000..3e95c3b --- /dev/null +++ b/backend/apps/tickets/migrations/0004_ticketdraft_ticketdraftimage_ticketdraftmessage_and_more.py @@ -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" + ), + ), + ] diff --git a/backend/apps/tickets/models.py b/backend/apps/tickets/models.py index 604c281..ebfa9f1 100644 --- a/backend/apps/tickets/models.py +++ b/backend/apps/tickets/models.py @@ -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) diff --git a/backend/apps/tickets/services.py b/backend/apps/tickets/services.py index 89e03a8..be76ef2 100644 --- a/backend/apps/tickets/services.py +++ b/backend/apps/tickets/services.py @@ -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"}, diff --git a/backend/apps/tickets/storage.py b/backend/apps/tickets/storage.py new file mode 100644 index 0000000..e61ee4d --- /dev/null +++ b/backend/apps/tickets/storage.py @@ -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), + ) diff --git a/backend/apps/tickets/tasks.py b/backend/apps/tickets/tasks.py new file mode 100644 index 0000000..7113a92 --- /dev/null +++ b/backend/apps/tickets/tasks.py @@ -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 diff --git a/backend/apps/tickets/tests/test_ticket_drafts.py b/backend/apps/tickets/tests/test_ticket_drafts.py new file mode 100644 index 0000000..6161ae6 --- /dev/null +++ b/backend/apps/tickets/tests/test_ticket_drafts.py @@ -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) diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index e927c24..cddc5c9 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -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, + }, } diff --git a/backend/config/urls.py b/backend/config/urls.py index 7d3b472..af6f329 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -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) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 750a006..4c10f1f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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]