feat: add trusted ticket intake backend

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

View File

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

View File

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

View File

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

View File

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

View File

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