bathys 0.6.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bathys/__init__.py +8 -0
- bathys/agents/HARNESS-DROPIN.md +50 -0
- bathys/agents/bathys-researcher.md +91 -0
- bathys/agents/skills/bathys-deep-dive/SKILL.md +55 -0
- bathys/agents/skills/bathys-source-audit/SKILL.md +48 -0
- bathys/batch.py +86 -0
- bathys/cache.py +47 -0
- bathys/compose.yaml +22 -0
- bathys/config.py +71 -0
- bathys/core.py +425 -0
- bathys/crawler.py +104 -0
- bathys/distill.py +135 -0
- bathys/doctor.py +139 -0
- bathys/installer.py +424 -0
- bathys/searx.py +149 -0
- bathys/searxng-settings.yml +13 -0
- bathys/server.py +399 -0
- bathys/services.py +263 -0
- bathys-0.6.1.dist-info/METADATA +147 -0
- bathys-0.6.1.dist-info/RECORD +23 -0
- bathys-0.6.1.dist-info/WHEEL +4 -0
- bathys-0.6.1.dist-info/entry_points.txt +4 -0
- bathys-0.6.1.dist-info/licenses/LICENSE +21 -0
bathys/searx.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""SearXNG client. Expects JSON format enabled on the backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .config import Config
|
|
14
|
+
|
|
15
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
16
|
+
_WS_RE = re.compile(r"\s+")
|
|
17
|
+
_DROP_PARAMS = {
|
|
18
|
+
"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
|
|
19
|
+
"gclid", "fbclid", "ref", "referrer", "irclickid", "irgwc",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SearxError(RuntimeError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def clean_text(s: str, limit: int) -> str:
|
|
28
|
+
s = html.unescape(_TAG_RE.sub(" ", s or ""))
|
|
29
|
+
s = _WS_RE.sub(" ", s).strip()
|
|
30
|
+
if len(s) > limit:
|
|
31
|
+
s = s[: limit - 1].rsplit(" ", 1)[0].rstrip(",;:—-") + "…"
|
|
32
|
+
return s
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalize_url(u: str) -> str:
|
|
36
|
+
try:
|
|
37
|
+
parts = urlsplit(u or "")
|
|
38
|
+
except ValueError:
|
|
39
|
+
return u or ""
|
|
40
|
+
if not parts.scheme:
|
|
41
|
+
return u or ""
|
|
42
|
+
query = [
|
|
43
|
+
(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
|
|
44
|
+
if k.lower() not in _DROP_PARAMS
|
|
45
|
+
]
|
|
46
|
+
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), ""))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class SearchHit:
|
|
51
|
+
title: str
|
|
52
|
+
url: str
|
|
53
|
+
snippet: str
|
|
54
|
+
engines: list[str] = field(default_factory=list)
|
|
55
|
+
score: float = 0.0
|
|
56
|
+
published: str = ""
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def dedupe_key(self) -> str:
|
|
60
|
+
p = urlsplit(self.url)
|
|
61
|
+
return (p.netloc.removeprefix("www.") + p.path.rstrip("/")).lower()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class SearchOutcome:
|
|
66
|
+
query: str
|
|
67
|
+
hits: list[SearchHit]
|
|
68
|
+
answers: list[str]
|
|
69
|
+
suggestions: list[str]
|
|
70
|
+
seconds: float
|
|
71
|
+
raw_chars: int
|
|
72
|
+
unresponsive: list[str] = field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _answer_text(a: object) -> str:
|
|
76
|
+
if isinstance(a, dict):
|
|
77
|
+
return str(a.get("answer") or a.get("title") or "")
|
|
78
|
+
return str(a)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def search(
|
|
82
|
+
cfg: Config,
|
|
83
|
+
http: httpx.AsyncClient,
|
|
84
|
+
query: str,
|
|
85
|
+
*,
|
|
86
|
+
categories: str | None = None,
|
|
87
|
+
engines: str | None = None,
|
|
88
|
+
language: str | None = None,
|
|
89
|
+
time_range: str | None = None,
|
|
90
|
+
safesearch: int = 1,
|
|
91
|
+
) -> SearchOutcome:
|
|
92
|
+
started = time.monotonic()
|
|
93
|
+
params: dict[str, str | int] = {"q": query, "format": "json", "safesearch": safesearch}
|
|
94
|
+
if categories:
|
|
95
|
+
params["categories"] = categories
|
|
96
|
+
if engines:
|
|
97
|
+
params["engines"] = engines
|
|
98
|
+
if language:
|
|
99
|
+
params["language"] = language
|
|
100
|
+
if time_range:
|
|
101
|
+
params["time_range"] = time_range
|
|
102
|
+
try:
|
|
103
|
+
resp = await http.get(cfg.searxng_url + "/search", params=params)
|
|
104
|
+
except httpx.HTTPError as e:
|
|
105
|
+
raise SearxError(
|
|
106
|
+
f"searxng unreachable at {cfg.searxng_url}: {e.__class__.__name__}: {e}"
|
|
107
|
+
) from e
|
|
108
|
+
if resp.status_code != 200:
|
|
109
|
+
hint = ""
|
|
110
|
+
if resp.status_code == 403:
|
|
111
|
+
hint = " — enable JSON in searxng settings: search.formats: [html, json]"
|
|
112
|
+
raise SearxError(f"searxng http {resp.status_code}{hint}")
|
|
113
|
+
|
|
114
|
+
data = resp.json()
|
|
115
|
+
hits: list[SearchHit] = []
|
|
116
|
+
seen: set[str] = set()
|
|
117
|
+
for item in data.get("results", []):
|
|
118
|
+
url = normalize_url(item.get("url", ""))
|
|
119
|
+
if not url or url.startswith(("mailto:", "javascript:")):
|
|
120
|
+
continue
|
|
121
|
+
hit = SearchHit(
|
|
122
|
+
title=clean_text(item.get("title", ""), 140),
|
|
123
|
+
url=url,
|
|
124
|
+
snippet=clean_text(item.get("content", ""), 280),
|
|
125
|
+
engines=[str(e) for e in item.get("engines", [])],
|
|
126
|
+
score=float(item.get("score", 0) or 0),
|
|
127
|
+
published=str(item.get("publishedDate") or "")[:10],
|
|
128
|
+
)
|
|
129
|
+
dk = hit.dedupe_key
|
|
130
|
+
if dk in seen:
|
|
131
|
+
continue
|
|
132
|
+
seen.add(dk)
|
|
133
|
+
hits.append(hit)
|
|
134
|
+
hits.sort(key=lambda h: -h.score)
|
|
135
|
+
unresponsive: list[str] = []
|
|
136
|
+
for item in data.get("unresponsive_engines", []):
|
|
137
|
+
if isinstance(item, (list, tuple)) and item:
|
|
138
|
+
unresponsive.append(f"{item[0]}:{item[1]}" if len(item) > 1 else str(item[0]))
|
|
139
|
+
elif item:
|
|
140
|
+
unresponsive.append(str(item))
|
|
141
|
+
return SearchOutcome(
|
|
142
|
+
query=query,
|
|
143
|
+
hits=hits,
|
|
144
|
+
answers=[clean_text(_answer_text(a), 300) for a in data.get("answers", []) if _answer_text(a)],
|
|
145
|
+
suggestions=[str(s) for s in data.get("suggestions", [])][:6],
|
|
146
|
+
seconds=round(time.monotonic() - started, 2),
|
|
147
|
+
raw_chars=len(resp.text),
|
|
148
|
+
unresponsive=unresponsive,
|
|
149
|
+
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Bathys-managed SearXNG settings: localhost-only, JSON API on, limiter off.
|
|
2
|
+
# Used for both the native subprocess backend and the docker backend.
|
|
3
|
+
use_default_settings: true
|
|
4
|
+
server:
|
|
5
|
+
secret_key: "bathys-local-3f9c2a7e51d84b60"
|
|
6
|
+
bind_address: "127.0.0.1"
|
|
7
|
+
port: 8888
|
|
8
|
+
limiter: false
|
|
9
|
+
image_proxy: false
|
|
10
|
+
search:
|
|
11
|
+
safe_search: 1
|
|
12
|
+
default_lang: "auto"
|
|
13
|
+
formats: ["html", "json", "csv", "rss"]
|
bathys/server.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Bathys — единый локальный поисковый сервис глубокого ресёрча (MCP, stdio).
|
|
2
|
+
|
|
3
|
+
Owns the whole pipeline (search -> extraction -> query distillation -> cache);
|
|
4
|
+
SearXNG and Crawl4AI are swappable internal engines, not the product.
|
|
5
|
+
|
|
6
|
+
Four tools, deliberately small surface:
|
|
7
|
+
deep_research(query) — search + read top sources + query-distilled digest;
|
|
8
|
+
web_search(query) — ranked links only;
|
|
9
|
+
read_url(url, query) — distilled text of one page;
|
|
10
|
+
read_urls(urls, …) — batch read of known URLs under one shared budget.
|
|
11
|
+
|
|
12
|
+
Plus three prompts (bathys_deep_research, bathys_source_audit,
|
|
13
|
+
bathys_fresh_scan) — built-in research strategies the harness can render.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import contextlib
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from mcp.server.fastmcp import Context, FastMCP
|
|
22
|
+
from mcp.types import ToolAnnotations
|
|
23
|
+
|
|
24
|
+
from . import batch
|
|
25
|
+
from .config import Config
|
|
26
|
+
from .core import Engine
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@contextlib.asynccontextmanager
|
|
30
|
+
async def _lifespan(_: FastMCP):
|
|
31
|
+
engine = Engine(Config.load())
|
|
32
|
+
await engine.start()
|
|
33
|
+
try:
|
|
34
|
+
yield engine
|
|
35
|
+
finally:
|
|
36
|
+
await engine.stop()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
mcp = FastMCP(
|
|
40
|
+
"bathys",
|
|
41
|
+
instructions=(
|
|
42
|
+
"Bathys — единый локальный поисковый сервис глубокого ресёрча: конвейер "
|
|
43
|
+
"поиск → извлечение → дистилляция → кэш целиком на вашей машине, ноль "
|
|
44
|
+
"облачных квот и API-ключей.\n\n"
|
|
45
|
+
"Матрица выбора инструмента:\n"
|
|
46
|
+
"- исследовательский вопрос («что/как/почему/сравни») → "
|
|
47
|
+
"deep_research(query): ищет, читает топ-источники, возвращает "
|
|
48
|
+
"дистиллят под запрос;\n"
|
|
49
|
+
"- нужны только ссылки → web_search(query); для программы/скрипта — "
|
|
50
|
+
"web_search(…, as_json=true);\n"
|
|
51
|
+
"- один известный URL → read_url(url, query=…);\n"
|
|
52
|
+
"- несколько известных URL (до 10) → read_urls(urls, query=…): один "
|
|
53
|
+
"вызов, общий бюджет, битая страница стоит строку.\n\n"
|
|
54
|
+
"Правила:\n"
|
|
55
|
+
"- запрос — вопрос, а не мешок ключевых слов: дистиллятор отбирает "
|
|
56
|
+
"пассажи по смыслу запроса;\n"
|
|
57
|
+
"- максимум 3 итерации по одной формулировке, дальше — смена угла "
|
|
58
|
+
"(web_search + read_urls по иным доменам), не шестой заход;\n"
|
|
59
|
+
"- подтверждённое утверждение — два независимых источника (разные "
|
|
60
|
+
"домены); один источник — «по данным одного источника»; конфликт "
|
|
61
|
+
"источников фиксируй явно, не выбирай молча сторону;\n"
|
|
62
|
+
"- свежесть — time_range (\"day\"/\"week\"/\"month\"/\"year\"); "
|
|
63
|
+
"refresh=true дорог (ломает кэш): только при протухшем кэше — cache "
|
|
64
|
+
"HIT при устаревших данных в дистилляте — и один раз на главный "
|
|
65
|
+
"источник.\n\n"
|
|
66
|
+
"Семантика сигналов:\n"
|
|
67
|
+
"- \"(not fetched — …)\" — источник не прочитан, причина рядом; это не "
|
|
68
|
+
"провал, используй остальные секции;\n"
|
|
69
|
+
"- robots-refused — сайт запретил краулер: уважай запрет, ищи другой "
|
|
70
|
+
"источник;\n"
|
|
71
|
+
"- \"No results … tried N engine sets\" — поисковики капризничали, "
|
|
72
|
+
"Bathys уже ретраился: смягчи формулировку (синонимы, язык, "
|
|
73
|
+
"category);\n"
|
|
74
|
+
"- \"Answer:\" — мгновенный справочный ответ: годится как факт, "
|
|
75
|
+
"спорное подтверди источником;\n"
|
|
76
|
+
"- футер \"[bathys: …]\" — статистика (cache/secs/ch), не для "
|
|
77
|
+
"цитирования.\n\n"
|
|
78
|
+
"Готовые стратегии — промпты bathys_deep_research, "
|
|
79
|
+
"bathys_source_audit, bathys_fresh_scan. Цитируй URL источников из "
|
|
80
|
+
"секций ответов — это твой след аудита."
|
|
81
|
+
),
|
|
82
|
+
lifespan=_lifespan,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _engine(ctx: Context) -> Engine:
|
|
87
|
+
return ctx.request_context.lifespan_context
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@mcp.tool(annotations=ToolAnnotations(
|
|
91
|
+
title="Глубокое исследование: поиск + чтение + дистиллят",
|
|
92
|
+
readOnlyHint=True,
|
|
93
|
+
openWorldHint=True,
|
|
94
|
+
))
|
|
95
|
+
async def deep_research(
|
|
96
|
+
query: str,
|
|
97
|
+
max_sources: int = 3,
|
|
98
|
+
max_results: int = 10,
|
|
99
|
+
per_source_chars: int = 3500,
|
|
100
|
+
time_range: str | None = None,
|
|
101
|
+
category: str | None = None,
|
|
102
|
+
language: str | None = None,
|
|
103
|
+
refresh: bool = False,
|
|
104
|
+
ctx: Context = None,
|
|
105
|
+
) -> str:
|
|
106
|
+
"""Search the web AND read the top sources in one shot.
|
|
107
|
+
|
|
108
|
+
Runs SearXNG metasearch, dives into the top max_sources pages with a real
|
|
109
|
+
browser, distills each page down to passages relevant to `query`, and
|
|
110
|
+
returns one merged digest. Best first call for any research question.
|
|
111
|
+
Args:
|
|
112
|
+
query: research question or keywords (RU/EN both fine)
|
|
113
|
+
max_sources: how many top hits to read in full (1-6)
|
|
114
|
+
max_results: how many search hits to consider (1-20)
|
|
115
|
+
per_source_chars: per-source character budget (300-8000)
|
|
116
|
+
time_range: "day" | "week" | "month" | "year"
|
|
117
|
+
category: searxng category, e.g. "general", "news", "science", "it"
|
|
118
|
+
language: result language, e.g. "ru", "en", "ru-RU"
|
|
119
|
+
refresh: ignore cache and re-fetch search results and pages
|
|
120
|
+
"""
|
|
121
|
+
return await _engine(ctx).research(
|
|
122
|
+
query, max_sources=max_sources, max_results=max_results,
|
|
123
|
+
per_source_chars=per_source_chars, category=category,
|
|
124
|
+
engines=None, language=language, time_range=time_range, refresh=refresh,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@mcp.tool(annotations=ToolAnnotations(
|
|
129
|
+
title="Веб-поиск: ранжированные ссылки",
|
|
130
|
+
readOnlyHint=True,
|
|
131
|
+
openWorldHint=True,
|
|
132
|
+
))
|
|
133
|
+
async def web_search(
|
|
134
|
+
query: str,
|
|
135
|
+
max_results: int = 8,
|
|
136
|
+
time_range: str | None = None,
|
|
137
|
+
category: str | None = None,
|
|
138
|
+
engines: str | None = None,
|
|
139
|
+
language: str | None = None,
|
|
140
|
+
refresh: bool = False,
|
|
141
|
+
as_json: bool = False,
|
|
142
|
+
ctx: Context = None,
|
|
143
|
+
) -> str:
|
|
144
|
+
"""Search the web via SearXNG metasearch; return a compact ranked link list.
|
|
145
|
+
|
|
146
|
+
Returns title, URL and a short snippet per hit — no page content. Empty or
|
|
147
|
+
blocked results are retried automatically with other engine sets.
|
|
148
|
+
To actually read pages, call read_url; to do both at once, call deep_research.
|
|
149
|
+
Args:
|
|
150
|
+
query: search query (natural language or keywords)
|
|
151
|
+
max_results: 1-20
|
|
152
|
+
time_range: "day" | "week" | "month" | "year"
|
|
153
|
+
category: e.g. "general", "news", "science", "it", "files"
|
|
154
|
+
engines: comma-separated engine names, e.g. "google,bing,duckduckgo"
|
|
155
|
+
language: e.g. "ru", "en", "ru-RU"
|
|
156
|
+
refresh: ignore cache and re-run the search
|
|
157
|
+
as_json: return pure machine-readable JSON {query, count, hits[], answer?}
|
|
158
|
+
instead of the human-friendly list (no footer line)
|
|
159
|
+
"""
|
|
160
|
+
return await _engine(ctx).search(
|
|
161
|
+
query, max_results=max_results, category=category,
|
|
162
|
+
engines=engines, language=language, time_range=time_range,
|
|
163
|
+
refresh=refresh, as_json=as_json,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@mcp.tool(annotations=ToolAnnotations(
|
|
168
|
+
title="Чтение одной страницы",
|
|
169
|
+
readOnlyHint=True,
|
|
170
|
+
openWorldHint=True,
|
|
171
|
+
))
|
|
172
|
+
async def read_url(
|
|
173
|
+
url: str,
|
|
174
|
+
query: str | None = None,
|
|
175
|
+
max_chars: int = 8000,
|
|
176
|
+
refresh: bool = False,
|
|
177
|
+
ctx: Context = None,
|
|
178
|
+
) -> str:
|
|
179
|
+
"""Read one web page; return its main content as clean, budgeted markdown.
|
|
180
|
+
|
|
181
|
+
JS-rendered pages are handled by a real headless browser. Boilerplate
|
|
182
|
+
(nav, footer, ads) is stripped; if `query` is given, only passages relevant
|
|
183
|
+
to it are returned. Pages are cached — re-reads with a different query are
|
|
184
|
+
instant and cost no network.
|
|
185
|
+
Args:
|
|
186
|
+
url: absolute http(s) URL
|
|
187
|
+
query: optional focus; return only passages relevant to it
|
|
188
|
+
max_chars: output character budget (300-50000)
|
|
189
|
+
refresh: ignore cache and re-fetch the page
|
|
190
|
+
"""
|
|
191
|
+
return await _engine(ctx).read(url, query=query, max_chars=max_chars, refresh=refresh)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@mcp.tool(annotations=ToolAnnotations(
|
|
195
|
+
title="Пакетное чтение до 10 страниц",
|
|
196
|
+
readOnlyHint=True,
|
|
197
|
+
openWorldHint=True,
|
|
198
|
+
))
|
|
199
|
+
async def read_urls(
|
|
200
|
+
urls: list[str],
|
|
201
|
+
query: str | None = None,
|
|
202
|
+
total_chars: int = 12000,
|
|
203
|
+
refresh: bool = False,
|
|
204
|
+
ctx: Context = None,
|
|
205
|
+
) -> str:
|
|
206
|
+
"""Read several known web pages in one call under one shared character budget.
|
|
207
|
+
|
|
208
|
+
Pages are fetched in parallel (JS-rendered, boilerplate-stripped) and the
|
|
209
|
+
combined total_chars budget is split evenly between the pages that came
|
|
210
|
+
back. Prefer this over N read_url calls when you already hold the URLs:
|
|
211
|
+
one round-trip, one budget, and a failed page costs one line instead of
|
|
212
|
+
a failed call.
|
|
213
|
+
Args:
|
|
214
|
+
urls: 1-10 absolute http(s) URLs; duplicates (after utm/fragment
|
|
215
|
+
cleanup) are merged, extras beyond 10 are reported in a Skipped line
|
|
216
|
+
query: optional focus; each page is distilled to passages relevant to it
|
|
217
|
+
total_chars: combined output budget across all sections (300-30000)
|
|
218
|
+
refresh: ignore cache and re-fetch every page
|
|
219
|
+
"""
|
|
220
|
+
return await batch.read_many(
|
|
221
|
+
_engine(ctx), urls, query=query, total_chars=total_chars, refresh=refresh,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# --- Built-in strategy prompts (rendered via get_prompt, see agents/skills/) ---
|
|
226
|
+
|
|
227
|
+
@mcp.prompt(description="Глубокое исследование одного вопроса за 1–3 итерации: "
|
|
228
|
+
"заход, разбор, уточнение терминами источников, верификация, синтез.")
|
|
229
|
+
def bathys_deep_research(question: str, time_range: str = "") -> str:
|
|
230
|
+
"""Стратегия глубокого исследования одного вопроса за 1–3 итерации.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
question: исследовательский вопрос (не мешок ключевых слов)
|
|
234
|
+
time_range: свежесть day|week|month|year; пусто — без фильтра
|
|
235
|
+
"""
|
|
236
|
+
tr = f', time_range="{time_range}"' if time_range else ""
|
|
237
|
+
return f"""# Глубокое исследование: {question}
|
|
238
|
+
|
|
239
|
+
Процедура — максимум 3 итерации, дальше смена угла, не шестой заход.
|
|
240
|
+
|
|
241
|
+
1. Заход: deep_research(query="{question}"{tr}, max_sources=3) — без
|
|
242
|
+
предварительного web_search: первый вызов уже ищет и читает
|
|
243
|
+
топ-источники.
|
|
244
|
+
2. Разбор дистиллята: из секций ответа выпиши (a) что отвечает на вопрос,
|
|
245
|
+
(b) что противоречит, (c) каких данных не хватает.
|
|
246
|
+
3. Уточнение: переформулируй запрос терминами из (a)–(c) — терминами
|
|
247
|
+
предметной области, найденными в источниках, не своими синонимами — и
|
|
248
|
+
повтори deep_research. Не хватает конкретики: web_search по узкому
|
|
249
|
+
термину, затем read_urls по 2–4 лучшим URL с query="{question}".
|
|
250
|
+
4. Верификация: ключевое утверждение подтверждено при двух независимых
|
|
251
|
+
доменах; конфликт — покажи обе версии с URL, не выбирай молча сторону.
|
|
252
|
+
5. Синтез: вывод — первым предложением; затем 3–7 пунктов, у каждого
|
|
253
|
+
нетривиального факта — URL; в конце — «что не удалось проверить».
|
|
254
|
+
|
|
255
|
+
Сигналы: «(not fetched — …)» — не провал, работай с остальными секциями;
|
|
256
|
+
robots-refused — уважай запрет, ищи другой источник; футер [bathys: …] —
|
|
257
|
+
статистика, не цитируй. Дистиллят — сырьё: твой результат — синтез, а не
|
|
258
|
+
пересказ."""
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@mcp.prompt(description="Аудит утверждения по списку URL: чтение под тезис, "
|
|
262
|
+
"кросс-поиск опровержений, вердикты по каждому пункту.")
|
|
263
|
+
def bathys_source_audit(claim: str, urls: str) -> str:
|
|
264
|
+
"""Аудит утверждения по списку URL: чтение под тезис, кросс-поиск опровержений, вердикты.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
claim: проверяемое утверждение словами
|
|
268
|
+
urls: 1–10 URL через запятую или пробел
|
|
269
|
+
"""
|
|
270
|
+
url_list = "\n".join(
|
|
271
|
+
f"- {u}" for u in urls.replace(",", " ").split() if u
|
|
272
|
+
) or "- (пусто: сначала добери URL через web_search)"
|
|
273
|
+
return f"""# Аудит утверждения: {claim}
|
|
274
|
+
|
|
275
|
+
Источники на проверку:
|
|
276
|
+
{url_list}
|
|
277
|
+
|
|
278
|
+
Процедура:
|
|
279
|
+
|
|
280
|
+
1. Пакетное чтение: read_urls(urls, query="{claim}") — дистилляция
|
|
281
|
+
вплотную к проверяемому утверждению, не «вообще о чём статья». Битые
|
|
282
|
+
URL отвалятся строкой «(not fetched — …)» — это не провал.
|
|
283
|
+
2. Контекст каждого источника: домен/автор, дата публикации,
|
|
284
|
+
первоисточник или пересказ. Два пересказа одного пресс-релиза —
|
|
285
|
+
один источник, не два.
|
|
286
|
+
3. Кросс-поиск опровержений: deep_research("{claim}" + «критика /
|
|
287
|
+
опровержение / альтернативы») — ищи расхождения, а не подтверждения;
|
|
288
|
+
при споре об актуальности добавь time_range.
|
|
289
|
+
4. Свежесть: проверяешь «как сейчас» — один refresh=true на главный
|
|
290
|
+
источник, не на весь пакет.
|
|
291
|
+
5. Вердикт по каждому тезису: подтверждено (≥2 независимых домена) /
|
|
292
|
+
частично (1 источник) / опровергнуто (опровержение с URL) /
|
|
293
|
+
непроверяемо (404, robots-refused — указать причину).
|
|
294
|
+
|
|
295
|
+
Выход: таблица «тезис → вердикт → источники (URL)» плюс абзац общего
|
|
296
|
+
вывода; каждый вердикт обязан содержать хотя бы один URL либо явную
|
|
297
|
+
причину его отсутствия."""
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@mcp.prompt(description="Свежий срез по теме за окно времени: отбор значимых "
|
|
301
|
+
"событий, пакетное чтение, сводка с датами и URL.")
|
|
302
|
+
def bathys_fresh_scan(topic: str, window: str = "week") -> str:
|
|
303
|
+
"""Свежий срез по теме: поиск в окне, отбор значимого, пакетное чтение, сводка с датами.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
topic: тема среза
|
|
307
|
+
window: окно свежести day|week|month|year
|
|
308
|
+
"""
|
|
309
|
+
return f"""# Свежий срез: {topic}
|
|
310
|
+
|
|
311
|
+
Процедура:
|
|
312
|
+
|
|
313
|
+
1. web_search(query="{topic}", time_range="{window}", max_results=8) —
|
|
314
|
+
только свежая выдача; не расширяй окно без необходимости.
|
|
315
|
+
2. Отбери 3–5 значимых событий/изменений; отсей дубли — пересказы одного
|
|
316
|
+
релиза в разных изданиях считаются одним событием.
|
|
317
|
+
3. read_urls(выбранные URL, query="{topic}") — один вызов, общий бюджет;
|
|
318
|
+
читай только то, что реально поясняет срез.
|
|
319
|
+
4. Сводка: события и изменения по убыванию значимости; каждый пункт —
|
|
320
|
+
с датой и URL. Конфликт дат или версий между источниками — покажи обе
|
|
321
|
+
версии, не выбирай молча сторону.
|
|
322
|
+
5. Подозрение протухшего кэша — cache HIT при устаревшей дате в
|
|
323
|
+
дистилляте — единственный refresh=true на главный источник, не на
|
|
324
|
+
весь пакет.
|
|
325
|
+
|
|
326
|
+
Выход: плотная сводка «что произошло / что изменилось» с датами и URL,
|
|
327
|
+
без пересказа дистиллятов целиком; спорный пункт на одном источнике
|
|
328
|
+
помечай «по данным одного источника»."""
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _describe_prompt_args(*names: str) -> None:
|
|
332
|
+
"""Fill PromptArgument.description from each prompt docstring's Args section.
|
|
333
|
+
|
|
334
|
+
mcp 1.29 func_metadata does not carry docstring Args into the argument
|
|
335
|
+
schema, so @prompt() alone yields description=None on the wire; FastMCP
|
|
336
|
+
has no public accessor for a registered Prompt, hence _prompt_manager.
|
|
337
|
+
Fails loudly if the internals ever move.
|
|
338
|
+
"""
|
|
339
|
+
for name in names:
|
|
340
|
+
prompt = mcp._prompt_manager.get_prompt(name)
|
|
341
|
+
docs: dict[str, str] = {}
|
|
342
|
+
in_args = False
|
|
343
|
+
for line in (prompt.fn.__doc__ or "").splitlines():
|
|
344
|
+
stripped = line.strip()
|
|
345
|
+
if stripped == "Args:":
|
|
346
|
+
in_args = True
|
|
347
|
+
elif in_args and stripped:
|
|
348
|
+
key, sep, value = stripped.partition(":")
|
|
349
|
+
if sep and value:
|
|
350
|
+
docs[key.strip()] = value.strip()
|
|
351
|
+
prompt.arguments = [
|
|
352
|
+
a.model_copy(update={"description": docs.get(a.name)})
|
|
353
|
+
for a in prompt.arguments or []
|
|
354
|
+
]
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
_describe_prompt_args(
|
|
358
|
+
"bathys_deep_research", "bathys_source_audit", "bathys_fresh_scan",
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _hush_stdout_logs() -> None:
|
|
363
|
+
"""MCP stdio owns stdout; any library handler pointed at it moves to stderr."""
|
|
364
|
+
import logging
|
|
365
|
+
import sys
|
|
366
|
+
|
|
367
|
+
for name in ("", "crawl4ai", "playwright"):
|
|
368
|
+
for h in logging.getLogger(name).handlers:
|
|
369
|
+
if getattr(h, "stream", None) is sys.stdout:
|
|
370
|
+
h.stream = sys.stderr
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def main() -> None:
|
|
374
|
+
argv = sys.argv[1:]
|
|
375
|
+
if argv and argv[0] in ("install", "doctor"):
|
|
376
|
+
# `bathys install …` routes to the installer/doctor CLI so a single
|
|
377
|
+
# console entry-point covers both the MCP server and setup commands.
|
|
378
|
+
from . import doctor, installer
|
|
379
|
+
|
|
380
|
+
if argv[0] == "install":
|
|
381
|
+
sys.argv = ["bathys install", *argv[1:]]
|
|
382
|
+
raise SystemExit(installer.main())
|
|
383
|
+
sys.argv = ["bathys doctor", *argv[1:]]
|
|
384
|
+
raise SystemExit(doctor.main())
|
|
385
|
+
if argv and argv[0] in ("-h", "--help", "help"):
|
|
386
|
+
print("bathys — единый локальный поисковый сервис глубокого ресёрча (MCP, stdio)\n"
|
|
387
|
+
"\n"
|
|
388
|
+
"Использование:\n"
|
|
389
|
+
" bathys запустить MCP-сервер (stdio; для харнесса)\n"
|
|
390
|
+
" bathys install автоподключение к найденным харнессам "
|
|
391
|
+
"(--dry-run, --print-config, --with-agent)\n"
|
|
392
|
+
" bathys doctor диагностика стека (--full)\n")
|
|
393
|
+
return
|
|
394
|
+
_hush_stdout_logs()
|
|
395
|
+
mcp.run(transport="stdio")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
if __name__ == "__main__":
|
|
399
|
+
main()
|