Files
research-agent-plugin/scripts/research_llm_utils.py
T

193 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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=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