Files
leocrm/tests/test_phase_j_self_improvement.py
T

271 lines
13 KiB
Python

"""Tests for Phase J — Controlled Self-Improvement."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
class TestImprovementSignals:
"""Test J-SIGNAL: Improvement signal collection."""
def test_signal_dataclass(self):
from app.ai.self_improvement import ImprovementSignal, SignalType
s = ImprovementSignal(signal_type=SignalType.AGENT_RUN, source_ref="agent_run:123", tenant_id="t1")
assert s.signal_type == SignalType.AGENT_RUN
assert s.source_ref == "agent_run:123"
d = s.to_dict()
assert d["signal_type"] == "agent_run"
def test_all_signal_types(self):
from app.ai.self_improvement import SignalType
assert SignalType.AGENT_RUN.value == "agent_run"
assert SignalType.WORKFLOW_RUN.value == "workflow_run"
assert SignalType.USER_CORRECTION.value == "user_correction"
assert SignalType.HANDOFF.value == "handoff"
assert SignalType.ERROR_RETRY.value == "error_retry"
@pytest.mark.asyncio
async def test_collect_signals_returns_list(self):
from app.ai.self_improvement import collect_signals
mock_db = AsyncMock()
mock_db.execute = AsyncMock(return_value=MagicMock(scalars=MagicMock(return_value=[])))
signals = await collect_signals(mock_db, uuid.uuid4(), days=30)
assert isinstance(signals, list)
class TestPatternDetection:
"""Test J-PATTERN: Pattern/bottleneck detection."""
def test_detect_patterns_empty(self):
from app.ai.self_improvement import detect_patterns
assert detect_patterns([]) == []
def test_detect_error_retries(self):
from app.ai.self_improvement import detect_patterns, ImprovementSignal, SignalType
signals = [
ImprovementSignal(signal_type=SignalType.AGENT_RUN, outcome="stopped_error"),
ImprovementSignal(signal_type=SignalType.AGENT_RUN, outcome="stopped_error"),
ImprovementSignal(signal_type=SignalType.AGENT_RUN, outcome="stopped_error"),
]
patterns = detect_patterns(signals)
assert len(patterns) == 1
assert patterns[0].pattern_type == "error_retries"
assert patterns[0].occurrence_count == 3
def test_detect_frequent_corrections(self):
from app.ai.self_improvement import detect_patterns, ImprovementSignal, SignalType
signals = [
ImprovementSignal(signal_type=SignalType.USER_CORRECTION, outcome="corrected"),
ImprovementSignal(signal_type=SignalType.USER_CORRECTION, outcome="corrected"),
ImprovementSignal(signal_type=SignalType.USER_CORRECTION, outcome="corrected"),
]
patterns = detect_patterns(signals)
assert len(patterns) == 1
assert patterns[0].pattern_type == "frequent_corrections"
def test_detect_repetitive_handoffs(self):
from app.ai.self_improvement import detect_patterns, ImprovementSignal, SignalType
signals = [
ImprovementSignal(signal_type=SignalType.HANDOFF, outcome="handoff"),
ImprovementSignal(signal_type=SignalType.HANDOFF, outcome="handoff"),
ImprovementSignal(signal_type=SignalType.HANDOFF, outcome="handoff"),
]
patterns = detect_patterns(signals)
assert len(patterns) == 1
assert patterns[0].pattern_type == "repetitive_handoffs"
def test_detect_rejected_suggestions(self):
from app.ai.self_improvement import detect_patterns, ImprovementSignal, SignalType
signals = [
ImprovementSignal(signal_type=SignalType.PROACTIVE_SUGGESTION, outcome="dismissed") for _ in range(5)
]
patterns = detect_patterns(signals)
assert len(patterns) == 1
assert patterns[0].pattern_type == "rejected_suggestions"
def test_pattern_confidence_capped(self):
from app.ai.self_improvement import detect_patterns, ImprovementSignal, SignalType
signals = [ImprovementSignal(signal_type=SignalType.AGENT_RUN, outcome="stopped_error") for _ in range(50)]
patterns = detect_patterns(signals)
assert patterns[0].confidence <= 0.9
class TestImprovementProposal:
"""Test J-PROP: Improvement proposal creation."""
def test_proposal_dataclass(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, ProposalStatus
p = ImprovementProposal(proposal_type=ProposalType.AGENT, title="Test")
assert p.proposal_type == ProposalType.AGENT
assert p.status == ProposalStatus.DRAFT
d = p.to_dict()
assert d["proposal_type"] == "agent"
assert d["status"] == "draft"
def test_create_proposal_from_pattern(self):
from app.ai.self_improvement import DetectedPattern, create_proposal, ProposalType
pattern = DetectedPattern(pattern_type="error_retries", description="3 errors", confidence=0.8, occurrence_count=3)
proposal = create_proposal(pattern, ProposalType.AGENT, "Fix agent errors")
assert proposal.title == "Fix agent errors"
assert proposal.proposal_type == ProposalType.AGENT
assert len(proposal.evidence_refs) == 0 # pattern had no refs
assert proposal.status.value == "draft"
def test_all_proposal_types(self):
from app.ai.self_improvement import ProposalType
assert ProposalType.AGENT.value == "agent"
assert ProposalType.SKILL.value == "skill"
assert ProposalType.WORKFLOW.value == "workflow"
assert ProposalType.PLUGIN_PATCH.value == "plugin_patch"
def test_all_proposal_statuses(self):
from app.ai.self_improvement import ProposalStatus
assert ProposalStatus.DRAFT.value == "draft"
assert ProposalStatus.PENDING_APPROVAL.value == "pending_approval"
assert ProposalStatus.APPROVED.value == "approved"
assert ProposalStatus.ACTIVE.value == "active"
assert ProposalStatus.ROLLED_BACK.value == "rolled_back"
class TestVersionedDraft:
"""Test J-DRAFT: Versioned draft creation."""
def test_draft_dataclass(self):
from app.ai.self_improvement import VersionedDraft
d = VersionedDraft(proposal_id="p1", version=1, config={"key": "value"})
assert d.version == 1
assert d.config == {"key": "value"}
assert d.previous_version_id is None
def test_create_draft_first_version(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, create_draft
proposal = ImprovementProposal(proposal_type=ProposalType.AGENT, draft_config={"model": "gpt-4o"})
draft = create_draft(proposal)
assert draft.version == 1
assert draft.config == {"model": "gpt-4o"}
assert draft.previous_version_id is None
def test_create_draft_incremented_version(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, create_draft, VersionedDraft
proposal = ImprovementProposal(proposal_type=ProposalType.AGENT, draft_config={"model": "gpt-4o-mini"})
prev = VersionedDraft(proposal_id="p1", version=1, config={"model": "gpt-4o"})
draft = create_draft(proposal, prev)
assert draft.version == 2
assert draft.previous_version_id == prev.id
class TestEvaluation:
"""Test J-EVAL: Evaluation/sandbox."""
@pytest.mark.asyncio
async def test_evaluate_proposal_returns_result(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, VersionedDraft, evaluate_proposal, ImprovementSignal, SignalType
proposal = ImprovementProposal(proposal_type=ProposalType.AGENT)
draft = VersionedDraft(proposal_id=proposal.id, version=1)
signals = [ImprovementSignal(signal_type=SignalType.AGENT_RUN, outcome="stopped_error") for _ in range(10)]
result = await evaluate_proposal(proposal, draft, signals)
assert result["test_cases"] == 10
assert result["passed"] == 10
assert result["score"] == 100.0
assert result["recommendation"] == "approve"
@pytest.mark.asyncio
async def test_evaluate_empty_signals(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, VersionedDraft, evaluate_proposal
proposal = ImprovementProposal(proposal_type=ProposalType.AGENT)
draft = VersionedDraft(proposal_id=proposal.id, version=1)
result = await evaluate_proposal(proposal, draft, [])
assert result["test_cases"] == 0
assert result["score"] == 0.0
class TestApprovalActivation:
"""Test J-APPROVAL, J-ACTIVATE: Approval and activation."""
@pytest.mark.asyncio
async def test_request_approval(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, ProposalStatus, request_approval
proposal = ImprovementProposal(proposal_type=ProposalType.AGENT)
with patch("app.core.approval.create_approval_request", new_callable=AsyncMock) as mock_approval:
mock_approval.return_value = MagicMock(id=uuid.uuid4())
result = await request_approval(AsyncMock(), uuid.uuid4(), uuid.uuid4(), proposal, {"score": 80})
assert result["status"] == "pending_approval"
assert proposal.status == ProposalStatus.PENDING_APPROVAL
@pytest.mark.asyncio
async def test_activate_without_approval_rejected(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, ProposalStatus, VersionedDraft, activate_proposal
proposal = ImprovementProposal(proposal_type=ProposalType.AGENT, status=ProposalStatus.DRAFT)
draft = VersionedDraft(proposal_id=proposal.id, version=1)
result = await activate_proposal(AsyncMock(), uuid.uuid4(), proposal, draft)
assert result["status"] == "rejected"
@pytest.mark.asyncio
async def test_activate_approved_proposal(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, ProposalStatus, VersionedDraft, activate_proposal
proposal = ImprovementProposal(proposal_type=ProposalType.AGENT, status=ProposalStatus.APPROVED)
draft = VersionedDraft(proposal_id=proposal.id, version=1)
result = await activate_proposal(AsyncMock(), uuid.uuid4(), proposal, draft)
assert result["status"] == "active"
assert result["rollback_available"] is True
assert proposal.status == ProposalStatus.ACTIVE
@pytest.mark.asyncio
async def test_rollback_active_proposal(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, ProposalStatus, VersionedDraft, rollback_proposal
proposal = ImprovementProposal(proposal_type=ProposalType.AGENT, status=ProposalStatus.ACTIVE)
prev_draft = VersionedDraft(proposal_id=proposal.id, version=1)
result = await rollback_proposal(AsyncMock(), uuid.uuid4(), proposal, prev_draft)
assert result["status"] == "rolled_back"
assert proposal.status == ProposalStatus.ROLLED_BACK
@pytest.mark.asyncio
async def test_rollback_non_active_rejected(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, ProposalStatus, rollback_proposal
proposal = ImprovementProposal(proposal_type=ProposalType.AGENT, status=ProposalStatus.DRAFT)
result = await rollback_proposal(AsyncMock(), uuid.uuid4(), proposal)
assert result["status"] == "rejected"
class TestImpactMeasurement:
"""Test J-MEASURE: Pre/post impact measurement."""
@pytest.mark.asyncio
async def test_measure_impact_not_active(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, measure_impact
proposal = ImprovementProposal(proposal_type=ProposalType.AGENT)
result = await measure_impact(AsyncMock(), uuid.uuid4(), proposal)
assert result["status"] == "not_active"
@pytest.mark.asyncio
async def test_measure_impact_active(self):
from app.ai.self_improvement import ImprovementProposal, ProposalType, ProposalStatus, measure_impact
proposal = ImprovementProposal(
proposal_type=ProposalType.AGENT,
status=ProposalStatus.ACTIVE,
activated_at=datetime.now(UTC) - timedelta(days=3),
measurement_before={"total_runs": 10, "errors": 5, "cost_usd": 1.0, "error_rate": 50.0},
)
mock_db = MagicMock()
mock_scalar = AsyncMock(side_effect=[20, 2, 0.5])
mock_db.scalar = mock_scalar
result = await measure_impact(mock_db, uuid.uuid4(), proposal, days=7)
assert isinstance(result, dict)
assert "proposal_id" in result
assert "period_days" in result
assert result["period_days"] == 7
# After may contain error if mock DB queries fail, or metrics if they succeed
assert "after" in result
@pytest.mark.asyncio
async def test_capture_baseline(self):
from app.ai.self_improvement import capture_baseline
mock_db = MagicMock()
mock_scalar = AsyncMock(side_effect=[10, 2, 0.5])
mock_db.scalar = mock_scalar
result = await capture_baseline(mock_db, uuid.uuid4(), days=7)
assert isinstance(result, dict)
# Result may contain metrics or error depending on mock DB behavior
assert len(result) > 0