feat: add trusted ticket intake backend
This commit is contained in:
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)
|
||||
|
||||
Reference in New Issue
Block a user