Newer
Older
gnexus-tasks / backend / app / services / detailing.py
"""Автодетализация задач: маленькая LLM (Ollama) предлагает черновые метаданные.

Предложение — НЕ окончательная детализация (см. docs/TZ.md 3.2/3.10): оно
применяется к задаче только при утверждении пользователем («Да, всё верно»
или после ручной правки). Ожидаемы промахи — это нормально.

Скоуп предложения (решение пользователя 2026-10-10, 0.93): ТОЛЬКО теги, градация
приоритета и оценка длительности. Заголовок и описание LLM не переписывает:
владелец, создавая задачу, владеет контекстом («зачем употребил то или иное
слово»), у модели его нет — сжатый текст теряет важные детали или меняет суть.
Проект тоже не предлагается.

Правило применения (то же решение): теги ДОПОЛНЯЮТ назначенные (существующие теги
задачи поэтому идут в контекст промпта), а приоритет и оценка пишутся только в
пустое поле — значения пользователя не перетираются никогда.

Малые модели пишут мусор (пустые строки, «NULL», пробелы вместо тегов, «высокий»
вместо числа), поэтому любой ответ проходит _clean/_clean_priority/_clean_estimate:
заглушки отсекаются, градации переводятся в числа шкалы, значения вне границ — нет.

Суммаризация проектов: полные заметки проектов могут быть огромными для
маленькой модели, поэтому для контекста используется projects.summary —
краткое резюме, генерируемое при правке заметки (summarize_project).
"""

import json
import logging
import re
from typing import Any

import httpx
from sqlalchemy import select

from app.config import get_settings
from app.db import get_session_factory
from app.models import Project, Tag, Task
from app.realtime import publish

logger = logging.getLogger(__name__)

# Сколько тегов можно ДОБАВИТЬ за раз (назначенные теги не считаются: они не заменяются)
MAX_TAGS = 3
MAX_PROJECT_SUMMARY_LEN = 600
# Описание задачи уходит в промпт только контекстом: после импорта из BugTrail
# (ТЗ 3.22) оно бывает на десятки тысяч знаков, а маленькой модели столько не надо
MAX_CONTEXT_DESC_LEN = 2000
# Сколько символов заметки проекта отдаём в контекст при отсутствии суммаризации
PROJECT_NOTE_SNIPPET = 300
# Сколько символов заметки проекта отдаём LLM-суммаризатору
SUMMARIZE_INPUT_LEN = 4000

# Приоритет: в БД шкала 0-10, в интерфейсе — пять градаций
# (frontend/src/taskui.ts gradeToPriority). Модель отвечает градацией, число
# считаем сами: маленькой модели выбрать слово проще, чем откалибровать число.
PRIORITY_GRADES = {"very_low": 1, "low": 3, "medium": 5, "high": 7, "urgent": 9}
# Свои градации модель тоже выдумывает («Very High», «critical», «normal») —
# притягиваем их к ближайшей градации интерфейса, чтобы ответ не пропадал.
PRIORITY_ALIASES = {
    "lowest": 1,
    "trivial": 1,
    "minor": 3,
    "normal": 5,
    "default": 5,
    "average": 5,
    "major": 7,
    "very_high": 9,
    "highest": 9,
    "critical": 9,
    "blocker": 9,
}
# Оценка длительности в минутах: нижняя граница — шаг округления, верхняя — как
# в схеме задачи (estimated_minutes le=24*60, app/schemas.py)
ESTIMATE_MIN = 5
ESTIMATE_MAX = 24 * 60
ESTIMATE_STEP = 5

# Заглушки, которыми модели отвечают вместо названия («создай проект NULL»)
PLACEHOLDERS = {"null", "none", "n/a", "na", "нет", "неизвестно", "-", "—", "?", "untitled"}


