diff --git a/backend/app/api/attachments.py b/backend/app/api/attachments.py index abc3304..85f5ff4 100644 --- a/backend/app/api/attachments.py +++ b/backend/app/api/attachments.py @@ -95,17 +95,13 @@ document = _get_document_or_404(db, document_id) return list( db.scalars( - select(Attachment) - .where(Attachment.document_id == document.id) - .order_by(Attachment.id) + select(Attachment).where(Attachment.document_id == document.id).order_by(Attachment.id) ).all() ) @router.get("/attachments/{attachment_id}/file") -async def get_attachment_file( - attachment_id: int, db: DbDep, user: UserDep -) -> FileResponse: +async def get_attachment_file(attachment_id: int, db: DbDep, user: UserDep) -> FileResponse: att = _get_attachment_or_404(db, attachment_id) path = _attachments_dir() / att.filename if not path.is_file(): diff --git a/backend/app/api/garden.py b/backend/app/api/garden.py index 2b0bc12..2dd4818 100644 --- a/backend/app/api/garden.py +++ b/backend/app/api/garden.py @@ -100,9 +100,7 @@ ], "next_expansion": expansion, # весь список расширений — для дерева уровней (что открывается на уровне) - "expansions": [ - {"key": key, **spec} for key, spec in sorted(EXPANSIONS.items()) - ], + "expansions": [{"key": key, **spec} for key, spec in sorted(EXPANSIONS.items())], "expansions_bought": garden.expansions_bought(db), "species": species_catalog(level), "upgrade_costs": UPGRADE_COSTS, @@ -131,9 +129,7 @@ size = grid_size(db) if not (0 <= move.x < size["cols"] and 0 <= move.y < size["rows"]): raise HTTPException(status_code=400, detail="Position outside the garden") - if item.kind == "plant" and (move.x, move.y) in house_footprint( - size["cols"], size["rows"] - ): + if item.kind == "plant" and (move.x, move.y) in house_footprint(size["cols"], size["rows"]): raise HTTPException(status_code=400, detail="Cannot place on the house") item.x, item.y = move.x, move.y db.commit() @@ -152,9 +148,7 @@ rarity = event.rarity if event else "common" cost = UPGRADE_COSTS[rarity][item.stage] if not garden.can_afford(db, cost): - raise HTTPException( - status_code=402, detail=f"Not enough coins: need {cost}" - ) + raise HTTPException(status_code=402, detail=f"Not enough coins: need {cost}") garden.spend(db, cost, f"upgrade:{item.item_key}") item.stage += 1 db.commit() @@ -196,9 +190,7 @@ raise HTTPException(status_code=409, detail="Expansion already bought") spec = EXPANSIONS[item_key] if level < spec["level"]: - raise HTTPException( - status_code=403, detail=f"Unlocks at level {spec['level']}" - ) + raise HTTPException(status_code=403, detail=f"Unlocks at level {spec['level']}") if not garden.can_afford(db, spec["cost"]): raise HTTPException(status_code=402, detail=f"Not enough coins: need {spec['cost']}") garden.spend(db, spec["cost"], item_key) diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index e93966d..eb2e9d3 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -37,9 +37,7 @@ @router.get("", response_model=list[ProjectOut]) -async def list_projects( - db: DbDep, user: UserDep, archived: bool = Query(False) -) -> list[Project]: +async def list_projects(db: DbDep, user: UserDep, archived: bool = Query(False)) -> list[Project]: """Активные проекты; archived=true — список архива (история).""" return list( db.scalars( diff --git a/backend/app/mcp_server.py b/backend/app/mcp_server.py index cf01640..7a290ef 100644 --- a/backend/app/mcp_server.py +++ b/backend/app/mcp_server.py @@ -1,6 +1,8 @@ """MCP-сервер (M5, ТЗ 3.10): инструменты для ИИ-агентов. -Агент действует от имени пользователя; мультитенантности нет. Аутентификация +Заточен под небольшие модели: подробные описания тулов и параметров, +адресация проекта по имени, ошибки с подсказкой следующего шага. Агент +действует от имени пользователя; мультитенантности нет. Аутентификация временно — bearer-токен из конфига (MCP_TOKEN), до интеграции SSO-токенов. Инструменты создают свои сессии к БД (вне HTTP-зависимостей FastAPI). @@ -8,10 +10,11 @@ import threading from datetime import date -from typing import Any, cast +from typing import Annotated, Any, cast from mcp.server.fastmcp import FastMCP -from sqlalchemy import and_, select +from pydantic import Field +from sqlalchemy import and_, func, select from app.db import get_session_factory from app.models import Attachment, CoinEvent, Document, Project, Tag, Task, utcnow @@ -23,9 +26,39 @@ VALID_STATUSES = {"to_do", "in_progress", "done", "cancelled", "deferred"} +# Серверные инструкции — агент видит их до вызова любого тула (ТЗ 3.10). +# Дисциплина проектного контекста: задачи не должны отрываться от проекта, +# а контекст разговора восстанавливается через get_project. +INSTRUCTIONS = """Личный менеджер задач GNexus Tasks (gntodo). + +ПОРЯДОК РАБОТЫ +1. Начинайте с проектов: list_projects() покажет проекты и их заметки; + get_project(project_id=... или name="...") вернёт заметку проекта и его + открытые задачи — это контекст, в рамках которого живут задачи. +2. Создавая или обновляя задачу, привязывайте её к проекту — передавайте + project_id или project_name. В ответе есть поле project: проверяйте, что + задача попала в нужный проект. Задача без проекта легко теряется. +3. Возвращаясь к разговору после перерыва, перечитайте get_project(...) — + заметка и открытые задачи восстановят контекст. +4. Берите id ТОЛЬКО из ответов инструментов (задачи, проекты, теги) — не + придумывайте их. Ошибки подсказывают следующий шаг (например, + «call list_projects()») — следуйте подсказке. + +ЗАКРЫТИЕ ЗАДАЧИ +update_task(task_id, status="done") или complete_task(task_id, actual_minutes=N). +Награды (XP, монеты, растение в саду) начисляются автоматически; повторное +закрытие дублей не даёт. У регулярной задачи следующий экземпляр создаётся сам +(поле spawned_next_id в ответе) — вручную создавать его не нужно. + +ПОИСК +list_tasks(query="подстрока", project_id=..., status=...) ищет по заголовку и +описанию; get_task(task_id) даёт полные данные. Найдя задачу, работайте с ней +по её id. +""" + # streamable_http_path="/" — чтобы под /mcp основного приложения маршрут # FastMCP не дублировался (иначе он оказывается на /mcp/mcp) -mcp = FastMCP("gntodo", streamable_http_path="/") +mcp = FastMCP("gntodo", streamable_http_path="/", instructions=INSTRUCTIONS) def _compact(task: Task) -> dict[str, Any]: @@ -34,6 +67,7 @@ "title": task.title, "status": task.status, "detail_state": task.detail_state, + "project_id": task.project_id, "project": task.project.name if task.project else None, "tags": [t.name for t in task.tags], "priority": task.priority, @@ -73,22 +107,95 @@ def _get_task(db: Any, task_id: int) -> Task: task = cast(Task | None, db.get(Task, task_id)) if task is None: - raise ValueError(f"Task {task_id} not found") + raise ValueError( + f"Task {task_id} not found — find the right id with list_tasks(query=...)." + ) return task +def _resolve_project(db: Any, project_id: int | None, project_name: str | None) -> Project | None: + """Проект по id или имени (регистронезависимо); ничего не задано — None. + + project_id приоритетнее. Ошибка всегда подсказывает, как найти верный + проект, — маленькая модель должна понимать следующий шаг (ТЗ 3.10). + """ + if project_id is not None: + project = cast(Project | None, db.get(Project, project_id)) + if project is None: + raise ValueError( + f"Project {project_id} not found — call list_projects() to get valid ids." + ) + return project + if project_name is not None: + # Сравнение в Python: lower() в SQLite понимает только ASCII, + # кириллические имена проектов не сматчились бы (ТЗ 3.10) + wanted = project_name.strip().lower() + project = cast( + Project | None, + next( + (p for p in db.scalars(select(Project)).all() if p.name.lower() == wanted), + None, + ), + ) + if project is None: + raise ValueError( + f"Project '{project_name}' not found — call list_projects() " + "to see existing projects." + ) + return project + return None + + +def _project_compact(project: Project, db: Any) -> dict[str, Any]: + """Проект для списка: контекст в сжатом виде, заметка обрезана.""" + open_tasks = ( + db.scalar( + select(func.count(Task.id)).where( + Task.project_id == project.id, Task.status.in_(("to_do", "in_progress")) + ) + ) + or 0 + ) + note = project.note + return { + "id": project.id, + "name": project.name, + "relevance_status": project.relevance_status, + "is_archived": project.is_archived, + "open_tasks": open_tasks, + "note": note[:300] + "…" if len(note) > 300 else note, + } + + # --- инструменты (логика отдельно от регистрации — тестируется напрямую) --- def create_task( - title: str, - description: str = "", - parent_task_id: int | None = None, - project_id: int | None = None, - tag_ids: list[int] | None = None, - priority: int | None = None, + title: Annotated[str, Field(description="Короткий текст задачи (обязателен)")], + description: Annotated[str, Field(description="Подробности и шаги, markdown")] = "", + parent_task_id: Annotated[ + int | None, Field(description="id родительской задачи, если это подзадача") + ] = None, + project_id: Annotated[ + int | None, + Field(description="id проекта (из list_projects); приоритетнее project_name"), + ] = None, + project_name: Annotated[ + str | None, Field(description="Имя проекта (регистронезависимо) — если id неизвестен") + ] = None, + tag_ids: Annotated[ + list[int] | None, Field(description="id тегов (список из list_tags)") + ] = None, + priority: Annotated[ + int | None, Field(description="Приоритет 0-10: 0-2 низкий, 5-6 средний, 9-10 срочный") + ] = None, ) -> dict[str, Any]: - """Создать задачу. Достаточно title — автодетализация запустится в фоне.""" + """Создать задачу; достаточно title — остальное дописывается через update_task. + + По возможности привяжите задачу к проекту (project_id или project_name) — + задача без проекта теряется. Автодетализация подставит метаданные в фоне. + Ответ содержит поле project — проверьте привязку. + """ session = get_session_factory()() try: if parent_task_id is not None: @@ -99,15 +206,13 @@ parent_task_id=parent_task_id, priority=priority, ) - if project_id is not None: - project = cast(Project | None, session.get(Project, project_id)) - if project is None: - raise ValueError(f"Project {project_id} not found") - task.project_id = project_id + project = _resolve_project(session, project_id, project_name) + if project is not None: + task.project_id = project.id if tag_ids: tags = list(session.scalars(select(Tag).where(Tag.id.in_(tag_ids))).all()) if len(tags) != len(set(tag_ids)): - raise ValueError("Unknown tag id in tag_ids") + raise ValueError("Unknown tag id in tag_ids — get ids from list_tags()") task.tags = tags session.add(task) session.flush() @@ -120,39 +225,91 @@ publish(None, "xp.changed", {"celebrate": False}) # Автодетализация — в фоновом потоке (LLM работает десятки секунд) threading.Thread(target=detail_task, args=(task.id,), daemon=True).start() - return {"id": task.id, "title": task.title, "detail_state": task.detail_state} + result: dict[str, Any] = { + "id": task.id, + "title": task.title, + "detail_state": task.detail_state, + "project": task.project.name if task.project else None, + } + if task.project is None: + result["hint"] = ( + "Проект не задан — передайте project_id/project_name " + "(см. list_projects()), чтобы задача не потерялась." + ) + return result finally: session.close() def update_task( - task_id: int, - title: str | None = None, - description: str | None = None, - status: str | None = None, - project_id: int | None = None, - parent_task_id: int | None = None, - priority: int | None = None, - estimated_minutes: int | None = None, - actual_minutes: int | None = None, - budget_money: int | None = None, - cost_estimate_money: int | None = None, - deadline_date: str | None = None, - deadline_period: str | None = None, - task_type: str | None = None, - recur_kind: str | None = None, - recur_interval_days: int | None = None, - recur_weekdays: str | None = None, - recur_day_of_month: int | None = None, - tag_ids: list[int] | None = None, + task_id: Annotated[int, Field(description="id задачи (из list_tasks/get_task)")], + title: Annotated[str | None, Field(description="Новый заголовок")] = None, + description: Annotated[str | None, Field(description="Новое описание, markdown")] = None, + status: Annotated[ + str | None, + Field( + description=( + "to_do | in_progress | done | cancelled | deferred. " + "done закрывает задачу — награды начисляются автоматически" + ) + ), + ] = None, + project_id: Annotated[ + int | None, + Field(description="id проекта (из list_projects); приоритетнее project_name"), + ] = None, + project_name: Annotated[ + str | None, Field(description="Имя проекта (регистронезависимо) — если id неизвестен") + ] = None, + parent_task_id: Annotated[ + int | None, Field(description="id родительской задачи (перенос под другую задачу)") + ] = None, + priority: Annotated[ + int | None, Field(description="Приоритет 0-10: 0-2 низкий, 5-6 средний, 9-10 срочный") + ] = None, + estimated_minutes: Annotated[ + int | None, Field(description="Оценка времени в минутах (влияет на XP за закрытие)") + ] = None, + actual_minutes: Annotated[ + int | None, Field(description="Фактически потраченное время в минутах") + ] = None, + budget_money: Annotated[int | None, Field(description="Бюджет в единой валюте")] = None, + cost_estimate_money: Annotated[ + int | None, Field(description="Оценка стоимости в единой валюте") + ] = None, + deadline_date: Annotated[ + str | None, Field(description="Строгий дедлайн, ISO-дата (например 2026-10-01)") + ] = None, + deadline_period: Annotated[ + str | None, Field(description="Нестрогий срок: day | week | month | year") + ] = None, + task_type: Annotated[ + str | None, Field(description="one_time (разовая) | recurring (регулярная)") + ] = None, + recur_kind: Annotated[ + str | None, + Field(description="Правило повторения: interval | weekdays | monthly"), + ] = None, + recur_interval_days: Annotated[ + int | None, Field(description="Для recur_kind=interval: раз в N дней") + ] = None, + recur_weekdays: Annotated[ + str | None, Field(description="Для recur_kind=weekdays: дни через запятую, пн=1..вс=7") + ] = None, + recur_day_of_month: Annotated[ + int | None, Field(description="Для recur_kind=monthly: число месяца 1-31") + ] = None, + tag_ids: Annotated[ + list[int] | None, Field(description="Полный новый список id тегов (из list_tags)") + ] = None, ) -> dict[str, Any]: - """Частично обновить задачу (передавайте только нужные поля). + """Частично обновить задачу — передавайте только нужные поля. - deadline_date — ISO-дата строгого дедлайна; deadline_period — нестрогий: - day|week|month|year. Регулярная задача: task_type="recurring" и правило - recur_kind (interval: recur_interval_days; weekdays: recur_weekdays - "1,3,5" пн=1..вс=7; monthly: recur_day_of_month). При завершении - регулярной задачи следующий экземпляр создаётся автоматически. + Регулярная задача: task_type="recurring" и правило recur_kind (interval: + recur_interval_days; weekdays: recur_weekdays "1,3,5" пн=1..вс=7; monthly: + recur_day_of_month). Закрытие (status="done") начисляет награды и создаёт + следующий экземпляр регулярной задачи автоматически. Ответ содержит поле + project — проверяйте, что задача осталась в нужном проекте. """ session = get_session_factory()() try: @@ -165,7 +322,9 @@ session.scalar(select(Task).where(Task.id == task_id).with_for_update(of=Task)), ) if task is None: - raise ValueError(f"Task {task_id} not found") + raise ValueError( + f"Task {task_id} not found — find the right id with list_tasks(query=...)." + ) if title is not None: if not title.strip(): raise ValueError("title cannot be empty") @@ -202,10 +361,10 @@ task.recur_weekdays = recur_weekdays or None if recur_day_of_month is not None: task.recur_day_of_month = recur_day_of_month - if project_id is not None: - if session.get(Project, project_id) is None: - raise ValueError(f"Project {project_id} not found") - task.project_id = project_id + if project_id is not None or project_name is not None: + project = _resolve_project(session, project_id, project_name) + assert project is not None # ветка входит только при заданном проекте + task.project_id = project.id if parent_task_id is not None: _get_task(session, parent_task_id) task.parent_task_id = parent_task_id @@ -254,27 +413,55 @@ session.close() -def complete_task(task_id: int, actual_minutes: int | None = None) -> dict[str, Any]: - """Завершить задачу; actual_minutes — фактическое время в минутах.""" +def complete_task( + task_id: Annotated[int, Field(description="id задачи (из list_tasks/get_task)")], + actual_minutes: Annotated[ + int | None, Field(description="Фактически потраченное время в минутах") + ] = None, +) -> dict[str, Any]: + """Завершить задачу (псевдоним update_task со status="done"). + + Награды и спавн следующего экземпляра регулярной задачи выполняются + автоматически; повторный вызов дублей не создаёт. + """ return update_task(task_id, status="done", actual_minutes=actual_minutes) def list_tasks( - status: str | None = None, - project_id: int | None = None, - tag_id: int | None = None, - detail_state: str | None = None, - query: str | None = None, - limit: int = 50, + status: Annotated[ + str | None, Field(description="Фильтр: to_do | in_progress | done | cancelled | deferred") + ] = None, + project_id: Annotated[ + int | None, Field(description="Фильтр по id проекта (из list_projects)") + ] = None, + project_name: Annotated[ + str | None, Field(description="Фильтр по имени проекта (регистронезависимо)") + ] = None, + tag_id: Annotated[int | None, Field(description="Фильтр по id тега (из list_tags)")] = None, + detail_state: Annotated[ + str | None, Field(description="raw (стек входящих) | approved (разобрано)") + ] = None, + query: Annotated[ + str | None, Field(description="Подстрока по заголовку и описанию задачи") + ] = None, + limit: Annotated[ + int, Field(description="Максимум задач в ответе (по умолчанию 50, до 200)") + ] = 50, ) -> list[dict[str, Any]]: - """Список/поиск задач: фильтры по статусу, проекту, тегу, подстроке.""" + """Найти задачи: фильтры по статусу, проекту, тегу и подстроке. + + Каждая задача в ответе: id, title, status, project, tags и сроки. Работайте + с найденной задачей по её id (update_task / get_task / complete_task). + """ session = get_session_factory()() try: stmt = select(Task).order_by(Task.created_at.desc()).limit(min(limit, 200)) if status: stmt = stmt.where(Task.status == status) - if project_id: - stmt = stmt.where(Task.project_id == project_id) + if project_id or project_name: + project = _resolve_project(session, project_id, project_name) + assert project is not None # задан хотя бы один фильтр проекта + stmt = stmt.where(Task.project_id == project.id) if detail_state: stmt = stmt.where(Task.detail_state == detail_state) if tag_id: @@ -292,8 +479,14 @@ session.close() -def get_task(task_id: int) -> dict[str, Any]: - """Полное описание задачи, включая вложения.""" +def get_task( + task_id: Annotated[int, Field(description="id задачи (из list_tasks)")], +) -> dict[str, Any]: + """Полное описание задачи: описание, время, бюджет, вложения. + + Ошибка «not found» означает неверный id — найдите верный через + list_tasks(query=...). + """ session = get_session_factory()() try: return _full(_get_task(session, task_id), session) @@ -301,10 +494,79 @@ session.close() +def list_projects( + include_archived: Annotated[bool, Field(description="Включая проекты в архиве")] = False, +) -> list[dict[str, Any]]: + """Список проектов: id, имя, заметка (сокращённая), число открытых задач. + + Вызывайте ПЕРЕД созданием задач: выберите подходящий проект и передавайте + его project_id (или project_name) в create_task/update_task — так задачи + остаются в контексте своего проекта. + """ + session = get_session_factory()() + try: + stmt = select(Project).order_by(Project.name) + if not include_archived: + stmt = stmt.where(Project.is_archived.is_(False)) + return [_project_compact(p, session) for p in session.scalars(stmt).all()] + finally: + session.close() + + +def get_project( + project_id: Annotated[int | None, Field(description="id проекта (из list_projects)")] = None, + name: Annotated[str | None, Field(description="Имя проекта, если id неизвестен")] = None, +) -> dict[str, Any]: + """Контекст проекта: заметка (цели, договорённости) и открытые задачи. + + Вызывайте перед обсуждением задач проекта и после перерыва в разговоре — + заметка и открытые задачи восстановят контекст. + """ + session = get_session_factory()() + try: + project = _resolve_project(session, project_id, name) + if project is None: + raise ValueError("Pass project_id or name — see list_projects().") + tasks = ( + session.scalars( + select(Task) + .where( + Task.project_id == project.id, + Task.status.in_(("to_do", "in_progress")), + ) + .order_by(Task.created_at.desc()) + .limit(100) + ) + .unique() + .all() + ) + data = _project_compact(project, session) + data["note"] = project.note # полная заметка, без обрезки списка + data["tasks"] = [_compact(t) for t in tasks] + return data + finally: + session.close() + + +def list_tags() -> list[dict[str, Any]]: + """Справочник тегов: id и имя — для параметра tag_ids.""" + session = get_session_factory()() + try: + return [ + {"id": t.id, "name": t.name} + for t in session.scalars(select(Tag).order_by(Tag.name)).all() + ] + finally: + session.close() + + # --- регистрация в MCP (сигнатуры — документация для агента) --- +mcp.tool()(list_projects) +mcp.tool()(get_project) mcp.tool()(create_task) mcp.tool()(update_task) mcp.tool()(complete_task) mcp.tool()(list_tasks) mcp.tool()(get_task) +mcp.tool()(list_tags) diff --git a/backend/app/services/detailing.py b/backend/app/services/detailing.py index 436b39d..65f681f 100644 --- a/backend/app/services/detailing.py +++ b/backend/app/services/detailing.py @@ -213,9 +213,7 @@ # только среди открытых проектов: в архивном (3.8.1) задача пропала бы # из рабочих видов сразу после утверждения project = db.scalars( - select(Project).where( - Project.name.ilike(project_name), Project.is_archived.is_(False) - ) + select(Project).where(Project.name.ilike(project_name), Project.is_archived.is_(False)) ).first() # совпадение с именем архивного — treated as not found: LLM предложит # проект заново или задача останется без проекта (пользователь поправит) diff --git a/backend/app/services/garden.py b/backend/app/services/garden.py index ca73776..9507b66 100644 --- a/backend/app/services/garden.py +++ b/backend/app/services/garden.py @@ -149,8 +149,7 @@ def species_catalog(level: int) -> list[dict[str, Any]]: """Каталог видов с пометкой «открыт на этом уровне» и ценой семени.""" return [ - {**s, "unlocked": level >= s["level"], "seed_cost": SEED_COSTS[s["key"]]} - for s in SPECIES + {**s, "unlocked": level >= s["level"], "seed_cost": SEED_COSTS[s["key"]]} for s in SPECIES ] @@ -178,14 +177,10 @@ def house_footprint(cols: int, rows: int) -> set[tuple[int, int]]: cx = cols // 2 - HOUSE_COLS // 2 cy = rows // 2 - HOUSE_ROWS // 2 - return { - (x, y) for x in range(cx, cx + HOUSE_COLS) for y in range(cy, cy + HOUSE_ROWS) - } + return {(x, y) for x in range(cx, cx + HOUSE_COLS) for y in range(cy, cy + HOUSE_ROWS)} -def spiral_position( - occupied: set[tuple[int, int]], cols: int, rows: int -) -> tuple[int, int]: +def spiral_position(occupied: set[tuple[int, int]], cols: int, rows: int) -> tuple[int, int]: """Следующая свободная ячейка по спирали вокруг домика в центре.""" cx, cy = cols // 2, rows // 2 radius = SPIRAL_START_RADIUS @@ -233,9 +228,7 @@ reached = level_from_xp(total_xp(db)) granted = set( db.scalars( - select(CoinEvent.level).where( - CoinEvent.source == "level", CoinEvent.level.is_not(None) - ) + select(CoinEvent.level).where(CoinEvent.source == "level", CoinEvent.level.is_not(None)) ).all() ) for level in range(2, reached + 1): @@ -272,9 +265,7 @@ for event in pending: # вид и уровень — на момент события, чтобы старые закрытия # получили вид из пула своего времени - species = species_for( - _level_at_event(db, event), event.task_id or 0, event.rarity - ) + species = species_for(_level_at_event(db, event), event.task_id or 0, event.rarity) x, y = spiral_position(occupied, size["cols"], size["rows"]) occupied.add((x, y)) db.add(GardenItem(kind="plant", ref_id=event.id, item_key=species, x=x, y=y)) @@ -287,5 +278,3 @@ def spend(db: Session, cost: int, item_key: str) -> None: db.add(CoinEvent(source="shop", amount=-cost, item_key=item_key)) - - diff --git a/backend/tests/test_detailing.py b/backend/tests/test_detailing.py index 7a7b0cd..ced1410 100644 --- a/backend/tests/test_detailing.py +++ b/backend/tests/test_detailing.py @@ -65,8 +65,9 @@ monkeypatch.setattr( services.detailing.DetailingService, "generate", - lambda self, prompt: '{"tags": ["быт", "новый тег"], "project": "Дом", ' - '"new_project": false, "priority": 99}', + lambda self, prompt: ( + '{"tags": ["быт", "новый тег"], "project": "Дом", "new_project": false, "priority": 99}' + ), ) proposal = service.propose("задача", "", ["быт"], [{"name": "Дом", "description": ""}]) assert proposal is not None @@ -82,8 +83,10 @@ monkeypatch.setattr( services.detailing.DetailingService, "generate", - lambda self, prompt: '{"tags": [" ", "null", "быт"], "project": "NULL", ' - '"new_project": true, "priority": null}', + lambda self, prompt: ( + '{"tags": [" ", "null", "быт"], "project": "NULL", ' + '"new_project": true, "priority": null}' + ), ) proposal = service.propose("задача", "", ["быт"], [{"name": "Дом", "description": ""}]) assert proposal is not None @@ -128,8 +131,10 @@ monkeypatch.setattr( services.detailing.DetailingService, "generate", - lambda self, prompt: '{"title": null, "description": null, "tags": [], ' - '"project": "Новый проект", "new_project": false, "priority": null}', + lambda self, prompt: ( + '{"title": null, "description": null, "tags": [], ' + '"project": "Новый проект", "new_project": false, "priority": null}' + ), ) proposal = service.propose("задача", "", [], [{"name": "Дом", "description": ""}]) assert proposal is not None @@ -145,9 +150,7 @@ task_id = resp.json()["id"] set_proposal(task_id, proposal) - approved = client.post( - f"/api/tasks/{task_id}/approve", json={"apply_proposal": True} - ).json() + 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"] == "Дом" @@ -160,9 +163,7 @@ 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() + 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()] @@ -196,9 +197,7 @@ "priority": None, } set_proposal(task["id"], proposal) - approved = client.post( - f"/api/tasks/{task['id']}/approve", json={"apply_proposal": True} - ).json() + approved = client.post(f"/api/tasks/{task['id']}/approve", json={"apply_proposal": True}).json() assert approved["title"] == "Сократить заголовок" assert approved["description"] == "1. Шаг раз" diff --git a/backend/tests/test_garden.py b/backend/tests/test_garden.py index 49d098e..294b991 100644 --- a/backend/tests/test_garden.py +++ b/backend/tests/test_garden.py @@ -133,21 +133,13 @@ # Из инвентаря на карту — перетаскиванием (PATCH с координатами) resp = client.patch(f"/api/garden/items/{item['id']}", json={"x": 3, "y": 4}) assert resp.status_code == 200 - placed = next( - i - for i in client.get("/api/garden").json()["items"] - if i["id"] == item["id"] - ) + placed = next(i for i in client.get("/api/garden").json()["items"] if i["id"] == item["id"]) assert (placed["x"], placed["y"]) == (3, 4) # Обратно в инвентарь (x/y None — только декорациям) resp = client.patch(f"/api/garden/items/{item['id']}", json={"x": None, "y": None}) assert resp.status_code == 200 - stored = next( - i - for i in client.get("/api/garden").json()["items"] - if i["id"] == item["id"] - ) + stored = next(i for i in client.get("/api/garden").json()["items"] if i["id"] == item["id"]) assert stored["x"] is None and stored["y"] is None # Растение в инвентарь убрать нельзя diff --git a/backend/tests/test_mcp.py b/backend/tests/test_mcp.py index d2e0d4a..cc1ccb3 100644 --- a/backend/tests/test_mcp.py +++ b/backend/tests/test_mcp.py @@ -149,3 +149,84 @@ assert "spawned_next_id" not in again xp, _coins = _db_events(tid) assert len(xp) == 1 # XP — один раз на задачу + + +# --- эргономика для агентов: проектный контекст (ТЗ 3.10) --- + + +def _make_project(name: str, note: str = "") -> int: + # через mcp_server.get_session_factory — monkeypatch в conftest подменяет + # её на тестовую SQLite; прямой импорт из app.db ушёл бы в dev-Postgres + session = mcp_server.get_session_factory()() + from app.models import Project + + try: + project = Project(name=name, note=note) + session.add(project) + session.commit() + return project.id + finally: + session.close() + + +def test_mcp_list_and_get_projects(client: TestClient) -> None: + pid = _make_project("gntodo-разработка", note="Личный менеджер задач") + mcp_server.create_task("Починить спавн", project_id=pid) + mcp_server.create_task("Свободная задача") + + projects = mcp_server.list_projects() + project = next(p for p in projects if p["id"] == pid) + assert project["name"] == "gntodo-разработка" + assert project["open_tasks"] == 1 + assert "менеджер задач" in project["note"] + # задача без проекта не приписана этому проекту + assert all(p["open_tasks"] == 1 for p in projects if p["id"] == pid) + + full = mcp_server.get_project(name="gntodo-разработка") + assert full["id"] == pid + assert full["note"] == "Личный менеджер задач" + assert [t["title"] for t in full["tasks"]] == ["Починить спавн"] + assert full["tasks"][0]["project"] == "gntodo-разработка" + + +def test_mcp_get_project_requires_id_or_name(client: TestClient) -> None: + with pytest.raises(ValueError, match="list_projects"): + mcp_server.get_project() + + +def test_mcp_task_by_project_name_and_hint(client: TestClient) -> None: + _make_project("Дом и быт") + created = mcp_server.create_task("Повесить полку", project_name=" дом и быт ") + assert created["project"] == "Дом и быт" + assert "hint" not in created + + found = mcp_server.list_tasks(project_name="Дом и быт") + assert [t["id"] for t in found] == [created["id"]] + + # перенос задачи в другой проект по имени + moved = mcp_server.update_task(created["id"], project_name="дом и БЫТ") + assert moved["project"] == "Дом и быт" + + free = mcp_server.create_task("Без проекта") + assert free["project"] is None + assert "list_projects" in free["hint"] + + +def test_mcp_unknown_project_hints_next_step(client: TestClient) -> None: + with pytest.raises(ValueError, match="list_projects"): + mcp_server.create_task("x", project_name="несуществующий") + tid = mcp_server.create_task("x")["id"] + with pytest.raises(ValueError, match="list_projects"): + mcp_server.update_task(tid, project_id=999) + + +def test_mcp_unknown_task_hints_search(client: TestClient) -> None: + with pytest.raises(ValueError, match="list_tasks"): + mcp_server.get_task(999) + with pytest.raises(ValueError, match="list_tasks"): + mcp_server.update_task(999, priority=5) + + +def test_mcp_list_tags(client: TestClient) -> None: + tags = mcp_server.list_tags() + assert tags == [] # теги создаёт UI/детализация; для агента важна пустая выдача diff --git a/backend/tests/test_tasks_api.py b/backend/tests/test_tasks_api.py index b51d051..bf36ca0 100644 --- a/backend/tests/test_tasks_api.py +++ b/backend/tests/test_tasks_api.py @@ -131,8 +131,7 @@ assert any(t["id"] == standalone["id"] for t in tasks) # но на странице проекта (фильтр по project_id) история видна assert any( - t["id"] == task_id - for t in client.get("/api/tasks", params={"project_id": pid}).json() + t["id"] == task_id for t in client.get("/api/tasks", params={"project_id": pid}).json() ) # и в выдаче задачи архивные проекты не участвуют options = client.post("/api/tasks/suggest", json={"available_minutes": 600}).json() diff --git a/backend/tests/test_tree.py b/backend/tests/test_tree.py index ba7a860..3ff3cf9 100644 --- a/backend/tests/test_tree.py +++ b/backend/tests/test_tree.py @@ -42,9 +42,7 @@ def test_detach_from_parent(client: TestClient) -> None: parent = client.post("/api/tasks", json={"title": "P"}).json()["id"] - child_id = client.post( - "/api/tasks", json={"title": "C", "parent_task_id": parent} - ).json()["id"] + child_id = client.post("/api/tasks", json={"title": "C", "parent_task_id": parent}).json()["id"] detached = client.patch(f"/api/tasks/{child_id}", json={"parent_task_id": None}).json() assert detached["parent_task_id"] is None diff --git a/backend/tests/test_xp.py b/backend/tests/test_xp.py index 8c70b3b..ed7de84 100644 --- a/backend/tests/test_xp.py +++ b/backend/tests/test_xp.py @@ -36,9 +36,7 @@ def test_xp_weighted_by_priority_and_estimate(client: TestClient) -> None: task_id = _create_task(client) - client.patch( - f"/api/tasks/{task_id}", json={"priority": 9, "estimated_minutes": 480} - ) + client.patch(f"/api/tasks/{task_id}", json={"priority": 9, "estimated_minutes": 480}) resp = client.patch(f"/api/tasks/{task_id}", json={"status": "done"}) # 10 + urgent 15 + >4ч 15 = 40 assert resp.headers.get("X-Earned-XP") == "40" @@ -96,9 +94,7 @@ # редкость случайна — фиксируем roll, чтобы тест не мигал (rare при xp=10 — 5%) monkeypatch.setattr("app.services.xp.random.random", lambda: 0.99) task_id = _create_task(client) - resp = client.patch( - f"/api/tasks/{task_id}", json={"status": "done", "earned_via": "options"} - ) + resp = client.patch(f"/api/tasks/{task_id}", json={"status": "done", "earned_via": "options"}) assert resp.status_code == 200 assert resp.headers.get("X-Earned-XP") == "10" assert resp.headers.get("X-Plant-Rarity") == "common" diff --git a/docs/TZ.md b/docs/TZ.md index d99dfcb..b647f3b 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.29 | +| Версия ТЗ | 0.30 | | Дата | 2026-09-21 | | Статус | На обсуждении | @@ -172,8 +172,21 @@ - Инструменты (первичный набор, расширяется): - `create_task` — добавить задачу (достаточно текста; метаданные опциональны); - `update_task`, `complete_task`; - - `list_tasks` / `search_tasks` — фильтры по проекту, тегу, статусу; - - `get_task` — полное описание с вложениями. + - `list_tasks` — фильтры по проекту (id или имени), тегу, статусу, подстроке; + - `get_task` — полное описание с вложениями; + - `list_projects` / `get_project` — обнаружение проектов и **восстановление + контекста проекта** (заметка проекта + его открытые задачи); + - `list_tags` — справочник тегов (id для параметра `tag_ids`). +- Эргономика для агентов (в т.ч. небольших моделей): + - серверные `instructions`: порядок работы, правило привязки задач к проекту, + восстановление контекста через `get_project` после перерыва, запрет + придумывать id, автоматика наград/спавна при закрытии; + - адресация проекта по имени: параметры `project_name` (регистронезависимо) + в `create_task`/`update_task`/`list_tasks` наряду с `project_id`; + - описания параметров в схеме каждого инструмента (enum значений, форматы дат, + диапазоны приоритета); + - ошибки подсказывают следующий шаг («call list_projects()», «найдите id через + list_tasks(query=...)»); ответ `create_task` без проекта содержит подсказку. - Аутентификация агентов — через SSO-механизм (токен, выданный централизованной системой; детали после предоставления данных SSO). - Агент действует от имени пользователя; отдельной мультитенантности нет. - Закрытие задачи агентом (`update_task`/`complete_task`) идёт по тому же общему