feat: init commint

This commit is contained in:
2026-09-18 15:26:19 +08:00
parent 3b61a767a0
commit 9f47dbb5cc
95 changed files with 7712 additions and 1137 deletions

View File

@@ -4,7 +4,13 @@ from apps.modelhub.models import ModelDeployment, ModelProvider
from apps.skills.models import SkillDefinition, SkillVersion
def create_deployment(*, capability="text_chat", provider=None, suffix=""):
def create_deployment(
*,
capability="text_chat",
provider=None,
suffix="",
structured_output_mode="native_json_schema",
):
provider = provider or ModelProvider.objects.create(
name=f"模型渠道{suffix}",
base_url="https://models.example.com/v1",
@@ -15,6 +21,8 @@ def create_deployment(*, capability="text_chat", provider=None, suffix=""):
display_name=f"支持模型{suffix}",
model_name=f"support-model{suffix}",
capability=capability,
structured_output_mode=structured_output_mode,
structured_output_test_status="passed",
)
@@ -46,6 +54,7 @@ def create_agent(deployment=None, *, published=False, two_steps=True):
"type": "object",
"properties": {"summary": {"type": "string"}},
"required": ["summary"],
"additionalProperties": False,
},
)
second_skill = None
@@ -56,6 +65,7 @@ def create_agent(deployment=None, *, published=False, two_steps=True):
"type": "object",
"properties": {"reply": {"type": "string"}},
"required": ["reply"],
"additionalProperties": False,
},
)
definition = AgentDefinition.objects.create(code="support-agent", name="支持 Agent")

View File

