File size: 12,854 Bytes
e86dfae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
"""
Unit Tests: Agents
Tests for all 4 agents and the AgentOrchestrator.
Uses mocked Gemini API to test without real API calls.
"""

import json
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

MOCK_CLASSIFICATION_JSON = json.dumps(
    {
        "category": "safety_protocol",
        "subcategory": "ventilation",
        "confidence": 0.93,
        "reasoning": "Document contains MSHA ventilation requirements and PPE protocols.",
    }
)

MOCK_SAFETY_JSON = json.dumps(
    {
        "score": 82.0,
        "status": "compliant",
        "confidence": 0.88,
        "hazards": [
            {
                "type": "atmospheric",
                "severity": "medium",
                "description": "Potential methane buildup in Section C",
                "regulation": "30 CFR 75.323",
            }
        ],
        "recommendations": ["Install additional methane detectors in Section C"],
        "compliance_details": {
            "msha_compliant": True,
            "osha_compliant": True,
            "dgms_compliant": True,
            "missing_elements": [],
        },
        "summary": "Generally compliant. One atmospheric hazard identified.",
    }
)

MOCK_ENTITIES_JSON = json.dumps(
    {
        "equipment": ["Caterpillar D11", "Joy 12CM15 Continuous Miner"],
        "chemicals": ["methane (CH4)", "carbon monoxide (CO)"],
        "locations": ["Section C", "Main Heading 3"],
        "personnel": ["Safety Officer", "Mine Foreman"],
        "dates": ["2024-01-15", "quarterly"],
        "regulations": ["30 CFR 75.321", "30 CFR 75.323", "MSHA 1910.134"],
    }
)

MOCK_SUMMARY_JSON = json.dumps(
    {
        "summary": "This document outlines safety procedures for underground coal mining operations, covering ventilation standards, PPE requirements, and emergency evacuation protocols.",
        "key_points": [
            "Methane levels must stay below 1% in all working areas",
            "PPE inspection required before each shift",
            "Emergency drills must occur quarterly",
        ],
        "action_items": [
            "Schedule quarterly emergency drill",
            "Replace methane detectors in Section C",
        ],
        "document_purpose": "Define safety standards for underground coal mine personnel.",
        "confidence": 0.91,
    }
)


def make_mock_model(json_response: str):
    """Create a mock Gemini model that returns json_response."""
    mock_response = MagicMock()
    mock_response.text = json_response

    mock_model = MagicMock()
    mock_model.generate_content = MagicMock(return_value=mock_response)
    return mock_model


def make_mock_client(json_response: str):
    """Create a mock OpenAI client that returns json_response."""
    mock_choice = MagicMock()
    mock_choice.message.content = json_response

    mock_completion = MagicMock()
    mock_completion.choices = [mock_choice]

    mock_chat = MagicMock()
    mock_chat.completions.create = AsyncMock(return_value=mock_completion)

    mock_client = MagicMock()
    mock_client.chat = mock_chat
    return mock_client


class TestClassifierAgent:
    """Tests for ClassifierAgent."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_analyze_returns_expected_keys(self, sample_mining_text):
        """ClassifierAgent.analyze() returns dict with required keys."""
        from app.agents.classifier import ClassifierAgent

        agent = ClassifierAgent()
        agent.client = make_mock_client(MOCK_CLASSIFICATION_JSON)

        result = await agent.analyze(sample_mining_text)

        assert "category" in result
        assert "subcategory" in result
        assert "confidence" in result
        assert "reasoning" in result

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_analyze_maps_category_correctly(self, sample_mining_text):
        """ClassifierAgent maps 'safety_protocol' string to correct value."""
        from app.agents.classifier import ClassifierAgent

        agent = ClassifierAgent()
        agent.client = make_mock_client(MOCK_CLASSIFICATION_JSON)

        result = await agent.analyze(sample_mining_text)
        assert result["category"] == "safety_protocol"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_analyze_confidence_is_float_in_range(self, sample_mining_text):
        """Confidence value is a float between 0 and 1."""
        from app.agents.classifier import ClassifierAgent

        agent = ClassifierAgent()
        agent.client = make_mock_client(MOCK_CLASSIFICATION_JSON)

        result = await agent.analyze(sample_mining_text)
        assert isinstance(result["confidence"], float)
        assert 0.0 <= result["confidence"] <= 1.0

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_analyze_unknown_category_maps_to_other(self, sample_mining_text):
        """Unknown category value falls back to 'other'."""
        from app.agents.classifier import ClassifierAgent

        agent = ClassifierAgent()
        agent.client = make_mock_client(
            json.dumps(
                {
                    "category": "completely_unknown_xyz",
                    "confidence": 0.5,
                    "reasoning": "unknown",
                }
            )
        )

        result = await agent.analyze(sample_mining_text)
        assert result["category"] == "other"

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_analyze_handles_empty_response(self, sample_mining_text):
        """Empty JSON response returns 'other' without crashing."""
        from app.agents.classifier import ClassifierAgent

        agent = ClassifierAgent()
        agent.client = make_mock_client("{}")

        result = await agent.analyze(sample_mining_text)
        assert "category" in result
        assert result["confidence"] == 0.5  # default


class TestSafetyAnalyzerAgent:
    """Tests for SafetyAnalyzerAgent."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_analyze_returns_expected_keys(self, sample_mining_text):
        """SafetyAnalyzerAgent.analyze() returns dict with required keys."""
        from app.agents.safety_analyzer import SafetyAnalyzerAgent

        agent = SafetyAnalyzerAgent()
        agent.client = make_mock_client(MOCK_SAFETY_JSON)

        result = await agent.analyze(
            sample_mining_text, {"category": "safety_protocol"}
        )

        for key in (
            "score",
            "status",
            "hazards",
            "recommendations",
            "compliance_details",
        ):
            assert key in result

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_score_is_numeric(self, sample_mining_text):
        """Safety score must be a numeric type."""
        from app.agents.safety_analyzer import SafetyAnalyzerAgent

        agent = SafetyAnalyzerAgent()
        agent.client = make_mock_client(MOCK_SAFETY_JSON)

        result = await agent.analyze(sample_mining_text)
        assert isinstance(result["score"], (int, float))

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_hazards_is_list(self, sample_mining_text):
        """Hazards must be a list."""
        from app.agents.safety_analyzer import SafetyAnalyzerAgent

        agent = SafetyAnalyzerAgent()
        agent.client = make_mock_client(MOCK_SAFETY_JSON)

        result = await agent.analyze(sample_mining_text)
        assert isinstance(result["hazards"], list)


