docs(discovery): предложение за discovery слой (RFC) - #311
Draft
nikimilenkov wants to merge 16 commits into
Draft
Conversation
…мантично търсене Как всеки, без регистрация, намира релевантните за него поръчки — минали и отворени. Обхваща модела на съпоставяне, модела на данните, ingest-а и архитектурата върху съществуващия Cloudflare стек. Проверено срещу източника: покритие 100% на mainCpvCode / estimatedValue / executionPlaceNuts / submissionDeadline / buyerName върху 19 дни от open-data емисията (2,856 реда, 1,249 поръчки, 2021-06 → 2026-08); една поръчка е header + обособени позиции с ключ УНП; позициите носят собствени CPV и стойност, затова съпоставянето е на ниво позиция.
…тта на съпоставяне Проверено върху 2026-08-04: lotName е null на всичките 98 реда за позиции, а subject е идентичен за всички позиции на една поръчка (18 от 18). Позициите се различават само по CPV, стойност и NUTS. Следствие: фасетите и CPV се съпоставят на ниво обособена позиция, но ембедингът е един на УНП — иначе 01785-2026-0011 би дала 36 еднакви вектора. Добавени два реални примера (Община Сливница, КОЦ-Бургас) и таблица с двата слоя на гранулярност.
…нзов раздел Три независими проверяващи преизмериха всяко твърдение срещу първоизточника със задача да го оборят. Оборени и коригирани: 1. Позициите ИМАТ собствен текст. Полето е lotTenderName (не lotName, което не съществува) — 100% попълнено, различно при 17 от 18 поръчки. Ембедингът се връща на ниво позиция. 2. Обем: ~89 поръчки на работен ден (n=18), не ~63. 3. Уикендите не са гарантирано празни, а 2020 г. не е празна — архивът има реален запис от 2020-01-02. Уточнени: estimatedValue е 99.97% (един празен низ на 3,524 реда); executionPlaceNuts е 100% попълнено, но само 84.3% на ниво NUTS3 — 15.5% са BG и трябва да влизат във всеки регионален филтър; празен низ != null; header-only полета се наследяват към позициите. Добавени: §2.10 лиценз CC0-1.0, лични данни и зрялост на емисията (жива от 29.06.2026); §5.3 опционален reranker; операторите на Vectorize в §10, за да не се защитава дизайнът с грешен аргумент.
Бележки от lyubomir-bozhinov и DiyanaDimitrova по 8566bca. Оправено: - Разделени знаменателите на двете извадки (2,856 срещу 3,524) — 99.97% вече не се чете срещу грешния сбор. - lots НЯМА колона за място (проверено: refresh-slice.sql:626-640 записва пет полета). §2.3 вече не твърди, че субстратът е готов; lots.place_nuts3 и пренасянето му през staging стават първата задача на Фаза 1, защото без тях водещият случай не работи. - „Празен низ != null" се отнася само за офлайн задачата — clean() (base.ts:33-37) вече свива "" към null в tenders/lots. - lots.title вече носи lotTenderName (base.ts:262) — Фаза 3 не иска нов ingest, само ембединг на съществуваща колона. - cpv_path/parent_code/depth отпадат: йерархията се смята с отрязване на цифри. Нормализация на контролната цифра — върху списъка, не върху емисията (и двата пътя дават 8 цифри без контролна цифра). - Агрегиране позиция->поръчка: GROUP BY tender_id, изчерпване на бюджета от 50 кандидата, един източник за стойността (без сумиране). Добавено: - §5.4 липсващи binding-и (AI и VECTORIZE в apps/etl) и наредбата Фаза 1 преди Фаза 3; §5.5 цена на ембединга (/bin/zsh.012/M). - §5.1 решение един или два Vectorize index-а. - Проверка на целостта на референтните файлове при load-reference. - Детерминистичен id на вектора за безопасен upsert. - Фаза 2: екраниране на XML, ограничител на честотата, таван на кеша. - §8: кеширане на отворените поръчки, лицензи на референтните данни. - Обосновка защо ekatte_settlements остава (разрешаване от страната на заявката), с условие да отпадне заедно с автодовършването.
…обори Бележката на lyubomir-bozhinov да се маркират числата с една извадка доведе до преизмерване. Върху нова извадка В (1,931 реда, 9 дни 2022-2026, без застъпване с A) делът NUTS3 е 75.5%, а не 84.3% — националните BG редове са 23.9% вместо 15.5%. Числото вече се дава като 75-84% с двете измервания и изрична бележка, че е нестабилно между периодите. Проектното правило не се променя, но става по-важно: до една четвърт от релевантните поръчки биха отпаднали при регионален филтър без включване на BG.
… backfill
И двата открити при широка проверка, преди да са стрували работа.
1. Префиксният предикат по CPV трябва да е ДИАПАЗОН. Измерено с
EXPLAIN QUERY PLAN върху 200,000 реда:
substr(cpv_code,1,5)='45233' -> SCAN lots (цялата таблица)
cpv_code LIKE '45233%' -> SCAN lots (цялата таблица)
cpv_code >= '45233' AND < '45234' -> SEARCH USING INDEX
Първите две биха сканирали целия lots при ВСЯКА заявка, а D1
таксува прочетените редове. §4 вече дава диапазонната форма.
2. lots се пълни с INSERT OR IGNORE (refresh-slice.sql:627) —
съществуващ ред никога не се обновява. Само добавяне на колоната в
INSERT-а би оставило целия исторически корпус NULL и регионалният
филтър безшумно не би намирал нищо в миналото. §6.1 вече изисква и
трите стъпки, вкл. еднократен backfill извън cron-а.
Най-големият риск за използваемостта беше в първата минута, не в подредбата: пътната фирма не знае, че е "45233", а хлебарят — че е "15811". 9 454-кодова таксономия като първо взаимодействие е тест по грамотност, не търсене. Ново подраздел в §3: входът е свободен текст, CPV идва като предложение. Прототипирано върху официалните български CPV етикети — "правя пътища" -> 45233, "доставям хляб" -> 15821, "почистване на офиси" -> 90911. FTS5 над cpv_codes.label_bg, без ML, изпълнимо във Фаза 1. cpv_terms го подобрява по-късно, но не е предпоставка. Три неща преместени в обхвата на Фаза 1, защото без тях слоят не се използва: текстово търсене над CPV етикетите, подредба по срок (за потребител, чиято задача е да не изпусне срока, цветът е сигнал, но подредбата е механизмът) и явното казване, че търсенето още не е по смисъл.
…н, часова зона, DoW
Най-важните три:
1. ns е МЕТАДАННО поле в rag.ts:143, не native namespace. Вектори,
качени преди създаването на метаданен index, не влизат в него —
тоест 200 хил. вектора, после index = нула резултати и повторен
ембединг на всичко. Native namespace-ите нямат този проблем, не ядат
от 10-те метаданни index-а и смяната е безплатна СЕГА, докато
index-ът е празен. Ново §5.1.1.
2. submissionDeadline е местно време без зона. deadline_at >
datetime('now') мери спрямо UTC и показва поръчки като отворени 2-3
часа след затварянето им. За кандидат това е пряка вреда.
3. Броенето е отделен проблем от филтрирането: широк филтър чете
десетки хиляди редове на всяка кеш-промах. Ограничено броене,
lot_facet_counts rollup (13 хил. реда) и бюджет за прочетени редове.
Освен това: place_of_performance ВЕЧЕ е в staging и raw_tenders е
lot-grained — обхватът на §2.3 намалява; 'BG' влиза в IN списъка, а не
като OR (OR разваля композитния index); deadline_at и embedded_at слизат
на ниво позиция (created_at се нулира при ship-domain и би предизвикал
пълен повторен ембединг); embed-lots се разделя на backfill извън cron-а
и делта в него; кеширането се квантува до час с явен SWR и Tiered Cache;
FTS преизграждането иска keyset цикъл заради 30-секундния лимит;
cpv_terms получава праг; cron-ът получава детерминистичен id.
Валидирано преди записване; едно от тях се обърна на проверката. СРОКОВЕТЕ СА КРАЙ НА ДЕНЯ. Измерено върху 1 034 срока от 5 дни между 2022 и 2026: 100% без часова зона, само две различни времена — 23:59:59 (80.4%) и 23:59:00 (19.6%). Оттук: §8.3 Кеширане — РЕШЕНО: сравнение по ДАТА в Europe/Sofia. Поправя грешката с часовата зона точно и прави резултата стабилен в рамките на денонощието. Квантуването до час ОТПАДА — то влошава застояването (страница от 14:59 с as_of=14:00 показва поръчка, затворила в 14:05, цели 84 минути), а кешът вече върши това, което то се опитваше. publicCache(1800, 3600) за страници, (900, 900) за емисии: явният SWR има значение точно тук, защото рядко искан адрес иначе получава вчерашния отговор. §8.4 Vectorize — РЕШЕНО: един index с native namespace-и, слиза във Фаза 3. Бюджетът от 10 метаданни индекса отпада; конфигурацията на index е непроменима, тоест смяна на модел иска нов index при всички положения — два не спестяват миграция, а само я разделят, а и двата корпуса са евтини. Не е блокер за Фаза 1; краткият срок принадлежеше на друго решение (метаданни срещу namespace, §5.1.1). §8.5 Къде живее discovery — РЕШЕНО: apps/web. Общият ресурс е D1, не Worker-ът; отделен worker не изолира тясното място. Репото дели worker-и по вид задействане, не по функционалност.
Пусната срещу двете числа, за които казах, че биха се променили при повече данни. И двете се промениха, а едното обори проектно решение. NUTS3: 65.5% на целия корпус, а не 75-84%. Националните BG редове са 31.7% — близо една трета, не една четвърт. Причината се вижда чак сега: делът расте силно през годините (49% през 2021 до 87% през 2026), а предишните извадки бяха смещени към последните години, не малки. Следствие за продукта: регионалното филтриране на историята е слабо и интерфейсът трябва да го казва. СРОКОВЕТЕ НЕ СА САМО ДВА ВИДА. При 1 034 изглеждаха точно два; при 10 053 са 17, макар два да покриват 99.2%. Това обори сравнението само по дата от предишния commit: 0.8% са реални следобедни срокове и биха стояли отворени до 8 часа излишно. Връщаме се на сравнение по отметка, приведена към Europe/Sofia. Аргументът за кеширането оцелява — при 99.2% предикатът пак се обръща в местна полунощ. CPV кардиналност: преизмерена срещу емисията (6 200 позиции, 3 853 поръчки) вместо 125 поръчки от service API. Изводът се втвърди — медиана 1 поръчка на точен 8-цифрен код за 50 дни. Ново: в раздел 45 най-честият код е самият родов 45000000 с 31.2% от позициите. Една трета от строителството е подадено под код, който не казва нищо освен строителство — аргумент, че текстовото съпоставяне не е излишък. Многопозиционни с >1 CPV: 27.7% при n=980, срещу 27.3% при n=359.
И двете излязоха от въпроси при преглед на примерите. РЕГИОН. Твърдението, че при място 'BG' регионът е неизвестен, беше твърде песимистично. За 00109-2020-0002 възложителят е ОБЩИНА БЛАГОЕВГРАД — регионът е очевиден, просто не е в полето, по което филтрираме. Измерено сред редовете с място 'BG': локални органи 20.1%, комунални услуги 6.8%, публичноправни организации 52.5% (от тях здравеопазване 997 и образование 661 реда — институции на едно място). Около 58% са възстановими. Обратната посока е капанът: за министерства и централни органи (~11.6%) адресът е София, а работата е навсякъде — приписването би обявило национален договор за софийски. Затова правилото има ТРИ случая, а не два, и се материализира като region_effective + region_source при ingest, за да остане заявката един index-ползващ IN. Етикетът в интерфейса е задължителен: 'място Пазарджик' != 'възложител в Пазарджик' != 'цялата страна'. Зависимост: регионът на възложителя не е в емисията; идва от authorities.region, за която кодът казва '~half of authorities'. CPV ГРАНИЦА. Под 45233* има 22 различни кода: най-честият дава 31%, два дават 55%, шест дават 81%. Абонамент за един код пропуска 160 от 232 безспорно пътни позиции — оттук префиксът. Но и префиксът има граница: реален пътен проект обхваща и 45221* (мостове), 45111* (земни работи), 71521* (надзор). Префиксът поправя пропускането вътре в семейството, не между семействата — затова браншовите пресети са набор от НЯКОЛКО префикса, а не един.
16 комита на main преместиха част от цитираните редове. Проверени са всичките 26 file:line препратки; шест бяха дрейфнали: base.ts:261-262 -> :278-279 (executionPlaceNuts, lotTenderName) refresh-slice.sql:561 -> :624 (status CASE) refresh-slice.sql:563 -> :626 (t.deadline) refresh-slice.sql:626-640 -> :690-703 (INSERT OR IGNORE INTO lots) refresh-slice.sql:727-758 -> :790-821 (lot-values UPDATE шаблонът) index.ts:57-60 -> :59-60 (правилото backfill = CLI) Следващата свободна миграция вече е 0008, не 0006 — main добави 0006_amendment_restated и 0007_amendment_value_suspect. Съдържателните твърдения оцеляват без промяна: lots още няма колона за място, пълни се с INSERT OR IGNORE, а ns:'entity' в rag.ts още е филтър по метаданни, не native namespace.
Девет от десет бележки приложени; една проверена и отхвърлена. ДВА ДЕФЕКТА, ПОТВЪРДЕНИ С ИЗМЕРВАНЕ: 1. Редът на колоните в композитния index беше грешен. SQLite спира да търси при първото ограничение-диапазон. С (cpv_code, region, ...) планът показва само cpv_code>? AND cpv_code<? — регионът и срокът изобщо не се ползват, тоест заявка за раздел 45* чете всички редове в раздела. С (region_effective, cpv_code, ...) се ползват и двете. Това и отслабваше правилото IN-вместо-OR: при диапазон отпред нито едното, нито другото помага. Правилото важи само с равенството отпред — и там наистина има значение. 2. lots.embedded_at не оцелява презареждането, заради което беше въведена. ship-domain.mjs прави DELETE FROM lots (ред 200; lots е в TABLES, ред 17) и презарежда от work базата, където колоната никога не е попълвана. Работи само отделна таблица vector_state, държана извън TABLES. ОЩЕ: четвърти случай за региона — NUTS1/NUTS2 места (~2.8%, около 280 реда) не попадаха в нито един клон и оставаха невидими за всяко търсене; смяната към native namespace е на три места, не на едно (indexSchemaCorpus пише метаданните, retrieveSchemaContext също чете); таблицата в §2.1 още показваше 75-84% NUTS3 вместо корпусните 65.5%; §2.7 сверяваше срещу оборените 63 поръчки/ден; „до 8 часа" стана 12 (наблюдаван срок 11:59); поправени са цитатите към core-scope.md:41-48 и etl.md:394-395. ОТХВЪРЛЕНО: бележката, че topK с метаданни е 20. Проверено срещу лимитите на Vectorize — 50 е вярното число и остава.
… три дефекта Осем от девет бележки приложени; една проверена и отхвърлена. НАЙ-СЕРИОЗНОТО: фасетният път питаше само lots, а тя е РЯДКА. Пълни се само WHERE t.lot_id IS NOT NULL (refresh-slice.sql:702), тоест поръчка без обособени позиции няма нито един ред в нея. Измерено на живо за 2026-08-04: 72 поръчки, само 18 с позиции — 54, или 75%, са без. Заявка само срещу lots би скрила три четвърти от корпуса. Поправено с подразбиращ се ред (lot_id = 0) за поръчките без позиции. ДВАТА ДЕФЕКТА ОТ ПЪРВИЯ РЕВЮ-КРЪГ: - Един index не стига. Водещото равенство трябва да е регионът, но той е ПО ИЗБОР — входът е свободен текст → CPV. Измерено: при заявка само по CPV index-ът с регион отпред дава SCAN. Сега са два. - region_effective не може да носи множество. Четвъртият случай (NUTS1/NUTS2) не се записва като всички NUTS3 отдолу в скаларна колона; разширението минава от страната на заявката — предците на избраните области, най-много четири стойности. ОЩЕ: tenders.place_of_performance вече съществува и се пълни (0000_init.sql:60) — втора сурова колона отпада; незатворен код-блок превръщаше DDL-а на cpv_terms/ekatte в проза и поглъщаше схемата на lots; ORDER BY deadline_at при диапазон по CPV дава TEMP B-TREE, тоест keyset пагинация по срок отпада — записано явно; тенденцията при NUTS3 е възходяща, но неравна (2023 липсва, 2022→2024 спада); двете цени (~/bin/zsh.35 записани редове, ~/bin/zsh.11 ембединг) са различни задачи и вече е казано; цитати base.ts:36-40, rag.ts:99 и :117. Изтрити са ix.db и t.db — мои benchmark остатъци в корена, които не са в .gitignore. ОТХВЪРЛЕНО: че двете цени си противоречат — те са за различни операции.
Ревюто отбеляза, че централните измервания не са възпроизводими от branch-а: скриптът, с който са снети, не е в репото. Скриптът остава извън него нарочно — той е еднократен изследователски инструмент на Python, а репото няма Python (нула .py файла; scripts/ е .mjs и .sql). Да се вкара един такъв файл значи интерпретатор, lint и CI решение заради инструмент, който никой няма да пусне втори път. Вместо това е описан методът, така че да се повтори на произволен език: шаблонът на URL с двата различни формата на датата, фиксираните ден/месец двойки за стратификацията, 59 опитани дати срещу 50 непразни, и определението на всяка метрика (какво е header ред, кога поле се брои за попълнено, защо се докладва медиана вместо средна). Записани са и ограниченията на самата извадка: датите са фиксирани, не случайни, тоест ефект по ден от седмицата не е изключен; 2023 г. е практически непокрита, защото избраните ѝ дати паднаха на празници; и трите по-ранни извадки бяха смещени по време — това, а не размерът им, обяснява дрейфа 84.3% -> 75.5% -> 65.5%. Ако някой препише скрипта и получи различни числа, разликата вече е различима между грешка в кода и реален дрейф на източника.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Какво и защо
Добавя
docs/discovery-layer-proposal.md— предложение (RFC) за discovery слой: как всеки, без регистрация, намира релевантните за него обществени поръчки, минали и отворени.Днес СИГМА е ретроспективна прозрачност — входните точки са агрегатни (сектор, възложител, фирма). Предложението добавя вход, който тръгва от думите на потребителя:
Само документация. Нула промени по код, схема, миграции или конфигурация.
Ключовото откритие, което прави това евтино: отворените поръчки вече са в D1 под
status = 'published'(refresh-slice.sql:624) — просто не се показват никъде.Свързан issue
Няма — отварям RFC-то за обсъждане преди да се заведе работа по него.
Вид промяна
docs— документацияКак е тествано
Няма код за тестване. Вместо това документът мина през състезателна проверка — независими проверяващи с изрична задача да оборват твърденията, плюс два кръга ревю от хора (@lyubomir-bozhinov, @DiyanaDimitrova).
Емпиричните твърдения са измерени срещу живия източник (
storage.eop.bg), а не приети наизуст:Проверката обори шест собствени твърдения — всичките са записани в §9 („Отхвърлени твърдения"), за да не бъдат възкресени:
lotTenderName) — проверявах несъществуващо поле. Обърна проектното решение за ембединга.Освен това проверката хвана два дефекта в самия план, преди да струват работа:
substr(cpv_code,1,5)='45233'не ползва index —SCANна цялата таблица при всяка заявка, а D1 таксува прочетените редове. Поправено на диапазон (EXPLAIN QUERY PLANвърху 200 000 реда).lotsсе пълни сINSERT OR IGNORE(refresh-slice.sql:690) — добавяне на колона не попълва нищо назад; целият исторически корпус би останалNULLмълчаливо.Всички 26
file:lineпрепратки са проверени след rebase-а върхуmain; шест бяха дрейфнали и са опреснени (виж последния комит).Чеклист
Co-Authored-By:trailer към агентmidt-bg/sigma:mainpnpm typecheck— неприложимо (само.md)pnpm test— неприложимо (само.md)pnpm lint— неприложимо (само.md).env*или.dev.varsdocs/README.mdе обновен с индекс записЗа ревюващите — какво заслужава най-много внимание
lotsобаче няма колона за място — това е първата задача на Фаза 1 и без нея водещият случай не работи.BG(31.7% от редовете) регионът често е ясен от възложителя, но приписването е капан за министерства. Правилото е измерено, не предположено.Интерфейсен мокъп с реални записи: https://claude.ai/code/artifact/8782d1e4-af48-4ab3-bc85-8c226b558961