fix: SEARXNG_URL no longer includes /search suffix (provider adds it), sync all files
This commit is contained in:
1 parent
e5e9b5c664
commit
d4731bee20
11 files changed
+2891
No files matched your search
@@ -0,0 +1,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
|
||||
Reference in new issue
Block a user