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

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

Скоуп предложения (решение пользователя 2026-09-22): ТОЛЬКО короткий заголовок,
описание с шагами и теги. Приоритет, проект и оценку времени LLM не угадывает —
«0% попадания», это планирование, а не детализация. Поля, уже заполненные
пользователем, при первичном анализе не трогаем (переспрос «Детализировать» —
исключение: там анализ полный).

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

Суммаризация проектов: полные заметки проектов могут быть огромными для
маленькой модели, поэтому для контекста используется 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_TITLE_LEN = 200
MAX_DESC_LEN = 5000
MAX_PROJECT_SUMMARY_LEN = 600
# Сколько символов заметки проекта отдаём в контекст при отсутствии суммаризации
PROJECT_NOTE_SNIPPET = 300
# Сколько символов заметки проекта отдаём LLM-суммаризатору
SUMMARIZE_INPUT_LEN = 4000

# Заглушки, которыми модели отвечают вместо названия («создай проект 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]],
    *,
    include_description: bool = True,
    include_tags: bool = True,
) -> str:
    """projects — открытые проекты: {"name", "summary"} (суммаризация или обрезка).

    include_description/include_tags — поля, которых у задачи ещё нет (заполненное
    пользователем не угадываем). Заголовок просим всегда: сжатие длинного
    заголовка — главная полезная работа LLM.
    """
    projects_ctx = "\n".join(f"- {p['name']}: {p['summary']}" for p in projects)
    fields = ['"title": "короткий заголовок или null"']
    if include_description:
        fields.append('"description": "описание с шагами или null"')
    if include_tags:
        fields.append('"tags": ["tag", ...]')
    schema = ", ".join(fields)

    rules = [
        # язык исходных данных — главный источник промахов маленькой модели
        "Пиши на том же языке, что и текст задачи (заголовок и описание).",
        "title: сделай из исходного заголовка короткое лаконичное название "
        "(до 60 знаков): убери лишние детали, числа и вводные слова, оставь суть. "
        "Если заголовок уже короткий и ясный — верни null.",
    ]
    if include_description:
        rules.append(
            "description: предложи понятное описание с нумерованным списком шагов "
            "(markdown, 1. 2. 3.). Если шаги неочевидны — верни null."
        )
    if include_tags:
        rules.append(
            f"tags: максимум {MAX_TAGS} коротких тегов. Можно взять подходящие из "
            "существующих; новые теги предлагай ТОЛЬКО на английском языке, в именительном "
            "падеже (например: cleanup, testing, shopping). Не выдумывай теги без "
            "необходимости и не повторяй смысл."
        )
    if projects_ctx:
        rules.append(
            "Контекст проектов ниже — только для понимания темы задачи, проект "
            "выбирать не нужно."
        )

    return (
        "Ты — ассистент личного таск-менеджера. Помоги оформить новую задачу.\n\n"
        "Правила:\n- " + "\n- ".join(rules) + "\n\n"
        + (f"Контекст проектов:\n{projects_ctx}\n\n" if projects_ctx else "")
        + f"Существующие теги: {json.dumps(tag_names, ensure_ascii=False)}\n\n"
        f'Задача: "{title}"\n'
        + (f'Описание: "{description}"\n\n' if description else "")
        + f'Ответь ТОЛЬКО 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],
        projects: list[dict[str, str]],
        *,
        include_description: bool = True,
        include_tags: bool = True,
    ) -> dict[str, Any] | None:
        prompt = build_prompt(
            title,
            description,
            tag_names,
            projects,
            include_description=include_description,
            include_tags=include_tags,
        )
        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

        # Заголовок: предлагаемый должен отличаться от исходного
        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
        if not include_description:
            new_description = None

        # Теги: новые разрешены (спрос — придумать подходящие), но мусор и дубли — нет
        tags: list[str] = []
        if include_tags:
            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]

        return {
            "title": new_title,
            "description": new_description,
            "tags": tags,
        }


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

    Теги добавляются к уже назначенным (не заменяют: пользователь мог выбрать
    свои — ИИ их не отбирает). Приоритет, проект и время LLM не предлагает.
    """
    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).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


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, force: bool = False) -> None:
    """Фоновая работа: сгенерировать и сохранить предложение для задачи в стеке.

    force («Переспросить ИИ») — полный анализ, включая заполненные поля.
    Первичный анализ (после создания) угадывает только недостающее: заголовок
    всегда (сжатие — главная работа LLM), описание и теги — если их нет.
    """
    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 ""

        include_description = force or not (task.description or "").strip()
        include_tags = force or not task.tags

        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()],
            _project_context(session, user_id),
            include_description=include_description,
            include_tags=include_tags,
        )
        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()