diff --git a/config/__pycache__/config.cpython-313.pyc b/config/__pycache__/config.cpython-313.pyc index 4b3cb15..d7014fd 100644 Binary files a/config/__pycache__/config.cpython-313.pyc and b/config/__pycache__/config.cpython-313.pyc differ diff --git a/config/config.py b/config/config.py index 6aa664c..7db94a7 100644 --- a/config/config.py +++ b/config/config.py @@ -99,12 +99,6 @@ ENABLE_LK = True # Личный кабинет сотрудник # (фото → превью → публикация) работает параллельно. # ========================================================= PHOTO_TEST_MODE = False # True — в меню фото-режима доступен пункт «тест проверка фото» -PHOTO_TEST_AI_URL = "http://srvkem-ii-03:8083/v1/chat/completions" -PHOTO_TEST_AI_MODEL = "/opt/llama.cpp-cpu/build/bin/models/gemma-4-E4B-mtp/gemma-4-E4B-it-UD-Q4_K_XL.gguf" -PHOTO_TEST_AI_TIMEOUT = 180.0 # Таймаут обращения к ИИ, сек -# Разрешение, до которого сжимается фото перед отправкой в ИИ (max side, px). -# 1536 — для чёткого разбора мимики/деталей (выше = медленнее). -PHOTO_TEST_AI_MAX_SIDE = 1536 # ========================================================= # 2. КОНТРОЛЬ ДОСТУПА diff --git a/photo_bot/__pycache__/handlers.cpython-313.pyc b/photo_bot/__pycache__/handlers.cpython-313.pyc index 0d7265a..d283e37 100644 Binary files a/photo_bot/__pycache__/handlers.cpython-313.pyc and b/photo_bot/__pycache__/handlers.cpython-313.pyc differ diff --git a/photo_bot/__pycache__/photo_processor.cpython-313.pyc b/photo_bot/__pycache__/photo_processor.cpython-313.pyc new file mode 100644 index 0000000..3cbe4a9 Binary files /dev/null and b/photo_bot/__pycache__/photo_processor.cpython-313.pyc differ diff --git a/photo_bot/ai_validator.py b/photo_bot/ai_validator.py deleted file mode 100644 index a3529c6..0000000 --- a/photo_bot/ai_validator.py +++ /dev/null @@ -1,148 +0,0 @@ -# /opt/trueconf_bot/photo_bot/ai_validator.py -# Тестовый мод фото-бота: прямая проверка фото ИИ (llama.cpp). -# Endpoint, модель и промпт — 1:1 из SibCemProfile/PhotoApp/photo_validator.py. -# Фото никуда не отправляется: только в ИИ, ответ возвращается пользователю. -import base64 -import io -import json -import logging -import time -import urllib.request -from PIL import Image - -import config.config as config - -logger = logging.getLogger(__name__) - -# Тег рассуждений thinking-моделей (конкатенация без литерала) -THINK_CLOSE_TAG = '' - - -def _build_prompt() -> str: - """Промпт проверки фото (без подсказки MediaPipe — в тест-моде геометрия не считается).""" - cv_hint = 'ℹ️ ПОДСКАЗКА ОТ ALGORITHM: Геометрическая проверка в тестовом режиме не выполняется, проверяй визуально.' - - return ('''Ты — строгий инспектор пропускной системы. Проверь фотографию сотрудника на соответствие регламенту. - -%s - -ЗАПРЕЩЕНО выводить внутренние рассуждения и теги размышлений. Сразу пиши финальный ответ. - -ПРЯМО СЕЙЧАС ВНИМАТЕЛЬНО РАССМОТРИ ФОТО И ПОСЛЕДОВАТЕЛЬНО ЗАФИКСИРУЙ: -A) РЕАЛЬНОСТЬ: это живое фото, а не ИИ-генерация, рисунок, 3D, мультфильм, фильтр? -B) ГРАФИКА: есть ли рамка, любые буквы/надписи/подписи (включая подпись с именем внизу), водяные знаки, декоративный фон с буквами, коллаж? -C) МИМИКА: высунут ли язык? Скривлены ли губы? Есть ли гримаса, смешная рожа, щёлканье зубами, смех, скос глаз, закатанные глаза? -D) РАКУРС: профиль, затылок, взгляд в сторону? -E) МАСШТАБ: ниже линии груди видны ли живот, талия, подмышки? - -ПРАВИЛО ОТКАЗА: если по любому пункту A–E есть хоть малейшее отклонение — вердикт «Не прошел». -Смех, ржа, рожи, высунутый язык, скривлённые губы — фото НЕ ПРОХОДИТ, даже если лицо анфас и хорошо освещено. -Рамка или буквы/подпись на фоне или по краю кадра — фото НЕ ПРОХОДИТ. - -ИТОГОВЫЙ ВЕРДИКТ: -- «Прошел» — только если все пункты A–E идеальны: нейтральное спокойное лицо, чистый кадр без рамки и букв. -- «Не прошел» — любое отклонение, описанное выше. -- «На модерацию» — только если фото реальное и лицо нейтральное, но масштаб или ракурс на грани нормы. - -ФОРМАТ ОТВЕТА — СТРОГО 4 строки, без других слов: -Мимика: <нейтрально / высунут язык / гримаса / скривленная улыбка / смех — что именно видно> -Графика: <нет / рамка / буквы / подпись / коллаж — что именно видно> -Вердикт: [Прошел / Не прошел / На модерацию] -Причина: [1–2 предложения с указанием конкретного пункта A–E] -''') % cv_hint - - -def validate_photo_with_ai(file_bytes: bytes) -> dict: - """ - Прямая проверка фото ИИ (тестовый мод). - - Возвращает {'ok': bool, 'verdict': str, 'reason': str, 'error': str, - 'metrics': {'total_time', 'prompt_tokens', 'completion_tokens', - 'prompt_per_second', 'predicted_per_second'}, - 'error_time': float} - """ - t0 = time.perf_counter() - try: - # Сжатие (PIL): разрешение настраивается в config (PHOTO_TEST_AI_MAX_SIDE) - side = int(config.PHOTO_TEST_AI_MAX_SIDE) - with Image.open(io.BytesIO(file_bytes)) as img: - img = img.convert('RGB') - img.thumbnail((side, side)) - buffer = io.BytesIO() - img.save(buffer, format='JPEG', quality=85) - b64_img = base64.b64encode(buffer.getvalue()).decode('utf-8') - - payload = { - 'model': config.PHOTO_TEST_AI_MODEL, - 'messages': [{ - 'role': 'user', - 'content': [ - {'type': 'text', 'text': _build_prompt()}, - {'type': 'image_url', 'image_url': {'url': 'data:image/jpeg;base64,' + b64_img}} - ] - }], - 'temperature': 0.1, - 'max_tokens': 1000 - } - - req = urllib.request.Request( - config.PHOTO_TEST_AI_URL, - data=json.dumps(payload).encode('utf-8'), - headers={'Content-Type': 'application/json'} - ) - - with urllib.request.urlopen(req, timeout=config.PHOTO_TEST_AI_TIMEOUT) as resp: - res = json.loads(resp.read().decode('utf-8')) - total_time = time.perf_counter() - t0 - - # llama.cpp в ответ кладёт usage (токены) и timings (скорости) - usage = res.get('usage') or {} - timings = res.get('timings') or {} - metrics = { - 'total_time': total_time, - 'prompt_tokens': usage.get('prompt_tokens'), - 'completion_tokens': usage.get('completion_tokens'), - 'prompt_per_second': timings.get('prompt_per_second'), - 'predicted_per_second': timings.get('predicted_per_second'), - } - - full_content = (res['choices'][0]['message'].get('content') or '').strip() - clean_ans = full_content.split(THINK_CLOSE_TAG)[-1].strip() if THINK_CLOSE_TAG in full_content else full_content.strip() - - text_lower = clean_ans.lower() - if 'вердикт: прошел' in text_lower or ('прошел' in text_lower and 'не прошел' not in text_lower and 'модерац' not in text_lower): - verdict = 'Прошел' - elif 'на модерацию' in text_lower or 'модерац' in text_lower: - verdict = 'На модерацию' - else: - verdict = 'Не прошел' - - reason = '' - mimi = '' - grafika = '' - for line in clean_ans.splitlines(): - ls = line.strip().lower() - if ls.startswith('причина:'): - reason = line.split(':', 1)[1].strip() - elif ls.startswith('мимика:'): - mimi = line.split(':', 1)[1].strip() - elif ls.startswith('графика:'): - grafika = line.split(':', 1)[1].strip() - if not reason: - reason = clean_ans.replace('\n', ' ').strip() - - logger.info( - f"[TEST MODE] ИИ-вердикт: {verdict} | мимика={mimi} | графика={grafika} | {reason}" - f" | total={metrics['total_time']:.2f}s " - f"prompt={metrics['prompt_tokens']}tok/({metrics['prompt_per_second']:.1f} tps) " - f"gen={metrics['completion_tokens']}tok/({metrics['predicted_per_second']:.1f} tps)" - ) - return {'ok': True, 'verdict': verdict, 'reason': reason, 'error': '', - 'mimi': mimi, 'grafika': grafika, 'metrics': metrics} - - except Exception as e: - err = f"{type(e).__name__}: {e}" - elapsed = time.perf_counter() - t0 - logger.error(f"[TEST MODE] Ошибка обращения к ИИ через {elapsed:.1f} сек: {err}") - return {'ok': False, 'verdict': '', 'reason': '', 'error': err, - 'error_time': elapsed} diff --git a/photo_bot/handlers.py b/photo_bot/handlers.py index f092621..8e6f403 100644 --- a/photo_bot/handlers.py +++ b/photo_bot/handlers.py @@ -14,8 +14,7 @@ from trueconf.types import FSInputFile import config.config as config from utils import states from utils.menu import MENU_TEXT -from photo_bot.photo_processor import prepare_ad_photo, get_crop_coordinates -from photo_bot.ai_validator import validate_photo_with_ai +from photo_bot.photo_processor import prepare_ad_photo, get_crop_coordinates, check_photo_api from utils.texts import ( UNKNOWN_MAIN_CMD_TEXT, EMOJI_DIGITS, @@ -324,29 +323,36 @@ async def photo_module_handler(msg: Message): try: with open(original_path, 'rb') as f: raw_photo = f.read() - ai_res = await asyncio.to_thread(validate_photo_with_ai, raw_photo) + # Dry-run: полный пайплайн проверки на САЙТЕ (CV + гримасы + ИИ), + # фото в БД/AD/Orion не попадает + check_res = await asyncio.to_thread(check_photo_api, raw_photo) except Exception as e: - logger.error(f"🔥 [TEST MODE] Ошибка чтения фото: {e}") - ai_res = {'ok': False, 'verdict': '', 'reason': '', 'error': str(e)} + logger.error(f"🔥 [TEST MODE] Ошибка проверки фото: {e}") + check_res = {"status": "error", "reason": f"Ошибка бота: {e}"} - if not ai_res['ok']: + if check_res.get("status") != "ok": # Фото остаётся в pending — можно повторить проверку (кнопка 1) - log_menu_stats(user_id, "Photo Bot (TEST)", "Ошибка обращения к ИИ") + log_menu_stats(user_id, "Photo Bot (TEST)", f"Ошибка проверки: {check_res.get('reason')}") await msg.answer( - photo_test_ai_error(ai_res.get('error', ''), ai_res.get('error_time')), + photo_test_ai_error(check_res.get('reason', 'Неизвестная ошибка')), parse_mode="html" ) return - verdict = ai_res['verdict'] - reason = ai_res['reason'] or 'Причина не указана.' - mimi = ai_res.get('mimi', '') - grafika = ai_res.get('grafika', '') - log_menu_stats(user_id, "Photo Bot (TEST)", f"ИИ-вердикт: {verdict}") + verdict = check_res.get('verdict', '—') + log_menu_stats(user_id, "Photo Bot (TEST)", f"Вердикт проверки: {verdict}") # Фото остаётся в pending — можно повторить проверку другим фото (кнопка 2) await msg.answer( - photo_test_result(verdict, reason, mimi, grafika, ai_res.get('metrics')), + photo_test_result( + verdict, + check_res.get('ai_reason') or '', + check_res.get('cv_approved'), + check_res.get('cv_reason') or '', + check_res.get('forced_moderation'), + check_res.get('timings'), + check_res.get('ai_metrics'), + ), parse_mode="html" ) return diff --git a/photo_bot/photo_processor.py b/photo_bot/photo_processor.py index 1e92ec7..ea99af4 100644 --- a/photo_bot/photo_processor.py +++ b/photo_bot/photo_processor.py @@ -187,3 +187,36 @@ def prepare_ad_photo(input_path: str, user_id: str, output_path: str = None, tar except Exception as e: logger.error(f"Ошибка в prepare_ad_photo: {e}", exc_info=True) return {"success": False, "error": str(e)} + + +def check_photo_api(image_bytes: bytes, timeout: float = 300.0) -> dict: + """ + Dry-run проверка фото через API сайта (POST /api/photo-bot/check/). + + Фото проходит полный пайплайн проверки на сайте (MediaPipe CV + + ARKit-формы + VLM ИИ), НО ни в БД, ни в AD, ни в Orion не попадает — + ответ возвращается боту для тест-под-режима. + + Returns: + {"status": "ok", "verdict", "ai_reason", "cv_approved", "cv_reason", + "raw_answer", "forced_moderation", "timings", "ai_metrics"} + или {"status": "error", "reason"} + """ + url = API_URL.replace("/upload/", "check/") + try: + response = requests.post( + url, + headers={"X-API-Key": API_KEY}, + files={"image": ("photo.jpg", image_bytes, "image/jpeg")}, + timeout=timeout, + verify=False + ) + response.raise_for_status() + logger.info(f"API /check/ response: status={response.status_code}, body[:500]: {response.text[:500]}") + return response.json() + except requests.exceptions.RequestException as e: + logger.error(f"Ошибка /check/ API: {e}") + return {"status": "error", "reason": f"Ошибка сети при обращении к сайту: {e}"} + except Exception as e: + logger.error(f"Ошибка /check/: {e}") + return {"status": "error", "reason": f"Ошибка сайта проверки: {e}"} diff --git a/utils/__pycache__/texts.cpython-313.pyc b/utils/__pycache__/texts.cpython-313.pyc index f22aab0..3511c60 100644 Binary files a/utils/__pycache__/texts.cpython-313.pyc and b/utils/__pycache__/texts.cpython-313.pyc differ diff --git a/utils/texts.py b/utils/texts.py index 2c094e7..c827b8a 100644 --- a/utils/texts.py +++ b/utils/texts.py @@ -283,7 +283,7 @@ def _photo_test_preview(emojis: dict) -> str: return ( f"🧪 Тестовый режим\n\n" f"Кадр получен. Фото никуда не публикуется.\n\n" - f"{emojis['1']} — отправлю фото напрямую в ИИ\n" + f"{emojis['1']} — отправлю фото на проверку по сайту\n" f"{emojis['2']} — Загрузить другое фото\n" f"{emojis['9']} — Назад\n" f"{emojis['0']} — В главное меню" @@ -291,59 +291,85 @@ def _photo_test_preview(emojis: dict) -> str: def _photo_test_ai_checking(emojis: dict) -> str: return ( - "🧪 Отправляю фото напрямую в ИИ (тестовый режим)...\n" + "🧪 Отправляю фото на проверку по сайту (тестовый режим)...\n" + "Фото проходят полный контроль: CV, гримасы, ИИ.\n" "Обычно это занимает 1-3 минуты. Пожалуйста, подождите..." ) def _photo_test_receiving(emojis: dict) -> str: return "🧪 Ищу лицо на фото (тестовый режим), пожалуйста, подождите..." -def _photo_test_result(verdict: str, reason: str, mimi: str = '', grafika: str = '', - ai_metrics: dict = None, emojis: dict = None) -> str: +def _photo_test_result(verdict: str, reason: str, cv_approved=None, cv_reason: str = '', + forced=None, timings: dict = None, ai_metrics: dict = None, + emojis: dict = None) -> str: emojis = emojis or {} icons = {"Прошел": "✅", "Не прошел": "❌", "На модерацию": "⏳"} icon = icons.get(verdict, "ℹ️") - lines = [] - if mimi: - lines.append(f"🙂 Мимика: {mimi}") - if grafika: - lines.append(f"🖼 Графика: {grafika}") - if lines: - detail = "\n".join(lines) + "\n\n" + + parts = [ + "🧪 Тестовый режим: результат проверки\n", + f"{icon} Вердикт: {verdict}\n", + ] + if reason: + parts.append(f"📝 Причина (ИИ): {reason}\n") + + # Детерминированный слой (MediaPipe CV + ARKit-формы) + if cv_approved is True: + line = "🎯 CV-проверка: ✅ пройдена" + if cv_reason: + line += f" — {cv_reason}" + parts.append(line + "\n") + elif cv_approved is False: + parts.append("🎯 CV-проверка: ❌ отклонена\n") + if cv_reason: + parts.append(f"↳ {cv_reason}\n") else: - detail = "" - metrics_block = '' + parts.append("🎯 CV-проверка: —\n") + + if forced: + parts.append("ℹ️ На сайте включён режим принудительной модерации: в реальном режиме фото ушло бы модератору, а не было опубликовано сразу.\n") + + # Тайминги этапов + if timings: + names = [ + ('cv', "CV (геометрия)"), + ('expression', "Гримасы (ARKit-формы)"), + ('ai', "ИИ (llama.cpp)"), + ('total', "Всего"), + ] + tl = ["📊 Технические показатели (по этапам)"] + for k, name in names: + v = timings.get(k) + if v is not None: + tl.append(f"⏱ {name}: {float(v):.2f} сек") + if len(tl) > 1: + parts.append("\n".join(tl) + "\n") + + # Токены и скорость (сайт передаёт их из ответа llama.cpp) if ai_metrics: - ml = ["📊 Технические показатели"] - tt = ai_metrics.get('total_time') - if tt is not None: - ml.append(f"⏱ Время ответа: {tt:.2f} сек") + ml = ["🔢 ИИ: токены и скорость"] pt = ai_metrics.get('prompt_tokens') gps = ai_metrics.get('prompt_per_second') if pt is not None: s = f"⬇️ Приём (промпт): {pt} токенов" if gps is not None: - s += f" — {gps:.1f} ток/сек" + s += f" — {float(gps):.1f} ток/сек" ml.append(s) ct = ai_metrics.get('completion_tokens') ggs = ai_metrics.get('predicted_per_second') if ct is not None: s = f"⬆️ Отдача (генерация): {ct} токенов" if ggs is not None: - s += f" — {ggs:.1f} ток/сек" + s += f" — {float(ggs):.1f} ток/сек" ml.append(s) if len(ml) > 1: - metrics_block = "\n".join(ml) + "\n\n" - return ( - f"🧪 Тестовый режим: вердикт ИИ\n\n" - f"{icon} Вердикт: {verdict}\n" - f"{detail}" - f"📝 Причина: {reason}\n\n" - f"{metrics_block}" - f"⚠️ Тестовый режим: фото не публиковалось и никуда не отправлялось.\n\n" - f"{emojis.get('2', '2')} — Загрузить другое фото\n" - f"{emojis.get('0', '0')} — В главное меню" - ) + parts.append("\n".join(ml) + "\n") + + parts.append("⚠️ Тестовый режим: фото не публиковалось и никуда не отправлялось.\n\n") + parts.append(f"{emojis.get('2', '2')} — Загрузить другое фото\n") + parts.append(f"{emojis.get('9', '9')} — Назад\n") + parts.append(f"{emojis.get('0', '0')} — В главное меню") + return "\n".join(parts) def _photo_test_ai_error(emojis: dict, error: str = '', elapsed: float = None) -> str: detail = '' @@ -355,9 +381,9 @@ def _photo_test_ai_error(emojis: dict, error: str = '', elapsed: float = None) - if elapsed is not None: detail += f"⏱ Время до ошибки: {elapsed:.1f} сек\n" return ( - f"❌ Ошибка обращения к ИИ.\n\n" + f"❌ Ошибка проверки фото.\n\n" f"{detail}\n" - f"Проверьте доступность ИИ-сервиса. Повторите попытку позже.\n\n" + f"Сервис проверки временно недоступен. Повторите попытку позже.\n\n" f"{emojis['1']} — Повторить проверку\n" f"{emojis['2']} — Загрузить другое фото\n" f"{emojis['0']} — В главное меню" @@ -448,12 +474,13 @@ PHOTO_TEST_PREVIEW_TEXT = _photo_test_preview(EMOJI_DIGITS) PHOTO_TEST_AI_CHECKING_TEXT = _photo_test_ai_checking(EMOJI_DIGITS) PHOTO_TEST_RECEIVING_TEXT = _photo_test_receiving(EMOJI_DIGITS) -def photo_test_result(verdict: str, reason: str, mimi: str = '', grafika: str = '', - ai_metrics: dict = None) -> str: - return _photo_test_result(verdict, reason, mimi, grafika, ai_metrics, EMOJI_DIGITS) +def photo_test_result(verdict: str, ai_reason: str = '', cv_approved=None, cv_reason: str = '', + forced=None, timings: dict = None, ai_metrics: dict = None) -> str: + return _photo_test_result(verdict, ai_reason, cv_approved, cv_reason, + forced, timings, ai_metrics, EMOJI_DIGITS) -def photo_test_ai_error(error: str = '', elapsed: float = None) -> str: - return _photo_test_ai_error(EMOJI_DIGITS, error, elapsed) +def photo_test_ai_error(error: str = '') -> str: + return _photo_test_ai_error(EMOJI_DIGITS, error) # =========================================================