271 lines
9.3 KiB
Python
271 lines
9.3 KiB
Python
"""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,
|
|
]
|