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