diff --git a/backend/alembic/versions/a9b8c7d6e5f4_project_summary.py b/backend/alembic/versions/a9b8c7d6e5f4_project_summary.py new file mode 100644 index 0000000..2e105ce --- /dev/null +++ b/backend/alembic/versions/a9b8c7d6e5f4_project_summary.py @@ -0,0 +1,32 @@ +r"""projects.summary (суммаризация заметки для контекста LLM, ТЗ 3.11). + +Заодно нормализация ai_proposal: движок раньше сериализовал JSON с +ensure_ascii=True — кириллица лежала \u-эскейпами (агенты через MCP видели +«u0432...» вместо букв). Раунд-трип через jsonb вычищает эскейпы. + +Revision ID: a9b8c7d6e5f4 +Revises: f1a2b3c4d5e6 +Create Date: 2026-09-22 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "a9b8c7d6e5f4" +down_revision: str | Sequence[str] | None = "f1a2b3c4d5e6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("projects", sa.Column("summary", sa.Text(), nullable=True)) + op.execute( + "UPDATE tasks SET ai_proposal = (ai_proposal::text::jsonb)::json" + " WHERE ai_proposal IS NOT NULL" + ) + + +def downgrade() -> None: + op.drop_column("projects", "summary") \ No newline at end of file diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index 2291285..e3b8ed1 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -2,13 +2,14 @@ from typing import Any, cast -from fastapi import APIRouter, HTTPException, Query, Response +from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Response from sqlalchemy import select from app.dependencies import DbDep, UserIdDep from app.models import CoinEvent, Project from app.realtime import publish from app.schemas import ProjectCreate, ProjectOut, ProjectUpdate +from app.services.detailing import summarize_project from app.services.xp import coins_for_create, grant_create_xp router = APIRouter(prefix="/api/projects", tags=["projects"]) @@ -29,7 +30,8 @@ @router.post("", response_model=ProjectOut) async def create_project( - schema: ProjectCreate, db: DbDep, user_id: UserIdDep, response: Response + schema: ProjectCreate, db: DbDep, user_id: UserIdDep, response: Response, + background: BackgroundTasks, ) -> Project: exists = db.scalars( select(Project).where(Project.name == schema.name, Project.user_id == user_id) @@ -49,6 +51,9 @@ response.headers["X-Created-XP"] = "5" response.headers["X-Created-Coins"] = "2" db.commit() + # суммаризация заметки для контекста LLM (ТЗ 3.11) — фон, коммит уже сделан + if (schema.note or "").strip(): + background.add_task(summarize_project, project.id) publish(user_id, "project.changed", {"id": project.id}) publish(user_id, "xp.changed", {"celebrate": False}) return project @@ -75,7 +80,8 @@ @router.patch("/{project_id}", response_model=ProjectOut) async def update_project( - project_id: int, schema: ProjectUpdate, db: DbDep, user_id: UserIdDep + project_id: int, schema: ProjectUpdate, db: DbDep, user_id: UserIdDep, + background: BackgroundTasks, ) -> Project: project = _get_project_or_404(db, project_id, user_id) data = schema.model_dump(exclude_unset=True) @@ -89,9 +95,15 @@ ).first() if exists is not None: raise HTTPException(status_code=409, detail="Project name already exists") + note_changed = "note" in data and data["note"] != project.note for field, value in data.items(): setattr(project, field, value) + # Коммит до фоновой работы: summarize_project открывает свою сессию (см. + # create_task про teardown-коммит get_db) db.commit() + # заметка изменилась — суммаризация в контексте LLM устарела, перегенерим + if note_changed: + background.add_task(summarize_project, project.id) publish(user_id, "project.changed", {"id": project.id}) return project diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 136ae9c..0899484 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -295,7 +295,9 @@ # и его запись не перезаписалась teardown-коммитом (см. create_task). db.commit() publish(user_id, "task.changed", {"id": task.id}) - background.add_task(detail_task, task.id) + # force=True: переспрос — LLM анализирует задачу целиком, включая поля, + # заполненные пользователем (при первичном анализе они не трогаются) + background.add_task(detail_task, task.id, True) return task diff --git a/backend/app/db.py b/backend/app/db.py index 8e2b91c..cfad1ef 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -1,6 +1,8 @@ """Подключение к БД и базовый класс моделей (SQLAlchemy 2.0, sync).""" +import json from collections.abc import Generator +from typing import Any from sqlalchemy import Engine, create_engine from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker @@ -12,6 +14,11 @@ pass +def json_serializer(obj: Any) -> str: + r"""Сериализация JSON-колонок: кириллица без \u-эскейпов (видят MCP-агенты).""" + return json.dumps(obj, ensure_ascii=False) + + _engine: Engine | None = None _session_factory: sessionmaker[Session] | None = None @@ -19,7 +26,13 @@ def get_engine() -> Engine: global _engine if _engine is None: - _engine = create_engine(get_settings().database_url, pool_pre_ping=True) + _engine = create_engine( + get_settings().database_url, + pool_pre_ping=True, + # ensure_ascii=False: иначе кириллица в JSON-колонках (ai_proposal, + # и т.п.) уезжает в в-эскейпы — MCP-агент видит «u0432» вместо букв + json_serializer=json_serializer, + ) return _engine diff --git a/backend/app/models.py b/backend/app/models.py index bede3cc..6cd8326 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -213,6 +213,9 @@ # Архив (ТЗ 3.8.1): проект уезжает в историю со всеми задачами is_archived: Mapped[bool] = mapped_column(Boolean, default=False) priority: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) + # Краткая суммаризация заметки для контекста LLM (генерится при правке + # заметки; null — суммаризации ещё нет, контекст берётся обрезкой заметки) + summary: Mapped[str | None] = mapped_column(Text, nullable=True, default=None) tasks: Mapped[list[Task]] = relationship(back_populates="project") diff --git a/backend/app/services/detailing.py b/backend/app/services/detailing.py index 0239bb2..5387457 100644 --- a/backend/app/services/detailing.py +++ b/backend/app/services/detailing.py @@ -4,9 +4,19 @@ применяется к задаче только при утверждении пользователем («Да, всё верно» или после ручной правки). Ожидаемы промахи — это нормально. +Скоуп предложения (решение пользователя 2026-09-22): ТОЛЬКО короткий заголовок, +описание с шагами и теги. Приоритет, проект и оценку времени LLM не угадывает — +«0% попадания», это планирование, а не детализация. Поля, уже заполненные +пользователем, при первичном анализе не трогаем (переспрос «Детализировать» — +исключение: там анализ полный). + Малые модели пишут мусор (пустые строки, «NULL», пробелы вместо названий), поэтому любой строковый ответ проходит _clean/_clean_markdown: заглушки отсекаются, пробелы схлопываются, длина ограничивается. + +Суммаризация проектов: полные заметки проектов могут быть огромными для +маленькой модели, поэтому для контекста используется projects.summary — +краткое резюме, генерируемое при правке заметки (summarize_project). """ import json @@ -25,12 +35,13 @@ logger = logging.getLogger(__name__) 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 — неясно" +MAX_PROJECT_SUMMARY_LEN = 600 +# Сколько символов заметки проекта отдаём в контекст при отсутствии суммаризации +PROJECT_NOTE_SNIPPET = 300 +# Сколько символов заметки проекта отдаём LLM-суммаризатору +SUMMARIZE_INPUT_LEN = 4000 # Заглушки, которыми модели отвечают вместо названия («создай проект NULL») PLACEHOLDERS = {"null", "none", "n/a", "na", "нет", "неизвестно", "-", "—", "?", "untitled"} @@ -64,35 +75,57 @@ description: str, tag_names: list[str], projects: list[dict[str, str]], + *, + include_description: bool = True, + include_tags: bool = True, ) -> str: - """projects — открытые проекты: {"name", "description"} (описание может быть пустым).""" - projects_ctx = "\n".join( - f"- {p['name']}" + (f" — {p['description']}" if p.get("description") else "") - for p in projects - ) + """projects — открытые проекты: {"name", "summary"} (суммаризация или обрезка). + + include_description/include_tags — поля, которых у задачи ещё нет (заполненное + пользователем не угадываем). Заголовок просим всегда: сжатие длинного + заголовка — главная полезная работа LLM. + """ + projects_ctx = "\n".join(f"- {p['name']}: {p['summary']}" for p in projects) + fields = ['"title": "короткий заголовок или null"'] + if include_description: + fields.append('"description": "описание с шагами или null"') + if include_tags: + fields.append('"tags": ["tag", ...]') + schema = ", ".join(fields) + + rules = [ + # язык исходных данных — главный источник промахов маленькой модели + "Пиши на том же языке, что и текст задачи (заголовок и описание).", + "title: сделай из исходного заголовка короткое лаконичное название " + "(до 60 знаков): убери лишние детали, числа и вводные слова, оставь суть. " + "Если заголовок уже короткий и ясный — верни null.", + ] + if include_description: + rules.append( + "description: предложи понятное описание с нумерованным списком шагов " + "(markdown, 1. 2. 3.). Если шаги неочевидны — верни null." + ) + if include_tags: + rules.append( + f"tags: максимум {MAX_TAGS} коротких тегов. Можно взять подходящие из " + "существующих; новые теги предлагай ТОЛЬКО на английском языке, в именительном " + "падеже (например: cleanup, testing, shopping). Не выдумывай теги без " + "необходимости и не повторяй смысл." + ) + if projects_ctx: + rules.append( + "Контекст проектов ниже — только для понимания темы задачи, проект " + "выбирать не нужно." + ) + return ( - "Ты — ассистент личного таск-менеджера. Подбери черновые метаданные для новой задачи.\n\n" - "Правила:\n" - "- 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" + "Ты — ассистент личного таск-менеджера. Помоги оформить новую задачу.\n\n" + "Правила:\n- " + "\n- ".join(rules) + "\n\n" + + (f"Контекст проектов:\n{projects_ctx}\n\n" if projects_ctx else "") + + f"Существующие теги: {json.dumps(tag_names, ensure_ascii=False)}\n\n" f'Задача: "{title}"\n' - f'Описание: "{description}"\n\n' - f"Шкала приоритета: {PRIORITY_SCALE}.\n" - "Оцени длительность задачи в минутах (целое число, 1–1440; null — неясно).\n\n" - 'Ответь ТОЛЬКО JSON вида: {"title": "короткий заголовок или null", ' - '"description": "описание с шагами или null", "tags": ["тег", ...], ' - '"project": "имя или null", "new_project": true или false, ' - '"priority": число или null, "estimated_minutes": число или null}' + + (f'Описание: "{description}"\n\n' if description else "") + + f'Ответь ТОЛЬКО JSON вида: {{{schema}}}' ) @@ -128,8 +161,18 @@ 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) + 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) @@ -140,8 +183,6 @@ if not isinstance(data, dict): return None - 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(): @@ -150,46 +191,34 @@ 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] = [] - 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") - 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 = _clean(data.get("project"), 200) - # флаг имеет смысл только при валидном имени: с отброшенным мусором - # нового проекта нет и создавать нечего - new_project = project is not None and ( - bool(data.get("new_project")) or project not in project_names - ) + 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, - "project": project, - "new_project": new_project, - "priority": priority, - "estimated_minutes": estimated, } 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"): @@ -200,45 +229,50 @@ existing = { t.name.lower(): t for t in db.scalars(select(Tag).where(Tag.user_id == user_id)).all() } - tags = [] + 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() - tags.append(tag) - task.tags = list(tags) - - project_name = proposal.get("project") - if project_name: - # только среди открытых проектов: в архивном (3.8.1) задача пропала бы - # из рабочих видов сразу после утверждения - project = db.scalars( - select(Project).where( - Project.user_id == user_id, - Project.name.ilike(project_name), - Project.is_archived.is_(False), - ) - ).first() - # совпадение с именем архивного — treated as not found: LLM предложит - # проект заново или задача останется без проекта (пользователь поправит) - if project is None and proposal.get("new_project"): - project = Project(user_id=user_id, 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"] + merged.append(tag) + have.add(name.lower()) + task.tags = merged -def detail_task(task_id: int) -> None: - """Фоновая работа: сгенерировать и сохранить предложение для задачи в стеке.""" +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) @@ -246,30 +280,17 @@ return user_id = task.user_id or "" - # Открытые проекты с описаниями: без них 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.user_id == user_id, - Project.relevance_status == "active", - Project.is_archived.is_(False), - ) - ).all() - ] + 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()], - projects, + _project_context(session, user_id), + include_description=include_description, + include_tags=include_tags, ) if proposal is not None: task.ai_proposal = proposal @@ -278,3 +299,39 @@ 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() diff --git a/backend/tests/test_detailing.py b/backend/tests/test_detailing.py index 19a9bb9..b48e621 100644 --- a/backend/tests/test_detailing.py +++ b/backend/tests/test_detailing.py @@ -13,7 +13,14 @@ """Подменить LLM-вызов фиксированным ответом.""" def fake_propose( - self: Any, title: str, description: str, tag_names: list[str], project_names: list[str] + self: Any, + title: str, + description: str, + tag_names: list[str], + projects: list[dict[str, str]], + *, + include_description: bool = True, + include_tags: bool = True, ) -> dict[str, Any]: return proposal @@ -40,17 +47,21 @@ def test_build_prompt_lists_catalog() -> None: - projects = [{"name": "ремонт", "description": "ремонт дачи летом"}] + projects = [{"name": "ремонт", "summary": "ремонт дачи летом"}] prompt = build_prompt("задача", "", ["быт", "дом"], projects) assert "быт" in prompt and "ремонт" in prompt assert "задача" in prompt - assert "new_project" in prompt - # описание проекта в контексте (LLM назначает задачи по смыслу) + # язык ответа = язык исходных данных; новые теги — только английские + assert "языке" in prompt and "английском" in prompt + # проект LLM не выбирает (решение 2026-09-22): контекст только для темы + assert "new_project" not in prompt and "project" not in prompt.split("Контекст")[0].replace( + "проект выбирать не нужно", "" + ) assert "ремонт дачи летом" in prompt def test_create_triggers_detailing(client: TestClient, monkeypatch: Any) -> None: - proposal = {"tags": [], "project": None, "new_project": False, "priority": 4} + proposal = {"title": None, "description": None, "tags": []} _mock_proposal(monkeypatch, proposal) # BackgroundTasks выполняются TestClient синхронно после ответа @@ -60,39 +71,37 @@ assert task["ai_proposal"] == proposal -def test_propose_priority_out_of_scale(client: TestClient, monkeypatch: Any) -> None: +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}' + '{"tags": ["быт", "новый тег"], "project": "Дом", "new_project": false, ' + '"priority": 99, "estimated_minutes": 42}' ), ) - proposal = service.propose("задача", "", ["быт"], [{"name": "Дом", "description": ""}]) + proposal = service.propose("задача", "", ["быт"], [{"name": "Дом", "summary": ""}]) assert proposal is not None - assert proposal["priority"] is None # 99 вне шкалы 0–10 + assert set(proposal) == {"title", "description", "tags"} # неизвестный тег больше не отбрасывается: LLM может предлагать новые assert proposal["tags"] == ["быт", "новый тег"] - assert proposal["new_project"] is False # проект есть в списке def test_propose_rejects_garbage_strings(client: TestClient, monkeypatch: Any) -> None: - """Мусор LLM («NULL», пробел, заглушки) не превращается в проект/тег.""" + """Мусор 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}' - ), + lambda self, prompt: '{"tags": [" ", "null", "быт"], "title": " "}', ) - proposal = service.propose("задача", "", ["быт"], [{"name": "Дом", "description": ""}]) + proposal = service.propose("задача", "", ["быт"], []) assert proposal is not None assert proposal["tags"] == ["быт"] - assert proposal["project"] is None - assert proposal["new_project"] is False + assert proposal["title"] is None def test_propose_title_description_and_same_as_original( @@ -122,30 +131,37 @@ # переносы описания не схлопнуты (маркдаун-список) assert "\n" in proposal["description"] # промпт объясняет правила заголовка и шагов - assert "короткий" in calls[0] + assert "короткое" in calls[0] assert "шагов" in calls[0] -def test_propose_new_project_flag(client: TestClient, monkeypatch: Any) -> None: +def test_propose_include_flags(client: TestClient, monkeypatch: Any) -> None: + """include_description/include_tags выключают соответствующие поля: заполненное + пользователем не угадываем (первичный анализ).""" 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}' - ), + calls: list[str] = [] + + def fake_generate(self: Any, prompt: str) -> str: + calls.append(prompt) + return '{"title": "Коротко", "description": "1. Шаг", "tags": ["x"]}' + + monkeypatch.setattr(services.detailing.DetailingService, "generate", fake_generate) + proposal = service.propose( + "задача", "уже есть описание", ["быт"], [], include_description=False, include_tags=False ) - proposal = service.propose("задача", "", [], [{"name": "Дом", "description": ""}]) assert proposal is not None - assert proposal["new_project"] is True # проекта нет в списке + assert proposal["title"] == "Коротко" + assert proposal["description"] is None + assert proposal["tags"] == [] + # в промпте нет правил для отключённых полей + assert "шагов" not in calls[0] + assert "тегов" not in calls[0] def test_apply_proposal_maps_and_creates(client: TestClient) -> None: client.post("/api/tags", json={"name": "быт"}) - client.post("/api/projects", json={"name": "Дом"}) - proposal = {"tags": ["БЫТ"], "project": "Дом", "new_project": False, "priority": 7} + proposal = {"title": None, "description": None, "tags": ["БЫТ"]} resp = client.post("/api/tasks", json={"title": "задача с предложением"}) task_id = resp.json()["id"] set_proposal(task_id, proposal) @@ -153,49 +169,36 @@ approved = client.post(f"/api/tasks/{task_id}/approve", json={"apply_proposal": True}).json() assert approved["detail_state"] == "approved" assert [t["name"] for t in approved["tags"]] == ["быт"] # case-insensitive матч - assert approved["project"]["name"] == "Дом" - assert approved["priority"] == 7 + assert approved["project"] is None # проект LLM не предлагает + assert approved["priority"] is None -def test_apply_proposal_creates_new_project(client: TestClient) -> None: - resp = client.post("/api/tasks", json={"title": "новое направление"}) - task_id = resp.json()["id"] - proposal = {"tags": [], "project": "Ремонт дачи", "new_project": True, "priority": None} - set_proposal(task_id, proposal) - - approved = client.post(f"/api/tasks/{task_id}/approve", json={"apply_proposal": True}).json() - assert approved["project"]["name"] == "Ремонт дачи" - # Проект создан в справочнике - names = [p["name"] for p in client.get("/api/projects").json()] - assert "Ремонт дачи" in names - - -def test_apply_proposal_function_directly(client: TestClient) -> None: - """apply_proposal создаёт несуществующие теги (LLM может предлагать новые).""" - proposal = {"tags": ["нет такого"], "project": None, "new_project": False, "priority": 3} +def test_apply_proposal_merges_tags(client: TestClient) -> None: + """Теги предложения добавляются к назначенным (пользовательские не отбираются).""" task = create_task(client) - from app.models import Task + from app.models import Tag, Task from tests.conftest import _test_session_factory session = _test_session_factory() db_task = session.get(Task, task["id"]) assert db_task is not None - apply_proposal(session, db_task, proposal, "1") - assert db_task.priority == 3 - assert [t.name for t in db_task.tags] == ["нет такого"] + client.post("/api/tags", json={"name": "своё"}) + own = client.get("/api/tags").json()[0] + own_tag = session.get(Tag, own["id"]) + assert own_tag is not None + db_task.tags = [own_tag] + session.commit() + apply_proposal(session, db_task, {"tags": ["новый", "своё"]}, "1") + names = sorted(t.name for t in db_task.tags) + assert names == ["новый", "своё"] + # тег предложения создан в каталоге + assert "новый" in [t["name"] for t in client.get("/api/tags").json()] 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, - } + proposal = {"title": "Сократить заголовок", "description": "1. Шаг раз", "tags": []} set_proposal(task["id"], proposal) approved = client.post(f"/api/tasks/{task['id']}/approve", json={"apply_proposal": True}).json() assert approved["title"] == "Сократить заголовок" @@ -203,7 +206,7 @@ def test_redetail_clears_proposal(client: TestClient, monkeypatch: Any) -> None: - _mock_proposal(monkeypatch, {"tags": [], "project": None, "new_project": False, "priority": 1}) + _mock_proposal(monkeypatch, {"title": None, "description": None, "tags": []}) task = create_task(client) assert task["ai_proposal"] is not None @@ -219,12 +222,101 @@ не сбрасывается (раньше redetail молча стирал ai_proposal).""" task = create_task(client) client.post(f"/api/tasks/{task['id']}/approve") - set_proposal(task["id"], {"tags": [], "project": None, "new_project": False, "priority": 5}) + set_proposal(task["id"], {"title": None, "description": None, "tags": []}) 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) — полный анализ.""" + from app.services.detailing import detail_task + + resp = client.post( + "/api/tasks", json={"title": "длинный заголовок задачи", "description": "уже описано"} + ) + task_id = resp.json()["id"] + client.post("/api/tags", json={"name": "готовый"}) + tag = client.get("/api/tags").json()[0] + client.patch(f"/api/tasks/{task_id}", json={"tag_ids": [tag["id"]]}) + + calls: list[str] = [] + + def fake_generate(self: Any, prompt: str) -> str: + calls.append(prompt) + return '{"title": "Коротко"}' + + monkeypatch.setattr(services.detailing.DetailingService, "generate", fake_generate) + + detail_task(task_id) + assert "шагов" not in calls[0] and "тегов" not in calls[0] + task = client.get(f"/api/tasks/{task_id}").json() + assert task["ai_proposal"]["description"] is None + assert task["ai_proposal"]["tags"] == [] + + detail_task(task_id, force=True) + assert "шагов" in calls[1] and "тегов" in calls[1] + + +def test_detail_uses_project_summary(client: TestClient, monkeypatch: Any) -> None: + """В контекст LLM идёт суммаризация проекта, а не полная заметка.""" + from sqlalchemy import select + + from app.models import Project + from app.services.detailing import _project_context + from tests.conftest import _test_session_factory + + client.post("/api/projects", json={"name": "Дача", "note": "краткое резюме проекта"}) + session = _test_session_factory() + project = session.scalars(select(Project).where(Project.name == "Дача")).one() + project.summary = "Дача: ремонт и благоустройство" + session.commit() + + ctx = _project_context(session, "1") + assert ctx == [{"name": "Дача", "summary": "Дача: ремонт и благоустройство"}] + session.close() + + +def test_summarize_project_stores_summary(client: TestClient, monkeypatch: Any) -> None: + """Суммаризация пишется в projects.summary при правке заметки (фоном).""" + from app.models import Project + from tests.conftest import _test_session_factory + + def fake_generate(self: Any, prompt: str) -> str: + return "Дача: ремонт и благоустройство летом" + + monkeypatch.setattr(services.detailing.DetailingService, "generate", fake_generate) + + resp = client.post("/api/projects", json={"name": "Дача", "note": "Очень длинная заметка"}) + project_id = resp.json()["id"] + # BackgroundTasks TestClient выполняет синхронно — суммаризация уже готова + session = _test_session_factory() + project = session.get(Project, project_id) + assert project is not None + assert project.summary == "Дача: ремонт и благоустройство летом" + + # правка заметки — перегенерация + monkeypatch.setattr( + services.detailing.DetailingService, "generate", lambda self, prompt: "Новое резюме" + ) + client.patch(f"/api/projects/{project_id}", json={"note": "Совсем другая заметка"}) + session.expire_all() + project = session.get(Project, project_id) + assert project is not None + assert project.summary == "Новое резюме" + session.close() + + +def test_json_serializer_no_unicode_escapes() -> None: + r"""Кириллица в JSON-колонках не превращается в \u-эскейпы (виден агентам в MCP).""" + from app.db import json_serializer + + raw = json_serializer({"tags": ["быт"]}) + assert "быт" in raw + assert "\\u" not in raw + + def test_attachments_upload_list_fetch_delete(client: TestClient) -> None: task = create_task(client) png = b"\x89PNG\r\n\x1a\nfake-image-bytes" diff --git a/backend/tests/test_predict.py b/backend/tests/test_predict.py index 3b433e1..ca36b54 100644 --- a/backend/tests/test_predict.py +++ b/backend/tests/test_predict.py @@ -94,21 +94,26 @@ def test_proposal_estimate_applied(client: TestClient, monkeypatch: Any) -> None: + """ИИ не предлагает оценку времени и приоритет (решение 2026-09-22): + утверждение предложения не заполняет поля планирования.""" + def fake_propose( - self: Any, title: str, description: str, tag_names: list[str], project_names: list[str] + self: Any, + title: str, + description: str, + tag_names: list[str], + projects: list[dict[str, str]], + *, + include_description: bool = True, + include_tags: bool = True, ) -> dict[str, Any]: - return { - "tags": [], - "project": None, - "new_project": False, - "priority": 7, - "estimated_minutes": 45, - } + return {"title": None, "description": None, "tags": []} 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"] == 45 + assert approved["estimated_minutes"] is None + assert approved["priority"] is None def test_global_currency_settings(client: TestClient) -> None: diff --git a/docs/TZ.md b/docs/TZ.md index 403a7ad..a35c742 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.39 | +| Версия ТЗ | 0.40 | | Дата | 2026-09-22 | | Статус | На обсуждении | @@ -51,27 +51,33 @@ ### 3.2. Автоматическая предварительная детализация -При попадании задачи в стек система самостоятельно предлагает черновые метаданные: +При попадании задачи в стек система самостоятельно предлагает черновые метаданные +(0.40: только оформление задачи, без планирования): -- теги — максимум 3, выбираются из существующего справочника; если подходящих нет, - LLM может предложить 1–2 новых (создаются при утверждении); -- проект — выбирается из **открытых проектов с их описаниями** (LLM видит заметку - каждого проекта, чтобы соотнести задачу по смыслу); если ни один не подходит — - краткое название нового проекта; -- короткий заголовок — если исходный перегружен деталями, суть переносится в описание; +- короткий заголовок — длинный/перегруженный заголовок сжимается до лаконичного + названия, суть переносится в описание; - описание с нумерованными шагами — если шаги выполнения очевидны; -- возможно, приоритет и оценка длительности. +- теги — максимум 3: подходящие из существующего справочника или 1–2 новых; + **новые теги — только на английском**; +- LLM отвечает **на языке исходного текста задачи**. + +Планирование ИИ не делает (решение 2026-09-22, «0% попадания»): **проект, +приоритет и оценку длительности LLM не предлагает** — их заполняет пользователь. Требования: - Это **не окончательная** детализация — промахи ожидаемы и нормальны. +- **Заполненные пользователем поля не угадываются** (0.40): при первичном анализе + предлагается только недостающее (заголовок — всегда, сжатие — главная работа; + описание и теги — только если их нет). «Переспросить ИИ» (redetail) — явный + запрос полного анализа, он может перепредложить всё. - При разборе задачи в стеке доступны варианты: - 1. **«Да, всё верно»** — принять предложенные метаданные одним действием; + 1. **«Да, всё верно»** — принять предложенные метаданные одним действием + (теги предложения **добавляются** к назначенным, пользовательские не снимаются); 2. **отредактировать** — изменить предложенное вручную, затем утвердить. - Задача считается утверждённой (`approved`) только после явного действия пользователя. - Ответ LLM валидируется: строки-заглушки («NULL», пустые/пробельные, «n/a») - отбрасываются, длина ограничивается, дубли тегов схлопываются; вне шкалы - приоритет и длительность обнуляются. + отбрасываются, длина ограничивается, дубли тегов схлопываются. - Механизм предсказания — см. 3.11 и раздел открытых вопросов. ### 3.3. Содержание и редактирование задачи @@ -211,10 +217,19 @@ - Автодетализация (3.2) выполняется **маленькой LLM, запущенной локально через Ollama**: - модель и параметры — **конфигурацией приложения**, без зашивки в код; - - LLM классифицирует текст задачи: предлагает теги (из справочника или новые), проект - по описаниям открытых проектов, короткий заголовок, описание с шагами, приоритет; - - результат — черновые метаданные для утверждения (3.2), не окончательное решение. + - LLM оформляет текст задачи: короткий заголовок, описание с шагами, теги + (из справочника или новые на английском), на языке исходного текста; + - результат — черновые метаданные для утверждения (3.2), не окончательное решение; + - планирование (проект/приоритет/время) ИИ не делает (0.40). +- **Суммаризация проектов (0.40)**: заметка проекта может быть большой для + маленькой модели, поэтому для контекста автодетализации LLM видит + `projects.summary` — краткое резюме заметки (суть + полезные признаки для + классификации задач). Суммаризация генерируется той же LLM фоном при создании + проекта с заметкой и при правке заметки (кэш в БД); без суммаризации в контекст + идёт обрезка заметки. - Все вызовы LLM — на собственном сервере, данные не покидают VPS. +- JSON-колонки хранят кириллицу без \u-эскейпов (0.40): MCP-агенты читают + читаемый текст (ensure_ascii=False). - Спекулярные LLM-сценарии вне метаданных — не в скоупе: большинство ИИ-задач выполняют внешние агенты через MCP (3.10). ### 3.12. Мультиязычность diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d90f8be..4b502f5 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -17,15 +17,17 @@ document_id: number | null } -// Черновое предложение автодетализации (LLM) +// Черновое предложение автодетализации (LLM): только заголовок, описание, теги. +// Поля планирования (project/priority/estimated_minutes) остались у старых +// предложений в БД — ИИ их больше не предлагает (ТЗ 0.40) export interface AiProposal { // короткий заголовок и описание с шагами — если LLM сочла нужным предложить title?: string | null description?: string | null tags: string[] - project: string | null - new_project: boolean - priority: number | null + project?: string | null + new_project?: boolean + priority?: number | null estimated_minutes?: number | null } diff --git a/frontend/src/taskui.ts b/frontend/src/taskui.ts index ca004a8..1995bd5 100644 --- a/frontend/src/taskui.ts +++ b/frontend/src/taskui.ts @@ -268,7 +268,7 @@ icon: 'ph-folder', label: p.new_project ? t('stack.proposalNewProject', { name: p.project }) : p.project, }) - if (p.priority !== null) + if (p.priority != null) items.push({ icon: 'ph-flag', label: priorityLabel(p.priority) }) if (p.estimated_minutes !== null && p.estimated_minutes !== undefined) items.push({ icon: 'ph-timer', label: `≈ ${formatMinutes(p.estimated_minutes)}` }) diff --git a/frontend/src/views/TaskView.vue b/frontend/src/views/TaskView.vue index 2469647..08069ca 100644 --- a/frontend/src/views/TaskView.vue +++ b/frontend/src/views/TaskView.vue @@ -383,7 +383,7 @@ term: t('task.field.project'), value: p.new_project ? t('stack.proposalNewProject', { name: p.project }) : p.project, }) - if (p.priority !== null) + if (p.priority != null) rows.push({ icon: 'ph-flag', term: t('task.field.priority'),