Newer
Older
gnexus-tasks / backend / app / services / options.py
"""Режим «3 варианта» (ТЗ 3.8).

Попадание в доступное время — нестрогое: оценка может превышать доступное
время до +50% (решение пользователя 2026-09-19). Меньшая задача всегда
подходит. Оценка длительности — только LLM/вручную; задачи без оценки
учитываются как фолбэк.
"""

from datetime import date
from typing import Any

from sqlalchemy import select

from app.models import Project, Task

# допустимое превышение оценки над доступным временем
FIT_TOLERANCE = 0.5


def _priority_key(task: Task) -> int:
    return -(task.priority if task.priority is not None else -1)


def _fits(estimated_minutes: int, available_minutes: int) -> bool:
    return estimated_minutes <= available_minutes * (1 + FIT_TOLERANCE)


# Срочность дедлайна: чем меньше ранг, тем раньше показываем (дедлайны решают
# пользователя 2026-09-19: учитывать в «3 вариантах»)
_PERIOD_RANK = {"day": 0, "week": 1, "month": 2, "year": 3}


def _urgency(task: Task) -> tuple[int, Any]:
    """Ранг срочности: просроченные и близкие строгие дедлайны — первыми."""
    if task.deadline_date is not None:
        days_left = (task.deadline_date - date.today()).days
        if days_left < 0:
            return (0, days_left)  # просрочен
        if days_left <= 3:
            return (1, days_left)
        return (2, task.deadline_date.toordinal())
    if task.deadline_period is not None:
        return (3, _PERIOD_RANK.get(task.deadline_period, 3))
    return (4, 0)


def pick_options(db: Any, available_minutes: int, limit: int = 3) -> list[Task]:
    """«3 варианта» (ТЗ 3.8): задачи, подходящие под доступное время.

    Кандидаты: утверждённые, к выполнению/в работе, проект активен (или без
    проекта). Сортировка: приоритет, затем свежесть. Разнообразие: максимум
    одна задача на проект; сначала — попадающие в окно ±50%, затем задачи
    без оценки (её нельзя отсечь по времени).
    """
    stmt = (
        select(Task)
        .where(Task.detail_state == "approved", Task.status.in_(("to_do", "in_progress")))
        .order_by(Task.priority.desc().nullslast(), Task.created_at.asc())
    )
    tasks = list(db.scalars(stmt).all())
    # дедлайны повышают срочность: сначала просроченные/близкие строгие,
    # затем нестрогие периоды, в пределах ранга — приоритет и свежесть
    tasks.sort(key=lambda t: (_urgency(t)[0], _priority_key(t), t.created_at))

    active_projects: set[int | None] = {None}
    for p in db.scalars(select(Project)).all():
        if p.relevance_status == "active":
            active_projects.add(p.id)

    used_projects: set[int | None] = set()
    picked_ids: set[int] = set()
    picked: list[Task] = []
    # сначала — попадающие в доступное время (допуск +50%)
    for t in tasks:
        if len(picked) >= limit:
            break
        if t.project_id not in active_projects or t.project_id in used_projects:
            continue
        if t.estimated_minutes is not None and not _fits(t.estimated_minutes, available_minutes):
            continue
        if t.estimated_minutes is None:
            continue  # без оценки — второй проход
        picked.append(t)
        picked_ids.add(t.id)
        used_projects.add(t.project_id)
    # добираем задачи без оценки длительности (её нельзя отсечь по времени)
    if len(picked) < limit:
        for t in tasks:
            if len(picked) >= limit:
                break
            if t.estimated_minutes is not None:
                continue  # с оценкой: либо уложились в первый проход, либо не влезли
            if t.id in picked_ids or t.project_id not in active_projects:
                continue
            if t.project_id in used_projects:
                continue
            picked.append(t)
            picked_ids.add(t.id)
            used_projects.add(t.project_id)
    return picked[:limit]