"""Автодетализация задач: маленькая LLM (Ollama) предлагает черновые метаданные.
Предложение — НЕ окончательная детализация (см. docs/TZ.md 3.2/3.10): оно
применяется к задаче только при утверждении пользователем («Да, всё верно»
или после ручной правки). Ожидаемы промахи — это нормально.
"""
import json
import logging
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
logger = logging.getLogger(__name__)
MAX_TAGS = 3
MAX_ESTIMATE_MINUTES = 24 * 60
PRIORITY_SCALE = "10 — срочно и важно; 7 — важно; 4 — обычное; 1 — когда-нибудь; null — неясно"
def build_prompt(
title: str, description: str, tag_names: list[str], project_names: list[str]
) -> str:
return (
"Ты — ассистент личного таск-менеджера. Подбери черновые метаданные для новой задачи.\n\n"
f"Доступные теги (выбирай ТОЛЬКО из них, максимум {MAX_TAGS}): "
f"{json.dumps(tag_names, ensure_ascii=False) or '[]'}\n"
"Доступные проекты: "
f"{json.dumps(project_names, ensure_ascii=False) or '[]'} — "
"или предложи краткое название нового проекта, если ни один не подходит.\n\n"
f'Задача: "{title}"\n'
f'Описание: "{description}"\n\n'
f"Шкала приоритета: {PRIORITY_SCALE}.\n"
"Оцени длительность задачи в минутах (целое число, 1–1440; null — неясно).\n\n"
'Ответь ТОЛЬКО JSON вида: {"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:
response = httpx.post(
f"{self.base_url}/api/generate",
json={"model": self.model, "prompt": prompt, "format": "json", "stream": False},
timeout=180.0,
)
response.raise_for_status()
return str(response.json()["response"])
def propose(
self, title: str, description: str, tag_names: list[str], project_names: list[str]
) -> dict[str, Any] | None:
prompt = build_prompt(title, description, tag_names, project_names)
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
known_tags = set(t.lower() for t in tag_names)
tags = [t for t in data.get("tags") or [] if isinstance(t, str) and t.lower() in known_tags]
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 = data.get("project")
if not isinstance(project, str) or not project.strip():
project = None
return {
"tags": tags,
"project": project,
"new_project": bool(data.get("new_project")) or project not in project_names,
"priority": priority,
"estimated_minutes": estimated,
}
def apply_proposal(db: Any, task: Task, proposal: dict[str, Any]) -> None:
"""Применить предложение к задаче: теги (только существующие), проект, приоритет."""
tag_names = proposal.get("tags") or []
if tag_names:
tags = [
t
for t in db.scalars(select(Tag)).all()
if t.name.lower() in {n.lower() for n in tag_names}
]
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
service = DetailingService()
proposal = service.propose(
task.title,
task.description,
[t.name for t in session.scalars(select(Tag)).all()],
[p.name for p in session.scalars(select(Project)).all()],
)
if proposal is not None:
task.ai_proposal = proposal
session.commit()
finally:
session.close()