Подсказка названия по фотографии

Фото выбрано в форме — приложение показывает его модели и предлагает название
с прочитанными надписями. «Подставить» переносит в поля, «Не надо» прячет.
Само не подставляется, и это принципиально: модели домысливают марки
и артикулы, когда надписей не видно. В замере клипсу для кабеля модель назвала
«конструктором LEGO Technic, 8376, 401 шт.». Запрет на выдумывание в промпте
помог: та же вещь стала «красной щёткой для посуды».

Смотрит gemma3:4b. Выбрана замером на настоящих снимках из базы:
  gemma3:4b     3.5 с, отвечает всегда, надписи читает верно
                («Лампа светодиодная C35, Нейтральный белый, 9 Вт LED, E14»);
  qwen3-vl:8b   11-32 с и часто пустой ответ — это ризонер, ему нужен бюджет
                в 2000+ токенов, и уменьшение картинки не спасает.
Проверялась и гипотеза, что дело в размере снимка: у gemma3 разницы между
1280 и 512 px почти нет, у qwen3-vl время падает с 62 до 41 с, но ответ
всё равно пуст. Картинка всё же уменьшается до 768 px — незачем гонять лишнее.

Генерация тегов чинилась по ходу: на «Стабилизатор 7805 (TO-220)» модель
отвечала массивом объектов с пояснениями, не укладывалась в 256 токенов,
и JSON обрывался на полуслове — теги выходили пустыми. Бюджет поднят до 500,
промпт требует плоский массив строк, а из битого ответа строки теперь
вытаскиваются регуляркой: лучше несколько живых тегов, чем ничего.

