fix: SEARXNG_URL no longer includes /search suffix (provider adds it), sync all files
This commit is contained in:
1 parent
e5e9b5c664
commit
d4731bee20
11 files changed
+2891
No files matched your search
@@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.env
|
||||
.venv/
|
||||
+58
@@ -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."
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
Whitespace-only changes.
+123
@@ -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
@@ -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()
|
||||
@@ -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
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user