feat: add trusted ticket intake backend
This commit is contained in:
@@ -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",
|
||||
),
|
||||
|
||||
159
backend/apps/agents/management/commands/seed_ticket_intake.py
Normal file
159
backend/apps/agents/management/commands/seed_ticket_intake.py
Normal 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}."
|
||||
)
|
||||
)
|
||||
62
backend/apps/agents/migrations/0002_agentartifact.py
Normal file
62
backend/apps/agents/migrations/0002_agentartifact.py
Normal 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",
|
||||
)
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
244
backend/apps/tickets/draft_api.py
Normal file
244
backend/apps/tickets/draft_api.py
Normal 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)
|
||||
66
backend/apps/tickets/draft_schemas.py
Normal file
66
backend/apps/tickets/draft_schemas.py
Normal 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
|
||||
380
backend/apps/tickets/draft_services.py
Normal file
380
backend/apps/tickets/draft_services.py
Normal 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"])
|
||||
0
backend/apps/tickets/management/__init__.py
Normal file
0
backend/apps/tickets/management/__init__.py
Normal 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}"
|
||||
)
|
||||
)
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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"},
|
||||
|
||||
112
backend/apps/tickets/storage.py
Normal file
112
backend/apps/tickets/storage.py
Normal 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),
|
||||
)
|
||||
55
backend/apps/tickets/tasks.py
Normal file
55
backend/apps/tickets/tasks.py
Normal 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
|
||||
277
backend/apps/tickets/tests/test_ticket_drafts.py
Normal file
277
backend/apps/tickets/tests/test_ticket_drafts.py
Normal 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)
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user