Skip to content

Commit a32036a

Browse files
penny-team[bot]jaredlockhartclaude
authored
fix: restore top-20 random selection and add learn topic rotation (jaredlockhart#553)
The heat model rewrite (PR jaredlockhart#528) accidentally replaced the top-N random entity selection with deterministic highest-heat-wins. This also introduced a hard learn-topic dedup filter that permanently blocked all entities from the same /learn command after the first notification. Restores random selection from top 20 candidates and replaces the hard learn-topic block with a soft rotation preference that sorts by least- recently-notified topic first, then by heat. Co-authored-by: Jared Lockhart <119884+jaredlockhart@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bfa0b52 commit a32036a

2 files changed

Lines changed: 66 additions & 61 deletions

File tree

penny/penny/agents/notification.py

Lines changed: 40 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import asyncio
1313
import logging
14+
import random
1415
from collections import defaultdict
1516
from datetime import UTC, datetime
1617
from 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

penny/penny/tests/agents/test_notification.py

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ async def test_notification_prefers_higher_heat_entity(
2222
test_user_info,
2323
running_penny,
2424
):
25-
"""Notification agent picks the entity with higher heat deterministically."""
25+
"""Notification agent picks from eligible entities (random from top 20 by heat)."""
2626
config = make_config()
2727

2828
captured_prompts: list[str] = []
@@ -31,13 +31,11 @@ def handler(request: dict, count: int) -> dict:
3131
messages = request.get("messages", [])
3232
prompt = messages[-1]["content"] if messages else ""
3333
captured_prompts.append(prompt)
34-
if "came across" in prompt:
35-
return mock_ollama._make_text_response(
36-
request,
37-
"Hey, I came across **interesting entity** recently and found some"
38-
" really interesting stuff worth sharing!",
39-
)
40-
return mock_ollama._make_text_response(request, "ok")
34+
return mock_ollama._make_text_response(
35+
request,
36+
"Hey, I came across something recently and found some"
37+
" really interesting stuff worth sharing!",
38+
)
4139

4240
mock_ollama.set_response_handler(handler)
4341

@@ -65,10 +63,9 @@ def handler(request: dict, count: int) -> dict:
6563
result = await penny.notification_agent.execute()
6664
assert result is True
6765

68-
# Should notify about the interesting entity (higher heat)
66+
# One of the eligible entities should be picked
6967
msgs = signal_server.outgoing_messages
7068
assert len(msgs) == 1
71-
assert "interesting entity" in msgs[0]["message"]
7269

7370
# Prompt sent to model should instruct it to synthesize, not echo raw facts
7471
assert any("Synthesize" in p for p in captured_prompts)
@@ -276,7 +273,7 @@ def handler(request: dict, count: int) -> dict:
276273
)
277274
penny.db.messages.mark_processed([msg_id])
278275

279-
# Create two entities — give entity A higher heat so it's picked first
276+
# Create two entities with heat
280277
entity_a = penny.db.entities.get_or_create(TEST_SENDER, "entity alpha")
281278
entity_b = penny.db.entities.get_or_create(TEST_SENDER, "entity beta")
282279
assert entity_a is not None and entity_a.id is not None
@@ -290,16 +287,24 @@ def handler(request: dict, count: int) -> dict:
290287

291288
agent = penny.notification_agent
292289

293-
# Cycle 1: entity A picked (highest heat)
290+
# Cycle 1: one entity is picked (random from top 20)
294291
signal_server.outgoing_messages.clear()
295292
result1 = await agent.execute()
296293
assert result1 is True
297-
# Verify entity A was notified and has cooldown set
294+
295+
# Determine which entity was picked by checking notified_at
298296
facts_a = penny.db.facts.get_for_entity(entity_a.id)
299-
assert any(f.notified_at is not None for f in facts_a)
300-
entity_a_refreshed = penny.db.entities.get(entity_a.id)
301-
assert entity_a_refreshed is not None
302-
assert entity_a_refreshed.heat_cooldown_until is not None
297+
facts_b = penny.db.facts.get_for_entity(entity_b.id)
298+
a_notified = any(f.notified_at is not None for f in facts_a)
299+
b_notified = any(f.notified_at is not None for f in facts_b)
300+
assert a_notified or b_notified
301+
first_id = entity_a.id if a_notified else entity_b.id
302+
second_id = entity_b.id if a_notified else entity_a.id
303+
304+
# Verify picked entity has cooldown set
305+
first_refreshed = penny.db.entities.get(first_id)
306+
assert first_refreshed is not None
307+
assert first_refreshed.heat_cooldown_until is not None
303308

304309
# Add new facts to both
305310
penny.db.facts.add(entity_b.id, "Another beta fact")
@@ -315,13 +320,13 @@ def handler(request: dict, count: int) -> dict:
315320
interaction_recorded = datetime.now(UTC)
316321
await wait_until(lambda: (datetime.now(UTC) - interaction_recorded).total_seconds() >= 0.1)
317322

318-
# Cycle 2: entity A is on cooldown, so entity B is picked instead
323+
# Cycle 2: first entity is on cooldown, so the other is picked
319324
signal_server.outgoing_messages.clear()
320325
result2 = await agent.execute()
321326
assert result2 is True
322-
# Verify entity B was notified this time (cooldown forced rotation)
323-
facts_b = penny.db.facts.get_for_entity(entity_b.id)
324-
assert any(f.notified_at is not None for f in facts_b)
327+
# Verify the second entity was notified this time (cooldown forced rotation)
328+
second_facts = penny.db.facts.get_for_entity(second_id)
329+
assert any(f.notified_at is not None for f in second_facts)
325330

326331

327332
@pytest.mark.asyncio

0 commit comments

Comments
 (0)