@@ -1,7 +1,7 @@
from django.contrib.auth import get_user_model
from django.core.management import call_command
from apps.agents.models import AgentRun
from apps.agents.models import AgentDefinition, AgentRun
from apps.agents.services import build_config_snapshot
from apps.modelhub.services import CompletionResult
@@ -18,9 +18,15 @@ def login_admin(client):
def test_admin_publishes_and_runs_agent(client, db, monkeypatch):
login_admin(client)
definition, version = create_agent()
outputs = iter(
[
CompletionResult('{"summary":"ok"}', 3, 2),
CompletionResult('{"reply":"ok"}', 3, 2),
]
)
monkeypatch.setattr(
"apps.modelhub.services._request_completion",
lambda *args, **kwargs: CompletionResult('{"summary":"ok","reply":"ok"}', 3, 2),
lambda *args, **kwargs: next(outputs),
)
published = client.post(f"/api/agents/{definition.id}/publish")
@@ -57,6 +63,18 @@ def test_publishing_rejects_draft_skill(client, db):
assert "已发布 Skill" in response.json()["detail"]
def test_publish_returns_structured_error_code_for_unverified_model(client, db):
login_admin(client)
definition, version = create_agent()
version.model_deployment.structured_output_test_status = "untested"
version.model_deployment.save(update_fields=["structured_output_test_status"])
response = client.post(f"/api/agents/{definition.id}/publish")
assert response.status_code == 400
assert response.json()["detail"].startswith("structured_output_not_verified:")
def test_draft_steps_can_override_model_and_clone_binding(client, db):
login_admin(client)
definition, version = create_agent()
@@ -152,6 +170,48 @@ def test_agent_run_creator_can_view_own_detail_without_global_run_permission(cli
assert outsider_response.status_code == 403
def test_agent_run_list_is_paginated_after_filtering(client, db):
login_admin(client)
definition, version = create_agent(published=True)
runs = [
AgentRun.objects.create(
definition=definition,
agent_version=version,
status="failed" if index < 2 else "queued",
input_payload={"sequence": index},
)
for index in range(25)
]
response = client.get("/api/agent-runs?status=queued&page=2&page_size=10")
assert response.status_code == 200
payload = response.json()
assert payload["page"] == 2
assert payload["page_size"] == 10
assert payload["total"] == 23
assert payload["total_pages"] == 3
assert len(payload["items"]) == 10
assert all(item["status"] == "queued" for item in payload["items"])
assert payload["items"][0]["public_id"] == str(runs[14].public_id)
def test_agent_run_list_clamps_invalid_pagination_values(client, db):
login_admin(client)
definition, version = create_agent(published=True)
AgentRun.objects.create(
definition=definition,
agent_version=version,
input_payload={"problem": "无法登录"},
)
response = client.get("/api/agent-runs?page=0&page_size=500")
assert response.status_code == 200
assert response.json()["page"] == 1
assert response.json()["page_size"] == 100
def test_queued_run_can_be_cancelled(client, db, monkeypatch):
login_admin(client)
definition, version = create_agent(published=True)
@@ -180,10 +240,15 @@ def test_queued_run_can_be_cancelled(client, db, monkeypatch):
def test_seed_command_creates_support_agent(client, db):
deployment = create_deployment()
business_agent = AgentDefinition.objects.create(
code="support-analysis",
name="支持分析 Agent",
)
call_command("seed_ai_demo", deployment_id=deployment.id)
from apps.agents.models import AgentDefinition
agent = AgentDefinition.objects.get(code="support-analysis")
assert agent.versions.get(status="draft").steps.count() == 3
demo_agent = AgentDefinition.objects.get(code="support-analysis-demo")
assert demo_agent.versions.get(status="draft").steps.count() == 3
business_agent.refresh_from_db()
assert business_agent.versions.count() == 0
assert business_agent.active_version is None

View File

@@ -1,5 +1,5 @@
import pytest
from django.core.management import call_command
from django.core.management import CommandError, call_command
from apps.agents.models import AgentDefinition, AgentVersion
from apps.agents.services import validate_agent_version
@@ -8,9 +8,20 @@ from apps.skills.models import SkillDefinition, SkillVersion
from .helpers import create_deployment
@pytest.mark.django_db
def test_command_rejects_model_below_native_guarantee_without_partial_publish():
deployment = create_deployment(structured_output_mode="prompt_schema")
with pytest.raises(CommandError, match="structured_output_incompatible"):
call_command("publish_trusted_ticket_intake", deployment_id=deployment.id)
assert not AgentDefinition.objects.filter(code="ticket-intake").exists()
assert not SkillDefinition.objects.filter(code="ticket-fact-extraction").exists()
@pytest.mark.django_db
def test_command_publishes_new_trusted_version_and_preserves_legacy():
deployment = create_deployment()
deployment = create_deployment(structured_output_mode="native_json_schema")
draft_skill = SkillDefinition.objects.create(code="ticket-draft", name="旧建单")
legacy_skill_version = SkillVersion.objects.create(
definition=draft_skill,
@@ -38,7 +49,7 @@ def test_command_publishes_new_trusted_version_and_preserves_legacy():
agent.refresh_from_db()
assert agent.active_version.version == 2
assert "trusted-ticket-intake-v5-structured-context" in agent.active_version.system_prompt
assert "trusted-ticket-intake-v6-native-schema-subset" 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
@@ -76,12 +87,15 @@ def test_command_publishes_new_trusted_version_and_preserves_legacy():
"message_timeline"
]["items"]
assert {"occurred_at_raw", "timezone"} <= set(timeline_item["properties"])
assert "uniqueItems" not in draft_skill.active_version.output_schema["properties"][
"missing_fields"
]
assert SkillVersion.objects.filter(definition=draft_skill, version=1).exists()
@pytest.mark.django_db
def test_command_is_idempotent_without_force():
deployment = create_deployment()
deployment = create_deployment(structured_output_mode="native_json_schema")
call_command("publish_trusted_ticket_intake", deployment_id=deployment.id)
call_command("publish_trusted_ticket_intake", deployment_id=deployment.id)

View File

@@ -6,6 +6,7 @@ from django.utils import timezone
from apps.agents.models import AgentRun
from apps.agents.services import build_config_snapshot, execute_agent_run
from apps.agents.structured_metrics import build_structured_output_metrics
from apps.agents.tasks import cleanup_expired_agent_runs
from apps.agents.tasks import execute_agent_run as execute_agent_run_task
from apps.audit.models import OperationAuditLog
@@ -47,6 +48,19 @@ 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.steps.count() == 2
assert sum(step.attempts.count() for step in run.steps.all()) == 2
first_invocation = run.steps.first().attempts.get().model_invocation
assert first_invocation.model_snapshot["structured_output"]["mode"] == "native_json_schema"
assert first_invocation.model_snapshot["structured_output"]["schema_hash"]
assert first_invocation.input_snapshot["output_schema"]
metrics = build_structured_output_metrics(
deployment_id=version.model_deployment_id,
)
assert metrics["initial_count"] == 2
assert metrics["first_pass_rate"] == 1
assert metrics["repair_count"] == 0
assert metrics["invocation_count"] == 2
assert run.progress_stage == "completed"
assert run.progress_message == "草稿生成完成"
@@ -75,9 +89,14 @@ def test_invalid_structure_is_repaired_once(db, monkeypatch):
"initial",
"structure_repair",
]
metrics = build_structured_output_metrics(skill_version_id=step.skill_version_id)
assert metrics["initial_count"] == 1
assert metrics["first_pass_rate"] == 0
assert metrics["repair_count"] == 1
assert metrics["repair_rate"] == 1
def test_flattened_fact_output_is_normalized_without_model_repair(db, monkeypatch):
def test_flattened_fact_output_never_passes_as_final_result(db, monkeypatch):
_, version = create_agent(two_steps=False)
skill_version = version.steps.get().skill_version
fact_object = {
@@ -114,16 +133,13 @@ def test_flattened_fact_output_is_normalized_without_model_repair(db, monkeypatc
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"],
}
assert run.status == "failed"
assert run.error_code == "structured_output_invalid"
assert step.attempts.count() == 2
assert run.final_output is None
def test_nested_fact_output_with_missing_string_is_completed_without_repair(db, monkeypatch):
def test_missing_nested_fact_field_never_passes_as_final_result(db, monkeypatch):
_, version = create_agent(two_steps=False)
skill_version = version.steps.get().skill_version
fact_object = {
@@ -160,14 +176,13 @@ def test_nested_fact_output_with_missing_string_is_completed_without_repair(db,
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"]
assert run.status == "failed"
assert run.error_code == "structured_output_invalid"
assert run.steps.get().attempts.count() == 2
assert run.final_output is None
def test_flattened_draft_output_accepts_follow_up_questions_without_repair(db, monkeypatch):
def test_old_flattened_draft_fields_never_pass_as_final_result(db, monkeypatch):
_, version = create_agent(two_steps=False)
skill_version = version.steps.get().skill_version
skill_version.output_schema = {
@@ -203,9 +218,70 @@ def test_flattened_draft_output_accepts_follow_up_questions_without_repair(db, m
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"] == ["请补充现象"]
assert run.status == "failed"
assert run.error_code == "structured_output_invalid"
assert run.steps.get().attempts.count() == 2
assert run.final_output is None
def test_wrong_evidence_properties_never_pass_as_final_result(db, monkeypatch):
_, version = create_agent(two_steps=False)
skill_version = version.steps.get().skill_version
evidence_schema = {
"type": "object",
"properties": {"title": {"type": "string"}},
"required": ["title"],
"additionalProperties": False,
}
skill_version.output_schema = {
"type": "object",
"properties": {
"facts": evidence_schema,
"evidence": evidence_schema,
"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":{"source":"旧证据字段"},"missing_fields":[]}',
10,
5,
),
)
run = create_run(version)
execute_agent_run(run.id)
run.refresh_from_db()
assert run.status == "failed"
assert run.error_code == "structured_output_invalid"
assert run.steps.get().attempts.count() == 2
assert run.final_output is None
def test_unverified_model_fails_before_any_model_call(db, monkeypatch):
_, version = create_agent(two_steps=False)
version.model_deployment.structured_output_test_status = "untested"
version.model_deployment.save(update_fields=["structured_output_test_status"])
monkeypatch.setattr(
"apps.modelhub.services._request_completion",
lambda *args, **kwargs: pytest.fail("未验证模型不应被调用"),
)
run = create_run(version)
execute_agent_run(run.id)
run.refresh_from_db()
step = run.steps.get()
assert run.status == "failed"
assert run.error_code == "structured_output_not_verified"
assert step.error_code == "structured_output_not_verified"
assert step.attempts.count() == 0
def test_second_invalid_structure_fails_and_stops(db, monkeypatch):
@@ -226,8 +302,13 @@ def test_second_invalid_structure_fails_and_stops(db, monkeypatch):
run.refresh_from_db()
assert run.status == "failed"
assert run.error_code == "structured_output_invalid"
assert run.final_output is None
assert run.steps.count() == 1
assert run.steps.get().status == "failed"
step = run.steps.get()
assert step.status == "failed"
assert step.attempts.count() == 2
assert step.attempts.get(attempt_type="structure_repair").validation_error
def test_cancellation_before_first_step_stops_run(db):
@@ -350,10 +431,12 @@ def test_step_model_override_controls_deployment_and_image_egress(db, monkeypatc
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",
}
"provider_name": vision.provider.name,
"model_name": vision.model_name,
"capability": "vision_chat",
"structured_output_mode": "native_json_schema",
"structured_output_test_status": "passed",
}
assert run.config_snapshot["steps"][1]["model"]["inherited"] is True
assert run.config_snapshot["steps"][1]["model"]["capability"] == "text_chat"