main
FastAPI-приложение навыка управления задачами для Яндекс Алисы.
Основная точка входа: обрабатывает POST-запросы от Яндекс Диалогов, парсит команды пользователя, выполняет CRUD-операции с задачами и возвращает ответы в формате JSON. Также предоставляет health-check и включает фоновые ассистенты (перенос задач, очистка Langfuse) через APScheduler.
1""" 2FastAPI-приложение навыка управления задачами для Яндекс Алисы. 3 4Основная точка входа: обрабатывает POST-запросы от Яндекс Диалогов, 5парсит команды пользователя, выполняет CRUD-операции с задачами 6и возвращает ответы в формате JSON. Также предоставляет health-check 7и включает фоновые ассистенты (перенос задач, очистка Langfuse) 8через APScheduler. 9""" 10 11import logging 12import os 13import re 14import sys 15import uuid 16 17from fastapi import FastAPI, HTTPException, Query, APIRouter 18from fastapi.staticfiles import StaticFiles 19from fastapi.middleware.cors import CORSMiddleware 20from datetime import datetime 21from typing import Optional 22from pydantic import BaseModel 23from fastapi.concurrency import asynccontextmanager 24from apscheduler.schedulers.asyncio import AsyncIOScheduler 25from apscheduler.triggers.cron import CronTrigger 26from langfuse import Langfuse 27from langfuse import observe 28from loguru import logger 29 30from Assistants.lf_cleaner import LangfuseCleanerAssistant 31from Assistants.otm import OTMAssistant 32from command_parser import CommandParser 33from config import config 34from models import AliceRequest 35from taskman import DB_FILE, TaskManager 36 37_langfuse_instance = None 38 39 40def get_langfuse() -> Langfuse: 41 """ 42 Возвращает экземпляр Langfuse для observability (синглтон). 43 44 При первом вызове создаёт подключение к Langfuse 45 с использованием переменных окружения LANGFUSE_PUBLIC_KEY, 46 LANGFUSE_SECRET_KEY и LANGFUSE_HOST. 47 48 Returns: 49 Langfuse: Экземпляр Langfuse-клиента 50 """ 51 global _langfuse_instance 52 if _langfuse_instance is None: 53 _langfuse_instance = Langfuse( 54 public_key=os.getenv("LANGFUSE_PUBLIC_KEY"), 55 secret_key=os.getenv("LANGFUSE_SECRET_KEY"), 56 host=os.getenv("LANGFUSE_HOST"), 57 ) 58 return _langfuse_instance 59 60 61class InterceptHandler(logging.Handler): 62 """ 63 Перехватчик логов из стандартного logging в loguru. 64 65 Перенаправляет все сообщения из логгеров uvicorn и fastapi 66 в форматированный вывод loguru. 67 """ 68 69 def emit(self, record): 70 """ 71 Обрабатывает и перенаправляет запись лога в loguru. 72 73 Args: 74 record: Запись лога из стандартного модуля logging 75 """ 76 try: 77 level = logger.level(record.levelname).name 78 except ValueError: 79 level = record.levelno 80 81 frame, depth = logging.currentframe(), 2 82 while frame.f_code.co_filename == logging.__file__: 83 frame = frame.f_back 84 depth += 1 85 86 logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) 87 88 89def setup_logging(): 90 """ 91 Настраивает логирование приложения. 92 93 Перенаправляет логи uvicorn и fastapi в loguru с цветным 94 форматированным выводом в stdout. Уровень DEBUG для всех 95 сообщений приложения. 96 """ 97 logger.remove() 98 99 logger.add( 100 sys.stdout, 101 format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | <level>{message}</level>", 102 level="DEBUG", 103 colorize=True, 104 ) 105 106 intercept_handler = InterceptHandler() 107 108 root_logger = logging.getLogger() 109 root_logger.handlers = [intercept_handler] 110 root_logger.setLevel(logging.INFO) 111 112 for logger_name in ["uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"]: 113 logging_logger = logging.getLogger(logger_name) 114 logging_logger.handlers = [intercept_handler] 115 logging_logger.setLevel(logging.INFO) 116 logging_logger.propagate = False 117 118 119@observe(name="Build response") 120def build_response(request: AliceRequest, text: str, tts: str | None = None) -> dict: 121 """ 122 Формирует ответ навыка Яндекс Алисы. 123 124 Args: 125 request: Входящий запрос Алисы (для копирования session/version) 126 text: Текстовый ответ навыка 127 tts: Озвучиваемый текст (если отличается от text) 128 129 Returns: 130 dict: Ответ в формате Яндекс Диалогов 131 """ 132 response: dict = { 133 "text": text, 134 "end_session": False, 135 } 136 if tts is not None: 137 response["tts"] = tts 138 return { 139 "version": request.version, 140 "session": { 141 "message_id": request.session.message_id, 142 "session_id": request.session.session_id, 143 "user_id": request.session.user_id, 144 "skill_id": request.session.skill_id, 145 }, 146 "response": response, 147 } 148 149 150@observe(name="Build error response") 151def build_error_response( 152 text: str = "Произошла ошибка при обработке запроса. Попробуйте еще раз.", 153) -> dict: 154 """ 155 Формирует ответ с сообщением об ошибке. 156 157 Args: 158 text: Текст ошибки (по умолчанию стандартное сообщение) 159 160 Returns: 161 dict: Ответ-заглушка с текстом ошибки 162 """ 163 return { 164 "version": "1.0", 165 "session": { 166 "message_id": 0, 167 "session_id": "", 168 "user_id": "", 169 "skill_id": "", 170 }, 171 "response": { 172 "text": text, 173 "end_session": False, 174 }, 175 } 176 177 178class TaskCreate(BaseModel): 179 """Модель для создания задачи через веб-API.""" 180 181 user_id: str 182 text: str 183 date: Optional[str] = None 184 185 186class TaskUpdate(BaseModel): 187 """Модель для обновления задачи через веб-API.""" 188 189 date: Optional[str] = None 190 completed: Optional[bool] = None 191 192 193web_router = APIRouter() 194 195 196@web_router.get("/api/tasks") 197async def web_list_tasks( 198 user_id: str, 199 date: Optional[str] = None, 200 order: Optional[str] = Query( 201 None 202 ), # "newest-first" или "oldest-first", по умолч. "newest-first" 203): 204 """Возвращает список задач пользователя с фильтрацией по дате и сортировкой.""" 205 if date == "all": 206 tasks = task_manager.get_all_tasks(user_id) 207 208 # Сортировка: сначала новые (с датой), потом без даты, затем по убыванию даты 209 def sort_key(task): 210 if not task.get("date"): 211 return ("0", datetime.min) # Без даты - в конец 212 has_future_date = task["date"] != "2100-01-01" 213 if has_future_date: 214 # Реальная дата - сортируем по убыванию (новые первыми) 215 try: 216 dt = datetime.strptime(task["date"], "%Y-%m-%d") 217 return ("1", dt) 218 except ValueError: 219 return ("1", datetime.min) 220 else: 221 # Задачи без даты (2100-01-01) - в конец 222 return ("0", datetime.max) 223 224 if order == "oldest-first": 225 tasks.sort(key=sort_key, reverse=False) # oldest-first: ascending 226 else: 227 tasks.sort(key=sort_key, reverse=True) # newest-first: descending 228 229 return {"tasks": tasks, "date": "all"} 230 if date is None: 231 date = task_manager.get_today_date() 232 tasks = task_manager.get_tasks_by_date(user_id, date) 233 return {"tasks": tasks, "date": date} 234 235 236@web_router.post("/api/tasks") 237async def web_create_task(body: TaskCreate): 238 """Создаёт новую задачу.""" 239 task_id = task_manager.add_task(body.user_id, body.text, body.date) 240 task = task_manager.get_task(body.user_id, task_id) 241 return {"task": task} 242 243 244@web_router.put("/api/tasks/{task_id}") 245async def web_update_task(task_id: int, body: TaskUpdate, user_id: str = Query(...)): 246 """Обновляет задачу (дату и/или статус выполнения).""" 247 if body.date is not None: 248 from datetime import datetime as _dt 249 250 try: 251 date = _dt.strptime(body.date, "%Y-%m-%d").strftime("%Y-%m-%d") 252 except ValueError: 253 date = task_manager.parse_date(body.date) 254 with task_manager._get_conn() as conn: 255 conn.execute( 256 "UPDATE tasks SET date = ? WHERE user_id = ? AND id = ?", 257 (date, user_id, task_id), 258 ) 259 if body.completed is not None: 260 if body.completed: 261 task_manager.complete_task(user_id, task_id) 262 task = task_manager.get_task(user_id, task_id) 263 if task is None: 264 raise HTTPException(status_code=404, detail="Task not found") 265 return {"task": task} 266 267 268@web_router.delete("/api/tasks/{task_id}") 269async def web_delete_task(task_id: int, user_id: str = Query(...)): 270 """Удаляет задачу по ID.""" 271 if task_manager.delete_task(user_id, task_id): 272 return {"deleted": True} 273 raise HTTPException(status_code=404, detail="Task not found") 274 275 276@web_router.get("/api/users") 277async def web_list_users(): 278 """Возвращает список пользователей из config.yaml.""" 279 users = {} 280 for name, ids in config.yaml.get("USERS", {}).items(): 281 users[name] = ids 282 return {"users": users} 283 284 285@asynccontextmanager 286async def lifespan(app: FastAPI): 287 """ 288 Управляет жизненным циклом FastAPI-приложения. 289 290 При запуске настраивает логирование и запускает APScheduler 291 с фоновыми ассистентами (перенос задач, очистка Langfuse). 292 При остановке корректно завершает планировщик. 293 """ 294 setup_logging() 295 296 scheduler = AsyncIOScheduler() 297 298 def to_cron(time_str: str) -> str: 299 """ 300 Преобразует время в формате HH:MM в cron-выражение. 301 302 Args: 303 time_str: Время в формате "ЧЧ:ММ" 304 305 Returns: 306 str: Cron-выражение для ежедневного запуска 307 """ 308 h, m = time_str.split(":") 309 return f"{m} {h} * * *" 310 311 if config.yaml["Assistants"]["TaskMover"]["ENABLED"]: 312 old_task_move_assistant = OTMAssistant( 313 name=config.yaml["Assistants"]["TaskMover"]["NAME"], 314 task_manager=task_manager, 315 ) 316 scheduler.add_job( 317 old_task_move_assistant.run, 318 CronTrigger.from_crontab(to_cron(config.yaml["Assistants"]["TaskMover"]["TIME"])), 319 id="old_task_mover", 320 replace_existing=True, 321 ) 322 logger.info(f'Ассистент "{config.yaml["Assistants"]["TaskMover"]["NAME"]}" включён') 323 324 if config.yaml["Assistants"]["LangfuseCleaner"]["ENABLED"]: 325 lf_cleaner_assistant = LangfuseCleanerAssistant( 326 project_slug=config.yaml["Assistants"]["LangfuseCleaner"]["PROJECT"], 327 period=config.yaml["Assistants"]["LangfuseCleaner"]["RETENTION_DAYS"], 328 ) 329 scheduler.add_job( 330 lf_cleaner_assistant.run, 331 CronTrigger.from_crontab(to_cron(config.yaml["Assistants"]["LangfuseCleaner"]["TIME"])), 332 id="lf_cleaner", 333 replace_existing=True, 334 ) 335 logger.info(f'Ассистент "{config.yaml["Assistants"]["LangfuseCleaner"]["NAME"]}" включён') 336 337 scheduler.start() 338 logger.info("Приложение запущено") 339 yield 340 scheduler.shutdown(wait=False) 341 logger.info("Приложение остановлено") 342 343 344app = FastAPI( 345 title="Alice Todo Skill", 346 description="Навык для Яндекс Алисы для управления задачами", 347 version="1.0.0", 348 lifespan=lifespan, 349) 350 351task_manager = TaskManager() 352command_parser = CommandParser() 353 354app.include_router(web_router, prefix="/web") 355 356if os.path.isdir(os.path.join(os.path.dirname(__file__), "web", "dist")): 357 app.mount( 358 "/web", 359 StaticFiles(directory=os.path.join(os.path.dirname(__file__), "web", "dist"), html=True), 360 name="web", 361 ) 362 363 364@app.post("/") 365@observe(name="Alice Todo Skill Observability") 366async def handle_alice(request: AliceRequest): 367 """ 368 Основной обработчик запросов от Яндекс Алисы. 369 370 Принимает POST-запрос от Диалогов, проверяет skill_id, 371 парсит команду пользователя и выполняет соответствующее действие 372 (добавление, просмотр, выполнение, удаление, перенос задач 373 или вывод справки). Возвращает ответ в формате Яндекс Диалогов. 374 375 Args: 376 request: Валидированный запрос от Яндекс Алисы 377 378 Returns: 379 dict: Ответ навыка в формате Яндекс Диалогов 380 381 Raises: 382 HTTPException: 403 если skill_id не совпадает 383 """ 384 request_id = uuid.uuid4().hex[:8] 385 log = logger.bind(request_id=request_id) 386 387 if request.session.skill_id != os.getenv("SKILL_ID"): 388 log.warning(f"Неверный skill_id: {request.session.skill_id}") 389 raise HTTPException(status_code=403, detail="Forbidden") 390 391 try: 392 user_id = request.session.user_id 393 for user in config.yaml["USERS"]: 394 if user_id in config.yaml["USERS"][user]: 395 user_id = user 396 break 397 398 command = request.request.command.lower() 399 original_utterance = request.request.original_utterance.lower() 400 401 log.info(f"Запрос от пользователя {user_id}: {command}") 402 403 parsed = command_parser.parse(command, original_utterance) 404 response_tts = None 405 406 if parsed is None: 407 response_text = "Мой ежедневник" 408 response_tts = "-" 409 elif parsed.action == "ping": 410 response_text = "pong" 411 elif parsed.action == "add_task": 412 if parsed.date_keyword is None: 413 task_id = task_manager.add_task(user_id, parsed.task_text) 414 response_text = f"Задача '{parsed.task_text}' добавлена. ID задачи: {task_id}" 415 else: 416 date = task_manager.parse_date(parsed.date_keyword) 417 task_id = task_manager.add_task(user_id, parsed.task_text, date) 418 response_text = f"Задача '{parsed.task_text}' добавлена на {parsed.date_keyword}. ID задачи: {task_id}" 419 elif parsed.action == "no_task_text": 420 response_text = "Не смогла понять, какую задачу добавить." 421 elif parsed.action == "list_tasks": 422 date = task_manager.parse_date(parsed.date_keyword) 423 tasks = task_manager.get_tasks_by_date(user_id, date) 424 incomplete_count = task_manager.get_incomplete_tasks_count(user_id, date) 425 426 if incomplete_count == 0: 427 response_text = f"На {parsed.date_keyword} невыполненных задач нет." 428 else: 429 prefix = f"На {parsed.date_keyword} у вас {incomplete_count} невыполненных задач: " 430 response_text = prefix + task_manager.format_task_list_truncated( 431 tasks, show_completed=False, max_chars=1024 - len(prefix) 432 ) 433 tts_formatted = task_manager.format_task_list_truncated( 434 tasks, show_completed=False, max_chars=1024 435 ) 436 if "\nи еще" in tts_formatted or tts_formatted.startswith("Не показано"): 437 summary = task_manager.get_incomplete_tasks_summary(user_id) 438 response_tts = ( 439 f"У вас {summary['total']} невыполненных задач. " 440 f"На сегодня {summary['today']}, на завтра {summary['tomorrow']}." 441 ) 442 else: 443 response_tts = tts_formatted 444 elif parsed.action == "help": 445 response_text = ( 446 "Я помогу вам управлять задачами. Вот что я умею:\n" 447 "• 'Добавь задачу купить молоко' - добавить задачу без срока\n" 448 "• 'Добавь задачу на завтра сходить к врачу' - добавить на завтра\n" 449 "• 'Добавь задачу на послезавтра ...' - добавить на послезавтра\n" 450 "• 'Какие задачи на сегодня?' - показать невыполненные задачи\n" 451 "• 'Дела на завтра' - показать задачи на завтра\n" 452 "• 'Что купить?' - найти задачи, начинающиеся с 'купить'\n" 453 "• 'Пометь задачу 3 выполненной' - отметить задачу как выполненную\n" 454 "• 'Удали задачу 2' / 'убери задачу 2' - удалить задачу\n" 455 "• 'Перенеси задачу 1 на завтра' - перенести одну задачу\n" 456 "• 'Перенеси задачи на завтра' - перенести все задачи" 457 ) 458 elif parsed.action == "what": 459 date = task_manager.parse_date(parsed.date_keyword) 460 tasks = task_manager.get_tasks_by_date(user_id, date) 461 filtered_tasks = [t for t in tasks if t["text"].lower().startswith(parsed.keyword)] 462 incomplete_count = sum(1 for t in filtered_tasks if not t["completed"]) 463 464 if incomplete_count == 0: 465 response_text = ( 466 f"На {parsed.date_keyword} нет задач, начинающихся с '{parsed.keyword}'." 467 ) 468 else: 469 prefix = f"На {parsed.date_keyword} у вас {incomplete_count} задач: " 470 response_text = prefix + task_manager.format_task_list_truncated( 471 filtered_tasks, show_completed=False, max_chars=1024 - len(prefix) 472 ) 473 tts_formatted = task_manager.format_task_list_truncated( 474 filtered_tasks, show_completed=False, max_chars=1024 475 ) 476 if "\nи еще" in tts_formatted or tts_formatted.startswith("Не показано"): 477 summary = task_manager.get_incomplete_tasks_summary(user_id) 478 response_tts = ( 479 f"У вас {summary['total']} невыполненных задач. " 480 f"На сегодня {summary['today']}, на завтра {summary['tomorrow']}." 481 ) 482 else: 483 response_tts = tts_formatted 484 elif parsed.action == "complete": 485 if task_manager.complete_task(user_id, parsed.task_id): 486 response_text = f"Задача {parsed.task_id} отмечена как выполненная." 487 else: 488 response_text = f"Задача с номером {parsed.task_id} не найдена." 489 elif parsed.action == "delete": 490 if task_manager.delete_task(user_id, parsed.task_id): 491 response_text = f"Задача {parsed.task_id} удалена." 492 else: 493 response_text = f"Задача с номером {parsed.task_id} не найдена." 494 elif parsed.action == "no_task_id": 495 response_text = "Не смогла найти номер задачи." 496 elif parsed.action == "move_one": 497 date = task_manager.parse_date(parsed.date_keyword) 498 if task_manager.move_task(user_id, parsed.task_id, date): 499 response_text = f"Задача {parsed.task_id} перенесена на {parsed.date_keyword}." 500 else: 501 response_text = f"Задача с номером {parsed.task_id} не найдена." 502 elif parsed.action == "move_all": 503 date = task_manager.parse_date(parsed.date_keyword) 504 task_manager.move_tasks(user_id, date) 505 response_text = f"Задачи перенесены на {parsed.date_keyword}." 506 507 try: 508 get_langfuse().update_current_span( 509 metadata={ 510 "user_id": user_id, 511 "action": parsed.action if parsed else None, 512 "command": command, 513 "text_length": len(response_text), 514 "tts_length": len(response_tts) if response_tts else 0, 515 } 516 ) 517 except Exception: 518 pass 519 log.info(f"Отправляем ответ: {response_text}") 520 return build_response(request, response_text, tts=response_tts) 521 522 except Exception as e: 523 log.error(f"Ошибка обработки запроса: {e}") 524 return build_error_response() 525 526 527@app.get("/") 528async def root(): 529 """ 530 Корневой эндпоинт для проверки доступности навыка. 531 532 Returns: 533 dict: Статус приложения и путь к файлу данных 534 """ 535 return { 536 "message": "Alice Todo Skill is running", 537 "status": "ok", 538 "data_file": DB_FILE, 539 } 540 541 542@app.get("/health") 543async def health_check(): 544 """ 545 Эндпоинт проверки здоровья приложения. 546 547 Returns: 548 dict: Статус health, временная метка и количество пользователей 549 """ 550 return { 551 "status": "healthy", 552 "timestamp": datetime.now().isoformat(), 553 "users_count": task_manager.get_user_count(), 554 } 555 556 557if __name__ == "__main__": 558 import uvicorn 559 560 uvicorn.run(app, host="0.0.0.0", port=8000)
41def get_langfuse() -> Langfuse: 42 """ 43 Возвращает экземпляр Langfuse для observability (синглтон). 44 45 При первом вызове создаёт подключение к Langfuse 46 с использованием переменных окружения LANGFUSE_PUBLIC_KEY, 47 LANGFUSE_SECRET_KEY и LANGFUSE_HOST. 48 49 Returns: 50 Langfuse: Экземпляр Langfuse-клиента 51 """ 52 global _langfuse_instance 53 if _langfuse_instance is None: 54 _langfuse_instance = Langfuse( 55 public_key=os.getenv("LANGFUSE_PUBLIC_KEY"), 56 secret_key=os.getenv("LANGFUSE_SECRET_KEY"), 57 host=os.getenv("LANGFUSE_HOST"), 58 ) 59 return _langfuse_instance
Возвращает экземпляр Langfuse для observability (синглтон).
При первом вызове создаёт подключение к Langfuse с использованием переменных окружения LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY и LANGFUSE_HOST.
Returns:
Langfuse: Экземпляр Langfuse-клиента
62class InterceptHandler(logging.Handler): 63 """ 64 Перехватчик логов из стандартного logging в loguru. 65 66 Перенаправляет все сообщения из логгеров uvicorn и fastapi 67 в форматированный вывод loguru. 68 """ 69 70 def emit(self, record): 71 """ 72 Обрабатывает и перенаправляет запись лога в loguru. 73 74 Args: 75 record: Запись лога из стандартного модуля logging 76 """ 77 try: 78 level = logger.level(record.levelname).name 79 except ValueError: 80 level = record.levelno 81 82 frame, depth = logging.currentframe(), 2 83 while frame.f_code.co_filename == logging.__file__: 84 frame = frame.f_back 85 depth += 1 86 87 logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
Перехватчик логов из стандартного logging в loguru.
Перенаправляет все сообщения из логгеров uvicorn и fastapi в форматированный вывод loguru.
70 def emit(self, record): 71 """ 72 Обрабатывает и перенаправляет запись лога в loguru. 73 74 Args: 75 record: Запись лога из стандартного модуля logging 76 """ 77 try: 78 level = logger.level(record.levelname).name 79 except ValueError: 80 level = record.levelno 81 82 frame, depth = logging.currentframe(), 2 83 while frame.f_code.co_filename == logging.__file__: 84 frame = frame.f_back 85 depth += 1 86 87 logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
Обрабатывает и перенаправляет запись лога в loguru.
Arguments:
- record: Запись лога из стандартного модуля logging
90def setup_logging(): 91 """ 92 Настраивает логирование приложения. 93 94 Перенаправляет логи uvicorn и fastapi в loguru с цветным 95 форматированным выводом в stdout. Уровень DEBUG для всех 96 сообщений приложения. 97 """ 98 logger.remove() 99 100 logger.add( 101 sys.stdout, 102 format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> | <level>{message}</level>", 103 level="DEBUG", 104 colorize=True, 105 ) 106 107 intercept_handler = InterceptHandler() 108 109 root_logger = logging.getLogger() 110 root_logger.handlers = [intercept_handler] 111 root_logger.setLevel(logging.INFO) 112 113 for logger_name in ["uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"]: 114 logging_logger = logging.getLogger(logger_name) 115 logging_logger.handlers = [intercept_handler] 116 logging_logger.setLevel(logging.INFO) 117 logging_logger.propagate = False
Настраивает логирование приложения.
Перенаправляет логи uvicorn и fastapi в loguru с цветным форматированным выводом в stdout. Уровень DEBUG для всех сообщений приложения.
120@observe(name="Build response") 121def build_response(request: AliceRequest, text: str, tts: str | None = None) -> dict: 122 """ 123 Формирует ответ навыка Яндекс Алисы. 124 125 Args: 126 request: Входящий запрос Алисы (для копирования session/version) 127 text: Текстовый ответ навыка 128 tts: Озвучиваемый текст (если отличается от text) 129 130 Returns: 131 dict: Ответ в формате Яндекс Диалогов 132 """ 133 response: dict = { 134 "text": text, 135 "end_session": False, 136 } 137 if tts is not None: 138 response["tts"] = tts 139 return { 140 "version": request.version, 141 "session": { 142 "message_id": request.session.message_id, 143 "session_id": request.session.session_id, 144 "user_id": request.session.user_id, 145 "skill_id": request.session.skill_id, 146 }, 147 "response": response, 148 }
Формирует ответ навыка Яндекс Алисы.
Arguments:
- request: Входящий запрос Алисы (для копирования session/version)
- text: Текстовый ответ навыка
- tts: Озвучиваемый текст (если отличается от text)
Returns:
dict: Ответ в формате Яндекс Диалогов
151@observe(name="Build error response") 152def build_error_response( 153 text: str = "Произошла ошибка при обработке запроса. Попробуйте еще раз.", 154) -> dict: 155 """ 156 Формирует ответ с сообщением об ошибке. 157 158 Args: 159 text: Текст ошибки (по умолчанию стандартное сообщение) 160 161 Returns: 162 dict: Ответ-заглушка с текстом ошибки 163 """ 164 return { 165 "version": "1.0", 166 "session": { 167 "message_id": 0, 168 "session_id": "", 169 "user_id": "", 170 "skill_id": "", 171 }, 172 "response": { 173 "text": text, 174 "end_session": False, 175 }, 176 }
Формирует ответ с сообщением об ошибке.
Arguments:
- text: Текст ошибки (по умолчанию стандартное сообщение)
Returns:
dict: Ответ-заглушка с текстом ошибки
179class TaskCreate(BaseModel): 180 """Модель для создания задачи через веб-API.""" 181 182 user_id: str 183 text: str 184 date: Optional[str] = None
Модель для создания задачи через веб-API.
187class TaskUpdate(BaseModel): 188 """Модель для обновления задачи через веб-API.""" 189 190 date: Optional[str] = None 191 completed: Optional[bool] = None
Модель для обновления задачи через веб-API.
197@web_router.get("/api/tasks") 198async def web_list_tasks( 199 user_id: str, 200 date: Optional[str] = None, 201 order: Optional[str] = Query( 202 None 203 ), # "newest-first" или "oldest-first", по умолч. "newest-first" 204): 205 """Возвращает список задач пользователя с фильтрацией по дате и сортировкой.""" 206 if date == "all": 207 tasks = task_manager.get_all_tasks(user_id) 208 209 # Сортировка: сначала новые (с датой), потом без даты, затем по убыванию даты 210 def sort_key(task): 211 if not task.get("date"): 212 return ("0", datetime.min) # Без даты - в конец 213 has_future_date = task["date"] != "2100-01-01" 214 if has_future_date: 215 # Реальная дата - сортируем по убыванию (новые первыми) 216 try: 217 dt = datetime.strptime(task["date"], "%Y-%m-%d") 218 return ("1", dt) 219 except ValueError: 220 return ("1", datetime.min) 221 else: 222 # Задачи без даты (2100-01-01) - в конец 223 return ("0", datetime.max) 224 225 if order == "oldest-first": 226 tasks.sort(key=sort_key, reverse=False) # oldest-first: ascending 227 else: 228 tasks.sort(key=sort_key, reverse=True) # newest-first: descending 229 230 return {"tasks": tasks, "date": "all"} 231 if date is None: 232 date = task_manager.get_today_date() 233 tasks = task_manager.get_tasks_by_date(user_id, date) 234 return {"tasks": tasks, "date": date}
Возвращает список задач пользователя с фильтрацией по дате и сортировкой.
237@web_router.post("/api/tasks") 238async def web_create_task(body: TaskCreate): 239 """Создаёт новую задачу.""" 240 task_id = task_manager.add_task(body.user_id, body.text, body.date) 241 task = task_manager.get_task(body.user_id, task_id) 242 return {"task": task}
Создаёт новую задачу.
245@web_router.put("/api/tasks/{task_id}") 246async def web_update_task(task_id: int, body: TaskUpdate, user_id: str = Query(...)): 247 """Обновляет задачу (дату и/или статус выполнения).""" 248 if body.date is not None: 249 from datetime import datetime as _dt 250 251 try: 252 date = _dt.strptime(body.date, "%Y-%m-%d").strftime("%Y-%m-%d") 253 except ValueError: 254 date = task_manager.parse_date(body.date) 255 with task_manager._get_conn() as conn: 256 conn.execute( 257 "UPDATE tasks SET date = ? WHERE user_id = ? AND id = ?", 258 (date, user_id, task_id), 259 ) 260 if body.completed is not None: 261 if body.completed: 262 task_manager.complete_task(user_id, task_id) 263 task = task_manager.get_task(user_id, task_id) 264 if task is None: 265 raise HTTPException(status_code=404, detail="Task not found") 266 return {"task": task}
Обновляет задачу (дату и/или статус выполнения).
269@web_router.delete("/api/tasks/{task_id}") 270async def web_delete_task(task_id: int, user_id: str = Query(...)): 271 """Удаляет задачу по ID.""" 272 if task_manager.delete_task(user_id, task_id): 273 return {"deleted": True} 274 raise HTTPException(status_code=404, detail="Task not found")
Удаляет задачу по ID.
277@web_router.get("/api/users") 278async def web_list_users(): 279 """Возвращает список пользователей из config.yaml.""" 280 users = {} 281 for name, ids in config.yaml.get("USERS", {}).items(): 282 users[name] = ids 283 return {"users": users}
Возвращает список пользователей из config.yaml.
286@asynccontextmanager 287async def lifespan(app: FastAPI): 288 """ 289 Управляет жизненным циклом FastAPI-приложения. 290 291 При запуске настраивает логирование и запускает APScheduler 292 с фоновыми ассистентами (перенос задач, очистка Langfuse). 293 При остановке корректно завершает планировщик. 294 """ 295 setup_logging() 296 297 scheduler = AsyncIOScheduler() 298 299 def to_cron(time_str: str) -> str: 300 """ 301 Преобразует время в формате HH:MM в cron-выражение. 302 303 Args: 304 time_str: Время в формате "ЧЧ:ММ" 305 306 Returns: 307 str: Cron-выражение для ежедневного запуска 308 """ 309 h, m = time_str.split(":") 310 return f"{m} {h} * * *" 311 312 if config.yaml["Assistants"]["TaskMover"]["ENABLED"]: 313 old_task_move_assistant = OTMAssistant( 314 name=config.yaml["Assistants"]["TaskMover"]["NAME"], 315 task_manager=task_manager, 316 ) 317 scheduler.add_job( 318 old_task_move_assistant.run, 319 CronTrigger.from_crontab(to_cron(config.yaml["Assistants"]["TaskMover"]["TIME"])), 320 id="old_task_mover", 321 replace_existing=True, 322 ) 323 logger.info(f'Ассистент "{config.yaml["Assistants"]["TaskMover"]["NAME"]}" включён') 324 325 if config.yaml["Assistants"]["LangfuseCleaner"]["ENABLED"]: 326 lf_cleaner_assistant = LangfuseCleanerAssistant( 327 project_slug=config.yaml["Assistants"]["LangfuseCleaner"]["PROJECT"], 328 period=config.yaml["Assistants"]["LangfuseCleaner"]["RETENTION_DAYS"], 329 ) 330 scheduler.add_job( 331 lf_cleaner_assistant.run, 332 CronTrigger.from_crontab(to_cron(config.yaml["Assistants"]["LangfuseCleaner"]["TIME"])), 333 id="lf_cleaner", 334 replace_existing=True, 335 ) 336 logger.info(f'Ассистент "{config.yaml["Assistants"]["LangfuseCleaner"]["NAME"]}" включён') 337 338 scheduler.start() 339 logger.info("Приложение запущено") 340 yield 341 scheduler.shutdown(wait=False) 342 logger.info("Приложение остановлено")
Управляет жизненным циклом FastAPI-приложения.
При запуске настраивает логирование и запускает APScheduler с фоновыми ассистентами (перенос задач, очистка Langfuse). При остановке корректно завершает планировщик.
365@app.post("/") 366@observe(name="Alice Todo Skill Observability") 367async def handle_alice(request: AliceRequest): 368 """ 369 Основной обработчик запросов от Яндекс Алисы. 370 371 Принимает POST-запрос от Диалогов, проверяет skill_id, 372 парсит команду пользователя и выполняет соответствующее действие 373 (добавление, просмотр, выполнение, удаление, перенос задач 374 или вывод справки). Возвращает ответ в формате Яндекс Диалогов. 375 376 Args: 377 request: Валидированный запрос от Яндекс Алисы 378 379 Returns: 380 dict: Ответ навыка в формате Яндекс Диалогов 381 382 Raises: 383 HTTPException: 403 если skill_id не совпадает 384 """ 385 request_id = uuid.uuid4().hex[:8] 386 log = logger.bind(request_id=request_id) 387 388 if request.session.skill_id != os.getenv("SKILL_ID"): 389 log.warning(f"Неверный skill_id: {request.session.skill_id}") 390 raise HTTPException(status_code=403, detail="Forbidden") 391 392 try: 393 user_id = request.session.user_id 394 for user in config.yaml["USERS"]: 395 if user_id in config.yaml["USERS"][user]: 396 user_id = user 397 break 398 399 command = request.request.command.lower() 400 original_utterance = request.request.original_utterance.lower() 401 402 log.info(f"Запрос от пользователя {user_id}: {command}") 403 404 parsed = command_parser.parse(command, original_utterance) 405 response_tts = None 406 407 if parsed is None: 408 response_text = "Мой ежедневник" 409 response_tts = "-" 410 elif parsed.action == "ping": 411 response_text = "pong" 412 elif parsed.action == "add_task": 413 if parsed.date_keyword is None: 414 task_id = task_manager.add_task(user_id, parsed.task_text) 415 response_text = f"Задача '{parsed.task_text}' добавлена. ID задачи: {task_id}" 416 else: 417 date = task_manager.parse_date(parsed.date_keyword) 418 task_id = task_manager.add_task(user_id, parsed.task_text, date) 419 response_text = f"Задача '{parsed.task_text}' добавлена на {parsed.date_keyword}. ID задачи: {task_id}" 420 elif parsed.action == "no_task_text": 421 response_text = "Не смогла понять, какую задачу добавить." 422 elif parsed.action == "list_tasks": 423 date = task_manager.parse_date(parsed.date_keyword) 424 tasks = task_manager.get_tasks_by_date(user_id, date) 425 incomplete_count = task_manager.get_incomplete_tasks_count(user_id, date) 426 427 if incomplete_count == 0: 428 response_text = f"На {parsed.date_keyword} невыполненных задач нет." 429 else: 430 prefix = f"На {parsed.date_keyword} у вас {incomplete_count} невыполненных задач: " 431 response_text = prefix + task_manager.format_task_list_truncated( 432 tasks, show_completed=False, max_chars=1024 - len(prefix) 433 ) 434 tts_formatted = task_manager.format_task_list_truncated( 435 tasks, show_completed=False, max_chars=1024 436 ) 437 if "\nи еще" in tts_formatted or tts_formatted.startswith("Не показано"): 438 summary = task_manager.get_incomplete_tasks_summary(user_id) 439 response_tts = ( 440 f"У вас {summary['total']} невыполненных задач. " 441 f"На сегодня {summary['today']}, на завтра {summary['tomorrow']}." 442 ) 443 else: 444 response_tts = tts_formatted 445 elif parsed.action == "help": 446 response_text = ( 447 "Я помогу вам управлять задачами. Вот что я умею:\n" 448 "• 'Добавь задачу купить молоко' - добавить задачу без срока\n" 449 "• 'Добавь задачу на завтра сходить к врачу' - добавить на завтра\n" 450 "• 'Добавь задачу на послезавтра ...' - добавить на послезавтра\n" 451 "• 'Какие задачи на сегодня?' - показать невыполненные задачи\n" 452 "• 'Дела на завтра' - показать задачи на завтра\n" 453 "• 'Что купить?' - найти задачи, начинающиеся с 'купить'\n" 454 "• 'Пометь задачу 3 выполненной' - отметить задачу как выполненную\n" 455 "• 'Удали задачу 2' / 'убери задачу 2' - удалить задачу\n" 456 "• 'Перенеси задачу 1 на завтра' - перенести одну задачу\n" 457 "• 'Перенеси задачи на завтра' - перенести все задачи" 458 ) 459 elif parsed.action == "what": 460 date = task_manager.parse_date(parsed.date_keyword) 461 tasks = task_manager.get_tasks_by_date(user_id, date) 462 filtered_tasks = [t for t in tasks if t["text"].lower().startswith(parsed.keyword)] 463 incomplete_count = sum(1 for t in filtered_tasks if not t["completed"]) 464 465 if incomplete_count == 0: 466 response_text = ( 467 f"На {parsed.date_keyword} нет задач, начинающихся с '{parsed.keyword}'." 468 ) 469 else: 470 prefix = f"На {parsed.date_keyword} у вас {incomplete_count} задач: " 471 response_text = prefix + task_manager.format_task_list_truncated( 472 filtered_tasks, show_completed=False, max_chars=1024 - len(prefix) 473 ) 474 tts_formatted = task_manager.format_task_list_truncated( 475 filtered_tasks, show_completed=False, max_chars=1024 476 ) 477 if "\nи еще" in tts_formatted or tts_formatted.startswith("Не показано"): 478 summary = task_manager.get_incomplete_tasks_summary(user_id) 479 response_tts = ( 480 f"У вас {summary['total']} невыполненных задач. " 481 f"На сегодня {summary['today']}, на завтра {summary['tomorrow']}." 482 ) 483 else: 484 response_tts = tts_formatted 485 elif parsed.action == "complete": 486 if task_manager.complete_task(user_id, parsed.task_id): 487 response_text = f"Задача {parsed.task_id} отмечена как выполненная." 488 else: 489 response_text = f"Задача с номером {parsed.task_id} не найдена." 490 elif parsed.action == "delete": 491 if task_manager.delete_task(user_id, parsed.task_id): 492 response_text = f"Задача {parsed.task_id} удалена." 493 else: 494 response_text = f"Задача с номером {parsed.task_id} не найдена." 495 elif parsed.action == "no_task_id": 496 response_text = "Не смогла найти номер задачи." 497 elif parsed.action == "move_one": 498 date = task_manager.parse_date(parsed.date_keyword) 499 if task_manager.move_task(user_id, parsed.task_id, date): 500 response_text = f"Задача {parsed.task_id} перенесена на {parsed.date_keyword}." 501 else: 502 response_text = f"Задача с номером {parsed.task_id} не найдена." 503 elif parsed.action == "move_all": 504 date = task_manager.parse_date(parsed.date_keyword) 505 task_manager.move_tasks(user_id, date) 506 response_text = f"Задачи перенесены на {parsed.date_keyword}." 507 508 try: 509 get_langfuse().update_current_span( 510 metadata={ 511 "user_id": user_id, 512 "action": parsed.action if parsed else None, 513 "command": command, 514 "text_length": len(response_text), 515 "tts_length": len(response_tts) if response_tts else 0, 516 } 517 ) 518 except Exception: 519 pass 520 log.info(f"Отправляем ответ: {response_text}") 521 return build_response(request, response_text, tts=response_tts) 522 523 except Exception as e: 524 log.error(f"Ошибка обработки запроса: {e}") 525 return build_error_response()
Основной обработчик запросов от Яндекс Алисы.
Принимает POST-запрос от Диалогов, проверяет skill_id, парсит команду пользователя и выполняет соответствующее действие (добавление, просмотр, выполнение, удаление, перенос задач или вывод справки). Возвращает ответ в формате Яндекс Диалогов.
Arguments:
- request: Валидированный запрос от Яндекс Алисы
Returns:
dict: Ответ навыка в формате Яндекс Диалогов
Raises:
- HTTPException: 403 если skill_id не совпадает
528@app.get("/") 529async def root(): 530 """ 531 Корневой эндпоинт для проверки доступности навыка. 532 533 Returns: 534 dict: Статус приложения и путь к файлу данных 535 """ 536 return { 537 "message": "Alice Todo Skill is running", 538 "status": "ok", 539 "data_file": DB_FILE, 540 }
Корневой эндпоинт для проверки доступности навыка.
Returns:
dict: Статус приложения и путь к файлу данных
543@app.get("/health") 544async def health_check(): 545 """ 546 Эндпоинт проверки здоровья приложения. 547 548 Returns: 549 dict: Статус health, временная метка и количество пользователей 550 """ 551 return { 552 "status": "healthy", 553 "timestamp": datetime.now().isoformat(), 554 "users_count": task_manager.get_user_count(), 555 }
Эндпоинт проверки здоровья приложения.
Returns:
dict: Статус health, временная метка и количество пользователей