"""Unit tests for LlmPipeline with a mocked LLM client. The pipeline no longer constructs an embedding provider: change detection moved to the deterministic RegulationDiffer, and the LLM is called only to explain changes that determinism already located. These tests pin that gating contract. """ from __future__ import annotations from unittest.mock import MagicMock, patch def _make_pipeline(content: str | None = None): """Build a pipeline whose LLM client is a mock returning `content`.""" default = ( '{"obligations":[{"text":"test obligation","deontic":"must","subject":"OEM",' '"object":"system","condition":""}],"deadlines":[{"date":"2026-07-01",' '"description":"实施截止"}],"scope":"适用于M1类车辆","penalties":"罚款",' '"impact_level":"high"}' ) with patch("app.infrastructure.perception.llm_pipeline.get_llm_client") as mock_llm_fn: mock_client = MagicMock() mock_client.chat.return_value = MagicMock(content=content or default) mock_llm_fn.return_value = mock_client from app.infrastructure.perception.llm_pipeline import LlmPipeline return LlmPipeline(), mock_client def test_extract_structure_returns_dict(): """Structure extraction still returns the enrichment keys callers expect.""" pipeline, _ = _make_pipeline() event = { "id": "evt-001", "standard_code": "GB 18384-2025", "title": "电动汽车安全要求", "summary": "新增 IP67 级别防护", "source_label": "CATARC", "tags": ["电池安全"], } result = pipeline.extract_structure(event) assert isinstance(result, dict) assert "obligations" in result assert "impact_level" in result def test_assess_impact_returns_list(): """Impact assessment still returns a list of affected documents.""" pipeline, _ = _make_pipeline( '[{"doc_id":"d1","doc_name":"Safety Manual","score":0.85,' '"key_clauses":"§4.2","recommendation":"更新第4章"}]' ) mock_retrieval = MagicMock() chunk = MagicMock() chunk.doc_id = "d1" chunk.doc_title = "Safety Manual" chunk.score = 0.85 chunk.text = "relevant text" chunk.section_title = "§4.2" mock_retrieval.retrieve.return_value = [chunk] event = { "standard_code": "GB 18384-2025", "title": "电动汽车安全要求", "obligations": [{"text": "OEM shall comply"}], } assert isinstance(pipeline.assess_impact(event, mock_retrieval), list) def test_compute_diff_no_change_costs_no_llm_call(): """Identical text must short-circuit before reaching the model.""" pipeline, mock_client = _make_pipeline() mock_client.chat.reset_mock() result = pipeline.compute_diff("第一条 保持不变的条款。", "第一条 保持不变的条款。") assert result["changed_sections"] == [] assert "No substantive changes" in result["change_summary"] mock_client.chat.assert_not_called() def test_compute_diff_classifies_a_real_change(): """A gated change is classified and the model's legal_effect is surfaced.""" pipeline, _ = _make_pipeline( '{"change_type":"tightened","legal_effect":"Requirement tightened."}' ) result = pipeline.compute_diff( "第三条 生产企业应当每年开展一次安全评估。", "第三条 生产企业宜每年开展一次安全评估。", ) sections = result["changed_sections"] assert len(sections) == 1 assert sections[0]["change_type"] == "tightened" assert sections[0]["summary"] == "Requirement tightened." def test_numeric_change_overrides_the_model_label(): """A moved number wins over the model, which routinely calls it 'clarified'.""" pipeline, _ = _make_pipeline( '{"change_type":"clarified","legal_effect":"Minor wording update."}' ) result = pipeline.compute_diff( "第二条 车辆制动系统应在30米内完全停止。", "第二条 车辆制动系统应在20米内完全停止。", ) section = result["changed_sections"][0] assert section["numeric_changed"] is True assert section["change_type"] == "numeric" def test_cosmetic_change_is_never_sent_to_the_model(): """Punctuation-only edits are recorded but must not cost a model call.""" pipeline, mock_client = _make_pipeline() mock_client.chat.reset_mock() result = pipeline.compute_diff( "第五条 本标准由全国汽车标准化技术委员会归口管理。", "第五条 本标准由全国汽车标准化技术委员会归口管理", ) assert len(result["changed_sections"]) == 1 mock_client.chat.assert_not_called() def test_llm_failure_preserves_the_deterministic_record(): """A model error must not discard a change deterministic analysis proved real.""" pipeline, mock_client = _make_pipeline() mock_client.chat.side_effect = RuntimeError("gateway down") result = pipeline.compute_diff( "第二条 车辆制动系统应在30米内完全停止。", "第二条 车辆制动系统应在20米内完全停止。", ) section = result["changed_sections"][0] assert section["numeric_changed"] is True assert section["change_type"] == "numeric" assert section["summary"] == "" assert "第二条" in section["old_text"] def test_only_gated_paragraphs_reach_the_model(): """One significant change among cosmetic ones yields exactly one model call.""" pipeline, mock_client = _make_pipeline( '{"change_type":"tightened","legal_effect":"Tighter limit."}' ) mock_client.chat.reset_mock() old = "\n".join([ "第一条 本标准规定了车辆制动系统的技术要求。", "第二条 车辆制动系统应在30米内完全停止。", "第三条 本标准由全国汽车标准化技术委员会归口管理。", ]) new = "\n".join([ "第一条 本标准规定了车辆制动系统的技术要求。", "第二条 车辆制动系统应在20米内完全停止。", "第三条 本标准由全国汽车标准化技术委员会归口管理", ]) result = pipeline.compute_diff(old, new) # Two paragraphs changed; only the numeric one clears the gate. assert len(result["changed_sections"]) == 2 assert mock_client.chat.call_count == 1