def _clean(value: Any, max_len: int) -> str | None:
    """Строка без мусора: не заглушка, пробелы схлопнуты, длина ограничена."""
    if not isinstance(value, str):
        return None
    collapsed = " ".join(value.split())
    if not collapsed or collapsed.lower() in PLACEHOLDERS:
        return None
    return collapsed[:max_len]


def _clean_priority(value: Any) -> int | None:
    """Градация (very_low … urgent) или готовое число 0-10 → число шкалы задачи."""
    if isinstance(value, bool):
        return None
    if isinstance(value, str):
        key = value.strip().lower().replace(" ", "_").replace("-", "_")
        if key in PRIORITY_GRADES:
            return PRIORITY_GRADES[key]
        if key in PRIORITY_ALIASES:
            return PRIORITY_ALIASES[key]
        if not key.isdigit():
            return None
        value = int(key)
    if not isinstance(value, int) or not 0 <= value <= 10:
        return None
    return value


def _clean_estimate(value: Any) -> int | None:
    """Минуты: число (или строка «30 мин») в границах, округлённое до 5 минут."""
    if isinstance(value, bool):
        return None
    if isinstance(value, str):
        # «30», «30 мин», «40 минут», «1 ч» — берём ведущее число; «1 ч» отсеем границей
        match = re.match(r"\d+", value.strip())
        if match is None:
            return None
        value = int(match.group())
    if not isinstance(value, int) or not ESTIMATE_MIN <= value <= ESTIMATE_MAX:
        return None
    return max(ESTIMATE_MIN, round(value / ESTIMATE_STEP) * ESTIMATE_STEP)


# Кириллица в тексте задачи → весь промпт на русском. Маленькие модели мимикрируют
# под язык промпта — правило «отвечай по-русски» внутри английского промпта они
# игнорируют (проверено на qwen3.5:2b, 2026-09-22), а весь русский промпт держит.
def _is_cyrillic(text: str) -> bool:
    return any("Ѐ" <= ch <= "ӿ" for ch in text)


def build_prompt(
    title: str,
    description: str,
    tag_names: list[str],
    assigned_tags: list[str],
    projects: list[dict[str, str]],
    *,
    include_priority: bool = True,
    include_estimate: bool = True,
) -> str:
    """projects — открытые проекты: {"name", "summary"} (суммаризация или обрезка).

    tag_names — справочник тегов пользователя, assigned_tags — уже назначенные
    задаче: теги предлагаются всегда и ДОПОЛНЯЮТ назначенные, поэтому модель
    должна видеть и то, что есть, и не повторять это.

    include_priority/include_estimate — поля, которых у задачи ещё нет (заполненное
    пользователем не угадываем и не перетираем). Промпт строится на языке задачи
    (см. _is_cyrillic) — так модель отвечает на языке исходника.
    """
    projects_ctx = "\n".join(f"- {p['name']}: {p['summary']}" for p in projects)
    description = description[:MAX_CONTEXT_DESC_LEN]
    if _is_cyrillic(f"{title} {description}"):
        return _prompt_ru(
            title, description, tag_names, assigned_tags, projects_ctx,
            include_priority=include_priority, include_estimate=include_estimate,
        )
    return _prompt_en(
        title, description, tag_names, assigned_tags, projects_ctx,
        include_priority=include_priority, include_estimate=include_estimate,
    )


