fix: SEARXNG_URL no longer includes /search suffix (provider adds it), sync all files

This commit is contained in:
nik committed 2026-06-29 22:46:24 +05:00
1 parent e5e9b5c664
commit d4731bee20
11 files changed
+2891

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.pyc
*.pyo
.env
.venv/
+58
View File
@@ -0,0 +1,58 @@
"""
Hermes Plugin: research-agent
Добавляет 3 инструмента веб-исследования:
- deep_research — многошаговое глубокое исследование (Perplexity Deep Research)
- perplexity_search — быстрый одношаговый поиск с синтезом (Perplexity)
- youtube_analyze — анализ YouTube-видео с таймкодами
Все инструменты используют SearXNG для поиска и локальный LLM для синтеза.
Скрипты в scripts/ самодостаточны — плагин не требует внешних файлов.
Установка: hermes plugins install n1kt4r/hermes-research-plugin
"""
from .schemas import DEEP_RESEARCH_SCHEMA, PERPLEXITY_SEARCH_SCHEMA, YOUTUBE_ANALYZE_SCHEMA
from .tools import handle_deep_research, handle_perplexity_search, handle_youtube_analyze
def register(ctx):
"""Регистрация инструментов в Hermes. Вызывается PluginManager при старте."""
# ─── deep_research ────────────────────────────────────────────────────
ctx.register_tool(
name="deep_research",
toolset="research_agent",
schema=DEEP_RESEARCH_SCHEMA,
handler=handle_deep_research,
description=(
"Deep multi-step web research with 2 search rounds, gap analysis, "
"and structured report with inline citations [N] and source URLs. "
"Use for in-depth analytical reports. Takes 2-5 min."
),
)
# ─── perplexity_search ────────────────────────────────────────────────
ctx.register_tool(
name="perplexity_search",
toolset="research_agent",
schema=PERPLEXITY_SEARCH_SCHEMA,
handler=handle_perplexity_search,
description=(
"Quick one-shot Perplexity-style search — reads top articles "
"and synthesizes a structured report with citations [N]. "
"Use for factual lookups and quick overviews. Takes 30-90 sec."
),
)
# ─── youtube_analyze ──────────────────────────────────────────────────
ctx.register_tool(
name="youtube_analyze",
toolset="research_agent",
schema=YOUTUBE_ANALYZE_SCHEMA,
handler=handle_youtube_analyze,
description=(
"Analyze YouTube videos — extract key points, timestamps [MM:SS], "
"and quotes. By URL or search query. Takes 1-3 min."
),
)
+6
View File
@@ -0,0 +1,6 @@
name: research-agent
version: "1.0.0"
description: Perplexity-style web research tools — deep multi-step research, quick search, and YouTube analysis. Uses SearXNG + LLM synthesis. Ships deep_research, perplexity_search, youtube_analyze as native tool calls.
requires_env:
- SEARXNG_URL
- LLM_MODEL
View File
Whitespace-only changes.
+123
View File
@@ -0,0 +1,123 @@
"""
JSON-схемы для инструментов research-agent плагина.
Схемы = то, что LLM видит как описание инструмента.
Строго по спецификации OpenAI function calling.
"""
DEEP_RESEARCH_SCHEMA = {
"type": "function",
"function": {
"name": "deep_research",
"description": (
"Deep multi-step research on a topic — Perplexity Deep Research style. "
"Searches the web in 2 rounds, reads full article texts, identifies "
"knowledge gaps, follows up, and synthesizes a structured report with "
"inline citations [N] and full source URLs. "
"Use this when the user needs an in-depth analytical report on a topic, "
"market research, competitive analysis, or comprehensive literature review. "
"Takes 2-5 minutes. Returns a markdown report with Overview, Trends, Details, "
"and Sources sections."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The research topic or question. Be specific — e.g. 'AI coding agents market 2026' not just 'AI agents'.",
},
"lang": {
"type": "string",
"enum": ["ru", "en"],
"default": "ru",
"description": "Language for search queries and report: 'ru' for Russian, 'en' for English.",
},
},
"required": ["query"],
},
},
}
PERPLEXITY_SEARCH_SCHEMA = {
"type": "function",
"function": {
"name": "perplexity_search",
"description": (
"Quick one-shot Perplexity-style search — searches the web, reads top articles, "
"and synthesizes a structured report with inline citations [N] and source URLs. "
"Use this for factual lookups, quick overviews, trend checks, or when the user "
"asks 'what is X', 'how does Y work', 'latest news about Z'. "
"Takes 30-90 seconds. Returns a markdown report with Overview, Trends, and Sources."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query. Be specific and keyword-rich.",
},
"category": {
"type": "string",
"enum": ["general", "news", "it", "science"],
"default": "general",
"description": "SearXNG search category: 'general' (default), 'news' for current events, 'it' for tech, 'science' for academic.",
},
"results": {
"type": "integer",
"minimum": 1,
"maximum": 5,
"default": 3,
"description": "Number of articles to fetch and read in full (1-5, default 3).",
},
},
"required": ["query"],
},
},
}
YOUTUBE_ANALYZE_SCHEMA = {
"type": "function",
"function": {
"name": "youtube_analyze",
"description": (
"Analyze YouTube videos — extract key points, timestamps, and quotes. "
"Two modes: (1) by URL — analyze a specific video; (2) by search — find "
"and analyze top video(s) on a topic. Can compare multiple videos. "
"Uses subtitles when available, falls back to Whisper transcription. "
"Takes 1-3 minutes. Returns a markdown report with timestamps [MM:SS], "
"key points, and quotes."
),
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "YouTube video URL to analyze. Mutually exclusive with 'search'.",
},
"search": {
"type": "string",
"description": "Search query to find and analyze videos on YouTube. Mutually exclusive with 'url'.",
},
"lang": {
"type": "string",
"enum": ["ru", "en"],
"default": "ru",
"description": "Preferred subtitle language: 'ru' for Russian, 'en' for English.",
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 5,
"default": 1,
"description": "Number of videos to analyze when using search mode (1-5, default 1).",
},
"compare": {
"type": "boolean",
"default": False,
"description": "If true and search mode, compare multiple videos side-by-side (requires limit >= 2).",
},
},
"required": [],
},
},
}
File diff suppressed because it is too large. Load diff
Regular → Executable
+424
View File
@@ -0,0 +1,424 @@
#!/usr/bin/env python3
"""
Perplexity-style search agent для Hermes (async).
Использование:
python3 perplexity_search.py "запрос"
python3 perplexity_search.py "запрос" --category news --results 5
python3 perplexity_search.py "запрос" --raw # только JSON
Опции:
--category Категория SearXNG: general (default), news, it, science
--results Сколько результатов fetch'ить (default: 3, макс: 5)
--raw Только сбор данных, JSON на stdout (без LLM-синтеза)
--model Имя LLM модели (env: LLM_MODEL)
--temperature Temperature (env: LLM_TEMPERATURE)
"""
import argparse
import asyncio
import json
import os
import random
import re
import subprocess
import sys
import time
import urllib.parse
from collections import defaultdict
from datetime import datetime
import aiohttp
from bs4 import BeautifulSoup
# ─── Конфигурация ─────────────────────────────────────────────────────────────
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://localhost:8888")
LLM_URL = os.environ.get("LLM_URL", "http://192.168.1.152:8080/v1/chat/completions")
LLM_MODEL = os.environ.get("LLM_MODEL", "Qwen3.6-35B-A3B-UD-IQ4_NL.gguf")
LLM_TEMPERATURE = float(os.environ.get("LLM_TEMPERATURE", "0.3"))
MAX_ARTICLE_CHARS = 5000
LLM_TIMEOUT = 120
# ─── Anti-blocking: User-Agent ротация ────────────────────────────────────────
USER_AGENTS = [
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15",
"Mozilla/5.0 (X11; Linux x86_64; rv:133.0) Gecko/20100101 Firefox/133.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0",
"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
]
def random_ua():
return random.choice(USER_AGENTS)
def build_headers(referer=None):
h = {
"User-Agent": random_ua(),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
"Accept-Encoding": "gzip, deflate",
"DNT": "1",
"Connection": "keep-alive",
}
if referer:
h["Referer"] = referer
return h
# ─── Anti-blocking: Domain Rate Limiter ────────────────────────────────────────
class DomainRateLimiter:
def __init__(self, min_interval=1.5, jitter=0.5):
self._last_request = defaultdict(float)
self._min_interval = min_interval
self._jitter = jitter
async def acquire(self, domain):
elapsed = time.monotonic() - self._last_request[domain]
base_wait = max(0, self._min_interval - elapsed)
jittered = base_wait + random.uniform(-self._jitter, self._jitter)
wait = max(0, jittered)
if wait > 0:
await asyncio.sleep(wait)
self._last_request[domain] = time.monotonic()
_limiter = DomainRateLimiter(min_interval=1.5, jitter=0.5)
# ─── Helpers ──────────────────────────────────────────────────────────────────
def log(msg):
print(f"[perplexity] {msg}", file=sys.stderr)
def _extract_text(html, max_chars=MAX_ARTICLE_CHARS):
"""Извлечь чистый текст из HTML."""
try:
soup = BeautifulSoup(html, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header", "aside"]):
tag.decompose()
text = soup.get_text(separator=" ")
text = re.sub(r"\s+", " ", text).strip()
if len(text) < 100:
return None
return text[:max_chars]
except Exception:
return None
# ─── Async поиск и парсинг ────────────────────────────────────────────────────
async def searxng_search(session, query, category="general", max_results=20):
"""Поиск через SearXNG (async)."""
params = {
"q": query,
"format": "json",
"categories": category,
"pageno": 1,
}
try:
async with session.get(
SEARXNG_URL, params=params, headers=build_headers(),
timeout=aiohttp.ClientTimeout(total=30)
) as resp:
resp.raise_for_status()
data = await resp.json()
results = data.get("results", [])
# Только с валидными URL
valid = []
for r in results:
url = r.get("url", "")
if url and url.startswith("http"):
valid.append(r)
return valid[:max_results]
except Exception as e:
log(f"SearXNG error: {e}")
return []
async def fetch_content(session, url, max_chars=MAX_ARTICLE_CHARS):
"""Скачать и извлечь текст (aiohttp + curl fallback)."""
domain = urllib.parse.urlparse(url).hostname
await _limiter.acquire(domain)
headers = build_headers(referer="https://www.google.com/")
# Метод 1: aiohttp
try:
async with session.get(
url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)
) as resp:
resp.raise_for_status()
html = await resp.text()
text = _extract_text(html, max_chars)
if text:
return text
except Exception:
pass
# Метод 2: curl fallback
try:
result = await asyncio.to_thread(
subprocess.run,
["curl", "-sL", "--max-time", "15", url,
"-H", f"User-Agent: {headers['User-Agent']}",
"-H", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"-H", "Accept-Language: ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7"],
capture_output=True, text=True, timeout=20,
)
if result.returncode == 0 and len(result.stdout) >= 300:
return _extract_text(result.stdout, max_chars)
except Exception:
pass
return None
def deduplicate(results):
seen = set()
unique = []
for r in results:
url = r.get("url", "")
if url and url not in seen:
seen.add(url)
unique.append(r)
return unique
# ─── Sync LLM call ────────────────────────────────────────────────────────────
def llm_chat(system, prompt, max_tokens=2000):
"""Запрос к OpenAI-совместимому API (llama-server)."""
payload = {
"model": LLM_MODEL,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": prompt}
],
"temperature": LLM_TEMPERATURE,
"max_tokens": max_tokens,
"stream": False
}
try:
result = subprocess.run(
["curl", "-sL", "--max-time", str(LLM_TIMEOUT + 30), LLM_URL,
"-H", "Content-Type: application/json",
"-d", json.dumps(payload)],
capture_output=True, text=True, timeout=LLM_TIMEOUT + 45,
)
data = json.loads(result.stdout)
return data.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
except Exception:
return ""
# ─── Pipeline ─────────────────────────────────────────────────────────────────
async def perplexity_pipeline(query, category="general", max_results=3):
connector = aiohttp.TCPConnector(limit=10, ttl_dns_cache=300)
async with aiohttp.ClientSession(connector=connector) as session:
print(f"\n{'='*60}")
print(f" Perplexity Search: {query}")
print(f"{'='*60}\n")
# PHASE 1: SEARCH
print(" Phase 1/3: Searching SearXNG...", flush=True)
results = await searxng_search(session, query, category)
results = deduplicate(results)
print(f" Found {len(results)} results")
if not results:
print("\n No results found.")
return
results.sort(key=lambda r: r.get("score", 0), reverse=True)
top = results[:max_results]
for i, r in enumerate(top, 1):
print(f" [{i}] {r.get('title','?')[:80]}")
print(f" ({r.get('engine','?')}) {r.get('url','')[:90]}")
# PHASE 2: FETCH (параллельно!)
print(f"\n Phase 2/3: Fetching ({len(top)} articles in parallel)...", flush=True)
tasks = [fetch_content(session, r.get("url", "")) for r in top]
contents = await asyncio.gather(*tasks, return_exceptions=True)
sources = []
for i, (r, content) in enumerate(zip(top, contents), 1):
title = r.get("title", "?")
url = r.get("url", "")
if isinstance(content, Exception) or not content:
# Fallback: snippet from SearXNG
snippet = r.get("content", "")
if snippet and len(snippet) > 100:
print(f" [{i}] {title[:40]}... using snippet ({len(snippet)} chars)")
sources.append({
"id": i, "title": title.strip(), "url": url,
"engine": r.get("engine", "?"), "content": snippet
})
else:
print(f" [{i}] {title[:40]}... FAIL")
else:
print(f" [{i}] {title[:40]}... OK ({len(content)} chars)")
sources.append({
"id": i, "title": title.strip(), "url": url,
"engine": r.get("engine", "?"), "content": content
})
if not sources:
print("\n Could not fetch any articles.")
return
print(f" Fetched: {len(sources)}/{len(top)}")
# PHASE 3: LLM SYNTHESIS
print(f"\n Phase 3/3: Synthesizing ({LLM_MODEL})...", flush=True)
context_parts = []
for s in sources:
snippet = s["content"][:1500]
context_parts.append(
f"[{s['id']}] {s['title']}\nURL: {s['url']}\nContent: {snippet}\n"
)
context = "\n---\n".join(context_parts)
system = """Ты — Perplexity-style AI research assistant.
На основе источников [1][2][3] напиши ответ на русском.
Правила:
1. Каждый факт — ссылка на источник [1], [2], [3]
2. Противоречия в источниках — отмечай
3. Только из предоставленных источников
4. В конце — 3 related questions
Формат:
## [Тема]
**Ключевой вывод:** [1-2 предложения]
### Обзор
[синтез с ссылками]
### Тренды
- **Тренд:** описание [1]
### Детали
[подробно]
### Related questions
- [Вопрос 1]?
- [Вопрос 2]?
- [Вопрос 3]?
### Источники
[1] Название — URL
[2] Название — URL"""
user_prompt = f"Запрос: {query}\n\nИсточники:\n{context}"
response = llm_chat(system, user_prompt, max_tokens=2000)
if response:
# Всегда добавляем блок источников программно
if not re.search(r'(?:###?\s*)?(?:Источники|Sources)', response):
src_block = "\n### Источники\n"
for s in sources:
src_block += f"[{s['id']}] {s['title']} — {s['url']}\n"
response += src_block
print("\n")
print(response)
else:
print("\n LLM временно недоступен. Raw-отчёт:\n")
print(f"## {query}\n")
for s in sources:
preview = s["content"][:200].replace("\n", " ")
print(f"\n[{s['id']}] {s['title']}")
print(f" {s['url']} ({s['engine']})")
print(f" {preview}...")
print(f"\n### Источники")
for s in sources:
print(f"[{s['id']}] {s['title']} — {s['url']}")
print(f"\n{'─'*50}")
print(f" {len(results)} results | {len(sources)} fetched | Model: {LLM_MODEL} | q: {query}")
async def go_raw(query, category="general", max_results=3):
"""Режим --raw: только сбор данных, JSON на stdout."""
connector = aiohttp.TCPConnector(limit=10, ttl_dns_cache=300)
async with aiohttp.ClientSession(connector=connector) as session:
results = await searxng_search(session, query, category)
results = deduplicate(results)
results.sort(key=lambda r: r.get("score", 0), reverse=True)
top = results[:max_results]
tasks = [fetch_content(session, r.get("url", "")) for r in top]
contents = await asyncio.gather(*tasks, return_exceptions=True)
sources = []
for r, content in zip(top, contents):
url = r.get("url", "")
if not url:
continue
if isinstance(content, Exception) or not content:
snippet = r.get("content", "")
content = snippet if len(snippet) > 100 else None
sources.append({
"title": r.get("title", "").strip(),
"url": url,
"engine": r.get("engine", ""),
"content": (content or "")[:2000]
})
output = {"query": query, "total_results": len(results), "sources": sources}
print(json.dumps(output, ensure_ascii=False, indent=2))
# ─── CLI ──────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Perplexity-style search agent (async)"
)
parser.add_argument("query", nargs="?", help="Поисковый запрос")
parser.add_argument("--category", default="general",
choices=["general", "news", "it", "science"])
parser.add_argument("--results", type=int, default=3,
help="Сколько статей fetch'ить (макс 5)")
parser.add_argument("--raw", action="store_true",
help="Только JSON-данные, без LLM-синтеза")
parser.add_argument("--model", help="LLM модель")
parser.add_argument("--temperature", type=float, help="LLM temperature")
args = parser.parse_args()
if not args.query:
parser.print_help()
sys.exit(1)
global LLM_MODEL, LLM_TEMPERATURE
if args.model:
LLM_MODEL = args.model
if args.temperature is not None:
LLM_TEMPERATURE = args.temperature
max_results = min(args.results, 5)
if args.raw:
asyncio.run(go_raw(args.query, args.category, max_results))
else:
asyncio.run(perplexity_pipeline(args.query, args.category, max_results))
if __name__ == "__main__":
main()
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""
Общие утилиты для research-скриптов — retry, валидация, промпты.
Использование:
from research_llm_utils import llm_call_safe, validate_report, REASON_FIRST_PREFIX
"""
import json
import os
import re
import time
import sys
import requests
# ─── Настройки (из env) ────────────────────────────────────────────────────────
LLM_URL = os.environ.get("LLM_URL", "http://192.168.1.152:8080/v1/chat/completions")
LLM_MODEL = os.environ.get("LLM_MODEL", "qwen3.6:27b")
LLM_TEMPERATURE = 0.3
LLM_MAX_RETRIES = 2
# ─── Префикс «Reason First» для системных промптов ────────────────────────────
# Добавляется в начало system message. Заставляет локальную LLM думать
# перед ответом — критично для качества синтеза.
REASON_FIRST_PREFIX = (
"Сначала прочитай и проанализируй ВСЕ источники. "
"Отметь противоречия между ними. "
"Затем составь ответ, привязывая каждый факт к источнику [N]. "
"После ответа проверь: все ли [N] из текста есть в блоке Источники.\n\n"
)
# ─── LLM Call с retry ──────────────────────────────────────────────────────────
def llm_call_raw(system, user, max_tokens=2048, temperature=None, model=None):
"""Одиночный вызов LLM. Возвращает текст или None."""
if temperature is None:
temperature = LLM_TEMPERATURE
if model is None:
model = LLM_MODEL
payload = {
"model": model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"max_tokens": max_tokens,
"temperature": temperature,
"stream": False,
}
try:
resp = requests.post(LLM_URL, json=payload, timeout=300)
resp.raise_for_status()
data = resp.json()
msg = data.get("choices", [{}])[0].get("message", {})
content = msg.get("content", "").strip()
if not content:
# Qwen3.6 thinking variant — вывод в reasoning_content
content = msg.get("reasoning_content", "").strip()
if not content:
return None
return content
except requests.exceptions.Timeout:
log("LLM timeout after 300s")
return None
except requests.exceptions.HTTPError as e:
log(f"LLM HTTP {e.response.status_code if e.response else '?'}: {str(e)[:200]}")
return None
except (KeyError, json.JSONDecodeError) as e:
log(f"LLM parse error: {e}")
return None
except Exception as e:
log(f"LLM error: {type(e).__name__}: {e}")
return None
def llm_call_safe(system, user, max_tokens=2048, temperature=None, model=None):
"""
Вызов LLM с retry и fallback.
- 2 попытки с задержкой 2s/4s
- При полном отказе: возвращает текст ошибки вместо None
"""
for attempt in range(LLM_MAX_RETRIES):
result = llm_call_raw(system, user, max_tokens, temperature, model)
if result:
return result
delay = 2 * (attempt + 1)
log(f"LLM retry {attempt + 1}/{LLM_MAX_RETRIES} через {delay}s...")
time.sleep(delay)
# Fallback — возвращаем сообщение об ошибке, а не None
return (
"⚠️ Не удалось синтезировать отчёт — LLM недоступен. "
"Ниже приведены собранные источники без анализа."
)
# ─── Валидация отчёта ──────────────────────────────────────────────────────────
def validate_report(text):
"""
Проверяет отчёт на:
- Наличие inline-цитат [N]
- Наличие блока «Источники» / «Sources»
- Минимальную длину
Возвращает (is_valid, list_of_warnings).
"""
warnings = []
if not text or len(text) < 100:
warnings.append("Слишком короткий ответ (< 100 символов)")
return False, warnings
# Проверка цитат [N]
has_citations = bool(re.search(r'\[\d+\]', text))
if not has_citations:
warnings.append("Отсутствуют inline-цитаты [N]")
# Проверка блока источников
has_sources = bool(re.search(r'(?:###?\s*)?Источники|Sources', text))
if not has_sources:
warnings.append("Отсутствует блок «Источники» / «Sources»")
# Проверка на явные ошибки LLM
if "я не могу" in text.lower() or "i cannot" in text.lower():
warnings.append("LLM отказалась отвечать (refusal detected)")
is_valid = len(warnings) == 0
return is_valid, warnings
def validate_and_warn(text):
"""
Валидирует отчёт и добавляет warnings в конец если проблемы.
Возвращает (текст с warnings, is_clean).
"""
is_valid, warnings = validate_report(text)
if is_valid:
return text, True
warn_text = "\n\n---\n⚠️ **Предупреждения валидации:**\n"
for w in warnings:
warn_text += f"- {w}\n"
return text + warn_text, False
# ─── Логирование ───────────────────────────────────────────────────────────────
def log(msg):
"""Лог в stderr — не мешает выводу отчёта в stdout."""
print(f"[research] {msg}", file=sys.stderr)
# ─── HTTP helpers ──────────────────────────────────────────────────────────────
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
SESSION = None
def get_session():
"""Ленивая сессия requests с таймаутами."""
global SESSION
if SESSION is None:
SESSION = requests.Session()
SESSION.headers.update({"User-Agent": USER_AGENT})
return SESSION
def fetch_url(url, timeout=15):
"""GET запрос с возвратом текста или None."""
try:
resp = get_session().get(url, timeout=timeout, allow_redirects=True)
resp.raise_for_status()
return resp.text
except Exception:
return None
# ─── Патчинг глобальных настроек ───────────────────────────────────────────────
def apply_cli_overrides(model=None, temperature=None):
"""Применить переопределения из CLI-аргументов к глобальным переменным."""
global LLM_MODEL, LLM_TEMPERATURE
if model:
LLM_MODEL = model
if temperature is not None:
LLM_TEMPERATURE = temperature
Regular → Executable
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""Research Validator — проверка качества исследовательских отчётов.
Три проверки, которые ловят 80% брака от локальных LLM:
1. Цитаты — все [N] существуют, нет «мёртвых» ссылок
2. URL — все валидные (http, не битые relative)
3. Полнота — минимум источников, достаточно контента
Использование:
from research_validator import validate_report
ok, errors = validate_report(report_text, sources_list)
if not ok:
print("Проблемы:", errors)
# → отправить errors обратно в промпт LLM на исправление
Также используется для предварительной фильтрации URL из SearXNG:
clean_urls = filter_valid_urls(raw_results)
"""
import re
from typing import Any
from urllib.parse import urlparse
def validate_report(report: str, sources: list[dict]) -> tuple[bool, list[str]]:
"""Проверить отчёт на соответствие стандартам качества.
Args:
report: текст отчёта (Markdown)
sources: список словарей с ключами ref (int), title (str), url (str)
Returns:
(True, []) — всё хорошо
(False, ["ошибка 1", "ошибка 2"]) — нужно исправлять
"""
errors = []
# 1. Проверка цитат: все [N] в тексте должны существовать в sources
cited_nums = set()
for m in re.finditer(r'\[(\d+)\]', report):
cited_nums.add(int(m.group(1)))
source_nums = {s["ref"] for s in sources}
missing = cited_nums - source_nums
if missing:
errors.append(
f"Цитаты [{', '.join(str(n) for n in sorted(missing))}] "
f"не существуют — нет таких источников"
)
unused = source_nums - cited_nums
if unused:
errors.append(
f"Источники [{', '.join(str(n) for n in sorted(unused))}] "
f"не процитированы в тексте"
)
# 2. Проверка URL: все валидные
for s in sources:
url = s.get("url", "")
if not url:
errors.append(f"[{s['ref']}] пустой URL")
elif not url.startswith("http"):
errors.append(f"[{s['ref']}] невалидный URL: {url[:80]}")
else:
parsed = urlparse(url)
if not parsed.netloc:
errors.append(f"[{s['ref']}] URL без домена: {url[:80]}")
# 3. Проверка полноты
if len(sources) < 3:
errors.append(f"Слишком мало источников: {len(sources)} (минимум 3)")
total_text = sum(len(s.get("text", "")) for s in sources)
if total_text < 1000:
errors.append(f"Слишком мало контента: {total_text} символов (минимум 1000)")
# 4. Проверка структуры — есть ли ключевые секции
has_overview = bool(re.search(r'###?\s*(Обзор|Overview)', report))
has_sources = bool(re.search(r'###?\s*(Источники|Sources)', report))
has_citations = bool(re.search(r'\[\d+\]', report))
if not has_overview:
errors.append("Нет секции «Обзор»")
if not has_citations:
errors.append("Нет inline-цитат [N] в тексте")
if not has_sources and sources:
errors.append("Нет секции «Источники» в конце отчёта")
return len(errors) == 0, errors
def filter_valid_urls(results: list[dict]) -> list[dict]:
"""Отфильтровать результаты SearXNG: только с валидными URL.
Args:
results: список словарей от SearXNG (url, title, content, engine)
Returns:
Отфильтрованный список (только http/https URL)
"""
valid = []
for r in results:
url = r.get("url", "")
if not url or not url.startswith("http"):
continue
parsed = urlparse(url)
if not parsed.netloc:
continue
# Отбрасываем явно битые
if any(x in url.lower() for x in ["example.com", "localhost", "0.0.0.0"]):
continue
valid.append(r)
return valid
def score_source(result: dict) -> float:
"""Оценить качество источника от 0 до 1.
Учитывает: поисковый движок, длину сниппета, наличие даты.
"""
score = 0.5 # базовый
engine = result.get("engine", "").lower()
engine_scores = {
"wikipedia": 0.4, # +0.4 за достоверность
"arxiv": 0.3,
"google": 0.2,
"bing": 0.1,
"duckduckgo": 0.1,
"brave": 0.1,
"reddit": -0.1, # штраф за субъективность
}
score += engine_scores.get(engine, 0)
# Бонус за длинный сниппет (больше информации)
snippet = result.get("content", "") or result.get("snippet", "")
if len(snippet) > 200:
score += 0.1
if len(snippet) > 500:
score += 0.1
# Штраф за отсутствие заголовка
if not result.get("title"):
score -= 0.3
return max(0.0, min(1.0, score))
def build_fix_prompt(report: str, errors: list[str], sources: list[dict]) -> str:
"""Собрать промпт для LLM с конкретными ошибками для исправления."""
sources_text = "\n".join(
f"[{s['ref']}] {s['title']} — {s['url']}" for s in sources
)
errors_text = "\n".join(f"- {e}" for e in errors)
return f"""Отчёт содержит ошибки. Исправь их и верни ПОЛНЫЙ исправленный отчёт.
ОШИБКИ:
{errors_text}
ДОСТУПНЫЕ ИСТОЧНИКИ:
{sources_text}
ТЕКУЩИЙ ОТЧЁТ:
{report[:4000]}
ТРЕБОВАНИЯ:
- Каждый факт с цитатой [N], где N — номер из списка источников
- Все указанные источники должны быть процитированы
- В конце — секция «### Источники» с полными URL
- Секция «### Обзор» обязательна
- Не добавляй источники, которых нет в списке"""
# ═══════════════════════════════════════════
# CLI
# ═══════════════════════════════════════════
if __name__ == "__main__":
import sys, json
if len(sys.argv) < 2:
print("Usage: research_validator.py <report.md> [sources.json]")
print(" sources.json: [{\"ref\": 1, \"title\": \"...\", \"url\": \"...\"}, ...]")
sys.exit(1)
report_text = open(sys.argv[1]).read()
if len(sys.argv) > 2:
sources = json.loads(open(sys.argv[2]).read())
else:
sources = []
ok, errors = validate_report(report_text, sources)
if ok:
print("✓ Отчёт валиден")
else:
print(f"✗ Найдено {len(errors)} проблем:")
for e in errors:
print(f" - {e}")
sys.exit(1)
+479
View File
@@ -0,0 +1,479 @@
#!/usr/bin/env python3
"""
YouTube Analyzer — анализ видео как Perplexity Deep Research.
Три режима:
1. По URL: youtube_analyzer.py --url "https://youtube.com/watch?v=..."
2. Поиск: youtube_analyzer.py --search "LLM агенты 2026"
3. Пакет: youtube_analyzer.py --search "AI" --limit 3 --compare (сравнение нескольких)
Алгоритм:
- Получение субтитров через youtube-transcript-api
- Если субтитров нет — pytube + whisper
- LLM-синтез → structured report с таймкодами и цитатами
Использование:
python3 scripts/youtube_analyzer.py --url "https://youtube.com/watch?v=..."
python3 scripts/youtube_analyzer.py --search "термоядерный синтез 2026" --lang ru
python3 scripts/youtube_analyzer.py --search "LLM agents" --limit 3 --compare --lang en
python3 scripts/youtube_analyzer.py --url "..." --transcript-only
Опции:
--url URL видео
--search Поисковый запрос для поиска видео
--limit 1-5 (default: 1, для --search и --compare)
--compare Сравнить несколько видео по теме
--lang Язык субтитров: ru (default) | en
--transcript-only Только текст без LLM-анализа
--output Сохранить в файл
Зависимости: youtube-transcript-api, pytube, openai-whisper (установлены)
"""
import argparse
import html
import json
import os
import os
import re
import sys
import time
import urllib.parse
from datetime import datetime, timedelta
import requests
# Utils для LLM — ищем в scripts/ плагина или в ~/.hermes/scripts/
import importlib.util as _importlib_util
import sys as _sys
_utils_paths = [
os.path.join(os.path.dirname(os.path.abspath(__file__)), "research_llm_utils.py"),
"/home/neo/.hermes/scripts/research_llm_utils.py",
]
_utils_path = None
for _up in _utils_paths:
if os.path.exists(_up):
_utils_path = _up
break
if _utils_path is None:
raise ImportError("research_llm_utils.py не найден. Установите плагин research-agent полностью.")
_spec = _importlib_util.spec_from_file_location("research_llm_utils", _utils_path)
_utils_mod = _importlib_util.module_from_spec(_spec)
_spec.loader.exec_module(_utils_mod)
llm_call_safe = _utils_mod.llm_call_safe
validate_and_warn = _utils_mod.validate_and_warn
REASON_FIRST_PREFIX = _utils_mod.REASON_FIRST_PREFIX
log = _utils_mod.log
apply_cli_overrides = _utils_mod.apply_cli_overrides
SEARXNG_URL = os.environ.get("SEARXNG_URL", "http://localhost:8888")
LLM_URL = os.environ.get("LLM_URL", "http://192.168.1.152:80/v1/chat/completions")
LLM_MODEL = os.environ.get("LLM_MODEL", "Qwen3.6-35B-A3B-UD-IQ4_NL.gguf")
LLM_TEMPERATURE = float(os.environ.get("LLM_TEMPERATURE", "0.3"))
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
def log(msg):
print(f"[youtube-analyzer] {msg}", file=sys.stderr)
def llm_call(system, user, max_tokens=2048, temperature=None):
if temperature is None:
temperature = LLM_TEMPERATURE
payload = {
"model": LLM_MODEL,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"max_tokens": max_tokens,
"temperature": temperature,
"stream": False,
}
try:
resp = requests.post(LLM_URL, json=payload, timeout=180)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"].strip()
if not content:
log("LLM error: empty response")
return None
return content
except Exception as e:
log(f"LLM error: {e}")
return None
def extract_video_id(url):
"""Извлечь video ID из URL YouTube."""
patterns = [
r"(?:v=|/v/|youtu\.be/|/embed/|/shorts/)([a-zA-Z0-9_-]{11})",
r"youtube\.com/watch\?.*v=([a-zA-Z0-9_-]{11})",
]
for p in patterns:
m = re.search(p, url)
if m:
return m.group(1)
return None
def get_video_title(video_id):
"""Получить название видео через оython youtube-транскрипт-api или прямой запрос."""
# Пробуем через страницу
try:
resp = requests.get(
f"https://www.youtube.com/watch?v={video_id}",
headers={"User-Agent": USER_AGENT},
timeout=10,
)
m = re.search(r"<title>([^<]+)</title>", resp.text)
if m:
title = html.unescape(m.group(1))
title = re.sub(r"\s*-\s*YouTube\s*$", "", title)
return title.strip()
except Exception:
pass
return f"Video {video_id}"
def get_transcript(video_id, lang="ru"):
"""Получить транскрипт видео (API youtube-transcript-api v2.x)."""
try:
from youtube_transcript_api import YouTubeTranscriptApi
lang_list = [lang, "en", "a.en", "ru", "es", "de", "fr"]
api = YouTubeTranscriptApi()
transcript = api.fetch(video_id, languages=lang_list)
raw = transcript.to_raw_data()
segments = [
{
"text": seg.get("text", ""),
"start": seg.get("start", 0),
"duration": seg.get("duration", 0),
}
for seg in raw
]
return segments, None
except ImportError:
return None, "youtube-transcript-api не установлен"
except Exception as e:
return None, str(e)
def format_timestamp(seconds):
"""MM:SS или HH:MM:SS."""
s = int(seconds)
h, r = divmod(s, 3600)
m, s = divmod(r, 60)
if h:
return f"{h}:{m:02d}:{s:02d}"
return f"{m}:{s:02d}"
def transcript_to_text(segments, with_timestamps=True):
"""Транскрипт в читаемый текст с/без таймкодов."""
lines = []
for seg in segments:
text = seg["text"].strip()
if not text:
continue
if with_timestamps:
ts = format_timestamp(seg["start"])
lines.append(f"[{ts}] {text}")
else:
lines.append(text)
return "\n".join(lines)
def search_youtube_videos(query, lang="ru", limit=5):
"""Поиск видео через SearXNG."""
params = {
"q": f"{query} site:youtube.com",
"format": "json",
"categories": "general",
"language": lang,
"pageno": 1,
}
try:
resp = requests.get(
SEARXNG_URL, params=params, headers={"User-Agent": USER_AGENT}, timeout=20
)
resp.raise_for_status()
data = resp.json()
results = data.get("results", [])
# Фильтр: только YouTube ссылки
videos = []
for r in results:
url = r.get("url", "")
vid = extract_video_id(url)
if vid:
videos.append({
"title": r.get("title", "?"),
"url": url,
"video_id": vid,
"snippet": r.get("content", ""),
})
if len(videos) >= limit:
break
return videos
except Exception as e:
log(f"Search error: {e}")
return []
def transcribe_with_whisper(video_id):
"""Скачать аудио + whisper транскрибация (fallback)."""
try:
from pytube import YouTube
log("Скачивание аудио через pytube...")
yt = YouTube(f"https://www.youtube.com/watch?v={video_id}")
stream = yt.streams.filter(only_audio=True).first()
if not stream:
return None, "Нет аудиопотока"
tmp_path = f"/tmp/yta_{video_id}.mp4"
stream.download(output_path="/tmp", filename=f"yta_{video_id}.mp4")
if not os.path.exists(tmp_path):
return None, "Не удалось скачать аудио"
log("Транскрибация через whisper...")
import whisper
model = whisper.load_model("small") # small — баланс скорость/качество
result = model.transcribe(tmp_path, language="ru")
os.remove(tmp_path)
segments = []
for seg in result.get("segments", []):
segments.append({
"text": seg.get("text", "").strip(),
"start": seg.get("start", 0),
"duration": seg.get("end", 0) - seg.get("start", 0),
})
return segments, None
except ImportError as e:
return None, f"pytube/whisper не установлен: {e}"
except Exception as e:
return None, str(e)
def analyze_single_video(video_id, title, lang="ru", transcript_only=False):
"""Анализ одного видео — полный цикл."""
result = {
"video_id": video_id,
"title": title,
"url": f"https://youtube.com/watch?v={video_id}",
"segments": None,
"error": None,
"transcript_text": "",
"report": "",
}
# Фаза 1: получение транскрипта
log(f"Получение транскрипта ({lang})...")
segments, error = get_transcript(video_id, lang)
used_whisper = False
if segments is None:
log(f"Транскрипт не найден: {error}")
log("Fallback: whisper транскрибация...")
segments, error = transcribe_with_whisper(video_id)
used_whisper = True
if segments is None:
result["error"] = f"Не удалось получить транскрипт: {error}"
return result
result["segments"] = segments
result["transcript_text"] = transcript_to_text(segments, with_timestamps=True)
result["used_whisper"] = used_whisper
log(f"Транскрипт: {len(segments)} сегментов, "
f"{len(result['transcript_text'])} символов")
if transcript_only:
return result
# Фаза 2: LLM-синтез
log("Синтез отчёта...")
text_for_llm = transcript_to_text(segments, with_timestamps=True)
# Ограничение для LLM
if len(text_for_llm) > 12000:
text_for_llm = text_for_llm[:12000] + "\n[...truncated]"
lang_system = "ru" if lang == "ru" else "en"
system = (
"Ты — аналитик контента. Проанализируй транскрипт видео и составь "
"структурированный отчёт.\n\n"
"Формат отчёта СТРОГО:\n\n"
"## [Название видео]\n\n"
"**Ключевой вывод:** [1-2 предложения]\n\n"
"### Краткое содержание\n[2-3 абзаца с ключевыми идеями и таймкодами [MM:SS]]\n\n"
"### Ключевые тезисы\n- **Тезис 1** (таймкод [MM:SS]): [формулировка]\n"
"- **Тезис 2** (таймкод [MM:SS]): [формулировка]\n\n"
"### Яркие цитаты\n> \"[цитата]\" — [MM:SS]\n\n"
"### Оценка\n[Кому будет полезно, качество аргументации, факты/мнение]\n"
)
report = llm_call(system, text_for_llm, max_tokens=2048, temperature=0.2)
if report:
result["report"] = report
else:
result["report"] = "Ошибка синтеза отчёта."
return result
def synthesize_compare(video_analyses, topic):
"""Сравнительный анализ нескольких видео."""
ctx_parts = []
for va in video_analyses:
if va["report"]:
ctx_parts.append(
f"## {va['title']}\n{va['report'][:2000]}"
)
elif va["error"]:
ctx_parts.append(f"## {va['title']}\nОшибка: {va['error']}")
context = "\n\n---\n\n".join(ctx_parts)
system = (
"Ты — аналитик. Составь сравнительный обзор нескольких видео на одну тему.\n\n"
"Формат:\n\n"
"## Сравнительный анализ: [тема]\n\n"
"**Ключевой вывод:** [общий вывод по всем видео]\n\n"
"### Обзор видео\n"
"- [Видео 1]: кратко (1-2 предложения)\n"
"- [Видео 2]: кратко\n\n"
"### Сходства\n[что общего в подаче / фактах / аргументах]\n\n"
"### Различия\n[чем отличаются: угол подачи, глубина, качество]\n\n"
"### Рейтинг (от лучшего к худшему)\n"
"1. [Видео] — почему\n"
"2. [Видео] — почему\n\n"
"### Итог\n[какое видео лучше всего раскрывает тему]\n"
)
prompt = (
f"Тема: {topic}\n\n"
f"Анализы видео:\n\n{context}\n\n"
"Составь сравнительный отчёт."
)
report = llm_call(system, prompt, max_tokens=2048, temperature=0.2)
return report or "Ошибка синтеза."
# ─── Main ──────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="YouTube Analyzer — анализ видео как Perplexity"
)
parser.add_argument("--url", help="URL видео YouTube")
parser.add_argument("--search", help="Поисковый запрос для поиска видео")
parser.add_argument("--limit", type=int, default=1, choices=range(1, 6),
help="Количество видео для поиска/сравнения")
parser.add_argument("--compare", action="store_true",
help="Сравнить несколько видео")
parser.add_argument("--lang", default="ru", choices=["ru", "en"])
parser.add_argument("--model", help="Имя LLM модели (env: LLM_MODEL)")
parser.add_argument("--temperature", type=float, help="Temperature (env: LLM_TEMPERATURE)")
parser.add_argument("--transcript-only", action="store_true",
help="Только текст транскрипта")
parser.add_argument("--output", "-o", help="Сохранить в файл")
args = parser.parse_args()
if args.model:
LLM_MODEL = args.model
if args.temperature is not None:
LLM_TEMPERATURE = args.temperature
if not args.url and not args.search:
parser.print_help()
sys.exit(1)
start_time = time.time()
# ─── Режим: поиск видео ─────────────────────────────────────────
videos = []
if args.search:
log(f"Поиск видео: {args.search}")
videos = search_youtube_videos(args.search, args.lang, args.limit)
if not videos:
print("Видео не найдены.")
sys.exit(1)
log(f"Найдено видео: {len(videos)}")
for v in videos:
log(f" {v['title'][:80]}")
if not v.get("title"):
v["title"] = get_video_title(v["video_id"])
elif args.url:
vid = extract_video_id(args.url)
if not vid:
print("Не удалось извлечь ID видео из URL")
sys.exit(1)
title = get_video_title(vid)
videos = [{"video_id": vid, "title": title, "url": args.url}]
log(f"Видео: {title}")
# ─── Анализ видео ───────────────────────────────────────────────
analyses = []
for i, v in enumerate(videos):
log(f"\nАнализ [{i+1}/{len(videos)}]: {v['title'][:60]}")
result = analyze_single_video(
v["video_id"],
v.get("title", v.get("title", "?")),
args.lang,
args.transcript_only,
)
analyses.append(result)
# ─── Вывод результатов ──────────────────────────────────────────
output_parts = []
elapsed = time.time() - start_time
if args.compare and len(analyses) > 1:
# Сравнительный отчёт
log("Синтез сравнительного отчёта...")
topic = args.search or "сравнение видео"
compare_report = synthesize_compare(analyses, topic)
output_parts.append(f"# Сравнительный анализ видео\n\n{compare_report}")
output_parts.append(f"\n\n---\n*Проанализировано {len(analyses)} видео за {elapsed:.0f} сек*")
else:
for a in analyses:
title_display = a["title"]
if a.get("error"):
output_parts.append(f"## {title_display}\n\n**Ошибка:** {a['error']}\n\n---")
continue
if args.transcript_only:
# Только текст транскрипта
header = f"## {title_display}\nURL: {a['url']}\n\n"
output_parts.append(header + a["transcript_text"])
else:
output_parts.append(a["report"])
if a.get("used_whisper"):
output_parts.append("\n\n*Транскрипция через whisper (субтитры отсутствовали)*")
if not args.transcript_only:
output_parts.append(f"\n\n---\n*Проанализировано {len(analyses)} видео за {elapsed:.0f} сек*")
output = "\n\n".join(output_parts)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(output)
log(f"Отчёт сохранён: {args.output}")
else:
print(output)
if __name__ == "__main__":
main()
+220
View File
@@ -0,0 +1,220 @@
"""
Обработчики инструментов research-agent плагина.
Каждый обработчик:
1. Собирает аргументы командной строки из параметров вызова
2. Запускает соответствующий Python-скрипт через subprocess
3. Захватывает stdout/stderr
4. Возвращает результат как JSON
Скрипты лежат в scripts/ рядом с плагином — плагин самодостаточен.
"""
import json
import os
import subprocess
import sys
from pathlib import Path
# ─── Пути к скриптам ──────────────────────────────────────────────────────────
_PLUGIN_DIR = Path(__file__).parent
_SCRIPTS_DIR = _PLUGIN_DIR / "scripts"
DEEP_RESEARCH_SCRIPT = _SCRIPTS_DIR / "deep_research.py"
PERPLEXITY_SEARCH_SCRIPT = _SCRIPTS_DIR / "perplexity_search.py"
YOUTUBE_ANALYZER_SCRIPT = _SCRIPTS_DIR / "youtube_analyzer.py"
# ─── Общие настройки ──────────────────────────────────────────────────────────
# Таймауты (секунд) — скрипты долгие из-за LLM-синтеза
TIMEOUT_DEEP_RESEARCH = 420 # 7 минут
TIMEOUT_PERPLEXITY = 120 # 2 минуты
TIMEOUT_YOUTUBE = 180 # 3 минуты
def _run_script(script_path: Path, args: list[str], timeout: int) -> dict:
"""
Запустить Python-скрипт и вернуть результат.
Args:
script_path: абсолютный путь к .py файлу
args: аргументы командной строки (без имени скрипта)
timeout: таймаут в секундах
Returns:
dict с ключами: success, report (текст отчёта), error (если ошибка),
exit_code, stderr
"""
if not script_path.exists():
return {
"success": False,
"error": f"Скрипт не найден: {script_path}\n"
f"Установите плагин полностью: hermes plugins install research-agent",
"report": "",
"exit_code": -1,
"stderr": "",
}
cmd = [sys.executable, str(script_path)] + args
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
cwd=str(script_path.parent),
env={**os.environ, "PYTHONUNBUFFERED": "1"},
)
# Скрипт пишет отчёт в stdout
report = result.stdout.strip()
stderr = result.stderr.strip()
if result.returncode != 0:
# Частичный отчёт может быть на stdout даже при ошибке
if not report and stderr:
report = f"[Ошибка выполнения]\n{stderr[-2000:]}"
return {
"success": False,
"error": stderr[-2000:] if stderr else f"Exit code {result.returncode}",
"report": report,
"exit_code": result.returncode,
"stderr": stderr[-3000:],
}
return {
"success": True,
"report": report,
"error": "",
"exit_code": 0,
"stderr": stderr[-1000:] if stderr else "",
}
except subprocess.TimeoutExpired:
return {
"success": False,
"error": f"Таймаут ({timeout}с). Исследование заняло слишком много времени.",
"report": "",
"exit_code": -1,
"stderr": f"Timeout after {timeout}s",
}
except Exception as e:
return {
"success": False,
"error": f"Ошибка запуска: {e}",
"report": "",
"exit_code": -1,
"stderr": str(e),
}
# ─── Обработчики ──────────────────────────────────────────────────────────────
def handle_deep_research(params: dict, **kwargs) -> str:
"""
Запустить deep_research.py с параметрами из tool call.
Ожидаемые параметры:
query (str): тема исследования
lang (str, опционально): 'ru' или 'en' (default 'ru')
"""
query = params.get("query", "")
if not query:
return json.dumps({"success": False, "error": "Параметр 'query' обязателен"})
args = [query]
lang = params.get("lang", "ru")
if lang:
args.extend(["--lang", lang])
# Опциональные параметры из env или переопределения
model = params.get("model") or os.environ.get("LLM_MODEL")
temperature = params.get("temperature") or os.environ.get("LLM_TEMPERATURE")
if model:
args.extend(["--model", model])
if temperature:
args.extend(["--temperature", str(temperature)])
result = _run_script(DEEP_RESEARCH_SCRIPT, args, TIMEOUT_DEEP_RESEARCH)
return json.dumps(result, ensure_ascii=False)
def handle_perplexity_search(params: dict, **kwargs) -> str:
"""
Запустить perplexity_search.py с параметрами из tool call.
Ожидаемые параметры:
query (str): поисковый запрос
category (str, опционально): 'general', 'news', 'it', 'science'
results (int, опционально): 1-5 (default 3)
"""
query = params.get("query", "")
if not query:
return json.dumps({"success": False, "error": "Параметр 'query' обязателен"})
args = [query]
category = params.get("category", "general")
if category and category != "general":
args.extend(["--category", category])
results = params.get("results", 3)
args.extend(["--results", str(results)])
# Опциональные параметры
model = params.get("model") or os.environ.get("LLM_MODEL")
temperature = params.get("temperature") or os.environ.get("LLM_TEMPERATURE")
if model:
args.extend(["--model", model])
if temperature:
args.extend(["--temperature", str(temperature)])
result = _run_script(PERPLEXITY_SEARCH_SCRIPT, args, TIMEOUT_PERPLEXITY)
return json.dumps(result, ensure_ascii=False)
def handle_youtube_analyze(params: dict, **kwargs) -> str:
"""
Запустить youtube_analyzer.py с параметрами из tool call.
Ожидаемые параметры:
url (str, опционально): URL видео (взаимоисключается с search)
search (str, опционально): поисковый запрос (взаимоисключается с url)
lang (str, опционально): 'ru' или 'en'
limit (int, опционально): 1-5
compare (bool, опционально): сравнить несколько видео
"""
url = params.get("url", "")
search = params.get("search", "")
if not url and not search:
return json.dumps({
"success": False,
"error": "Укажите 'url' или 'search' параметр",
})
args = []
if url:
args.extend(["--url", url])
else:
args.extend(["--search", search])
lang = params.get("lang", "ru")
if lang:
args.extend(["--lang", lang])
limit = params.get("limit")
if limit and search:
args.extend(["--limit", str(limit)])
if params.get("compare") and search:
args.append("--compare")
result = _run_script(YOUTUBE_ANALYZER_SCRIPT, args, TIMEOUT_YOUTUBE)
return json.dumps(result, ensure_ascii=False)