update for mcp
This commit is contained in:
@@ -1,28 +1,34 @@
|
||||
"""Unit tests for LlmPipeline — mock LLM client and embedding provider."""
|
||||
"""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
|
||||
import json
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_pipeline():
|
||||
with patch("app.infrastructure.perception.llm_pipeline.get_llm_client") as mock_llm_fn, \
|
||||
patch("app.infrastructure.perception.llm_pipeline.OpenAICompatibleEmbeddingProvider") as mock_emb_cls:
|
||||
|
||||
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='{"obligations":[{"text":"test obligation","deontic":"must","subject":"OEM","object":"system","condition":""}],"deadlines":[{"date":"2026-07-01","description":"实施截止"}],"scope":"适用于M1类车辆","penalties":"罚款","impact_level":"high"}')
|
||||
mock_client.chat.return_value = MagicMock(content=content or default)
|
||||
mock_llm_fn.return_value = mock_client
|
||||
|
||||
mock_emb = MagicMock()
|
||||
mock_emb.embed_texts.return_value = [[0.1] * 1024, [0.9] * 1024]
|
||||
mock_emb_cls.return_value = mock_emb
|
||||
|
||||
from app.infrastructure.perception.llm_pipeline import LlmPipeline
|
||||
return LlmPipeline(), mock_client, mock_emb
|
||||
return LlmPipeline(), mock_client
|
||||
|
||||
|
||||
def test_extract_structure_returns_dict():
|
||||
pipeline, mock_client, _ = _make_pipeline()
|
||||
"""Structure extraction still returns the enrichment keys callers expect."""
|
||||
pipeline, _ = _make_pipeline()
|
||||
event = {
|
||||
"id": "evt-001",
|
||||
"standard_code": "GB 18384-2025",
|
||||
@@ -38,8 +44,11 @@ def test_extract_structure_returns_dict():
|
||||
|
||||
|
||||
def test_assess_impact_returns_list():
|
||||
pipeline, mock_client, _ = _make_pipeline()
|
||||
mock_client.chat.return_value = MagicMock(content='[{"doc_id":"d1","doc_name":"Safety Manual","score":0.85,"key_clauses":"§4.2","recommendation":"更新第4章"}]')
|
||||
"""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"
|
||||
@@ -53,25 +62,103 @@ def test_assess_impact_returns_list():
|
||||
"title": "电动汽车安全要求",
|
||||
"obligations": [{"text": "OEM shall comply"}],
|
||||
}
|
||||
result = pipeline.assess_impact(event, mock_retrieval)
|
||||
assert isinstance(result, list)
|
||||
assert isinstance(pipeline.assess_impact(event, mock_retrieval), list)
|
||||
|
||||
|
||||
def test_compute_diff_no_change():
|
||||
pipeline, _, mock_emb = _make_pipeline()
|
||||
mock_emb.embed_texts.return_value = [[0.5] * 1024, [0.5] * 1024]
|
||||
result = pipeline.compute_diff("paragraph one", "paragraph one")
|
||||
assert isinstance(result, dict)
|
||||
assert "changed_sections" in result
|
||||
assert "change_summary" in result
|
||||
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_detects_change():
|
||||
pipeline, mock_client, mock_emb = _make_pipeline()
|
||||
mock_emb.embed_texts.return_value = [
|
||||
[1.0] + [0.0] * 1023,
|
||||
[0.0] + [1.0] + [0.0] * 1022,
|
||||
]
|
||||
mock_client.chat.return_value = MagicMock(content='{"change_type":"tightened","summary":"Requirement tightened"}')
|
||||
result = pipeline.compute_diff("old paragraph text", "new tighter requirement text")
|
||||
assert isinstance(result["changed_sections"], list)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user