Files
Zilant2025/db_update_shortname.py
gitadmin f74953e082
ci/woodpecker/push/woodpecker Pipeline was successful
[ZILANT] логирование ответа АИ
2026-09-02 22:19:58 +03:00

372 lines
16 KiB
Python

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()