Тест-режим: проверка через /api/photo-bot/check/ сайта (CV+гримасы+ИИ), метрики из llama.cpp, удаление прямого llama.cpp-клиента
This commit is contained in:
Binary file not shown.
@@ -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. КОНТРОЛЬ ДОСТУПА
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
@@ -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
|
||||
|
||||
@@ -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}"}
|
||||
|
||||
Binary file not shown.
+64
-37
@@ -283,7 +283,7 @@ def _photo_test_preview(emojis: dict) -> str:
|
||||
return (
|
||||
f"🧪 <b>Тестовый режим</b>\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 (
|
||||
"🧪 <i>Отправляю фото напрямую в ИИ (тестовый режим)...\n"
|
||||
"🧪 <i>Отправляю фото на проверку по сайту (тестовый режим)...\n"
|
||||
"Фото проходят полный контроль: CV, гримасы, ИИ.\n"
|
||||
"Обычно это занимает 1-3 минуты. Пожалуйста, подождите...</i>"
|
||||
)
|
||||
|
||||
def _photo_test_receiving(emojis: dict) -> str:
|
||||
return "🧪 <i>Ищу лицо на фото (тестовый режим), пожалуйста, подождите...</i>"
|
||||
|
||||
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"🙂 <b>Мимика:</b> {mimi}")
|
||||
if grafika:
|
||||
lines.append(f"🖼 <b>Графика:</b> {grafika}")
|
||||
if lines:
|
||||
detail = "\n".join(lines) + "\n\n"
|
||||
|
||||
parts = [
|
||||
"🧪 <b>Тестовый режим: результат проверки</b>\n",
|
||||
f"{icon} <b>Вердикт:</b> {verdict}\n",
|
||||
]
|
||||
if reason:
|
||||
parts.append(f"📝 <b>Причина (ИИ):</b> {reason}\n")
|
||||
|
||||
# Детерминированный слой (MediaPipe CV + ARKit-формы)
|
||||
if cv_approved is True:
|
||||
line = "🎯 <b>CV-проверка:</b> ✅ пройдена"
|
||||
if cv_reason:
|
||||
line += f" — <i>{cv_reason}</i>"
|
||||
parts.append(line + "\n")
|
||||
elif cv_approved is False:
|
||||
parts.append("🎯 <b>CV-проверка:</b> ❌ отклонена\n")
|
||||
if cv_reason:
|
||||
parts.append(f"↳ <i>{cv_reason}</i>\n")
|
||||
else:
|
||||
detail = ""
|
||||
metrics_block = ''
|
||||
parts.append("🎯 <b>CV-проверка:</b> —\n")
|
||||
|
||||
if forced:
|
||||
parts.append("ℹ️ <i>На сайте включён режим принудительной модерации: в реальном режиме фото ушло бы модератору, а не было опубликовано сразу.</i>\n")
|
||||
|
||||
# Тайминги этапов
|
||||
if timings:
|
||||
names = [
|
||||
('cv', "CV (геометрия)"),
|
||||
('expression', "Гримасы (ARKit-формы)"),
|
||||
('ai', "ИИ (llama.cpp)"),
|
||||
('total', "Всего"),
|
||||
]
|
||||
tl = ["📊 <b>Технические показатели (по этапам)</b>"]
|
||||
for k, name in names:
|
||||
v = timings.get(k)
|
||||
if v is not None:
|
||||
tl.append(f"⏱ {name}: <b>{float(v):.2f} сек</b>")
|
||||
if len(tl) > 1:
|
||||
parts.append("\n".join(tl) + "\n")
|
||||
|
||||
# Токены и скорость (сайт передаёт их из ответа llama.cpp)
|
||||
if ai_metrics:
|
||||
ml = ["📊 <b>Технические показатели</b>"]
|
||||
tt = ai_metrics.get('total_time')
|
||||
if tt is not None:
|
||||
ml.append(f"⏱ Время ответа: <b>{tt:.2f} сек</b>")
|
||||
ml = ["🔢 <b>ИИ: токены и скорость</b>"]
|
||||
pt = ai_metrics.get('prompt_tokens')
|
||||
gps = ai_metrics.get('prompt_per_second')
|
||||
if pt is not None:
|
||||
s = f"⬇️ Приём (промпт): <b>{pt} токенов</b>"
|
||||
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"⬆️ Отдача (генерация): <b>{ct} токенов</b>"
|
||||
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"🧪 <b>Тестовый режим: вердикт ИИ</b>\n\n"
|
||||
f"{icon} <b>Вердикт:</b> {verdict}\n"
|
||||
f"{detail}"
|
||||
f"📝 <b>Причина:</b> {reason}\n\n"
|
||||
f"{metrics_block}"
|
||||
f"⚠️ <i>Тестовый режим: фото не публиковалось и никуда не отправлялось.</i>\n\n"
|
||||
f"{emojis.get('2', '2')} — Загрузить другое фото\n"
|
||||
f"{emojis.get('0', '0')} — В главное меню"
|
||||
)
|
||||
parts.append("\n".join(ml) + "\n")
|
||||
|
||||
parts.append("⚠️ <i>Тестовый режим: фото не публиковалось и никуда не отправлялось.</i>\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"⏱ Время до ошибки: <b>{elapsed:.1f} сек</b>\n"
|
||||
return (
|
||||
f"❌ <b>Ошибка обращения к ИИ.</b>\n\n"
|
||||
f"❌ <b>Ошибка проверки фото.</b>\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)
|
||||
|
||||
|
||||
# =========================================================
|
||||
|
||||
Reference in New Issue
Block a user