diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index e495dd4..aeb4aa3 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -25,7 +25,7 @@ from app.realtime import publish from app.schemas import DEADLINE_PERIODS, RECUR_KINDS from app.services.closing import handle_task_closed -from app.services.detailing import detail_task +from app.services.detailing import detail_task, summarize_project from app.services.xp import coins_for_create, grant_create_xp VALID_STATUSES = {"to_do", "in_progress", "done", "cancelled", "deferred"} @@ -70,6 +70,13 @@ list_tasks(query="подстрока", project_id=..., status=...) ищет по заголовку и описанию; get_task(task_id) даёт полные данные. Найдя задачу, работайте с ней по её id. + +УПРАВЛЕНИЕ ПРОЕКТАМИ +create_project(name, note) — новый проект; update_project — переименование, +заметка, relevance_status ("active" | "paused"), priority. Архив вместо +удаления: archive_project прячет проект вместе с задачами из активных +(история сохраняется), restore_project возвращает. Безвозвратного удаления +проектов в MCP нет намеренно. """ @@ -658,10 +665,175 @@ session.close() +# --- управление проектами (ТЗ 3.10): архив вместо удаления --- + + +def create_project( + name: Annotated[str, Field(description="Название проекта (уникально)")], + note: Annotated[ + str | None, + Field(description="Заметка проекта: цель, договорённости, ссылки (markdown)"), + ] = None, + priority: Annotated[int | None, Field(description="Приоритет 0-10")] = None, + ctx: Context[Any, Any, Any] | None = None, +) -> dict[str, Any]: + """Создать проект — контейнер задач со своей заметкой. + + Микронаграда (XP, монеты) начисляется автоматически. Название должно быть + уникальным: если не уверены, что проекта ещё нет, сначала list_projects(). + Создав проект, привязывайте к нему задачи (project_id/project_name). + """ + user_id = _mcp_user(ctx) + if not name.strip(): + raise ValueError("name cannot be empty") + session = get_session_factory()() + try: + exists = cast( + Project | None, + session.scalar(select(Project).where(Project.name == name, Project.user_id == user_id)), + ) + if exists is not None: + raise ValueError( + f"Project '{name}' already exists (id {exists.id}) — pass project_id=... " + "to create_task/update_task instead of creating a duplicate." + ) + project = Project(user_id=user_id, name=name, note=note or "", priority=priority) + session.add(project) + session.flush() + grant_create_xp(session, "create_project", user_id) + session.add( + CoinEvent( + user_id=user_id, source="create_project", amount=coins_for_create("create_project") + ) + ) + session.commit() + if (note or "").strip(): + threading.Thread(target=summarize_project, args=(project.id,), daemon=True).start() + publish(user_id, "project.changed", {"id": project.id}) + publish(user_id, "xp.changed", {"celebrate": False}) + return _project_compact(project, session) + finally: + session.close() + + +def update_project( + project_id: Annotated[int | None, Field(description="id проекта (из list_projects)")] = None, + name: Annotated[ + str | None, Field(description="Текущее имя проекта, если id неизвестен") + ] = None, + new_name: Annotated[str | None, Field(description="Новое название проекта")] = None, + note: Annotated[str | None, Field(description="Новая заметка проекта, markdown")] = None, + relevance_status: Annotated[ + str | None, Field(description="active (в работе) | paused (приостановлен)") + ] = None, + priority: Annotated[int | None, Field(description="Приоритет 0-10")] = None, + ctx: Context[Any, Any, Any] | None = None, +) -> dict[str, Any]: + """Частично обновить проект — передавайте только нужные поля. + + Заметка проекта — главный контекст для агента (get_project) и + автодетализации, обновляйте её по итогам договорённостей. + """ + user_id = _mcp_user(ctx) + session = get_session_factory()() + try: + project = _resolve_project(session, user_id, project_id, name) + if project is None: + raise ValueError("Pass project_id or name — see list_projects().") + if new_name is not None: + if not new_name.strip(): + raise ValueError("new_name cannot be empty") + if new_name != project.name: + exists = cast( + Project | None, + session.scalar( + select(Project).where(Project.name == new_name, Project.user_id == user_id) + ), + ) + if exists is not None: + raise ValueError( + f"Project name '{new_name}' already exists (id {exists.id}) — " + "pick another name." + ) + project.name = new_name + note_changed = note is not None and note != project.note + if note is not None: + project.note = note + if relevance_status is not None: + if relevance_status not in ("active", "paused"): + raise ValueError( + f"Unknown relevance_status: {relevance_status} — use 'active' or 'paused'." + ) + project.relevance_status = relevance_status + if priority is not None: + project.priority = priority + session.commit() + if note_changed: + # суммаризация заметки для контекста детализации — не блокирует ответ + threading.Thread(target=summarize_project, args=(project.id,), daemon=True).start() + publish(user_id, "project.changed", {"id": project.id}) + return _project_compact(project, session) + finally: + session.close() + + +def archive_project( + project_id: Annotated[int | None, Field(description="id проекта (из list_projects)")] = None, + name: Annotated[str | None, Field(description="Имя проекта, если id неизвестен")] = None, + ctx: Context[Any, Any, Any] | None = None, +) -> dict[str, Any]: + """Переместить проект в архив вместе со всеми задачами — НЕ удаление. + + Проект исчезает из активных списков, но история и задачи сохраняются; + вернуть можно restore_project(). Повторный вызов безвреден. + """ + user_id = _mcp_user(ctx) + session = get_session_factory()() + try: + project = _resolve_project(session, user_id, project_id, name) + if project is None: + raise ValueError("Pass project_id or name — see list_projects().") + if not project.is_archived: + project.is_archived = True + session.commit() + publish(user_id, "project.changed", {"id": project.id}) + return _project_compact(project, session) + finally: + session.close() + + +def restore_project( + project_id: Annotated[int | None, Field(description="id проекта (из list_projects)")] = None, + name: Annotated[str | None, Field(description="Имя проекта, если id неизвестен")] = None, + ctx: Context[Any, Any, Any] | None = None, +) -> dict[str, Any]: + """Вернуть проект из архива — со всеми задачами и историей. + + Архивные проекты ищутся: list_projects(include_archived=True). + """ + user_id = _mcp_user(ctx) + session = get_session_factory()() + try: + project = _resolve_project(session, user_id, project_id, name) + if project is None: + raise ValueError("Pass project_id or name — see list_projects().") + if project.is_archived: + project.is_archived = False + session.commit() + publish(user_id, "project.changed", {"id": project.id}) + return _project_compact(project, session) + finally: + session.close() + + # --- регистрация в MCP (сигнатуры — документация для агента) --- mcp.tool()(list_projects) mcp.tool()(get_project) +mcp.tool()(create_project) +mcp.tool()(update_project) +mcp.tool()(archive_project) +mcp.tool()(restore_project) mcp.tool()(create_task) mcp.tool()(update_task) mcp.tool()(complete_task) diff --git a/backend/tests/test_mcp.py b/backend/tests/test_mcp.py index 0198c39..6fd6e22 100644 --- a/backend/tests/test_mcp.py +++ b/backend/tests/test_mcp.py @@ -23,6 +23,10 @@ "list_projects", "get_project", "list_tags", + "create_project", + "update_project", + "archive_project", + "restore_project", ): orig = getattr(mcp_server, name) @@ -270,3 +274,78 @@ def test_mcp_list_tags(client: TestClient) -> None: tags = mcp_server.list_tags() assert tags == [] # теги создаёт UI/детализация; для агента важна пустая выдача + + +def test_mcp_create_project(client: TestClient) -> None: + p = mcp_server.create_project("Проект агента", note="цель — дожить до пятницы") + assert p["is_archived"] is False + assert "дожить" in p["note"] + + # дубликат имени отклоняется с подсказкой + with pytest.raises(ValueError, match="already exists"): + mcp_server.create_project("Проект агента") + + # задача сразу привязывается к новому проекту + task = mcp_server.create_task("x", project_name="Проект агента") + assert task["project"] == "Проект агента" + + +def test_mcp_create_project_grants_micro_reward(client: TestClient) -> None: + from sqlalchemy import select + + from app.models import CoinEvent, XpEvent + from tests.conftest import _test_session_factory + + mcp_server.create_project("С микронаградой") + session = _test_session_factory() + try: + kinds = session.scalars( + select(XpEvent.kind).where(XpEvent.kind == "create_project") + ).all() + assert kinds == ["create_project"] + coins = session.scalars( + select(CoinEvent).where(CoinEvent.source == "create_project") + ).all() + assert len(coins) == 1 + finally: + session.close() + + +def test_mcp_update_project(client: TestClient) -> None: + p = mcp_server.create_project("Старое имя") + updated = mcp_server.update_project( + project_id=p["id"], new_name="Новое имя", relevance_status="paused", priority=7 + ) + assert updated["name"] == "Новое имя" + assert updated["relevance_status"] == "paused" + + # переименование в занятое имя и неизвестный статус отклоняются + mcp_server.create_project("Занятое") + with pytest.raises(ValueError, match="already exists"): + mcp_server.update_project(project_id=p["id"], new_name="Занятое") + with pytest.raises(ValueError, match="relevance_status"): + mcp_server.update_project(project_id=p["id"], relevance_status="done") + + +def test_mcp_archive_and_restore_project(client: TestClient) -> None: + """Архив — не удаление: задачи и история переживают архив (ТЗ 3.10).""" + p = mcp_server.create_project("Архивный") + tid = mcp_server.create_task("Задача в архивном", project_name="Архивный")["id"] + + archived = mcp_server.archive_project(name="Архивный") + assert archived["is_archived"] is True + # из активных исчез, в архивном списке есть + assert all(x["id"] != p["id"] for x in mcp_server.list_projects()) + assert any( + x["id"] == p["id"] for x in mcp_server.list_projects(include_archived=True) + ) + # задача проекта сохранилась + assert mcp_server.get_task(tid)["id"] == tid + + restored = mcp_server.restore_project(project_id=p["id"]) + assert restored["is_archived"] is False + assert any(x["id"] == p["id"] for x in mcp_server.list_projects()) + + # повторные вызовы безвредны (идемпотентны) + assert mcp_server.restore_project(project_id=p["id"])["is_archived"] is False + assert mcp_server.archive_project(project_id=p["id"])["is_archived"] is True diff --git a/docs/TZ.md b/docs/TZ.md index 0229f2a..07df63d 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,8 +4,8 @@ | | | |---|---| -| Версия ТЗ | 0.44 | -| Дата | 2026-09-22 | +| Версия ТЗ | 0.45 | +| Дата | 2026-09-23 | | Статус | На обсуждении | --- @@ -203,13 +203,25 @@ - `get_task` — полное описание с вложениями; - `list_projects` / `get_project` — обнаружение проектов и **восстановление контекста проекта** (заметка проекта + его открытые задачи); + - `create_project` — новый проект (имя, опционально заметка и приоритет); + дубликат имени отклоняется с подсказкой; заметка уходит в фоновую + суммаризацию; микронаграда как при создании из UI; + - `update_project` — переименование, заметка, relevance_status + (`active`/`paused`), приоритет; адресация по id или имени; + - `archive_project` / `restore_project` — **архив вместо удаления** (0.45): + проект прячется вместе с задачами из активных (история сохраняется), + restore возвращает; вызовы идемпотентны; архивные проекты видны через + `list_projects(include_archived=True)`. Безвозвратного удаления проектов + в MCP нет намеренно; - `list_tags` — справочник тегов (id для параметра `tag_ids`). - Эргономика для агентов (в т.ч. небольших моделей): - серверные `instructions`: порядок работы, правило привязки задач к проекту, восстановление контекста через `get_project` после перерыва, запрет придумывать id, автоматика наград/спавна при закрытии; - адресация проекта по имени: параметры `project_name` (регистронезависимо) - в `create_task`/`update_task`/`list_tasks` наряду с `project_id`; + в тудах задач (`create_task`/`update_task`/`list_tasks`) и в тудах + управления проектами (`update_project`/`archive_project`/ + `restore_project`) наряду с `project_id`; - описания параметров в схеме каждого инструмента (enum значений, форматы дат, диапазоны приоритета); - ошибки подсказывают следующий шаг («call list_projects()», «найдите id через