Skip to content

docs(discovery): предложение за discovery слой (RFC) - #311

Draft
nikimilenkov wants to merge 16 commits into
midt-bg:mainfrom
nikimilenkov:docs/discovery-layer-proposal
Draft

docs(discovery): предложение за discovery слой (RFC)#311
nikimilenkov wants to merge 16 commits into
midt-bg:mainfrom
nikimilenkov:docs/discovery-layer-proposal

Conversation

@nikimilenkov

Copy link
Copy Markdown
Contributor

Какво и защо

Добавя docs/discovery-layer-proposal.md — предложение (RFC) за discovery слой: как всеки, без регистрация, намира релевантните за него обществени поръчки, минали и отворени.

Днес СИГМА е ретроспективна прозрачност — входните точки са агрегатни (сектор, възложител, фирма). Предложението добавя вход, който тръгва от думите на потребителя:

„Аз правя пътища в Пазарджишко. Какво мога да спечеля — и кажи ми, когато излезе ново."

Само документация. Нула промени по код, схема, миграции или конфигурация.

Ключовото откритие, което прави това евтино: отворените поръчки вече са в D1 под status = 'published' (refresh-slice.sql:624) — просто не се показват никъде.

Свързан issue

Няма — отварям RFC-то за обсъждане преди да се заведе работа по него.

Вид промяна

  • docs — документация

Как е тествано

Няма код за тестване. Вместо това документът мина през състезателна проверка — независими проверяващи с изрична задача да оборват твърденията, плюс два кръга ревю от хора (@lyubomir-bozhinov, @DiyanaDimitrova).

Емпиричните твърдения са измерени срещу живия източник (storage.eop.bg), а не приети наизуст:

Извадка Обхват
A 19 дни, 2 856 реда
Б (независим проверяващ) 30 дни, 5 163 реда
В 9 дни 2022–2026, 1 931 реда
Голяма стратифицирана 50 непразни дни 2020–2026, 10 053 реда

Проверката обори шест собствени твърдения — всичките са записани в §9 („Отхвърлени твърдения"), за да не бъдат възкресени:

  1. Позициите имат собствен текст (lotTenderName) — проверявах несъществуващо поле. Обърна проектното решение за ембединга.
  2. Обемът е ~89 поръчки/работен ден, не ~63.
  3. Уикендите не са гарантирано празни; 2020 г. не е празна.
  4. Делът NUTS3 е 65.5% на целия корпус, не 84.3% — ранните извадки бяха смещени към последните години.
  5. Сроковете не са само два вида (17 са, макар два да покриват 99.2%) — обори сравнението само по дата.
  6. Квантуването на „сега" до час влошава застояването вместо да го намалява.

Освен това проверката хвана два дефекта в самия план, преди да струват работа:

  • 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; шест бяха дрейфнали и са опреснени (виж последния комит).

Чеклист

  • Комитите следват conventional commits и нямат Co-Authored-By: trailer към агент
  • PR-ът е с един логически обхват и е от форк към midt-bg/sigma:main
  • pnpm typecheck — неприложимо (само .md)
  • pnpm test — неприложимо (само .md)
  • pnpm lint — неприложимо (само .md)
  • Няма комитнати тайни, .env* или .dev.vars
  • docs/README.md е обновен с индекс запис

За ревюващите — какво заслужава най-много внимание

  1. §8 съдържа пет затворени решения. Три от тях (кеширане, един/два Vectorize index-а, къде живее discovery) бяха затворени с обосновка, а не по подразбиране. Ако някое ви се вижда сгрешено, сега е моментът.
  2. §2.3 — обособените позиции са единица за съпоставяне. Позиция носи собствени CPV, стойност и наименование. lots обаче няма колона за място — това е първата задача на Фаза 1 и без нея водещият случай не работи.
  3. §4 — регионът има три случая. При място BG (31.7% от редовете) регионът често е ясен от възложителя, но приписването е капан за министерства. Правилото е измерено, не предположено.
  4. §9 е нарочно откровен за метода и ограниченията. Ако нещо ви се стори недостатъчно подкрепено — вероятно е, и е отбелязано.

Интерфейсен мокъп с реални записи: https://claude.ai/code/artifact/8782d1e4-af48-4ab3-bc85-8c226b558961

…мантично търсене

Как всеки, без регистрация, намира релевантните за него поръчки — минали и
отворени. Обхваща модела на съпоставяне, модела на данните, 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%.

Ако някой препише скрипта и получи различни числа, разликата вече е
различима между грешка в кода и реален дрейф на източника.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant