Тест-режим: проверка через /api/photo-bot/check/ сайта (CV+гримасы+ИИ), метрики из llama.cpp, удаление прямого llama.cpp-клиента

This commit is contained in:
dddennnisss
2026-08-22 20:47:57 +07:00
parent ab58203982
commit f18c564ead
9 changed files with 117 additions and 205 deletions
Binary file not shown.
-148
View File
@@ -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 = '</' + 'think>'
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}
+20 -14
View File
@@ -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
+33
View File
@@ -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}"}