feat: complete multimodal ticket intake

This commit is contained in:
hailin
2026-07-27 18:59:28 +08:00
parent 96452934a3
commit 93065c0a00
39 changed files with 6443 additions and 267 deletions

View File

@@ -1,10 +1,15 @@
import json
import time
from celery.result import AsyncResult
from django.conf import settings
from django.db import IntegrityError, transaction
from django.http import StreamingHttpResponse
from django.utils import timezone
from ninja import Router
from ninja.errors import HttpError
from apps.accounts.api import require_permission
from apps.accounts.api import require_login, require_permission
from apps.agents.models import (
AgentDefinition,
AgentRun,
@@ -24,20 +29,21 @@ from apps.agents.schemas import (
)
from apps.agents.services import (
AgentValidationError,
build_config_snapshot,
append_run_event,
ensure_agent_draft,
queue_agent_run,
replace_agent_steps,
validate_agent_version,
)
from apps.agents.tasks import execute_agent_run
from apps.audit.models import OperationAuditLog
from apps.skills.services import SkillValidationError, validate_payload
from apps.skills.services import SkillValidationError
router = Router(tags=["agents"])
runs_router = Router(tags=["agent-runs"])
def serialize_step(step: AgentStepBinding) -> dict:
deployment = step.model_deployment or step.agent_version.model_deployment
return {
"id": step.id,
"code": step.code,
@@ -46,6 +52,9 @@ def serialize_step(step: AgentStepBinding) -> dict:
"skill_name": step.skill_version.definition.name,
"skill_version": step.skill_version.version,
"skill_status": step.skill_version.status,
"model_deployment_id": step.model_deployment_id,
"model_display_name": deployment.display_name,
"model_capability": deployment.capability,
"input_mapping": step.input_mapping,
}
@@ -60,6 +69,7 @@ def serialize_version(version: AgentVersion) -> dict:
"system_prompt": version.system_prompt,
"input_schema": version.input_schema,
"example_input": version.example_input,
"allowed_actions": version.allowed_actions,
"temperature_override": version.temperature_override,
"max_output_tokens_override": version.max_output_tokens_override,
"steps": [serialize_step(step) for step in version.steps.all()],
@@ -96,6 +106,9 @@ def serialize_run(run: AgentRun, detailed: bool = False) -> dict:
"status": run.status,
"sandbox": run.sandbox,
"current_step": run.current_step,
"progress_stage": run.progress_stage,
"progress_message": run.progress_message,
"retry_count": run.retry_count,
"total_steps": run.total_steps,
"input_tokens": run.input_tokens,
"output_tokens": run.output_tokens,
@@ -119,6 +132,17 @@ def serialize_run(run: AgentRun, detailed: bool = False) -> dict:
"code": step.code,
"name": step.name,
"order": step.order,
"model_deployment_id": step.model_deployment_id,
"model_display_name": (
step.model_deployment.display_name
if step.model_deployment_id
else ""
),
"model_capability": (
step.model_deployment.capability
if step.model_deployment_id
else ""
),
"status": step.status,
"input_payload": step.input_payload,
"output_payload": step.output_payload,
@@ -136,6 +160,48 @@ def serialize_run(run: AgentRun, detailed: bool = False) -> dict:
return payload
def get_run_for_stream(request, public_id: str) -> AgentRun:
user = require_login(request)
run = AgentRun.objects.select_related("definition", "agent_version").filter(
public_id=public_id
).first()
if run is None:
raise HttpError(404, "Agent 运行不存在")
if (
run.created_by_id == user.id
or user.is_superuser
or user.has_perm("system.agent.run.view")
):
return run
raise HttpError(403, "Permission denied")
def parse_event_cursor(request, after: int | None) -> int:
header_value = request.headers.get("Last-Event-ID", "").strip()
try:
header_cursor = int(header_value) if header_value else 0
except ValueError as error:
raise HttpError(400, "Last-Event-ID 必须是非负整数") from error
query_cursor = after or 0
if header_cursor < 0 or query_cursor < 0:
raise HttpError(400, "事件游标必须是非负整数")
return max(header_cursor, query_cursor)
def format_sse_event(event) -> str:
data = json.dumps(
{
"sequence": event.sequence,
"event_type": event.event_type,
"payload": event.payload,
"created_at": event.created_at.isoformat(),
},
ensure_ascii=False,
separators=(",", ":"),
)
return f"id: {event.sequence}\nevent: {event.event_type}\ndata: {data}\n\n"
def audit(user, action: str, target_type: str, target_id: str, snapshot: dict) -> None:
OperationAuditLog.objects.create(
user=user,
@@ -173,6 +239,7 @@ def create_agent(request, payload: AgentCreateIn):
system_prompt=payload.system_prompt,
input_schema=payload.input_schema,
example_input=payload.example_input,
allowed_actions=payload.allowed_actions,
temperature_override=payload.temperature_override,
max_output_tokens_override=payload.max_output_tokens_override,
created_by=request.user,
@@ -315,24 +382,14 @@ def start_agent_run(request, agent_id: int, payload: AgentRunIn):
)
sandbox = False
try:
validate_agent_version(version, publishing=not sandbox)
validate_payload(version.input_schema, payload.input, "Agent 输入")
run = queue_agent_run(
version,
payload.input,
created_by=request.user,
sandbox=sandbox,
)
except (AgentValidationError, SkillValidationError) as error:
raise HttpError(400, str(error)) from error
run = AgentRun.objects.create(
definition=definition,
agent_version=version,
sandbox=sandbox,
input_payload=payload.input,
config_snapshot=build_config_snapshot(version),
total_steps=version.steps.count(),
created_by=request.user,
)
result = execute_agent_run.delay(run.id)
run.refresh_from_db()
if not run.task_id:
run.task_id = result.id or ""
run.save(update_fields=["task_id"])
audit(
request.user,
"run_started",
@@ -356,11 +413,47 @@ def list_agent_runs(request, status: str | None = None, agent_id: int | None = N
@runs_router.get("/{public_id}", response=AgentRunDetailOut)
def get_agent_run(request, public_id: str):
require_permission(request, "agent.run.view")
run = AgentRun.objects.select_related("definition", "agent_version").get(public_id=public_id)
run = get_run_for_stream(request, public_id)
return serialize_run(run, detailed=True)
@runs_router.get("/{public_id}/events")
def stream_agent_run_events(request, public_id: str, after: int | None = None):
run = get_run_for_stream(request, public_id)
cursor = parse_event_cursor(request, after)
terminal_events = {"completed", "failed", "cancelled", "timed_out", "needs_input"}
max_seconds = getattr(settings, "AGENT_RUN_SSE_MAX_SECONDS", 60)
def event_stream():
current = cursor
started = time.monotonic()
last_heartbeat = started
while time.monotonic() - started < max_seconds:
events = list(run.events.filter(sequence__gt=current).order_by("sequence")[:100])
for event in events:
current = event.sequence
yield format_sse_event(event)
if events and events[-1].event_type in terminal_events:
return
latest = run.events.order_by("-sequence").first()
if (
latest is not None
and latest.sequence <= current
and latest.event_type in terminal_events
):
return
now = time.monotonic()
if now - last_heartbeat >= 15:
yield ": keep-alive\n\n"
last_heartbeat = now
time.sleep(0.5)
response = StreamingHttpResponse(event_stream(), content_type="text/event-stream")
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no"
return response
@runs_router.post("/{public_id}/cancel", response=AgentRunDetailOut)
def cancel_agent_run(request, public_id: str):
require_permission(request, "agent.use")
@@ -371,9 +464,13 @@ def cancel_agent_run(request, public_id: str):
update_fields = ["cancellation_requested_at"]
if run.status == "queued":
run.status = "cancelled"
run.progress_stage = "cancelled"
run.progress_message = "运行已取消"
run.finished_at = timezone.now()
update_fields.extend(["status", "finished_at"])
update_fields.extend(["status", "progress_stage", "progress_message", "finished_at"])
run.save(update_fields=update_fields)
if run.status == "cancelled":
append_run_event(run, "cancelled", {"message": "运行已取消"})
if run.task_id:
AsyncResult(run.task_id).revoke(terminate=False)
audit(request.user, "cancel", "agent_run", str(run.public_id), {"status": run.status})