193 lines
7.6 KiB
Python
193 lines
7.6 KiB
Python
#!/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.7
|
||
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=4096, 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
|