Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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())

Expand Down
20 changes: 20 additions & 0 deletions src/drive_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
135 changes: 135 additions & 0 deletions src/weekly.py
Original file line number Diff line number Diff line change
@@ -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"<!-- msg_id: \d+ -->\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)
36 changes: 36 additions & 0 deletions tests/test_weekly.py
Original file line number Diff line number Diff line change
@@ -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"
"<!-- msg_id: 1 -->\nfirst\n"
"<!-- msg_id: 2 -->\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"
Loading