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,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",