Fix official policy selection and persistent attachment gaps
Development CI / check-and-build (push) Successful in 2m55s
GitHub Git mirror / mirror (push) Successful in 6s
Development CI / deploy-development (push) Successful in 2m31s

Read the OFAC action body and use focused macro policy phrases, including national budgets, appropriations and reserve policies. Keep unknown image text as an explicit gap instead of treating it as complete or irrelevant.\n\nPreserve meeting attachment retries and derive pending/exhausted document counts from saved source cursors. Cover real selection negatives, positive macro boundaries and attachment failures with isolated regressions.
This commit is contained in:
eezd committed 2026-11-04 23:39:29 +08:00
1 parent 328f1e91a1
commit 80665905e5
7 files changed
+145 -11

No files matched your search

+10 -1
View File
@@ -188,6 +188,15 @@ async def list_sources(session: Annotated[AsyncSession, Depends(get_session)]) -
run_stats = {run.source_code: run.stats for run in latest_runs}
statuses = await redis.hgetall(STATUS_KEY)
db_size = await session.scalar(select(func.pg_database_size(func.current_database())))
attachment_stats = {}
for source in sources:
retries = (source.history_cursor or {}).get('attachment_retries', {})
pending = sum(value['attempts'] < 3 for value in retries.values())
attachment_stats[source.code] = {
'attachment_gaps': len(retries),
'attachment_retry_pending': pending,
'attachment_retry_exhausted': len(retries) - pending,
}
return success(SourcesOut(
manager_alive=bool(statuses),
db_size_bytes=db_size,
@@ -198,7 +207,7 @@ async def list_sources(session: Annotated[AsyncSession, Depends(get_session)]) -
"region_code": REGIONS.get(s.code),
"supports_history": s.code in HISTORY_SOURCES,
"configuration_error": configuration_error(s.code),
"collection_stats": run_stats.get(s.code, {}),
"collection_stats": {**run_stats.get(s.code, {}), **attachment_stats[s.code]},
"process": json.loads(statuses[s.code]) if s.code in statuses else None,
"today_new": today_counts.get(s.code, (0, 0))[0],
"today_revised": today_counts.get(s.code, (0, 0))[1],
+5 -4
View File
@@ -23,7 +23,7 @@ SOURCES = {
'sec': OfficialSource('SEC·证券监管', ('https://www.sec.gov/newsroom/press-releases', 'https://www.sec.gov/newsroom/speeches-statements', 'https://www.sec.gov/rules-regulations/rulemaking-activity', 'https://www.sec.gov/rules-regulations/self-regulatory-organization-rulemaking'), r'/(?:newsroom/(?:press-releases|speeches-statements)/[^/]+|rules-regulations/.*\d.*)', '.node-details-layout__main-region__content .field--name-body', ('www.sec.gov',)),
'cftc': OfficialSource('CFTC·衍生品监管', ('https://www.cftc.gov/PressRoom/PressReleases', 'https://www.cftc.gov/PressRoom/SpeechesTestimony', 'https://www.cftc.gov/LawRegulation/CFTCStaffLetters/letters.htm'), r'/(?:PressRoom/(?:PressReleases/\d+-\d+|SpeechesTestimony/(?!index)[^/]+)|csl/)', 'article .field--name-body', ('www.cftc.gov',)),
'treasury': OfficialSource('美国财政部', ('https://home.treasury.gov/news/press-releases',), r'/news/press-releases/[a-z]+\d+/?$', '.field--name-field-news-body', ('home.treasury.gov', 'www.treasury.gov', 'ofac.treasury.gov')),
'ofac': OfficialSource('OFAC·金融制裁', ('https://ofac.treasury.gov/recent-actions',), r'/recent-actions/\d{8}[^/]*$', '.field--name-body, .body-content', ('ofac.treasury.gov', 'home.treasury.gov')),
'ofac': OfficialSource('OFAC·金融制裁', ('https://ofac.treasury.gov/recent-actions',), r'/recent-actions/\d{8}[^/]*$', 'article.node--type-ofac-recent-action .field--name-field-body', ('ofac.treasury.gov', 'home.treasury.gov')),
'fincen': OfficialSource('FinCEN·反洗钱', ('https://www.fincen.gov/news/press-releases', 'https://www.fincen.gov/news/enforcement-actions', 'https://www.fincen.gov/resources/advisoriesbulletinsfact-sheets'), r'/(?:news/news-releases/[^/]+|resources/advisories/[^/]+|news/enforcement-actions/[^/]+)', 'article .field--name-body', ('www.fincen.gov',)),
'whitehouse': OfficialSource('白宫·政策', ('https://www.whitehouse.gov/presidential-actions/', 'https://www.whitehouse.gov/fact-sheets/', 'https://www.whitehouse.gov/briefings-statements/', 'https://bidenwhitehouse.archives.gov/briefing-room/'), r'/(?:presidential-actions|fact-sheets|briefings-statements|briefing-room/[^/]+)/202[5-9]/\d{2}/\d{2}/|/(?:presidential-actions|fact-sheets|briefings-statements)/202[5-9]/\d{2}/[^/]+', '.wp-block-post-content, .body-content, .entry-content', ('www.whitehouse.gov', 'bidenwhitehouse.archives.gov')),
'congress': OfficialSource('美国国会·立法', ('https://api.congress.gov/v3/bill?format=json&limit=250', 'https://api.congress.gov/v3/committee-meeting?format=json&limit=250'), r'/bill/', '', ('api.congress.gov', 'www.congress.gov', 'www.govinfo.gov'), 900),
@@ -32,11 +32,11 @@ SOURCES = {
'doj': OfficialSource('DOJ·加密机构执法', ('https://www.justice.gov/api/v1/press_releases.json?pagesize=50&sort=date&direction=DESC&fields=uuid,date',), r'/(?:opa|usao[^/]*|archives/opa)/(?:pr|speech)/[^/]+', '.field--name-body, .field--name-field-pr-body', ('www.justice.gov',)),
'fed': OfficialSource('美联储·政策与讲话', tuple(f'https://www.federalreserve.gov/newsevents/{kind}/{year}-{suffix}.htm' for year in range(datetime.now(UTC).year, 2024, -1) for kind, suffix in (('pressreleases', 'press'), ('speech', 'speeches'), ('testimony', 'testimony'))) + ('https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm', 'https://www.federalreserve.gov/releases/h41/releaseDates.json'), r'/(?:newsevents/(?:pressreleases|speech|testimony)/[^/]+\.htm|monetarypolicy/(?:fomcminutes|fomcprojtabl)\d+\.htm|releases/h41/\d+/)', '#article, .col-xs-12.col-sm-8.col-md-8, #content', ('www.federalreserve.gov',), 60),
}
RULE_VERSION = 'us-policy-v1'
RULE_VERSION = 'us-policy-v2'
CRYPTO = re.compile(r'\b(?:crypto(?:currency|currencies|[ -]assets?)?|digital[ -]assets?|virtual[ -]currenc(?:y|ies)|stablecoins?|bitcoin|ethereum|blockchain|tokeniz(?:ed|ation))\b', re.I)
POLICY = re.compile(r'\b(?:regulat\w*|rules?|guidance|legislation|acts?|sanctions?|licenses?|executive order|monetary policy|federal funds|interest rates?|balance sheet|liquidity|quantitative|refunding|borrowing|debt limit|tariffs?|fiscal|budget|appropriation|tax reform)\b', re.I)
INSTITUTION = re.compile(r'\b(?:exchanges?|banks?|financial institutions?|payment processors?|custodians?|stablecoin issuers?|binance|coinbase|tether|circle|kraken|bybit|bitfinex)\b', re.I)
MACRO = re.compile(r'\b(?:monetary policy|federal funds|interest rates?|fomc|balance sheet|liquidity|quantitative|refunding|borrowing estimates|debt (?:limit|ceiling)|tariffs?|fiscal|budget|appropriation|tax reform|financial sanctions)\b', re.I)
MACRO = re.compile(r"\b(?:monetary policy|federal funds (?:rate|target)|interest rates?|discount rates?|(?:primary|secondary) credit rates?|reserve requirements?|interest on reserve balances|fomc|balance sheet|liquidity|quantitative (?:easing|tightening)|refunding|borrowing estimates|debt (?:limit|ceiling)|tariffs?|fiscal (?:policy|stimulus|outlook)|federal (?:budget|deficit)|president['’]s budget|budget of the united states|(?:continuing|consolidated|full[ -]year) appropriations?|government (?:shutdown|funding)|tax reform|financial sanctions)\b", re.I)
def selection(source: str, title: str, text: str) -> list[str]:
@@ -44,7 +44,8 @@ def selection(source: str, title: str, text: str) -> list[str]:
crypto = bool(CRYPTO.search(content))
if crypto and (source not in ('doj', 'ofac', 'fincen') or POLICY.search(title) or INSTITUTION.search(content)):
return ['crypto_policy' if POLICY.search(content) else 'crypto_institution']
if source not in ('doj', 'cftc', 'sec') and MACRO.search(content):
macro_scope = title if source == 'federal_register' else content
if source not in ('doj', 'cftc', 'sec') and MACRO.search(macro_scope):
return ['us_macro_policy']
if source == 'ofac' and INSTITUTION.search(content) and POLICY.search(content):
return ['financial_sanctions']
@@ -316,7 +316,7 @@ class USOfficialSpider(NewsSpider):
for result in self.complete(lane):
yield result
return
parsed = {'title': data['title'], 'content_html': '<pre>' + escape(json.dumps(data, ensure_ascii=False, indent=2)) + '</pre>', 'published_at': at, 'url': response.url, 'external_id': identity(response.url), 'raw_response_id': response.meta['raw_response_id'],
parsed = {'title': data['title'], 'content_html': '<pre>' + escape(json.dumps(data, ensure_ascii=False, indent=2)) + '</pre>', 'published_at': at, 'url': response.url, 'external_id': identity(response.url), 'raw_response_id': response.meta['raw_response_id'], 'retry_document': doc,
'extra': {'content_scope': 'article', 'published_at_precision': precision, 'published_at_basis': 'official_update', 'published_date': at.date().isoformat(), 'official_meeting': data, 'rule_version': RULE_VERSION, 'attachments': [{'url': official_url(self.name, d['url']), 'title': d.get('name') or d.get('documentType', ''), 'status': 'pending'} for d in {d['url']: d for d in data.get('meetingDocuments', []) + data.get('witnessDocuments', []) if d.get('format') == 'PDF'}.values()]}}
for result in self.attach_or_finish(parsed, lane):
yield result
@@ -514,7 +514,7 @@ class USOfficialSpider(NewsSpider):
retries[parsed['url']] = {'document': retry_doc, 'attempts': previous.get('attempts', 0) + 1, 'next_at': (self.run.started_at + timedelta(hours=1)).timestamp()}
elif not incomplete:
retries.pop(parsed['url'], None)
if reasons or incomplete:
if reasons:
parsed['extra']['selection_reasons'] = reasons
parsed['extra']['content_scope'] = 'partial' if incomplete else 'article'
self.run.stats['partial'] = self.run.stats.get('partial', 0) + int(incomplete)
@@ -523,6 +523,8 @@ class USOfficialSpider(NewsSpider):
yield item
else:
self.run.stats['excluded'] = self.run.stats.get('excluded', 0) + 1
if incomplete:
self.run.stats['unclassified_attachment_gaps'] = self.run.stats.get('unclassified_attachment_gaps', 0) + 1
yield from self.complete(lane)
def parse_attachment(self, response):
@@ -532,6 +534,8 @@ class USOfficialSpider(NewsSpider):
try:
if response.status != 200:
raise ValueError(f'HTTP {response.status}')
if response.headers.get('Content-Type', b'').lower().startswith(b'image/') and response.body.startswith((b'\x89PNG\r\n\x1a\n', b'\xff\xd8\xff', b'GIF87a', b'GIF89a')):
raise ValueError('官方图片附件尚未提取文字,请核对原文')
text = pdf_text(response.body)
attachment['status'] = 'complete'
parsed['content_html'] += f"<section><h2>{escape(attachment['title'])}</h2><pre>{escape(text)}</pre></section>"
Binary file not shown.
+104 -3
View File
@@ -8,7 +8,7 @@ from types import SimpleNamespace
import pytest
from scrapy import Request
from scrapy.http import HtmlResponse
from scrapy.http import HtmlResponse, Response
from alphapulse.core.us_news import SOURCES, article, official_url, published, selection
@@ -39,6 +39,14 @@ def response(url, body, **meta):
('treasury', 'Meeting with delegation', 'Greetings and cooperation', False),
('ofac', 'New sanctions', 'Sanctions against a financial institution', True),
('sec', 'Ethernet equipment', 'Method and hardware', False),
('whitehouse', "The President's Budget for Fiscal Year 2027", 'The President submitted his budget request to Congress, proposing nationwide spending and revenue changes.', True),
('whitehouse', 'The President’s Budget', 'Annual spending and revenue proposals.', True),
('congress', 'Continuing Appropriations Act, 2027', 'This bill provides continuing appropriation for the operations of the United States.', True),
('federal_register', 'Adjustments to the Reserve Bank Discount Rates', 'The Federal Reserve increases interest rates charged on loans to depository institutions.', True),
('federal_register', 'Reserve Requirements of Depository Institutions', 'The Federal Reserve adjusts reserve requirements.', True),
('federal_register', 'Interest on Reserve Balances', 'The Federal Reserve adjusts interest paid on reserve balances.', True),
('federal_register', 'Hospital Budget Estimates for Fiscal Year 2027', 'The budget provides funding for hospital operations.', False),
('whitehouse', 'Health research funding', 'The department budget supports quantitative research during the fiscal year.', False),
])
def test_selection_boundaries(source, title, text, expected):
assert bool(selection(source, title, text)) is expected
@@ -58,14 +66,102 @@ def test_dates_do_not_fabricate_publisher_time():
def test_ofac_original_body_and_attachment_boundaries():
data = article('ofac', '''<h1>Crypto sanctions</h1><main><div class="field--name-body"><p>New sanctions against a virtual currency exchange institution.</p>
<a href="/media/123/download">License</a><a href="https://evil.example/a.pdf">external</a></div></main>''', 'https://ofac.treasury.gov/recent-actions/20250101')
data = article('ofac', '''<h1>Crypto sanctions</h1><main><article class="node--type-ofac-recent-action"><div class="field--name-field-body"><p>New sanctions against a virtual currency exchange institution.</p>
<a href="/media/123/download">License</a><a href="https://evil.example/a.pdf">external</a></div></article></main>''', 'https://ofac.treasury.gov/recent-actions/20250101')
assert data['published_at'] == datetime(2025, 1, 1, tzinfo=UTC)
assert data['extra']['published_at_precision'] == 'date'
assert len(data['extra']['attachments']) == 2
assert data['extra']['attachments'][1]['status'] == 'unsupported_host'
def test_live_ofac_body_and_policy_selection():
import gzip
from pathlib import Path
sample = json.loads(gzip.decompress((Path(__file__).parent / 'fixtures/us_news/live_selection.json.gz').read_bytes()))
parsed = article('ofac', sample['ofac']['body'], sample['ofac']['url'])
assert 'Iran General License FF-1' in parsed['content_html']
assert "A Part of Treasury's Office" not in parsed['content_html']
for case in sample['cases']:
assert bool(selection(case['source_code'], case['title'], case['content_text'])) is case['expected'], case['title']
def test_unrelated_failed_attachment_is_not_news(trading):
obj = spider(trading, 'doj')
obj.run.history_deadline = time.monotonic() - 1
doc = {'url': 'https://www.justice.gov/api/v1/press_releases/x.json', 'kind': 'doj'}
obj.cursor['history']['pending'] = [doc]
parsed = {'url': doc['url'], 'title': 'Federal prosecutor receives national award', 'external_id': 'award', 'content_html': '<p>The department recognized superior performance in Indian Country.</p>', 'published_at': datetime(2026, 11, 4, tzinfo=UTC), 'raw_response_id': 12, 'retry_document': doc,
'extra': {'attachments': [{'url': 'https://www.justice.gov/media/1/dl', 'status': 'unavailable'}]}}
assert list(obj.attach_or_finish(parsed, 'history')) == []
assert obj.run.stats['unclassified_attachment_gaps'] == 1
assert obj.cursor['attachment_retries'][doc['url']]['attempts'] == 1
@pytest.mark.parametrize('content_type,body', [('image/png', b'\x89PNG\r\n\x1a\n'), ('image/jpeg', b'\xff\xd8\xff'), ('image/gif', b'GIF89a')])
@pytest.mark.parametrize('relevant', [True, False])
def test_image_attachment_preserves_unknown_text_gap(trading, monkeypatch, content_type, body, relevant):
from alphapulse.crawler.spiders import us_official
obj = spider(trading, 'doj')
obj.run.history_deadline = time.monotonic() - 1
url = 'https://www.justice.gov/media/1/dl'
doc = {'url': 'https://www.justice.gov/api/v1/press_releases/x.json', 'kind': 'doj'}
obj.cursor['history']['pending'] = [doc]
parsed = {'url': doc['url'], 'title': 'Cryptocurrency exchange charged' if relevant else 'Department announces an enforcement action', 'external_id': 'case', 'content_html': '<p>The cryptocurrency exchange was charged with money laundering.</p>' if relevant else '<p>See the attached consent order for the findings and penalty.</p>', 'published_at': datetime(2026, 11, 4, tzinfo=UTC), 'raw_response_id': 12, 'retry_document': doc,
'extra': {'attachments': [{'url': url, 'title': 'Consent Order', 'status': 'pending'}]}}
monkeypatch.setattr(us_official, 'pdf_text', lambda body: pytest.fail('Images must not reach the PDF parser'))
res = Response(url, body=body, headers={'Content-Type': content_type}, request=Request(url, meta={'lane': 'history', 'parsed': parsed, 'raw_response_id': 13}))
items = list(obj.parse_attachment(res))
if relevant:
item, = items
assert item['extra']['content_scope'] == 'partial'
else:
assert items == []
assert obj.run.stats['unclassified_attachment_gaps'] == 1
attachment = parsed['extra']['attachments'][0]
assert attachment['status'] == 'unavailable'
assert '图片' in attachment['error']
assert attachment['raw_response_id'] == 13
assert obj.cursor['attachment_retries'][doc['url']]['attempts'] == 1
def test_source_api_keeps_attachment_gaps_visible_across_rounds(trading, monkeypatch):
from unittest.mock import AsyncMock
from alphapulse.api import crawler
from alphapulse.models import Source
source = Source(code='doj', name='DOJ', list_url=SOURCES['doj'].entries[0], enabled=False, interval_seconds=300, backfill_hours=72,
consecutive_failures=0, last_success_at=None, last_error=None, paused_until=None, history_cursor={'attachment_retries': {'a': {'attempts': 1}, 'b': {'attempts': 2}, 'c': {'attempts': 3}}},
history_target=None, history_position=None, history_done=False)
latest = SimpleNamespace(source_code='doj', stats={'discovered': 2, 'attachment_gaps': 999})
class Session:
async def scalars(self, statement):
rows = [source] if statement.column_descriptions[0]['entity'] is Source else [latest]
return SimpleNamespace(all=lambda: rows)
async def execute(self, statement):
return SimpleNamespace(all=lambda: []) if 'news_items' in str(statement) else []
async def scalar(self, statement):
return 1234
monkeypatch.setattr(crawler.redis, 'hgetall', AsyncMock(return_value={}))
monkeypatch.setattr(crawler, 'calendar_sources', AsyncMock(return_value=[]))
for stats in ({'discovered': 2, 'attachment_gaps': 999}, {}):
latest.stats = stats
result = asyncio.run(crawler.list_sources(Session())).data.sources[0]
assert result.collection_stats['attachment_gaps'] == 3
assert result.collection_stats['attachment_retry_pending'] == 2
assert result.collection_stats['attachment_retry_exhausted'] == 1
source.history_cursor['attachment_retries'].pop('a')
result = asyncio.run(crawler.list_sources(Session())).data.sources[0]
assert result.collection_stats['attachment_gaps'] == 2
assert result.collection_stats['attachment_retry_pending'] == 1
source.history_cursor = {}
latest.stats = {'attachment_gaps': 999}
result = asyncio.run(crawler.list_sources(Session())).data.sources[0]
assert result.collection_stats['attachment_gaps'] == 0
assert result.collection_stats['attachment_retry_exhausted'] == 0
def test_challenge_is_failure_not_empty_success(trading):
obj = spider(trading)
with pytest.raises(ValueError, match='未识别'):
@@ -730,6 +826,11 @@ def test_congress_meeting_includes_deduplicated_witness_pdfs(trading, monkeypatc
assert req.url == pdf['url']
assert len(req.meta['parsed']['extra']['attachments']) == 1
assert req.meta['parsed']['extra']['attachments'][0]['title'] == 'Witness Statement'
obj.run.history_deadline = time.monotonic() - 1
req.meta['raw_response_id'] = 13
item, = obj.parse_attachment(Response(req.url, status=404, request=req))
assert item['extra']['content_scope'] == 'partial'
assert obj.cursor['attachment_retries'][doc['url']]['document'] == doc
def test_dated_but_unavailable_bill_text_remains_a_gap(trading):
+16
View File
@@ -276,6 +276,22 @@ attachment text are searchable and versioned. Date-only releases are displayed a
dates, not precise release instants. Backfilled documents describe the version
available at capture time; they are not historical point-in-time evidence.
Selection rule `us-policy-v2` uses explicit national budget, continuing/consolidated
appropriations, discount-rate and reserve-policy phrases instead of standalone
`budget`, `fiscal` or `quantitative`. Federal Register macro screening uses the title
to avoid incidental references in unrelated regulations; crypto screening still
reads the complete body and extracted attachments.
Image attachments remain an explicit text-extraction gap, including when their
format is PNG, JPEG or GIF. They do not establish completeness or irrelevance.
Relevant documents remain `partial`; documents whose relevance is still unknown
are kept in bounded attachment retries rather than inserted as unrelated news.
Congress meeting attachment failures retain the same retry evidence as articles.
The source API derives `attachment_gaps`, `attachment_retry_pending` and
`attachment_retry_exhausted` from the persisted cursor, independently of the latest
round's statistics. The console keeps these document counts visible across rounds;
success clears the gap, while three failed attempts remain visible for investigation.
Validation without production credentials:
- `cd backend && uv run pytest && uvx ruff check --select F,E9,B src tests`
@@ -160,8 +160,11 @@ onMounted(loadLatest)
<div v-if="source.collection_stats?.unresolved_bills" class="text-xs text-amber-600">
待核验法案 {{ source.collection_stats.unresolved_bills }};官方资料尚不充分
</div>
<div v-if="source.collection_stats?.attachment_gaps" class="text-xs text-amber-600">
附件待核验文档 {{ source.collection_stats.attachment_gaps }};待重试 {{ source.collection_stats.attachment_retry_pending }};重试已耗尽 {{ source.collection_stats.attachment_retry_exhausted }},请核对原文
</div>
<div v-if="source.collection_stats?.discovered" class="text-xs text-slate-500">
本轮发现 {{ source.collection_stats.discovered }};筛除 {{ source.collection_stats.excluded ?? 0 }};附件缺口 {{ source.collection_stats.partial ?? 0 }}
本轮发现 {{ source.collection_stats.discovered }};筛除 {{ source.collection_stats.excluded ?? 0 }};不完整包 {{ source.collection_stats.partial ?? 0 }}
</div>
<div class="flex justify-between text-xs text-slate-500">
<span>今日新增 <b class="num text-[var(--el-color-primary)]">+{{ source.today_new }}</b></span>