174 lines
5.1 KiB
Python
174 lines
5.1 KiB
Python
"""Governance promotion flow: train → evaluate → request → approve."""
|
|
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from forja_core.api import deps
|
|
from forja_core.main import create_app
|
|
|
|
FIXTURES = Path(__file__).parent.parent / "fixtures"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def patch_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
agents_dir = tmp_path / "agents"
|
|
shutil.copytree(FIXTURES / "agents", agents_dir)
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
monkeypatch.setenv("AGENTS_DIR", str(agents_dir))
|
|
monkeypatch.setenv("POLICIES_DIR", str(FIXTURES / "policies"))
|
|
monkeypatch.setenv("DATASETS_DIR", str(FIXTURES / "datasets"))
|
|
monkeypatch.setenv("MODELS_DIR", str(FIXTURES / "models"))
|
|
monkeypatch.setenv("TRAINING_BACKEND", "mock")
|
|
for fn in (
|
|
deps.get_settings,
|
|
deps.get_registry,
|
|
deps.get_policy_store,
|
|
deps.get_dataset_store,
|
|
deps.get_model_store,
|
|
deps.get_training_backend,
|
|
deps.get_orchestrator,
|
|
deps.get_llm_provider,
|
|
deps.get_guardrail_engine,
|
|
):
|
|
fn.cache_clear()
|
|
|
|
|
|
def test_promotion_flow_approve_activates_draft_version() -> None:
|
|
client = TestClient(create_app())
|
|
|
|
run = client.post(
|
|
"/api/training/runs",
|
|
json={
|
|
"dataset_name": "incident_sft",
|
|
"model_name": "gpt4o_lora_base",
|
|
"agent_name": "test_agent",
|
|
"agent_version": "v2",
|
|
},
|
|
).json()
|
|
run_id = run["id"]
|
|
|
|
# Evaluation must cover the candidate version, not the active one.
|
|
eval_report = client.post(f"/api/training/runs/{run_id}/evaluate").json()
|
|
assert eval_report["passed"] is True
|
|
assert eval_report["agent_version"] == "v2"
|
|
|
|
promo = client.post(
|
|
"/api/promotions",
|
|
json={
|
|
"agent_name": "test_agent",
|
|
"agent_version": "v2",
|
|
"training_run_id": run_id,
|
|
"evaluation_report_id": eval_report["id"],
|
|
},
|
|
)
|
|
assert promo.status_code == 201
|
|
request_id = promo.json()["id"]
|
|
|
|
approve = client.post(
|
|
f"/api/promotions/{request_id}/approve",
|
|
json={"approved_by": "ops-lead", "comment": "Eval gate passed"},
|
|
)
|
|
assert approve.status_code == 200
|
|
|
|
agent = client.get("/api/agents/test_agent").json()
|
|
assert agent["version"] == "v2"
|
|
assert agent["state"] == "active"
|
|
|
|
|
|
def test_promotion_blocked_when_eval_failed(tmp_path: Path) -> None:
|
|
"""Promotion returns 422 if linked evaluation did not pass."""
|
|
from datetime import UTC, datetime
|
|
from uuid import uuid4
|
|
|
|
from forja_core.api.evaluation_persistence import save_evaluation
|
|
from forja_core.api.training_persistence import save_training_run
|
|
from forja_core.domain.training import (
|
|
EvaluationReport,
|
|
TrainingHyperparameters,
|
|
TrainingJobSpec,
|
|
TrainingRun,
|
|
)
|
|
|
|
client = TestClient(create_app())
|
|
settings = deps.get_settings()
|
|
run_id = uuid4()
|
|
now = datetime.now(UTC)
|
|
spec = TrainingJobSpec(
|
|
dataset_name="incident_sft",
|
|
dataset_version="v1",
|
|
model_name="gpt4o_lora_base",
|
|
model_version="v1",
|
|
agent_name="test_agent",
|
|
hyperparameters=TrainingHyperparameters(),
|
|
)
|
|
save_training_run(
|
|
settings.data_dir,
|
|
TrainingRun(
|
|
id=run_id,
|
|
backend="mock",
|
|
status="succeeded",
|
|
spec=spec,
|
|
external_job_id="mock:x",
|
|
created_at=now,
|
|
updated_at=now,
|
|
),
|
|
)
|
|
report = EvaluationReport(
|
|
id=uuid4(),
|
|
training_run_id=run_id,
|
|
agent_name="test_agent",
|
|
agent_version="v1",
|
|
passed=False,
|
|
scenario_count=1,
|
|
violation_count=1,
|
|
created_at=now,
|
|
notes="forced failure",
|
|
)
|
|
save_evaluation(settings.data_dir, report)
|
|
|
|
r = client.post(
|
|
"/api/promotions",
|
|
json={
|
|
"agent_name": "test_agent",
|
|
"agent_version": "v2",
|
|
"training_run_id": str(run_id),
|
|
"evaluation_report_id": str(report.id),
|
|
},
|
|
)
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_promotion_blocked_when_eval_covers_other_version() -> None:
|
|
"""Evaluating the active version must not unlock promoting a different draft."""
|
|
client = TestClient(create_app())
|
|
|
|
# No agent_version on the run → evaluation covers the active version (v1).
|
|
run = client.post(
|
|
"/api/training/runs",
|
|
json={
|
|
"dataset_name": "incident_sft",
|
|
"model_name": "gpt4o_lora_base",
|
|
"agent_name": "test_agent",
|
|
},
|
|
).json()
|
|
run_id = run["id"]
|
|
|
|
eval_report = client.post(f"/api/training/runs/{run_id}/evaluate").json()
|
|
assert eval_report["passed"] is True
|
|
assert eval_report["agent_version"] == "v1"
|
|
|
|
r = client.post(
|
|
"/api/promotions",
|
|
json={
|
|
"agent_name": "test_agent",
|
|
"agent_version": "v2",
|
|
"training_run_id": run_id,
|
|
"evaluation_report_id": eval_report["id"],
|
|
},
|
|
)
|
|
assert r.status_code == 422
|
|
assert "evaluate the candidate version" in r.json()["detail"]
|