feat(ingest+поиск): приём ZIP через интерфейс, регистронезависимый поиск, чистка очереди

- Загрузка архива (§4.1): POST /ingest распаковывает ZIP, дораспознаёт и
  догружает прайсы в базу (append). Виджет в админке. Кэш эмбеддингов справочника
  (dict_emb.npy) — догрузка считает только новый файл, эмбеддинги best-effort с
  откатом на fuzzy при сбое Gemini.
- Поиск по name_norm (lowercase): «УЗИ» и «узи» дают одинаковый результат —
  SQLite LIKE не сворачивает регистр для кириллицы.
- Фильтр названий: код-мнемоники («МОЗО.15») больше не попадают в очередь.
- /stats считается из базы — цифры на дашборде обновляются после загрузки.
- Деплой через Dockerfile (зависимости в образе, код монтируется томом).
- ruff: ядро и весь репозиторий проходят проверку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-27 11:35:12 +05:00
parent 89928511ba
commit 69a9884db5
25 changed files with 2217 additions and 182 deletions
+42
View File
@@ -51,6 +51,21 @@
<h2 class="text-2xl font-bold mt-6">Дашборд обработки</h2>
<div id="stats" class="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4"></div>
<!-- Загрузка нового архива (ТЗ §4.1) -->
<div class="bg-white border border-[#ebedf0] rounded-2xl shadow-sm p-5 mt-6">
<div class="flex items-center justify-between gap-4 flex-wrap">
<div>
<h3 class="text-lg font-semibold">Загрузить архив прайсов</h3>
<p class="text-sm text-tg mt-1">ZIP с прайсами клиник (pdf, xlsx, xls, docx). Система распакует, распознает и догрузит позиции в базу.</p>
</div>
<div class="shrink-0">
<input id="zip" type="file" accept=".zip" class="hidden" onchange="uploadArchive(this)">
<button onclick="$('zip').click()" class="bg-nomad text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-nomad-dark">Выбрать ZIP</button>
</div>
</div>
<div id="ingest-result" class="mt-3 text-sm hidden"></div>
</div>
<div class="flex items-center justify-between mt-8">
<h3 class="text-lg font-semibold">Очередь верификации</h3>
<div id="vprogress" class="text-sm text-tg"></div>
@@ -145,6 +160,33 @@ async function loadAdmin() {
loadQueue();
}
async function uploadArchive(input) {
const f = input.files[0];
if (!f) return;
const box = $('ingest-result');
box.classList.remove('hidden');
box.innerHTML = `<span class="text-tg">⏳ Обрабатываю <b>${f.name}</b> — распаковка, распознавание и нормализация. Может занять до минуты…</span>`;
const fd = new FormData();
fd.append('file', f);
try {
const res = await fetch(`${API}/ingest`, { method: 'POST', body: fd });
const r = await res.json().catch(() => ({}));
if (!res.ok) {
box.innerHTML = `<span class="text-[#dc2626]">Ошибка: ${r.detail || res.status}</span>`;
} else {
const note = r.embeddings ? '' : ' <span class="text-tg">(нормализация по кодам и нечёткому совпадению — без эмбеддингов)</span>';
box.innerHTML = `<div class="bg-nomad-soft border border-[#ffd9cc] rounded-lg p-3">
✓ Архив обработан: <b>${r.documents}</b> прайс(ов), <b>${r.items}</b> позиций,
автонормализация <b class="text-nomad">${r.auto_matched_pct}%</b>${r.new_partners ? ', новых клиник ' + r.new_partners : ''}.${note}
<div class="text-tg mt-1">Новые позиции уже доступны в поиске и в очереди верификации.</div></div>`;
loadAdmin(); // обновить дашборд и очередь под новые данные
}
} catch (e) {
box.innerHTML = `<span class="text-[#dc2626]">Сбой загрузки: ${e}</span>`;
}
input.value = '';
}
async function loadQueue() {
const items = await fetch(`${API}/unmatched?limit=25`).then(r => r.json());
$('queue').innerHTML = items.map(renderCard).join('') || '<div class="text-tg p-4">Очередь пуста 🎉</div>';