From fb7c4f34b7f080d9bce7af58f1483f617fe8a99d Mon Sep 17 00:00:00 2001 From: grigo Date: Tue, 9 Jun 2026 13:31:01 +0300 Subject: [PATCH] Fixed Taiga integration --- backend/app/character/card.py | 8 ++-- backend/app/chat/notices.py | 37 ++++++++++++++- backend/app/projects/context.py | 84 +++++++++++++++++++++------------ backend/app/projects/service.py | 57 ++++++++++++++++++++++ backend/app/tools/registry.py | 31 +++++++++++- 5 files changed, 181 insertions(+), 36 deletions(-) diff --git a/backend/app/character/card.py b/backend/app/character/card.py index ee3d81c..48e4acf 100644 --- a/backend/app/character/card.py +++ b/backend/app/character/card.py @@ -8,9 +8,11 @@ TOOLS_INSTRUCTIONS = """ - После вызова инструмента кратко объясни результат пользователю по-человечески. - Помидоро: get_pomodoro_status, start_pomodoro, start_short_break, start_long_break, stop_pomodoro, skip_pomodoro_phase, reset_pomodoro_cycle, get_pomodoro_history. -- Задачи: sync_taiga_projects, list_taiga_projects, create_work_item, list_work_items. -- create_work_item — при «заведи баг/фичу», «добавь в таигу»; передай полный текст пользователя. -- Список проектов и открытых задач уже в контексте — не выдумывай, при необходимости уточни tool-вызовом. +- Taiga: sync_taiga_projects, list_taiga_projects, list_taiga_tasks, create_work_item, list_work_items. +- «Какие задачи» / «покажи задачи проекта» → list_taiga_tasks (живые данные Taiga). +- list_work_items — ТОЛЬКО задачи, созданные через create_work_item (локальная БД). +- create_work_item — при «заведи баг/фичу»; передай полный текст и project_slug. +- Снимок проектов/задач есть в контексте, но для актуализации вызывай tools. Никогда не пиши «ожидаю ответа от системы». """.strip() DEFAULT_CARD: dict[str, Any] = { diff --git a/backend/app/chat/notices.py b/backend/app/chat/notices.py index 4bc9d71..4fac8d9 100644 --- a/backend/app/chat/notices.py +++ b/backend/app/chat/notices.py @@ -73,6 +73,9 @@ def format_pomodoro_notice(tool_name: str, raw_result: str) -> str | None: if tool_name == "list_work_items": return _format_work_items_list_notice(data) + if tool_name == "list_taiga_tasks": + return _format_taiga_tasks_notice(data) + if tool_name == "sync_taiga_projects": return f"📋 Синхронизировано проектов Taiga: **{len(data)}**" @@ -114,8 +117,8 @@ def _format_work_item_notice(data: dict[str, Any]) -> str | None: def _format_work_items_list_notice(data: Any) -> str | None: if not isinstance(data, list) or not data: - return "📋 Work items не найдены." - lines = ["📋 **Work items:**"] + return "📋 Локальных work items (созданных ассистентом) нет." + lines = ["📋 **Work items ассистента:**"] for item in data[:15]: lines.append( f"- [{item.get('status')}] #{item.get('taiga_ref')} {item.get('title')} " @@ -124,6 +127,36 @@ def _format_work_items_list_notice(data: Any) -> str | None: return "\n".join(lines) +def _format_taiga_tasks_notice(data: Any) -> str | None: + if not isinstance(data, dict): + return None + if data.get("error"): + return f"📋 {data['error']}" + + blocks = data.get("projects") or [] + total_stories = data.get("total_stories", 0) + total_tasks = data.get("total_tasks", 0) + + if not blocks or (total_stories == 0 and total_tasks == 0): + slug = blocks[0].get("slug") if len(blocks) == 1 else None + if slug: + return f"📋 В `{slug}` нет открытых user stories и tasks в Taiga." + return "📋 Открытых задач в Taiga не найдено." + + lines = [f"📋 **Открытые задачи Taiga** (stories: {total_stories}, tasks: {total_tasks}):"] + for block in blocks: + stories = block.get("stories") or [] + tasks = block.get("tasks") or [] + if not stories and not tasks: + continue + lines.append(f"**{block.get('name')}** (`{block.get('slug')}`):") + for s in stories: + lines.append(f"- story #{s.get('ref')} {s.get('subject')}") + for t in tasks: + lines.append(f"- task #{t.get('ref')} {t.get('subject')}") + return "\n".join(lines) + + def _format_status_notice(data: dict[str, Any]) -> str: status = data.get("status", "idle") phase = data.get("phase", PHASE_WORK) diff --git a/backend/app/projects/context.py b/backend/app/projects/context.py index e08cb77..283a4cc 100644 --- a/backend/app/projects/context.py +++ b/backend/app/projects/context.py @@ -6,8 +6,8 @@ from app.config import get_settings from app.integrations.taiga import TaigaClient from app.projects.service import ProjectService -MAX_PROJECTS_IN_CONTEXT = 8 -MAX_OPEN_PER_PROJECT = 5 +MAX_PROJECTS_IN_CONTEXT = 20 +MAX_OPEN_PER_PROJECT = 8 def get_projects_snapshot(db: Session) -> dict[str, Any]: @@ -21,11 +21,18 @@ def get_projects_snapshot(db: Session) -> dict[str, Any]: if not projects: try: projects = service.sync_taiga_projects() - except Exception: - projects = [] + except Exception as exc: + return { + "configured": True, + "projects": [], + "open_items": [], + "taiga_open": [], + "error": str(exc), + } open_items = service.list_work_items(limit=15, status="open") taiga_open: list[dict[str, Any]] = [] + fetch_error: str | None = None try: client = TaigaClient() @@ -33,8 +40,7 @@ def get_projects_snapshot(db: Session) -> dict[str, Any]: stories = client.list_open_userstories( proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT ) - if not stories: - continue + tasks = client.list_open_tasks(proj["taiga_id"], limit=MAX_OPEN_PER_PROJECT) taiga_open.append( { "slug": proj["slug"], @@ -46,16 +52,24 @@ def get_projects_snapshot(db: Session) -> dict[str, Any]: } for s in stories ], + "tasks": [ + { + "ref": t.get("ref"), + "subject": t.get("subject", "")[:120], + } + for t in tasks + ], } ) - except Exception: - pass + except Exception as exc: + fetch_error = str(exc) return { "configured": True, "projects": projects, "open_items": open_items, "taiga_open": taiga_open, + "error": fetch_error, } @@ -63,13 +77,16 @@ def format_projects_context(snapshot: dict[str, Any]) -> str: if not snapshot.get("configured"): return "[Taiga/Gitea]\nНе настроено (нет TAIGA_USERNAME/PASSWORD в .env)." - lines = ["[Проекты и задачи — актуальный снимок для контекста]"] + lines = ["[Проекты и задачи — снимок на начало ответа]"] + + if snapshot.get("error"): + lines.append(f"⚠ Ошибка загрузки задач из Taiga: {snapshot['error']}") projects = snapshot.get("projects") or [] if not projects: - lines.append("Проекты Taiga: кэш пуст. Попроси sync_taiga_projects или проверь подключение.") + lines.append("Проекты Taiga: кэш пуст. Вызови sync_taiga_projects.") else: - lines.append("Проекты Taiga:") + lines.append(f"Проекты Taiga ({len(projects)}):") for p in projects[:MAX_PROJECTS_IN_CONTEXT]: gitea = ( f"{p.get('gitea_owner')}/{p.get('gitea_repo')}" @@ -78,32 +95,39 @@ def format_projects_context(snapshot: dict[str, Any]) -> str: ) lines.append(f"- `{p.get('slug')}`: {p.get('name')} · {gitea}") - open_items = snapshot.get("open_items") or [] - if open_items: - lines.append("") - lines.append("Локальные work items (созданные ассистентом, открытые):") - for item in open_items[:10]: - gitea_part = f", gitea #{item.get('gitea_issue')}" if item.get("gitea_issue") else "" - lines.append( - f"- taiga #{item.get('taiga_ref')} {item.get('title')} " - f"({item.get('taiga_slug')}{gitea_part})" - ) - taiga_open = snapshot.get("taiga_open") or [] if taiga_open: lines.append("") - lines.append("Открытые user stories в Taiga:") + lines.append("Открытые задачи в Taiga (live):") for block in taiga_open: - lines.append(f" [{block.get('slug')}]") - for story in block.get("stories", []): - lines.append(f" - #{story.get('ref')} {story.get('subject')}") - elif projects: + stories = block.get("stories") or [] + tasks = block.get("tasks") or [] + if not stories and not tasks: + lines.append(f" `{block.get('slug')}`: нет открытых") + continue + lines.append(f" `{block.get('slug')}`:") + for story in stories: + lines.append(f" story #{story.get('ref')} {story.get('subject')}") + for task in tasks: + lines.append(f" task #{task.get('ref')} {task.get('subject')}") + + open_items = snapshot.get("open_items") or [] + if open_items: lines.append("") - lines.append("Открытые user stories в Taiga: нет или не удалось загрузить.") + lines.append("Work items созданные ассистентом (локальная БД):") + for item in open_items[:10]: + gitea_part = f", gitea #{item.get('gitea_issue')}" if item.get("gitea_issue") else "" + lines.append( + f"- #{item.get('taiga_ref')} {item.get('title')} " + f"({item.get('taiga_slug')}{gitea_part})" + ) lines.append("") lines.append( - "Для создания фичи/бага из вольного текста используй create_work_item. " - "Не выдумывай номера задач — опирайся на список выше." + "Правила: " + "«какие задачи» → list_taiga_tasks (Taiga API), НЕ list_work_items. " + "list_work_items — только созданные через ассистента. " + "Не пиши «ожидаю систему» — сразу вызывай tool или отвечай из снимка выше. " + "create_work_item — для новых фич/багов из вольного текста." ) return "\n".join(lines) diff --git a/backend/app/projects/service.py b/backend/app/projects/service.py index 9e37775..b5614da 100644 --- a/backend/app/projects/service.py +++ b/backend/app/projects/service.py @@ -366,6 +366,63 @@ class ProjectService: item.status = "closed" item.closed_at = datetime.now(timezone.utc) + def list_taiga_open_tasks( + self, + project_slug: str | None = None, + limit: int = 20, + ) -> dict[str, Any]: + if not self.settings.taiga_configured: + raise ValueError("Taiga не настроена") + + projects = self.list_projects() + if not projects: + projects = self.sync_taiga_projects() + + if project_slug: + projects = [p for p in projects if p["slug"] == project_slug] + if not projects: + raise ValueError( + f"Проект '{project_slug}' не найден. Вызови sync_taiga_projects." + ) + + client = TaigaClient() + blocks: list[dict[str, Any]] = [] + + for proj in projects: + stories = client.list_open_userstories(proj["taiga_id"], limit=limit) + tasks = client.list_open_tasks(proj["taiga_id"], limit=limit) + blocks.append( + { + "slug": proj["slug"], + "name": proj["name"], + "taiga_id": proj["taiga_id"], + "stories": [ + { + "ref": s.get("ref"), + "subject": s.get("subject", ""), + "url": client.story_url(proj["taiga_id"], s.get("ref", 0)), + } + for s in stories + ], + "tasks": [ + { + "ref": t.get("ref"), + "subject": t.get("subject", ""), + "user_story": t.get("user_story"), + } + for t in tasks + ], + } + ) + + total_stories = sum(len(b["stories"]) for b in blocks) + total_tasks = sum(len(b["tasks"]) for b in blocks) + return { + "projects": blocks, + "total_stories": total_stories, + "total_tasks": total_tasks, + } + def list_work_items(self, limit: int = 30, status: str | None = None) -> list[dict[str, Any]]: stmt = select(WorkItem).order_by(WorkItem.created_at.desc()).limit(limit) if status: diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index 71ad201..417e72f 100644 --- a/backend/app/tools/registry.py +++ b/backend/app/tools/registry.py @@ -131,6 +131,27 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ "parameters": {"type": "object", "properties": {}, "required": []}, }, }, + { + "type": "function", + "function": { + "name": "list_taiga_tasks", + "description": ( + "ОБЯЗАТЕЛЬНО при вопросах «какие задачи», «покажи задачи проекта», «что открыто в Taiga». " + "Живые user stories и tasks из Taiga API. НЕ путать с list_work_items." + ), + "parameters": { + "type": "object", + "properties": { + "project_slug": { + "type": "string", + "description": "Slug проекта, например home_assistant. Пусто = все проекты.", + }, + "limit": {"type": "integer", "description": "Макс. на проект, по умолчанию 20"}, + }, + "required": [], + }, + }, + }, { "type": "function", "function": { @@ -156,7 +177,10 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ "type": "function", "function": { "name": "list_work_items", - "description": "Список созданных work items (Taiga + Gitea связки).", + "description": ( + "Только задачи, созданные ЭТИМ ассистентом через create_work_item (локальная БД). " + "НЕ использовать для общего вопроса «какие задачи в Taiga» — для того list_taiga_tasks." + ), "parameters": { "type": "object", "properties": { @@ -201,6 +225,11 @@ async def execute_tool(db: Session, name: str, arguments: dict[str, Any]) -> str result = projects.sync_taiga_projects() elif name == "list_taiga_projects": result = projects.list_projects() + elif name == "list_taiga_tasks": + result = projects.list_taiga_open_tasks( + project_slug=arguments.get("project_slug"), + limit=arguments.get("limit", 20), + ) elif name == "create_work_item": result = await projects.create_work_item( arguments.get("text", ""),