1180 lines
50 KiB
Python
1180 lines
50 KiB
Python
#!/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.7"))
|
||
|
||
# Игнорировать 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 <url> --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
|
||
|
||
# Извлечь текст из <body> или <article>
|
||
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'<think>.*?</think>', '', 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.7)
|
||
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())
|