165 lines
6.2 KiB
Python
165 lines
6.2 KiB
Python
"""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
|