From 0b1604e01816d041da23e894f5dcfb93a378ff94 Mon Sep 17 00:00:00 2001 From: Bossiara13 <236771060+DmitryBykov-ISPO@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:11:45 +0300 Subject: [PATCH] feat(tools): llm-suggest via groq with JSON response format --- tools/command_builder/llm_suggest.py | 153 +++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 tools/command_builder/llm_suggest.py diff --git a/tools/command_builder/llm_suggest.py b/tools/command_builder/llm_suggest.py new file mode 100644 index 0000000..b5d69ec --- /dev/null +++ b/tools/command_builder/llm_suggest.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +import config +from tools.command_builder.schema import ( + SchemaError, + VALID_SOUND_NAMES, + VALID_SYSTEM_OPS, + action_from_dict, + slugify, +) + + +_ACTION_SCHEMA_DOC = """ +Допустимые типы действий (action.type): + +- exe — запуск .exe из custom-commands/ + {"type":"exe","file":"","wait":,"delay_ms":} +- play_sound — встроенный звук + {"type":"play_sound","name":""} +- system — системная операция + {"type":"system","op":""} +- sleep — пауза в миллисекундах (только внутри multi) + {"type":"sleep","ms":} +- shell — PowerShell/cmd строка + {"type":"shell","cmd":"","wait":} +- url — открыть URL в системном браузере + {"type":"url","href":""} +- keys — нажатие клавиш ИЛИ ввод текста (только одно из двух) + {"type":"keys","sequence":"ctrl+shift+m"} + {"type":"keys","text":"печатаемая строка"} +- multi — последовательность шагов + {"type":"multi","steps":[, ...]} +""".strip() + + +def _build_system_prompt(exe_names: list[str]) -> str: + schema = ( + _ACTION_SCHEMA_DOC + .replace("%SOUNDS%", ", ".join(VALID_SOUND_NAMES)) + .replace("%SYS_OPS%", ", ".join(VALID_SYSTEM_OPS)) + ) + if exe_names: + exe_lines = "\n".join(f" - {n}.exe" for n in exe_names) + else: + exe_lines = " (папка custom-commands/ пуста)" + return ( + "Ты помогаешь собрать команду для голосового ассистента Jarvis. " + "Пользователь описывает желаемое поведение по-русски. " + "Верни ОДИН JSON-объект ровно с такими полями:\n" + "{\n" + ' "cmd_id": "<латиница, snake_case, опционально — иначе будет сгенерирован>",\n' + ' "phrases": ["<голосовая фраза 1>", "..."],\n' + ' "action": <один action из схемы ниже>,\n' + ' "confirm_sound": "<опционально, имя звука для подтверждения>"\n' + "}\n\n" + f"{schema}\n\n" + "Доступные .exe файлы (используй ровно эти имена в action.file):\n" + f"{exe_lines}\n\n" + "Правила:\n" + "- Только валидный JSON, без пояснений и markdown.\n" + "- phrases — 1-3 коротких русских фразы, без слова-активатора «джарвис».\n" + "- Если нужно несколько шагов — используй multi.\n" + "- sleep валиден только внутри multi.steps." + ) + + +def _get_client(): + from openai import OpenAI + if not config.GROQ_TOKEN: + raise RuntimeError("GROQ_TOKEN не задан в dev.env") + return OpenAI(api_key=config.GROQ_TOKEN, base_url=config.GROQ_BASE_URL) + + +def _list_exes() -> list[str]: + custom = REPO_ROOT / "custom-commands" + if not custom.exists(): + return [] + return sorted(p.stem for p in custom.iterdir() if p.suffix.lower() == ".exe") + + +def suggest(prompt: str) -> dict: + user_prompt = (prompt or "").strip() + if not user_prompt: + return {"ok": False, "error": "Пустой запрос"} + + try: + client = _get_client() + except Exception as ex: + return {"ok": False, "error": str(ex)} + + system_prompt = _build_system_prompt(_list_exes()) + + try: + response = client.chat.completions.create( + model=config.GROQ_MODEL, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + response_format={"type": "json_object"}, + temperature=0.2, + max_tokens=800, + ) + except Exception as ex: + return {"ok": False, "error": f"Ошибка запроса к Groq: {ex}"} + + raw = response.choices[0].message.content or "" + try: + parsed = json.loads(raw) + except json.JSONDecodeError as ex: + return {"ok": False, "error": f"Модель вернула не-JSON: {ex}", "raw": raw} + + phrases = parsed.get("phrases") + if not isinstance(phrases, list) or not phrases: + return {"ok": False, "error": "В ответе нет phrases", "raw": parsed} + phrases = [p for p in phrases if isinstance(p, str) and p.strip()] + if not phrases: + return {"ok": False, "error": "phrases пуст после фильтрации", "raw": parsed} + + action_raw = parsed.get("action") + if not isinstance(action_raw, dict): + return {"ok": False, "error": "В ответе нет action", "raw": parsed} + + try: + action_obj = action_from_dict(action_raw) + except SchemaError as ex: + return {"ok": False, "error": f"Action не прошёл валидацию: {ex}", "raw": parsed} + + cmd_id = parsed.get("cmd_id") + if not isinstance(cmd_id, str) or not cmd_id.strip(): + cmd_id = slugify(phrases[0]) + + confirm = parsed.get("confirm_sound") + if confirm is not None and confirm not in VALID_SOUND_NAMES: + confirm = None + + suggestion = { + "cmd_id": cmd_id, + "phrases": phrases, + "action": action_obj.to_dict(), + } + if confirm: + suggestion["confirm_sound"] = confirm + + return {"ok": True, "suggestion": suggestion}