Align the L3 graph, agent service, and sync tool shims on one canonical continuity contract so clarification resumes and persisted snapshots behave consistently. Add targeted regressions and hardening notes covering system-message coalescing, async bridge usage, and continuity rehydration.
302 lines
10 KiB
Python
302 lines
10 KiB
Python
"""Agent 工具集 - 日程相关"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date, datetime
|
||
from zoneinfo import ZoneInfo
|
||
|
||
from langchain_core.tools import tool
|
||
from sqlalchemy import select
|
||
|
||
from app.agents.context import get_current_user
|
||
from app.agents.tools.async_bridge import run_async
|
||
from app.database import async_session
|
||
from app.models.goal import Goal, GoalStatus
|
||
from app.models.reminder import Reminder
|
||
from app.models.task import Task, TaskPriority, TaskStatus
|
||
from app.models.todo import DailyTodo, TodoSource
|
||
|
||
|
||
def _run_async(coro, timeout: int = 30):
|
||
return run_async(coro, timeout=timeout)
|
||
|
||
|
||
def _parse_date(value: str | None) -> date:
|
||
if not value:
|
||
return date.today()
|
||
return date.fromisoformat(value)
|
||
|
||
|
||
def _parse_datetime(value: str) -> datetime:
|
||
normalized = value.strip().replace("Z", "+00:00")
|
||
return datetime.fromisoformat(normalized)
|
||
|
||
|
||
def _parse_datetime_with_timezone(value: str, time_zone: str | None) -> datetime:
|
||
"""Parse an ISO datetime and return a tz-naive datetime in the intended local time.
|
||
|
||
- If value includes an offset/Z, it will be converted to `time_zone` when provided.
|
||
- If value is naive and `time_zone` is provided, it is interpreted in that zone.
|
||
"""
|
||
parsed = _parse_datetime(value)
|
||
tz = (time_zone or "").strip()
|
||
if parsed.tzinfo is None:
|
||
if tz:
|
||
parsed = parsed.replace(tzinfo=ZoneInfo(tz))
|
||
return parsed.replace(tzinfo=None)
|
||
|
||
if tz:
|
||
parsed = parsed.astimezone(ZoneInfo(tz))
|
||
return parsed.replace(tzinfo=None)
|
||
|
||
|
||
def _normalize_title(title: str | None, content: str | None) -> str:
|
||
resolved = (title or content or "").strip()
|
||
if not resolved:
|
||
raise ValueError("title 不能为空")
|
||
return resolved
|
||
|
||
|
||
def _normalize_schedule_due_date(due_date: str | None, date_value: str | None) -> str | None:
|
||
resolved = (due_date or date_value or "").strip()
|
||
if not resolved:
|
||
return None
|
||
if "T" in resolved:
|
||
return resolved
|
||
return f"{resolved}T09:00:00"
|
||
|
||
|
||
def _format_summary(target_date: date, todos: list[DailyTodo], tasks: list[Task], reminders: list[Reminder], goals: list[Goal]) -> str:
|
||
lines = [f"日期: {target_date.isoformat()}"]
|
||
|
||
if todos:
|
||
lines.append("待办:")
|
||
lines.extend(f"- {item.title} | 完成:{'是' if item.is_completed else '否'}" for item in todos)
|
||
else:
|
||
lines.append("待办: 无")
|
||
|
||
if tasks:
|
||
lines.append("任务:")
|
||
lines.extend(
|
||
f"- {item.title} | 状态:{item.status.value if hasattr(item.status, 'value') else item.status} | 优先级:{item.priority.value if hasattr(item.priority, 'value') else item.priority} | 截止:{item.due_date.isoformat() if item.due_date else '无'}"
|
||
for item in tasks
|
||
)
|
||
else:
|
||
lines.append("任务: 无")
|
||
|
||
if reminders:
|
||
lines.append("提醒:")
|
||
lines.extend(f"- {item.title} | 时间:{item.reminder_at.isoformat()}" for item in reminders)
|
||
else:
|
||
lines.append("提醒: 无")
|
||
|
||
if goals:
|
||
lines.append("目标:")
|
||
lines.extend(
|
||
f"- {item.title} | 状态:{item.status.value if hasattr(item.status, 'value') else item.status}"
|
||
for item in goals
|
||
)
|
||
else:
|
||
lines.append("目标: 无")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
@tool
|
||
def get_schedule_day(target_date: str | None = None) -> str:
|
||
"""获取指定日期的 todo/task/reminder/goal 聚合信息。target_date 格式 YYYY-MM-DD,默认今天。"""
|
||
uid = get_current_user()
|
||
parsed_date = _parse_date(target_date)
|
||
date_key = parsed_date.isoformat()
|
||
start_dt = datetime.combine(parsed_date, datetime.min.time())
|
||
end_dt = datetime.combine(parsed_date, datetime.max.time())
|
||
|
||
async def _get():
|
||
async with async_session() as db:
|
||
todos = (
|
||
await db.execute(
|
||
select(DailyTodo)
|
||
.where(DailyTodo.user_id == uid, DailyTodo.todo_date == date_key)
|
||
.order_by(DailyTodo.created_at.desc())
|
||
)
|
||
).scalars().all()
|
||
tasks = (
|
||
await db.execute(
|
||
select(Task)
|
||
.where(
|
||
Task.user_id == uid,
|
||
Task.due_date.is_not(None),
|
||
Task.due_date >= start_dt,
|
||
Task.due_date <= end_dt,
|
||
)
|
||
.order_by(Task.created_at.desc())
|
||
)
|
||
).scalars().all()
|
||
reminders = (
|
||
await db.execute(
|
||
select(Reminder)
|
||
.where(
|
||
Reminder.user_id == uid,
|
||
Reminder.reminder_at >= start_dt,
|
||
Reminder.reminder_at <= end_dt,
|
||
)
|
||
.order_by(Reminder.reminder_at.asc(), Reminder.created_at.asc())
|
||
)
|
||
).scalars().all()
|
||
goals = (
|
||
await db.execute(
|
||
select(Goal)
|
||
.where(Goal.user_id == uid, Goal.goal_date == date_key)
|
||
.order_by(Goal.created_at.desc())
|
||
)
|
||
).scalars().all()
|
||
return _format_summary(parsed_date, todos, tasks, reminders, goals)
|
||
|
||
try:
|
||
return _run_async(_get())
|
||
except Exception as exc:
|
||
return f"获取日程失败: {exc}"
|
||
|
||
|
||
@tool
|
||
def create_todo(title: str, todo_date: str | None = None) -> str:
|
||
"""创建指定日期的待办。todo_date 格式 YYYY-MM-DD,默认今天。"""
|
||
uid = get_current_user()
|
||
parsed_date = _parse_date(todo_date)
|
||
|
||
async def _create():
|
||
async with async_session() as db:
|
||
todo = DailyTodo(
|
||
user_id=uid,
|
||
title=title,
|
||
source=TodoSource.AI_CHAT,
|
||
todo_date=parsed_date.isoformat(),
|
||
)
|
||
db.add(todo)
|
||
await db.commit()
|
||
await db.refresh(todo)
|
||
return f"TODO创建成功: [{todo.id[:8]}] {todo.title} @ {todo.todo_date}"
|
||
|
||
try:
|
||
return _run_async(_create())
|
||
except Exception as exc:
|
||
return f"创建TODO失败: {exc}"
|
||
|
||
|
||
@tool
|
||
def create_schedule_task(
|
||
title: str = "",
|
||
description: str = "",
|
||
priority: str = "medium",
|
||
due_date: str | None = None,
|
||
content: str = "",
|
||
date: str | None = None,
|
||
) -> str:
|
||
"""创建任务。priority 支持 low/medium/high/urgent;due_date 使用 ISO datetime。兼容 content/date 别名。"""
|
||
uid = get_current_user()
|
||
resolved_title = _normalize_title(title, content)
|
||
resolved_due_date = _normalize_schedule_due_date(due_date, date)
|
||
|
||
async def _create():
|
||
async with async_session() as db:
|
||
task = Task(
|
||
user_id=uid,
|
||
title=resolved_title,
|
||
description=description or content or None,
|
||
priority=TaskPriority(priority),
|
||
due_date=_parse_datetime(resolved_due_date) if resolved_due_date else None,
|
||
status=TaskStatus.TODO,
|
||
)
|
||
db.add(task)
|
||
await db.commit()
|
||
await db.refresh(task)
|
||
due_label = task.due_date.isoformat() if task.due_date else "无截止时间"
|
||
return f"任务创建成功: [{task.id[:8]}] {task.title} | 优先级:{task.priority.value} | 截止:{due_label}"
|
||
|
||
try:
|
||
return _run_async(_create())
|
||
except Exception as exc:
|
||
return f"创建任务失败: {exc}"
|
||
|
||
|
||
@tool
|
||
def create_reminder(
|
||
title: str = "",
|
||
reminder_at: str | None = None,
|
||
note: str = "",
|
||
description: str = "",
|
||
datetime: str = "",
|
||
at: str = "",
|
||
remind_at: str = "",
|
||
content: str = "",
|
||
time_zone: str = "",
|
||
timezone: str = "",
|
||
time: str = "",
|
||
) -> str:
|
||
"""创建提醒。reminder_at 使用 ISO datetime。兼容 description/datetime/at/remind_at/time_zone 别名。"""
|
||
uid = get_current_user()
|
||
|
||
try:
|
||
resolved_title = (title or content or "").strip()
|
||
if not resolved_title:
|
||
raise ValueError("title 不能为空")
|
||
|
||
resolved_at = ((reminder_at or datetime or at or remind_at or time or "").strip())
|
||
if not resolved_at:
|
||
raise ValueError("reminder_at 不能为空")
|
||
|
||
resolved_note = (note or description or "").strip()
|
||
|
||
async def _create():
|
||
async with async_session() as db:
|
||
tz = (time_zone or timezone or "").strip()
|
||
reminder = Reminder(
|
||
user_id=uid,
|
||
title=resolved_title,
|
||
note=resolved_note or None,
|
||
reminder_at=_parse_datetime_with_timezone(resolved_at, tz),
|
||
)
|
||
db.add(reminder)
|
||
await db.commit()
|
||
await db.refresh(reminder)
|
||
return f"提醒创建成功: [{reminder.id[:8]}] {reminder.title} @ {reminder.reminder_at.isoformat()}"
|
||
|
||
return _run_async(_create())
|
||
except Exception as exc:
|
||
return f"创建提醒失败: {exc}"
|
||
|
||
|
||
@tool
|
||
def create_goal(title: str, goal_date: str | None = None, note: str = "", status: str = "active") -> str:
|
||
"""创建指定日期目标。goal_date 格式 YYYY-MM-DD,默认今天;status 支持 active/done/archived。"""
|
||
uid = get_current_user()
|
||
parsed_date = _parse_date(goal_date)
|
||
|
||
async def _create():
|
||
async with async_session() as db:
|
||
goal = Goal(
|
||
user_id=uid,
|
||
title=title,
|
||
note=note or None,
|
||
goal_date=parsed_date.isoformat(),
|
||
status=GoalStatus(status),
|
||
)
|
||
db.add(goal)
|
||
await db.commit()
|
||
await db.refresh(goal)
|
||
return f"目标创建成功: [{goal.id[:8]}] {goal.title} @ {goal.goal_date}"
|
||
|
||
try:
|
||
return _run_async(_create())
|
||
except Exception as exc:
|
||
return f"创建目标失败: {exc}"
|
||
|
||
|
||
__all__ = [
|
||
"get_schedule_day",
|
||
"create_todo",
|
||
"create_schedule_task",
|
||
"create_reminder",
|
||
"create_goal",
|
||
]
|