1111
1212import asyncio
1313import logging
14+ import random
1415from collections import defaultdict
1516from datetime import UTC , datetime
1617from typing import TYPE_CHECKING
@@ -46,7 +47,7 @@ def __init__(self, **kwargs: object) -> None:
4647 super ().__init__ (** kwargs ) # type: ignore[arg-type]
4748 self ._channel : MessageChannel | None = None
4849 self ._backoff_state : dict [str , BackoffState ] = {}
49- self ._last_notified_learn_prompt_id : dict [str , int | None ] = {}
50+ self ._learn_topic_last_notified_at : dict [str , dict [ int | None , datetime ] ] = {}
5051 self ._last_notification : dict [str , tuple [int , datetime ]] = {} # user → (entity_id, sent_at)
5152 self ._heat_engine : HeatEngine | None = None
5253
@@ -381,9 +382,8 @@ async def _try_notify_user(self, user: str) -> bool:
381382 for fact in unnotified :
382383 facts_by_entity [fact .entity_id ].append (fact )
383384
384- # Pick the hottest eligible entity
385- last_learn_prompt_id = self ._last_notified_learn_prompt_id .get (user )
386- entity = self ._pick_hottest_entity (user , facts_by_entity , last_learn_prompt_id )
385+ # Pick an eligible entity (prefers stale learn topics, top 20 by heat, random)
386+ entity = self ._pick_hottest_entity (user , facts_by_entity )
387387 if entity is None :
388388 return False
389389 assert entity .id is not None
@@ -406,8 +406,11 @@ async def _try_notify_user(self, user: str) -> bool:
406406 if self ._heat_engine :
407407 self ._heat_engine .start_cooldown (entity .id )
408408
409- # Track which learn prompt this notification came from (to suppress same-topic dedup)
410- self ._last_notified_learn_prompt_id [user ] = self ._get_learn_prompt_id (facts )
409+ # Track when this learn topic was last notified (for rotation preference)
410+ learn_prompt_id = self ._get_learn_prompt_id (facts )
411+ if user not in self ._learn_topic_last_notified_at :
412+ self ._learn_topic_last_notified_at [user ] = {}
413+ self ._learn_topic_last_notified_at [user ][learn_prompt_id ] = datetime .now (UTC )
411414
412415 # Track this notification for ignore detection
413416 self ._last_notification [user ] = (entity .id , datetime .now (UTC ))
@@ -421,20 +424,20 @@ def _pick_hottest_entity(
421424 self ,
422425 user : str ,
423426 facts_by_entity : dict [int , list [Fact ]],
424- last_notified_learn_prompt_id : int | None = None ,
425427 ) -> Entity | None :
426- """Pick the hottest eligible entity deterministically .
428+ """Pick an eligible entity, preferring learn topic rotation .
427429
428- Filters by: heat > 0, not on cooldown, not same learn topic.
429- Returns the entity with the highest heat, or None.
430+ Sorts by least-recently-notified learn topic first, takes the top 20
431+ by heat, then picks randomly. This naturally rotates between learn
432+ topics while still favoring hot entities.
430433 """
431- eligible = self ._get_eligible_entities (user , facts_by_entity , last_notified_learn_prompt_id )
434+ eligible = self ._get_eligible_entities (user , facts_by_entity )
432435 if not eligible :
433436 return None
434437
435- # Sort by heat descending, pick the hottest
436- eligible . sort ( key = lambda e : e . heat , reverse = True )
437- chosen = eligible [ 0 ]
438+ ranked = self . _rank_by_topic_recency_then_heat ( user , eligible , facts_by_entity )
439+ top = ranked [: 20 ]
440+ chosen = random . choice ( top )
438441
439442 logger .info (
440443 "Notification: picked '%s' (heat=%.2f) from %d eligible" ,
@@ -444,30 +447,43 @@ def _pick_hottest_entity(
444447 )
445448 return chosen
446449
450+ def _rank_by_topic_recency_then_heat (
451+ self ,
452+ user : str ,
453+ entities : list [Entity ],
454+ facts_by_entity : dict [int , list [Fact ]],
455+ ) -> list [Entity ]:
456+ """Sort entities by learn topic recency (stale first), then heat."""
457+ topic_times = self ._learn_topic_last_notified_at .get (user , {})
458+ epoch = datetime .min .replace (tzinfo = UTC )
459+
460+ def sort_key (entity : Entity ) -> tuple [datetime , float ]:
461+ assert entity .id is not None
462+ facts = facts_by_entity .get (entity .id , [])
463+ lp_id = self ._get_learn_prompt_id (facts )
464+ last_notified = topic_times .get (lp_id , epoch )
465+ return (last_notified , - entity .heat )
466+
467+ return sorted (entities , key = sort_key )
468+
447469 def _get_eligible_entities (
448470 self ,
449471 user : str ,
450472 facts_by_entity : dict [int , list [Fact ]],
451- last_notified_learn_prompt_id : int | None ,
452473 ) -> list [Entity ]:
453- """Get entities with unnotified facts that pass all eligibility filters."""
474+ """Get entities with unnotified facts that pass heat and cooldown filters."""
454475 entities = self .db .entities .get_for_user (user )
455476 eligible : list [Entity ] = []
456477 for entity in entities :
457478 if entity .id not in facts_by_entity :
458479 continue
459- if not self ._is_eligible (entity , facts_by_entity , last_notified_learn_prompt_id ):
480+ if not self ._is_eligible (entity ):
460481 continue
461482 eligible .append (entity )
462483 return eligible
463484
464- def _is_eligible (
465- self ,
466- entity : Entity ,
467- facts_by_entity : dict [int , list [Fact ]],
468- last_notified_learn_prompt_id : int | None ,
469- ) -> bool :
470- """Check heat, cooldown, and learn-topic dedup filters."""
485+ def _is_eligible (self , entity : Entity ) -> bool :
486+ """Check heat and cooldown filters."""
471487 assert entity .id is not None
472488
473489 if entity .heat <= 0 :
@@ -483,24 +499,8 @@ def _is_eligible(
483499 cooldown_until .isoformat (),
484500 )
485501 return False
486- facts = facts_by_entity .get (entity .id , [])
487- if self ._is_same_learn_topic (facts , last_notified_learn_prompt_id ):
488- logger .debug (
489- "Notification: skipping '%s' (same learn topic as last notification)" ,
490- entity .name ,
491- )
492- return False
493502 return True
494503
495- def _is_same_learn_topic (
496- self , facts : list [Fact ], last_notified_learn_prompt_id : int | None
497- ) -> bool :
498- """Check if these facts share the same learn topic as the last notification."""
499- if last_notified_learn_prompt_id is None :
500- return False
501- entity_learn_prompt_id = self ._get_learn_prompt_id (facts )
502- return entity_learn_prompt_id == last_notified_learn_prompt_id
503-
504504 def _handle_ignored_notification (self , user : str ) -> None :
505505 """Check if the previous notification was ignored and penalize heat.
506506
0 commit comments