3b71549b91
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
111 lines
3.6 KiB
Python
111 lines
3.6 KiB
Python
"""
|
|
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]]
|