"""Behavioral contracts for one-news packages and reusable keyword indexes.""" from datetime import UTC, datetime, timedelta from types import SimpleNamespace as N import pytest def test_storage_is_additive_and_membership_is_many_to_many(trading): from alphapulse.news_research.models import TABLES, Package, Membership assert all(t.name.startswith("news_research_") for t in TABLES) assert {c.name for c in Membership.__table__.primary_key} == { "event_id", "package_id", } assert any( {c.name for c in constraint.columns} == {"news_id"} for constraint in Package.__table__.constraints if constraint.__class__.__name__ == "UniqueConstraint" ) def test_keywords_require_original_aliases_and_literal_evidence(trading): from alphapulse.news_research.contracts import IndexResult from alphapulse.news_research.service import validate_keywords source = {"id": 7, "content": "Binance reported a security incident."} output = IndexResult( keywords=[ { "canonical": "币安", "aliases": ["Binance"], "quote": "Binance reported a security incident.", } ] ) validate_keywords(output, source) output.keywords[0].aliases = ["Invented"] with pytest.raises(ValueError, match="别名"): validate_keywords(output, source) output.keywords[0].aliases = ["Binance"] output.keywords[0].quote = "Binance was hacked" with pytest.raises(ValueError, match="原文"): validate_keywords(output, source) @pytest.mark.parametrize("canonical", ["币安", "Binance", "MI455X"]) def test_chinese_assignment_preserves_indexed_names_and_english_evidence(trading, canonical): from alphapulse.news_research.contracts import Assignment from alphapulse.news_research.service import validate_assignment quote = "Binance announced scheduled maintenance for MI455X." source = {"id": 7, "content": quote} keywords = [{"canonical": canonical, "aliases": ["Binance", "MI455X"], "quote": quote}] result = Assignment(targets=[{ "event_id": None, "new_key": "maintenance", "title": "币安计划维护公告", "reason": "原文宣布计划维护,独立建立事件。", "quote": quote, "keyword_canonicals": [canonical], "change_kind": "new_fact", "urgent": False, }]) validate_assignment(result, source, set(), keywords) assert result.targets[0].keyword_canonicals == [canonical] result.targets[0].quote = "币安宣布计划维护。" with pytest.raises(ValueError, match="原文"): validate_assignment(result, source, set(), keywords) result.targets[0].quote = quote result.targets[0].keyword_canonicals = ["改写的关键词"] with pytest.raises(ValueError, match="逐字"): validate_assignment(result, source, set(), keywords) def test_one_package_can_be_assigned_to_multiple_events_without_splitting(trading): from alphapulse.news_research.contracts import Assignment from alphapulse.news_research.service import validate_assignment base = { "new_key": None, "title": "Incident", "reason": "Follow-up", "quote": "Original", "keyword_canonicals": ["Original"], "change_kind": "new_fact", "urgent": False, } result = Assignment(targets=[{**base, "event_id": 1}, {**base, "event_id": 2}]) keywords = [{"canonical": "Original", "aliases": ["Original"]}] validate_assignment(result, {"id": 8, "content": "Original"}, {1, 2}, keywords) with pytest.raises(ValueError, match="未知事件"): validate_assignment(result, {"id": 8, "content": "Original"}, {1}, keywords) with pytest.raises(ValueError, match="重复"): Assignment(targets=[{**base, "event_id": 1}, {**base, "event_id": 1}]) def test_event_refresh_keeps_hard_deadline_and_urgency(trading): from alphapulse.news_research.service import dirty now = datetime.now(UTC) event = N( revision=2, state="done", dirty_since=now - timedelta(minutes=14), due_at=now, error="Old", ) dirty(event) assert event.due_at <= event.dirty_since + timedelta(minutes=15) assert event.revision == 3 and event.state == "pending" and event.error is None dirty(event, True) immediate = event.due_at dirty(event) assert event.due_at <= datetime.now(UTC) and event.due_at >= immediate @pytest.mark.parametrize("urgent", [False, True]) def test_new_event_waits_for_aggregation_unless_urgent(trading, urgent): from alphapulse.news_research.service import dirty event = N( revision=0, state="pending", dirty_since=None, due_at=datetime.now(UTC) - timedelta(seconds=1), ) dirty(event, urgent) assert event.due_at - event.dirty_since == timedelta(minutes=0 if urgent else 5) @pytest.mark.parametrize("state", ["failed", "pending", "done"]) def test_event_retry_preserves_content_revision_until_content_changes(trading, state): import asyncio from unittest.mock import AsyncMock from alphapulse.news_research.api import retry_event event = N( revision=7, state=state, dirty_since=None, due_at=datetime.now(UTC), error="Previous failure", ) session = N(get=AsyncMock(return_value=event), commit=AsyncMock()) asyncio.run(retry_event(1, session)) assert event.revision == (8 if state == "done" else 7) assert event.state == "pending" and event.error is None assert event.due_at <= datetime.now(UTC) @pytest.mark.parametrize("revision", [None, 2]) def test_news_detail_does_not_attach_old_keywords_to_updated_original( trading, revision ): import asyncio from unittest.mock import AsyncMock from alphapulse.news_research.api import news_package news = N(revision_no=2, content_hash="new") package = N(content_hash="old", keywords=[{"canonical": "Old fact"}]) session = N( get=AsyncMock(return_value=news), scalar=AsyncMock(return_value=package) ) result = asyncio.run(news_package(1, session, revision_no=revision)) assert result.data == [] def test_two_stage_contract_rejects_package_summaries(trading): from alphapulse.news_research.contracts import ( IndexResult, Assignment, ResearchResult, ) assert set(IndexResult.model_fields) == {"keywords"} assert set(Assignment.model_fields) == {"targets"} assert "references" in ResearchResult.model_fields assert "package_correction" not in ResearchResult.model_fields def test_timeline_distinguishes_member_progress_from_external_background(trading): from alphapulse.news_research.service import validate_timeline point = N( package_ids=[17], evidence_refs=[{"article_id": -3, "quote": "External source"}] ) result = N(timeline_points=[point]) with pytest.raises(ValueError, match="对应新闻包"): validate_timeline(result, {17: 463205}) point.package_ids = [] validate_timeline(result, {17: 463205}) point.evidence_refs = [{"article_id": 463205, "quote": "Member source"}] with pytest.raises(ValueError, match="标注"): validate_timeline(result, {17: 463205}) point.package_ids = [17] validate_timeline(result, {17: 463205}) def test_retry_reuses_original_snapshots_and_keeps_the_evidence_auditable(trading): import asyncio from alphapulse.news_research.service import Research research = Research(event={"id": 8}) snapshot = {"article_id": -1, "news_id": 7, "content": "Original evidence"} research.restore( [{"tool": "read_news", "arguments": {"id": 7}, "result": snapshot}] ) result = asyncio.run(research.tool("read_news", {"id": 7})) assert result == snapshot assert research.counts["read"] == 0 assert research.records[0]["reused"] is True assert research.records[0]["result"]["article_id"] == -1 @pytest.mark.parametrize("tool", ["search_packages", "search_events"]) @pytest.mark.parametrize("cached", [[], [{"id": 1}]]) def test_retry_refreshes_local_search_with_current_vocabulary( trading, monkeypatch, tool, cached ): import asyncio from unittest.mock import AsyncMock from alphapulse.news_research import service session = AsyncMock() session.__aenter__.return_value = session session.execute.return_value = [ (N(id=2, news_id=12, content_hash="current", keywords=[]), "current") ] session.scalars.return_value = [N(id=2, title="Fresh event", tags=["binance"])] monkeypatch.setattr(service, "SessionLocal", lambda: session) reader = service.Research(event={"id": 8}) reader.vocabulary = { "revision": 2, "entries": [{"canonical": "币安", "aliases": ["Binance", "BNC"]}], } arguments = {"query": "BNC", "keywords": []} reader.restore([{"tool": tool, "arguments": arguments, "result": cached}]) result = asyncio.run(reader.tool(tool, arguments)) assert [row["id"] for row in result] == [2] assert reader.counts["search"] == 1 assert len(reader.records) == 1 and not reader.records[0].get("reused") def test_settings_contract_rejects_third_stage_and_unknown_efforts(trading): from alphapulse.news_research.api import SettingsInput model = { "provider_id": 1, "model": "selected", "skill_id": 1, "reasoning_effort": None, } assert SettingsInput(index=model, research=model).concurrency == 3 with pytest.raises(ValueError, match="extra_forbidden"): SettingsInput(index=model, research=model, outlook=model) with pytest.raises(ValueError): SettingsInput( index={**model, "reasoning_effort": "unsupported"}, research=model ) @pytest.mark.parametrize("tool", ["read_news", "read_package"]) def test_compressed_members_read_frozen_positive_sources_and_reject_old_cache( trading, tool ): import asyncio from alphapulse.news_research.service import Research, validate_timeline research = Research(event={"id": 8}) source = {"id": 101, "news_id": 101, "content": "Frozen original"} research.register_members( {"articles": [source], "packages": [{"id": 10, "news_id": 101}]} ) research.provided_packages = {10: {"id": 10, "news_id": 101}} research.restore( [ { "tool": "read_news", "arguments": {"id": 101}, "result": {"article_id": -1, "news_id": 101, "content": "Old cache"}, } ] ) assert not research.provided_news and -1 not in research.sources result = asyncio.run( research.tool(tool, {"id": 101 if tool == "read_news" else 10}) ) assert result["article_id"] == 101 and result["content"] == "Frozen original" validate_timeline( N( timeline_points=[ N( package_ids=[10], evidence_refs=[ {"article_id": result["article_id"], "quote": "Frozen original"} ], ) ] ), {10: 101}, ) repeated = asyncio.run(research.tool("read_news", {"id": 101})) assert repeated["status"] == "already_provided" and research.counts["read"] == 1 def test_assignment_gate_serializes_only_assignment_and_releases_after_failure( trading, monkeypatch ): import asyncio from alphapulse.news_research import service class Session: async def __aenter__(self): return self async def __aexit__(self, *args): pass async def get(self, model, identifier): return N(state="index_pending" if identifier == 3 else "assign_pending") async def scenario(): monkeypatch.setattr(service, "assignment_lock", asyncio.Lock()) entered, release = asyncio.Event(), asyncio.Event() calls = [] async def process(identifier, config, stage): calls.append((identifier, stage)) if identifier == 1: entered.set() await release.wait() raise ValueError("Failed model") monkeypatch.setattr(service, "SessionLocal", Session) monkeypatch.setattr(service, "_process_package", process) first = asyncio.create_task(service.process_package(1, {})) await entered.wait() second = asyncio.create_task(service.process_package(2, {})) await service.process_package(3, {}) assert calls == [(1, "assign"), (3, "index")] release.set() await asyncio.gather(first, second, return_exceptions=True) assert calls[-1] == (2, "assign") and not service.assignment_lock.locked() asyncio.run(scenario()) def test_digest_tags_are_selected_per_membership(trading): from alphapulse.news_research.service import selected_tags keywords = [ {"canonical": "中国人民银行", "aliases": ["央行"]}, {"canonical": "美国民主党", "aliases": ["民主党"]}, {"canonical": "股票回购增持再贷款", "aliases": ["股票回购增持再贷款"]}, {"canonical": "日经225指数", "aliases": ["日经225指数"]}, ] assert selected_tags(keywords, ["中国人民银行", "股票回购增持再贷款"]) == { "中国人民银行", "央行", "股票回购增持再贷款", } assert selected_tags(keywords, ["美国民主党"]) == {"美国民主党", "民主党"} assert len(keywords) == 4 @pytest.mark.parametrize("selection", [[], ["Invented"], ["AMD", "AMD"], ["amd"]]) def test_assignment_rejects_empty_unknown_duplicate_or_rewritten_tags(trading, selection): from alphapulse.news_research.contracts import Assignment from alphapulse.news_research.service import validate_assignment with pytest.raises(ValueError): result = Assignment(targets=[{ "event_id": 1, "new_key": None, "title": "AMD", "reason": "Same event", "quote": "AMD shipped", "keyword_canonicals": selection, "change_kind": "new_fact", "urgent": False, }]) validate_assignment(result, {"id": 1, "content": "AMD shipped"}, {1}, [{"canonical": "AMD", "aliases": ["AMD"]}]) def test_assignment_keeps_entity_aliases_outside_scope_quote(trading): from alphapulse.news_research.contracts import Assignment from alphapulse.news_research.service import selected_tags, validate_assignment source = {"id": 1, "content": "高盛警示美股风险。研报作者为鲍比·莫拉维(Bobby Molavi)。"} keywords = [ {"canonical": "高盛", "aliases": ["高盛"]}, {"canonical": "鲍比·莫拉维", "aliases": ["鲍比·莫拉维", "Bobby Molavi"]}, {"canonical": "美国民主党", "aliases": ["民主党"]}, ] selected = ["高盛", "鲍比·莫拉维"] result = Assignment(targets=[{ "event_id": 1, "new_key": None, "title": "高盛警示美股风险", "reason": "Same report", "quote": "高盛警示美股风险。", "keyword_canonicals": selected, "change_kind": "new_fact", "urgent": False, }]) validate_assignment(result, source, {1}, keywords) assert selected_tags(keywords, selected) == {"高盛", "鲍比·莫拉维", "bobby molavi"} def test_event_tags_are_rebuilt_instead_of_retaining_removed_noise(trading): import asyncio from unittest.mock import AsyncMock from alphapulse.news_research.service import refresh_event_tags event = N(id=1, tags=["民主党", "旧事件"]) session = N(execute=AsyncMock(return_value=[( N(package_id=1, evidence="央行下调利率"), N(rules={"membership_tags": {"1": ["中国人民银行", "央行"]}}), )])) asyncio.run(refresh_event_tags(session, event)) assert event.tags == ["中国人民银行", "央行"] session.execute.return_value = [] asyncio.run(refresh_event_tags(session, event)) assert event.tags == [] def test_event_rebuild_uses_successful_snapshot_not_mutable_package_index(trading): import asyncio from unittest.mock import AsyncMock from sqlalchemy.dialects import postgresql from alphapulse.news_research.service import refresh_event_tags session = N(execute=AsyncMock(return_value=[( N(package_id=1, evidence="公司遭到黑客攻击。"), N(rules={"membership_tags": {"1": ["旧公司", "黑客攻击"]}}), )])) event = N(id=1, tags=["英伟达"]) asyncio.run(refresh_event_tags(session, event)) assert event.tags == ["旧公司", "黑客攻击"] statement = str(session.execute.call_args.args[0].compile( dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True})) assert "news_research_packages" not in statement assert "news_research_attempts.state = 'done'" in statement assert "max(news_research_attempts.id)" in statement def test_legacy_assignment_retains_its_original_keyword_snapshot(trading): import asyncio from unittest.mock import AsyncMock from alphapulse.news_research.service import refresh_event_tags event = N(id=1, tags=[]) session = N(execute=AsyncMock(return_value=[( N(package_id=1, evidence="公司遭到黑客攻击。"), N(rules={}, input={"keywords": [ {"canonical": "旧公司", "aliases": ["OldCorp"]}, ]}), )])) asyncio.run(refresh_event_tags(session, event)) assert event.tags == ["oldcorp", "旧公司"] @pytest.mark.parametrize("snapshot", [None, N(rules={}, input={}), N(rules={"membership_tags": {"2": ["wrong"]}})]) def test_event_rebuild_fails_without_owning_assignment_snapshot(trading, snapshot): import asyncio from unittest.mock import AsyncMock from alphapulse.news_research.service import refresh_event_tags event = N(id=1, tags=["preserved"]) session = N(execute=AsyncMock(return_value=[(N(package_id=1), snapshot)])) with pytest.raises(ValueError, match="快照"): asyncio.run(refresh_event_tags(session, event)) assert event.tags == ["preserved"] @pytest.mark.parametrize("offset", ["+03:30", "+08:00", "-05:00"]) @pytest.mark.parametrize("precision,day", [("day", 4), ("month", 1)]) def test_coarse_timeline_dates_are_consistent_calendar_markers(trading, offset, precision, day): from alphapulse.news_research.contracts import Point p = Point(occurred_at=f"2026-11-04T13:25:00{offset}", time_precision=precision, planned_at=f"2026-12-04T13:25:00{offset}", planned_precision=precision, time_text="4日", reported_at=None, description="公告", verification="supported", evidence_refs=[{"article_id": 1, "quote": "4日公告"}], package_ids=[1]) assert p.occurred_at == datetime(2026, 11, day, tzinfo=UTC) assert p.planned_at == datetime(2026, 12, day, tzinfo=UTC) assert p.time_text == "4日" def test_minute_precision_retains_real_instant_and_unknown_stays_null(trading): from alphapulse.news_research.contracts import Point p = Point(occurred_at="2026-11-04T13:25:00+03:30", time_precision="minute", time_text="13:25", reported_at=None, description="公告", verification="supported", evidence_refs=[{"article_id": 1, "quote": "13:25公告"}], package_ids=[1]) assert p.occurred_at.isoformat() == "2026-11-04T13:25:00+03:30" p = Point(**{**p.model_dump(), "occurred_at": None, "time_precision": "unknown"}) assert p.occurred_at is None def test_research_scope_uses_resolved_assignment_and_keeps_full_selected_records(trading): from copy import deepcopy from alphapulse.news_research.service import research_scope keywords = [ {"canonical": "高盛", "aliases": ["高盛"], "quote": "高盛警示风险"}, {"canonical": "鲍比·莫拉维", "aliases": ["Bobby Molavi"], "quote": "作者Bobby Molavi"}, {"canonical": "美国民主党", "aliases": ["民主党"], "quote": "民主党选情更新"}, ] main = {"title": "风险报告", "quote": "高盛警示风险", "reason": "同一报告", "keyword_canonicals": ["高盛", "鲍比·莫拉维"]} other = {"title": "选情更新", "quote": "民主党选情更新", "reason": "独立事项", "keyword_canonicals": ["美国民主党"]} attempt = N(input={"keywords": keywords}, rules={"membership_targets": {"1": main, "2": other}}) before = deepcopy(attempt.input) scope = research_scope(N(package_id=10, event_id=1), attempt) assert scope["keywords"] == keywords[:2] assert scope["keyword_canonicals"] == ["高盛", "鲍比·莫拉维"] assert scope["scope_selection"] == "assignment" assert scope["other_scopes"] == [{"title": "选情更新", "scope_quote": "民主党选情更新", "reason": "独立事项"}] assert research_scope(N(package_id=10, event_id=2), attempt)["keywords"] == keywords[2:] assert attempt.input == before @pytest.mark.parametrize("existing", [False, True]) def test_research_recovers_unambiguous_selected_tags_from_older_assignment(trading, existing): from alphapulse.news_research.service import research_scope keyword = {"canonical": "触雷", "aliases": ["水雷"]} target = {"event_id": 1 if existing else None, "quote": "四艘油轮触雷", "reason": "具体事故", "keyword_canonicals": ["触雷"]} other = {"event_id": None, "title": "船舶拦截", "quote": "另有两艘船被拦截", "reason": "独立行动"} attempt = N(input={"keywords": [keyword, {"canonical": "拦截", "aliases": ["拦截"]}]}, rules={}, output={"targets": [target, other]}) scope = research_scope(N(package_id=10, event_id=1, evidence=target["quote"], reason=target["reason"]), attempt) assert scope["keywords"] == [keyword] assert scope["other_scopes"][0]["scope_quote"] == "另有两艘船被拦截" @pytest.mark.parametrize("targets", [[], [ {"event_id": None, "quote": "同一引文", "reason": "同一理由", "keyword_canonicals": ["甲"]}, {"event_id": None, "quote": "同一引文", "reason": "同一理由", "keyword_canonicals": ["乙"]}, ]]) def test_research_legacy_does_not_invent_a_selected_scope(trading, targets): from alphapulse.news_research.service import research_scope keywords = [{"canonical": "甲", "aliases": ["甲"]}, {"canonical": "乙", "aliases": ["乙"]}] scope = research_scope(N(package_id=10, event_id=1, evidence="同一引文", reason="同一理由"), N(input={"keywords": keywords}, rules={}, output={"targets": targets})) assert scope["scope_selection"] == "legacy_unscoped" assert scope["keyword_canonicals"] is None and scope["keywords"] == keywords assert scope["other_scopes"] == [] def test_legacy_research_retains_known_sibling_boundaries_without_inventing_tags(trading): from alphapulse.news_research.service import research_scope keywords = [{"canonical": "甲", "aliases": ["甲"]}] own = {"event_id": 1, "title": "事项甲", "quote": "原文甲", "reason": "主线甲"} other = {"event_id": 2, "title": "事项乙", "quote": "原文乙", "reason": "主线乙"} scope = research_scope(N(package_id=10, event_id=1), N(input={"keywords": keywords}, rules={}, output={"targets": [own, other]})) assert scope["scope_selection"] == "legacy_unscoped" and scope["keyword_canonicals"] is None assert scope["keywords"] == keywords assert scope["other_scopes"] == [{"title": "事项乙", "scope_quote": "原文乙", "reason": "主线乙"}] @pytest.mark.parametrize("attempt", [None, N(input={}, rules={}), N( input={"keywords": [{"canonical": "甲", "aliases": ["甲"]}]}, rules={"membership_targets": {"2": {}}}, )]) def test_research_fails_without_owning_assignment_snapshot(trading, attempt): from alphapulse.news_research.service import research_scope with pytest.raises(ValueError, match="快照"): research_scope(N(package_id=10, event_id=1), attempt) @pytest.mark.parametrize("tool,identifier", [("read_news", 101), ("read_package", 10)]) def test_provided_member_reads_are_audited_without_reading_or_spending_budget(trading, tool, identifier): import asyncio from alphapulse.news_research.service import Research reader = Research(event={"id": 1}) source = {"id": 101, "news_id": 101, "content": "完整原文"} payload = {"articles": [source], "packages": [{"id": 10, "news_id": 101}]} reader.register_members(payload) reader.provided_news = {101: 101} result = asyncio.run(reader.tool(tool, {"id": identifier})) assert result["status"] == "already_provided" and result["article_id"] == 101 assert "复用" in result["instruction"] and "独立补证查询合并同轮" in result["instruction"] assert "content" not in result and reader.counts["read"] == 0 assert reader.records == [{"tool": tool, "arguments": {"id": identifier}, "result": result}] retry = Research(event={"id": 1}) retry.register_members(payload) retry.restore(reader.records) assert not retry.provided_news and not retry.records reread = asyncio.run(retry.tool(tool, {"id": identifier})) assert reread["content"] == "完整原文" and retry.counts["read"] == 1 @pytest.mark.parametrize("needs_compaction", [False, True]) def test_long_event_compaction_uses_final_research_context(trading, monkeypatch, needs_compaction): import asyncio from unittest.mock import AsyncMock from alphapulse.news_research import service from alphapulse.news_research.contracts import RESEARCH_SKILL, ResearchResult from alphapulse.news_research.models import Version from alphapulse.news_research import pipeline provider = N(max_tokens=32000, context_window=250000) skill = pipeline.BASE + "\n" + RESEARCH_SKILL quote = "该公司披露事故调查及资金追踪的最新进展。" keywords = [{"canonical": f"因素{j}", "aliases": [f"Factors{j}"], "quote": quote * 4} for j in range(8)] target = {"title": "事故调查", "quote": quote, "reason": "同一事故的后续处置。", "keyword_canonicals": [k["canonical"] for k in keywords]} event = N(id=1, revision=1, title="事故调查", state="pending", result=None, due_at=datetime.now(UTC) - timedelta(seconds=1)) members, attempts, versions = [], [], [] for identifier in range(1, 41): news = N(id=identifier, title=quote, summary="", content_text=quote * 2000, content_hash=str(identifier), url="https://example.invalid/event", published_at=datetime.now(UTC)) members.append((N(id=identifier, news_id=identifier, state="done", content_hash=news.content_hash), N(event_id=1, package_id=identifier, evidence=quote, reason=target["reason"]), news)) attempts.append(N(package_id=identifier, content_hash=news.content_hash, input={"keywords": keywords}, rules={"membership_targets": {"1": target}})) extracts = [{"facts": "detail " * 400 if needs_compaction else "调查继续。", "evidence_refs": [{"article_id": p.id, "quote": quote}]} for p, _, _ in members] class Session: async def __aenter__(self): return self async def __aexit__(self, *args): pass def add(self, version): version.id = 1 versions.append(version) session = Session() session.get = AsyncMock(side_effect=lambda model, *args, **kwargs: versions[0] if model is Version else event) session.execute = AsyncMock(side_effect=[members, N(all=lambda: [(n.id, n.content_hash) for _, _, n in members])]) session.scalars = AsyncMock(return_value=attempts) session.scalar = AsyncMock(return_value=None) session.commit = AsyncMock() session.rollback = AsyncMock() monkeypatch.setattr(service, "SessionLocal", lambda: session) monkeypatch.setattr(service, "model_config", AsyncMock(return_value=(provider, skill, {}))) monkeypatch.setattr(service.vocab, "snapshot", AsyncMock(return_value={})) extract = AsyncMock(return_value=(extracts, [])) monkeypatch.setattr(service, "extract_articles", extract) # Quote and timeline validation are independent of this context-budget regression. monkeypatch.setattr(service, "validate_event", lambda *args: None) monkeypatch.setattr(service, "validate_timeline", lambda *args: None) async def consolidate(provider, rules, payload, schema): assert schema is pipeline.Extraction assert "当前事件及各成员归属边界" in rules and quote in rules assert "extract和consolidate生成的facts等叙述也必须用简体中文" in rules assert "time_text及取自原文的period_text逐字保留原语言和字符" in rules assert pipeline.fits_context(provider, rules, payload, schema) refs = [ref for part in payload["extractions"] for ref in part["evidence_refs"]] return pipeline.Extraction(facts="调查继续。", evidence_refs=refs), {"mock": True} complete = AsyncMock(side_effect=consolidate) monkeypatch.setattr(pipeline, "complete", complete) research_requests = [] async def research(reader, provider, rules, request, schema): assert rules == skill and schema is ResearchResult assert reader.fits(provider, rules, request, schema) assert request["articles"] == [] and len(request["packages"]) == 40 assert {ref["article_id"] for part in request["extractions"] for ref in part["evidence_refs"]} == set(range(1, 41)) research_requests.append(request) return N(id=1, title=event.title, references=[], model_dump=lambda **kwargs: {"id": 1}), {} monkeypatch.setattr(service.Research, "run", research) asyncio.run(service.process_event(1, {})) assert versions[0].state == "done", versions[0].error assert event.state == "done" and len(research_requests) == 1 assert bool(complete.await_count) == needs_compaction boundary_rules = extract.await_args.args[1] assert "extract和consolidate生成的facts等叙述也必须用简体中文" in boundary_rules compressed = {**versions[0].input, "articles": [], "extractions": extracts} reader = service.Research() assert not reader.fits(provider, skill, versions[0].input, ResearchResult) assert not reader.fits(provider, boundary_rules, compressed, ResearchResult) assert reader.fits(provider, skill, compressed, ResearchResult) != needs_compaction