diff --git a/src/bot.py b/src/bot.py index 3ba3551..2d0bbad 100644 --- a/src/bot.py +++ b/src/bot.py @@ -15,6 +15,7 @@ from config import config from google_auth import generate_auth_url, TokenStorage from timebox import build_timebox_handler +from weekly import week_command import drive_handler # Configure logging @@ -38,6 +39,7 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N "You are authenticated with Google Drive.\n" "Send me any message and I'll save it to your Drive.\n\n" "Commands:\n" + "/week - Weekly review of your captured notes\n" "/timebox - Plan a timeboxed schedule for tomorrow\n" "/status - Check connection status\n" "/logout - Disconnect Google Drive\n" @@ -69,6 +71,7 @@ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No "Edit a message here and it updates in Drive too.\n\n" "Commands:\n" "/authenticate - Connect your Google Drive\n" + "/week - Weekly review of your captured notes\n" "/timebox - Plan a timeboxed schedule for tomorrow\n" "/status - Check connection status\n" "/logout - Disconnect Google Drive\n" @@ -309,6 +312,7 @@ def register_handlers(application: Application) -> None: application.add_handler(CommandHandler("authenticate", authenticate_command)) application.add_handler(CommandHandler("status", status_command)) application.add_handler(CommandHandler("logout", logout_command)) + application.add_handler(CommandHandler("week", week_command)) application.add_handler(build_timebox_handler()) diff --git a/src/drive_handler.py b/src/drive_handler.py index bc55924..6f88518 100644 --- a/src/drive_handler.py +++ b/src/drive_handler.py @@ -250,6 +250,26 @@ def _replace_message_content( return file_content[:header_start] + new_block + file_content[content_end:] +def list_markdown_files(service, folder_id: str) -> list[dict]: + """Return the markdown files in the folder as [{id, name}, ...] (read-only).""" + try: + results = service.files().list( + q=f"mimeType='{MARKDOWN_MIME_TYPE}' and trashed=false and parents='{folder_id}'", + spaces='drive', + fields='files(id, name)', + pageSize=1000, + ).execute() + return results.get('files', []) + except Exception as e: + logger.error(f"Failed to list markdown files: {e}") + return [] + + +def read_file(service, file_id: str) -> str | None: + """Public read of a Drive file's text content, or None on failure.""" + return _download_file_content(service, file_id) + + def _download_file_content(service, file_id: str) -> str | None: """Download a file's content from Drive.""" try: diff --git a/src/weekly.py b/src/weekly.py new file mode 100644 index 0000000..c532691 --- /dev/null +++ b/src/weekly.py @@ -0,0 +1,135 @@ +"""/week: an LLM-summarized review of the last 7 days of captured notes. + +Higher-altitude counterpart to /today: surfaces recurring themes, action items, +and open loops across the week. Thin Telegram glue — Drive I/O lives in +drive_handler, the LLM in scheduler. File selection and parsing are pure +functions so they can be unit-tested without Drive. +""" + +import asyncio +import logging +import re +from functools import lru_cache + +from telegram import Update +from telegram.ext import ContextTypes + +from config import config +import drive_handler +import scheduler + +logger = logging.getLogger(__name__) + +_BLOCK_RE = re.compile(r"\n") +_DAILY_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})\.md$") + +_DAYS = 7 +_MAX_REPLY = 3900 + +_REVIEW_SYSTEM = ( + "You are the user's second-brain weekly reviewer. You receive the notes " + "they captured over the past week, grouped by day. Produce a concise review " + "with short bullet points under three headings: Themes (recurring topics), " + "Action items / open loops, and Highlights. Be specific and skip headings " + "with nothing to say." +) + + +def parse_blocks(content: str) -> list[str]: + """Captured note texts in order, stripped of the msg_id markers.""" + parts = _BLOCK_RE.split(content) + return [p.strip() for p in parts[1:] if p.strip()] + + +def select_recent_daily(files: list[dict], days: int = _DAYS) -> list[dict]: + """Pick the most recent `days` daily files (YYYY-MM-DD.md), newest first. + + Non-daily file names are ignored. Pure — unit-tested without Drive. + """ + daily = [f for f in files if _DAILY_RE.match(f.get("name", ""))] + daily.sort(key=lambda f: f["name"], reverse=True) + return daily[:days] + + +def build_review_input(days: list[tuple[str, list[str]]]) -> str: + """Render (date, notes) groups as the human message handed to the LLM.""" + out = [] + for date, notes in days: + out.append(f"## {date}") + out.extend(f"- {n}" for n in notes) + out.append("") + return "\n".join(out).strip() + + +@lru_cache(maxsize=1) +def _llm(): + return scheduler.create_llm( + api_key=config.openrouter_api_key, model=config.timebox_llm_model + ) + + +def _summarize(days: list[tuple[str, list[str]]]) -> str | None: + """LLM weekly review of the day-grouped notes, or None if the call fails.""" + try: + resp = _llm().invoke( + [("system", _REVIEW_SYSTEM), ("human", build_review_input(days))] + ) + text = (getattr(resp, "content", "") or "").strip() + return text or None + except Exception as e: + logger.warning(f"Weekly review summary failed: {e}") + return None + + +async def week_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle /week - summarize the notes captured over the last 7 days.""" + token_storage = context.bot_data.get("token_storage") + user_id = update.effective_user.id + if not token_storage or not token_storage.is_authenticated(user_id): + await update.message.reply_text( + "Please authenticate with Google Drive first using /authenticate" + ) + return + + service = drive_handler.get_drive_service(user_id, token_storage) + if not service: + await update.message.reply_text( + "Your Google Drive session expired — please /logout then /authenticate." + ) + return + + folder_id = drive_handler.get_or_create_folder(service, config.drive_folder_name) + if not folder_id: + await update.message.reply_text( + "Couldn't reach your Drive folder — please try again later." + ) + return + + files = select_recent_daily(drive_handler.list_markdown_files(service, folder_id)) + days: list[tuple[str, list[str]]] = [] + total = 0 + # Oldest-first reads better in the review. + for f in reversed(files): + notes = parse_blocks(drive_handler.read_file(service, f["id"]) or "") + if notes: + days.append((_DAILY_RE.match(f["name"]).group(1), notes)) + total += len(notes) + + if total == 0: + await update.message.reply_text("Nothing captured in the last 7 days.") + return + + summary = await asyncio.to_thread(_summarize, days) + + header = f"šŸ—“ Weekly review — {len(days)} day(s), {total} note(s)" + if summary: + reply = f"{header}\n\n{summary}" + else: + # LLM unavailable: fall back to a per-day count so the command still helps. + lines = [header, "", "(summary unavailable — per-day counts:)"] + lines += [f"• {date}: {len(notes)} note(s)" for date, notes in days] + reply = "\n".join(lines) + + if len(reply) > _MAX_REPLY: + reply = reply[:_MAX_REPLY] + "\n…(truncated)" + await update.message.reply_text(reply) diff --git a/tests/test_weekly.py b/tests/test_weekly.py new file mode 100644 index 0000000..5bb7d76 --- /dev/null +++ b/tests/test_weekly.py @@ -0,0 +1,36 @@ +from weekly import parse_blocks, select_recent_daily, build_review_input + + +def test_select_recent_daily_filters_and_sorts(): + files = [ + {"id": "a", "name": "2026-06-20.md"}, + {"id": "b", "name": "2026-06-25.md"}, + {"id": "c", "name": "notes.md"}, # non-daily, ignored + {"id": "d", "name": "2026-06-22.md"}, + {"id": "e", "name": "2026-06-26.md"}, + ] + picked = select_recent_daily(files, days=3) + assert [f["name"] for f in picked] == [ + "2026-06-26.md", "2026-06-25.md", "2026-06-22.md", + ] + + +def test_select_recent_daily_empty(): + assert select_recent_daily([{"id": "x", "name": "readme.md"}]) == [] + + +def test_parse_blocks(): + content = ( + "# Telegram Messages\n\n" + "\nfirst\n" + "\nsecond\n" + ) + assert parse_blocks(content) == ["first", "second"] + + +def test_build_review_input_groups_by_day(): + out = build_review_input([ + ("2026-06-25", ["a", "b"]), + ("2026-06-26", ["c"]), + ]) + assert out == "## 2026-06-25\n- a\n- b\n\n## 2026-06-26\n- c"