Files
AIRegulation-DocAnalysis/backend/tests/perception/test_llm_pipeline.py
T

165 lines
6.2 KiB
Python
Raw Normal View History

2026-08-06 11:08:46 +08:00
"""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.
"""
2026-06-08 11:16:28 +08:00
from __future__ import annotations
2026-08-06 11:08:46 +08:00
2026-06-08 11:16:28 +08:00
from unittest.mock import MagicMock, patch
2026-08-06 11:08:46 +08:00
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:
2026-06-08 11:16:28 +08:00
mock_client = MagicMock()
2026-08-06 11:08:46 +08:00
mock_client.chat.return_value = MagicMock(content=content or default)
2026-06-08 11:16:28 +08:00
mock_llm_fn.return_value = mock_client
from app.infrastructure.perception.llm_pipeline import LlmPipeline
2026-08-06 11:08:46 +08:00
return LlmPipeline(), mock_client
2026-06-08 11:16:28 +08:00
def test_extract_structure_returns_dict():
2026-08-06 11:08:46 +08:00
"""Structure extraction still returns the enrichment keys callers expect."""
pipeline, _ = _make_pipeline()
2026-06-08 11:16:28 +08:00
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():
2026-08-06 11:08:46 +08:00
"""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章"}]'
)
2026-06-08 11:16:28 +08:00
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"}],
}
2026-08-06 11:08:46 +08:00
assert isinstance(pipeline.assess_impact(event, mock_retrieval), list)
2026-06-08 11:16:28 +08:00
2026-08-06 11:08:46 +08:00
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()
2026-06-08 11:16:28 +08:00
2026-08-06 11:08:46 +08:00
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