fix: SEARXNG_URL no longer includes /search suffix (provider adds it), sync all files

This commit is contained in:
nik committed 2026-06-29 22:46:24 +05:00
1 parent e5e9b5c664
commit d4731bee20
11 files changed
+2891

No files matched your search

+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""
Общие утилиты для research-скриптов — retry, валидация, промпты.
Использование:
from research_llm_utils import llm_call_safe, validate_report, REASON_FIRST_PREFIX
"""
import json
import os
import re
import time
import sys
import requests
# ─── Настройки (из env) ────────────────────────────────────────────────────────
LLM_URL = os.environ.get("LLM_URL", "http://192.168.1.152:8080/v1/chat/completions")
LLM_MODEL = os.environ.get("LLM_MODEL", "qwen3.6:27b")
LLM_TEMPERATURE = 0.3
LLM_MAX_RETRIES = 2
# ─── Префикс «Reason First» для системных промптов ────────────────────────────
# Добавляется в начало system message. Заставляет локальную LLM думать
# перед ответом — критично для качества синтеза.
REASON_FIRST_PREFIX = (
"Сначала прочитай и проанализируй ВСЕ источники. "
"Отметь противоречия между ними. "
"Затем составь ответ, привязывая каждый факт к источнику [N]. "
"После ответа проверь: все ли [N] из текста есть в блоке Источники.\n\n"
)
# ─── LLM Call с retry ──────────────────────────────────────────────────────────
def llm_call_raw(system, user, max_tokens=2048, temperature=None, model=None):
"""Одиночный вызов LLM. Возвращает текст или None."""
if temperature is None:
temperature = LLM_TEMPERATURE
if model is None:
model = LLM_MODEL
payload = {
"model": model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"max_tokens": max_tokens,
"temperature": temperature,
"stream": False,
}
try:
resp = requests.post(LLM_URL, json=payload, timeout=300)
resp.raise_for_status()
data = resp.json()
msg = data.get("choices", [{}])[0].get("message", {})
content = msg.get("content", "").strip()
if not content:
# Qwen3.6 thinking variant — вывод в reasoning_content
content = msg.get("reasoning_content", "").strip()
if not content:
return None
return content
except requests.exceptions.Timeout:
log("LLM timeout after 300s")
return None
except requests.exceptions.HTTPError as e:
log(f"LLM HTTP {e.response.status_code if e.response else '?'}: {str(e)[:200]}")
return None
except (KeyError, json.JSONDecodeError) as e:
log(f"LLM parse error: {e}")
return None
except Exception as e:
log(f"LLM error: {type(e).__name__}: {e}")
return None
def llm_call_safe(system, user, max_tokens=2048, temperature=None, model=None):
"""
Вызов LLM с retry и fallback.
- 2 попытки с задержкой 2s/4s
- При полном отказе: возвращает текст ошибки вместо None
"""
for attempt in range(LLM_MAX_RETRIES):
result = llm_call_raw(system, user, max_tokens, temperature, model)
if result:
return result
delay = 2 * (attempt + 1)
log(f"LLM retry {attempt + 1}/{LLM_MAX_RETRIES} через {delay}s...")
time.sleep(delay)
# Fallback — возвращаем сообщение об ошибке, а не None
return (
"⚠️ Не удалось синтезировать отчёт — LLM недоступен. "
"Ниже приведены собранные источники без анализа."
)
# ─── Валидация отчёта ──────────────────────────────────────────────────────────
def validate_report(text):
"""
Проверяет отчёт на:
- Наличие inline-цитат [N]
- Наличие блока «Источники» / «Sources»
- Минимальную длину
Возвращает (is_valid, list_of_warnings).
"""
warnings = []
if not text or len(text) < 100:
warnings.append("Слишком короткий ответ (< 100 символов)")
return False, warnings
# Проверка цитат [N]
has_citations = bool(re.search(r'\[\d+\]', text))
if not has_citations:
warnings.append("Отсутствуют inline-цитаты [N]")
# Проверка блока источников
has_sources = bool(re.search(r'(?:###?\s*)?Источники|Sources', text))
if not has_sources:
warnings.append("Отсутствует блок «Источники» / «Sources»")
# Проверка на явные ошибки LLM
if "я не могу" in text.lower() or "i cannot" in text.lower():
warnings.append("LLM отказалась отвечать (refusal detected)")
is_valid = len(warnings) == 0
return is_valid, warnings
def validate_and_warn(text):
"""
Валидирует отчёт и добавляет warnings в конец если проблемы.
Возвращает (текст с warnings, is_clean).
"""
is_valid, warnings = validate_report(text)
if is_valid:
return text, True
warn_text = "\n\n---\n⚠️ **Предупреждения валидации:**\n"
for w in warnings:
warn_text += f"- {w}\n"
return text + warn_text, False
# ─── Логирование ───────────────────────────────────────────────────────────────
def log(msg):
"""Лог в stderr — не мешает выводу отчёта в stdout."""
print(f"[research] {msg}", file=sys.stderr)
# ─── HTTP helpers ──────────────────────────────────────────────────────────────
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
SESSION = None
def get_session():
"""Ленивая сессия requests с таймаутами."""
global SESSION
if SESSION is None:
SESSION = requests.Session()
SESSION.headers.update({"User-Agent": USER_AGENT})
return SESSION
def fetch_url(url, timeout=15):
"""GET запрос с возвратом текста или None."""
try:
resp = get_session().get(url, timeout=timeout, allow_redirects=True)
resp.raise_for_status()
return resp.text
except Exception:
return None
# ─── Патчинг глобальных настроек ───────────────────────────────────────────────
def apply_cli_overrides(model=None, temperature=None):
"""Применить переопределения из CLI-аргументов к глобальным переменным."""
global LLM_MODEL, LLM_TEMPERATURE
if model:
LLM_MODEL = model
if temperature is not None:
LLM_TEMPERATURE = temperature