update for mcp

This commit is contained in:
wangwei
2026-08-06 11:08:46 +08:00
parent 31bbf80aeb
commit b2feaeddb4
40 changed files with 1986 additions and 202 deletions
+349 -7
View File
@@ -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
+121 -34
View File
@@ -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