def _prompt_ru(
    title: str,
    description: str,
    tag_names: list[str],
    assigned_tags: list[str],
    projects_ctx: str,
    *,
    include_priority: bool,
    include_estimate: bool,
) -> str:
    fields = ['"tags": ["tag", ...]']
    if include_priority:
        fields.append('"priority": "very_low|low|medium|high|urgent"')
    if include_estimate:
        fields.append('"estimated_minutes": 30')
    schema = ", ".join(fields)

    rules = [
        f"tags: максимум {MAX_TAGS} коротких тегов. Сначала бери подходящие из "
        "существующих; новые предлагай ТОЛЬКО на английском языке, в именительном "
        "падеже (например: cleanup, testing, shopping). Уже назначенные теги не "
        "повторяй — их список ниже. Если добавить нечего — верни пустой список.",
    ]
    if include_priority:
        rules.append(
            "priority: насколько задача важна — одна из градаций: very_low (мелочь, "
            "можно не делать), low, medium, high, urgent (горит). Оценивай важность "
            "по смыслу задачи, а не по её длительности."
        )
    if include_estimate:
        rules.append(
            "estimated_minutes: сколько минут займёт выполнение — целое число минут "
            "(например 15, 30, 120). Это оценка одной задачи, а не проекта."
        )
    if projects_ctx:
        rules.append("Контекст проектов — только для понимания темы, проект выбирать не нужно.")

    # few-shot пример в языке промпта: маленькие модели копируют формат и язык примера.
    example_fields = ['"tags": ["validation", "email"]']
    if include_priority:
        example_fields.append('"priority": "medium"')
    if include_estimate:
        example_fields.append('"estimated_minutes": 60')
    example = "{" + ", ".join(example_fields) + "}"

    # Назначенные теги — отдельной строкой: модель не должна тратить свои слоты
    # на дубли уже стоящих тегов (они не заменяются, а дополняются).
    assigned_line = (
        f"Уже назначены: {json.dumps(assigned_tags, ensure_ascii=False)}\n\n"
        if assigned_tags
        else ""
    )

    return (
        "Ты — ассистент личного таск-менеджера. Помоги разметить новую задачу.\n\n"
        "Правила:\n- " + "\n- ".join(rules) + "\n\n"
        "Пример:\n"
        'Задача: "Переделать валидацию формы регистрации чтобы она не пропускала '
        'некорректные email адреса"\n'
        f"Ответ: {example}\n\n"
        + (f"Контекст проектов:\n{projects_ctx}\n\n" if projects_ctx else "")
        + f"Существующие теги: {json.dumps(tag_names, ensure_ascii=False)}\n\n"
        + assigned_line
        + f'Задача: "{title}"\n'
        + (f'Описание: "{description}"\n\n' if description else "")
        + f"Ответь ТОЛЬКО JSON вида: {{{schema}}}"
    )


def _prompt_en(
    title: str,
    description: str,
    tag_names: list[str],
    assigned_tags: list[str],
    projects_ctx: str,
    *,
    include_priority: bool,
    include_estimate: bool,
) -> str:
    fields = ['"tags": ["tag", ...]']
    if include_priority:
        fields.append('"priority": "very_low|low|medium|high|urgent"')
    if include_estimate:
        fields.append('"estimated_minutes": 30')
    schema = ", ".join(fields)

    rules = [
        f"tags: up to {MAX_TAGS} short tags. Reuse fitting existing tags; propose new "
        "tags in English only, nominative case (e.g. cleanup, testing, shopping). Do "
        "not repeat tags already assigned — they are listed below. If there is "
        "nothing to add, return an empty list.",
    ]
    if include_priority:
        rules.append(
            "priority: how important the task is — one of: very_low (a trifle, can be "
            "skipped), low, medium, high, urgent (on fire). Judge importance by the "
            "meaning of the task, not by its duration."
        )
    if include_estimate:
        rules.append(
            "estimated_minutes: how many minutes it will take — a whole number of "
            "minutes (e.g. 15, 30, 120). This is the estimate for one task, not a project."
        )
    if projects_ctx:
        rules.append(
            "Project context below is only for understanding the topic — do not pick a project."
        )

    example_fields = ['"tags": ["validation", "email"]']
    if include_priority:
        example_fields.append('"priority": "medium"')
    if include_estimate:
        example_fields.append('"estimated_minutes": 60')
    example = "{" + ", ".join(example_fields) + "}"

    assigned_line = (
        f"Already assigned: {json.dumps(assigned_tags, ensure_ascii=False)}\n\n"
        if assigned_tags
        else ""
    )

    return (
        "You are a personal task manager assistant. Help tag a new task.\n\n"
        "Rules:\n- " + "\n- ".join(rules) + "\n\n"
        "Example:\n"
        'Task: "Redo the registration form validation so it does not accept invalid '
        'email addresses"\n'
        f"Answer: {example}\n\n"
        + (f"Project context:\n{projects_ctx}\n\n" if projects_ctx else "")
        + f"Existing tags: {json.dumps(tag_names, ensure_ascii=False)}\n\n"
        + assigned_line
        + f'Task: "{title}"\n'
        + (f'Description: "{description}"\n\n' if description else "")
        + f"Answer ONLY as JSON: {{{schema}}}"
    )


