Deep Research MCP Server
README.md
# Deep Research MCP Server 🔎
MCP-сервер глубокого поиска. Подключается к **Open WebUI** (и любому MCP-клиенту) и
позволяет запускать полноценный deep-research режим: итеративный веб-поиск через
**SearXNG** + парсинг страниц через **Crawl4AI** + синтез финального ответа через LLM.
Архитектура и логика итеративного research-цикла портированы с
[Vane](https://github.com/ItzCrazyKns/Vane) (TypeScript) на Python.
---
## 🏗️ Архитектура
```
┌─────────────────────┐
│ Open WebUI │ ← фронтенд, видит стрим прогресса
│ (MCP-клиент) │
└──────────┬──────────┘
│ MCP (streamable-http / SSE)
▼
┌─────────────────────┐
│ Deep Research MCP │ ← этот сервер
│ server.py │
└──────────┬──────────┘
│
┌──────┴──────┐
▼ ▼
┌────────┐ ┌─────────┐
│SearXNG │ │Crawl4AI │ ← внешние сервисы
│ поиск │ │ парсинг │
└────────┘ └─────────┘
│
▼
┌────────┐
│ LLM │ ← OpenAI-совместимый endpoint
└────────┘ (Ollama / Open WebUI / OpenAI)
```
## ✨ Что умеет
- 🔍 **`deep_research(query, mode)`** — главный тулчейн:
- итеративный цикл планирование → поиск → парсинг → синтез (как в Vane)
- режимы `speed` (2 итерации), `balanced` (6), `quality` (25)
- **LLM Source Planner** — перед стартом цикла отдельный LLM-вызов классифицирует
запрос (`social` / `academic` / `news` / `videos` / `general` / `all`) и подбирает
релевантные категории SearXNG + адаптивную политику реранкинга.
- **Query Rephraser** — каждый длинный пользовательский запрос переформулируется
через LLM в SEO-friendly ключевые слова (порт `webSearchRetrieverPrompt` из Vane).
- **стримит в UI все шаги**: план, поисковые запросы, найденные URL, чтение страниц, чанки финального ответа
- 🌐 **`web_search(query)`** — одиночный запрос в SearXNG (для отладки)
- 📄 **`scrape_url(url)`** — одиночный парсинг страницы через Crawl4AI
- 🧭 **Роутинг моделей** — отдельные `planner` / `worker` модели (сильная для планирования
и синтеза, лёгкая для извлечения фактов); опционально worker может ходить на другой
endpoint (например, локальный Ollama) — `LLM_PLANNER_MODEL` / `LLM_WORKER_MODEL`.
## 🚀 Быстрый старт
### 1. Локально (для разработки)
```bash
# Клонируем и заходим
cd Deep_Research
# Создаём venv
python3.11 -m venv .venv
source .venv/bin/activate
# Зависимости
pip install -r requirements.txt
# Конфиг
cp .env.example .env
# отредактируй .env: SEARXNG_URL, LLM_BASE_URL, LLM_MODEL, ...
# Запуск
python -m deep_research
```
Сервер поднимется на `http://localhost:8765` (по умолчанию) и начнёт слушать
MCP-транспорт `streamable-http`. Точка входа для клиентов:
- `http://localhost:8765/mcp` — MCP-over-HTTP
### 2. В Docker
```bash
docker compose up --build
```
Поднимаются два контейнера:
- `searxng` — на `http://localhost:8888` (UI) и `http://searxng:8080` (API)
- `deep-research-mcp` — на `http://localhost:8765/mcp`
## 🔌 Подключение к Open WebUI
### Где в UI
1. Кликни по **аватару / имени пользователя** (правый верхний угол) → **Settings**.
2. В левом меню выбери раздел **Tools** (он же «Инструменты» в русской локали).
3. Справа увидишь блок **«+ Add MCP Server»** — жми туда.
В Open WebUI >= 0.5 нативная поддержка MCP встроена, ничего дополнительно ставить не нужно.
### Какую строку вписать в поле URL
Это самое важное — зависит от того, **где крутится Open WebUI** относительно твоего
контейнера `deep-research-mcp`. Конечная точка у нас всегда одна:
```
http://<адрес_контейнера>:8765/mcp
```
Адрес зависит от сценария:
| Сценарий | URL для подключения |
|---|---|
| OW и MCP на **одном хосте, оба в Docker, одна сеть** | `http://deep-research-mcp:8765/mcp` (имя сервиса) |
| OW локально (без Docker), MCP в Docker на той же машине | `http://localhost:8765/mcp` |
| OW в Docker, MCP на хосте (или в отдельном контейнере без общей сети) | `http://host.docker.internal:8765/mcp` |
| **Удалённый сервер/VPS**: OW и MCP на разных машинах | `http://<публичный_IP_или_домен>:8765/mcp` |
| За reverse-proxy с TLS (nginx/Caddy/Traefik) | `https://<домен>/mcp` |
> ⚠️ Порт `8765/tcp` должен быть **открыт в файрволе** на хосте, где крутится MCP.
> В `docker-compose.yml` у нас он уже проброшен: `ports: "8765:8765"`.
### Остальные поля формы
| Поле | Значение |
|---|---|
| **Name** | `Deep Research` (любое понятное имя) |
| **Type / Transport** | `Streamable HTTP` — соответствует нашему MCP-транспорту |
| **Authentication** | `None` — для локального/частного использования |
### Активация
После добавления:
- в списке тулзов должна появиться строка `Deep Research` → поставь галочку **Enable**;
- по желанию включи **«Show in Model Selector»**, чтобы тул был виден в селекторе модели.
### Проверка в чате
Открой новый чат, выбери ту LLM, которая указана у тебя в `.env` как `LLM_MODEL`
(например, `qwen2.5:7b`), и спроси:
> «Используй deep_research и расскажи про квантовые компьютеры в 2026 году»
Если всё ок, в UI будет стрим прогресса:
> 🔍 Plan: «Okay, the user wants to know about …»
> 🌐 Searching for: ["renewable energy 2025", "solar panel efficiency 2025"]
> 📄 Reading: https://example.com/report
> ✍️ Synthesizing answer…
### Если что-то не работает — чеклист
1. **Не резолвится URL.** С машины, где крутится OW, выполни:
```bash
curl -i http://<адрес>:8765/mcp
```
Должен прийти HTTP-ответ (не `Connection refused`, не `timeout`).
2. **Тул не вызывается моделью.** Включи в **Admin Panel → Settings → Models** → выбранная
модель → раздел **Capabilities** — должна быть галка **Tool Calling**.
3. **Нет стриминга прогресса, только финальный ответ.** Убедись, что в Settings → Tools →
MCP Servers выбран тип **Streamable HTTP**, а не устаревший `/sse`.
4. **`421 Misdirected Request`.** Мы уже включили middleware в `server.py`, который
переписывает Host-заголовок — если всё равно возникает, проверь, что контейнер
запущен из свежего образа (`docker compose pull && docker compose up -d`).
5. **OW видит тул, но без описания.** Проверь раздел Logs в OW — обычно там видно,
прошёл ли MCP-handshake. На нашей стороне смотри `docker logs deep-research-mcp`.
## ⚙️ Конфигурация (.env)
Все параметры читаются из переменных окружения. **Если переменная не задана,
используются эффективные встроенные дефолты** — так что сервер работает
качественно даже с пустым `.env`.
| Переменная | Дефолт | Описание |
|---|---|---|
| `SEARXNG_URL` | `http://searxng:8080` | URL SearXNG |
| `SEARXNG_LANGUAGE` | `ru` | Язык поиска (можно `en`, `en-all`) |
| `SEARXNG_CATEGORIES` | `general` | Дефолтные SearXNG-категории (используются, если LLM-планировщик не сработал). Допустимые: `general news science social videos images files music it map` |
| `SEARXNG_ENGINES` | `google,bing,duckduckgo` | Список движков |
| `SEARXNG_SAFESEARCH` | `0` | 0/1/2 |
| `LLM_BASE_URL` | `http://localhost:11434/v1` | OpenAI-совместимый endpoint |
| `LLM_API_KEY` | `ollama` | API-ключ |
| `LLM_MODEL` | `qwen2.5:7b` | Базовая модель (используется обеими ролями, если роли не заданы) |
| `LLM_PLANNER_MODEL` | *(пусто → `LLM_MODEL`)* | **Сильная** модель для планирования источников и финального синтеза |
| `LLM_WORKER_MODEL` | *(пусто → `LLM_MODEL`)* | **Слабая/дешёвая** модель для извлечения фактов из чанков |
| `LLM_WORKER_BASE_URL` | *(пусто → `LLM_BASE_URL`)* | Опц. отдельный endpoint для worker'а (например, локальный Ollama с 3B-моделью) |
| `LLM_WORKER_API_KEY` | *(пусто → `LLM_API_KEY`)* | Опц. отдельный API-ключ для worker'а |
| `MCP_HOST` | `0.0.0.0` | Хост MCP-сервера |
| `MCP_PORT` | `8765` | Порт |
| `MAX_ITERATIONS_SPEED/BALANCED/QUALITY` | `2 / 6 / 25` | Лимиты итераций по режимам |
| `MAX_PARALLEL_CRAWLS` | `5` | Одновременных парсингов |
| `MAX_RESULTS_PER_QUERY` | `10` | Сколько просить у SearXNG на запрос |
| `CRAWL_TIMEOUT_SEC` | `60` | Таймаут парсинга URL |
| `MIN_RESULT_SCORE` | `0.0` | Фильтр по SearXNG-score. 0 = не фильтровать |
| `RESULTS_TOP_K_PER_QUERY` | `10` | Сколько URL оставлять на запрос после реранкинга |
| `DOMAIN_BOOST_THRESHOLD` | `2` | Буст домена, если он встретился по ≥N запросам |
| `BLOCKED_DOMAINS` | *(пусто)* | Список доменов под безусловный отсев (opt-in) |
| `PRIORITY_DOMAINS` | *(пусто)* | Список доменов-экспертов, +100 к рангу (opt-in) |
| `SOCIAL_DOMAINS` | 12 источников | Домены соцсетей — поднимаются в режиме «ищи в социальных сетях» |
| `ACADEMIC_DOMAINS` | 29 источников | Домены научных/академических источников — для режима «факт-чек» |
| `NEWS_DOMAINS` | 36 источников | Домены мировых СМИ — для режима «новости/статьи» |
| `INTENT_DETECTION` | `true` | Включён ли детектор намерений в запросе пользователя |
### 🧭 Адаптивные режимы поиска
Researcher автоматически распознаёт намерение пользователя по тексту запроса
и переключает режим реранкинга. Никаких жёстких блокировок «из коробки» — только
**мягкие приоритеты**: попавшие в приоритет домены поднимаются в топ, остальные
не отрезаются.
| Что пишет пользователь | Распознанный режим | Что происходит |
|---|---|---|
| «ищи в социальных сетях / vk / reddit / twitter» | `social` | Соцсети поднимаются в топ |
| «научное подтверждение / факт-чек / peer-reviewed / arxiv» | `academic` | Академические домены в топе |
| «новости / статьи в СМИ / press» | `news` | СМИ в топе |
| «ищи всё / включи все / без фильтров» | `all` | Никаких приоритетов и блокировок |
| обычный запрос без подсказок | `neutral` | Только то, что задано в `PRIORITY_DOMAINS`/`BLOCKED_DOMAINS` |
Детектор использует встроенные RU+EN-ключевики (расширяются через
`INTENT_KEYWORDS_SOCIAL`, `INTENT_KEYWORDS_ACADEMIC` и т.п.).
Если `INTENT_DETECTION=false` — режим всегда `neutral`.
### 🔎 Как работает реранкинг
Мы не блокируем домены по умолчанию. Что работает из коробки:
1. **Глобальная дедупликация по URL** — один URL не повторяется между запросами и итерациями.
2. **Min-score фильтр** (опционально) — только если задать `MIN_RESULT_SCORE > 0`.
3. **Domain-boost** — домен, встретившийся по ≥`DOMAIN_BOOST_THRESHOLD` разным запросам,
получает +10·hits к рангу.
4. **Тонкая настройка через ENV** — `BLOCKED_DOMAINS` и `PRIORITY_DOMAINS` (opt-in).
5. **Top-K на запрос** — после реранкинга оставляем `RESULTS_TOP_K_PER_QUERY` URL.
6. **Адаптивный режим** — если пользователь сказал «ищи в X», соответствующие
домены получают +100 к рангу (остальные не трогаются).
Адаптивные списки **`SOCIAL_DOMAINS` / `ACADEMIC_DOMAINS` / `NEWS_DOMAINS`
уже предзаполнены** авторитетными источниками мирового уровня (12 / 29 / 36
доменов соответственно) — см. `src/deep_research/config.py`.
Их критерии: скорость поступления информации, качество журналистики /
peer-review, охват и доверие аудитории. Например:
* `SOCIAL_DOMAINS` — Twitter/X, Reddit, Facebook, Instagram, LinkedIn,
TikTok, Threads, Mastodon, YouTube, VK, Telegram.
* `ACADEMIC_DOMAINS` — `.edu` (все университеты мира), `arxiv.org`,
`biorxiv.org`, `scholar.google.com`, `nature.com`, `science.org`,
`cell.com`, `thelancet.com`, `nejm.org`, `sciencedirect.com`,
Springer, Wiley, JSTOR, PLOS, Frontiers, MDPI, IEEE, ACM, …
* `NEWS_DOMAINS` — Reuters, AP, AFP, BBC, Guardian, NYT, WaPo, WSJ,
FT, Bloomberg, CNN, Al Jazeera, DW, France 24, Le Monde, El País,
Spiegel, Asahi, SCMP, Straits Times + научпоп-порталы (Nature,
Scientific American, New Scientist, TechCrunch, The Verge, Wired)
+ ведущие русскоязычные СМИ (РИА, ТАСС, РБК, Ведомости, Коммерсантъ,
Интерфакс, Лента, Газета).
Чтобы **заменить** дефолтный набор — просто задайте переменную в `.env`,
она переопределит встроенный список (но **не** сольётся с ним).
Все домены указаны как **суффиксы** (.com/....org) или TLD-префиксы (.edu),
поэтому внутренние поддомены (`m.twitter.com`, `cs.mit.edu`, …) тоже матчатся.
### 🧠 LLM-планирование источников (до старта цикла)
Помимо keyword-детектора по тексту, в начале каждого исследования Researcher
выполняет **один отдельный LLM-вызов** (через planner-модель), который
классифицирует запрос и выбирает SearXNG-категории:
| Intent | SearXNG categories | Когда выбирается |
|---|---|---|
| `social` | `social` | «что обсуждают в твиттере / на реддите / в телеграме» |
| `academic` | `science` (+ `general` для подстраховки) | «научные статьи / факт-чек / peer-reviewed / arxiv» |
| `news` | `news` | «последние новости / пресс-релизы» |
| `videos` | `videos` (+ `general`) | «видео на ютубе / обучающие ролики» |
| `general` | `general` | обычный web-поиск |
| `all` | `general + news + science + social + videos` | «ищи всё» |
План стримится в UI как `plan`-событие и сохраняется в `self._source_plan`,
оттуда попадает в `SearXNGClient.search_many(categories=…)` — поиск сразу
идёт по правильным категориям (а не только по «general»).
При любой ошибке LLM (нет endpoint'а, битый JSON) — fallback на `SourcePlan.default()`
с категорией `general`. Пайплайн не падает.
### 🧬 Модельный роутинг: planner (сильная) / worker (слабая)
В Researcher'е используются **две роли LLM** через `LLMFactory`:
| Роль | Что делает | Требования | Пример модели |
|---|---|---|---|
| **Planner** | • `_plan_sources()` — выбор источника перед циклом<br>• Главный цикл `research()` — каждый ход (preamble + tools)<br>• `_synthesize()` — финальный ответ | Логика, JSON, следование инструкциям | `qwen2.5:14b/32b`, `llama3.1:70b`, `gpt-4o`, `claude-sonnet` |
| **Worker** | • `_extract_facts()` — извлечение фактов из скрапленного контента (chunked) | Быстро, дёшево, JSON | `qwen2.5:3b`, `llama3.2:3b`, `phi3:mini`, `gpt-4o-mini` |
Если роли не заданы в `.env` — обе используют базовый `LLM_MODEL`
(полная обратная совместимость).
Пример конфигурации:
```bash
# Сильная модель — планирование и синтез
LLM_PLANNER_MODEL=qwen2.5:14b
# Слабая модель — извлечение фактов
LLM_WORKER_MODEL=qwen2.5:3b
# Опционально: worker на отдельном endpoint
LLM_WORKER_BASE_URL=http://localhost:11434/v1
LLM_WORKER_API_KEY=ollama
```
`LLMFactory` сам решает — открывать два независимых HTTP-клиента
или переиспользовать один, если конфиги planner и worker совпадают.
### 📜 Полный flow одного исследования (sequence diagram)
Вот что происходит от момента, как ты отправил запрос в Open WebUI,
до момента, как ты увидел финальный ответ со ссылками:
```
Пользователь Open WebUI MCP-сервер LLM (planner) SearXNG LLM (worker) Crawl4AI
│ │ │ │ │ │ │
│ "расскажи про │ │ │ │ │ │
│ квантовые │ │ │ │ │ │
│ компьютеры 2026" │ │ │ │ │ │
├─────────────────►│ deep_research( │ │ │ │ │
│ │ query, mode) │ │ │ │ │
│ ├────────────────────►│ │ │ │ │
│ │ │ │ │ │ │
│ │ │ 0. _plan_sources() │ │ │ │
│ │ ├──────────────────────►│ │ │ │
│ │ │ SOURCE_PLANNER_PROMPT │ │ │ │
│ │ │ + query │ │ │ │
│ │ │ │ JSON: intent, │ │ │
│ │ │ │ categories │ │ │
│ │ │◄──────────────────────┤ │ │ │
│ │ │ self._source_plan = │ │ │ │
│ │ │ SourcePlan(...) │ │ │ │
│ │ │ emit_plan("academic") │ │ │ │
│ │◄───── log ──────────│ │ │ │ │
│ │ "plan: academic │ │ │ │ │
│ │ [science]" │ │ │ │ │
│ │ │ │ │ │ │
│ │ │ 1. Главный цикл │ │ │ │
│ │ │ tools=[preamble, │ │ │ │
│ │ │ web_search, │ │ │ │
│ │ │ scrape_url, │ │ │ │
│ │ │ done] │ │ │ │
│ │ ├──────────────────────►│ │ │ │
│ │ │ system + history │ │ │ │
│ │ │ │ tool_call( │ │ │
│ │ │ │ preamble) │ │ │
│ │ │◄──────────────────────┤ │ │ │
│ │ │ emit_plan("Plan:…") │ │ │ │
│ │◄───── log ──────────│ │ │ │ │
│ │ │ │ │ │ │
│ │ │ tool_call( │ │ │ │
│ │ │ web_search) │ │ │ │
│ │ │◄──────────────────────┤ │ │ │
│ │ │ search_many( │ │ │ │
│ │ │ queries=[...], │ │ │ │
│ │ │ categories=["science"] │ │ │
│ │ ├───────────────────────┼────────────────►│ │ │
│ │ │ │ │ JSON results │ │
│ │ │◄──────────────────────┼─────────────────┤ │ │
│ │ │ rank_score + should_drop (FilterPolicy) │ │ │
│ │ │ emit_search_results │ │ │ │
│ │◄───── log ──────────│ │ │ │ │
│ │ "search_result: │ │ │ │ │
│ │ 5 URLs" │ │ │ │ │
│ │ │ │ │ │ │
│ │ │ tool_call( │ │ │ │
│ │ │ scrape_url) │ │ │ │
│ │ │◄──────────────────────┤ │ │ │
│ │ │ crawl_many(urls) │ │ │ │
│ │ ├───────────────────────┼─────────────────┼──────────────────┼───────────────►│
│ │ │ │ │ │ │ markdown
│ │ │◄──────────────────────┼─────────────────┼──────────────────┼────────────────┤
│ │ │ _extract_facts() ──► WORKER LLM │ │ │
│ │ │ │ │ chunked JSON │ │
│ │ │ │ │◄─────────────────┤ │
│ │ │ emit_read_done(url, facts) │ │ │
│ │◄───── log ──────────│ │ │ │ │
│ │ │ │ │ │ │
│ │ │ tool_call(done) │ │ │ │
│ │ │◄──────────────────────┤ │ │ │
│ │ │ │ │ │ │
│ │ │ 2. _synthesize() ──► PLANNER LLM │ │ │
│ │ │ │ stream=markdown │ │ │
│ │ │◄──────────────────────┤ │ │ │
│ │ │ emit_synthesis_chunk │ │ │ │
│ │◄───── log ──────────│ (стрим чанков ответа) │ │ │ │
│ │ "## Квантовые…" │ │ │ │ │
│ │◄────────────────────│ │ │ │ │
│ │ │ │ │ │ │
│ │ │ emit_done(answer, │ │ │ │
│ │ │ sources) │ │ │ │
│ │◄───── log ──────────│ │ │ │ │
│ │ │ │ │ │ │
│ │ TextContent(markdown+│ │ │ │ │
│ │ sources) │ │ │ │ │
│ │◄────────────────────│ │ │ │ │
│ видит финал │ │ │ │ │ │
│◄─────────────────┤ │ │ │ │ │
```
### 🔍 Куда какой LLM ходит (всё в одном месте)
| Этап | LLM-роль | Модель (если задана) | Что делает |
|---|---|---|---|
| 0. `_plan_sources()` | **Planner** | `LLM_PLANNER_MODEL` | Классифицирует запрос, выбирает SearXNG-категории |
| 1. Главный цикл `research()` | **Planner** | `LLM_PLANNER_MODEL` | Каждый ход: preamble + выбор tools + JSON-валидация |
| 2. `scrape_url` → `_extract_facts` | **Worker** | `LLM_WORKER_MODEL` | Извлечение фактов из чанков (механическая работа) |
| 3. `_synthesize()` | **Planner** | `LLM_PLANNER_MODEL` | Финальный markdown-ответ со ссылками |
Если роли не заданы — **все 4 этапа** идут через `LLM_MODEL` (обратная совместимость).
### 📊 Что увидит пользователь в Open WebUI
После запуска `deep_research` в UI приходит стрим событий (через `ctx.session.send_log_message`):
1. `plan`: *"source plan: academic (science, general) — нужны научные источники"*
2. `plan`: *"Okay, the user wants to know about quantum computers in 2026…"*
3. `search_start`: *queries=["quantum computing 2026", "quantum supremacy recent"]*
4. `search_result`: 5-10 URL с заголовками
5. `read_start`: *urls=[…]*
6. `read_done`: *url + первые 500 символов extracted_facts*
7. `synthesis_chunk`: чанки markdown-ответа (печатаются как пишутся)
8. `done`: финальный ответ + список источников
Все эти шаги прокидываются через MCP-шину (`EventBus` → `send_log_message`), Open WebUI рисует их как «task steps» в чате.
### 🛡️ Поведение при ошибках
| Сценарий | Что произойдёт |
|---|---|
| LLM-планировщик недоступен (endpoint не отвечает) | `_plan_sources()` ловит исключение, `self._source_plan` остаётся дефолтным (`categories=["general"]`). Цикл продолжается как раньше. |
| LLM вернул битый JSON / текст без JSON | `_parse_source_plan()` возвращает `None` → план остаётся дефолтным. |
| LLM вернул невалидные категории/intent | Whitelist-фильтр, fallback на `general` + `categories=["general"]`. |
| Worker-LLM недоступен во время `_extract_facts` | Возвращается исходный chunk текста без извлечения фактов (логируется warning). |
| Planner и worker идентичны по конфигу | `LLMFactory` создаёт **один** HTTP-клиент и переиспользует для обеих ролей — никакого overhead'а. |
| `LLM_PLANNER_MODEL` / `LLM_WORKER_MODEL` пустые | Обе роли используют базовый `LLM_MODEL`. Полная обратная совместимость. |
### 🧪 Что покрыто тестами (87 тестов, все зелёные)
```
tests/test_researcher.py — 1 e2e-тест: цикл plan→search→scrape→done→synthesis
tests/test_llm_factory.py — 13 тестов: factory shared/distinct, ENV-overrides,
SourcePlan parser (strict JSON / markdown fence /
garbage recovery / invalid categories / intent),
worker_endpoint
tests/test_streaming.py — pub/sub EventBus, close-unblocks-subscribers
tests/test_filtering.py — intent detection, FilterPolicy, rank_score, should_drop,
интеграция с Researcher (intgration + planner-arg fix)
tests/test_searxng_client.py — SearXNGClient search/search_many с categories
tests/test_tools.py — CrawlClient
tests/test_prompts.py — 10 тестов: normalize_mode() + get_researcher_system_prompt()
(защита от KeyError на невалидный mode от MCP-клиента)
```
Локально прогоняется так (Python 3.11+):
```bash
python scripts/check_syntax.py # ast-валидация всех .py-файлов
PYTHONPATH=src pytest tests/ -v # 87 тестов
```
В CI (GitHub Actions) гоняется в матрице Python 3.11 + 3.12 + Docker build.
## 📁 Структура проекта
```
Deep_Research/
├── docker-compose.yml # SearXNG + MCP-сервер
├── Dockerfile # образ MCP-сервера
├── requirements.txt
├── pyproject.toml
├── .env.example
├── src/deep_research/
│ ├── server.py # FastMCP-сервер (entrypoint)
│ ├── researcher.py # главный цикл итеративного поиска
│ ├── intent.py # детектор намерений по тексту запроса (RU+EN)
│ ├── filter_policy.py # политики фильтрации/реранкинга по intent
│ ├── llm_client.py # OpenAI-compatible клиент + streaming + tool calls
│ ├── prompts.py # промпты (портированы с Vane)
│ ├── streaming.py # EventBus — стрим событий в UI
│ ├── config.py # конфигурация из .env
│ ├── types.py # dataclasses + tool-схемы
│ └── tools/
│ ├── searxng_client.py # HTTP-клиент SearXNG
│ └── crawl_client.py # Crawl4AI-обёртка + httpx-fallback
└── tests/
├── test_streaming.py
├── test_searxng_client.py
├── test_tools.py
├── test_researcher.py
├── test_filtering.py # intent-детектор + политики + реранкинг + интеграция с Researcher
└── test_prompts.py # normalize_mode + get_researcher_system_prompt (10 тестов)
```
## 🆕 Что нового в v0.4.3 (current)
- **🛡️ Защита от `KeyError: 'comprehensive'`** — MCP-клиенты (например, Open WebUI) иногда
присылали произвольный `mode` (любая строка, не входящая в `speed/balanced/quality`),
и пайплайн падал с непонятным traceback в логах. Теперь:
* В `prompts.py` добавлена `normalize_mode()` с whitelist + fallback на `balanced`.
* `get_researcher_system_prompt()` нормализует mode перед индексом `_MODE_PROMPTS`.
* `Researcher.research()` нормализует на входе и фиксирует валидное значение.
* MCP-тул `deep_research_tool` в `server.py` нормализует mode ещё до старта и пишет
`WARNING: deep_research: unknown mode 'X' from MCP client, using 'balanced'`.
- **🧪 CI стал зелёным** — заодно починен долгоживущий баг в `tests/test_filtering.py`:
5 интеграционных тестов не передавали 4-й позиционный аргумент `planner` в
`Researcher._execute_one` (появился в v0.4.2 вместе с Query Rephraser). Локально:
87 passed.
- **🧹 Гигиена** — `.tmp_*` добавлен в `.gitignore` (чтобы ad-hoc scratch-файлы не попадали
в коммиты).
## 🆕 Что нового в v0.4.2
- **🪄 Query Rephraser** — порт `webSearchRetrieverPrompt` из Vane. Каждый
сгенерированный LLM-агентом поисковый запрос перед отправкой в SearXNG
переформулируется через LLM в SEO-friendly ключевые слова (2-5 слов,
без вопросительных слов и булевых операторов). Управляется через
`QUERY_REPHRASER=true|false` и `MAX_QUERY_LENGTH=120`. На короткие запросы
(<=30 символов, <=5 слов) не действует. При недоступности LLM-rephraser'а —
regex-нормализация как fallback.
- **Новые fewshot-примеры** в `prompts.QUERY_REPHRASER_FEWSHOTS` — 5 примеров
(вопрос → ключевые слова) для стабилизации выхода модели.
## 🆕 Что нового в v0.4.1
- **🕷️ Фикс краулера** — `CrawlClient` теперь корректно переиспользует
`AsyncWebCrawler` между вызовами (раньше каждый `crawl_one()` создавал новый
инстанс, что ломало `async with` semantics). Также добавлена поддержка
`async with` — `CrawlClient` стал полноценным async context manager'ом.
## 🆕 Что нового в v0.4.0
- **🧠 LLM-driven Source Planner** — перед стартом итеративного цикла Researcher
делает **один отдельный LLM-вызов** (через planner-модель), который классифицирует
запрос и подбирает релевантные категории SearXNG + адаптивную политику реранкинга.
Стримится в UI как `plan`-событие, fallback на `general` при ошибках.
- **🧭 Роутинг моделей planner / worker** — `LLMFactory` создаёт **две роли LLM**:
сильную (planner) для планирования и синтеза, лёгкую (worker) для извлечения
фактов. Опционально worker может ходить на отдельный endpoint (например, локальный
Ollama с 3B-моделью). Полная обратная совместимость — если роли не заданы,
обе используют `LLM_MODEL`.
- **🛡️ ASGI-middleware** — обход 421 Misdirected Request от uvicorn при Host-заголовке
вроде `deep-research:8765` или `<внешний_IP>:8765`. Работает на любой версии
uvicorn (не требует `host_header_validation=False` из 0.32+).
- **📜 OpenAPI JSON** — middleware также отдаёт валидный `/openapi.json`,
`/docs` и `/mcp/openapi.json` (минимальный stub), чтобы Open WebUI не получал
404 при авто-детекте.
## 🆕 Что нового в v0.3.0
- **Адаптивная фильтрация по доменам** — Researcher распознаёт намерение пользователя
по тексту запроса (`social` / `academic` / `news` / `all` / `neutral`) и мягко
поднимает нужный тип источников в топ. Никаких автоблоков — другие источники не
отсекаются, только получают меньший ранг. Детектор использует RU+EN ключевые слова,
расширяемые через `INTENT_KEYWORDS_*`.
- **Предзаполненные наборы доменов мирового уровня** —
`SOCIAL_DOMAINS` (12), `ACADEMIC_DOMAINS` (29), `NEWS_DOMAINS` (36). Подобраны
по скорости поступления информации, качеству журналистики/peer-review, охвату и
доверию аудитории. Переопределяются через `.env` целиком (без слияния с дефолтом).
- **Никаких жёстких блокировок по доменам** — убрали авто-блоклист соцсетей.
Хотите отсечь конкретный домен — задайте `BLOCKED_DOMAINS=…` (opt-in).
- **Новые модули**:
* `src/deep_research/intent.py` — детектор намерений по тексту запроса.
* `src/deep_research/filter_policy.py` — политики реранкинга под каждый intent
(`make_policy`, `matches_domain`, `rank_score`, `should_drop`).
- **Расширенные тесты** — `tests/test_filtering.py` покрывает детектор, политики,
матчинг доменов (включая `.edu`-TLD) и интеграцию с Researcher. **52 теста,
все зелёные.**
- **API для MCP/UI** — результат `web_search` теперь содержит поле `policy`
(`intent`, `priority_count`, `blocked_count`), чтобы клиентский UI мог
показать, в каком режиме выполнен поиск.
## 🧬 Портировано с Vane
| Vane (TypeScript) | Deep Research MCP (Python) |
|---|---|
| `lib/agents/search/researcher/index.ts` | `src/deep_research/researcher.py` |
| `…/actions/registry.ts` | inline в researcher.py |
| `…/actions/plan.ts` | промпт `__reasoning_preamble` в `prompts.py` |
| `…/actions/search/webSearch.ts` | `web_search` tool |
| `…/actions/search/baseSearch.ts` | `web_search` tool + дедуп по URL |
| `…/actions/scrapeURL.ts` | `scrape_url` tool + `_extract_facts` |
| `lib/searxng` | `tools/searxng_client.py` |
| `lib/scraper` | `tools/crawl_client.py` (Crawl4AI) |
| `lib/session.emitBlock` | `streaming.EventBus` |
## � CI/CD и деплой
См. **[DEPLOY.md](DEPLOY.md)** — полная инструкция:
- GitHub Actions: тесты + автосборка Docker-образа
- Multi-arch образ (amd64 + arm64) в GHCR
- `./scripts/release.sh patch` → push тега → авторелиз
- `./scripts/deploy.sh v0.1.0 user@server` → деплой одной командой
## �📜 Лицензия
MIT
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues