diff --git a/backend/app/services/detailing.py b/backend/app/services/detailing.py index abb3f33..245d023 100644 --- a/backend/app/services/detailing.py +++ b/backend/app/services/detailing.py @@ -3,6 +3,10 @@ Предложение — НЕ окончательная детализация (см. docs/TZ.md 3.2/3.10): оно применяется к задаче только при утверждении пользователем («Да, всё верно» или после ручной правки). Ожидаемы промахи — это нормально. + +Малые модели пишут мусор (пустые строки, «NULL», пробелы вместо названий), +поэтому любой строковый ответ проходит _clean/_clean_markdown: заглушки +отсекаются, пробелы схлопываются, длина ограничивается. """ import json @@ -21,26 +25,70 @@ MAX_TAGS = 3 MAX_ESTIMATE_MINUTES = 24 * 60 +MAX_TITLE_LEN = 200 +MAX_DESC_LEN = 5000 +# Сколько символов описания проекта показываем LLM (заметки бывают длинными) +PROJECT_DESC_SNIPPET = 300 PRIORITY_SCALE = "10 — срочно и важно; 7 — важно; 4 — обычное; 1 — когда-нибудь; null — неясно" +# Заглушки, которыми модели отвечают вместо названия («создай проект NULL») +PLACEHOLDERS = {"null", "none", "n/a", "na", "нет", "неизвестно", "-", "—", "?", "untitled"} + + +def _clean(value: Any, max_len: int) -> str | None: + """Строка без мусора: не заглушка, пробелы схлопнуты, длина ограничена.""" + if not isinstance(value, str): + return None + collapsed = " ".join(value.split()) + if not collapsed or collapsed.lower() in PLACEHOLDERS: + return None + return collapsed[:max_len] + + +def _clean_markdown(value: Any, max_len: int) -> str | None: + """Описание: переносы сохраняем (маркдаун-списки), остальная чистка как в _clean.""" + if not isinstance(value, str): + return None + text = value.strip() + if not text or text.lower() in PLACEHOLDERS: + return None + return text[:max_len] + def build_prompt( - title: str, description: str, tag_names: list[str], project_names: list[str] + title: str, + description: str, + tag_names: list[str], + projects: list[dict[str, str]], ) -> str: + """projects — открытые проекты: {"name", "description"} (описание может быть пустым).""" + projects_ctx = "\n".join( + f"- {p['name']}" + (f" — {p['description']}" if p.get("description") else "") + for p in projects + ) return ( "Ты — ассистент личного таск-менеджера. Подбери черновые метаданные для новой задачи.\n\n" - f"Доступные теги (выбирай ТОЛЬКО из них, максимум {MAX_TAGS}): " - f"{json.dumps(tag_names, ensure_ascii=False) or '[]'}\n" - "Доступные проекты: " - f"{json.dumps(project_names, ensure_ascii=False) or '[]'} — " - "или предложи краткое название нового проекта, если ни один не подходит.\n\n" + "Правила:\n" + "- title: если исходный заголовок длинный или перегруженный деталями — предложи " + "короткий (до 60 знаков), суть перенеси в description. Если исходный короткий и " + "ясный — верни null.\n" + "- description: если шаги выполнения очевидны — предложи описание с нумерованным " + "списком шагов (markdown, 1. 2. 3.). Если шаги неочевидны или их нет — null.\n" + f"- tags: максимум {MAX_TAGS}, не обязательно один. Выбирай из существующих; " + "если ни один не подходит — предложи 1-2 новых коротких. Не выдумывай теги " + "без необходимости и не повторяй смысл.\n" + "- project: выбери из открытых проектов по смыслу их описаний; если ни один " + "не подходит — предложи краткое название нового (2-4 слова).\n\n" + f"Существующие теги: {json.dumps(tag_names, ensure_ascii=False)}\n" + f"Открытые проекты:\n{projects_ctx or ' (нет)'}\n\n" f'Задача: "{title}"\n' f'Описание: "{description}"\n\n' f"Шкала приоритета: {PRIORITY_SCALE}.\n" "Оцени длительность задачи в минутах (целое число, 1–1440; null — неясно).\n\n" - 'Ответь ТОЛЬКО JSON вида: {"tags": ["тег", ...], "project": "имя или null", ' - '"new_project": true или false, "priority": число или null, ' - '"estimated_minutes": число или null}' + 'Ответь ТОЛЬКО JSON вида: {"title": "короткий заголовок или null", ' + '"description": "описание с шагами или null", "tags": ["тег", ...], ' + '"project": "имя или null", "new_project": true или false, ' + '"priority": число или null, "estimated_minutes": число или null}' ) @@ -71,9 +119,13 @@ return str(response.json()["response"]) def propose( - self, title: str, description: str, tag_names: list[str], project_names: list[str] + self, + title: str, + description: str, + tag_names: list[str], + projects: list[dict[str, str]], ) -> dict[str, Any] | None: - prompt = build_prompt(title, description, tag_names, project_names) + prompt = build_prompt(title, description, tag_names, projects) try: raw = self.generate(prompt) data = json.loads(raw) @@ -84,8 +136,26 @@ 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] + project_names = [p["name"] for p in projects] + + # Заголовок: предлагаемый должен отличаться от исходного + new_title = _clean(data.get("title"), MAX_TITLE_LEN) + if new_title and new_title.lower() == title.strip().lower(): + new_title = None + + new_description = _clean_markdown(data.get("description"), MAX_DESC_LEN) + if new_description and new_description == description.strip(): + new_description = None + + # Теги: новые разрешены (спрос — придумать подходящие), но мусор и дубли — нет + tags: list[str] = [] + seen: set[str] = set() + for raw_tag in data.get("tags") or []: + tag = _clean(raw_tag, 40) + if tag is None or tag.lower() in seen: + continue + seen.add(tag.lower()) + tags.append(tag) tags = tags[:MAX_TAGS] priority = data.get("priority") @@ -96,28 +166,42 @@ 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 + project = _clean(data.get("project"), 200) + # флаг имеет смысл только при валидном имени: с отброшенным мусором + # нового проекта нет и создавать нечего + new_project = project is not None and ( + bool(data.get("new_project")) or project not in project_names + ) return { + "title": new_title, + "description": new_description, "tags": tags, "project": project, - "new_project": bool(data.get("new_project")) or project not in project_names, + "new_project": new_project, "priority": priority, "estimated_minutes": estimated, } def apply_proposal(db: Any, task: Task, proposal: dict[str, Any]) -> None: - """Применить предложение к задаче: теги (только существующие), проект, приоритет.""" + """Применить предложение: заголовок/описание, теги (недостающие создаются), приоритет.""" + if proposal.get("title"): + task.title = proposal["title"].strip() + if proposal.get("description"): + task.description = proposal["description"].strip() + tag_names = proposal.get("tags") or [] if tag_names: - tags = [ - t - for t in db.scalars(select(Tag)).all() - if t.name.lower() in {n.lower() for n in tag_names} - ] + existing = {t.name.lower(): t for t in db.scalars(select(Tag)).all()} + tags = [] + for name in tag_names: + tag = existing.get(name.lower()) + if tag is None: + tag = Tag(name=name) + db.add(tag) + db.flush() + tags.append(tag) task.tags = list(tags) project_name = proposal.get("project") @@ -147,12 +231,26 @@ if task is None or task.detail_state != "raw": return + # Открытые проекты с описаниями: без них LLM назначает задачу первому + # попавшемуся или выдумывает новый (ТЗ 3.2) + projects = [ + { + "name": p.name, + "description": (p.document.body or "").strip()[:PROJECT_DESC_SNIPPET] + if p.document + else "", + } + for p in session.scalars( + select(Project).where(Project.relevance_status == "active") + ).all() + ] + service = DetailingService() proposal = service.propose( task.title, task.description, [t.name for t in session.scalars(select(Tag)).all()], - [p.name for p in session.scalars(select(Project)).all()], + projects, ) if proposal is not None: task.ai_proposal = proposal diff --git a/backend/tests/test_detailing.py b/backend/tests/test_detailing.py index 9bd55fe..f016fa6 100644 --- a/backend/tests/test_detailing.py +++ b/backend/tests/test_detailing.py @@ -1,5 +1,6 @@ """Тесты M2: автодетализация (LLM-предложение) и вложения.""" +import json from typing import Any from fastapi.testclient import TestClient @@ -39,10 +40,13 @@ def test_build_prompt_lists_catalog() -> None: - prompt = build_prompt("задача", "", ["быт", "дом"], ["ремонт"]) + projects = [{"name": "ремонт", "description": "ремонт дачи летом"}] + prompt = build_prompt("задача", "", ["быт", "дом"], projects) assert "быт" in prompt and "ремонт" in prompt assert "задача" in prompt assert "new_project" in prompt + # описание проекта в контексте (LLM назначает задачи по смыслу) + assert "ремонт дачи летом" in prompt def test_create_triggers_detailing(client: TestClient, monkeypatch: Any) -> None: @@ -56,18 +60,80 @@ assert task["ai_proposal"] == proposal -def test_propose_filters_unknown_tags_and_priority(client: TestClient, monkeypatch: Any) -> None: +def test_propose_priority_out_of_scale(client: TestClient, monkeypatch: Any) -> None: service = services.detailing.DetailingService(base_url="http://mock", model="m") monkeypatch.setattr( services.detailing.DetailingService, "generate", - lambda self, prompt: '{"tags": ["быт", "несуществующий"], "project": "Дом", ' + lambda self, prompt: '{"tags": ["быт", "новый тег"], "project": "Дом", ' '"new_project": false, "priority": 99}', ) - proposal = service.propose("задача", "", ["быт"], ["Дом"]) + proposal = service.propose("задача", "", ["быт"], [{"name": "Дом", "description": ""}]) assert proposal is not None - assert proposal["tags"] == ["быт"] # неизвестный тег отфильтрован assert proposal["priority"] is None # 99 вне шкалы 0–10 + # неизвестный тег больше не отбрасывается: LLM может предлагать новые + assert proposal["tags"] == ["быт", "новый тег"] + assert proposal["new_project"] is False # проект есть в списке + + +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", "быт"], "project": "NULL", ' + '"new_project": true, "priority": null}', + ) + proposal = service.propose("задача", "", ["быт"], [{"name": "Дом", "description": ""}]) + assert proposal is not None + assert proposal["tags"] == ["быт"] + assert proposal["project"] is None + assert proposal["new_project"] is False + + +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("Помыть посуду", "", ["быт"], []) + 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] + + +def test_propose_new_project_flag(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": null, "description": null, "tags": [], ' + '"project": "Новый проект", "new_project": false, "priority": null}', + ) + proposal = service.propose("задача", "", [], [{"name": "Дом", "description": ""}]) + assert proposal is not None + assert proposal["new_project"] is True # проекта нет в списке def test_apply_proposal_maps_and_creates(client: TestClient) -> None: @@ -104,7 +170,7 @@ def test_apply_proposal_function_directly(client: TestClient) -> None: - """apply_proposal игнорирует неизвестные теги.""" + """apply_proposal создаёт несуществующие теги (LLM может предлагать новые).""" proposal = {"tags": ["нет такого"], "project": None, "new_project": False, "priority": 3} task = create_task(client) from app.models import Task @@ -115,10 +181,28 @@ assert db_task is not None apply_proposal(session, db_task, proposal) assert db_task.priority == 3 - assert db_task.tags == [] + assert [t.name for t in db_task.tags] == ["нет такого"] session.close() +def test_apply_proposal_rewrites_title_and_description(client: TestClient) -> None: + task = create_task(client, title="очень длинный заголовок который надо сократить") + proposal = { + "title": "Сократить заголовок", + "description": "1. Шаг раз", + "tags": [], + "project": None, + "new_project": False, + "priority": None, + } + set_proposal(task["id"], proposal) + approved = client.post( + f"/api/tasks/{task['id']}/approve", json={"apply_proposal": True} + ).json() + assert approved["title"] == "Сократить заголовок" + assert approved["description"] == "1. Шаг раз" + + def test_redetail_clears_proposal(client: TestClient, monkeypatch: Any) -> None: _mock_proposal(monkeypatch, {"tags": [], "project": None, "new_project": False, "priority": 1}) task = create_task(client) diff --git a/docs/TZ.md b/docs/TZ.md index 732a859..d2a97fb 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.24 | +| Версия ТЗ | 0.25 | | Дата | 2026-09-21 | | Статус | На обсуждении | @@ -46,9 +46,14 @@ При попадании задачи в стек система самостоятельно предлагает черновые метаданные: -- теги — выбираются из существующего справочника тегов; -- проект или категорию; -- возможно, приоритет. +- теги — максимум 3, выбираются из существующего справочника; если подходящих нет, + LLM может предложить 1–2 новых (создаются при утверждении); +- проект — выбирается из **открытых проектов с их описаниями** (LLM видит заметку + каждого проекта, чтобы соотнести задачу по смыслу); если ни один не подходит — + краткое название нового проекта; +- короткий заголовок — если исходный перегружен деталями, суть переносится в описание; +- описание с нумерованными шагами — если шаги выполнения очевидны; +- возможно, приоритет и оценка длительности. Требования: @@ -57,6 +62,9 @@ 1. **«Да, всё верно»** — принять предложенные метаданные одним действием; 2. **отредактировать** — изменить предложенное вручную, затем утвердить. - Задача считается утверждённой (`approved`) только после явного действия пользователя. +- Ответ LLM валидируется: строки-заглушки («NULL», пустые/пробельные, «n/a») + отбрасываются, длина ограничивается, дубли тегов схлопываются; вне шкалы + приоритет и длительность обнуляются. - Механизм предсказания — см. 3.11 и раздел открытых вопросов. ### 3.3. Содержание и редактирование задачи @@ -169,7 +177,8 @@ - Автодетализация (3.2) выполняется **маленькой LLM, запущенной локально через Ollama**: - модель и параметры — **конфигурацией приложения**, без зашивки в код; - - LLM классифицирует текст задачи: предлагает теги из справочника, проект/категорию, приоритет; + - LLM классифицирует текст задачи: предлагает теги (из справочника или новые), проект + по описаниям открытых проектов, короткий заголовок, описание с шагами, приоритет; - результат — черновые метаданные для утверждения (3.2), не окончательное решение. - Все вызовы LLM — на собственном сервере, данные не покидают VPS. - Спекулярные LLM-сценарии вне метаданных — не в скоупе: большинство ИИ-задач выполняют внешние агенты через MCP (3.10). diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 07d6280..3163ddc 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -19,6 +19,9 @@ // Черновое предложение автодетализации (LLM) export interface AiProposal { + // короткий заголовок и описание с шагами — если LLM сочла нужным предложить + title?: string | null + description?: string | null tags: string[] project: string | null new_project: boolean diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts index d1ea748..9667040 100644 --- a/frontend/src/locales/en.ts +++ b/frontend/src/locales/en.ts @@ -295,6 +295,8 @@ 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.', proposalMyself: "I'll do it myself", diff --git a/frontend/src/locales/ru.ts b/frontend/src/locales/ru.ts index 766c8b4..9fa3d74 100644 --- a/frontend/src/locales/ru.ts +++ b/frontend/src/locales/ru.ts @@ -295,6 +295,8 @@ task: { notFoundTitle: 'Задача не найдена', notFoundText: 'Возможно, задача удалена или ссылка устарела.', + proposalNewTitle: 'Новый заголовок', + proposalNewDescription: 'Описание с шагами', proposalTitle: 'Предложение ИИ', proposalHint: 'Проверьте детали и утвердите — задача попадёт в список.', proposalMyself: 'Я сам', diff --git a/frontend/src/locales/uk.ts b/frontend/src/locales/uk.ts index 9bff265..0c334bb 100644 --- a/frontend/src/locales/uk.ts +++ b/frontend/src/locales/uk.ts @@ -295,6 +295,8 @@ task: { notFoundTitle: 'Задачу не знайдено', notFoundText: 'Можливо, задачу видалено або посилання застаріло.', + proposalNewTitle: 'Новий заголовок', + proposalNewDescription: 'Опис із кроками', proposalTitle: 'Пропозиція ШІ', proposalHint: 'Перевірте деталі та затвердіть — задача потрапить до списку.', proposalMyself: 'Я сам', diff --git a/frontend/src/taskui.ts b/frontend/src/taskui.ts index 228120e..7e4b8ae 100644 --- a/frontend/src/taskui.ts +++ b/frontend/src/taskui.ts @@ -241,6 +241,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({ diff --git a/frontend/src/views/TaskView.vue b/frontend/src/views/TaskView.vue index 748ed88..52c1bfd 100644 --- a/frontend/src/views/TaskView.vue +++ b/frontend/src/views/TaskView.vue @@ -325,6 +325,15 @@ 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)