feat: complete multimodal ticket intake
This commit is contained in:
@@ -4,16 +4,17 @@ from apps.modelhub.models import ModelDeployment, ModelProvider
|
||||
from apps.skills.models import SkillDefinition, SkillVersion
|
||||
|
||||
|
||||
def create_deployment():
|
||||
provider = ModelProvider.objects.create(
|
||||
name="模型渠道",
|
||||
def create_deployment(*, capability="text_chat", provider=None, suffix=""):
|
||||
provider = provider or ModelProvider.objects.create(
|
||||
name=f"模型渠道{suffix}",
|
||||
base_url="https://models.example.com/v1",
|
||||
api_key_encrypted=encrypt_secret("secret"),
|
||||
)
|
||||
return ModelDeployment.objects.create(
|
||||
provider=provider,
|
||||
display_name="支持模型",
|
||||
model_name="support-model",
|
||||
display_name=f"支持模型{suffix}",
|
||||
model_name=f"support-model{suffix}",
|
||||
capability=capability,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@ def test_admin_publishes_and_runs_agent(client, db, monkeypatch):
|
||||
detail = client.get(f"/api/agent-runs/{public_id}")
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()["config_snapshot"]["agent"]["version"] == 1
|
||||
assert all(
|
||||
step["model_deployment_id"] == version.model_deployment_id
|
||||
for step in detail.json()["steps"]
|
||||
)
|
||||
|
||||
|
||||
def test_publishing_rejects_draft_skill(client, db):
|
||||
@@ -53,6 +57,64 @@ def test_publishing_rejects_draft_skill(client, db):
|
||||
assert "已发布 Skill" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_draft_steps_can_override_model_and_clone_binding(client, db):
|
||||
login_admin(client)
|
||||
definition, version = create_agent()
|
||||
override = create_deployment(
|
||||
capability="vision_chat",
|
||||
provider=version.model_deployment.provider,
|
||||
suffix="-vision",
|
||||
)
|
||||
steps = [
|
||||
{
|
||||
"code": step.code,
|
||||
"skill_version_id": step.skill_version_id,
|
||||
"model_deployment_id": override.id if index == 0 else None,
|
||||
"input_mapping": step.input_mapping,
|
||||
}
|
||||
for index, step in enumerate(version.steps.all())
|
||||
]
|
||||
|
||||
response = client.patch(
|
||||
f"/api/agents/{definition.id}/draft",
|
||||
data={"steps": steps},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
response_steps = response.json()["steps"]
|
||||
assert response_steps[0]["model_deployment_id"] == override.id
|
||||
assert response_steps[0]["model_display_name"] == override.display_name
|
||||
assert response_steps[0]["model_capability"] == "vision_chat"
|
||||
assert response_steps[1]["model_deployment_id"] is None
|
||||
assert response_steps[1]["model_display_name"] == version.model_deployment.display_name
|
||||
|
||||
assert client.post(f"/api/agents/{definition.id}/publish").status_code == 200
|
||||
clone = client.post(f"/api/agents/{definition.id}/draft")
|
||||
|
||||
assert clone.status_code == 200
|
||||
assert clone.json()["steps"][0]["model_deployment_id"] == override.id
|
||||
assert clone.json()["steps"][1]["model_deployment_id"] is None
|
||||
|
||||
|
||||
def test_publishing_rejects_non_chat_step_model(client, db):
|
||||
login_admin(client)
|
||||
definition, version = create_agent()
|
||||
embedding = create_deployment(
|
||||
capability="embedding",
|
||||
provider=version.model_deployment.provider,
|
||||
suffix="-embedding",
|
||||
)
|
||||
binding = version.steps.first()
|
||||
binding.model_deployment = embedding
|
||||
binding.save(update_fields=["model_deployment"])
|
||||
|
||||
response = client.post(f"/api/agents/{definition.id}/publish")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "必须绑定文本或视觉模型" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_agent_run_permission_is_required(client, db):
|
||||
definition, _ = create_agent(published=True)
|
||||
user = get_user_model().objects.create_user(username="requester", password="password")
|
||||
@@ -67,6 +129,29 @@ def test_agent_run_permission_is_required(client, db):
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_agent_run_creator_can_view_own_detail_without_global_run_permission(client, db):
|
||||
definition, version = create_agent(published=True)
|
||||
owner = get_user_model().objects.create_user(username="run-owner")
|
||||
outsider = get_user_model().objects.create_user(username="run-outsider")
|
||||
run = AgentRun.objects.create(
|
||||
definition=definition,
|
||||
agent_version=version,
|
||||
input_payload={"problem": "无法登录"},
|
||||
config_snapshot=build_config_snapshot(version),
|
||||
created_by=owner,
|
||||
total_steps=version.steps.count(),
|
||||
)
|
||||
|
||||
client.force_login(owner)
|
||||
owner_response = client.get(f"/api/agent-runs/{run.public_id}")
|
||||
|
||||
client.force_login(outsider)
|
||||
outsider_response = client.get(f"/api/agent-runs/{run.public_id}")
|
||||
|
||||
assert owner_response.status_code == 200
|
||||
assert outsider_response.status_code == 403
|
||||
|
||||
|
||||
def test_queued_run_can_be_cancelled(client, db, monkeypatch):
|
||||
login_admin(client)
|
||||
definition, version = create_agent(published=True)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
|
||||
from apps.agents.models import AgentDefinition, AgentVersion
|
||||
from apps.agents.services import validate_agent_version
|
||||
from apps.skills.models import SkillDefinition, SkillVersion
|
||||
|
||||
from .helpers import create_deployment
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_command_publishes_new_trusted_version_and_preserves_legacy():
|
||||
deployment = create_deployment()
|
||||
draft_skill = SkillDefinition.objects.create(code="ticket-draft", name="旧建单")
|
||||
legacy_skill_version = SkillVersion.objects.create(
|
||||
definition=draft_skill,
|
||||
version=1,
|
||||
status="published",
|
||||
prompt_template="旧提示词",
|
||||
input_schema={"type": "object"},
|
||||
output_schema={"type": "object"},
|
||||
)
|
||||
draft_skill.active_version = legacy_skill_version
|
||||
draft_skill.save(update_fields=["active_version"])
|
||||
agent = AgentDefinition.objects.create(code="ticket-intake", name="旧建单 Agent")
|
||||
legacy_agent_version = AgentVersion.objects.create(
|
||||
definition=agent,
|
||||
version=1,
|
||||
status="published",
|
||||
model_deployment=deployment,
|
||||
system_prompt="旧系统提示词",
|
||||
input_schema={"type": "object"},
|
||||
)
|
||||
agent.active_version = legacy_agent_version
|
||||
agent.save(update_fields=["active_version"])
|
||||
|
||||
call_command("publish_trusted_ticket_intake", deployment_id=deployment.id)
|
||||
|
||||
agent.refresh_from_db()
|
||||
assert agent.active_version.version == 2
|
||||
assert "trusted-ticket-intake-v5-structured-context" in agent.active_version.system_prompt
|
||||
assert AgentVersion.objects.filter(definition=agent, version=1).exists()
|
||||
assert [step.code for step in agent.active_version.steps.all()] == ["facts", "draft"]
|
||||
assert agent.active_version.temperature_override == 0.1
|
||||
assert "material_texts" in agent.active_version.input_schema["required"]
|
||||
validate_agent_version(agent.active_version, publishing=True)
|
||||
|
||||
draft_skill.refresh_from_db()
|
||||
assert draft_skill.active_version.version == 2
|
||||
assert "知识库中的企业、人员、机构" in draft_skill.active_version.prompt_template
|
||||
assert draft_skill.active_version.output_schema["required"][:2] == [
|
||||
"problem_valid",
|
||||
"invalid_reason",
|
||||
]
|
||||
ticket_fields = draft_skill.active_version.output_schema["properties"]["fields"][
|
||||
"properties"
|
||||
]
|
||||
assert {
|
||||
"product_name",
|
||||
"module_name",
|
||||
"tags",
|
||||
"attempted_actions",
|
||||
"error_information",
|
||||
"customer_request",
|
||||
"current_result",
|
||||
"problem_started_at",
|
||||
"customer_last_message_at",
|
||||
"support_first_response_at",
|
||||
"support_last_reply_at",
|
||||
} <= set(ticket_fields)
|
||||
evidence_item = draft_skill.active_version.output_schema["properties"][
|
||||
"field_evidence"
|
||||
]["properties"]["problem_started_at"]
|
||||
assert {"raw_value", "timezone"} <= set(evidence_item["properties"])
|
||||
timeline_item = draft_skill.active_version.output_schema["properties"][
|
||||
"message_timeline"
|
||||
]["items"]
|
||||
assert {"occurred_at_raw", "timezone"} <= set(timeline_item["properties"])
|
||||
assert SkillVersion.objects.filter(definition=draft_skill, version=1).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_command_is_idempotent_without_force():
|
||||
deployment = create_deployment()
|
||||
|
||||
call_command("publish_trusted_ticket_intake", deployment_id=deployment.id)
|
||||
call_command("publish_trusted_ticket_intake", deployment_id=deployment.id)
|
||||
|
||||
agent = AgentDefinition.objects.get(code="ticket-intake")
|
||||
assert agent.versions.count() == 1
|
||||
assert SkillDefinition.objects.get(code="ticket-fact-extraction").versions.count() == 1
|
||||
assert SkillDefinition.objects.get(code="ticket-draft").versions.count() == 1
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -10,7 +11,7 @@ from apps.agents.tasks import execute_agent_run as execute_agent_run_task
|
||||
from apps.audit.models import OperationAuditLog
|
||||
from apps.modelhub.services import CompletionResult
|
||||
|
||||
from .helpers import create_agent
|
||||
from .helpers import create_agent, create_deployment
|
||||
|
||||
|
||||
def create_run(version):
|
||||
@@ -46,6 +47,8 @@ def test_ordered_runtime_records_steps_usage_and_final_output(db, monkeypatch):
|
||||
assert run.input_tokens == 22
|
||||
assert run.output_tokens == 11
|
||||
assert list(run.steps.values_list("code", flat=True)) == ["structure", "reply"]
|
||||
assert run.progress_stage == "completed"
|
||||
assert run.progress_message == "草稿生成完成"
|
||||
|
||||
|
||||
def test_invalid_structure_is_repaired_once(db, monkeypatch):
|
||||
@@ -74,6 +77,137 @@ def test_invalid_structure_is_repaired_once(db, monkeypatch):
|
||||
]
|
||||
|
||||
|
||||
def test_flattened_fact_output_is_normalized_without_model_repair(db, monkeypatch):
|
||||
_, version = create_agent(two_steps=False)
|
||||
skill_version = version.steps.get().skill_version
|
||||
fact_object = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
},
|
||||
"required": ["title", "description"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
skill_version.output_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"facts": fact_object,
|
||||
"evidence": fact_object,
|
||||
"missing_fields": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["facts", "evidence", "missing_fields"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
skill_version.save(update_fields=["output_schema"])
|
||||
monkeypatch.setattr(
|
||||
"apps.modelhub.services._request_completion",
|
||||
lambda *args, **kwargs: CompletionResult(
|
||||
'{"title":"登录失败","evidence":{"title":"无法登录"},"missing_fields":[]}',
|
||||
10,
|
||||
5,
|
||||
),
|
||||
)
|
||||
run = create_run(version)
|
||||
|
||||
execute_agent_run(run.id)
|
||||
|
||||
run.refresh_from_db()
|
||||
step = run.steps.get()
|
||||
assert run.status == "succeeded"
|
||||
assert step.attempts.count() == 1
|
||||
assert run.final_output == {
|
||||
"facts": {"title": "登录失败", "description": ""},
|
||||
"evidence": {"title": "无法登录", "description": ""},
|
||||
"missing_fields": ["description"],
|
||||
}
|
||||
|
||||
|
||||
def test_nested_fact_output_with_missing_string_is_completed_without_repair(db, monkeypatch):
|
||||
_, version = create_agent(two_steps=False)
|
||||
skill_version = version.steps.get().skill_version
|
||||
fact_object = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"problem_performance": {"type": "string"},
|
||||
},
|
||||
"required": ["title", "problem_performance"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
skill_version.output_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"facts": fact_object,
|
||||
"evidence": fact_object,
|
||||
"missing_fields": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["facts", "evidence", "missing_fields"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
skill_version.save(update_fields=["output_schema"])
|
||||
monkeypatch.setattr(
|
||||
"apps.modelhub.services._request_completion",
|
||||
lambda *args, **kwargs: CompletionResult(
|
||||
'{"facts":{"title":"登录失败"},'
|
||||
'"evidence":{"title":"无法登录"},"missing_fields":[]}',
|
||||
10,
|
||||
5,
|
||||
),
|
||||
)
|
||||
run = create_run(version)
|
||||
|
||||
execute_agent_run(run.id)
|
||||
|
||||
run.refresh_from_db()
|
||||
assert run.status == "succeeded"
|
||||
assert run.steps.get().attempts.count() == 1
|
||||
assert run.final_output["facts"]["problem_performance"] == ""
|
||||
assert run.final_output["evidence"]["problem_performance"] == ""
|
||||
assert run.final_output["missing_fields"] == ["problem_performance"]
|
||||
|
||||
|
||||
def test_flattened_draft_output_accepts_follow_up_questions_without_repair(db, monkeypatch):
|
||||
_, version = create_agent(two_steps=False)
|
||||
skill_version = version.steps.get().skill_version
|
||||
skill_version.output_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fields": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
},
|
||||
"required": ["title", "description"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"missing_fields": {"type": "array", "items": {"type": "string"}},
|
||||
"questions": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["fields", "missing_fields", "questions"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
skill_version.save(update_fields=["output_schema"])
|
||||
monkeypatch.setattr(
|
||||
"apps.modelhub.services._request_completion",
|
||||
lambda *args, **kwargs: CompletionResult(
|
||||
'{"title":"登录失败","description":"",'
|
||||
'"missing_fields":["description"],"follow_up_questions":["请补充现象"]}',
|
||||
10,
|
||||
5,
|
||||
),
|
||||
)
|
||||
run = create_run(version)
|
||||
|
||||
execute_agent_run(run.id)
|
||||
|
||||
run.refresh_from_db()
|
||||
assert run.status == "succeeded"
|
||||
assert run.steps.get().attempts.count() == 1
|
||||
assert run.final_output["questions"] == ["请补充现象"]
|
||||
|
||||
|
||||
def test_second_invalid_structure_fails_and_stops(db, monkeypatch):
|
||||
_, version = create_agent()
|
||||
outputs = iter(
|
||||
@@ -106,6 +240,7 @@ def test_cancellation_before_first_step_stops_run(db):
|
||||
|
||||
run.refresh_from_db()
|
||||
assert run.status == "cancelled"
|
||||
assert run.progress_stage == "cancelled"
|
||||
assert run.steps.count() == 0
|
||||
|
||||
|
||||
@@ -130,6 +265,10 @@ def test_cancellation_after_model_response_stops_following_steps(db, monkeypatch
|
||||
|
||||
def test_multimodal_request_keeps_base64_out_of_database_snapshots(db, monkeypatch):
|
||||
_, version = create_agent(two_steps=False)
|
||||
version.model_deployment.capability = "vision_chat"
|
||||
version.model_deployment.provider.image_egress_approved = True
|
||||
version.model_deployment.provider.save(update_fields=["image_egress_approved"])
|
||||
version.model_deployment.save(update_fields=["capability"])
|
||||
captured = {}
|
||||
|
||||
def request(deployment, messages, **kwargs):
|
||||
@@ -159,6 +298,92 @@ def test_multimodal_request_keeps_base64_out_of_database_snapshots(db, monkeypat
|
||||
assert invocation.input_snapshot["image_refs"][0]["object_path"].endswith("proof.png")
|
||||
|
||||
|
||||
def test_step_model_override_controls_deployment_and_image_egress(db, monkeypatch):
|
||||
_, version = create_agent()
|
||||
vision = create_deployment(
|
||||
capability="vision_chat",
|
||||
provider=version.model_deployment.provider,
|
||||
suffix="-vision",
|
||||
)
|
||||
vision.provider.image_egress_approved = True
|
||||
vision.provider.save(update_fields=["image_egress_approved"])
|
||||
first_binding = version.steps.get(code="structure")
|
||||
first_binding.model_deployment = vision
|
||||
first_binding.save(update_fields=["model_deployment"])
|
||||
captured = []
|
||||
outputs = iter(
|
||||
[
|
||||
CompletionResult('{"summary":"识别到截图"}', 8, 3),
|
||||
CompletionResult('{"reply":"请重置密码"}', 7, 2),
|
||||
]
|
||||
)
|
||||
|
||||
def request(deployment, messages, **kwargs):
|
||||
captured.append((deployment.id, messages))
|
||||
return next(outputs)
|
||||
|
||||
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"},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
run.refresh_from_db()
|
||||
steps = list(run.steps.select_related("model_deployment"))
|
||||
assert run.status == "succeeded"
|
||||
assert [item[0] for item in captured] == [vision.id, version.model_deployment_id]
|
||||
assert isinstance(captured[0][1][-1]["content"], list)
|
||||
assert isinstance(captured[1][1][-1]["content"], str)
|
||||
assert steps[0].model_deployment_id == vision.id
|
||||
assert steps[1].model_deployment_id == version.model_deployment_id
|
||||
assert run.config_snapshot["steps"][0]["model"] == {
|
||||
"deployment_id": vision.id,
|
||||
"inherited": False,
|
||||
"provider_name": vision.provider.name,
|
||||
"model_name": vision.model_name,
|
||||
"capability": "vision_chat",
|
||||
}
|
||||
assert run.config_snapshot["steps"][1]["model"]["inherited"] is True
|
||||
assert run.config_snapshot["steps"][1]["model"]["capability"] == "text_chat"
|
||||
|
||||
|
||||
def test_unapproved_vision_step_rejects_image_egress(db, monkeypatch):
|
||||
_, version = create_agent(two_steps=False)
|
||||
version.model_deployment.capability = "vision_chat"
|
||||
version.model_deployment.save(update_fields=["capability"])
|
||||
monkeypatch.setattr(
|
||||
"apps.modelhub.services._request_completion",
|
||||
lambda *args, **kwargs: pytest.fail("未授权图片不应进入模型调用"),
|
||||
)
|
||||
run = create_run(version)
|
||||
|
||||
execute_agent_run(
|
||||
run.id,
|
||||
image_parts=[
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "data:image/png;base64,c2Vuc2l0aXZl"},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
run.refresh_from_db()
|
||||
assert run.status == "failed"
|
||||
assert "未授权图片出境" in run.error_message
|
||||
assert run.steps.get().attempts.count() == 0
|
||||
|
||||
|
||||
def test_soft_time_limit_marks_run_timed_out_and_audits(db, monkeypatch):
|
||||
_, version = create_agent()
|
||||
run = create_run(version)
|
||||
@@ -172,6 +397,7 @@ def test_soft_time_limit_marks_run_timed_out_and_audits(db, monkeypatch):
|
||||
run.refresh_from_db()
|
||||
assert run.status == "timed_out"
|
||||
assert run.error_code == "run_timeout"
|
||||
assert run.progress_stage == "timed_out"
|
||||
assert OperationAuditLog.objects.filter(
|
||||
target_id=str(run.public_id),
|
||||
action="run_finished",
|
||||
|
||||
Reference in New Issue
Block a user