"""Timeline agent — project milestone management (list, create, update, delete).""" from __future__ import annotations import re from datetime import datetime, timezone from typing import Any from langchain_core.tools import tool from app.core.ws_context import execute_on_client _UUID_RE = re.compile( r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$" ) def _is_uuid(value: str) -> bool: return bool(_UUID_RE.match(value)) @tool async def list_timelines( project_id: str = "", type: str = "", is_completed: int = -1, is_ai_suggested: int = -1, order_by: str = "", order_dir: str = "", date_from: int = -1, date_to: int = -1, created_at_from: int = -1, created_at_to: int = -1, completed_at_from: int = -1, completed_at_to: int = -1, limit: int = 50, offset: int = 0, ) -> str: """List timeline events (milestones, checkpoints, activities) with optional filters. project_id: UUID to scope results to a specific project. type: filter by event type — milestone | checkpoint | activity. is_completed: 0 = incomplete only, 1 = completed only, -1 = any (default). is_ai_suggested: 0 or 1 to filter by AI-suggested flag; -1 = any. order_by: sort field — date (default) | createdAt | completedAt. order_dir: asc (default) | desc. date_from / date_to: ms epoch range for the event date. Use -1 to omit. created_at_from / created_at_to: ms epoch range for createdAt. Use -1 to omit. completed_at_from / completed_at_to: ms epoch range for completedAt. Use -1 to omit. limit: max rows to return (default 50). Use with offset to paginate. offset: skip first N rows (default 0). Tip — combine *_from and *_to for a closed range; pass only one for open-ended. Tip — prefer count_timelines for "how many" questions to avoid listing rows. Tip — for natural-language windows ("today", "this week", "last month", etc.) take date_from / date_to verbatim from the DATE CONTEXT block in the system prompt; do not compute boundaries from the current UTC instant. """ normalized_project_id = project_id if (project_id and _is_uuid(project_id)) else "" filters: dict[str, Any] = { "projectId": normalized_project_id or None, "orderBy": order_by or None, "orderDir": order_dir or None, "limit": limit, "offset": offset, } if type: filters["type"] = type if is_completed != -1: filters["isCompleted"] = is_completed if is_ai_suggested != -1: filters["isAiSuggested"] = is_ai_suggested if date_from != -1: filters["dateFrom"] = date_from if date_to != -1: filters["dateTo"] = date_to if created_at_from != -1: filters["createdAtFrom"] = created_at_from if created_at_to != -1: filters["createdAtTo"] = created_at_to if completed_at_from != -1: filters["completedAtFrom"] = completed_at_from if completed_at_to != -1: filters["completedAtTo"] = completed_at_to result = await execute_on_client(action="select", table="timelines", filters=filters) rows = result.get("rows", []) if not rows: return "No timeline events found." lines = [ f"- {r['title']} (date: {r['date']}, type: {r.get('type')}, " f"completed: {bool(r.get('isCompleted'))}, completedAt: {r.get('completedAt')}, " f"projectId: {r.get('projectId')}, id: {r['id']})" for r in rows ] return f"Found {len(rows)} timeline event(s):\n" + "\n".join(lines) @tool async def count_timelines( project_id: str = "", type: str = "", is_completed: int = -1, is_ai_suggested: int = -1, date_from: int = -1, date_to: int = -1, created_at_from: int = -1, created_at_to: int = -1, completed_at_from: int = -1, completed_at_to: int = -1, ) -> str: """Count timeline events matching the given filters without returning rows. Use this instead of list_timelines for "how many" questions — it is much cheaper. Same filter parameters as list_timelines (no limit/offset/order_by needed). date_from / date_to: ms epoch range for the event date. Use -1 to omit. completed_at_from / completed_at_to: ms epoch range for completedAt. Use -1 to omit. Tip — for natural-language windows take date_from / date_to from the DATE CONTEXT block; do not compute boundaries from the current UTC instant. """ normalized_project_id = project_id if (project_id and _is_uuid(project_id)) else "" filters: dict[str, Any] = {"projectId": normalized_project_id or None} if type: filters["type"] = type if is_completed != -1: filters["isCompleted"] = is_completed if is_ai_suggested != -1: filters["isAiSuggested"] = is_ai_suggested if date_from != -1: filters["dateFrom"] = date_from if date_to != -1: filters["dateTo"] = date_to if created_at_from != -1: filters["createdAtFrom"] = created_at_from if created_at_to != -1: filters["createdAtTo"] = created_at_to if completed_at_from != -1: filters["completedAtFrom"] = completed_at_from if completed_at_to != -1: filters["completedAtTo"] = completed_at_to result = await execute_on_client(action="count", table="timelines", filters=filters) return f"Timeline event count: {result.get('count', 0)}" @tool async def create_timeline( project_id: str, title: str, date: int, type: str = "milestone", is_completed: int = 0, is_ai_suggested: int = 0, ) -> str: """Create a project timeline event. project_id: REQUIRED UUID of the parent project title: descriptive name for the event date: Unix timestamp in milliseconds for the event date type: milestone (default) | checkpoint | activity is_completed: 1 if already completed, 0 if not (default 0) is_ai_suggested: 1 if proactively suggested, 0 if user-requested completedAt is set automatically when is_completed is 1. """ result = await execute_on_client( action="insert", table="timelines", data={ "projectId": project_id, "title": title, "date": date, "type": type, "isCompleted": is_completed, "isAiSuggested": is_ai_suggested, }, ) row = result["row"] return f"Timeline event created: '{row['title']}' (id: {row['id']}, date: {row['date']}, type: {row.get('type')})" @tool async def update_timeline( timeline_id: str, title: str = "", date: int = -1, is_completed: int = -1, ) -> str: """Update a timeline event. Only pass fields that should change. timeline_id: UUID of the event (required) date: -1 means unchanged; any other value sets the new date (ms timestamp) is_completed: 0 = mark incomplete, 1 = mark complete, -1 = unchanged completedAt is managed automatically: - setting is_completed to 1 records the current timestamp - setting is_completed to 0 clears completedAt """ updates: dict[str, Any] = {} if title: updates["title"] = title if date != -1: updates["date"] = date if is_completed != -1: updates["isCompleted"] = is_completed result = await execute_on_client( action="update", table="timelines", data={"id": timeline_id, "updates": updates}, ) row = result["row"] return f"Timeline event updated: '{row['title']}' (id: {row['id']})" @tool async def delete_timeline(timeline_id: str) -> str: """Delete a timeline event permanently by its UUID.""" await execute_on_client(action="delete", table="timelines", data={"id": timeline_id}) return f"Timeline event {timeline_id} deleted." @tool async def list_timelines_today(user_timezone: str = "UTC", include_completed: bool = True) -> str: """List all timeline events whose date falls on today. user_timezone: IANA timezone name (e.g. 'Europe/Rome', 'America/New_York'). Always pass the user's timezone so 'today' is computed in their local time. include_completed: set False to exclude already-completed events (default True). """ try: from zoneinfo import ZoneInfo tz = ZoneInfo(user_timezone or "UTC") except Exception: tz = timezone.utc now_local = datetime.now(tz=tz) start_dt = datetime(now_local.year, now_local.month, now_local.day, tzinfo=tz) start_ms = int(start_dt.timestamp() * 1000) end_ms = start_ms + 86_400_000 - 1 filters: dict[str, Any] = {"dateFrom": start_ms, "dateTo": end_ms} if not include_completed: filters["isCompleted"] = 0 result = await execute_on_client( action="select", table="timelines", filters=filters, ) rows = result.get("rows", []) if not rows: return "No timeline events today." lines = [ f"- {r['title']} (date: {r['date']}, type: {r.get('type')}, " f"completed: {bool(r.get('isCompleted'))}, projectId: {r.get('projectId')}, id: {r['id']})" for r in rows ] return f"Timeline events today ({len(rows)}):\n" + "\n".join(lines) TIMELINE_TOOLS: list[Any] = [ list_timelines, count_timelines, list_timelines_today, create_timeline, update_timeline, delete_timeline, ] TIMELINE_READ_TOOLS: list[Any] = [ list_timelines, count_timelines, list_timelines_today, ]