From d4731bee201ab0feba0edee0cc9cfcb991656b37 Mon Sep 17 00:00:00 2001 From: nik Date: Mon, 29 Jun 2026 22:46:24 +0500 Subject: [PATCH] fix: SEARXNG_URL no longer includes /search suffix (provider adds it), sync all files --- .gitignore | 5 + __init__.py | 58 ++ plugin.yaml | 6 + research_llm_utils.py | 0 schemas.py | 123 ++++ scripts/deep_research.py | 1179 +++++++++++++++++++++++++++++++++ scripts/perplexity_search.py | 424 ++++++++++++ scripts/research_llm_utils.py | 192 ++++++ scripts/research_validator.py | 205 ++++++ scripts/youtube_analyzer.py | 479 ++++++++++++++ tools.py | 220 ++++++ 11 files changed, 2891 insertions(+) create mode 100644 .gitignore delete mode 100644 research_llm_utils.py mode change 100644 => 100755 scripts/perplexity_search.py mode change 100644 => 100755 scripts/research_validator.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..898c381 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +*.pyo +.env +.venv/ diff --git a/__init__.py b/__init__.py index e69de29..f731e6b 100644 --- a/__init__.py +++ b/__init__.py @@ -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." + ), + ) diff --git a/plugin.yaml b/plugin.yaml index e69de29..a99cf68 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -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 diff --git a/research_llm_utils.py b/research_llm_utils.py deleted file mode 100644 index e69de29..0000000 diff --git a/schemas.py b/schemas.py index e69de29..3c3b11e 100644 --- a/schemas.py +++ b/schemas.py @@ -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": [], + }, + }, +} diff --git a/scripts/deep_research.py b/scripts/deep_research.py index e69de29..3e917cf 100644 --- a/scripts/deep_research.py +++ b/scripts/deep_research.py @@ -0,0 +1,1179 @@ +#!/usr/bin/env python3 +""" +Deep Research mode — многошаговое исследование как Perplexity Deep Research. + +Архитектура: + 1. Генерация 3-4 связанных поисковых запросов по теме (LLM) + 2. SearXNG → сбор результатов по каждому запросу + 3. Извлечение полного текста топ-статей (aiohttp → curl → Obscura → Playwright → Google Cache) + 4. LLM-анализ: gaps, follow-up вопросы + 5. Второй раунд поиска по follow-up + 6. Финальный LLM-синтез → structured report с [N] цитатами + +Использование: + python3 scripts/deep_research.py "тема исследования" [--lang ru] [--output report.md] + +Опции: + --lang ru (default) | en + --output путь к файлу для сохранения отчёта (по умолчанию stdout) + --ignore-robots игнорировать robots.txt при загрузке страниц (по умолчанию False) + +Зависимости: requests, beautifulsoup4, obscura (AUR: obscura-browser) +""" + +import argparse +import asyncio +import hashlib +import json +import os +import random +import re +import sqlite3 +import subprocess +import sys +import time +import urllib.parse +from collections import defaultdict +from datetime import datetime +from pathlib import Path + +import aiohttp +import requests +from bs4 import BeautifulSoup + +# Импорт валидатора — ищем в scripts/ плагина или в ~/workspace/ +import importlib.util +_validator_paths = [ + os.path.join(os.path.dirname(os.path.abspath(__file__)), "research_validator.py"), + "/home/neo/workspace/research_validator.py", +] +_validator = None +for _vp in _validator_paths: + if os.path.exists(_vp): + _validator_spec = importlib.util.spec_from_file_location("research_validator", _vp) + _validator = importlib.util.module_from_spec(_validator_spec) + _validator_spec.loader.exec_module(_validator) + break +if _validator is None: + raise ImportError("research_validator.py не найден. Установите плагин research-agent полностью.") +validate_report = _validator.validate_report +filter_valid_urls = _validator.filter_valid_urls +build_fix_prompt = _validator.build_fix_prompt + +# ─── Конфигурация ───────────────────────────────────────────────────────────── + +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")) + +# Игнорировать robots.txt (по умолчанию False) +IGNORE_ROBOTS = False + +MAX_ARTICLES_PER_PHASE = 10 +MAX_RESULTS_PER_QUERY = 10 +MAX_ARTICLE_CHARS = 6000 + +SOURCE_COUNTER = [0] # mutable counter for [N] references + +# ─── 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): + """Собрать реалистичные заголовки для HTTP-запроса.""" + 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", + "Upgrade-Insecure-Requests": "1", + } + if referer: + h["Referer"] = referer + return h + + +# ─── Anti-blocking: Domain Rate Limiter (с jitter) ──────────────────────────── + + +class DomainRateLimiter: + """Минимум 1.5 сек между запросами к одному домену ± случайный jitter.""" + + 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): + """Ждать с jitter: 1.5s ± 0.5s — неотличимо от человека.""" + 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) + + +# ─── Anti-blocking: Circuit Breaker ──────────────────────────────────────────── + + +class DomainCircuitBreaker: + """После 3 ошибок подряд — домен отключается на 5 минут.""" + + def __init__(self, threshold=3, cooldown=300): + self._failures = defaultdict(int) + self._cooldown_until = {} + self._threshold = threshold + self._cooldown = cooldown + + def is_open(self, domain): + """True = домен отключён, не ходить.""" + if domain in self._cooldown_until: + if time.monotonic() < self._cooldown_until[domain]: + return True + # cooldown истёк, сброс + del self._cooldown_until[domain] + self._failures[domain] = 0 + return False + + def record_failure(self, domain): + self._failures[domain] += 1 + if self._failures[domain] >= self._threshold: + self._cooldown_until[domain] = time.monotonic() + self._cooldown + log(f"⚠ Circuit breaker OPEN for {domain} ({self._cooldown}s cooldown)") + + def record_success(self, domain): + self._failures[domain] = 0 + + +_circuit_breaker = DomainCircuitBreaker(threshold=3, cooldown=300) + + +# ─── Статистика доменов ──────────────────────────────────────────────────────── + +_stats = defaultdict(lambda: {"requests": 0, "ok": 0, "blocked": 0, "timeout": 0}) + + +def _record_result(domain, outcome): + """Записать исход запроса для статистики.""" + _stats[domain]["requests"] += 1 + _stats[domain][outcome] = _stats[domain].get(outcome, 0) + 1 + + +def dump_stats(): + """Вывести статистику по доменам (проблемные первые).""" + if not _stats: + return + log("=== Domain stats ===") + for domain, s in sorted(_stats.items(), key=lambda x: -(x[1]["blocked"] + x[1]["timeout"])): + if s["blocked"] > 0 or s["timeout"] > 0: + log(f" {domain}: {s['requests']} req, " + f"ok={s['ok']}, blocked={s['blocked']}, timeout={s['timeout']}") + + +# ─── Anti-blocking: SQLite-кэш URL ───────────────────────────────────────────── + +CACHE_DB_PATH = Path.home() / ".hermes" / "scraper_cache.db" + + +def _cache_init(): + """Создать таблицу кэша если нет.""" + CACHE_DB_PATH.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(str(CACHE_DB_PATH)) as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS url_cache ( + url_hash TEXT PRIMARY KEY, + url TEXT NOT NULL, + content TEXT NOT NULL, + fetched_at REAL NOT NULL + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_fetched_at ON url_cache(fetched_at)") + + +def _cache_get(url): + """Получить закэшированный контент (если свежее 24ч).""" + url_hash = hashlib.sha256(url.encode()).hexdigest() + try: + with sqlite3.connect(str(CACHE_DB_PATH)) as conn: + row = conn.execute( + "SELECT content, fetched_at FROM url_cache WHERE url_hash = ?", + (url_hash,), + ).fetchone() + if row: + content, fetched_at = row + age_hours = (time.time() - fetched_at) / 3600 + if age_hours <= 24: + return content + except Exception: + pass + return None + + +def _cache_put(url, content): + """Сохранить контент в кэш.""" + url_hash = hashlib.sha256(url.encode()).hexdigest() + try: + with sqlite3.connect(str(CACHE_DB_PATH)) as conn: + conn.execute( + "INSERT OR REPLACE INTO url_cache (url_hash, url, content, fetched_at) VALUES (?, ?, ?, ?)", + (url_hash, url, content, time.time()), + ) + except Exception: + pass + + +def _cache_cleanup_if_needed(): + """Раз в ~20 запусков — удалить записи старше 30 дней и vacuum.""" + if random.randint(1, 20) != 1: + return + try: + with sqlite3.connect(str(CACHE_DB_PATH)) as conn: + deleted = conn.execute( + "DELETE FROM url_cache WHERE fetched_at < ?", + (time.time() - 30 * 86400,), + ).rowcount + if deleted > 0: + conn.execute("VACUUM") + log(f"Cache cleanup: removed {deleted} entries older than 30 days") + except Exception: + pass + + +_cache_init() # при импорте + + +# ─── Advanced: Headless Browser (Playwright) ────────────────────────────────── + +BROWSER_ENABLED = True # всегда используем browser как fallback + +HYSTERIA2_PROXY = os.environ.get("HYSTERIA2_PROXY", "socks5://192.168.1.1:1080") + +# Домены, заблокированные или ограниченные в РФ — через hysteria2 +БЛОКИРОВАННЫЕ_В_РФ = { + "wikipedia.org", "en.wikipedia.org", "ru.wikipedia.org", + "iaea.org", "www.iaea.org", + "arxiv.org", "export.arxiv.org", + "medium.com", "www.medium.com", + "twitter.com", "x.com", + "instagram.com", "www.instagram.com", + "facebook.com", "www.facebook.com", + "reuters.com", "www.reuters.com", + "apnews.com", "www.apnews.com", + "bbc.com", "www.bbc.com", "bbc.co.uk", + "theguardian.com", "www.theguardian.com", + "bloomberg.com", "www.bloomberg.com", + "nytimes.com", "www.nytimes.com", + "forbes.com", "www.forbes.com", + "ft.com", "www.ft.com", + "independent.co.uk", "www.independent.co.uk", + "politico.com", "www.politico.com", + "newsweek.com", "www.newsweek.com", + "dw.com", "www.dw.com", + "eurointegration.com.ua", + "pravda.com.ua", + "reddit.com", "www.reddit.com", + "discord.com", "discord.gg", + "telegram.org", "telegram.me", + "signal.org", + "change.org", "www.change.org", + "opensea.io", + "crunchbase.com", "www.crunchbase.com", + "producthunt.com", "www.producthunt.com", + "techcrunch.com", "www.techcrunch.com", + "venturebeat.com", "www.venturebeat.com", + "wired.com", "www.wired.com", + "theverge.com", "www.theverge.com", +} + + +def _нужен_прокси(url): + """Проверить, нужен ли hysteria2 прокси для этого URL.""" + from urllib.parse import urlparse + domain = urlparse(url).hostname or "" + for blocked in БЛОКИРОВАННЫЕ_В_РФ: + if domain == blocked or domain.endswith("." + blocked): + return True + return False + +# robots.txt кэш (на 6 часов) — домен → set запрещённых путей +_robots_cache = {} # {domain: (timestamp, set_of_disallowed_paths)} + + +async def _check_robots(session, domain): + """Проверить robots.txt домена. Возвращает set запрещённых путей. + Кэширует результат на 6 часов.""" + now = time.time() + if domain in _robots_cache: + ts, paths = _robots_cache[domain] + if now - ts < 21600: # 6 часов + return paths + + disallowed = set() + try: + robots_url = f"https://{domain}/robots.txt" + async with session.get(robots_url, + headers=build_headers(), + timeout=aiohttp.ClientTimeout(total=5)) as resp: + if resp.status == 200: + text = await resp.text() + for line in text.split("\n"): + line = line.strip() + if line.lower().startswith("disallow:"): + path = line.split(":", 1)[1].strip() + if path: + disallowed.add(path) + except Exception: + pass + + _robots_cache[domain] = (now, disallowed) + return disallowed + + +def _is_url_allowed(url, disallowed_paths): + """Проверить, разрешён ли URL согласно robots.txt.""" + if not disallowed_paths: + return True + path = urllib.parse.urlparse(url).path + for pattern in disallowed_paths: + if pattern == "/": + return False + if pattern.endswith("*"): + if path.startswith(pattern[:-1]): + return False + elif path.startswith(pattern): + return False + return True + +# Stealth-скрипт для маскировки под обычный браузер +_STEALTH_JS = """ +// Скрыть webdriver-флаг +Object.defineProperty(navigator, 'webdriver', {get: () => false}); +// Подменить plugins +Object.defineProperty(navigator, 'plugins', {get: () => [1, 2, 3, 4, 5]}); +// Подменить languages +Object.defineProperty(navigator, 'languages', {get: () => ['ru-RU', 'ru', 'en-US', 'en']}); +// Скрыть chrome.runtime +window.chrome = {runtime: {}}; +// Подменить permissions +const originalQuery = window.navigator.permissions.query; +window.navigator.permissions.query = (parameters) => ( + parameters.name === 'notifications' ? + Promise.resolve({state: Notification.permission}) : + originalQuery(parameters) +); +""" + + +class ObscuraFetcher: + """Fetch через Obscura (Rust headless browser) для JS-страниц. + Быстрее Playwright: ~85ms vs ~500ms, ~30MB vs ~200MB. + Использует obscura CLI: fetch --dump markdown. + """ + + def __init__(self, timeout=20): + self._available = None # None = не проверено, True/False + self._timeout = timeout + + def _is_available(self): + """Проверить, установлен ли obscura.""" + if self._available is None: + self._available = ( + subprocess.run( + ["which", "obscura"], + capture_output=True, timeout=5 + ).returncode == 0 + ) + if not self._available: + log(" ⚠ obscura not found in PATH (skip)") + return self._available + + async def fetch(self, url): + """Загрузить страницу через Obscura CLI и извлечь markdown. + Возвращает None если не удалось.""" + if not self._is_available(): + return None + + proxy_flag = "" + if _нужен_прокси(url): + proxy_flag = f"--proxy {HYSTERIA2_PROXY}" + + cmd = [ + "obscura", "fetch", url, + "--dump", "markdown", + "--wait-until", "networkidle", + "--timeout", str(self._timeout), + "--wait", "10", + "--quiet", + ] + if proxy_flag: + cmd.extend(proxy_flag.split()) + + try: + result = await asyncio.to_thread( + subprocess.run, cmd, + capture_output=True, text=True, timeout=self._timeout + 5 + ) + if result.returncode != 0: + return None + + text = result.stdout.strip() + if not text or len(text) < 300: + return None + + # Obscura уже возвращает чистый markdown — обрезка + if len(text) > MAX_ARTICLE_CHARS: + text = text[:MAX_ARTICLE_CHARS] + return text + + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + self._available = False + log(" ⚠ Obscura fetch failed — marking unavailable") + return None + except Exception as e: + log(f" ✗ Obscura error: {e}") + return None + + +class BrowserFetcher: + """Headless Chromium через Playwright для JavaScript-сайтов. + Используется как последний fallback после aiohttp + curl + obscura.""" + + def __init__(self, timeout=20000): + self._playwright = None + self._browser = None + self._timeout = timeout + self._launched = False + + async def _ensure_browser(self): + """Ленивый запуск браузера (один раз за сессию).""" + if self._launched: + return + try: + from playwright.async_api import async_playwright + except ImportError: + log("⚠ Playwright not installed. Install: pip install playwright && playwright install chromium") + return + self._playwright = await async_playwright().start() + self._browser = await self._playwright.chromium.launch( + headless=True, + args=[ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--disable-blink-features=AutomationControlled", + "--disable-infobars", + ], + ) + self._launched = True + log(" ✓ Browser launched (Playwright Chromium)") + + async def fetch(self, url): + """Загрузить страницу через браузер и извлечь текст. + Возвращает None если не удалось.""" + await self._ensure_browser() + if not self._browser: + return None + + context = None + try: + proxy_cfg = {} + if _нужен_прокси(url): + proxy_cfg["server"] = HYSTERIA2_PROXY + + context = await self._browser.new_context( + user_agent=random_ua(), + java_script_enabled=True, + viewport={"width": 1920, "height": 1080}, + locale="ru-RU", + proxy=proxy_cfg if proxy_cfg else None, + ) + page = await context.new_page() + + # Антидетект + await page.add_init_script(_STEALTH_JS) + + # Загрузка страницы + await page.goto(url, wait_until="networkidle", timeout=self._timeout + 10000) + + # Дождаться отрисовки JS-контента (SPA) + try: + await page.wait_for_timeout(3000) + await page.wait_for_load_state("networkidle", timeout=10000) + except Exception: + pass # не все сайты доходят до networkidle + + # Извлечь текст из или
+ try: + text = await page.evaluate(""" + () => { + const article = document.querySelector('article'); + const main = document.querySelector('main'); + const target = article || main || document.body; + return target ? target.innerText : document.body.innerText; + } + """) + except Exception: + text = await page.evaluate("() => document.body.innerText") + + # Очистка + text = re.sub(r"\s+", " ", text).strip() if text else "" + if len(text) >= 300: + return text[:MAX_ARTICLE_CHARS] + + except Exception as e: + log(f" ✗ Browser fetch failed: {type(e).__name__}: {str(e)[:100]}") + finally: + if context: + try: + await context.close() + except Exception: + pass + + return None + + async def close(self): + """Закрыть браузер (вызывается в конце сессии).""" + if self._browser: + try: + await self._browser.close() + except Exception: + pass + if self._playwright: + try: + await self._playwright.stop() + except Exception: + pass + self._launched = False + +def detect_waf_protection(html): + """Детектит QRator, Cloudflare, Akamai и другие WAF/captcha страницы. + Возвращает строку с названием защиты или None если не обнаружено. + """ + if not html: + return None + html_lower = html.lower() + # Cloudflare — только реальный WAF challenge (не email-decode) + if 'cdn-cgi/challenge/' in html_lower or 'cf-browser-verification' in html_lower or 'cdn-cgi/images' in html_lower: + return 'Cloudflare' + # QRator + if 'qrator' in html_lower or '__qrator' in html_lower: + return 'QRator WAF' + # Generic captcha/challenge + if any(kw in html_lower for kw in ['captcha', 'captcha-img', 'challenge-page', 'verify you are human', + 'enable javascript', 'turn on javascript', + 'permission denied', 'access denied', + 'sucuri', 'perimeterx', 'imperva', + 'incapsula', 'botguard', + 'akamai', 'f5 apm', 'f5 big-ip']): + return 'WAF/Captcha' + return None + + +def log(msg): + print(f"[deep-research] {msg}", file=sys.stderr) + + +def llm_call(system, user, max_tokens=2048, temperature=None): + """Вызвать LLM и вернуть текст ответа (использует completions для скорости).""" + if temperature is None: + temperature = LLM_TEMPERATURE + # Completions endpoint — быстрее (без thinking на Qwen3.6) + prompt = f"<|im_start|>system\n{system}<|im_end|>\n<|im_start|>user\n{user}<|im_end|>\n<|im_start|>assistant\n" + payload = { + "prompt": prompt, + "temperature": temperature, + "max_tokens": max_tokens, + "stop": ["<|im_end|>", "<|im_start|>"], + "stream": False, + } + # Используем completions endpoint (быстрее без thinking) + completions_url = LLM_URL.replace("/chat/completions", "/completions") + try: + resp = requests.post(completions_url, json=payload, timeout=300) + resp.raise_for_status() + data = resp.json() + text = data["choices"][0]["text"].strip() + # Убираем thinking-теги если есть + import re + text = re.sub(r'.*?', '', text, flags=re.DOTALL).strip() + if not text: + log("LLM error: empty response (all thinking, no content)") + return None + return text + except requests.exceptions.Timeout: + log("LLM error: timeout after 300s") + return None + except requests.exceptions.HTTPError as e: + log(f"LLM error: HTTP {resp.status_code} — {resp.text[:300]}") + return None + except (KeyError, json.JSONDecodeError) as e: + log(f"LLM error: parse failed — {e}") + log(f"Response preview: {resp.text[:500]}") + return None + except Exception as e: + log(f"LLM error: {type(e).__name__}: {e}") + return None + +async def searxng_search(session, query, lang="ru", category="general", pageno=1): + """Поиск через SearXNG (async). Возвращает список результатов.""" + params = { + "q": query, + "format": "json", + "categories": category, + "language": lang, + "pageno": pageno, + } + # Для русского языка — только Yandex (лучший RU-индекс, меньше WAF) + if lang == "ru": + params["engines"] = "yandex" + else: + params["engines"] = "duckduckgo,bing,google,startpage,qwant" + headers = build_headers() + try: + async with session.get( + SEARXNG_URL, params=params, headers=headers, timeout=aiohttp.ClientTimeout(total=30) + ) as resp: + resp.raise_for_status() + data = await resp.json() + results = data.get("results", []) + # фильтр: только с нормальными URL + valid = filter_valid_urls(results)[:MAX_RESULTS_PER_QUERY] + except Exception as e: + log(f"SearXNG error: {e}") + return [] + return valid + + +async def fetch_article_text(session, url, use_cache=True, browser_fetcher=None, obscura_fetcher=None): + """Скачать и извлечь чистый текст статьи (async). + Приоритет: кэш → circuit breaker → rate limiter → aiohttp (с retry) → curl → obscura (JS) → browser (Playwright) → Google Cache. + """ + # 0. Проверить кэш + if use_cache: + cached = _cache_get(url) + if cached: + return cached + + domain = urllib.parse.urlparse(url).hostname + + # 1. Circuit breaker — пропустить проблемный домен + if _circuit_breaker.is_open(domain): + log(f" ⚠ SKIP {domain} (circuit breaker open)") + return None + + # 1.5 Проверить robots.txt (если не отключено) + if not IGNORE_ROBOTS: + disallowed = await _check_robots(session, domain) + if not _is_url_allowed(url, disallowed): + log(f" ⚠ SKIP {domain} (robots.txt disallow: {urllib.parse.urlparse(url).path})") + return None + + # 2. Rate limiting с jitter + await _limiter.acquire(domain) + + headers = build_headers(referer="https://www.google.com/") + + # 2.5 Определить, нужен ли прокси для этого домена + use_proxy = _нужен_прокси(url) + + # 3. aiohttp с экспоненциальным backoff (до 3 попыток) + last_error = None + for attempt in range(3): + try: + if use_proxy: + # Используем hysteria2 SOCKS5 для заблокированных доменов + from aiohttp_socks import ProxyConnector + proxy_connector = ProxyConnector.from_url(HYSTERIA2_PROXY) + async with session.get( + url, headers=headers, timeout=aiohttp.ClientTimeout(total=30), + connector=proxy_connector + ) as resp: + if resp.status == 429: + wait = 2 ** attempt + random.uniform(0, 1) + log(f" ⚠ 429 from {domain}, waiting {wait:.1f}s (attempt {attempt+1}/3)") + await asyncio.sleep(wait) + continue + if resp.status == 401 or resp.status == 403: + _circuit_breaker.record_failure(domain) + _record_result(domain, "blocked") + log(f" ✗ {resp.status} from {domain} (failures: {_circuit_breaker._failures[domain]})") + return None + resp.raise_for_status() + html = await resp.text() + waf = detect_waf_protection(html) + if waf: + log(f" ✗ {domain} protected by {waf}, skipping") + _record_result(domain, "blocked") + return None + else: + async with session.get( + url, headers=headers, timeout=aiohttp.ClientTimeout(total=15) + ) as resp: + if resp.status == 429: + wait = 2 ** attempt + random.uniform(0, 1) + log(f" ⚠ 429 from {domain}, waiting {wait:.1f}s (attempt {attempt+1}/3)") + await asyncio.sleep(wait) + continue + if resp.status == 401 or resp.status == 403: + _circuit_breaker.record_failure(domain) + _record_result(domain, "blocked") + log(f" ✗ {resp.status} from {domain} (failures: {_circuit_breaker._failures[domain]})") + return None + resp.raise_for_status() + html = await resp.text() + waf = detect_waf_protection(html) + if waf: + log(f" ✗ {domain} protected by {waf}, skipping") + _record_result(domain, "blocked") + return None + text = _extract_text(html) + if text: + _cache_put(url, text) + _circuit_breaker.record_success(domain) + _record_result(domain, "ok") + return text + # Текст извлёкся, но меньше 300 символов — не считаем успехом + _record_result(domain, "ok") + return None + except aiohttp.ClientTimeout: + last_error = "timeout" + _record_result(domain, "timeout") + _circuit_breaker.record_failure(domain) + if attempt < 2: + wait = 2 ** attempt + random.uniform(0, 1) + log(f" ⚠ timeout from {domain}, waiting {wait:.1f}s (attempt {attempt+1}/3)") + await asyncio.sleep(wait) + except aiohttp.ClientError as e: + last_error = str(e)[:100] + _circuit_breaker.record_failure(domain) + if attempt < 2: + wait = 2 ** attempt + random.uniform(0, 1) + await asyncio.sleep(wait) + except Exception as e: + last_error = str(e)[:100] + break # неожиданная ошибка — не ретраить + + # 4. Curl fallback (один раз, если aiohttp не сработал) + try: + curl_cmd = ["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"] + if use_proxy: + # Через hysteria2 SOCKS5 + socks_addr = HYSTERIA2_PROXY.replace("socks5://", "") + curl_cmd.extend(["--socks5-hostname", socks_addr, "--max-time", "30"]) + result = await asyncio.to_thread( + subprocess.run, curl_cmd, capture_output=True, text=True, timeout=30 + ) + if result.returncode == 0 and len(result.stdout) >= 300: + # Проверим на WAF до парсинга + waf = detect_waf_protection(result.stdout) + if waf: + log(f" ✗ {domain} protected by {waf} (curl), skipping browser") + _record_result(domain, "blocked") + return None + text = _extract_text(result.stdout) + if text: + _cache_put(url, text) + _circuit_breaker.record_success(domain) + _record_result(domain, "ok") + return text + # Если curl вернул WAF-страницу или мало текста — не будем тратить browser на капчу + if result.returncode == 0 and len(result.stdout) < 5000: + waf = detect_waf_protection(result.stdout) + if waf: + log(f" ✗ {domain} protected by {waf} (short page), skipping browser") + _record_result(domain, "blocked") + return None + except Exception: + pass + + # 5. Obscura (JS-рендер, Markdown) — быстрый Rust-browser fallback + if obscura_fetcher: + try: + log(f" 🔍 Trying Obscura for {domain}...") + text = await obscura_fetcher.fetch(url) + if text: + _cache_put(url, text) + _circuit_breaker.record_success(domain) + _record_result(domain, "ok") + return text + except Exception as e: + log(f" ✗ Obscura fetch failed: {type(e).__name__}: {str(e)[:80]}") + + # 6. Browser fallback (Playwright) — для JS-сайтов и заблокированных + if browser_fetcher: + try: + log(f" 🔍 Trying Playwright browser for {domain}...") + text = await browser_fetcher.fetch(url) + if text: + _cache_put(url, text) + _circuit_breaker.record_success(domain) + _record_result(domain, "ok") + return text + except Exception as e: + log(f" ✗ Browser fallback failed: {type(e).__name__}: {str(e)[:80]}") + + # 7. Google Cache fallback — для WAF-защищённых страниц + try: + cache_url = f"https://webcache.googleusercontent.com/search?q=cache:{url}" + headers = build_headers(referer="https://www.google.com/") + async with session.get(cache_url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp: + if resp.status == 200: + html = await resp.text() + text = _extract_text(html) + if text and len(text) >= 500: + log(f" ✓ Cache: {domain}") + _cache_put(url, text) + _circuit_breaker.record_success(domain) + _record_result(domain, "ok") + return text + except Exception: + pass + + _record_result(domain, "blocked") + return None + + +def _extract_text(html): + """Извлечь чистый текст из HTML через BeautifulSoup.""" + try: + soup = BeautifulSoup(html, "html.parser") + for tag in soup(["script", "style", "nav", "footer", "header"]): + tag.decompose() + text = soup.get_text(separator=" ") + text = re.sub(r"\s+", " ", text).strip() + if len(text) < 300: + return None + return text[:MAX_ARTICLE_CHARS] + except Exception: + return None + + +def generate_search_queries(topic, lang="ru"): + """Фаза 1: сгенерировать 3-4 поисковых запроса по теме.""" + system = ( + "Ты — аналитик, который подбирает поисковые запросы для глубокого исследования темы. " + "Верни ТОЛЬКО список запросов, каждый на отдельной строке, без нумерации." + ) + lang_hint = "на русском языке" if lang == "ru" else "in English" + prompt = ( + f"Тема: {topic}\n\n" + f"Составь 4 различных поисковых запроса {lang_hint}, которые раскрывают тему с разных сторон. " + f"Запросы должны покрывать: основные факты, свежие новости/события, " + f"ключевые персоны/организации, смежные темы.\n" + f"Только запросы, каждый на новой строке." + ) + result = llm_call(system, prompt) + if not result: + return [topic] + queries = [q.strip().strip('"').strip("'") for q in result.split("\n") if q.strip()] + # Убрать нумерацию + queries_clean = [] + for q in queries: + q = re.sub(r"^\d+[\.\)]\s*", "", q).strip() + if q: + queries_clean.append(q) + return queries_clean[:4] + + +async def collect_articles(session, queries, lang="ru"): + """Собрать результаты по всем запросам ПАРАЛЛЕЛЬНО. Возвращает список словарей.""" + all_results = [] + seen_urls = set() + + tasks = [searxng_search(session, q, lang) for q in queries] + results_per_query = await asyncio.gather(*tasks, return_exceptions=True) + + for query, result in zip(queries, results_per_query): + if isinstance(result, Exception): + log(f" ✗ {query[:60]}: {type(result).__name__}: {result}") + continue + log(f" ✓ {query[:60]}: {len(result)} результатов") + for r in result: + url = r.get("url", "") + if url in seen_urls: + continue + seen_urls.add(url) + all_results.append({ + "title": r.get("title", "?"), + "url": url, + "snippet": r.get("content", ""), + "engine": r.get("engine", "?"), + }) + + return all_results + + +async def read_articles(session, results, limit=MAX_ARTICLES_PER_PHASE, browser_fetcher=None, obscura_fetcher=None): + """Прочитать полный текст топ-N статей ПАРАЛЛЕЛЬНО.""" + articles = [] + to_read = results[:limit] + + if not to_read: + return articles + + tasks = [fetch_article_text(session, r["url"], browser_fetcher=browser_fetcher, obscura_fetcher=obscura_fetcher) for r in to_read] + texts = await asyncio.gather(*tasks, return_exceptions=True) + + for i, (r, text) in enumerate(zip(to_read, texts)): + if isinstance(text, Exception) or text is None: + log(f" ✗ {r['title'][:60]}: не удалось прочитать") + else: + SOURCE_COUNTER[0] += 1 + n = SOURCE_COUNTER[0] + articles.append({ + "ref": n, + "title": r["title"], + "url": r["url"], + "text": text, + }) + log(f" ✓ [{n}] {r['title'][:60]}") + + # Сохраняем порядок по ref + articles.sort(key=lambda a: a["ref"]) + return articles + + +def analyze_gaps(topic, articles_text, lang="ru"): + """Фаза 4: LLM анализирует gaps и генерирует follow-up вопросы.""" + system = ( + "Ты — исследователь. Ты прочитал несколько статей по теме. " + "Проанализируй gaps: какие аспекты не раскрыты, какие вопросы остались без ответа. " + "Верни ТОЛЬКО 2-3 follow-up поисковых запроса, каждый на новой строке." + ) + lang_hint = "на русском языке" if lang == "ru" else "in English" + prompt = ( + f"Тема: {topic}\n\n" + f"Прочитанные статьи (кратко):\n{articles_text[:3000]}\n\n" + f"Какие аспекты темы не раскрыты? Сформулируй 2-3 follow-up поисковых запроса " + f"{lang_hint}, чтобы заполнить пробелы. Только запросы, каждый на новой строке." + ) + result = llm_call(system, prompt) + if not result: + return [] + queries = [q.strip().strip('"').strip("'") for q in result.split("\n") if q.strip()] + queries_clean = [] + for q in queries: + q = re.sub(r"^\d+[\.\)]\s*", "", q).strip() + if q: + queries_clean.append(q) + return queries_clean[:3] + + +def synthesize_report(topic, all_articles, lang="ru"): + """Фаза 6: финальный LLM-синтез → structured report.""" + # Подготовка контекста из статей + articles_ctx = "\n\n".join( + f"[{a['ref']}] {a['title']}\nURL: {a['url']}\n{_truncate(a['text'], 3000)}" + for a in all_articles + ) + + system = ( + "Ты — аналитик. Составь структурированный отчёт по результатам исследования.\n" + "Формат отчёта СТРОГО:\n\n" + "## [Заголовок темы]\n\n" + "**Ключевой вывод:** [1-2 предложения]\n\n" + "### Обзор\n[Синтез с цитатами [1][2][3]...]\n\n" + "### [Подтема 1]\n[Детали с цитатами [N]]\n\n" + "### [Подтема 2]\n[Детали с цитатами [N]]\n\n" + "### Дополнительные вопросы\n- [Вопрос 1]?\n- [Вопрос 2]?\n\n" + "### Источники\n[1] Заголовок — URL\n[2] Заголовок — URL\n..." + ) + + lang_instruction = ( + "Отчёт на русском языке. Все inline-цитаты в формате [N]. " + "Каждый абзац содержит минимум одну цитату. " + "Не используй фразы 'по данным', 'согласно' — только [N]." + if lang == "ru" + else "Report in English. All inline citations as [N]. " + "Every paragraph has at least one citation. " + "Don't use phrases like 'according to' — only [N]." + ) + + prompt = ( + f"Тема исследования: {topic}\n{lang_instruction}\n\n" + f"Прочитанные источники:\n\n{articles_ctx}\n\n" + f"Составь структурированный отчёт. Все факты подкрепляй цитатами [N]." + ) + + report = llm_call(system, prompt, max_tokens=4096, temperature=0.2) + if not report: + report = "# Ошибка синтеза отчёта\n\nНе удалось сгенерировать отчёт." + + # Формируем полный блок источников из реальных данных — всегда, + # не полагаемся на LLM (локальные модели часто теряют URL). + source_lines = [] + for a in all_articles: + source_lines.append(f"[{a['ref']}] {a['title']} — {a['url']}") + + # Удаляем возможный кривой блок источников от LLM и вставляем правильный + report = re.sub(r'\n###?\s*(?:Источники|Sources).*', '', report, flags=re.DOTALL) + report += "\n\n### Источники\n" + "\n".join(source_lines) + + return report + + +def _truncate(text, max_chars): + if len(text) <= max_chars: + return text + return text[:max_chars] + "..." + + +# ─── Main ──────────────────────────────────────────────────────────────────── + + +async def main_async(): + parser = argparse.ArgumentParser( + description="Deep Research — многошаговое исследование как Perplexity" + ) + parser.add_argument("topic", 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("--output", "-o", help="Сохранить отчёт в файл") + parser.add_argument("--concurrency", type=int, default=10, + help="Лимит одновременных HTTP-запросов (по умолчанию 10)") + parser.add_argument("--ignore-robots", action="store_true", + help="Игнорировать robots.txt при загрузке страниц") + args = parser.parse_args() + + if args.model: + global LLM_MODEL + LLM_MODEL = args.model + if args.temperature is not None: + global LLM_TEMPERATURE + LLM_TEMPERATURE = args.temperature + + if args.ignore_robots: + global IGNORE_ROBOTS + IGNORE_ROBOTS = True + + topic = args.topic + lang = args.lang + log(f"Deep Research: {topic} (lang={lang})") + start_time = time.time() + + # Fetchers — ленивый запуск при первой необходимости + browser_fetcher = BrowserFetcher() + obscura_fetcher = ObscuraFetcher() + + # Создаём одну сессию на всё исследование — connection pooling + connector = aiohttp.TCPConnector(limit=args.concurrency, ttl_dns_cache=300) + async with aiohttp.ClientSession(connector=connector) as session: + + # ─── Фаза 1: генерация запросов ─────────────────────────────────────── + log("Фаза 1: генерация поисковых запросов...") + queries = generate_search_queries(topic, lang) + if not queries: + queries = [topic] + log(f" Запросы: {queries}") + + # ─── Фаза 2: первичный сбор ──────────────────────────────────────────── + log(f"Фаза 2: сбор результатов по {len(queries)} запросам...") + results = await collect_articles(session, queries, lang) + log(f" Найдено {len(results)} уникальных результатов") + + # ─── Фаза 3: чтение статей ───────────────────────────────────────────── + log(f"Фаза 3: чтение статей (топ {MAX_ARTICLES_PER_PHASE})...") + all_articles = await read_articles(session, results, MAX_ARTICLES_PER_PHASE, + browser_fetcher=browser_fetcher, obscura_fetcher=obscura_fetcher) + log(f" Прочитано {len(all_articles)} статей") + + # ─── Фаза 4: анализ gaps и follow-up ─────────────────────────────────── + if all_articles: + articles_summary = "\n\n".join( + f"[{a['ref']}] {a['title']}: {_truncate(a['text'], 500)}" + for a in all_articles + ) + log("Фаза 4: анализ gaps и генерация follow-up...") + followup_queries = analyze_gaps(topic, articles_summary, lang) + if followup_queries: + log(f" Follow-up: {followup_queries}") + + # ─── Фаза 5: второй раунд поиска ──────────────────────────────── + log("Фаза 5: второй раунд поиска...") + more_results = await collect_articles(session, followup_queries, lang) + log(f" Найдено ещё {len(more_results)} результатов") + + # Читаем ещё 3 статьи + more_articles = await read_articles(session, more_results, 3, + browser_fetcher=browser_fetcher, obscura_fetcher=obscura_fetcher) + all_articles.extend(more_articles) + log(f" Всего прочитано: {len(all_articles)} статей") + + # ─── Фаза 6: финальный синтез ────────────────────────────────────────── + log("Фаза 6: синтез отчёта...") + report = synthesize_report(topic, all_articles, lang) + + # ─── Валидация отчёта ──────────────────────────────────────────────── + log("Проверка качества отчёта...") + ok, errors = validate_report(report, all_articles) + if not ok: + log(f" ⚠ Найдено проблем: {len(errors)}") + for e in errors: + log(f" - {e}") + # Пробуем переспросить LLM с конкретными ошибками + if all_articles: + fix_prompt = build_fix_prompt(report, errors, all_articles) + fixed_report = llm_call( + "Ты — редактор. Исправь отчёт по замечаниям. Верни ПОЛНЫЙ исправленный отчёт.", + fix_prompt, + max_tokens=4096, + temperature=0.1, + ) + if fixed_report: + # Перепроверяем + ok2, errors2 = validate_report(fixed_report, all_articles) + if ok2 or len(errors2) < len(errors): + report = fixed_report + log(" ✓ Отчёт исправлен") + else: + log(f" ⚠ Исправление не помогло: {len(errors2)} проблем осталось") + else: + log(" ⚠ LLM не ответил на запрос исправления") + else: + log(" ✓ Отчёт прошёл валидацию") + + elapsed = time.time() - start_time + report += f"\n\n---\n*Исследование выполнено за {elapsed:.0f} сек, прочитано {len(all_articles)} источников*" + + # Статистика и обслуживание + dump_stats() + _cache_cleanup_if_needed() + + # Закрыть браузер если был запущен + if browser_fetcher: + await browser_fetcher.close() + + if args.output: + with open(args.output, "w", encoding="utf-8") as f: + f.write(report) + log(f"Отчёт сохранён: {args.output}") + else: + print("\n" + report) + + +if __name__ == "__main__": + asyncio.run(main_async()) diff --git a/scripts/perplexity_search.py b/scripts/perplexity_search.py old mode 100644 new mode 100755 index e69de29..5b55d80 --- a/scripts/perplexity_search.py +++ b/scripts/perplexity_search.py @@ -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() diff --git a/scripts/research_llm_utils.py b/scripts/research_llm_utils.py index e69de29..e590b80 100644 --- a/scripts/research_llm_utils.py +++ b/scripts/research_llm_utils.py @@ -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 diff --git a/scripts/research_validator.py b/scripts/research_validator.py old mode 100644 new mode 100755 index e69de29..8b471d4 --- a/scripts/research_validator.py +++ b/scripts/research_validator.py @@ -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 [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) diff --git a/scripts/youtube_analyzer.py b/scripts/youtube_analyzer.py index e69de29..b3c6371 100644 --- a/scripts/youtube_analyzer.py +++ b/scripts/youtube_analyzer.py @@ -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"([^<]+)", 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() diff --git a/tools.py b/tools.py index e69de29..35ebc5e 100644 --- a/tools.py +++ b/tools.py @@ -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)