class DetailingService:
    """Вызов LLM и разбор ответа. generate() изолирован для тестов."""

    def __init__(self, base_url: str | None = None, model: str | None = None) -> None:
        settings = get_settings()
        self.base_url = (base_url or settings.ollama_base_url).rstrip("/")
        self.model = model or settings.ollama_model

    def generate(self, prompt: str) -> str:
        # think: false — «думающие» модели (qwen3.5) с format:json кладут ответ
        # в поле thinking, а response оставляют пустым; для черновых метаданных
        # рассуждения не нужны
        response = httpx.post(
            f"{self.base_url}/api/generate",
            json={
                "model": self.model,
                "prompt": prompt,
                "format": "json",
                "stream": False,
                "think": False,
            },
            timeout=180.0,
        )
        response.raise_for_status()
        return str(response.json()["response"])

    def propose(
        self,
        title: str,
        description: str,
        tag_names: list[str],
        assigned_tags: list[str],
        projects: list[dict[str, str]],
        *,
        include_priority: bool = True,
        include_estimate: bool = True,
    ) -> dict[str, Any] | None:
        prompt = build_prompt(
            title,
            description,
            tag_names,
            assigned_tags,
            projects,
            include_priority=include_priority,
            include_estimate=include_estimate,
        )
        try:
            raw = self.generate(prompt)
            data = json.loads(raw)
        except Exception:
            logger.warning("Detailing LLM call failed for task %r", title, exc_info=True)
            return None

        if not isinstance(data, dict):
            return None

        # Теги: новые разрешены (спрос — придумать подходящие), но мусор и дубли — нет
        raw_tags = data.get("tags")
        if isinstance(raw_tags, str):
            # модель вернула строку вместо списка — «bug, auth» разбираем по запятым
            raw_tags = raw_tags.split(",")
        elif not isinstance(raw_tags, list):
            raw_tags = []
        tags: list[str] = []
        seen: set[str] = set()
        for raw_tag in raw_tags:
            tag = _clean(raw_tag, 40)
            if tag is None or tag.lower() in seen:
                continue
            seen.add(tag.lower())
            tags.append(tag)

        return {
            "tags": tags[:MAX_TAGS],
            "priority": _clean_priority(data.get("priority")) if include_priority else None,
            "estimated_minutes": (
                _clean_estimate(data.get("estimated_minutes")) if include_estimate else None
            ),
        }


def apply_proposal(db: Any, task: Task, proposal: dict[str, Any], user_id: str) -> None:
    """Применить предложение: теги дополняют назначенные, приоритет и оценка — в пустое.

    Теги не заменяют пользовательские (он мог выбрать свои — ИИ их не отбирает).
    Заголовок и описание LLM не предлагает (0.93), поэтому не трогаются вовсе.

    Пустое поле проверяется и здесь, а не только при сборке промпта: пользователь
    мог проставить приоритет, пока модель думала, — предложение придёт позже, и
    перетирать его значение нельзя.
    """
    tag_names = proposal.get("tags") or []
    if tag_names:
        existing = {
            t.name.lower(): t for t in db.scalars(select(Tag).where(Tag.user_id == user_id)).all()
        }
        merged = list(task.tags)
        have = {t.name.lower() for t in merged}
        for name in tag_names:
            if name.lower() in have:
                continue
            tag = existing.get(name.lower())
            if tag is None:
                tag = Tag(user_id=user_id, name=name)
                db.add(tag)
                db.flush()
            merged.append(tag)
            have.add(name.lower())
        task.tags = merged

    priority = _clean_priority(proposal.get("priority"))
    if priority is not None and task.priority is None:
        task.priority = priority
    estimate = _clean_estimate(proposal.get("estimated_minutes"))
    if estimate is not None and task.estimated_minutes is None:
        task.estimated_minutes = estimate


