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)
def get_langfuse() -> unittest.mock.MagicMock:
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-клиента

class InterceptHandler(logging.Handler):
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.

def emit(self, record):
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
def setup_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 для всех сообщений приложения.

@observe(name='Build response')
def build_response(request: models.AliceRequest, text: str, tts: str | None = None) -> dict:
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: Ответ в формате Яндекс Диалогов

@observe(name='Build error response')
def build_error_response( text: str = 'Произошла ошибка при обработке запроса. Попробуйте еще раз.') -> 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: Ответ-заглушка с текстом ошибки

class TaskCreate(pydantic.main.BaseModel):
179class TaskCreate(BaseModel):
180    """Модель для создания задачи через веб-API."""
181
182    user_id: str
183    text: str
184    date: Optional[str] = None

Модель для создания задачи через веб-API.

user_id: str = PydanticUndefined
text: str = PydanticUndefined
date: Optional[str] = None
class TaskUpdate(pydantic.main.BaseModel):
187class TaskUpdate(BaseModel):
188    """Модель для обновления задачи через веб-API."""
189
190    date: Optional[str] = None
191    completed: Optional[bool] = None

Модель для обновления задачи через веб-API.

date: Optional[str] = None
completed: Optional[bool] = None
web_router = <fastapi.routing.APIRouter object>
@web_router.get('/api/tasks')
async def web_list_tasks( user_id: str, date: Optional[str] = None, order: Optional[str] = Query(None)):
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}

Возвращает список задач пользователя с фильтрацией по дате и сортировкой.

@web_router.post('/api/tasks')
async def web_create_task(body: TaskCreate):
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}

Создаёт новую задачу.

@web_router.put('/api/tasks/{task_id}')
async def web_update_task( task_id: int, body: TaskUpdate, user_id: str = Query(PydanticUndefined)):
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}

Обновляет задачу (дату и/или статус выполнения).

@web_router.delete('/api/tasks/{task_id}')
async def web_delete_task(task_id: int, user_id: str = Query(PydanticUndefined)):
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.

@web_router.get('/api/users')
async def web_list_users():
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.

@asynccontextmanager
async def lifespan(app: fastapi.applications.FastAPI):
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). При остановке корректно завершает планировщик.

app = <fastapi.applications.FastAPI object>
task_manager = <taskman.TaskManager object>
command_parser = <command_parser.CommandParser object>
@app.post('/')
@observe(name='Alice Todo Skill Observability')
async def handle_alice(request: models.AliceRequest):
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 не совпадает
@app.get('/')
async def root():
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: Статус приложения и путь к файлу данных

@app.get('/health')
async def health_check():
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, временная метка и количество пользователей