release: bump version to 0.71.1
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
release / images (push) Has been cancelled
ci / delivery (push) Has been cancelled

This commit is contained in:
linkong
2026-06-26 17:34:19 +08:00
parent 899e3bce43
commit 3265d22af5
26 changed files with 1052 additions and 71 deletions

View File

@@ -2307,7 +2307,10 @@ def _diversify_news_items_for_locale(
buckets: dict[str, list[ParsedNewsItem]] = {}
order: list[str] = []
for item in ranked:
key = _news_item_source_id(item) or item.source or item.feed_name or item.id
if active_region == "global":
key = item.feed_region or "global"
else:
key = _news_item_source_id(item) or item.source or item.feed_name or item.id
if key not in buckets:
buckets[key] = []
order.append(key)
@@ -2751,6 +2754,7 @@ async def get_earth_news_payload(
)
await record_earth_news_sources_health(db, health_by_source)
ranked_fetched_items = _rank_and_trim_items(fetched_items, active_region=active_region)
await _enqueue_unverified_locations(ranked_fetched_items)
await upsert_earth_news_items(db, ranked_fetched_items)
items = await _call_store_list_items(
@@ -2802,7 +2806,8 @@ async def get_earth_news_payload(
)
else:
cruise_items = items
await _enqueue_unverified_locations(items)
enqueue_candidates = {item.id: item for item in [*items, *cruise_items] if item.id}
await _enqueue_unverified_locations(list(enqueue_candidates.values()))
stale = bool(errors and items)
return _build_payload(

View File

@@ -14,10 +14,14 @@ from app.core.logging import get_logger
logger = get_logger(__name__, service="earth_news")
TARGET_LOCATION_STREAM = "earth_news:target_location:jobs"
TARGET_LOCATION_PRIORITY_STREAM = "earth_news:target_location:priority"
TARGET_LOCATION_GROUP = "earth_news_target_location"
TARGET_LOCATION_DEAD_LETTER_STREAM = "earth_news:target_location:dead"
TARGET_LOCATION_RESULT_TTL_SECONDS = 60 * 60 * 12
TARGET_LOCATION_JOB_DEDUP_TTL_SECONDS = 60 * 60 * 6
TARGET_LOCATION_PRIORITY_JOB_DEDUP_TTL_SECONDS = 60 * 5
TARGET_LOCATION_PENDING_RECLAIM_IDLE_MS = 2 * 60 * 1000
TARGET_LOCATION_PRIORITY_READ_BLOCK_MS = 1
TARGET_LOCATION_MAX_ATTEMPTS = 3
_redis_client: redis.Redis | None = None
@@ -28,6 +32,7 @@ class NewsTargetLocationMessage:
message_id: str
item_id: str
payload: dict[str, Any]
stream_name: str = TARGET_LOCATION_STREAM
attempts: int = 0
@@ -44,7 +49,7 @@ class NewsTargetLocationQueue(Protocol):
) -> list[NewsTargetLocationMessage]:
...
async def ack(self, message_id: str) -> None:
async def ack(self, message: NewsTargetLocationMessage) -> None:
...
async def retry_or_dead_letter(
@@ -71,6 +76,10 @@ def _queued_key(item_id: str) -> str:
return f"earth_news:target_location:queued:{item_id}"
def _priority_queued_key(item_id: str) -> str:
return f"earth_news:target_location:priority_queued:{item_id}"
class RedisStreamsNewsTargetLocationQueue:
def __init__(self, client: redis.Redis | None = None) -> None:
self.client = client or _get_redis_client()
@@ -79,34 +88,44 @@ class RedisStreamsNewsTargetLocationQueue:
async def _ensure_group(self) -> None:
if self._group_ready:
return
try:
await self.client.xgroup_create(
TARGET_LOCATION_STREAM,
TARGET_LOCATION_GROUP,
id="0",
mkstream=True,
)
except ResponseError as exc:
if "BUSYGROUP" not in str(exc):
raise
for stream_name in (TARGET_LOCATION_PRIORITY_STREAM, TARGET_LOCATION_STREAM):
try:
await self.client.xgroup_create(
stream_name,
TARGET_LOCATION_GROUP,
id="0",
mkstream=True,
)
except ResponseError as exc:
if "BUSYGROUP" not in str(exc):
raise
self._group_ready = True
async def enqueue(self, *, item_id: str, payload: dict[str, Any], force: bool = False) -> bool:
await self._ensure_group()
if force:
await self.client.delete(_result_key(item_id), _queued_key(item_id))
await self.client.delete(_result_key(item_id))
queued_key = _priority_queued_key(item_id)
elif await self.client.exists(_result_key(item_id)):
return False
else:
queued_key = _queued_key(item_id)
dedup_ttl = (
TARGET_LOCATION_PRIORITY_JOB_DEDUP_TTL_SECONDS
if force
else TARGET_LOCATION_JOB_DEDUP_TTL_SECONDS
)
queued = await self.client.set(
_queued_key(item_id),
queued_key,
"1",
nx=True,
ex=TARGET_LOCATION_JOB_DEDUP_TTL_SECONDS,
ex=dedup_ttl,
)
if not queued:
return bool(await self.client.exists(_queued_key(item_id)))
return bool(await self.client.exists(queued_key))
stream_name = TARGET_LOCATION_PRIORITY_STREAM if force else TARGET_LOCATION_STREAM
await self.client.xadd(
TARGET_LOCATION_STREAM,
stream_name,
{
"item_id": item_id,
"attempts": "0",
@@ -123,39 +142,104 @@ class RedisStreamsNewsTargetLocationQueue:
block_ms: int,
) -> list[NewsTargetLocationMessage]:
await self._ensure_group()
streams = await self.client.xreadgroup(
streams = []
priority_claimed = await self._claim_stale_messages(
stream_name=TARGET_LOCATION_PRIORITY_STREAM,
consumer_name=consumer_name,
count=count,
)
if priority_claimed:
return priority_claimed
priority_messages = await self.client.xreadgroup(
TARGET_LOCATION_GROUP,
consumer_name,
{TARGET_LOCATION_STREAM: ">"},
{TARGET_LOCATION_PRIORITY_STREAM: ">"},
count=count,
block=block_ms,
block=TARGET_LOCATION_PRIORITY_READ_BLOCK_MS,
)
if priority_messages:
streams = priority_messages
else:
regular_claimed = await self._claim_stale_messages(
stream_name=TARGET_LOCATION_STREAM,
consumer_name=consumer_name,
count=count,
)
if regular_claimed:
return regular_claimed
streams = await self.client.xreadgroup(
TARGET_LOCATION_GROUP,
consumer_name,
{TARGET_LOCATION_STREAM: ">"},
count=count,
block=block_ms,
)
messages: list[NewsTargetLocationMessage] = []
for _stream_name, stream_messages in streams:
for stream_name, stream_messages in streams:
for message_id, fields in stream_messages:
raw_payload = fields.get("payload")
item_id = fields.get("item_id")
if not raw_payload or not item_id:
await self.ack(message_id)
continue
try:
payload = json.loads(raw_payload)
except json.JSONDecodeError:
await self.ack(message_id)
continue
attempts = int(fields.get("attempts") or 0)
messages.append(
NewsTargetLocationMessage(
message_id=message_id,
item_id=item_id,
payload=payload,
attempts=attempts,
)
)
message = await self._message_from_fields(stream_name, message_id, fields)
if message is not None:
messages.append(message)
return messages
async def ack(self, message_id: str) -> None:
await self.client.xack(TARGET_LOCATION_STREAM, TARGET_LOCATION_GROUP, message_id)
async def _claim_stale_messages(
self,
*,
stream_name: str,
consumer_name: str,
count: int,
) -> list[NewsTargetLocationMessage]:
try:
_next_id, claimed, _deleted = await self.client.xautoclaim(
stream_name,
TARGET_LOCATION_GROUP,
consumer_name,
TARGET_LOCATION_PENDING_RECLAIM_IDLE_MS,
start_id="0-0",
count=count,
)
except ResponseError:
return []
messages: list[NewsTargetLocationMessage] = []
for message_id, fields in claimed:
message = await self._message_from_fields(stream_name, message_id, fields)
if message is not None:
messages.append(message)
return messages
async def _message_from_fields(
self,
stream_name: str,
message_id: str,
fields: dict[str, str],
) -> NewsTargetLocationMessage | None:
raw_payload = fields.get("payload")
item_id = fields.get("item_id")
if not raw_payload or not item_id:
await self._discard_message(stream_name, message_id)
return None
try:
payload = json.loads(raw_payload)
except json.JSONDecodeError:
await self._discard_message(stream_name, message_id)
return None
attempts = int(fields.get("attempts") or 0)
return NewsTargetLocationMessage(
message_id=message_id,
item_id=item_id,
payload=payload,
stream_name=stream_name,
attempts=attempts,
)
async def ack(self, message: NewsTargetLocationMessage) -> None:
await self.client.xack(message.stream_name, TARGET_LOCATION_GROUP, message.message_id)
await self.client.xdel(message.stream_name, message.message_id)
async def _discard_message(self, stream_name: str, message_id: str) -> None:
await self.client.xack(stream_name, TARGET_LOCATION_GROUP, message_id)
await self.client.xdel(stream_name, message_id)
async def retry_or_dead_letter(
self,
@@ -163,7 +247,7 @@ class RedisStreamsNewsTargetLocationQueue:
*,
error: str,
) -> None:
await self.ack(message.message_id)
await self.ack(message)
if message.attempts + 1 >= TARGET_LOCATION_MAX_ATTEMPTS:
await self.client.xadd(
TARGET_LOCATION_DEAD_LETTER_STREAM,
@@ -176,7 +260,7 @@ class RedisStreamsNewsTargetLocationQueue:
)
return
await self.client.xadd(
TARGET_LOCATION_STREAM,
message.stream_name,
{
"item_id": message.item_id,
"attempts": str(message.attempts + 1),
@@ -231,4 +315,4 @@ async def save_target_location_patch(item_id: str, patch: dict[str, Any]) -> Non
TARGET_LOCATION_RESULT_TTL_SECONDS,
json.dumps(patch, ensure_ascii=False),
)
await client.delete(_queued_key(item_id))
await client.delete(_queued_key(item_id), _priority_queued_key(item_id))

View File

@@ -19,6 +19,17 @@ from app.services.earth_news_classification import (
normalize_breaking_scope,
)
CRUISE_REGION_ORDER = (
"americas",
"europe",
"middle-east-africa",
"asia-pacific",
"global",
)
CRUISE_REGION_QUERY_MULTIPLIER = 12
CRUISE_REGION_QUERY_MIN_LIMIT = 240
CRUISE_REGION_QUERY_MAX_LIMIT = 1000
def _coerce_datetime(value: datetime | None) -> datetime | None:
if value is None:
@@ -106,6 +117,41 @@ def _sort_parsed_news_items(items: list[ParsedNewsItem], *, active_region: str)
)
def _diversify_parsed_news_items_by_region(
items: list[ParsedNewsItem],
*,
limit: int,
) -> list[ParsedNewsItem]:
if limit <= 0:
return []
sorted_items = _sort_parsed_news_items(items, active_region="global")
buckets: dict[str, list[ParsedNewsItem]] = {}
for item in sorted_items:
region = item.feed_region or "global"
buckets.setdefault(region, []).append(item)
ordered_regions = [
*[region for region in CRUISE_REGION_ORDER if buckets.get(region)],
*sorted(region for region in buckets if region not in CRUISE_REGION_ORDER),
]
diversified: list[ParsedNewsItem] = []
cursor = 0
while len(diversified) < limit:
added = False
for region in ordered_regions:
bucket = buckets.get(region) or []
if cursor >= len(bucket):
continue
diversified.append(bucket[cursor])
added = True
if len(diversified) >= limit:
break
if not added:
break
cursor += 1
return diversified
def _query_sort_key(active_region: str):
if active_region == "global":
return (
@@ -167,6 +213,8 @@ async def list_earth_news_items(
[record_to_parsed_news_item(record) for record in records],
active_region=active_region,
)
if active_region == "global" and not source_ids:
return _diversify_parsed_news_items_by_region(items, limit=limit)
return items[:limit]
@@ -177,15 +225,20 @@ async def list_earth_news_cruise_items(
categories: set[str] | None = None,
source_ids: set[str] | None = None,
) -> list[ParsedNewsItem]:
query_limit = min(
max(limit * CRUISE_REGION_QUERY_MULTIPLIER, CRUISE_REGION_QUERY_MIN_LIMIT),
CRUISE_REGION_QUERY_MAX_LIMIT,
)
query = (
select(EarthNewsItem)
.order_by(
EarthNewsItem.region.asc(),
EarthNewsItem.published_at.is_(None),
EarthNewsItem.published_at.desc().nullslast(),
EarthNewsItem.last_seen_at.desc(),
EarthNewsItem.region.asc(),
EarthNewsItem.feed_name.asc(),
)
.limit(min(max(limit * 4, limit), 200))
.limit(query_limit)
)
category_clause = _category_filter_clause(categories)
if category_clause is not None:
@@ -194,11 +247,10 @@ async def list_earth_news_cruise_items(
if source_clause is not None:
query = query.where(source_clause)
result = await db.execute(query)
items = _sort_parsed_news_items(
return _diversify_parsed_news_items_by_region(
[record_to_parsed_news_item(record) for record in result.scalars().all()],
active_region="global",
limit=limit,
)
return items[:limit]
async def get_earth_news_freshness(

View File

@@ -29,6 +29,9 @@ logger = get_logger(__name__, service="earth_news")
WORKER_BATCH_SIZE = 4
WORKER_BLOCK_MS = 5000
WORKER_BACKOFF_SECONDS = 5.0
WORKER_JOB_TIMEOUT_MIN_SECONDS = 20.0
WORKER_JOB_TIMEOUT_MAX_SECONDS = 90.0
WORKER_JOB_TIMEOUT_GRACE_SECONDS = 10.0
_worker_task: asyncio.Task | None = None
@@ -109,12 +112,25 @@ async def _run_target_location_worker() -> None:
if not messages:
continue
provider_client = await _build_provider_client()
for message in messages:
job_timeout = _get_worker_job_timeout(provider_client)
async def handle_message(message: NewsTargetLocationMessage) -> None:
try:
await process_target_location_message(message, provider_client=provider_client)
await queue.ack(message.message_id)
await asyncio.wait_for(
process_target_location_message(message, provider_client=provider_client),
timeout=job_timeout,
)
await queue.ack(message)
except asyncio.CancelledError:
raise
except TimeoutError as exc:
logger.warning_event(
"Earth news target location worker job timed out",
event="earth_news.target_location.worker_job_timeout",
context={"item_id": message.item_id, "timeout_seconds": job_timeout},
)
with suppress(Exception):
await queue.retry_or_dead_letter(message, error=str(exc) or "job timed out")
except Exception as exc:
logger.warning_event(
"Earth news target location worker job failed",
@@ -124,6 +140,8 @@ async def _run_target_location_worker() -> None:
with suppress(Exception):
await queue.retry_or_dead_letter(message, error=str(exc))
await asyncio.gather(*(handle_message(message) for message in messages))
def start_earth_news_target_worker() -> None:
global _worker_task
@@ -140,3 +158,15 @@ async def stop_earth_news_target_worker() -> None:
with suppress(asyncio.CancelledError):
await task
_worker_task = None
def _get_worker_job_timeout(provider_client: AIProviderClient | None) -> float:
timeout = float(getattr(provider_client, "timeout", 0) or WORKER_JOB_TIMEOUT_MIN_SECONDS)
retry_attempts = float(getattr(provider_client, "retry_attempts", 1) or 1)
return min(
max(
timeout * retry_attempts + WORKER_JOB_TIMEOUT_GRACE_SECONDS,
WORKER_JOB_TIMEOUT_MIN_SECONDS,
),
WORKER_JOB_TIMEOUT_MAX_SECONDS,
)

View File

@@ -24,6 +24,7 @@ from app.services.earth_news import (
from app.services.earth_news_queue import NewsTargetLocationMessage
from app.services.earth_news_worker import process_target_location_message
from app.services.collectors.media_news_archive import MediaNewsArchiveCollector
from app.services.earth_news_store import _diversify_parsed_news_items_by_region
def test_serialize_item_includes_region_anchor_for_cruise():
@@ -172,6 +173,68 @@ def test_diversify_news_items_prefers_display_ready_content_across_sources():
assert [item.id.split(":", 1)[0] for item in result] == ["source-b", "source-c", "source-a"]
def test_cruise_news_diversity_keeps_regions_from_being_starved():
published_at = datetime(2026, 6, 26, 8, 0, tzinfo=UTC)
def make_item(region: str, index: int) -> ParsedNewsItem:
return ParsedNewsItem(
id=f"{region}:{index}",
title=f"{region} story {index}",
summary=f"{region} summary {index}",
url=f"https://example.com/{region}/{index}",
source=region,
feed_name=region,
feed_region=region,
homepage_url="https://example.com",
published_at=published_at - timedelta(minutes=index),
)
items = [
*[make_item("asia-pacific", index) for index in range(40)],
make_item("europe", 1),
make_item("middle-east-africa", 1),
make_item("americas", 1),
make_item("global", 1),
]
result = _diversify_parsed_news_items_by_region(items, limit=8)
regions = [item.feed_region for item in result]
assert "europe" in regions
assert "middle-east-africa" in regions
assert "americas" in regions
assert regions.count("asia-pacific") < len(regions)
def test_global_news_diversity_uses_same_region_balance():
published_at = datetime(2026, 6, 26, 8, 0, tzinfo=UTC)
def make_item(region: str, index: int) -> ParsedNewsItem:
return ParsedNewsItem(
id=f"{region}:global:{index}",
title=f"{region} story {index}",
summary=f"{region} summary {index}",
url=f"https://example.com/{region}/global/{index}",
source=region,
feed_name=region,
feed_region=region,
homepage_url="https://example.com",
published_at=published_at - timedelta(minutes=index),
)
items = [
*[make_item("asia-pacific", index) for index in range(24)],
*[make_item("europe", index) for index in range(2)],
*[make_item("middle-east-africa", index) for index in range(2)],
*[make_item("americas", index) for index in range(2)],
]
result = _diversify_parsed_news_items_by_region(items, limit=6)
regions = {item.feed_region for item in result}
assert {"europe", "middle-east-africa", "americas"}.issubset(regions)
def test_serialize_item_falls_back_to_global_anchor():
item = ParsedNewsItem(
id="custom:test",