""" Briefing preferences: load topic settings, aggregate reaction scores, filter and rank RSS items for briefing inclusion. """ import json import logging from datetime import datetime, timezone from fabledassistant.models import async_session logger = logging.getLogger(__name__) async def load_topic_preferences(user_id: int) -> tuple[list[str], list[str]]: """ Return (include_topics, exclude_topics) from user settings. """ from fabledassistant.services.settings import get_setting raw_include = await get_setting(user_id, "briefing_include_topics", "[]") raw_exclude = await get_setting(user_id, "briefing_exclude_topics", "[]") def _parse(raw) -> list[str]: try: val = json.loads(raw) if isinstance(raw, str) else raw return [str(t) for t in val] if isinstance(val, list) else [] except Exception: return [] return _parse(raw_include), _parse(raw_exclude) async def load_topic_reaction_scores(user_id: int) -> dict[str, float]: """ Aggregate per-topic reaction scores from the last 30 days. Returns a dict of topic -> net_score (positive = liked, negative = disliked). Uses rss_item_reactions joined to rss_items.topics. """ try: from sqlalchemy import text as _text async with async_session() as session: result = await session.execute( _text(""" SELECT unnest(i.topics) AS topic, SUM(CASE r.reaction WHEN 'up' THEN 1 ELSE -1 END) AS score FROM rss_item_reactions r JOIN rss_items i ON i.id = r.rss_item_id WHERE r.user_id = :uid AND r.created_at > NOW() - INTERVAL '30 days' GROUP BY topic """).bindparams(uid=user_id) ) return {row.topic: float(row.score) for row in result} except Exception: logger.warning("Failed to load topic reaction scores", exc_info=True) return {} def score_and_filter_items( items: list[dict], include_topics: list[str], exclude_topics: list[str], topic_scores: dict[str, float], max_items: int = 10, ) -> list[dict]: """ Score, filter, and rank RSS items for briefing inclusion. Scoring: - Hard-exclude: any item tagged with an excluded topic is removed. - Base score: 0.0 - +2.0 per topic that appears in include_topics - +1.0 / -1.0 per topic based on reaction score (clamped per topic) - Tiebreak: newer published_at wins Returns up to max_items items, highest score first. Items with classified_at=None (unclassified) pass through with score=0. """ include_set = set(include_topics) exclude_set = set(exclude_topics) scored = [] for item in items: item_topics = item.get("topics") or [] # Hard exclude if exclude_set and any(t in exclude_set for t in item_topics): continue score = 0.0 for topic in item_topics: if topic in include_set: score += 2.0 if topic in topic_scores: score += max(-1.0, min(1.0, topic_scores[topic])) # Parse published_at for tiebreak pub_str = item.get("published_at") or "" try: pub_ts = datetime.fromisoformat(pub_str).timestamp() if pub_str else 0.0 except ValueError: pub_ts = 0.0 scored.append((score, pub_ts, item)) # Sort: highest score first, then newest first scored.sort(key=lambda x: (x[0], x[1]), reverse=True) return [item for _, _, item in scored[:max_items]]