diff --git a/src/fabledassistant/services/briefing_preferences.py b/src/fabledassistant/services/briefing_preferences.py new file mode 100644 index 0000000..9a142ac --- /dev/null +++ b/src/fabledassistant/services/briefing_preferences.py @@ -0,0 +1,110 @@ +""" +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]]