Files
FabledScribe/src/fabledassistant/services/recurrence.py
T

156 lines
5.7 KiB
Python

"""Recurring task rule validation, date calculation, and scheduled spawning."""
import asyncio
import calendar
import logging
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import and_, select
from fabledassistant.models import async_session
from fabledassistant.models.note import Note, TaskStatus
logger = logging.getLogger(__name__)
_VALID_UNITS = {"day", "week", "month", "year"}
def validate_recurrence_rule(rule: dict) -> None:
"""Raise ValueError if rule is structurally invalid."""
if not isinstance(rule, dict):
raise ValueError("recurrence_rule must be an object")
rule_type = rule.get("type")
if rule_type not in ("interval", "calendar"):
raise ValueError("recurrence_rule.type must be 'interval' or 'calendar'")
unit = rule.get("unit")
if unit not in _VALID_UNITS:
raise ValueError(f"recurrence_rule.unit must be one of {_VALID_UNITS}")
if rule_type == "interval":
every = rule.get("every")
if not isinstance(every, int) or every < 1:
raise ValueError("recurrence_rule.every must be a positive integer")
elif rule_type == "calendar":
day = rule.get("day_of_month")
if not isinstance(day, int) or not 1 <= day <= 31:
raise ValueError("recurrence_rule.day_of_month must be an integer between 1 and 31")
if unit == "year":
month = rule.get("month")
if not isinstance(month, int) or not 1 <= month <= 12:
raise ValueError("recurrence_rule.month must be an integer between 1 and 12")
elif unit != "month":
raise ValueError("calendar recurrence only supports unit 'month' or 'year'")
def _add_months(d: date, months: int) -> date:
month = d.month - 1 + months
year = d.year + month // 12
month = month % 12 + 1
day = min(d.day, calendar.monthrange(year, month)[1])
return d.replace(year=year, month=month, day=day)
def _next_day_of_month(from_date: date, day: int) -> date:
"""Return the next occurrence of day-of-month strictly after from_date."""
year, month = from_date.year, from_date.month
max_day = calendar.monthrange(year, month)[1]
clamped = min(day, max_day)
if clamped > from_date.day:
return from_date.replace(day=clamped)
# Advance one month
if month == 12:
year, month = year + 1, 1
else:
month += 1
return date(year, month, min(day, calendar.monthrange(year, month)[1]))
def _next_annual_date(from_date: date, month: int, day: int) -> date:
"""Return the next occurrence of month/day after from_date."""
max_day = calendar.monthrange(from_date.year, month)[1]
candidate = date(from_date.year, month, min(day, max_day))
if candidate > from_date:
return candidate
return date(
from_date.year + 1,
month,
min(day, calendar.monthrange(from_date.year + 1, month)[1]),
)
def calculate_next_due(rule: dict, from_date: date) -> date:
"""Return the next due date after from_date according to rule."""
rule_type = rule["type"]
unit = rule["unit"]
if rule_type == "interval":
every = rule["every"]
if unit == "day":
return from_date + timedelta(days=every)
if unit == "week":
return from_date + timedelta(weeks=every)
if unit == "month":
return _add_months(from_date, every)
if unit == "year":
return _add_months(from_date, every * 12)
elif rule_type == "calendar":
if unit == "month":
return _next_day_of_month(from_date, rule["day_of_month"])
if unit == "year":
return _next_annual_date(from_date, rule["month"], rule["day_of_month"])
raise ValueError(f"Unhandled recurrence rule: {rule!r}")
async def spawn_recurring_tasks() -> int:
"""Create child tasks for all recurring tasks whose spawn date has arrived.
Returns the number of tasks spawned.
"""
from fabledassistant.services.embeddings import upsert_note_embedding
from fabledassistant.services.notes import create_note
now = datetime.now(timezone.utc)
spawned = 0
async with async_session() as session:
result = await session.execute(
select(Note).where(
and_(
Note.recurrence_rule.isnot(None),
Note.recurrence_next_spawn_at <= now,
)
)
)
tasks = result.scalars().all()
for task in tasks:
base_date = task.due_date or now.date()
next_due = calculate_next_due(task.recurrence_rule, base_date)
try:
child = await create_note(
task.user_id,
title=task.title,
body=task.body or "",
status=TaskStatus.todo.value,
priority=task.priority or "none",
due_date=next_due,
tags=list(task.tags or []),
project_id=task.project_id,
milestone_id=task.milestone_id,
recurrence_rule=task.recurrence_rule,
)
text = f"{child.title}\n{child.body}".strip() if child.body else (child.title or "")
if text:
asyncio.create_task(upsert_note_embedding(child.id, task.user_id, text))
except Exception:
logger.exception("Failed to spawn recurring task %d", task.id)
continue
async with async_session() as session:
parent = await session.get(Note, task.id)
if parent:
parent.recurrence_next_spawn_at = None
await session.commit()
spawned += 1
logger.info("Spawned recurring task %d → child due %s", task.id, next_due)
return spawned