Pillow добавлен в зависимости — для уменьшения картинки перед распознаванием.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 01:24:49 +03:00
co-authored by Claude Opus 5
parent 01d7f17f89
commit bae5116d77
9 changed files with 329 additions and 8 deletions
+5
View File
@@ -14,5 +14,10 @@ OLLAMA_HOST=http://localhost:11434
# ollama pull bge-m3 # ollama pull bge-m3
# HLAMINGO_EMBED_MODEL=bge-m3 # HLAMINGO_EMBED_MODEL=bge-m3
# Модель, которая смотрит на фотографии и подсказывает название вещи.
# Необязательна — пустое значение выключает подсказку:
# ollama pull gemma3:4b
# HLAMINGO_VISION_MODEL=gemma3:4b
# Порт веб-интерфейса. # Порт веб-интерфейса.
# HLAMINGO_PORT=5000 # HLAMINGO_PORT=5000
+27
View File
@@ -18,6 +18,7 @@
``` ```
ollama pull qwen3:8b разбор фраз и теги ollama pull qwen3:8b разбор фраз и теги
ollama pull bge-m3 поиск по смыслу ollama pull bge-m3 поиск по смыслу
ollama pull gemma3:4b подсказка названия по фото (необязательно)
``` ```
### Какую модель выбрать ### Какую модель выбрать
@@ -102,6 +103,7 @@ start.cmd запускает сервер и печатает адреса
| `OLLAMA_HOST` | `http://localhost:11434` | адрес Ollama | | `OLLAMA_HOST` | `http://localhost:11434` | адрес Ollama |
| `HLAMINGO_CHAT_MODEL` | `qwen3:8b` | разбор фраз и теги | | `HLAMINGO_CHAT_MODEL` | `qwen3:8b` | разбор фраз и теги |
| `HLAMINGO_EMBED_MODEL` | `bge-m3` | поиск по смыслу | | `HLAMINGO_EMBED_MODEL` | `bge-m3` | поиск по смыслу |
| `HLAMINGO_VISION_MODEL` | `gemma3:4b` | подсказка по фото; пусто — выключено |
| `HLAMINGO_PORT` | `5000` | порт веб-интерфейса | | `HLAMINGO_PORT` | `5000` | порт веб-интерфейса |
| `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами | | `HLAMINGO_DATA_DIR` | — | другая папка для данных и `.env`; используется тестами |
@@ -175,6 +177,31 @@ start.cmd запускает сервер и печатает адреса
Модель разбирает фразу за ~1,5 с, места из неё создаются сами. Модель разбирает фразу за ~1,5 с, места из неё создаются сами.
### Подсказка по фотографии
Когда фото выбрано в форме заведения вещи, приложение показывает его модели
и предлагает название с прочитанными надписями:
```
фото коробки лампы → «Лампочка LED E14»
надписи: Лампа светодиодная C35, Нейтральный белый, 9 Вт LED, E14
```
Кнопка **«✓ Подставить»** переносит это в поля, **«Не надо»** прячет.
Само оно не подставляется, и это принципиально: модели домысливают марки
и артикулы, когда надписей не видно. В замере клипсу для кабеля модель
назвала «конструктором LEGO Technic, 8376, 401 шт.». Поэтому — только
подсказка, решает человек.
Смотрит **`gemma3:4b`** (`HLAMINGO_VISION_MODEL`, 3,3 ГБ). Выбрана замером
на настоящих снимках: 3,5 с и всегда отвечает, тогда как `qwen3-vl:8b` — 1132 с
и часто пустой ответ, потому что это ризонер, которому нужен бюджет
в 2000+ токенов. Перед отправкой картинка уменьшается до 768 px: снимок
разворачивается в токены, а для чтения крупных надписей столько не нужно.
Распознавание отключается пустым `HLAMINGO_VISION_MODEL` — тогда блок
подсказки просто не появляется.
### Фото ### Фото
Две кнопки: **«📷 Снять»** открывает камеру, **«🖼»** — готовые снимки в галерее. Две кнопки: **«📷 Снять»** открывает камеру, **«🖼»** — готовые снимки в галерее.
+56
View File
@@ -495,7 +495,12 @@ def api_retag_item(item_id):
return jsonify({"ok": False, return jsonify({"ok": False,
"reply": "Модель недоступна — теги придумывать некому."}), 503 "reply": "Модель недоступна — теги придумывать некому."}), 503
# Вторая попытка не роскошь: если только что распознавали фото, в памяти
# Ollama лежит vision-модель, и первый запрос уходит на её выгрузку
# и загрузку текстовой — он может вернуться пустым.
fresh = brain.aliases(item.get("name"), item.get("note")) fresh = brain.aliases(item.get("name"), item.get("note"))
if not fresh:
fresh = brain.aliases(item.get("name"), item.get("note"))
if not fresh: if not fresh:
return jsonify({"ok": False, "reply": "Модель не вернула ни одного тега."}) return jsonify({"ok": False, "reply": "Модель не вернула ни одного тега."})
@@ -611,6 +616,57 @@ def _drop_photo_file(filename):
logger.warning("Не удалось удалить фото %s: %s", filename, e) logger.warning("Не удалось удалить фото %s: %s", filename, e)
@app.route("/api/recognize", methods=["POST"])
def api_recognize():
"""Что на снимке — подсказка для поля «Предмет».
Ничего не сохраняет: возвращает предложение, а решает человек. Модели
домысливают бренды и артикулы, когда надписей не видно, поэтому запись
вслепую сделала бы базу хуже, а не лучше.
"""
upload = request.files.get("photo")
if not upload or not upload.filename:
return jsonify({"ok": False, "reply": "Файл не пришёл."}), 400
if not config.VISION_MODEL:
return jsonify({"ok": False, "reply": "Распознавание выключено."}), 503
if not ollama.check():
return jsonify({"ok": False, "reply": "Модель недоступна."}), 503
guess = brain.describe_photo(upload.read())
if not guess:
return jsonify({"ok": False, "reply": "Не смог разобрать, что на фото."})
reply = "Похоже на «%s»" % guess["name"]
if guess.get("text"):
reply += ". Надписи: %s" % guess["text"]
if not guess.get("sure"):
reply += " (не уверен — проверь)"
return jsonify({"ok": True, "guess": guess, "reply": reply})
@app.route("/api/item/<item_id>/recognize", methods=["POST"])
def api_recognize_saved(item_id):
"""То же, но для фотографии, которая уже прикреплена к вещи."""
db = store.load()
item = db["items"].get(item_id)
if not item or not item.get("photo"):
return jsonify({"ok": False, "reply": "У этой записи нет фото."}), 404
if not config.VISION_MODEL or not ollama.check():
return jsonify({"ok": False, "reply": "Модель недоступна."}), 503
path = PHOTOS_DIR / item["photo"]
if not path.exists():
return jsonify({"ok": False, "reply": "Файл фотографии не найден."}), 404
guess = brain.describe_photo(path.read_bytes())
if not guess:
return jsonify({"ok": False, "reply": "Не смог разобрать, что на фото."})
return jsonify({"ok": True, "guess": guess,
"reply": "На фото похоже на «%s»%s" % (
guess["name"],
(". Надписи: " + guess["text"]) if guess.get("text") else "")})
@app.route("/api/photo", methods=["DELETE"]) @app.route("/api/photo", methods=["DELETE"])
def api_delete_photo(): def api_delete_photo():
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
+74 -4
View File
@@ -71,7 +71,9 @@ note — уточнение, если есть, иначе null.
ALIAS_SYSTEM = """Ты придумываешь альтернативные названия вещи, чтобы её потом можно было найти. ALIAS_SYSTEM = """Ты придумываешь альтернативные названия вещи, чтобы её потом можно было найти.
Верни ТОЛЬКО JSON-массив строк, 3-7 штук, без пояснений. Верни ТОЛЬКО плоский JSON-массив СТРОК, 3-7 штук: ["первое","второе","третье"].
НЕ объекты, НЕ поля type/function/description только строки. Без пояснений
до и после массива: любой лишний текст ломает разбор.
Включай: обиходные названия, синонимы, маркировку и артикулы, расшифровку Включай: обиходные названия, синонимы, маркировку и артикулы, расшифровку
сокращений, а для деталей что это по функции. сокращений, а для деталей что это по функции.
@@ -286,6 +288,53 @@ def _split_places(text):
return parts return parts
# ============================================================
# Подсказка по фотографии
# ============================================================
LOOK_PROMPT = (
"Посмотри на фото и определи, что это за предмет. Ответь ТОЛЬКО JSON, "
"без пояснений:\n"
'{"name": "...", "text": "...", "sure": true}\n'
"name — как назвал бы вещь человек, который кладёт её в коробку: коротко, "
"по-русски, в именительном падеже. Если на упаковке есть марка и параметры "
"(мощность, размер, цоколь) — включи их в название.\n"
"text — все надписи и маркировка, которые действительно видно на предмете, "
"через запятую. Если надписей нет — пустая строка.\n"
"sure — true, если предмет узнан уверенно; false, если это догадка.\n"
"НЕ ВЫДУМЫВАЙ марки, артикулы и модели, которых не видно на фото: "
"лучше общее название («красная щётка»), чем придуманный артикул."
)
def describe_photo(image_bytes):
"""Что на фотографии: {name, text, sure} или None.
Это подсказка, а не готовая запись: модели склонны домысливать бренды
и артикулы, когда надписей не видно. Проверено на реальных снимках
клипсу для кабеля модель называла «конструктором LEGO Technic 8376».
Поэтому результат всегда показывается человеку на подтверждение.
"""
answer = ollama.look(image_bytes, LOOK_PROMPT)
if not answer:
return None
data = _extract_json(answer)
if not isinstance(data, dict):
# Модель ответила прозой — берём первую строку как название.
line = _strip_thoughts(answer).strip().splitlines()[0] if answer.strip() else ""
line = line.strip(" .;")
return {"name": line[:80], "text": "", "sure": False} if line else None
name = str(data.get("name") or "").strip(" .;")
if not name:
return None
return {
"name": name[:80],
"text": str(data.get("text") or "").strip()[:300],
"sure": bool(data.get("sure", False)),
}
# ============================================================ # ============================================================
# Синонимы # Синонимы
# ============================================================ # ============================================================
@@ -296,12 +345,33 @@ def aliases(name, note=None, model=None):
return [] return []
model = model or config.CHAT_MODEL model = model or config.CHAT_MODEL
prompt = name + (" (" + note + ")" if note else "") prompt = name + (" (" + note + ")" if note else "")
# 500, а не 256: модель может ответить многословно (объектами вместо строк),
# и тогда на 256 токенах JSON обрывается на полуслове — разбирать нечего.
content = ollama.chat(model, [ content = ollama.chat(model, [
{"role": "system", "content": ALIAS_SYSTEM}, {"role": "system", "content": ALIAS_SYSTEM},
{"role": "user", "content": prompt}, {"role": "user", "content": prompt},
], num_predict=256) ], num_predict=500)
data = _extract_json(content, "[", "]") if content else None if not content:
return _clean_tags(data, name) return []
tags = _clean_tags(_extract_json(content, "[", "]"), name)
return tags or _salvage_tags(content, name)
_QUOTED_RE = re.compile(r'"([^"\\]{2,60})"')
_SERVICE_WORDS = {"name", "type", "function", "description", "synonym",
"synonyms", "aliases", "tags", "value", "text"}
def _salvage_tags(content, name):
"""Достаёт теги из оборванного или неверно оформленного ответа.
Модель иногда не укладывается в лимит, и JSON приходит битым. Строки в нём
всё равно есть вытаскиваем их, отбрасывая имена полей вроде "name".
Лучше несколько живых тегов, чем ничего.
"""
text = _strip_thoughts(content or "")
found = [m for m in _QUOTED_RE.findall(text) if m.strip().lower() not in _SERVICE_WORDS]
return _clean_tags(found, name)
def _clean_tags(data, name): def _clean_tags(data, name):
+16
View File
@@ -152,6 +152,22 @@ OLLAMA_HOST = (os.environ.get("OLLAMA_HOST") or "http://localhost:11434").rstrip
CHAT_MODEL = os.environ.get("HLAMINGO_CHAT_MODEL") or "qwen3:8b" CHAT_MODEL = os.environ.get("HLAMINGO_CHAT_MODEL") or "qwen3:8b"
CHAT_MODEL_ALT = "qwen2.5:7b" # быстрее, но русский хуже CHAT_MODEL_ALT = "qwen2.5:7b" # быстрее, но русский хуже
# Модель, которая смотрит на фотографии: подсказывает название вещи и читает
# надписи с упаковки. Пустая строка — распознавание выключено.
#
# gemma3:4b выбрана по замеру на настоящих снимках из базы:
# gemma3:4b 3.5 с, отвечает всегда, надписи читает верно («IEK, C35,
# 9 Вт LED, E14»), занимает 3.3 ГБ;
# qwen3-vl:8b 11-32 с и часто пустой ответ: это ризонер, ему нужен бюджет
# в 2000+ токенов, и даже уменьшение картинки не спасает.
# Обе выдумывают, когда надписей нет, поэтому результат — только подсказка,
# которую человек подтверждает.
VISION_MODEL = os.environ.get("HLAMINGO_VISION_MODEL", "gemma3:4b").strip()
VISION_BUDGET = 400 # хватает на название и список надписей
# Картинку перед отправкой уменьшаем: снимок с телефона разворачивается
# в тысячи токенов, а для чтения крупных надписей столько не нужно.
VISION_MAX_SIDE = 768
# bge-m3 — многоязычная модель, на русском заметно лучше прежней # bge-m3 — многоязычная модель, на русском заметно лучше прежней
# nomic-embed-text. Замер на одном и том же складе: # nomic-embed-text. Замер на одном и том же складе:
# nomic-embed-text: «велосипедная камера» набирала мультиметру 0.704, а верный # nomic-embed-text: «велосипедная камера» набирала мультиметру 0.704, а верный
+77 -1
View File
@@ -4,8 +4,10 @@
активно развивается, и общий модуль сделал бы hlamingo заложником её рефакторингов. активно развивается, и общий модуль сделал бы hlamingo заложником её рефакторингов.
""" """
import io
import json import json
import time import time
import base64
import socket import socket
import logging import logging
import urllib.parse import urllib.parse
@@ -29,6 +31,11 @@ logger = logging.getLogger(__name__)
# Поэтому: сначала быстрая проверка TCP (полтора секунды вместо двух минут), # Поэтому: сначала быстрая проверка TCP (полтора секунды вместо двух минут),
# а после неудачи — пауза, в течение которой к серверу вообще не ходим. # а после неудачи — пауза, в течение которой к серверу вообще не ходим.
# ============================================================ # ============================================================
# Бюджет ответа для моделей, которые всегда размышляют: размышления идут
# в счёт того же лимита, и обычных 512 токенов им не хватает даже на короткий
# ответ. Замер на qwen3-vl: при 200 токенах content пуст, при 400 — уже ответ.
THINKING_BUDGET = 1200
CONNECT_TIMEOUT = 1.5 # столько ждём само соединение CONNECT_TIMEOUT = 1.5 # столько ждём само соединение
DOWN_COOLDOWN = 30.0 # столько не тревожим сервер после неудачи DOWN_COOLDOWN = 30.0 # столько не тревожим сервер после неудачи
UP_CACHE = 5.0 # столько верим, что сервер жив, без повторной проверки UP_CACHE = 5.0 # столько верим, что сервер жив, без повторной проверки
@@ -142,12 +149,81 @@ def chat(model, messages, timeout=CHAT_TIMEOUT, num_predict=MAX_OUTPUT_TOKENS,
with urllib.request.urlopen(req, timeout=timeout) as r: with urllib.request.urlopen(req, timeout=timeout) as r:
resp = json.loads(r.read().decode("utf-8")) resp = json.loads(r.read().decode("utf-8"))
_mark_up() _mark_up()
return (resp.get("message") or {}).get("content", "")
except Exception as e: except Exception as e:
_mark_down() _mark_down()
logger.warning("Запрос к модели %s не удался: %s", model, e) logger.warning("Запрос к модели %s не удался: %s", model, e)
return None return None
message = resp.get("message") or {}
content = message.get("content", "")
# Модели вроде qwen3-vl размышляют всегда, и think=False их не выключает:
# весь бюджет уходит в поле thinking, а content остаётся пустым при
# done_reason="length". Один раз повторяем с запасом — иначе такая модель
# выглядит нерабочей, хотя дело только в лимите.
if not content.strip() and message.get("thinking") \
and resp.get("done_reason") == "length" and num_predict < THINKING_BUDGET:
logger.info("Модель %s ушла в размышления — повторяю с бюджетом %d",
model, THINKING_BUDGET)
return chat(model, messages, timeout=timeout,
num_predict=THINKING_BUDGET, think=think)
return content
def look(image_bytes, prompt, model=None, budget=None, timeout=180):
"""Показать модели картинку и получить текстовый ответ. None при неудаче.
Картинка уменьшается перед отправкой: изображение разворачивается в токены,
и снимок с телефона стоит их тысячами без пользы для чтения крупных надписей.
"""
model = model or config.VISION_MODEL
if not model or not image_bytes or not available():
return None
small = _shrink(image_bytes, config.VISION_MAX_SIDE)
payload = {
"model": model,
"stream": False,
"messages": [{"role": "user", "content": prompt,
"images": [base64.b64encode(small).decode("ascii")]}],
"options": {"num_predict": budget or config.VISION_BUDGET, "temperature": 0},
}
req = urllib.request.Request(
config.OLLAMA_HOST + "/api/chat",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
resp = json.loads(r.read().decode("utf-8"))
_mark_up()
return ((resp.get("message") or {}).get("content") or "").strip()
except Exception as e:
_mark_down()
logger.warning("Не удалось распознать фото моделью %s: %s", model, e)
return None
def _shrink(image_bytes, side):
"""Уменьшает картинку по длинной стороне. Без Pillow отдаёт как есть."""
try:
from PIL import Image
except ImportError:
return image_bytes
try:
img = Image.open(io.BytesIO(image_bytes))
img = img.convert("RGB")
if max(img.size) > side:
ratio = side / float(max(img.size))
img = img.resize((max(1, int(img.width * ratio)),
max(1, int(img.height * ratio))), Image.LANCZOS)
out = io.BytesIO()
img.save(out, format="JPEG", quality=85)
return out.getvalue()
except Exception as e:
logger.warning("Не удалось уменьшить картинку: %s", e)
return image_bytes
def embed(text, is_query=False): def embed(text, is_query=False):
"""Вектор текста или None. is_query=True для поисковых запросов. """Вектор текста или None. is_query=True для поисковых запросов.
+1
View File
@@ -1 +1,2 @@
flask flask
pillow
+44
View File
@@ -209,6 +209,7 @@
<button class="act ghost small" id="formPhotoClear" style="flex:0 0 26%" hidden>Убрать</button> <button class="act ghost small" id="formPhotoClear" style="flex:0 0 26%" hidden>Убрать</button>
</div> </div>
<div id="formPhotoPreview" class="photo" hidden></div> <div id="formPhotoPreview" class="photo" hidden></div>
<div id="guessBox" class="card" hidden></div>
</div> </div>
<div class="mt"><button class="act" id="goBtn">Положить</button></div> <div class="mt"><button class="act" id="goBtn">Положить</button></div>
@@ -510,11 +511,53 @@ async function takeFormPhoto(input) {
img.src = URL.createObjectURL(pendingPhoto); img.src = URL.createObjectURL(pendingPhoto);
preview.appendChild(img); preview.appendChild(img);
$('#formPhotoClear').hidden = false; $('#formPhotoClear').hidden = false;
recognizePending(); // фото есть — спросим модель, что на нём
} catch (e) { } catch (e) {
say('Не смог прочитать это изображение: ' + e); say('Не смог прочитать это изображение: ' + e);
clearPendingPhoto(); clearPendingPhoto();
} }
} }
// Подсказка по фото: показываем, что увидела модель, и даём подставить это
// в поле названия. Само не подставляется — модель домысливает бренды,
// когда надписей не видно.
async function recognizePending() {
if (!pendingPhoto) return;
const box = $('#guessBox');
box.hidden = false;
box.innerHTML = '<div class="meta">Смотрю на фото…</div>';
try {
const fd = new FormData();
fd.append('photo', pendingPhoto, 'photo.jpg');
const d = await api('/api/recognize', {method: 'POST', body: fd});
if (!d.ok) { box.hidden = true; return; }
const g = d.guess;
box.innerHTML = '<div class="meta">На фото похоже на:</div>'
+ '<h3>' + esc(g.name) + '</h3>'
+ (g.text ? '<div class="meta">Надписи: ' + esc(g.text) + '</div>' : '')
+ (g.sure ? '' : '<div class="meta">Модель не уверена — проверьте.</div>');
const bar = document.createElement('div');
bar.className = 'acts';
const use = document.createElement('button');
use.textContent = '✓ Подставить';
use.onclick = () => {
$('#itemName').value = g.name;
if (g.text && !$('#itemNote').value) $('#itemNote').value = g.text;
box.hidden = true;
$('#itemName').focus();
};
const skip = document.createElement('button');
skip.textContent = 'Не надо';
skip.onclick = () => { box.hidden = true; };
bar.appendChild(use);
bar.appendChild(skip);
box.appendChild(bar);
} catch (e) {
box.hidden = true;
}
}
$('#formCamInput').onchange = () => takeFormPhoto($('#formCamInput')); $('#formCamInput').onchange = () => takeFormPhoto($('#formCamInput'));
$('#formGalInput').onchange = () => takeFormPhoto($('#formGalInput')); $('#formGalInput').onchange = () => takeFormPhoto($('#formGalInput'));
@@ -524,6 +567,7 @@ function clearPendingPhoto() {
$('#formPhotoPreview').hidden = true; $('#formPhotoPreview').hidden = true;
$('#formPhotoPreview').innerHTML = ''; $('#formPhotoPreview').innerHTML = '';
$('#formPhotoClear').hidden = true; $('#formPhotoClear').hidden = true;
$('#guessBox').hidden = true;
} }
// Отправляет уже сжатый blob к готовой записи. Возвращает имя файла или null. // Отправляет уже сжатый blob к готовой записи. Возвращает имя файла или null.
+29 -3
View File
@@ -116,13 +116,39 @@ r.check("пустое имя — 400",
r.check("занятое имя — 400", r.check("занятое имя — 400",
c.open("/api/item/" + item, method="PATCH", json={"name": "лопата"}).status_code == 400) c.open("/api/item/" + item, method="PATCH", json={"name": "лопата"}).status_code == 400)
print("\n== Подсказка по фотографии ==")
HAS_VISION = bool(config.VISION_MODEL) and HAS_AI and config.VISION_MODEL in ollama.models()
if HAS_VISION:
real = None
for candidate in ("31ee37dada70.jpg", "afbaf962ad16.jpg"):
path = config.PROJECT_DIR / "static" / "photos" / candidate
if path.exists():
real = path
break
if real:
d = c.post("/api/recognize", content_type="multipart/form-data",
data={"photo": (io.BytesIO(real.read_bytes()), "p.jpg")}).get_json()
r.check("модель что-то увидела", d.get("ok") and d["guess"]["name"], d)
r.check("название не пустое и короткое",
0 < len(d["guess"]["name"]) <= 80, d["guess"]["name"])
print(" предложила: %s" % d["guess"]["name"])
else:
print(" (реальных фото нет — пропускаю)")
else:
print(" (vision-модель недоступна — пропускаю)")
r.check("без файла — 400", c.post("/api/recognize", content_type="multipart/form-data",
data={}).status_code == 400)
r.check("для записи без фото — 404",
c.post("/api/item/нет-такой/recognize").status_code == 404)
print("\n== Пересоздание тегов ==") print("\n== Пересоздание тегов ==")
if HAS_AI: if HAS_AI:
j("PATCH", "/api/item/" + item, aliases=["мусорный тег"]) j("PATCH", "/api/item/" + item, aliases=["мусорный тег"])
d = c.post("/api/item/" + item + "/retag").get_json() d = c.post("/api/item/" + item + "/retag").get_json()
r.check("теги пересозданы", d.get("ok") and d["item"]["aliases"], d) r.check("теги пересозданы", d.get("ok") and d.get("item", {}).get("aliases"), d)
r.check("мусорный тег заменён", "мусорный тег" not in d["item"]["aliases"], r.check("мусорный тег заменён",
d["item"]["aliases"]) "мусорный тег" not in d.get("item", {}).get("aliases", ["мусорный тег"]),
d.get("item"))
d = c.post("/api/item/" + item + "/retag?keep=1").get_json() d = c.post("/api/item/" + item + "/retag?keep=1").get_json()
r.check("режим дополнения работает", d.get("ok"), d) r.check("режим дополнения работает", d.get("ok"), d)
r.check("retag для несуществующей — 404", r.check("retag для несуществующей — 404",