update for mcp
This commit is contained in:
@@ -6,6 +6,7 @@ import pytest
|
||||
|
||||
from app.infrastructure.perception.crawlers.base import RawEvent
|
||||
from app.infrastructure.perception.mock_event_store import MockEventStore
|
||||
from app.infrastructure.perception.mock_notification_store import MockNotificationStore
|
||||
|
||||
|
||||
def _make_raw_event(code="TST-001"):
|
||||
@@ -17,11 +18,18 @@ def _make_raw_event(code="TST-001"):
|
||||
)
|
||||
|
||||
|
||||
def _make_crawler(raw_events, full_text="full body text"):
|
||||
"""Build a mock crawler. `full_text=""` simulates a failed detail fetch."""
|
||||
mock_crawler = MagicMock()
|
||||
mock_crawler.fetch.return_value = raw_events
|
||||
mock_crawler.fetch_full_text.return_value = full_text
|
||||
return mock_crawler
|
||||
|
||||
|
||||
def _make_service(raw_events):
|
||||
from app.application.perception.crawl_service import CrawlService
|
||||
|
||||
mock_crawler = MagicMock()
|
||||
mock_crawler.fetch.return_value = raw_events
|
||||
mock_crawler = _make_crawler(raw_events)
|
||||
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.extract_structure.return_value = {
|
||||
@@ -41,6 +49,9 @@ def _make_service(raw_events):
|
||||
event_store=store,
|
||||
llm_pipeline=mock_pipeline,
|
||||
retrieval_service=mock_retrieval,
|
||||
notification_store=MockNotificationStore(),
|
||||
embedding_provider=MagicMock(),
|
||||
vector_index=MagicMock(),
|
||||
)
|
||||
|
||||
|
||||
@@ -54,8 +65,7 @@ def test_crawl_yields_progress_and_done():
|
||||
def test_crawl_upserts_to_store():
|
||||
store = MockEventStore()
|
||||
from app.application.perception.crawl_service import CrawlService
|
||||
mock_crawler = MagicMock()
|
||||
mock_crawler.fetch.return_value = [_make_raw_event("NEW-001")]
|
||||
mock_crawler = _make_crawler([_make_raw_event("NEW-001")])
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.extract_structure.return_value = {
|
||||
"obligations": [], "deadlines": [], "scope": "",
|
||||
@@ -70,6 +80,9 @@ def test_crawl_upserts_to_store():
|
||||
event_store=store,
|
||||
llm_pipeline=mock_pipeline,
|
||||
retrieval_service=MagicMock(),
|
||||
notification_store=MockNotificationStore(),
|
||||
embedding_provider=MagicMock(),
|
||||
vector_index=MagicMock(),
|
||||
)
|
||||
list(svc.run_crawl())
|
||||
result = store.get_by_standard_code("NEW-001")
|
||||
@@ -80,7 +93,8 @@ def test_crawl_upserts_to_store():
|
||||
def test_crawl_skips_unchanged_events():
|
||||
store = MockEventStore()
|
||||
raw = _make_raw_event("SKIP-001")
|
||||
content_hash = hashlib.sha256(raw.raw_text.encode()).hexdigest()
|
||||
body = "full body text"
|
||||
content_hash = hashlib.sha256(body.encode()).hexdigest()
|
||||
store.upsert({
|
||||
"id": hashlib.sha256(f"TEST-SKIP-001".encode()).hexdigest()[:12],
|
||||
"standard_code": "SKIP-001",
|
||||
@@ -99,13 +113,341 @@ def test_crawl_skips_unchanged_events():
|
||||
})
|
||||
mock_pipeline = MagicMock()
|
||||
from app.application.perception.crawl_service import CrawlService
|
||||
mock_crawler = MagicMock()
|
||||
mock_crawler.fetch.return_value = [raw]
|
||||
mock_crawler = _make_crawler([raw], full_text=body)
|
||||
svc = CrawlService(
|
||||
crawlers={"TEST": mock_crawler},
|
||||
event_store=store,
|
||||
llm_pipeline=mock_pipeline,
|
||||
retrieval_service=MagicMock(),
|
||||
notification_store=MockNotificationStore(),
|
||||
embedding_provider=MagicMock(),
|
||||
vector_index=MagicMock(),
|
||||
)
|
||||
list(svc.run_crawl())
|
||||
mock_pipeline.extract_structure.assert_not_called()
|
||||
|
||||
|
||||
def test_crawl_stores_the_fetched_body_for_the_next_diff():
|
||||
"""The body must be persisted, or the next crawl has no baseline to compare."""
|
||||
store = MockEventStore()
|
||||
from app.application.perception.crawl_service import CrawlService
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.extract_structure.return_value = {}
|
||||
mock_pipeline.assess_impact.return_value = []
|
||||
svc = CrawlService(
|
||||
crawlers={"TEST": _make_crawler([_make_raw_event("BODY-001")], full_text="第一条 正文内容。")},
|
||||
event_store=store,
|
||||
llm_pipeline=mock_pipeline,
|
||||
retrieval_service=MagicMock(),
|
||||
notification_store=MockNotificationStore(),
|
||||
embedding_provider=MagicMock(),
|
||||
vector_index=MagicMock(),
|
||||
)
|
||||
list(svc.run_crawl())
|
||||
|
||||
stored = store.get_by_standard_code("BODY-001")
|
||||
assert stored["raw_text"] == "第一条 正文内容。"
|
||||
|
||||
|
||||
def test_crawl_falls_back_when_full_text_fetch_fails():
|
||||
"""An unreachable detail page degrades to the list-page text, never crashes."""
|
||||
store = MockEventStore()
|
||||
from app.application.perception.crawl_service import CrawlService
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.extract_structure.return_value = {}
|
||||
mock_pipeline.assess_impact.return_value = []
|
||||
svc = CrawlService(
|
||||
crawlers={"TEST": _make_crawler([_make_raw_event("FALL-001")], full_text="")},
|
||||
event_store=store,
|
||||
llm_pipeline=mock_pipeline,
|
||||
retrieval_service=MagicMock(),
|
||||
notification_store=MockNotificationStore(),
|
||||
embedding_provider=MagicMock(),
|
||||
vector_index=MagicMock(),
|
||||
)
|
||||
list(svc.run_crawl())
|
||||
|
||||
stored = store.get_by_standard_code("FALL-001")
|
||||
assert stored is not None
|
||||
assert stored["raw_text"] == "full text"
|
||||
|
||||
|
||||
def test_crawl_skips_diff_when_no_previous_body_exists():
|
||||
"""Rows stored before raw_text was persisted must not be diffed against nothing."""
|
||||
store = MockEventStore()
|
||||
from app.application.perception.crawl_service import CrawlService
|
||||
event_id = hashlib.sha256(b"TEST-OLD-001").hexdigest()[:12]
|
||||
store.upsert({
|
||||
"id": event_id,
|
||||
"standard_code": "OLD-001",
|
||||
"source": "TEST",
|
||||
"title": "Test OLD-001",
|
||||
"summary": "legacy row",
|
||||
"impact_level": "low",
|
||||
"published_at": "2026-01-01",
|
||||
"tags": [],
|
||||
"content_hash": "stale-hash-from-before-this-change",
|
||||
})
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.extract_structure.return_value = {}
|
||||
mock_pipeline.assess_impact.return_value = []
|
||||
svc = CrawlService(
|
||||
crawlers={"TEST": _make_crawler([_make_raw_event("OLD-001")], full_text="第一条 新正文。")},
|
||||
event_store=store,
|
||||
llm_pipeline=mock_pipeline,
|
||||
retrieval_service=MagicMock(),
|
||||
notification_store=MockNotificationStore(),
|
||||
embedding_provider=MagicMock(),
|
||||
vector_index=MagicMock(),
|
||||
)
|
||||
list(svc.run_crawl())
|
||||
|
||||
mock_pipeline.compute_diff.assert_not_called()
|
||||
assert store.get(event_id)["raw_text"] == "第一条 新正文。"
|
||||
|
||||
|
||||
def test_new_event_creates_a_new_notification():
|
||||
"""A brand-new event must produce exactly one kind='new' notification."""
|
||||
from app.application.perception.crawl_service import CrawlService
|
||||
|
||||
store = MockEventStore()
|
||||
notifications = MockNotificationStore()
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.extract_structure.return_value = {}
|
||||
mock_pipeline.assess_impact.return_value = []
|
||||
svc = CrawlService(
|
||||
crawlers={"TEST": _make_crawler([_make_raw_event("NOTIF-NEW")])},
|
||||
event_store=store,
|
||||
llm_pipeline=mock_pipeline,
|
||||
retrieval_service=MagicMock(),
|
||||
notification_store=notifications,
|
||||
embedding_provider=MagicMock(),
|
||||
vector_index=MagicMock(),
|
||||
)
|
||||
list(svc.run_crawl())
|
||||
|
||||
items = notifications.list_for_user("any-user")
|
||||
assert len(items) == 1
|
||||
assert items[0]["kind"] == "new"
|
||||
|
||||
|
||||
def test_significant_change_creates_a_changed_notification():
|
||||
"""A numeric or deontic change must produce a kind='changed' notification."""
|
||||
from app.application.perception.crawl_service import CrawlService
|
||||
|
||||
store = MockEventStore()
|
||||
event_id = hashlib.sha256(b"TEST-SIG-001").hexdigest()[:12]
|
||||
store.upsert({
|
||||
"id": event_id, "standard_code": "SIG-001", "source": "TEST",
|
||||
"title": "Test SIG-001", "summary": "", "impact_level": "medium",
|
||||
"published_at": "2026-01-01", "tags": [],
|
||||
"content_hash": "old-hash", "raw_text": "old body",
|
||||
})
|
||||
notifications = MockNotificationStore()
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.extract_structure.return_value = {}
|
||||
mock_pipeline.assess_impact.return_value = []
|
||||
mock_pipeline.compute_diff.return_value = {
|
||||
"changed_sections": [{"change_type": "modified", "numeric_changed": True, "deontic_changed": False}],
|
||||
"change_summary": "1 paragraph changed (numeric).",
|
||||
}
|
||||
svc = CrawlService(
|
||||
crawlers={"TEST": _make_crawler([_make_raw_event("SIG-001")], full_text="new body")},
|
||||
event_store=store,
|
||||
llm_pipeline=mock_pipeline,
|
||||
retrieval_service=MagicMock(),
|
||||
notification_store=notifications,
|
||||
embedding_provider=MagicMock(),
|
||||
vector_index=MagicMock(),
|
||||
)
|
||||
list(svc.run_crawl())
|
||||
|
||||
items = notifications.list_for_user("any-user")
|
||||
assert len(items) == 1
|
||||
assert items[0]["kind"] == "changed"
|
||||
|
||||
|
||||
def test_cosmetic_only_change_creates_no_notification():
|
||||
"""A change with no numeric/deontic/added/removed section must not notify."""
|
||||
from app.application.perception.crawl_service import CrawlService
|
||||
|
||||
store = MockEventStore()
|
||||
event_id = hashlib.sha256(b"TEST-COS-001").hexdigest()[:12]
|
||||
store.upsert({
|
||||
"id": event_id, "standard_code": "COS-001", "source": "TEST",
|
||||
"title": "Test COS-001", "summary": "", "impact_level": "low",
|
||||
"published_at": "2026-01-01", "tags": [],
|
||||
"content_hash": "old-hash", "raw_text": "old body.",
|
||||
})
|
||||
notifications = MockNotificationStore()
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.extract_structure.return_value = {}
|
||||
mock_pipeline.assess_impact.return_value = []
|
||||
mock_pipeline.compute_diff.return_value = {
|
||||
"changed_sections": [{"change_type": "modified", "numeric_changed": False, "deontic_changed": False}],
|
||||
"change_summary": "cosmetic only",
|
||||
}
|
||||
svc = CrawlService(
|
||||
crawlers={"TEST": _make_crawler([_make_raw_event("COS-001")], full_text="old body")},
|
||||
event_store=store,
|
||||
llm_pipeline=mock_pipeline,
|
||||
retrieval_service=MagicMock(),
|
||||
notification_store=notifications,
|
||||
embedding_provider=MagicMock(),
|
||||
vector_index=MagicMock(),
|
||||
)
|
||||
list(svc.run_crawl())
|
||||
|
||||
assert notifications.list_for_user("any-user") == []
|
||||
|
||||
|
||||
def test_notification_store_failure_does_not_abort_the_crawl():
|
||||
"""A broken notification store must not stop the crawl or raise."""
|
||||
from app.application.perception.crawl_service import CrawlService
|
||||
|
||||
store = MockEventStore()
|
||||
broken_notifications = MagicMock()
|
||||
broken_notifications.create.side_effect = RuntimeError("notification db down")
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.extract_structure.return_value = {}
|
||||
mock_pipeline.assess_impact.return_value = []
|
||||
svc = CrawlService(
|
||||
crawlers={"TEST": _make_crawler([_make_raw_event("BROKEN-001")])},
|
||||
event_store=store,
|
||||
llm_pipeline=mock_pipeline,
|
||||
retrieval_service=MagicMock(),
|
||||
notification_store=broken_notifications,
|
||||
embedding_provider=MagicMock(),
|
||||
vector_index=MagicMock(),
|
||||
)
|
||||
events = list(svc.run_crawl())
|
||||
|
||||
assert any(e.get("event") == "done" for e in events)
|
||||
assert store.get_by_standard_code("BROKEN-001") is not None
|
||||
|
||||
|
||||
def test_new_event_is_indexed_in_the_knowledge_base():
|
||||
"""A brand-new event must be chunked, embedded, and upserted into Milvus."""
|
||||
from app.application.perception.crawl_service import CrawlService
|
||||
|
||||
body = "第一条 本标准规定了车辆制动系统的技术要求。\n第二条 车辆制动系统应在时速50公里条件下于30米内完全停止。"
|
||||
embedding_provider = MagicMock()
|
||||
embedding_provider.embed_texts.return_value = [[0.1] * 8]
|
||||
vector_index = MagicMock()
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.extract_structure.return_value = {}
|
||||
mock_pipeline.assess_impact.return_value = []
|
||||
svc = CrawlService(
|
||||
crawlers={"TEST": _make_crawler([_make_raw_event("IDX-NEW")], full_text=body)},
|
||||
event_store=MockEventStore(),
|
||||
llm_pipeline=mock_pipeline,
|
||||
retrieval_service=MagicMock(),
|
||||
notification_store=MockNotificationStore(),
|
||||
embedding_provider=embedding_provider,
|
||||
vector_index=vector_index,
|
||||
)
|
||||
list(svc.run_crawl())
|
||||
|
||||
event_id = hashlib.sha256(b"TEST-IDX-NEW").hexdigest()[:12]
|
||||
vector_index.delete_by_document.assert_called_once_with(event_id)
|
||||
vector_index.upsert.assert_called_once()
|
||||
chunks_arg = vector_index.upsert.call_args.args[0]
|
||||
assert len(chunks_arg) > 0
|
||||
|
||||
|
||||
def test_significant_change_reindexes_the_knowledge_base():
|
||||
"""A numeric/deontic change must delete the stale chunks and upsert new ones."""
|
||||
from app.application.perception.crawl_service import CrawlService
|
||||
|
||||
store = MockEventStore()
|
||||
event_id = hashlib.sha256(b"TEST-IDX-SIG").hexdigest()[:12]
|
||||
store.upsert({
|
||||
"id": event_id, "standard_code": "IDX-SIG", "source": "TEST",
|
||||
"title": "Test IDX-SIG", "summary": "", "impact_level": "medium",
|
||||
"published_at": "2026-01-01", "tags": [],
|
||||
"content_hash": "old-hash", "raw_text": "old body",
|
||||
})
|
||||
embedding_provider = MagicMock()
|
||||
embedding_provider.embed_texts.return_value = [[0.1] * 8]
|
||||
vector_index = MagicMock()
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.extract_structure.return_value = {}
|
||||
mock_pipeline.assess_impact.return_value = []
|
||||
mock_pipeline.compute_diff.return_value = {
|
||||
"changed_sections": [{"change_type": "modified", "numeric_changed": True, "deontic_changed": False}],
|
||||
"change_summary": "numeric change",
|
||||
}
|
||||
svc = CrawlService(
|
||||
crawlers={"TEST": _make_crawler([_make_raw_event("IDX-SIG")], full_text="new body with a number 20米")},
|
||||
event_store=store,
|
||||
llm_pipeline=mock_pipeline,
|
||||
retrieval_service=MagicMock(),
|
||||
notification_store=MockNotificationStore(),
|
||||
embedding_provider=embedding_provider,
|
||||
vector_index=vector_index,
|
||||
)
|
||||
list(svc.run_crawl())
|
||||
|
||||
vector_index.delete_by_document.assert_called_once_with(event_id)
|
||||
vector_index.upsert.assert_called_once()
|
||||
|
||||
|
||||
def test_cosmetic_only_change_does_not_touch_the_knowledge_base():
|
||||
"""A punctuation-only edit must not trigger embedding or a Milvus write."""
|
||||
from app.application.perception.crawl_service import CrawlService
|
||||
|
||||
store = MockEventStore()
|
||||
event_id = hashlib.sha256(b"TEST-IDX-COS").hexdigest()[:12]
|
||||
store.upsert({
|
||||
"id": event_id, "standard_code": "IDX-COS", "source": "TEST",
|
||||
"title": "Test IDX-COS", "summary": "", "impact_level": "low",
|
||||
"published_at": "2026-01-01", "tags": [],
|
||||
"content_hash": "old-hash", "raw_text": "old body.",
|
||||
})
|
||||
embedding_provider = MagicMock()
|
||||
vector_index = MagicMock()
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.extract_structure.return_value = {}
|
||||
mock_pipeline.assess_impact.return_value = []
|
||||
mock_pipeline.compute_diff.return_value = {
|
||||
"changed_sections": [{"change_type": "modified", "numeric_changed": False, "deontic_changed": False}],
|
||||
"change_summary": "cosmetic only",
|
||||
}
|
||||
svc = CrawlService(
|
||||
crawlers={"TEST": _make_crawler([_make_raw_event("IDX-COS")], full_text="old body")},
|
||||
event_store=store,
|
||||
llm_pipeline=mock_pipeline,
|
||||
retrieval_service=MagicMock(),
|
||||
notification_store=MockNotificationStore(),
|
||||
embedding_provider=embedding_provider,
|
||||
vector_index=vector_index,
|
||||
)
|
||||
list(svc.run_crawl())
|
||||
|
||||
embedding_provider.embed_texts.assert_not_called()
|
||||
vector_index.upsert.assert_not_called()
|
||||
|
||||
|
||||
def test_vector_index_failure_does_not_abort_the_crawl():
|
||||
"""A broken vector index must not stop the crawl or raise."""
|
||||
from app.application.perception.crawl_service import CrawlService
|
||||
|
||||
broken_vector_index = MagicMock()
|
||||
broken_vector_index.upsert.side_effect = RuntimeError("milvus unreachable")
|
||||
store = MockEventStore()
|
||||
mock_pipeline = MagicMock()
|
||||
mock_pipeline.extract_structure.return_value = {}
|
||||
mock_pipeline.assess_impact.return_value = []
|
||||
svc = CrawlService(
|
||||
crawlers={"TEST": _make_crawler([_make_raw_event("IDX-FAIL")], full_text="第一条 正文内容。")},
|
||||
event_store=store,
|
||||
llm_pipeline=mock_pipeline,
|
||||
retrieval_service=MagicMock(),
|
||||
notification_store=MockNotificationStore(),
|
||||
embedding_provider=MagicMock(embed_texts=MagicMock(return_value=[[0.1] * 8])),
|
||||
vector_index=broken_vector_index,
|
||||
)
|
||||
events = list(svc.run_crawl())
|
||||
|
||||
assert any(e.get("event") == "done" for e in events)
|
||||
assert store.get_by_standard_code("IDX-FAIL") is not None
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Tests for the notification store's per-user read-state contract.
|
||||
|
||||
These pin the core property that makes broadcast-to-everyone work without a
|
||||
subscription model: one notification row is shared by all users, and each
|
||||
user's read state is tracked independently against it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.infrastructure.perception.mock_notification_store import MockNotificationStore
|
||||
|
||||
|
||||
def _store_with_one_notification() -> MockNotificationStore:
|
||||
store = MockNotificationStore()
|
||||
store.create(
|
||||
event_id="evt-001",
|
||||
kind="new",
|
||||
title="《电动汽车安全要求》国家标准第三版正式发布",
|
||||
impact_level="high",
|
||||
summary=None,
|
||||
)
|
||||
return store
|
||||
|
||||
|
||||
def test_a_new_notification_is_unread_for_everyone():
|
||||
"""Nobody has read it yet, so unread_count is 1 for any user."""
|
||||
store = _store_with_one_notification()
|
||||
assert store.unread_count("user-a") == 1
|
||||
assert store.unread_count("user-b") == 1
|
||||
|
||||
|
||||
def test_mark_all_read_zeroes_the_count_for_that_user():
|
||||
"""Reading clears the count for the user who read it."""
|
||||
store = _store_with_one_notification()
|
||||
marked = store.mark_all_read("user-a")
|
||||
assert marked == 1
|
||||
assert store.unread_count("user-a") == 0
|
||||
|
||||
|
||||
def test_one_users_read_state_does_not_affect_another():
|
||||
"""The whole point of read receipts over per-user fan-out: independence."""
|
||||
store = _store_with_one_notification()
|
||||
store.mark_all_read("user-a")
|
||||
assert store.unread_count("user-a") == 0
|
||||
assert store.unread_count("user-b") == 1
|
||||
|
||||
|
||||
def test_list_for_user_reports_the_read_flag_correctly():
|
||||
"""The list endpoint must reflect this user's own read state per item."""
|
||||
store = _store_with_one_notification()
|
||||
store.mark_all_read("user-a")
|
||||
|
||||
items_a = store.list_for_user("user-a")
|
||||
items_b = store.list_for_user("user-b")
|
||||
|
||||
assert len(items_a) == 1
|
||||
assert items_a[0]["read"] is True
|
||||
assert items_b[0]["read"] is False
|
||||
|
||||
|
||||
def test_list_for_user_orders_newest_first():
|
||||
"""Notifications appear most-recent-first regardless of creation order."""
|
||||
store = MockNotificationStore()
|
||||
store.create(event_id="evt-1", kind="new", title="first", impact_level="low", summary=None)
|
||||
store.create(event_id="evt-2", kind="new", title="second", impact_level="low", summary=None)
|
||||
|
||||
items = store.list_for_user("user-a")
|
||||
|
||||
assert [item["title"] for item in items] == ["second", "first"]
|
||||
|
||||
|
||||
def test_mark_all_read_is_a_no_op_on_an_empty_feed():
|
||||
"""Reading an empty feed must not raise and reports zero marked."""
|
||||
store = MockNotificationStore()
|
||||
assert store.mark_all_read("user-a") == 0
|
||||
|
||||
|
||||
def test_mark_all_read_does_not_recount_already_read_notifications():
|
||||
"""Calling mark_all_read twice must not double-count as newly marked."""
|
||||
store = _store_with_one_notification()
|
||||
first = store.mark_all_read("user-a")
|
||||
second = store.mark_all_read("user-a")
|
||||
assert first == 1
|
||||
assert second == 0
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Tests for the notification API routes.
|
||||
|
||||
Follows the same direct-call convention as backend/tests/mcp/test_mcp_status.py:
|
||||
patch the bootstrap singleton getter where the route module imports it, then
|
||||
call the async route function directly with asyncio.run rather than standing
|
||||
up a FastAPI TestClient — this repo has no existing TestClient harness, and
|
||||
these route handlers are thin enough that exercising them directly tests the
|
||||
same logic without inventing a new testing convention for two pass-through
|
||||
endpoints.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.domain.auth.models import UserClaims, UserRole
|
||||
from app.infrastructure.perception.mock_notification_store import MockNotificationStore
|
||||
|
||||
|
||||
def _user(user_id: str) -> UserClaims:
|
||||
return UserClaims(user_id=user_id, username=user_id, role=UserRole.READONLY)
|
||||
|
||||
|
||||
def _list_notifications(store, user_id: str, limit: int = 20) -> dict:
|
||||
from app.api.routes.perception import list_notifications
|
||||
|
||||
with patch("app.api.routes.perception.get_notification_store", return_value=store):
|
||||
return asyncio.run(list_notifications(limit=limit, current_user=_user(user_id)))
|
||||
|
||||
|
||||
def _mark_read(store, user_id: str) -> dict:
|
||||
from app.api.routes.perception import mark_notifications_read
|
||||
|
||||
with patch("app.api.routes.perception.get_notification_store", return_value=store):
|
||||
return asyncio.run(mark_notifications_read(current_user=_user(user_id)))
|
||||
|
||||
|
||||
def test_a_fresh_user_sees_the_notification_as_unread():
|
||||
"""GET .../notifications reports the caller's own unread_count."""
|
||||
store = MockNotificationStore()
|
||||
store.create(event_id="evt-1", kind="new", title="新法规发布", impact_level="high", summary=None)
|
||||
|
||||
result = _list_notifications(store, "user-a")
|
||||
|
||||
assert result["unread_count"] == 1
|
||||
assert len(result["items"]) == 1
|
||||
assert result["items"][0]["read"] is False
|
||||
|
||||
|
||||
def test_mark_read_zeroes_a_subsequent_get():
|
||||
"""POST .../read must clear unread_count for the next GET by the same user."""
|
||||
store = MockNotificationStore()
|
||||
store.create(event_id="evt-1", kind="new", title="新法规发布", impact_level="high", summary=None)
|
||||
|
||||
marked = _mark_read(store, "user-a")
|
||||
result = _list_notifications(store, "user-a")
|
||||
|
||||
assert marked == {"marked": 1}
|
||||
assert result["unread_count"] == 0
|
||||
assert result["items"][0]["read"] is True
|
||||
|
||||
|
||||
def test_mark_read_for_one_user_does_not_affect_another():
|
||||
"""Read state is per-user — the whole point of the read-receipt design."""
|
||||
store = MockNotificationStore()
|
||||
store.create(event_id="evt-1", kind="new", title="新法规发布", impact_level="high", summary=None)
|
||||
|
||||
_mark_read(store, "user-a")
|
||||
result_b = _list_notifications(store, "user-b")
|
||||
|
||||
assert result_b["unread_count"] == 1
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Tests for the scheduled crawl Celery task.
|
||||
|
||||
These pin the draining contract: the task must consume every item from
|
||||
CrawlService.run_crawl(), tally per-source errors without raising on them, and
|
||||
let a genuine whole-crawl exception propagate rather than swallowing it.
|
||||
|
||||
Patches target app.shared.bootstrap.get_crawl_service — not
|
||||
perception_tasks.get_crawl_service — because the task imports it inside its
|
||||
own function body (see perception_tasks.py's docstring for why), so there is
|
||||
no module-level name in perception_tasks to intercept.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _fake_crawl_service(events):
|
||||
"""Build a fake whose run_crawl() yields the given fixed event sequence."""
|
||||
service = MagicMock()
|
||||
service.run_crawl.return_value = iter(events)
|
||||
return service
|
||||
|
||||
|
||||
def test_task_drains_generator_and_summarizes_errors():
|
||||
"""One source error among two must not stop the run or raise."""
|
||||
events = [
|
||||
{"event": "progress", "data": {"source": "CATARC", "stage": "fetching"}},
|
||||
{"event": "error", "data": {"source": "CATARC", "message": "timeout"}},
|
||||
{"event": "progress", "data": {"source": "EUR-Lex", "stage": "fetching"}},
|
||||
{"event": "done", "data": {"total_new": 2, "total_updated": 1}},
|
||||
]
|
||||
with patch(
|
||||
"app.shared.bootstrap.get_crawl_service",
|
||||
return_value=_fake_crawl_service(events),
|
||||
):
|
||||
from app.infrastructure.tasks.perception_tasks import crawl_regulations_task
|
||||
result = crawl_regulations_task()
|
||||
|
||||
assert result == {"new": 2, "updated": 1, "source_errors": 1}
|
||||
|
||||
|
||||
def test_task_reports_zero_errors_on_a_clean_run():
|
||||
"""A run with no source errors must report source_errors: 0."""
|
||||
events = [
|
||||
{"event": "progress", "data": {"source": "CATARC", "stage": "fetching"}},
|
||||
{"event": "done", "data": {"total_new": 0, "total_updated": 0}},
|
||||
]
|
||||
with patch(
|
||||
"app.shared.bootstrap.get_crawl_service",
|
||||
return_value=_fake_crawl_service(events),
|
||||
):
|
||||
from app.infrastructure.tasks.perception_tasks import crawl_regulations_task
|
||||
result = crawl_regulations_task()
|
||||
|
||||
assert result == {"new": 0, "updated": 0, "source_errors": 0}
|
||||
|
||||
|
||||
def test_whole_crawl_exception_is_not_swallowed():
|
||||
"""A failure below run_crawl's own error handling must propagate.
|
||||
|
||||
Per-source failures are already handled inside run_crawl and never raise;
|
||||
an exception escaping the generator entirely means something unexpected
|
||||
broke, and Celery's own failure handling — not a silent catch here — is
|
||||
the intended backstop.
|
||||
"""
|
||||
broken_service = MagicMock()
|
||||
broken_service.run_crawl.side_effect = RuntimeError("event store unreachable")
|
||||
|
||||
with patch(
|
||||
"app.shared.bootstrap.get_crawl_service",
|
||||
return_value=broken_service,
|
||||
):
|
||||
from app.infrastructure.tasks.perception_tasks import crawl_regulations_task
|
||||
with pytest.raises(RuntimeError, match="event store unreachable"):
|
||||
crawl_regulations_task()
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Tests for the deterministic regulation differ.
|
||||
|
||||
These tests pin the behaviour that the previous cosine-similarity implementation
|
||||
could not deliver: real regulatory edits (numeric limits, deontic modals) must be
|
||||
detected, and inserting a paragraph must not report unrelated paragraphs as
|
||||
changed. Everything here runs offline — no LLM, no network, no embeddings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.infrastructure.perception.regulation_differ import RegulationDiffer
|
||||
|
||||
|
||||
def _differ() -> RegulationDiffer:
|
||||
"""Build a differ with an explicit ratio so tests never depend on .env."""
|
||||
return RegulationDiffer(min_change_ratio=0.02)
|
||||
|
||||
|
||||
def test_identical_documents_report_no_changes():
|
||||
"""An unchanged regulation must produce an empty change list."""
|
||||
text = "第一条 车辆制动系统应在时速50公里条件下于30米内完全停止。\n第二条 驾驶员座椅面料的阻燃性能应符合附录B的规定。"
|
||||
assert _differ().diff(text, text) == []
|
||||
|
||||
|
||||
def test_numeric_tightening_is_detected():
|
||||
"""A changed numeric limit is the case cosine similarity scored 0.9153 and missed."""
|
||||
old = "第一条 车辆制动系统应在时速50公里条件下于30米内完全停止。"
|
||||
new = "第一条 车辆制动系统应在时速50公里条件下于20米内完全停止。"
|
||||
|
||||
changes = _differ().diff(old, new)
|
||||
|
||||
assert len(changes) == 1
|
||||
change = changes[0]
|
||||
assert change.change_type == "modified"
|
||||
assert change.numeric_changed is True
|
||||
assert change.needs_llm is True
|
||||
|
||||
|
||||
def test_deontic_relaxation_is_detected():
|
||||
"""Weakening 应当 to 宜 changes the legal force and must be flagged."""
|
||||
old = "第三条 生产企业应当每年开展一次安全评估。"
|
||||
new = "第三条 生产企业宜每年开展一次安全评估。"
|
||||
|
||||
changes = _differ().diff(old, new)
|
||||
|
||||
assert len(changes) == 1
|
||||
assert changes[0].deontic_changed is True
|
||||
assert changes[0].needs_llm is True
|
||||
|
||||
|
||||
def test_prohibition_removal_is_detected():
|
||||
"""Dropping 不得 flips a prohibition into a permission."""
|
||||
old = "第四条 车辆不得使用未经认证的电池组。"
|
||||
new = "第四条 车辆可以使用经备案的电池组。"
|
||||
|
||||
changes = _differ().diff(old, new)
|
||||
|
||||
assert len(changes) == 1
|
||||
assert changes[0].deontic_changed is True
|
||||
|
||||
|
||||
def test_inserted_paragraph_does_not_shift_the_rest():
|
||||
"""Regression test for positional alignment.
|
||||
|
||||
The previous implementation compared old[i] to new[i], so inserting one
|
||||
paragraph at the top reported every following paragraph as changed. With
|
||||
sequence alignment only the inserted paragraph is new.
|
||||
"""
|
||||
old = "\n".join([
|
||||
"第一条 本标准规定了车辆制动系统的技术要求。",
|
||||
"第二条 车辆制动系统应在时速50公里条件下于30米内完全停止。",
|
||||
"第三条 驾驶员座椅面料的阻燃性能应符合附录B的规定。",
|
||||
])
|
||||
new = "\n".join([
|
||||
"第零条 本标准适用于所有M1类车辆。",
|
||||
"第一条 本标准规定了车辆制动系统的技术要求。",
|
||||
"第二条 车辆制动系统应在时速50公里条件下于30米内完全停止。",
|
||||
"第三条 驾驶员座椅面料的阻燃性能应符合附录B的规定。",
|
||||
])
|
||||
|
||||
changes = _differ().diff(old, new)
|
||||
|
||||
assert len(changes) == 1, f"expected only the inserted paragraph, got {changes}"
|
||||
assert changes[0].change_type == "added"
|
||||
assert "第零条" in changes[0].new_text
|
||||
assert changes[0].old_text == ""
|
||||
|
||||
|
||||
def test_deleted_paragraph_is_reported_once():
|
||||
"""Removing a provision yields exactly one 'removed' record."""
|
||||
old = "\n".join([
|
||||
"第一条 本标准规定了车辆制动系统的技术要求。",
|
||||
"第二条 车辆制动系统应在时速50公里条件下于30米内完全停止。",
|
||||
"第三条 驾驶员座椅面料的阻燃性能应符合附录B的规定。",
|
||||
])
|
||||
new = "\n".join([
|
||||
"第一条 本标准规定了车辆制动系统的技术要求。",
|
||||
"第三条 驾驶员座椅面料的阻燃性能应符合附录B的规定。",
|
||||
])
|
||||
|
||||
changes = _differ().diff(old, new)
|
||||
|
||||
assert len(changes) == 1
|
||||
assert changes[0].change_type == "removed"
|
||||
assert "第二条" in changes[0].old_text
|
||||
assert changes[0].new_text == ""
|
||||
assert changes[0].needs_llm is True
|
||||
|
||||
|
||||
def test_trivial_edit_is_not_sent_to_the_llm():
|
||||
"""A cosmetic edit with no number or modal change must not cost an LLM call."""
|
||||
old = "第五条 本标准由全国汽车标准化技术委员会归口管理。"
|
||||
new = "第五条 本标准由全国汽车标准化技术委员会归口管理"
|
||||
|
||||
changes = _differ().diff(old, new)
|
||||
|
||||
for change in changes:
|
||||
assert change.numeric_changed is False
|
||||
assert change.deontic_changed is False
|
||||
assert change.needs_llm is False, f"trivial edit was gated to the LLM: {change}"
|
||||
|
||||
|
||||
def test_large_rewrite_is_sent_to_the_llm():
|
||||
"""A substantial rewrite clears the change-ratio gate even without numbers or modals."""
|
||||
old = "第六条 本标准参考了国际同类标准的相关内容。"
|
||||
new = "第六条 本条款描述了完全不同的主题内容,涉及整车认证流程与型式试验的组织安排。"
|
||||
|
||||
changes = _differ().diff(old, new)
|
||||
|
||||
assert len(changes) == 1
|
||||
assert changes[0].change_ratio >= 0.02
|
||||
assert changes[0].needs_llm is True
|
||||
|
||||
|
||||
def test_empty_old_text_yields_no_changes():
|
||||
"""A first crawl has no baseline, so there is nothing to diff."""
|
||||
assert _differ().diff("", "第一条 任意内容。") == []
|
||||
|
||||
|
||||
def test_unchanged_paragraphs_are_never_returned():
|
||||
"""Only changed paragraphs appear; equal ones are dropped."""
|
||||
old = "\n".join([
|
||||
"第一条 保持不变的条款。",
|
||||
"第二条 车辆制动距离不得超过30米。",
|
||||
"第三条 另一条保持不变的条款。",
|
||||
])
|
||||
new = "\n".join([
|
||||
"第一条 保持不变的条款。",
|
||||
"第二条 车辆制动距离不得超过20米。",
|
||||
"第三条 另一条保持不变的条款。",
|
||||
])
|
||||
|
||||
changes = _differ().diff(old, new)
|
||||
|
||||
assert len(changes) == 1
|
||||
assert changes[0].numeric_changed is True
|
||||
assert "第二条" in changes[0].old_text
|
||||
Reference in New Issue
Block a user