class TestEntityExtractorAgent:
    """Tests for EntityExtractorAgent."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_analyze_returns_all_entity_types(self, sample_mining_text):
        """EntityExtractorAgent returns all 6 entity categories."""
        from app.agents.entity_extractor import EntityExtractorAgent

        agent = EntityExtractorAgent()
        agent.client = make_mock_client(MOCK_ENTITIES_JSON)

        result = await agent.analyze(sample_mining_text)

        for key in (
            "equipment",
            "chemicals",
            "locations",
            "personnel",
            "dates",
            "regulations",
        ):
            assert key in result
            assert isinstance(result[key], list)

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_entity_count_is_correct(self, sample_mining_text):
        """entity_count equals sum of all entity lists."""
        from app.agents.entity_extractor import EntityExtractorAgent

        agent = EntityExtractorAgent()
        agent.client = make_mock_client(MOCK_ENTITIES_JSON)

        result = await agent.analyze(sample_mining_text)
        expected = sum(
            len(result[k])
            for k in (
                "equipment",
                "chemicals",
                "locations",
                "personnel",
                "dates",
                "regulations",
            )
        )
        assert result["entity_count"] == expected


class TestSummarizerAgent:
    """Tests for SummarizerAgent."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_analyze_returns_expected_keys(self, sample_mining_text):
        """SummarizerAgent returns summary, key_points, action_items."""
        from app.agents.summarizer import SummarizerAgent

        agent = SummarizerAgent()
        agent.model = make_mock_model(MOCK_SUMMARY_JSON)

        result = await agent.analyze(sample_mining_text)

        assert "summary" in result
        assert "key_points" in result
        assert isinstance(result["summary"], str)
        assert len(result["summary"]) > 0

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_key_points_is_list(self, sample_mining_text):
        """key_points must be a list of strings."""
        from app.agents.summarizer import SummarizerAgent

        agent = SummarizerAgent()
        agent.model = make_mock_model(MOCK_SUMMARY_JSON)

        result = await agent.analyze(sample_mining_text)
        assert isinstance(result["key_points"], list)


class TestAgentOrchestrator:
    """Tests for AgentOrchestrator."""

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_orchestrator_returns_all_sections(self, sample_mining_text):
        """Orchestrator returns classification, safety, entities, summary, metadata."""
        from app.agents.orchestrator import AgentOrchestrator

        orchestrator = AgentOrchestrator()
        orchestrator.classifier.client = make_mock_client(MOCK_CLASSIFICATION_JSON)
        orchestrator.safety_analyzer.client = make_mock_client(MOCK_SAFETY_JSON)
        orchestrator.entity_extractor.client = make_mock_client(MOCK_ENTITIES_JSON)
        orchestrator.summarizer.model = make_mock_model(MOCK_SUMMARY_JSON)

        result = await orchestrator.analyze_document(sample_mining_text)

        for section in ("classification", "safety", "entities", "summary", "metadata"):
            assert section in result

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_orchestrator_includes_processing_time(self, sample_mining_text):
        """Metadata includes processing_time_ms > 0."""
        from app.agents.orchestrator import AgentOrchestrator

        orchestrator = AgentOrchestrator()
        orchestrator.classifier.client = make_mock_client(MOCK_CLASSIFICATION_JSON)
        orchestrator.safety_analyzer.client = make_mock_client(MOCK_SAFETY_JSON)
        orchestrator.entity_extractor.client = make_mock_client(MOCK_ENTITIES_JSON)
        orchestrator.summarizer.model = make_mock_model(MOCK_SUMMARY_JSON)

        result = await orchestrator.analyze_document(sample_mining_text)

        assert "processing_time_ms" in result["metadata"]
        assert isinstance(result["metadata"]["processing_time_ms"], int)
        assert result["metadata"]["processing_time_ms"] >= 0

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_orchestrator_handles_agent_failure_gracefully(
        self, sample_mining_text
    ):
        """If one agent fails, orchestrator still returns results from other agents."""
        from app.agents.orchestrator import AgentOrchestrator

        orchestrator = AgentOrchestrator()
        orchestrator.classifier.client = make_mock_client(MOCK_CLASSIFICATION_JSON)
        orchestrator.safety_analyzer.client = make_mock_client(MOCK_SAFETY_JSON)
        orchestrator.entity_extractor.client = make_mock_client(MOCK_ENTITIES_JSON)

        # Make summarizer fail
        fail_model = MagicMock()
        fail_model.generate_content.side_effect = Exception("API rate limit exceeded")
        orchestrator.summarizer.model = fail_model

        result = await orchestrator.analyze_document(sample_mining_text)

        # Should still return — summary will have "error" key
        assert "summary" in result
        assert "error" in result["summary"] or "summary" in result["summary"]