def _project_context(db: Any, user_id: str) -> list[dict[str, str]]:
    """Открытые проекты с суммаризациями: {"name", "summary"}.

    Суммаризация (projects.summary) короче и уже полезной для LLM; без неё —
    обрезка заметки (суммаризация догонит фоном при следующей правке проекта).
    """
    projects = db.scalars(
        select(Project).where(
            Project.user_id == user_id,
            Project.relevance_status == "active",
            Project.is_archived.is_(False),
        )
    ).all()
    return [
        {
            "name": p.name,
            "summary": (p.summary or (p.note or "").strip())[:PROJECT_NOTE_SNIPPET],
        }
        for p in projects
    ]


def detail_task(task_id: int) -> None:
    """Фоновая работа: сгенерировать и сохранить предложение для сырой задачи.

    Теги предлагаются всегда — они ДОПОЛНЯЮТ назначенные, поэтому существующие
    теги задачи уходят в промпт контекстом. Приоритет и оценка — только если поля
    пусты: заполненное пользователем не угадываем. «Переспросить ИИ» переиспользует
    эту же работу, поэтому перетереть пользовательские значения она не может.
    """
    session = get_session_factory()()
    try:
        task = session.get(Task, task_id)
        if task is None or task.detail_state != "raw":
            return
        user_id = task.user_id or ""

        service = DetailingService()
        proposal = service.propose(
            task.title,
            task.description,
            [t.name for t in session.scalars(select(Tag).where(Tag.user_id == user_id)).all()],
            [t.name for t in task.tags],
            _project_context(session, user_id),
            include_priority=task.priority is None,
            include_estimate=task.estimated_minutes is None,
        )
        if proposal is not None:
            task.ai_proposal = proposal
            session.commit()
            # LLM работает вне запроса — уведомляем вкладки владельца (ТЗ 3.14)
            publish(user_id, "detail.changed", {"id": task_id})
    finally:
        session.close()


def summarize_project(project_id: int) -> None:
    """Фоновая работа: краткая суммаризация заметки проекта в projects.summary.

    Полные заметки бывают большими — маленькой модели в контекст автодетализации
    отдаём резюме, где уже выделены полезные признаки. null-ответ оставляет
    старую суммаризацию (лучше устаревшая, чем никакой).
    """
    session = get_session_factory()()
    try:
        project = session.get(Project, project_id)
        if project is None:
            return
        note = (project.note or "").strip()
        if not note:
            project.summary = None
            session.commit()
            return
        service = DetailingService()
        prompt = (
            "Сделай краткое резюме описания проекта (до 400 знаков, на языке "
            "оригинала): суть проекта и полезные признаки для классификации задач "
            "(о чём проект, какая деятельность). Без вступлений и обращений, "
            "только резюме.\n\n"
            f"Описание проекта:\n{note[:SUMMARIZE_INPUT_LEN]}"
        )
        summary = _clean(service.generate(prompt), MAX_PROJECT_SUMMARY_LEN)
        if summary:
            project.summary = summary
            session.commit()
            publish(project.user_id or "", "project.changed", {"id": project_id})
    except Exception:
        logger.warning("Project summarization failed for %s", project_id, exc_info=True)
    finally:
        session.close()