import os import pymysql from dotenv import load_dotenv import requests import json import re import time import sys import random from datetime import datetime # Загрузка переменных окружения load_dotenv() # Конфигурация из переменных окружения DB_CONFIG = { 'host': os.getenv('MDB_HOST'), 'user': os.getenv('MDB_USER'), 'password': os.getenv('MDB_PW'), 'database': os.getenv('MDBASE'), 'charset': 'utf8mb4' } ROUTERAI_CONFIG = { 'api_key': os.getenv('RA_KEY'), 'model': os.getenv('RA_MODEL'), 'base_url': 'https://routerai.ru/api/v1', } # Настройки журналирования LOG_FILE = os.getenv('LOG_FILE', 'ai_namer.log') # Путь к лог-файлу LOG_PREFIX = "AI_namer" # Уникальный префикс для идентификации скрипта LOG_AI_RESPONSE_MAX = int(os.getenv('LOG_AI_RESPONSE_MAX', '8000')) # обрезка длинных полей в логе def _truncate_for_log(value, max_len=None): """Обрезает длинные значения для журнала, не теряя информацию о размере.""" if value is None: return None if not isinstance(value, str): value = json.dumps(value, ensure_ascii=False, default=str) limit = max_len if max_len is not None else LOG_AI_RESPONSE_MAX if len(value) <= limit: return value return f"{value[:limit]}... [обрезано, всего {len(value)} символов]" def _message_fields_for_log(message): """Все значимые поля message из ответа chat/completions.""" if not message: return {} logged = {} for key in ( 'role', 'content', 'reasoning_content', 'reasoning', 'refusal', 'tool_calls', 'function_call', 'audio', ): if key not in message: continue value = message.get(key) if value in (None, '', [], {}): logged[key] = value elif isinstance(value, str): logged[key] = _truncate_for_log(value) else: logged[key] = _truncate_for_log(json.dumps(value, ensure_ascii=False, default=str)) return logged def _log_routerai_request(text, prompt): """Журнал параметров исходящего запроса (без API-ключа).""" log_message( "Запрос RouterAI: " f"model={ROUTERAI_CONFIG['model']}, " f"text_len={len(text)}, prompt_len={len(prompt)}, " f"text_preview={_truncate_for_log(text.strip(), 300)}" ) def _log_routerai_response(response, result=None, error_body=None): """Подробный журнал HTTP-ответа RouterAI.""" payload = { "http_status": getattr(response, 'status_code', None), "model": ROUTERAI_CONFIG.get('model'), } if result is not None: payload["response_id"] = result.get("id") payload["object"] = result.get("object") payload["created"] = result.get("created") payload["usage"] = result.get("usage") payload["system_fingerprint"] = result.get("system_fingerprint") choices = result.get("choices") or [] payload["choices_count"] = len(choices) payload["choices"] = [] for i, choice in enumerate(choices): payload["choices"].append({ "index": choice.get("index", i), "finish_reason": choice.get("finish_reason"), "message": _message_fields_for_log(choice.get("message") or {}), "logprobs": choice.get("logprobs"), }) if result.get("error"): payload["api_error"] = result.get("error") if error_body is not None: payload["error_body"] = _truncate_for_log(error_body) log_message(f"Ответ RouterAI: {json.dumps(payload, ensure_ascii=False, default=str)}") def log_message(message, max_retries=5, retry_delay=0.1): """ Записывает сообщение в лог-файл с обработкой блокировок и идентификатором скрипта """ if not LOG_FILE: return timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') # Добавляем идентификатор скрипта к сообщению log_line = f"[{timestamp}] [{LOG_PREFIX}] {message}\n" for attempt in range(max_retries): try: with open(LOG_FILE, 'a', encoding='utf-8') as log: log.write(log_line) return True except (IOError, OSError) as e: if "locked" in str(e).lower() and attempt < max_retries - 1: # Случайная задержка для уменьшения коллизий sleep_time = retry_delay * (1 + random.random() * 0.5) time.sleep(sleep_time) else: # Если не удалось записать после всех попыток print(f"Ошибка записи в лог: {e}") print(f"Сообщение для лога: {log_line.strip()}") return False def _generate_shortname_by_api(text): """Внутренняя функция для генерации краткого названия через RouterAI API""" if not ROUTERAI_CONFIG['api_key'] or not ROUTERAI_CONFIG['model']: log_message("RA_KEY или RA_MODEL не заданы в .env") print("Ошибка: RA_KEY или RA_MODEL не заданы в .env") return None url = f"{ROUTERAI_CONFIG['base_url']}/chat/completions" headers = { "Authorization": f"Bearer {ROUTERAI_CONFIG['api_key']}", "Content-Type": "application/json", } prompt = f""" Прочитай нижеприведенный анонс и сформируй краткое (до 80 знаков) название этого события для каталога. Место, дата и время проведения для каталога несущественны. Важно название и, в зависимости от того, что это за событие, выступающие на нем или проводящие его. Для данного каталога допустимо использование никнеймов вместо официальных имен и фамилий. Ответ должен содержать только одно название без каких-либо объяснений, комментариев и вариантов. АНОНС: {text} Название:""" data = { "model": ROUTERAI_CONFIG['model'], "messages": [{"role": "user", "content": prompt}], "max_tokens": 5000, "temperature": 0.3, } _log_routerai_request(text, prompt) try: response = requests.post(url, headers=headers, json=data, timeout=180) # Обработка ошибки 429 (Too Many Requests) if response.status_code == 429: error_info = { "status_code": response.status_code, "headers": dict(response.headers), "body": response.text } # Пытаемся распарсить JSON ответ try: error_info["body"] = response.json() except Exception: pass _log_routerai_response(response, error_body=error_info.get("body")) log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}") print("Ошибка 429: Превышен лимит запросов. Подробности в логе.") # Не sys.exit — эта функция вызывается и из веб-API Flask raise RuntimeError("RouterAI 429: превышен лимит запросов") if not response.ok: _log_routerai_response(response, error_body=response.text) response.raise_for_status() result = response.json() _log_routerai_response(response, result=result) # Извлекаем только текст ответа (у thinking-моделей ответ в content, # рассуждения — в reasoning_content / reasoning) choices = result.get('choices') or [] if not choices: log_message("RouterAI вернул пустой choices — название не извлечь") return None message = choices[0].get('message') or {} shortname = (message.get('content') or '').strip() if not shortname: reasoning = (message.get('reasoning_content') or message.get('reasoning') or '').strip() log_message( "Пустой content в ответе RouterAI; " f"finish_reason={choices[0].get('finish_reason')!r}, " f"message_keys={list(message.keys())}, " f"reasoning_len={len(reasoning)}" ) return None # Удаляем возможные кавычки и лишние символы shortname = re.sub(r'^["\']|["\']$', '', shortname) # Если модель вернула многострочный ответ — берём первую непустую строку for line in shortname.splitlines(): line = line.strip() if line: shortname = line break # Обрезаем до 80 символов return shortname[:80] except requests.exceptions.HTTPError as e: if e.response is not None and e.response.status_code == 429: error_info = { "status_code": e.response.status_code, "headers": dict(e.response.headers), "body": e.response.text } # Пытаемся распарсить JSON ответ try: error_info["body"] = e.response.json() except Exception: pass _log_routerai_response(e.response, error_body=error_info.get("body")) log_message(f"Ошибка 429: Превышен лимит запросов. Ответ API: {json.dumps(error_info, ensure_ascii=False)}") print("Ошибка 429: Превышен лимит запросов. Подробности в логе.") raise RuntimeError("RouterAI 429: превышен лимит запросов") else: if e.response is not None: try: _log_routerai_response(e.response, error_body=e.response.text) except Exception: pass error_msg = f"HTTP ошибка при генерации названия: {e}" log_message(error_msg) print(error_msg) return None except requests.exceptions.Timeout as e: error_msg = f"Таймаут запроса RouterAI ({e})" log_message(error_msg) print(error_msg) return None except requests.exceptions.RequestException as e: error_msg = f"Сетевая ошибка RouterAI: {e}" log_message(error_msg) print(error_msg) return None except RuntimeError: raise except Exception as e: error_msg = f"Ошибка при генерации названия: {e}" log_message(error_msg) print(error_msg) return None def generate_ai_shortname(text): """ Генерирует краткое название для описанного в тексте мероприятия. Args: text (str): Текст описания мероприятия Returns: str: Краткое название (до 80 знаков) или None в случае ошибки """ log_message(f"Начало генерации названия для текста длиной {len(text)} символов") if not text or not text.strip(): log_message("Получен пустой текст для генерации названия") return None shortname = _generate_shortname_by_api(text) if shortname: log_message(f"Успешно сгенерировано название: {shortname}") else: log_message("Не удалось сгенерировать название") return shortname def ai_shortname_all(): """ Обрабатывает все записи в базе данных с пустым полем shortname, генерируя для них краткие названия с помощью нейросети. """ log_message("Запуск обработки всех записей с пустым shortname") # Подключение к БД try: connection = pymysql.connect(**DB_CONFIG) log_message("Успешное подключение к базе данных") except Exception as e: error_msg = f"Ошибка подключения к базе данных: {e}" log_message(error_msg) print(error_msg) return try: with connection.cursor() as cursor: # Выбор записей с пустым shortname cursor.execute("SELECT id, text FROM posts WHERE shortname IS NULL OR shortname = ''") posts = cursor.fetchall() log_message(f"Найдено {len(posts)} записей для обработки") print(f"Найдено {len(posts)} записей для обработки") for i, (post_id, text) in enumerate(posts, 1): if not text: continue log_message(f"Обрабатывается запись {i}/{len(posts)} (ID: {post_id})") print(f"Обрабатывается запись {i}/{len(posts)} (ID: {post_id})") shortname = generate_ai_shortname(text) if shortname: # Обновление записи try: cursor.execute( "UPDATE posts SET shortname = %s WHERE id = %s", (shortname, post_id) ) connection.commit() log_message(f"Запись {post_id} успешно обновлена: {shortname}") print(f"Обновлено: {shortname}") except Exception as e: error_msg = f"Ошибка обновления записи {post_id}: {e}" log_message(error_msg) print(error_msg) else: log_message(f"Не удалось сгенерировать название для записи {post_id}") print("Не удалось сгенерировать название") # Небольшая пауза между запросами time.sleep(1) log_message("Обработка всех записей завершена") print("Обработка завершена") except RuntimeError as e: # 429 и прочие явные ошибки RouterAI log_message(f"Завершение работы: {e}") print(f"Завершение работы: {e}") except Exception as e: error_msg = f"Произошла ошибка при обработке записей: {e}" log_message(error_msg) print(error_msg) finally: connection.close() log_message("Соединение с базой данных закрыто") if __name__ == "__main__": ai_shortname_all()