diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 53c5604..b69d385 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -515,7 +515,13 @@ async def redetail_task( task_id: int, db: DbDep, user_id: UserIdDep, background: BackgroundTasks ) -> Task: - """Перезапустить автодетализацию (например, после появления новых тегов/проектов).""" + """Перезапустить автодетализацию (например, после появления новых тегов/проектов). + + Переспрос — это сброс предложения и анализ заново по текущему тексту задачи + (владелец мог дополнить заголовок или описание). Правила анализа те же, что + у первичного: теги дополняются, приоритет и оценка — только в пустые поля, + поэтому переспрос не может перетереть значения пользователя. + """ task = _get_task_or_404(db, task_id, user_id) if task.detail_state != "raw": # Утверждённая задача уже детализирована: повтор — no-op, предложение @@ -527,9 +533,7 @@ # и его запись не перезаписалась teardown-коммитом (см. create_task). db.commit() publish(user_id, "task.changed", {"id": task.id}) - # force=True: переспрос — LLM анализирует задачу целиком, включая поля, - # заполненные пользователем (при первичном анализе они не трогаются) - background.add_task(detail_task, task.id, True) + background.add_task(detail_task, task.id) return task diff --git a/backend/app/models.py b/backend/app/models.py index 3c413e7..52041a8 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -212,8 +212,9 @@ ) project: Mapped["Project | None"] = relationship(back_populates="tasks", lazy="joined") - # Черновое предложение автодетализации (LLM): теги/проект/приоритет. - # Не окончательные метаданные — применяется только при утверждении. + # Черновое предложение автодетализации (LLM): теги, приоритет, оценка времени + # (0.93; заголовок и описание модель не переписывает). Не окончательные + # метаданные — применяется только при утверждении. ai_proposal: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True, default=None) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) diff --git a/backend/app/services/detailing.py b/backend/app/services/detailing.py index e9187e2..8ecd678 100644 --- a/backend/app/services/detailing.py +++ b/backend/app/services/detailing.py @@ -4,15 +4,19 @@ применяется к задаче только при утверждении пользователем («Да, всё верно» или после ручной правки). Ожидаемы промахи — это нормально. -Скоуп предложения (решение пользователя 2026-09-22): ТОЛЬКО короткий заголовок, -описание с шагами и теги. Приоритет, проект и оценку времени LLM не угадывает — -«0% попадания», это планирование, а не детализация. Поля, уже заполненные -пользователем, при первичном анализе не трогаем (переспрос «Детализировать» — -исключение: там анализ полный). +Скоуп предложения (решение пользователя 2026-10-10, 0.93): ТОЛЬКО теги, градация +приоритета и оценка длительности. Заголовок и описание LLM не переписывает: +владелец, создавая задачу, владеет контекстом («зачем употребил то или иное +слово»), у модели его нет — сжатый текст теряет важные детали или меняет суть. +Проект тоже не предлагается. -Малые модели пишут мусор (пустые строки, «NULL», пробелы вместо названий), -поэтому любой строковый ответ проходит _clean/_clean_markdown: заглушки -отсекаются, пробелы схлопываются, длина ограничивается. +Правило применения (то же решение): теги ДОПОЛНЯЮТ назначенные (существующие теги +задачи поэтому идут в контекст промпта), а приоритет и оценка пишутся только в +пустое поле — значения пользователя не перетираются никогда. + +Малые модели пишут мусор (пустые строки, «NULL», пробелы вместо тегов, «высокий» +вместо числа), поэтому любой ответ проходит _clean/_clean_priority/_clean_estimate: +заглушки отсекаются, градации переводятся в числа шкалы, значения вне границ — нет. Суммаризация проектов: полные заметки проектов могут быть огромными для маленькой модели, поэтому для контекста используется projects.summary — @@ -34,15 +38,42 @@ logger = logging.getLogger(__name__) +# Сколько тегов можно ДОБАВИТЬ за раз (назначенные теги не считаются: они не заменяются) MAX_TAGS = 3 -MAX_TITLE_LEN = 200 -MAX_DESC_LEN = 5000 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"} @@ -57,17 +88,37 @@ return collapsed[:max_len] -def _clean_markdown(value: Any, max_len: int) -> str | None: - """Описание: переносы сохраняем (маркдаун-списки), остальная чистка как в _clean.""" - if not isinstance(value, str): +def _clean_priority(value: Any) -> int | None: + """Градация (very_low … urgent) или готовое число 0-10 → число шкалы задачи.""" + if isinstance(value, bool): return None - text = value.strip() - if not text or text.lower() in PLACEHOLDERS: + 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 - # LLM вместо markdown приносит HTML (
, ,
    ) — при утверждении он - # попадал в описание и вылезал тегами при редактировании; просили markdown - text = re.sub(r"]*>", " ", text, flags=re.IGNORECASE) - return re.sub(r"[ \t]{2,}", " ", text)[:max_len] + 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) # Кириллица в тексте задачи → весь промпт на русском. Маленькие модели мимикрируют @@ -77,57 +128,36 @@ return any("Ѐ" <= ch <= "ӿ" for ch in text) -# Служебные слова: их потеря при сокращении заголовка потерей информации не считается -_DROPPED_STOPWORDS = { - "и", "в", "на", "с", "со", "для", "по", "за", "из", "от", "до", "у", "к", "о", - "не", "чтобы", "как", "это", "при", "the", "a", "an", "to", "for", "so", "of", - "in", "on", "at", "it", "is", "be", -} - - -def _details_dropped(original: str, shortened: str) -> bool: - """True, если при сокращении заголовка потеряны содержательные слова исходника. - - Сравнение подстрокой (регистронезависимо): морфологию маленькая модель может - изменить («валидацию» → «Валидация») — ложное «потеряно» не страшно, страховка - просто положит исходный заголовок в описание. Числа учитываются любой длины — - их выкидывание и есть главный случай потери деталей. - """ - short = shortened.lower() - for word in re.findall(r"\w+", original.lower()): - if word.isdigit(): - if word not in short: - return True - elif len(word) >= 3 and word not in _DROPPED_STOPWORDS and word not in short: - return True - return False - - def build_prompt( title: str, description: str, tag_names: list[str], + assigned_tags: list[str], projects: list[dict[str, str]], *, - include_description: bool = True, - include_tags: bool = True, + include_priority: bool = True, + include_estimate: bool = True, ) -> str: """projects — открытые проекты: {"name", "summary"} (суммаризация или обрезка). - include_description/include_tags — поля, которых у задачи ещё нет (заполненное - пользователем не угадываем). Заголовок просим всегда: сжатие длинного - заголовка — главная полезная работа LLM. Промпт строится на языке задачи + 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, projects_ctx, - include_description=include_description, include_tags=include_tags, + title, description, tag_names, assigned_tags, projects_ctx, + include_priority=include_priority, include_estimate=include_estimate, ) return _prompt_en( - title, description, tag_names, projects_ctx, - include_description=include_description, include_tags=include_tags, + title, description, tag_names, assigned_tags, projects_ctx, + include_priority=include_priority, include_estimate=include_estimate, ) @@ -135,55 +165,57 @@ title: str, description: str, tag_names: list[str], + assigned_tags: list[str], projects_ctx: str, *, - include_description: bool, - include_tags: bool, + include_priority: bool, + include_estimate: bool, ) -> str: - fields = ['"title": "короткий заголовок или null"'] - if include_description: - fields.append('"description": "описание с шагами или null"') - if include_tags: - fields.append('"tags": ["tag", ...]') + 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 = [ - "title: сделай из исходного заголовка короткое лаконичное название " - "(до 60 знаков): убери лишние детали, числа и вводные слова, оставь суть. " - "Всё, что убрал из заголовка (условия, числа, уточнения), перенеси в " - "description — ничего не теряй. Пиши по-русски. Если заголовок уже " - "короткий и ясный — верни null.", + f"tags: максимум {MAX_TAGS} коротких тегов. Сначала бери подходящие из " + "существующих; новые предлагай ТОЛЬКО на английском языке, в именительном " + "падеже (например: cleanup, testing, shopping). Уже назначенные теги не " + "повторяй — их список ниже. Если добавить нечего — верни пустой список.", ] - if include_description: + if include_priority: rules.append( - "description: предложи понятное описание с нумерованным списком шагов " - "(markdown, 1. 2. 3.) по-русски; если убрал что-то из заголовка — " - "обязательно включи это. Если шаги неочевидны и в заголовке нечего " - "было убирать — верни null." + "priority: насколько задача важна — одна из градаций: very_low (мелочь, " + "можно не делать), low, medium, high, urgent (горит). Оценивай важность " + "по смыслу задачи, а не по её длительности." ) - if include_tags: + if include_estimate: rules.append( - f"tags: максимум {MAX_TAGS} коротких тегов. Можно взять подходящие из " - "существующих; новые теги предлагай ТОЛЬКО на английском языке, в именительном " - "падеже (например: cleanup, testing, shopping). Не выдумывай теги без " - "необходимости и не повторяй смысл." + "estimated_minutes: сколько минут займёт выполнение — целое число минут " + "(например 15, 30, 120). Это оценка одной задачи, а не проекта." ) if projects_ctx: rules.append("Контекст проектов — только для понимания темы, проект выбирать не нужно.") # few-shot пример в языке промпта: маленькие модели копируют формат и язык примера. - # description НЕ null: пример учит переносить выброшенные из заголовка детали - example_fields = ['"title": "Валидация email в форме регистрации"'] - if include_description: - example_fields.append( - '"description": "Форма регистрации не должна пропускать некорректные email адреса"' - ) - if include_tags: - example_fields.append('"tags": ["validation", "email"]') + 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" "Правила:\n- " + "\n- ".join(rules) + "\n\n" "Пример:\n" 'Задача: "Переделать валидацию формы регистрации чтобы она не пропускала ' @@ -191,7 +223,8 @@ 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' + + assigned_line + + f'Задача: "{title}"\n' + (f'Описание: "{description}"\n\n' if description else "") + f"Ответь ТОЛЬКО JSON вида: {{{schema}}}" ) @@ -201,54 +234,56 @@ title: str, description: str, tag_names: list[str], + assigned_tags: list[str], projects_ctx: str, *, - include_description: bool, - include_tags: bool, + include_priority: bool, + include_estimate: 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", ...]') + 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 = [ - "title: turn the original title into a short concise name (up to 60 chars): " - "drop extra details, numbers and filler words, keep the essence. Move " - "everything you dropped (conditions, numbers, clarifications) into the " - "description — do not lose anything. If the title is already short and " - "clear — return null.", + 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_description: + if include_priority: rules.append( - "description: propose a clear description with a numbered list of steps " - "(markdown, 1. 2. 3.); include anything you dropped from the title. If " - "the steps are not obvious and nothing was dropped from the title — " - "return null." + "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_tags: + if include_estimate: 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." + "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 = ['"title": "Registration email validation"'] - if include_description: - example_fields.append( - '"description": "The registration form must not accept invalid email addresses"' - ) - if include_tags: - example_fields.append('"tags": ["validation", "email"]') + 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 format a new task.\n\n" + "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 ' @@ -256,7 +291,8 @@ 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' + + assigned_line + + f'Task: "{title}"\n' + (f'Description: "{description}"\n\n' if description else "") + f"Answer ONLY as JSON: {{{schema}}}" ) @@ -293,18 +329,20 @@ title: str, description: str, tag_names: list[str], + assigned_tags: list[str], projects: list[dict[str, str]], *, - include_description: bool = True, - include_tags: bool = True, + include_priority: bool = True, + include_estimate: bool = True, ) -> dict[str, Any] | None: prompt = build_prompt( title, description, tag_names, + assigned_tags, projects, - include_description=include_description, - include_tags=include_tags, + include_priority=include_priority, + include_estimate=include_estimate, ) try: raw = self.generate(prompt) @@ -316,59 +354,41 @@ 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 - - # Страховка потери информации: промпт требует переносить выброшенное из - # заголовка в описание, но маленькие модели нарушают. Если заголовок - # изменён, описание не предложено, а содержательные слова исходника - # пропали — складываем в описание исходный заголовок целиком. - if ( - new_title - and not new_description - and include_description - and _details_dropped(title, new_title) - ): - new_description = title.strip() - # Теги: новые разрешены (спрос — придумать подходящие), но мусор и дубли — нет + 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] = [] - 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] + 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 { - "title": new_title, - "description": new_description, - "tags": tags, + "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 не предлагает. + Теги не заменяют пользовательские (он мог выбрать свои — ИИ их не отбирает). + Заголовок и описание LLM не предлагает (0.93), поэтому не трогаются вовсе. + + Пустое поле проверяется и здесь, а не только при сборке промпта: пользователь + мог проставить приоритет, пока модель думала, — предложение придёт позже, и + перетирать его значение нельзя. """ - 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 = { @@ -388,6 +408,13 @@ 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"}. @@ -411,12 +438,13 @@ ] -def detail_task(task_id: int, force: bool = False) -> None: +def detail_task(task_id: int) -> None: """Фоновая работа: сгенерировать и сохранить предложение для задачи в стеке. - force («Переспросить ИИ») — полный анализ, включая заполненные поля. - Первичный анализ (после создания) угадывает только недостающее: заголовок - всегда (сжатие — главная работа LLM), описание и теги — если их нет. + Теги предлагаются всегда — они ДОПОЛНЯЮТ назначенные, поэтому существующие + теги задачи уходят в промпт контекстом. Приоритет и оценка — только если поля + пусты: заполненное пользователем не угадываем. «Переспросить ИИ» переиспользует + эту же работу, поэтому перетереть пользовательские значения она не может. """ session = get_session_factory()() try: @@ -425,17 +453,15 @@ 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()], + [t.name for t in task.tags], _project_context(session, user_id), - include_description=include_description, - include_tags=include_tags, + include_priority=task.priority is None, + include_estimate=task.estimated_minutes is None, ) if proposal is not None: task.ai_proposal = proposal diff --git a/backend/tests/test_detailing.py b/backend/tests/test_detailing.py index b96854d..39955f7 100644 --- a/backend/tests/test_detailing.py +++ b/backend/tests/test_detailing.py @@ -17,10 +17,11 @@ title: str, description: str, tag_names: list[str], + assigned_tags: list[str], projects: list[dict[str, str]], *, - include_description: bool = True, - include_tags: bool = True, + include_priority: bool = True, + include_estimate: bool = True, ) -> dict[str, Any]: return proposal @@ -33,6 +34,17 @@ return client.get(f"/api/tasks/{resp.json()['id']}").json() +def _propose_with(monkeypatch: Any, payload: dict[str, Any]) -> dict[str, Any] | None: + """Прогнать propose на фиксированном JSON-ответе модели.""" + monkeypatch.setattr( + services.detailing.DetailingService, + "generate", + lambda self, prompt: json.dumps(payload, ensure_ascii=False), + ) + service = services.detailing.DetailingService(base_url="http://mock", model="m") + return service.propose("задача", "", [], [], []) + + def set_proposal(task_id: int, proposal: dict[str, Any]) -> None: """Записать LLM-предложение в задачу напрямую (как сделал бы background worker).""" from app.models import Task @@ -48,20 +60,37 @@ def test_build_prompt_lists_catalog() -> None: projects = [{"name": "ремонт", "summary": "ремонт дачи летом"}] - prompt = build_prompt("задача", "", ["быт", "дом"], projects) + prompt = build_prompt("задача", "", ["быт", "дом"], ["быт"], projects) assert "быт" in prompt and "ремонт" in prompt assert "задача" in prompt # язык ответа = язык исходных данных; новые теги — только английские assert "языке" in prompt and "английском" in prompt - # проект LLM не выбирает (решение 2026-09-22): контекст только для темы - assert "new_project" not in prompt and "project" not in prompt.split("Контекст")[0].replace( - "проект выбирать не нужно", "" - ) + # проект LLM не выбирает: контекст только для темы + assert "new_project" not in prompt assert "ремонт дачи летом" in prompt + # назначенные теги уходят в промпт отдельной строкой, повторять их нельзя: + # теги ДОПОЛНЯЮТ задачу, а не заменяют назначенное (0.93) + assert "Уже назначены" in prompt and "не повторяй" in prompt + # заголовок и описание модель не переписывает — их нет и в схеме ответа + assert '"title"' not in prompt and '"description"' not in prompt + + +def test_build_prompt_en_asks_same_fields() -> None: + prompt = build_prompt("Fix the login form", "", ["auth"], ["bug"], []) + assert "Already assigned" in prompt and "Do not repeat" in prompt + assert '"tags"' in prompt and '"priority"' in prompt and '"estimated_minutes"' in prompt + + +def test_build_prompt_truncates_long_description() -> None: + """Описание уходит только контекстом: после импорта из BugTrail оно бывает + на десятки тысяч знаков (ТЗ 3.22) — хвост в промпт не тащим.""" + prompt = build_prompt("задача", "х" * 5000, [], [], []) + # считаем блоками, а не буквами: «х» встречается и в русских словах промпта + assert prompt.count("х" * 50) == 40 # ровно MAX_CONTEXT_DESC_LEN = 2000 знаков def test_create_triggers_detailing(client: TestClient, monkeypatch: Any) -> None: - proposal = {"title": None, "description": None, "tags": []} + proposal = {"tags": [], "priority": None, "estimated_minutes": None} _mock_proposal(monkeypatch, proposal) # BackgroundTasks выполняются TestClient синхронно после ответа @@ -71,202 +100,133 @@ assert task["ai_proposal"] == proposal -def test_propose_ignores_planning_fields(client: TestClient, monkeypatch: Any) -> None: - """Приоритет, проект и время LLM не предлагает (решение 2026-09-22): даже - вернув их в ответе, модель не увидит их в предложении.""" - service = services.detailing.DetailingService(base_url="http://mock", model="m") - monkeypatch.setattr( - services.detailing.DetailingService, - "generate", - lambda self, prompt: ( - '{"tags": ["быт", "новый тег"], "project": "Дом", "new_project": false, ' - '"priority": 99, "estimated_minutes": 42}' - ), +def test_propose_ignores_title_and_description( + client: TestClient, monkeypatch: Any +) -> None: + """Заголовок и описание модель не переписывает (0.93): даже вернув их в ответе, + она не увидит их в предложении. Приоритет и оценка, наоборот, читаются.""" + proposal = _propose_with( + monkeypatch, + { + "tags": ["быт", "новый тег"], + "title": "Помыть посуду", + "description": "1. Включить воду", + "priority": "high", + "estimated_minutes": 42, + }, ) - proposal = service.propose("задача", "", ["быт"], [{"name": "Дом", "summary": ""}]) - assert proposal is not None - assert set(proposal) == {"title", "description", "tags"} - # неизвестный тег больше не отбрасывается: LLM может предлагать новые - assert proposal["tags"] == ["быт", "новый тег"] + assert proposal == {"tags": ["быт", "новый тег"], "priority": 7, "estimated_minutes": 40} def test_propose_rejects_garbage_strings(client: TestClient, monkeypatch: Any) -> None: """Мусор LLM («NULL», пробел, заглушки) не превращается в тег.""" - service = services.detailing.DetailingService(base_url="http://mock", model="m") - monkeypatch.setattr( - services.detailing.DetailingService, - "generate", - lambda self, prompt: '{"tags": [" ", "null", "быт"], "title": " "}', + proposal = _propose_with( + monkeypatch, + {"tags": [" ", "null", "быт"], "priority": "null", "estimated_minutes": "много"}, ) - proposal = service.propose("задача", "", ["быт"], []) + assert proposal == {"tags": ["быт"], "priority": None, "estimated_minutes": None} + + +def test_propose_dedups_and_caps_tags(client: TestClient, monkeypatch: Any) -> None: + """Дубли (без учёта регистра) схлопываются, тегов не больше MAX_TAGS.""" + proposal = _propose_with( + monkeypatch, {"tags": ["Быт", "быт", "a", "b", "c", "d"], "priority": None} + ) assert proposal is not None - assert proposal["tags"] == ["быт"] - assert proposal["title"] is None + assert proposal["tags"] == ["Быт", "a", "b"] -def test_propose_title_description_and_same_as_original( - client: TestClient, monkeypatch: Any -) -> None: - service = services.detailing.DetailingService(base_url="http://mock", model="m") - calls: list[str] = [] - - def fake_generate(self: Any, prompt: str) -> str: - calls.append(prompt) - return json.dumps( - { - "title": "Помыть посуду", # совпадает с исходным — отбросить - "description": "1. Включить воду\n2. Помыть тарелки", - "tags": ["быт"], - "project": None, - "priority": 4, - }, - ensure_ascii=False, - ) - - monkeypatch.setattr(services.detailing.DetailingService, "generate", fake_generate) - proposal = service.propose("Помыть посуду", "", ["быт"], []) +def test_propose_accepts_tags_as_string(client: TestClient, monkeypatch: Any) -> None: + """Модель вернула строку вместо списка — разбираем по запятым, а не по буквам.""" + proposal = _propose_with(monkeypatch, {"tags": "bug, auth", "priority": "low"}) assert proposal is not None - assert proposal["title"] is None # повтор исходного заголовка - assert proposal["description"] == "1. Включить воду\n2. Помыть тарелки" - # переносы описания не схлопнуты (маркдаун-список) - assert "\n" in proposal["description"] - # промпт объясняет правила заголовка и шагов - assert "короткое" in calls[0] - assert "шагов" in calls[0] + assert proposal["tags"] == ["bug", "auth"] + + +def test_propose_empty_answer(client: TestClient, monkeypatch: Any) -> None: + """Модель ответила пустым JSON: предложение непустое как объект, но пустое + по содержанию — фронт по такому ответу молчит, а не рисует карточку.""" + proposal = _propose_with(monkeypatch, {}) + assert proposal == {"tags": [], "priority": None, "estimated_minutes": None} + + +def test_propose_priority_grades(client: TestClient, monkeypatch: Any) -> None: + """Градация словами переводится в число шкалы задачи; регистр и дефисы нормализуем.""" + cases: list[tuple[Any, int | None]] = [ + ("very_low", 1), + ("low", 3), + ("medium", 5), + ("high", 7), + ("urgent", 9), + ("Very High", 9), # своя градация модели — притягиваем к ближайшей + ("very-low", 1), + (" URGENT ", 9), + # готовое число 0-10 принимаем (снисходительность), остальное — нет + (0, 0), + (10, 10), + ("4", 4), + (11, None), + (-1, None), + ("срочно", None), + (None, None), + ] + for raw, expected in cases: + proposal = _propose_with(monkeypatch, {"priority": raw}) + assert proposal is not None, raw + assert proposal["priority"] == expected, raw + + +def test_propose_estimate_bounds_and_rounding(client: TestClient, monkeypatch: Any) -> None: + """Минуты: число или строка с числом, границы 5…1440, округление до 5 минут.""" + cases: list[tuple[Any, int | None]] = [ + (30, 30), + (47, 45), + ("30 мин", 30), + (4, None), # ниже границы + (1441, None), # верхняя граница — как estimated_minutes le=24*60 + ("скоро", None), + (True, None), # bool — не минуты + ] + for raw, expected in cases: + proposal = _propose_with(monkeypatch, {"estimated_minutes": raw}) + assert proposal is not None, raw + assert proposal["estimated_minutes"] == expected, raw def test_propose_include_flags(client: TestClient, monkeypatch: Any) -> None: - """include_description/include_tags выключают соответствующие поля: заполненное - пользователем не угадываем (первичный анализ).""" + """include_priority/include_estimate выключают свои поля: заполненное + пользователем не угадываем (первичный анализ). Теги предлагаются всегда — + они дополняют назначенные, а не заменяют их.""" service = services.detailing.DetailingService(base_url="http://mock", model="m") calls: list[str] = [] def fake_generate(self: Any, prompt: str) -> str: calls.append(prompt) - return '{"title": "Коротко", "description": "1. Шаг", "tags": ["x"]}' + return '{"tags": ["x"], "priority": "high", "estimated_minutes": 30}' monkeypatch.setattr(services.detailing.DetailingService, "generate", fake_generate) proposal = service.propose( - "задача", "уже есть описание", ["быт"], [], include_description=False, include_tags=False - ) - assert proposal is not None - assert proposal["title"] == "Коротко" - assert proposal["description"] is None - assert proposal["tags"] == [] - # в промпте нет правил для отключённых полей - assert "шагов" not in calls[0] - assert "тегов" not in calls[0] - - -def test_propose_moves_dropped_title_details_to_description( - client: TestClient, monkeypatch: Any -) -> None: - """Модель сократила заголовок, а детали (условия, числа) выкинула — исходный - заголовок складывается в описание, ничего не теряется (страховка промпта).""" - service = services.detailing.DetailingService(base_url="http://mock", model="m") - monkeypatch.setattr( - services.detailing.DetailingService, - "generate", - lambda self, prompt: '{"title": "Валидация email", "description": null, "tags": []}', - ) - proposal = service.propose( - "Переделать валидацию формы регистрации чтобы она не пропускала " - "некорректные email адреса", - "", + "задача", + "уже есть описание", + ["быт"], [], [], + include_priority=False, + include_estimate=False, ) assert proposal is not None - assert proposal["title"] == "Валидация email" - assert proposal["description"] == ( - "Переделать валидацию формы регистрации чтобы она не пропускала " - "некорректные email адреса" - ) - - -def test_propose_dropped_number_falls_back_to_original_title( - client: TestClient, monkeypatch: Any -) -> None: - """Выкинутое из заголовка число — тоже потерянная деталь.""" - service = services.detailing.DetailingService(base_url="http://mock", model="m") - monkeypatch.setattr( - services.detailing.DetailingService, - "generate", - lambda self, prompt: '{"title": "Позвонить в сервис", "description": null, "tags": []}', - ) - proposal = service.propose("Позвонить в сервис по номеру 4455", "", [], []) - assert proposal is not None - assert proposal["description"] == "Позвонить в сервис по номеру 4455" - - -def test_propose_no_fallback_when_nothing_lost( - client: TestClient, monkeypatch: Any -) -> None: - """Все содержательные слова на месте (модель дала своё описание, либо заголовок - переформулирован без потерь) — исходник в описание не дублируется.""" - service = services.detailing.DetailingService(base_url="http://mock", model="m") - monkeypatch.setattr( - services.detailing.DetailingService, - "generate", - lambda self, prompt: '{"title": "Купить молоко и хлеб", "description": null, "tags": []}', - ) - proposal = service.propose("Купить молоко", "", [], []) - assert proposal is not None - assert proposal["title"] == "Купить молоко и хлеб" - assert proposal["description"] is None - - # модель сама перенесла детали в описание — страховка не нужна - monkeypatch.setattr( - services.detailing.DetailingService, - "generate", - lambda self, prompt: '{"title": "Валидация email", ' - '"description": "Форма не должна пропускать некорректные email", "tags": []}', - ) - proposal = service.propose( - "Переделать валидацию формы регистрации чтобы она не пропускала " - "некорректные email адреса", - "", - [], - [], - ) - assert proposal is not None - assert proposal["description"] == "Форма не должна пропускать некорректные email" - - -def test_propose_no_fallback_when_description_filled_by_user( - client: TestClient, monkeypatch: Any -) -> None: - """include_description=False (описание уже заполнено пользователем) — страховка - не трогает заполненное поле.""" - service = services.detailing.DetailingService(base_url="http://mock", model="m") - monkeypatch.setattr( - services.detailing.DetailingService, - "generate", - lambda self, prompt: '{"title": "Коротко", "tags": []}', - ) - proposal = service.propose( - "длинный заголовок с лишними деталями про числа 42", "моё описание", [], [], - include_description=False, - ) - assert proposal is not None - assert proposal["title"] == "Коротко" - assert proposal["description"] is None - - -def test_build_prompt_keeps_dropped_details_rule() -> None: - """Промпт (ru и en) требует переносить выброшенное из заголовка в описание.""" - ru = build_prompt("Переделать валидацию формы регистрации", "", [], []) - assert "перенеси" in ru - assert "не пропускала некорректные email адреса" in ru # пример учит переносу - en = build_prompt("Redo registration form validation", "", [], []) - assert "Move" in en - assert "must not accept invalid email addresses" in en + assert proposal["tags"] == ["x"] + assert proposal["priority"] is None + assert proposal["estimated_minutes"] is None + # в промпте нет правил для отключённых полей, но правила тегов есть + assert "важна" not in calls[0] and "минут займёт" not in calls[0] + assert "тегов" in calls[0] def test_apply_proposal_maps_and_creates(client: TestClient) -> None: client.post("/api/tags", json={"name": "быт"}) - proposal = {"title": None, "description": None, "tags": ["БЫТ"]} + proposal = {"tags": ["БЫТ"], "priority": "urgent", "estimated_minutes": 45} resp = client.post("/api/tasks", json={"title": "задача с предложением"}) task_id = resp.json()["id"] set_proposal(task_id, proposal) @@ -275,7 +235,24 @@ assert approved["detail_state"] == "approved" assert [t["name"] for t in approved["tags"]] == ["быт"] # case-insensitive матч assert approved["project"] is None # проект LLM не предлагает + # заголовок и описание не переписываются, приоритет и оценка — заполняются + assert approved["title"] == "задача с предложением" + assert approved["priority"] == 9 + assert approved["estimated_minutes"] == 45 + + +def test_apply_proposal_without_apply_flag_keeps_fields(client: TestClient) -> None: + """«Утвердить» без галочки применения — метаданные предложения не трогают.""" + proposal = {"tags": ["быт"], "priority": "urgent", "estimated_minutes": 45} + resp = client.post("/api/tasks", json={"title": "задача"}) + task_id = resp.json()["id"] + set_proposal(task_id, proposal) + + approved = client.post(f"/api/tasks/{task_id}/approve").json() + assert approved["detail_state"] == "approved" + assert approved["tags"] == [] assert approved["priority"] is None + assert approved["estimated_minutes"] is None def test_apply_proposal_merges_tags(client: TestClient) -> None: @@ -301,17 +278,32 @@ session.close() -def test_apply_proposal_rewrites_title_and_description(client: TestClient) -> None: - task = create_task(client, title="очень длинный заголовок который надо сократить") - proposal = {"title": "Сократить заголовок", "description": "1. Шаг раз", "tags": []} - set_proposal(task["id"], proposal) +def test_apply_proposal_keeps_user_priority_and_estimate(client: TestClient) -> None: + """Правило владельца (0.93): заполненные вручную приоритет и оценка не + перетираются даже при согласии с предложением.""" + task = create_task(client) + client.patch(f"/api/tasks/{task['id']}", json={"priority": 3, "estimated_minutes": 120}) + set_proposal( + task["id"], {"tags": [], "priority": "urgent", "estimated_minutes": 15} + ) + approved = client.post(f"/api/tasks/{task['id']}/approve", json={"apply_proposal": True}).json() - assert approved["title"] == "Сократить заголовок" - assert approved["description"] == "1. Шаг раз" + assert approved["priority"] == 3 + assert approved["estimated_minutes"] == 120 + + +def test_apply_proposal_skips_junk_values(client: TestClient) -> None: + """Мусор в предложении не доходит до задачи (валидаторы работают и здесь).""" + task = create_task(client) + set_proposal(task["id"], {"tags": [], "priority": 99, "estimated_minutes": 0}) + + approved = client.post(f"/api/tasks/{task['id']}/approve", json={"apply_proposal": True}).json() + assert approved["priority"] is None + assert approved["estimated_minutes"] is None def test_redetail_clears_proposal(client: TestClient, monkeypatch: Any) -> None: - _mock_proposal(monkeypatch, {"title": None, "description": None, "tags": []}) + _mock_proposal(monkeypatch, {"tags": [], "priority": None, "estimated_minutes": None}) task = create_task(client) assert task["ai_proposal"] is not None @@ -327,15 +319,17 @@ не сбрасывается (раньше redetail молча стирал ai_proposal).""" task = create_task(client) client.post(f"/api/tasks/{task['id']}/approve") - set_proposal(task["id"], {"title": None, "description": None, "tags": []}) + set_proposal(task["id"], {"tags": [], "priority": None, "estimated_minutes": None}) resp = client.post(f"/api/tasks/{task['id']}/redetail") assert resp.json()["ai_proposal"] is not None -def test_initial_detail_skips_filled_fields(client: TestClient, monkeypatch: Any) -> None: - """Первичный анализ: у задачи есть описание и теги — LLM не угадывает их, - предлагается только сжатие заголовка. Переспрос (force) — полный анализ.""" +def test_initial_detail_asks_tags_and_empty_fields_only( + client: TestClient, monkeypatch: Any +) -> None: + """Первичный анализ: теги предлагаются всегда (они дополняют назначенные), + приоритет и оценка — только пока поля пусты.""" from app.services.detailing import detail_task resp = client.post( @@ -350,18 +344,42 @@ def fake_generate(self: Any, prompt: str) -> str: calls.append(prompt) - return '{"title": "Коротко"}' + return '{"tags": ["новый"], "priority": "high", "estimated_minutes": 30}' monkeypatch.setattr(services.detailing.DetailingService, "generate", fake_generate) detail_task(task_id) - assert "шагов" not in calls[0] and "тегов" not in calls[0] + # приоритет и оценка у задачи пусты — их спрашиваем; назначенный тег уходит + # в контекст отдельной строкой, чтобы модель не тратила слоты на дубли + assert "важна" in calls[0] and "минут займёт" in calls[0] + assert "Уже назначены" in calls[0] task = client.get(f"/api/tasks/{task_id}").json() - assert task["ai_proposal"]["description"] is None - assert task["ai_proposal"]["tags"] == [] + assert task["ai_proposal"]["tags"] == ["новый"] - detail_task(task_id, force=True) - assert "шагов" in calls[1] and "тегов" in calls[1] + # поля заполнены — переспрос предлагает только теги + client.patch(f"/api/tasks/{task_id}", json={"priority": 5, "estimated_minutes": 30}) + detail_task(task_id) + assert "важна" not in calls[1] and "минут займёт" not in calls[1] + assert "Уже назначены" in calls[1] # назначенные теги уходят в контекст всегда + + +def test_detail_keeps_user_values_in_proposal(client: TestClient, monkeypatch: Any) -> None: + """Заполненные вручную приоритет и оценка не предлагаются даже при переспросе.""" + from app.services.detailing import detail_task + + resp = client.post("/api/tasks", json={"title": "задача"}) + task_id = resp.json()["id"] + client.patch(f"/api/tasks/{task_id}", json={"priority": 7, "estimated_minutes": 60}) + + monkeypatch.setattr( + services.detailing.DetailingService, + "generate", + lambda self, prompt: '{"tags": [], "priority": "urgent", "estimated_minutes": 15}', + ) + detail_task(task_id) + task = client.get(f"/api/tasks/{task_id}").json() + assert task["ai_proposal"]["priority"] is None + assert task["ai_proposal"]["estimated_minutes"] is None def test_detail_uses_project_summary(client: TestClient, monkeypatch: Any) -> None: diff --git a/backend/tests/test_predict.py b/backend/tests/test_predict.py index ca36b54..34d8691 100644 --- a/backend/tests/test_predict.py +++ b/backend/tests/test_predict.py @@ -94,26 +94,28 @@ def test_proposal_estimate_applied(client: TestClient, monkeypatch: Any) -> None: - """ИИ не предлагает оценку времени и приоритет (решение 2026-09-22): - утверждение предложения не заполняет поля планирования.""" + """Оценка времени и приоритет из предложения заполняются при утверждении (0.93): + оценка включает задачу в режим «3 вариантов» (ТЗ 3.9). Заголовок и описание + ИИ не предлагает, заполненные пользователем поля не перетираются.""" def fake_propose( self: Any, title: str, description: str, tag_names: list[str], + assigned_tags: list[str], projects: list[dict[str, str]], *, - include_description: bool = True, - include_tags: bool = True, + include_priority: bool = True, + include_estimate: bool = True, ) -> dict[str, Any]: - return {"title": None, "description": None, "tags": []} + return {"tags": [], "priority": "high", "estimated_minutes": 45} monkeypatch.setattr("app.services.detailing.DetailingService.propose", fake_propose) tid = client.post("/api/tasks", json={"title": "Что-то на 45 минут"}).json()["id"] approved = client.post(f"/api/tasks/{tid}/approve", json={"apply_proposal": True}).json() - assert approved["estimated_minutes"] is None - assert approved["priority"] is None + assert approved["estimated_minutes"] == 45 + assert approved["priority"] == 7 def test_global_currency_settings(client: TestClient) -> None: diff --git a/docs/TZ.md b/docs/TZ.md index 1f88473..fe078cc 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.92 | +| Версия ТЗ | 0.93 | | Дата | 2026-10-10 | | Статус | На обсуждении | @@ -52,37 +52,46 @@ ### 3.2. Автоматическая предварительная детализация -При попадании задачи в стек система самостоятельно предлагает черновые метаданные -(0.40: только оформление задачи, без планирования): +При попадании задачи в стек система самостоятельно предлагает черновые метаданные. +Состав предложения (решение 2026-10-10, 0.93): -- короткий заголовок — длинный/перегруженный заголовок сжимается до лаконичного - названия; **выброшенное не теряется (0.46)**: условия, числа и уточнения, - убранные из заголовка, переносятся в описание; если модель сократила заголовок, - но описания не предложила, — в описание автоматически кладётся исходный - заголовок (страховка от потери информации, при отключённом описании — не - срабатывает); -- описание с нумерованными шагами — если шаги выполнения очевидны; -- теги — максимум 3: подходящие из существующего справочника или 1–2 новых; +- **теги** — максимум 3: подходящие из существующего справочника или новые; **новые теги — только на английском**; +- **градация приоритета** — very_low / low / medium / high / urgent (шкала + интерфейса, см. 3.13); +- **оценка длительности** — минуты; она же включает задачу в режим «3 варианта» (3.9); - LLM отвечает **на языке исходного текста задачи**. -Планирование ИИ не делает (решение 2026-09-22, «0% попадания»): **проект, -приоритет и оценку длительности LLM не предлагает** — их заполняет пользователь. +Заголовок и описание LLM **не предлагает** (решение 2026-10-10): дело не в размере +модели, а в отсутствии контекста. Создавая задачу, владелец владеет контекстом +(«зачем употребил то или иное слово»); у модели его нет, поэтому сжатый текст теряет +важные детали или меняет суть задачи. Проект ИИ тоже не предлагает. Требования: - Это **не окончательная** детализация — промахи ожидаемы и нормальны. -- **Заполненные пользователем поля не угадываются** (0.40): при первичном анализе - предлагается только недостающее (заголовок — всегда, сжатие — главная работа; - описание и теги — только если их нет). «Переспросить ИИ» (redetail) — явный - запрос полного анализа, он может перепредложить всё. +- Правило применения (решение 2026-10-10) — **предложение нельзя испортить**: + - **теги дополняют** назначенные, а не заменяют их (поэтому существующие теги + задачи уходят в контекст модели — она не должна предлагать их повторно); + - приоритет и оценка длительности пишутся **только в пустое поле**: если владелец + уже указал время или приоритет, они не меняются, даже если модель что-то + предложила и пользователь согласился. +- **Заполненные пользователем поля не предлагаются**: при первичном анализе + приоритет и оценка запрашиваются только у пустых полей; теги — всегда. «Переспросить + ИИ» (redetail) сбрасывает текущее предложение и спрашивает заново по **текущему** + тексту задачи — то же правило пустых полей действует и там. - При разборе задачи в стеке доступны варианты: 1. **«Да, всё верно»** — принять предложенные метаданные одним действием - (теги предложения **добавляются** к назначенным, пользовательские не снимаются); + (теги **добавляются** к назначенным, пользовательские не снимаются, заполненные + приоритет и оценка остаются как есть); 2. **отредактировать** — изменить предложенное вручную, затем утвердить. - Задача считается утверждённой (`approved`) только после явного действия пользователя. +- Если предложить нечего (модель не добавила тегов, а приоритет и оценка у задачи уже + стоят) — **предложение не показывается вовсе**: пустая карточка только сбивает с + толку. Задачу в этом случае утверждают как есть, без применения предложения. - Ответ LLM валидируется: строки-заглушки («NULL», пустые/пробельные, «n/a») - отбрасываются, длина ограничивается, дубли тегов схлопываются. + отбрасываются, длина ограничивается, дубли тегов схлопываются, градация приоритета + переводится в число шкалы, оценка ограничивается разумными границами. - Механизм предсказания — см. 3.11 и раздел открытых вопросов. ### 3.3. Содержание и редактирование задачи @@ -154,7 +163,7 @@ - *строгий*: «сделать до конкретной даты»; - *нестрогий*: «сделать в течение недели / месяца / года» — не привязан к конкретному дню, задаётся периодом. -**Прогнозирование времени выполнения** — система оценивает длительность задачи (на основе истории завершённых задач; алгоритм уточняется). +**Прогнозирование времени выполнения** — длительность задачи оценивается автодетализацией (3.2, 0.93) или задаётся вручную; уточнение по истории завершённых задач (факт) — открытый вопрос 8.2. **Бюджет** — опциональный, на задачу; есть не у каждой задачи: - задаётся пользователем вручную (деньги и/или время); @@ -603,10 +612,11 @@ - Автодетализация (3.2) выполняется **маленькой LLM, запущенной локально через Ollama**: - модель и параметры — **конфигурацией приложения**, без зашивки в код; - - LLM оформляет текст задачи: короткий заголовок, описание с шагами, теги - (из справочника или новые на английском), на языке исходного текста; - - результат — черновые метаданные для утверждения (3.2), не окончательное решение; - - планирование (проект/приоритет/время) ИИ не делает (0.40). + - LLM предлагает метаданные задачи: теги (из справочника или новые на английском), + градацию приоритета и оценку длительности — на языке исходного текста; + - заголовок и описание LLM не переписывает (0.93): контекста задачи у неё нет, а + сжатый текст теряет детали или меняет суть (3.2); + - результат — черновые метаданные для утверждения (3.2), не окончательное решение. - **Суммаризация проектов (0.40)**: заметка проекта может быть большой для маленькой модели, поэтому для контекста автодетализации LLM видит `projects.summary` — краткое резюме заметки (суть + полезные признаки для @@ -1510,11 +1520,9 @@ - **Потолки сборки**: не больше 50 шагов и 20 картинок, длина описания — до 20 000 знаков; отброшенное отмечается в тексте. Окружение и запись движений мыши не переносятся — они видны в отчёте по ссылке. -- **Оговорка про «Переспросить ИИ»** (3.2): импортированное описание непустое, - поэтому обычная детализация его не трогает — описание предлагается, только если - пусто. Но «Переспросить ИИ» на странице задачи предлагает описание заново и при - подтверждении **перезапишет** импортированное. Это осознанное ручное действие, не - автоматика. +- **Автодетализация импорт не трогает** (0.93): заголовок и описание ИИ не + предлагает вовсе (3.2), поэтому «Переспросить ИИ» на импортированной задаче + перезаписать описание не может — речь идёт только о тегах, приоритете и оценке. ## 4. Нефункциональные требования @@ -1594,7 +1602,8 @@ запрещены (3.4), поэтому экземпляр с нестрогим периодом даты не получает recurrence nullable — правило повторения (для task_type = recurring) - time_estimate nullable — прогноз времени (система) + time_estimate nullable — прогноз времени: из автодетализации (3.2, 0.93) или + задан вручную; заполненное не перетирается budget nullable — ручной бюджет (деньги и/или время), опционален estimated_cost nullable — оценка затрат по задаче (сравнивается с бюджетом) actual_time nullable — фактические затраты (для обучения прогноза) @@ -1722,7 +1731,7 @@ | M5 — MCP | MCP-сервер, инструменты для агентов | | M5+ — мандат агента | Доступность задачи для ИИ-агента, взятие в работу (аренда), приёмка работы, журнал `task_events` (0.85, 3.20) | | M6 — мобильность | PWA (Android-приложение — позже, вне стартового скоупа) | -| M6+ — уведомления | Системные уведомления (Web Push): подписки устройств, ключи VAPID, блок настроек, работа агента в закрытом приложении (0.88, 3.21). Напоминания: дедлайны, ритм регулярных задач, утренняя сводка, тихие часы (0.89, 3.21). Регулярные задачи: дата следующего экземпляра у клона, метка в карточке, фильтр «Регулярные» (0.91, 3.5). Импорт задачи из ссылки на багрепорт BugTrail — в форме задачи и в попапе расширения (0.92, 3.22) | +| M6+ — уведомления | Системные уведомления (Web Push): подписки устройств, ключи VAPID, блок настроек, работа агента в закрытом приложении (0.88, 3.21). Напоминания: дедлайны, ритм регулярных задач, утренняя сводка, тихие часы (0.89, 3.21). Регулярные задачи: дата следующего экземпляра у клона, метка в карточке, фильтр «Регулярные» (0.91, 3.5). Импорт задачи из ссылки на багрепорт BugTrail — в форме задачи и в попапе расширения (0.92, 3.22). Автодетализация переведена на теги, приоритет и оценку времени — заголовок и описание ИИ не предлагает (0.93, 3.2) | | M7 — упаковка | Docker (docker-compose: API, PostgreSQL, фронт, Ollama + модель) | Порядок M4/M5 может меняться — MCP можно поднять раньше ради ИИ-сценария. @@ -1730,7 +1739,7 @@ ## 8. Открытые вопросы 1. ~~**Правила повторения**~~ — **решено 2026-09-19**: три правила — интервал N дней, дни недели (ISO), день месяца (без дня — последний); якорь — фиксированный календарь от даты выполнения; новый экземпляр рождается при завершении предыдущего. -2. ~~**Прогнозирование времени**~~ — **решено 2026-09-19**: оценка длительности приходит из LLM-детализации (или задаётся вручную); точность не требуется — оценка нужна, чтобы отсекать заведомо большие задачи при малом доступном времени. +2. ~~**Прогнозирование времени**~~ — **решено 2026-09-19**, **подтверждено 2026-10-10 (0.93)**: оценка длительности приходит из LLM-детализации (или задаётся вручную); точность не требуется — оценка нужна, чтобы отсекать заведомо большие задачи при малом доступном времени. Решение 2026-09-22 «планирование ИИ не делает» отменено: LLM предлагает оценку и градацию приоритета, заполненные вручную поля не перетираются (3.2). 3. ~~**Оценка затрат к бюджету**~~ — **решено 2026-09-19**: оба поля (бюджет и оценка затрат) — ручные; валюта — глобальная настройка, выбирается один раз и применяется всюду (UAH, USD, EUR, GBP, PLN). 4. **Оффлайн-режим** в PWA: нужен ли, или всегда онлайн? 5. ~~**Модель для Ollama**~~ — **решено 2026-09-21**: по умолчанию `qwen3.5:2b-q4_K_M` (проверена на M2); меняется конфигом (`OLLAMA_MODEL`) без правок кода. Ollama — внешний сервер, адрес — `OLLAMA_BASE_URL`. diff --git a/frontend/src/aiPrompt.ts b/frontend/src/aiPrompt.ts index 16655fa..24d6e79 100644 --- a/frontend/src/aiPrompt.ts +++ b/frontend/src/aiPrompt.ts @@ -74,15 +74,16 @@ add(task.description.trim() || t('task.ai.descriptionEmpty')) add() - // Черновик автодетализации: задача ещё не утверждена, но описание с шагами в - // предложении уже есть — агенту оно полезнее пустого поля выше + // Черновик автодетализации: задача ещё не утверждена, но теги, приоритет и + // оценка времени в предложении уже есть — агенту это полезнее пустых полей выше const proposal = task.ai_proposal if (proposal) { const rows: string[] = [] - if (proposal.title) rows.push(`- ${t('task.proposalNewTitle')}: ${proposal.title}`) - if (proposal.description) - rows.push(`- ${t('task.proposalNewDescription')}: ${proposal.description}`) if (proposal.tags.length) rows.push(`- ${t('stack.form.tags')}: ${proposal.tags.join(', ')}`) + if (proposal.priority != null) + rows.push(`- ${t('task.field.priority')}: ${priorityLabel(proposal.priority)}`) + if (proposal.estimated_minutes != null) + rows.push(`- ${t('task.field.estimate')}: ${formatMinutes(proposal.estimated_minutes)}`) if (rows.length) { add(`## ${t('task.ai.proposalSection')}`) rows.forEach(add) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index e1d051a..a623848 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -25,17 +25,15 @@ repository_url: string | null } -// Черновое предложение автодетализации (LLM): только заголовок, описание, теги. -// Поля планирования (project/priority/estimated_minutes) остались у старых -// предложений в БД — ИИ их больше не предлагает (ТЗ 0.40) +// Черновое предложение автодетализации (LLM): теги, градация приоритета и оценка +// времени (ТЗ 0.93). Заголовок и описание ИИ не переписывает — контекста задачи +// у модели нет, а текст теряет детали; проект не предлагает. export interface AiProposal { - // короткий заголовок и описание с шагами — если LLM сочла нужным предложить - title?: string | null - description?: string | null + // теги ДОПОЛНЯЮТ назначенные, а не заменяют их tags: string[] - project?: string | null - new_project?: boolean + // число шкалы задачи (very_low 1 … urgent 9); null — предложения нет priority?: number | null + // минуты, оценка одной задачи; null — предложения нет estimated_minutes?: number | null } diff --git a/frontend/src/components/TaskCard.vue b/frontend/src/components/TaskCard.vue index 7ac69b6..7f4591b 100644 --- a/frontend/src/components/TaskCard.vue +++ b/frontend/src/components/TaskCard.vue @@ -38,11 +38,16 @@ // а открывают его из меню const moveOpen = ref(false) -// Сниппет: описание задачи, а без него — пункты предложения ИИ. +// Сниппет: описание задачи, а без него — теги предложения ИИ. Приоритет и оценка +// («Средний · ≈ 30 мин») в текстовый сниппет не годятся — это не текст задачи. // Markdown-разметку убираем — сниппет plain-текст. const snippet = computed(() => { const text = - props.task.description.trim() || proposalItems(props.task).map((i) => i.label).join(' · ') + props.task.description.trim() || + proposalItems(props.task) + .filter((i) => i.icon === 'ph-tag') + .map((i) => i.label) + .join(' · ') return text // Картинки — до ссылок: у `![alt](url)` правило ссылок съедает `[alt](url)`, // но ведущий `!` остаётся и лезет в сниппет («!screenshot»). ТЗ 3.22 наполняет diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts index 1d1fea0..aec124e 100644 --- a/frontend/src/locales/en.ts +++ b/frontend/src/locales/en.ts @@ -280,7 +280,6 @@ uploading: 'uploading…', proposal: 'AI proposal:', proposalPending: 'Detailing in progress…', - proposalNewProject: '+ project "{name}"', approveProposal: 'Yes, looks right', detail: 'Detail it', redetail: 'Ask AI again', @@ -329,8 +328,6 @@ task: { notFoundTitle: 'Task not found', notFoundText: 'The task may have been deleted or the link is stale.', - proposalNewTitle: 'New title', - proposalNewDescription: 'Description with steps', proposalTitle: 'AI proposal', proposalHint: 'Review the details and approve — the task joins the list.', proposalNotRight: 'Not that', diff --git a/frontend/src/locales/ru.ts b/frontend/src/locales/ru.ts index 759a2fb..0bce7c2 100644 --- a/frontend/src/locales/ru.ts +++ b/frontend/src/locales/ru.ts @@ -280,7 +280,6 @@ uploading: 'загрузка…', proposal: 'Предложение ИИ:', proposalPending: 'Детализация подбирается…', - proposalNewProject: '+ проект «{name}»', approveProposal: 'Да, всё верно', detail: 'Детализировать', redetail: 'Переспросить ИИ', @@ -329,8 +328,6 @@ task: { notFoundTitle: 'Задача не найдена', notFoundText: 'Возможно, задача удалена или ссылка устарела.', - proposalNewTitle: 'Новый заголовок', - proposalNewDescription: 'Описание с шагами', proposalTitle: 'Предложение ИИ', proposalHint: 'Проверьте детали и утвердите — задача попадёт в список.', proposalNotRight: 'Не то', diff --git a/frontend/src/locales/uk.ts b/frontend/src/locales/uk.ts index c8ace1b..01b7c4c 100644 --- a/frontend/src/locales/uk.ts +++ b/frontend/src/locales/uk.ts @@ -280,7 +280,6 @@ uploading: 'завантаження…', proposal: 'Пропозиція ШІ:', proposalPending: 'Деталізація підбирається…', - proposalNewProject: '+ проєкт «{name}»', approveProposal: 'Так, все вірно', detail: 'Деталізувати', redetail: 'Перепитати ШІ', @@ -329,8 +328,6 @@ task: { notFoundTitle: 'Задачу не знайдено', notFoundText: 'Можливо, задачу видалено або посилання застаріло.', - proposalNewTitle: 'Новий заголовок', - proposalNewDescription: 'Опис із кроками', proposalTitle: 'Пропозиція ШІ', proposalHint: 'Перевірте деталі та затвердіть — задача потрапить до списку.', proposalNotRight: 'Не те', diff --git a/frontend/src/taskui.ts b/frontend/src/taskui.ts index cb5f5d3..d60ae75 100644 --- a/frontend/src/taskui.ts +++ b/frontend/src/taskui.ts @@ -365,13 +365,7 @@ const p = task.ai_proposal if (!p) return [] const items: { icon: string; label: string }[] = [] - if (p.title) items.push({ icon: 'ph-text-t', label: p.title }) p.tags.forEach((tag) => items.push({ icon: 'ph-tag', label: tag })) - if (p.project) - items.push({ - icon: 'ph-folder', - label: p.new_project ? t('stack.proposalNewProject', { name: p.project }) : p.project, - }) if (p.priority != null) items.push({ icon: 'ph-flag', label: priorityLabel(p.priority) }) if (p.estimated_minutes !== null && p.estimated_minutes !== undefined) diff --git a/frontend/src/views/StackView.vue b/frontend/src/views/StackView.vue index 655d7e0..e2780ab 100644 --- a/frontend/src/views/StackView.vue +++ b/frontend/src/views/StackView.vue @@ -122,8 +122,10 @@ async function approve(task: Task) { try { - // «Да, всё верно» — принять предложение автодетализации - await api.approveTask(task.id, task.ai_proposal !== null) + // «Да, всё верно» — принять предложение автодетализации. Пустой ответ модели + // применять нечего: утверждаем задачу как есть (применение всё равно + // безопасно — теги дополняют, пустые поля заполняются, но лишний шум ни к чему) + await api.approveTask(task.id, proposalItems(task).length > 0) toast.success({ title: t('common.taskApproved') }) await loadAll() } catch (e) { @@ -267,8 +269,10 @@ {{ new Date(task.created_at).toLocaleString(intlLocale()) }} - -
    + +
    {{ t('stack.proposal') }}
    -
    +
    {{ t('stack.proposalPending') }}
    diff --git a/frontend/src/views/TaskView.vue b/frontend/src/views/TaskView.vue index 8d3748c..3e3fa83 100644 --- a/frontend/src/views/TaskView.vue +++ b/frontend/src/views/TaskView.vue @@ -398,28 +398,15 @@ return rows }) -// Предложение ИИ — построчно с иконками (не сжатые бейджи) +// Предложение ИИ — построчно с иконками (не сжатые бейджи). Заголовок и описание +// модель не предлагает (0.93): контекста задачи у неё нет, сжатый текст теряет +// детали. Теги дополняют назначенные, приоритет и оценка идут только в пустые поля. const proposalRows = computed(() => { const p = task.value?.ai_proposal if (!p) return [] const rows: { icon: string; term: string; value: string }[] = [] - if (p.title) - rows.push({ icon: 'ph-text-t', term: t('task.proposalNewTitle'), value: p.title }) - if (p.description) - rows.push({ - icon: 'ph-list-numbers', - term: t('task.proposalNewDescription'), - // длинное описание сжимаем: полное попадёт в карточку «Описание» после утверждения - value: p.description.length > 200 ? `${p.description.slice(0, 200)}…` : p.description, - }) if (p.tags.length) rows.push({ icon: 'ph-tag', term: t('stack.form.tags'), value: p.tags.join(', ') }) - if (p.project) - rows.push({ - icon: p.new_project ? 'ph-folder-plus' : 'ph-folder', - term: t('task.field.project'), - value: p.new_project ? t('stack.proposalNewProject', { name: p.project }) : p.project, - }) if (p.priority != null) rows.push({ icon: 'ph-flag', @@ -428,7 +415,7 @@ }) if (p.estimated_minutes !== null && p.estimated_minutes !== undefined) rows.push({ - icon: 'ph-clock', + icon: 'ph-timer', term: t('task.field.estimate'), value: `≈ ${formatMinutes(p.estimated_minutes)}`, }) @@ -444,16 +431,39 @@ })) } +// Карточка предложения: показываем, пока ИИ думает (ai_proposal === null), и когда +// есть что предложить. Пустой ответ модели («добавить нечего») не рисуем вовсе — +// пустая карточка только сбивает с толку; утвердить сырую задачу можно из меню шапки. +const showProposal = computed( + () => + task.value?.detail_state === 'raw' && + (task.value.ai_proposal === null || proposalRows.value.length > 0), +) + // Действия страницы — меню в шапке (статус вынесен в сайдбар) function pageActions(): { label: string; icon: string; danger?: boolean; onSelect: () => void }[] { const task0 = task.value if (!task0) return [] - return [ + const actions: { label: string; icon: string; danger?: boolean; onSelect: () => void }[] = [ { label: t('common.edit'), icon: 'ph-pencil-simple', onSelect: () => (editing.value = true) }, { label: t('task.ai.menu'), icon: 'ph-robot', onSelect: openAiPrompt }, { label: t('stack.redetail'), icon: 'ph-arrows-counter-clockwise', onSelect: redetail }, - { label: t('common.delete'), icon: 'ph-trash', danger: true, onSelect: () => askDelete(task0) }, ] + // ИИ предложить нечего (карточки нет) — сырую задачу всё равно надо чем-то + // утвердить: «Да, всё верно» из карточки в этом случае недоступно + if (task0.detail_state === 'raw') + actions.push({ + label: t('stack.approve'), + icon: 'ph-check-circle', + onSelect: doItMyself, + }) + actions.push({ + label: t('common.delete'), + icon: 'ph-trash', + danger: true, + onSelect: () => askDelete(task0), + }) + return actions } // «Промпт для ИИ-агента» (0.87): задача текстом — владелец вставляет его в чат со @@ -828,14 +838,15 @@