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