#!/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