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

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

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

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_ESTIMATE_MINUTES = 24 * 60
MAX_TITLE_LEN = 200
MAX_DESC_LEN = 5000
# Сколько символов описания проекта показываем LLM (заметки бывают длинными)
PROJECT_DESC_SNIPPET = 300
PRIORITY_SCALE = "10 — срочно и важно; 7 — важно; 4 — обычное; 1 — когда-нибудь; null — неясно"

# Заглушки, которыми модели отвечают вместо названия («создай проект 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_markdown(value: Any, max_len: int) -> str | None:
    """Описание: переносы сохраняем (маркдаун-списки), остальная чистка как в _clean."""
    if not isinstance(value, str):
        return None
    text = value.strip()
    if not text or text.lower() in PLACEHOLDERS:
        return None
    # LLM вместо markdown приносит HTML (<br>, <b>, <ol>) — при утверждении он
    # попадал в описание и вылезал тегами при редактировании; просили markdown
    text = re.sub(r"</?[a-z][^>]*>", " ", text, flags=re.IGNORECASE)
    return re.sub(r"[ \t]{2,}", " ", text)[:max_len]


def build_prompt(
    title: str,
    description: str,
    tag_names: list[str],
    projects: list[dict[str, str]],
) -> str:
    """projects — открытые проекты: {"name", "description"} (описание может быть пустым)."""
    projects_ctx = "\n".join(
        f"- {p['name']}" + (f" — {p['description']}" if p.get("description") else "")
        for p in projects
    )
    return (
        "Ты — ассистент личного таск-менеджера. Подбери черновые метаданные для новой задачи.\n\n"
        "Правила:\n"
        "- title: если исходный заголовок длинный или перегруженный деталями — предложи "
        "короткий (до 60 знаков), суть перенеси в description. Если исходный короткий и "
        "ясный — верни null.\n"
        "- description: если шаги выполнения очевидны — предложи описание с нумерованным "
        "списком шагов (markdown, 1. 2. 3.). Если шаги неочевидны или их нет — null.\n"
        f"- tags: максимум {MAX_TAGS}, не обязательно один. Выбирай из существующих; "
        "если ни один не подходит — предложи 1-2 новых коротких. Не выдумывай теги "
        "без необходимости и не повторяй смысл.\n"
        "- project: выбери из открытых проектов по смыслу их описаний; если ни один "
        "не подходит — предложи краткое название нового (2-4 слова).\n\n"
        f"Существующие теги: {json.dumps(tag_names, ensure_ascii=False)}\n"
        f"Открытые проекты:\n{projects_ctx or '  (нет)'}\n\n"
        f'Задача: "{title}"\n'
        f'Описание: "{description}"\n\n'
        f"Шкала приоритета: {PRIORITY_SCALE}.\n"
        "Оцени длительность задачи в минутах (целое число, 1–1440; null — неясно).\n\n"
        'Ответь ТОЛЬКО JSON вида: {"title": "короткий заголовок или null", '
        '"description": "описание с шагами или null", "tags": ["тег", ...], '
        '"project": "имя или null", "new_project": true или false, '
        '"priority": число или null, "estimated_minutes": число или null}'
    )


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],
        projects: list[dict[str, str]],
    ) -> dict[str, Any] | None:
        prompt = build_prompt(title, description, tag_names, projects)
        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

        project_names = [p["name"] for p in projects]

        # Заголовок: предлагаемый должен отличаться от исходного
        new_title = _clean(data.get("title"), MAX_TITLE_LEN)
        if new_title and new_title.lower() == title.strip().lower():
            new_title = None

        new_description = _clean_markdown(data.get("description"), MAX_DESC_LEN)
        if new_description and new_description == description.strip():
            new_description = None

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

        priority = data.get("priority")
        if priority is not None and not (isinstance(priority, int) and 0 <= priority <= 10):
            priority = None

        estimated = data.get("estimated_minutes")
        if not (isinstance(estimated, int) and 1 <= estimated <= MAX_ESTIMATE_MINUTES):
            estimated = None

        project = _clean(data.get("project"), 200)
        # флаг имеет смысл только при валидном имени: с отброшенным мусором
        # нового проекта нет и создавать нечего
        new_project = project is not None and (
            bool(data.get("new_project")) or project not in project_names
        )

        return {
            "title": new_title,
            "description": new_description,
            "tags": tags,
            "project": project,
            "new_project": new_project,
            "priority": priority,
            "estimated_minutes": estimated,
        }


def apply_proposal(db: Any, task: Task, proposal: dict[str, Any]) -> None:
    """Применить предложение: заголовок/описание, теги (недостающие создаются), приоритет."""
    if proposal.get("title"):
        task.title = proposal["title"].strip()
    if proposal.get("description"):
        task.description = proposal["description"].strip()

    tag_names = proposal.get("tags") or []
    if tag_names:
        existing = {t.name.lower(): t for t in db.scalars(select(Tag)).all()}
        tags = []
        for name in tag_names:
            tag = existing.get(name.lower())
            if tag is None:
                tag = Tag(name=name)
                db.add(tag)
                db.flush()
            tags.append(tag)
        task.tags = list(tags)

    project_name = proposal.get("project")
    if project_name:
        project = db.scalars(
            select(Project).where(Project.name.ilike(project_name))
        ).first()
        if project is None and proposal.get("new_project"):
            project = Project(name=project_name.strip())
            db.add(project)
            db.flush()
        if project is not None:
            task.project_id = project.id

    if proposal.get("priority") is not None:
        task.priority = proposal["priority"]

    if proposal.get("estimated_minutes") is not None:
        task.estimated_minutes = proposal["estimated_minutes"]


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

        # Открытые проекты с описаниями: без них LLM назначает задачу первому
        # попавшемуся или выдумывает новый (ТЗ 3.2)
        projects = [
            {
                "name": p.name,
                "description": (p.document.body or "").strip()[:PROJECT_DESC_SNIPPET]
                if p.document
                else "",
            }
            for p in session.scalars(
                select(Project).where(Project.relevance_status == "active")
            ).all()
        ]

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