"""Автодетализация задач: маленькая 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]
# Кириллица в тексте задачи → весь промпт на русском. Маленькие модели мимикрируют
# под язык промпта — правило «отвечай по-русски» внутри английского промпта они
# игнорируют (проверено на 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],
projects: list[dict[str, str]],
*,
include_description: bool = True,
include_tags: bool = True,
) -> str:
"""projects — открытые проекты: {"name", "summary"} (суммаризация или обрезка).
include_description/include_tags — поля, которых у задачи ещё нет (заполненное
пользователем не угадываем). Заголовок просим всегда: сжатие длинного
заголовка — главная полезная работа LLM. Промпт строится на языке задачи
(см. _is_cyrillic) — так модель отвечает на языке исходника.
"""
projects_ctx = "\n".join(f"- {p['name']}: {p['summary']}" for p in projects)
if _is_cyrillic(f"{title} {description}"):
return _prompt_ru(
title, description, tag_names, projects_ctx,
include_description=include_description, include_tags=include_tags,
)
return _prompt_en(
title, description, tag_names, projects_ctx,
include_description=include_description, include_tags=include_tags,
)
def _prompt_ru(
title: str,
description: str,
tag_names: list[str],
projects_ctx: str,
*,
include_description: bool,
include_tags: bool,
) -> str:
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("Контекст проектов — только для понимания темы, проект выбирать не нужно.")
# few-shot пример в языке промпта: маленькие модели копируют формат и язык примера
example_fields = ['"title": "Валидация email в форме регистрации"']
if include_description:
example_fields.append('"description": null')
if include_tags:
example_fields.append('"tags": ["validation", "email"]')
example = "{" + ", ".join(example_fields) + "}"
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"
f'Задача: "{title}"\n'
+ (f'Описание: "{description}"\n\n' if description else "")
+ f"Ответь ТОЛЬКО JSON вида: {{{schema}}}"
)
def _prompt_en(
title: str,
description: str,
tag_names: list[str],
projects_ctx: str,
*,
include_description: bool,
include_tags: bool,
) -> str:
fields = ['"title": "short title or null"']
if include_description:
fields.append('"description": "steps description or null"')
if include_tags:
fields.append('"tags": ["tag", ...]')
schema = ", ".join(fields)
rules = [
"title: turn the original title into a short concise name (up to 60 chars): "
"drop extra details, numbers and filler words, keep the essence. If the "
"title is already short and clear — return null.",
]
if include_description:
rules.append(
"description: propose a clear description with a numbered list of steps "
"(markdown, 1. 2. 3.). If the steps are not obvious — return null."
)
if include_tags:
rules.append(
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 invent tags without need and do not repeat meanings."
)
if projects_ctx:
rules.append(
"Project context below is only for understanding the topic — do not pick a project."
)
example_fields = ['"title": "Registration email validation"']
if include_description:
example_fields.append('"description": null')
if include_tags:
example_fields.append('"tags": ["validation", "email"]')
example = "{" + ", ".join(example_fields) + "}"
return (
"You are a personal task manager assistant. Help format 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"
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],
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()