- Python 60.1%
- HTML 22.5%
- Jupyter Notebook 9%
- TypeScript 7.7%
- Mermaid 0.4%
- Other 0.2%
| .claude | ||
| .forgejo/workflows | ||
| backend | ||
| deploy/gpu-server | ||
| docs/bpmn | ||
| eval | ||
| frontend | ||
| research | ||
| research-library | ||
| source-of-truth | ||
| tools/flash-swarm | ||
| .gitignore | ||
| .mcp.json | ||
| AGENTS.md | ||
| ARCHITECTURE.md | ||
| CLAUDE.md | ||
| compose.yaml | ||
| README.md | ||
| report.md | ||
| RESEARCH.md | ||
TenderHack Казань — КТРУ
Команда «Династия» · TenderHack Казань 2026 · кейс Портала поставщиков. Подбор характеристик и кода КТРУ по свободному описанию товара так, чтобы выбор можно было проверить.
| Участник | Роль |
|---|---|
| Камиль Ахмадиев | Фулстак-разработчик |
| Серафим Солдатов | Фронтенд-разработчик |
| Михаил Истомин | Фулстак-разработчик |
Задача
Поставщик пишет свободным текстом: с опечатками и неверной раскладкой («rehnrf мужская»), с отрицаниями («натуральная кожа: нет»), без части характеристик («стол, столешница ЛДСП, бук» — тип каркаса не указан). Каталог строгий: конечные позиции одного семейства КТРУ различаются только значениями характеристик, поэтому код выбирается проверкой значений, а не поиском по названию.
- В выгрузке КТРУ 81 465 записей, из них 54 190 действующих конечных позиций.
- У 7 832 позиций нет блока характеристик: достраивать их по догадке нельзя.
Мы поняли задачу как согласование описания с каталогом:
- Сохранять смысл. Каждое значение подтверждено цитатой из текста.
- Показывать основания. Видно, почему позиция исключена.
- Уточнять недостающее. Не больше двух вопросов.
Путь одного описания
- Описание поставщика. Свободный текст, опечатки, список товаров; можно загрузить CSV.
- Факты с цитатами. Правила и открытая LLM; каждый факт проверяется по тексту.
- Семейство КТРУ. Поиск по словам и по смыслу.
- Код или уточнение. Сверка значений с каталогом, не больше двух вопросов.
Схема BPMN 2.0: docs/bpmn.
- Конвейер. Разделяет ввод на товары и сохраняет ревизию. Правила исправляют раскладку и опечатки и извлекают факты; поиск предлагает семейства КТРУ; открытая модель дополняет факты из описания.
- Движок правил. Проверяет цитаты, принадлежность свойств, значения, единицы и ограничения, затем сужает полный состав подтверждённого семейства.
- Уточнение. Если данных не хватает, пользователь отвечает на вопрос или выбирает «Не знаю».
- Код и готовность карточки — разные состояния. Обязательное поле может требовать заполнения уже после выбора кода. Конечный код ставится, когда в полном подтверждённом семействе остался один кандидат.
- Карточка. Пользователь проверяет и редактирует поля, добавляет характеристики и скачивает JSON или CSV.
Как выбирали решения
Извлечение фактов. Ранний отбор: 24 описания, 75 фактов, одинаковый проверяющий слой.
| Вариант | Все факты верны | Время |
|---|---|---|
| GLiNER2.5 + проверки | 15 из 24 (62,5 %) | ≈ 109 мс |
| Gemma + те же проверки | 22 из 24 (91,7 %), F1 0,980 против 0,932 | ≈ 1,5 с |
Компактная модель переносила массу упаковки на сам товар. Затем открытые LLM сравнили на Golden Set из 264 случаев:
| Модель | Верно | Примечание |
|---|---|---|
| Qwen3-14B-AWQ без рассуждения | 203 | выбрана |
| GigaChat3-10B-A1.8B | 198 | в пределах шума |
| Qwen3-4B | 196 | на 7 меньше |
| Qwen3-14B с рассуждением | 190 | в 6 раз медленнее |
Итог: основной путь — открытая LLM с проверкой по дословной цитате, а код выбирают каталог и правила. Качество измерено на 24 синтетических описаниях, время — в разных замерах; это не парное сравнение.
Поиск семейства. Доля описаний, у которых верное семейство в первых трёх кандидатах. Качество измерено на 37 описаниях, время — на 60. Это поиск семейства, а не конечного кода.
| Вариант | Семейство в первых трёх | Медиана |
|---|---|---|
| Поиск по словам, SQLite FTS5 | 34/37 (91,9 %) | 24,2 мс |
| Слова + E5-small INT8 | 35/37 (94,6 %) | 29,5 мс |
| Гибрид + разбор запроса Qwen3-4B | 31/37 (83,8 %) | 938,8 мс |
- Добавили Qwen3-Embedding-0.6B и контроль товаров вне трёх групп: ложные семейства в test снизились с 15 из 22 до 1 из 22.
- Отклонили Qwen3-Reranker-0.6B: −6 верных семейств на 1 247 реальных карточках Портала.
- Итог. Лёгкий гибридный поиск. LLM в поиске ухудшила качество и замедлила ответ более чем в 30 раз.
Результаты
| Измерение | Результат |
|---|---|
| Семейство выбрано верно (504 описания по позициям КТРУ) | 504/504 |
| Точное множество кодов | 496/504, неверных кодов 0 |
| Accuracy на Golden Dataset | 97,8 % |
| Верный код после уточнений (симуляция диалога) | 184/193, в среднем 0,94 вопроса, не больше двух |
| Товары вне трёх групп: ложное семейство | 1 из 22 |
| Абсурдные значения | 35/35 найдено, 0/38 ложных тревог |
| Веса двух открытых моделей | ≈ 11,2 ГБ при лимите 40 ГБ |
| Гипотезы | проверено 50: принято 41, отклонено 8 |
Скорость измерена на 504 запросах в 8 потоков:
| Режим | Время |
|---|---|
| Правила, медиана | 0,07 с |
| Правила + поиск, медиана | 0,3 с |
| С моделью, медиана | 2,6 с |
| С моделью, p90 | 7,9 с |
Источники: Golden Dataset и RESEARCH.md (эксперименты 09–11.10.2026).
Как учли разбор жюри
| Замечание жюри | Что сделали |
|---|---|
| «Миллион вопросов — это не автоматизация» | Не больше двух вопросов; семейство, названное в тексте, подтверждается без вопроса. 1,66 → 0,94 вопроса, 37 % описаний без вопросов |
| «Подсвечивайте подозрительные характеристики» | Нормы величин по реальным карточкам Портала и проверка «чужого» измерения: 35/35 найдено, 0/38 ложных тревог |
| «Подсветить — не значит заставить» | Код найден, а обязательной характеристики нет: код показан, поле подсвечено, карточку можно выгрузить |
| КТРУ, затем Портал, затем новое | Поля карточки разделены: характеристики КТРУ, поля Портала, новые поля |
Ошибки объясняются:
- «Молоток 1500 кг». Масса намного выше нормы для молотков, система предлагает проверить единицу измерения.
- «50 литров молотка». Объём не свойственен молотку; товар назван верно.
- «Тяга двигателя» у стола. Такого свойства нет ни у одной позиции этой товарной области.
Нормы величин построены по реальным карточкам Портала. Тревога срабатывает, если значение выше трёх 95-х процентилей нормы по существительному или группе либо измерение не свойственно товару. Слишком малые значения не проверяются: в карточках встречаются ошибки ×1000. Пороги подбирались на этом же наборе.
Соответствие требованиям кейса
| Требование | Как выполнено | Чем подтверждено |
|---|---|---|
| Свободный ввод, опечатки | Раскладка и опечатки по словарю каталога; факты с цитатой | Golden 203/264 |
| Характеристики и единицы | Привязка к характеристикам КТРУ и кодам ОКЕИ | 496/504 множеств кодов |
| Сужение до конечного кода | Каталог → семейство → свойства → ответы | воронка в интерфейсе |
| Неоднозначность | Не больше двух вопросов, затем подсказка, какую характеристику дописать | 0,94 вопроса в среднем |
| Ошибки с обоснованием | Абсурдные величины, чужие свойства, противоречия | 35/35, ложных 0/38 |
| Только открытые модели | Qwen3-14B-AWQ и Qwen3-Embedding; проприетарные хосты запрещены в коде | веса ≈ 11 ГБ из 40 |
| Linux и веб-интерфейс | Docker Compose: снимок → индекс → API → nginx | compose.yaml |
| BPMN-схема | BPMN 2.0 текущей архитектуры | docs/bpmn |
Приоритет источников: КТРУ → Портал → новые характеристики. Без low-code и платных поисковых API: Python, FastAPI, React, SQLite FTS5. API не зависит от GPU, модели работают отдельными OpenAI-совместимыми серверами.
Ограничения
Неверный код дороже одного вопроса, поэтому пороги выбраны осторожными. Отклонены 8 из 50 гипотез:
- Reranker: −6 верных семейств.
- Рассуждение у Qwen3-14B: 190 против 203 и в 6 раз медленнее.
- Строгая JSON-схема и голосование: без выигрыша.
- NuExtract3: копирует факты из примеров.
- Порог сходства 0,5: втрое больше ложных семейств.
- Автоподтверждение семейства: 6–15 % неверных кодов.
Ограничения оценки:
- Test — 68 описаний, написанных LLM: один случай ≈ 1,5 п.п.
- Golden Set размечен одним человеком и использовался при настройке.
- Метки реальных карточек — ОКПД2 поставщиков, они шумные.
- Ответы модели на вопросы из самого описания проверены на отдельных примерах.
- Нагрузочная приёмка не проводилась.
- Задачи измерены по отдельности; объединять их в одну точность конечного кода нельзя.
Дальше: описания реальных поставщиков, экспертная разметка, замеры на целевой Linux-машине. Подробности: RESEARCH.md.
Технические детали
S0 core: real KTRU XML (+ Portal Excel exports) → immutable SQLite/FTS5 snapshot → independent extraction,
binding, narrowing, questions → FastAPI (/api/ktru/*, no external database server) → React card. /parse performs
rule extraction and configured retrieval without generative fact extraction. Retrieval can use local embeddings
and optional semantic/classification/reranking models. Open-model extraction (/analyze, a compatible OpenAI-protocol server, e.g. Ollama with the llm compose
profile) proposes facts with verbatim quotes; code checks their local property, value, subject, number and unit.
The main UI calls /split, then /parse for each product and /analyze on accepted provisional revisions.
After extraction, /analyze may make up to three bounded catalog-choice calls with source quotes.
A final code requires a confirmed complete family:
the user's answer, or a description that names exactly one family completely (then no question is asked).
Without KTRU_LLM_URL/KTRU_LLM_MODEL it is disabled (/health → llm: disabled). Known proprietary API hosts are refused; this host check does not verify a model license or a proxy backend.
What the card does (see ARCHITECTURE.md):
- Fields come from KTRU first, then the Portal schema, then «Новое». An extra property that no KTRU position or Portal schema of the product domain uses is kept and flagged («Свойство … не характерно для товара …»). General properties such as warranty, manufacturer and dimensions are exempt from this vocabulary check.
- Several candidates left → one question about the characteristic that splits them best; never a guessed code.
- A missing required value does not by itself prevent a final code; the card stays
needs_answersuntil required fields are filled. - The code shows its status, version, validity date and required/optional counts. The snapshot imports only
*_actual_*XML and stores records by code and version, including non-ACTIVE records. Retrieval and family expansion use ACTIVE positions; templates cannot be final answers. - Загрузка CSV (
/batch): one column with the header «наименование товара», one description per line. Results appear as each row finishes; each row opens its full card; results export back to CSV.
Quality
eval/main.py measures the main scenario: a description built from one ACTIVE final KTRU position (its name and
1–4 of its own values), gold computed from the catalog (not from the pipeline), and a simulated user who answers
each question with the true value. Families are split into two disjoint halves: dev guided fixes, holdout only
reports. Scenarios: full values, one value, and full values plus a property foreign to the domain.
uv run --project backend --python 3.12 python eval/main.py --split holdout # rules only
uv run --project backend --python 3.12 python eval/main.py --split holdout --with-model # + /analyze
The following numbers record the checkpoint of 10.10.2026; current verification is in delivery status.
| Catalog-derived holdout, 111 cases, rules only | Before checkpoint | Recorded checkpoint |
|---|---|---|
| Correct code and version after the dialogue | 64.0% | 88.3% |
| Wrong final code | 17 | 0 |
| Correct code without any question (unique cases) | 0/75 | 58/75 |
| Questions per case | 1.66 | 0.85 |
| Foreign property flagged / false alarms | 0/37 / 0 | 37/37 / 0 |
An earlier README reported the same 98/111 with local Qwen3-4B-Instruct-2507 (llama.cpp, macOS), 0 wrong codes
and p95 /analyze about 14 s; the raw run was not retained in the repository and this audit did not rerun it.
Catalog-derived text is cleaner than supplier input; this is validation, not an expert-labelled holdout.
Historical rules checkpoint details: report.md.
Other evaluators: eval/run.py seeds (17 tuned-on smoke cases), eval/run.py golden (300 hard cases, fact-level
checks) and eval/run.py ste --n 100 (real Portal listings; behaviour only, no KTRU gold).
backend/— FastAPI + uv, Python 3.12. Code inbackend/src/ktru/(contracts.py—s0-4models and Protocols;catalog/— build, reader (catalog and FTS5 search) and shared validation;engine/— binding, compatibility, fields, questions, readiness, source maps (source_map.py), canonical fact vocabulary (canonical.py: presence, materials, colours, property names, product label, modality) and spelled numbers (numwords.py);service/— typed stage adapters and session CAS) andbackend/src/api/ktru/app.py.frontend/— React 19 + Vite + TanStack (pnpm); UI infrontend/src/components/ktru/.source-of-truth/— decisions, binding rules, status.research/— organizer materials (read-only).
Repository layout
| Path | Purpose |
|---|---|
backend/, frontend/ |
Product code, configuration and tests |
eval/ |
Evaluators, thresholds, smoke cases and Golden Set |
docs/bpmn/ |
Current architecture BPMN (.bpmn, SVG, PNG) and the earlier MVP diagram |
.forgejo/workflows/ |
CI/CD on the self-hosted Forgejo runner |
source-of-truth/ |
Current rules, contracts, status and evidence links |
research-library/ |
Indexed research, reproducible experiments and organizer transcript |
research/ |
Original organizer materials and dated research sources |
tools/flash-swarm/ |
Current development agent tool |
.ktru-build/ |
Ignored snapshots, models, reports and tool caches |
Start with source-of-truth. Each document has one source; indexes link directly to it.
Keep frozen experiment inputs with their manifests. New temporary output belongs in .ktru-build/<task>/;
pytest and Ruff caches use .ktru-build/cache/. Product dependencies stay in their normal local directories.
Run with Docker Compose (Linux)
docker compose up --build -d # snapshot → vector-index (one-shot) → api → web
# Open http://localhost:8080 in a browser; nginx proxies /api/ to the api service
docker compose down # keeps the snapshot volume; `down -v` forces a rebuild next time
snapshotunpacksresearch/Казань 2026/КТРУ/КТРУ xml.zip(only*_actual_*XML) into thektru_snapshotsvolume; the Portal Excel files fromresearch/Казань 2026/Примеры выгрузок/(both mounted read-only) are read intocatalog.sqlitewithout being unpacked. It is skipped whencurrent/manifest.jsonalready exists.vector-indexbuilds the pinned local E5 INT8 family index inktru_vectors; its first build downloads public model files.api(non-root, port 8010 inside the network only) waits for both builders; healthcheck requiresready: true.docker compose --profile llm up -dalso startsollama/ollama(volumektru_ollama). The api receivesKTRU_LLM_URL/KTRU_LLM_MODELfrom your shell or.env(e.g.KTRU_LLM_URL=http://llm:11434); unset by default. The profile does not download a model. Install the chosen open model in that Ollama instance, verify that the pinned Ollama version supports it, set both variables, and recreate the API service. Model installation usesdocker compose exec llm ollama pull <model-name>; replace<model-name>with the verified model identifier. The earlier ~40 s snapshot build is a local measurement, not a startup guarantee. For an NVIDIA GPU uncomment thedeployblock incompose.yaml.
Local development
# 1. Unpack the XML into the git-ignored build dir (scripts/snapshot.py keeps only the 82 *_actual_* files)
cd backend && uv sync --python 3.12
uv run python scripts/snapshot.py "../research/Казань 2026/КТРУ/КТРУ xml.zip" ../.ktru-build/snapshots \
--portal-dir "../research/Казань 2026/Примеры выгрузок" --work ../.ktru-build/raw
# (or, with raw XML already unpacked: uv run python -m src.ktru.catalog.build ../.ktru-build/raw ../.ktru-build/snapshots --portal-dir …)
uv run --python 3.12 python scripts/vector_index.py # optional local hybrid; absent default index falls back to FTS5
# 2. API on :8010 (KTRU_SNAPSHOT_DIR overrides ../.ktru-build/snapshots/current)
uv run uvicorn src.api.ktru.app:app --host 127.0.0.1 --port 8010
# Keep the API running in this terminal.
In another terminal, from the repository root:
# UI on :5173, /api proxied to :8010
cd frontend && pnpm install && pnpm dev
Run only one pnpm dev per checkout. Two Vite servers on the same frontend/ keep rewriting
src/app/routeTree.gen.ts, and every open page reloads in a loop.
Optional open model with llama.cpp (run the API command from backend/; first model download is about 4.3 GB).
The server must support /v1/models, /v1/chat/completions and the configured JSON/generation parameters:
llama-server -hf unsloth/Qwen3-4B-Instruct-2507-GGUF:Q8_0 --port 8081 -c 8192 --jinja
# then start the API with the model enabled
KTRU_LLM_URL=http://127.0.0.1:8081 KTRU_LLM_MODEL=qwen3-4b uv run uvicorn src.api.ktru.app:app --host 127.0.0.1 --port 8010
On Docker Desktop use KTRU_LLM_URL=http://host.docker.internal:8081. Native Linux needs an address reachable
from the container or a configured host-gateway mapping for that hostname.
Remote GPU server (vLLM, OpenAI-compatible, API key): see deploy/gpu-server/README.md.
The API reads KTRU_LLM_API_KEY (Bearer key) and KTRU_LLM_CONCURRENCY (parallel /analyze calls, default 4;
match the server's slots). backend/scripts/llm_bench.py measures a configured endpoint. The CSV page first
parses every row with the rules, then lets the model refine rows in parallel.
Optional semantic retrieval uses KTRU_EMBED_URL / KTRU_EMBED_MODEL; reranking uses
KTRU_RERANK_URL / KTRU_RERANK_MODEL. Both use independently configured open-model endpoints;
their API keys default to KTRU_LLM_API_KEY. Root Compose forwards the embedding settings, but currently
does not forward KTRU_RERANK_*: add service environment mappings to enable reranking in a container.
These paths can run during /parse. KTRU_SEARCH_MODE=lexical disables the bundled E5 channel,
but does not disable separately configured semantic, classifier or reranking adapters.
Tests and lint
cd backend
uv run --python 3.12 pytest -q --cov=src.ktru --cov-fail-under=80
uv run --python 3.12 ruff check src tests scripts/export_contract.py
uv run --python 3.12 python scripts/export_contract.py --check
cd ../frontend && pnpm lint && pnpm build
pnpm exec playwright install chromium # once
pnpm test:e2e
Seed cases are dev smoke tests the rules were tuned on, not held-out evidence. The dev/holdout evaluator uses
uv run --project backend --python 3.12 python eval/run.py --split dev --check eval/thresholds.yaml; missing gold, metrics or verified Linux runtime
fail the gate. See evaluation status.
Model timing probes on macOS do not establish Linux VRAM or the 2.5 s analyze target.
CI/CD
.forgejo/workflows/ci-cd.yaml runs on the Forgejo runner of the deployment
server (host Docker, label ubuntu-latest):
backend.ruffandpytestin a throwaway uv container, with the server's KTRU snapshot volume mounted read-only.frontend.pnpm lintandpnpm buildinnode:20-alpine.deploy. Runs only onmainand after both checks pass. It updates the server checkout and rebuildsapiandwebwithdocker compose, then waits for a healthy API and a page that answers.
Pushes that change only documentation (*.md, docs/, research*/, source-of-truth/) run nothing. When a push
changes nothing under backend/, frontend/ or compose.yaml (tests and Markdown excluded), the deploy updates
the checkout and keeps the running containers.
Server .env holds KTRU_BIND, KTRU_PORT and the model endpoints. The runner mounts the checkout at its host
path, because compose binds ./research/... for the snapshot.
Agents
One lead agent edits the code; light delegated work goes to the flash-swarm MCP server (DeepSeek Flash planner,
parallel workers and reviewer): tools/flash-swarm/README.md. See AGENTS.md.
Architecture: ARCHITECTURE.md. Status and evidence: source-of-truth/.
