fix: rss classifier think-tag stripping, briefing calendar dict access, embed empty-string guard

- rss_classifier: strip <think>...</think> blocks (qwen3 reasoning output)
  before JSON parse; use strict=False for control chars; bump timeout 30s→120s
- briefing_pipeline: list_events returns dicts not Event objects — fix
  attribute access (.all_day/.start_dt/.title) to dict access
- embeddings: guard upsert_note_embedding and semantic_search_notes against
  empty-string input to prevent Ollama embed 400 errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-05 13:14:55 -04:00
parent b5106441dd
commit db092b113e
3 changed files with 19 additions and 6 deletions
@@ -172,14 +172,18 @@ async def _gather_internal(user_id: int) -> dict:
user_id=user_id, date_from=day_start, date_to=day_end user_id=user_id, date_from=day_start, date_to=day_end
) )
for e in internal_events: for e in internal_events:
if e.all_day: if e.get("all_day"):
time_str = "all day" time_str = "all day"
elif e.start_dt: elif e.get("start_dt"):
local_dt = e.start_dt.astimezone(user_tz) if e.start_dt.tzinfo else e.start_dt.replace(tzinfo=timezone.utc).astimezone(user_tz) from datetime import datetime as _dt
start = e["start_dt"]
if isinstance(start, str):
start = _dt.fromisoformat(start)
local_dt = start.astimezone(user_tz) if start.tzinfo else start.replace(tzinfo=timezone.utc).astimezone(user_tz)
time_str = local_dt.strftime("%-I:%M %p") time_str = local_dt.strftime("%-I:%M %p")
else: else:
time_str = "unknown time" time_str = "unknown time"
calendar_events.append(f"{e.title} at {time_str}") calendar_events.append(f"{e.get('title', 'Event')} at {time_str}")
except Exception: except Exception:
logger.warning("Failed to gather internal calendar events for briefing", exc_info=True) logger.warning("Failed to gather internal calendar events for briefing", exc_info=True)
# Also pull CalDAV events (deduped) # Also pull CalDAV events (deduped)
@@ -63,6 +63,8 @@ def _cosine_similarity(a: list[float], b: list[float]) -> float:
async def upsert_note_embedding(note_id: int, user_id: int, text: str) -> None: async def upsert_note_embedding(note_id: int, user_id: int, text: str) -> None:
"""Generate and persist an embedding for a note. Safe to fire-and-forget.""" """Generate and persist an embedding for a note. Safe to fire-and-forget."""
if not text or not text.strip():
return
try: try:
embedding = await get_embedding(text) embedding = await get_embedding(text)
except Exception: except Exception:
@@ -97,6 +99,8 @@ async def semantic_search_notes(
*threshold* are returned, sorted highest-first. *threshold* are returned, sorted highest-first.
Returns an empty list if the embedding model is unavailable or on any error. Returns an empty list if the embedding model is unavailable or on any error.
""" """
if not query or not query.strip():
return []
try: try:
query_vec = await get_embedding(query) query_vec = await get_embedding(query)
except Exception: except Exception:
@@ -7,6 +7,7 @@ Called from rss.py after new items are stored — fire-and-forget.
import json import json
import logging import logging
import re
from datetime import datetime, timezone from datetime import datetime, timezone
import httpx import httpx
@@ -37,7 +38,7 @@ async def _llm_classify(prompt: str, model: str) -> str:
"stream": False, "stream": False,
"options": {"num_ctx": 2048, "temperature": 0.0}, "options": {"num_ctx": 2048, "temperature": 0.0},
} }
async with httpx.AsyncClient(timeout=30.0) as client: async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(f"{Config.OLLAMA_URL}/api/chat", json=payload) resp = await client.post(f"{Config.OLLAMA_URL}/api/chat", json=payload)
resp.raise_for_status() resp.raise_for_status()
return resp.json().get("message", {}).get("content", "") return resp.json().get("message", {}).get("content", "")
@@ -75,13 +76,17 @@ async def classify_items_batch(
try: try:
raw = await _llm_classify(prompt, model) raw = await _llm_classify(prompt, model)
# Strip <think>...</think> blocks emitted by reasoning models (e.g. qwen3)
raw = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL)
# Extract JSON from response (LLM may wrap it in markdown) # Extract JSON from response (LLM may wrap it in markdown)
raw = raw.strip() raw = raw.strip()
if raw.startswith("```"): if raw.startswith("```"):
raw = raw.split("```")[1] raw = raw.split("```")[1]
if raw.startswith("json"): if raw.startswith("json"):
raw = raw[4:] raw = raw[4:]
parsed = json.loads(raw) raw = raw.strip()
# Allow control characters that may appear in LLM-generated JSON strings
parsed = json.loads(raw, strict=False)
return {int(k): v for k, v in parsed.items() if isinstance(v, list)} return {int(k): v for k, v in parsed.items() if isinstance(v, list)}
except Exception: except Exception:
logger.warning("RSS classification failed", exc_info=True) logger.warning("RSS classification failed", exc_info=True)