159 lines
4.6 KiB
Python
159 lines
4.6 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""
|
||
|
|
Side-chat to main-chat work siphon — siphon action.
|
||
|
|
|
||
|
|
When detection fires, post a summary to main chat with:
|
||
|
|
- Category badge
|
||
|
|
- One-line summary (never full message text)
|
||
|
|
- Link back to the source side chat thread
|
||
|
|
- Confidence score (for transparency)
|
||
|
|
|
||
|
|
Safety:
|
||
|
|
- Rate limited (max N siphons per hour per thread)
|
||
|
|
- Never posts full message content
|
||
|
|
- Respects opt-out registry
|
||
|
|
- Deduplicates (same message_id never siphoned twice)
|
||
|
|
"""
|
||
|
|
|
||
|
|
import time
|
||
|
|
from dataclasses import dataclass, field
|
||
|
|
from typing import Callable, Optional
|
||
|
|
|
||
|
|
from detect import SiphonHit, is_opted_out
|
||
|
|
|
||
|
|
|
||
|
|
# --- Follow-up modulation ---
|
||
|
|
#
|
||
|
|
# Wire the follow-up modulation table into the siphon so each hit gets
|
||
|
|
# the right follow-up policy:
|
||
|
|
# ALERT / BLOCKER / DECISION -> tracked, fast fuse for ALERT/BLOCKER
|
||
|
|
# COMPLETED / MILESTONE -> untracked (no nudge budget burned)
|
||
|
|
#
|
||
|
|
# modulate.py must be landed on bl before this runs (rollout step 1).
|
||
|
|
# If the import fails we degrade to the old behavior: post the summary
|
||
|
|
# with no follow-up tags (fail-closed toward visibility, not tracking).
|
||
|
|
try:
|
||
|
|
from modulate import for_siphon_hit, render_tags
|
||
|
|
_MODULATION_AVAILABLE = True
|
||
|
|
except ImportError: # pragma: no cover - deploy keeps modulate.py present
|
||
|
|
_MODULATION_AVAILABLE = False
|
||
|
|
for_siphon_hit = None
|
||
|
|
render_tags = None
|
||
|
|
|
||
|
|
|
||
|
|
def policy_for_hit(hit: SiphonHit):
|
||
|
|
"""Follow-up policy for a siphon hit, or None when untracked.
|
||
|
|
|
||
|
|
COMPLETED / MILESTONE hits return None (post the summary, create no
|
||
|
|
follow-up record). ALERT / BLOCKER / DECISION return a Policy whose
|
||
|
|
tags render into the canonical bracket vocabulary.
|
||
|
|
"""
|
||
|
|
if not _MODULATION_AVAILABLE:
|
||
|
|
return None
|
||
|
|
return for_siphon_hit(hit.category)
|
||
|
|
|
||
|
|
|
||
|
|
def is_tracked(hit: SiphonHit) -> bool:
|
||
|
|
"""True when this hit should create a follow-up record.
|
||
|
|
|
||
|
|
Callers that route tracked posts through dm.py --expect-reply (so a
|
||
|
|
dm_followup record is actually created) can use this to choose the
|
||
|
|
post path. Untracked hits post as plain summaries.
|
||
|
|
"""
|
||
|
|
return policy_for_hit(hit) is not None
|
||
|
|
|
||
|
|
|
||
|
|
# --- Rate limiting ---
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class RateLimiter:
|
||
|
|
max_per_hour: int = 5
|
||
|
|
_timestamps: dict = field(default_factory=dict) # thread_id -> [ts, ...]
|
||
|
|
|
||
|
|
def allow(self, thread_id: str) -> bool:
|
||
|
|
now = time.time()
|
||
|
|
stamps = self._timestamps.get(thread_id, [])
|
||
|
|
# Prune older than 1 hour
|
||
|
|
stamps = [s for s in stamps if now - s < 3600]
|
||
|
|
if len(stamps) >= self.max_per_hour:
|
||
|
|
return False
|
||
|
|
stamps.append(now)
|
||
|
|
self._timestamps[thread_id] = stamps
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
# --- Deduplication ---
|
||
|
|
|
||
|
|
_siphoned_ids: set = set()
|
||
|
|
|
||
|
|
|
||
|
|
def already_siphoned(message_id: str) -> bool:
|
||
|
|
return message_id in _siphoned_ids
|
||
|
|
|
||
|
|
|
||
|
|
def mark_siphoned(message_id: str):
|
||
|
|
_siphoned_ids.add(message_id)
|
||
|
|
|
||
|
|
|
||
|
|
# --- Siphon action ---
|
||
|
|
|
||
|
|
CATEGORY_EMOJI = {
|
||
|
|
"COMPLETED": "✅",
|
||
|
|
"BLOCKER": "🚧",
|
||
|
|
"DECISION": "❓",
|
||
|
|
"ALERT": "🚨",
|
||
|
|
"MILESTONE": "🎯",
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def format_siphon(hit: SiphonHit, agent_name: str = "sidechat") -> str:
|
||
|
|
"""
|
||
|
|
Format a siphon message for main chat.
|
||
|
|
Never includes full message text — summary + link only.
|
||
|
|
"""
|
||
|
|
emoji = CATEGORY_EMOJI.get(hit.category, "📋")
|
||
|
|
thread_url = f"https://muse.ai/thread/{hit.thread_id}"
|
||
|
|
return (
|
||
|
|
f"{emoji} [{hit.category}] from {agent_name} side chat\n"
|
||
|
|
f"{hit.summary}\n"
|
||
|
|
f"→ {thread_url}\n"
|
||
|
|
f"(confidence {hit.confidence:.0%})"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def siphon(hit: SiphonHit,
|
||
|
|
agent_name: str,
|
||
|
|
post_to_main: Callable[[str], bool],
|
||
|
|
limiter: Optional[RateLimiter] = None) -> bool:
|
||
|
|
"""
|
||
|
|
Execute the siphon: post summary to main chat.
|
||
|
|
|
||
|
|
post_to_main: callable that posts text to main chat, returns True on success.
|
||
|
|
Returns True if siphoned, False if suppressed.
|
||
|
|
"""
|
||
|
|
# Safety checks
|
||
|
|
if is_opted_out(hit.thread_id):
|
||
|
|
return False
|
||
|
|
if already_siphoned(hit.message_id):
|
||
|
|
return False
|
||
|
|
|
||
|
|
lim = limiter or RateLimiter()
|
||
|
|
if not lim.allow(hit.thread_id):
|
||
|
|
return False
|
||
|
|
|
||
|
|
text = format_siphon(hit, agent_name)
|
||
|
|
|
||
|
|
# Follow-up modulation: tracked hits (ALERT/BLOCKER/DECISION) get
|
||
|
|
# the canonical follow-up tags appended — [reply:expected],
|
||
|
|
# [reply:timeout=N], [reply:nudges=N], [reply:escalate=X], and
|
||
|
|
# [input:siphon] for the audit trail. Untracked hits
|
||
|
|
# (COMPLETED/MILESTONE) post as plain summaries.
|
||
|
|
policy = policy_for_hit(hit)
|
||
|
|
if policy is not None:
|
||
|
|
text = text + "\n" + render_tags(policy)
|
||
|
|
|
||
|
|
ok = post_to_main(text)
|
||
|
|
if ok:
|
||
|
|
mark_siphoned(hit.message_id)
|
||
|
|
return ok
|