Skip to content

feat(web): „Подобни договори" - ценови ориентир по CPV кохорта на страницата на договора#210

Open
B353N wants to merge 3 commits into
midt-bg:mainfrom
B353N:feat/similar-contracts

Conversation

@B353N

@B353N B353N commented Jul 4, 2026

Copy link
Copy Markdown

Какво и защо

Добавя секция „Подобни договори" на страницата на договора - разбираем ценови контекст на всяка поръчка: „1 000 000 € е в топ 5% по стойност сред 214 договора в сектор „Строителство" (CPV 45); медианата за сектора е 112 000 €." Различно от рисковите флагове (#127) и health индекса (#188): те казват „нещо е съмнително", това отговаря на „голяма ли е тази сума за този вид поръчка".

Как работи:

  • Нов rollup cpv_division_stats (миграция 0002): персентили на стойността (p25/медиана/p75/p90/p95/p99) по двуцифрена CPV дивизия, nearest-rank (k = ceil(q·n)), само върху „чистата" кохорта - value_flag = 'ok', amount_eur > 0, известен CPV. Преизгражда се изцяло и в двата пътя: scripts/precompute.sql (§4c) и scripts/refresh-slice.sql (в batch globals, при sector_totals/facet_counts) - тест пази двете копия идентични.
  • Защо предизчислено: страницата на договор чете един ред от rollup-а вместо да сканира цялата си дивизия на всеки изглед (D1 таксува прочетени редове; 192 хил. договорни страници × sitemap crawl-ове правят on-the-fly вариант неприемлив).
  • Заявки в @sigma/db (queries/cohort.ts): getContractCohort връща бенчмарк само когато сравнението е честно - чиста стойност, наличен CPV, кохорта от поне 12 договора (същия праг като anomaly report-а). Договори с непотвърдена стойност не получават сравнение.
  • Съзнателно широки стъпала („топ 5%", „над медианата"), никога фалшива точност („топ 4,7%"): rollup-ът е споделен и включва самия договор (за разлика от leave-one-out p95 в scripts/anomaly-report.mjs, който е праг за флагване, не дисплей). UI-ят изрично казва, че това е контекст за мащаба, не оценка за нередност.
  • Методологията (§6) описва изчислението, прага от 12 договора и ограниченията.
  • scripts/compare-served-sqlite.mjs и docs/etl-pipeline-state.md включват новата таблица.

Свързан issue

Няма пряк issue; допълва посоката на #127 (рискови флагове) и #188 (health индекс) с неутрален ценови контекст.

Вид промяна

  • feat — нова функционалност

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

  • pnpm typecheck - минава (всички пакети).
  • pnpm test - минава. Нови тестове: поведенчески SQL тест (precompute-cohort.test.ts) прилага миграциите, зарежда корпус със шум (suspect/NULL/нулеви стойности, липсващ CPV) и проверява nearest-rank персентилите на реален sqlite3 + идемпотентност + парити между precompute и refresh-slice; unit тестове за cohortBand/getContractCohort (нулеви пътища: suspect, малка кохорта, липсващ CPV).
  • pnpm lint - чисто.

Чеклист

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

@ydimitrof

Copy link
Copy Markdown

Проверих PR #210 из основи локално — целия diff, SQL пътищата, заявките в @sigma/db, UI слоя и тестовете. По-долу е рецензията.

Резюме

Чиста, добре обхваната функционалност: предизчислен rollup cpv_division_stats (персентили на стойността по двуцифрена CPV дивизия) + честен кохортен бенчмарк на страницата на договора. Обхватът е един логически, документацията (методология §6, docs/etl-pipeline-state.md) и compare-served-sqlite.mjs са обновени в тон с останалите rollup-и.

Сигурност / OWASP

  • SQL инжекция — няма. И двете заявки в cohort.ts са параметризирани с ? bind (WHERE division = ?, WHERE c.id = ?). division не е потребителски вход — извлича се от БД чрез substr(t.cpv_code, 1, 2). SQL в precompute.sql / refresh-slice.sql е статичен, без интерполация. ✅
  • XSS — няма. Всичко минава през автоматичното екраниране на React (money, count, plural, c.sector?.short, cohort.stats.division). Няма dangerouslySetInnerHTML. Линкът /contracts?sector=${division}&sort=value-desc използва стойности, произведени от БД (двуцифрен код), не сурови входни данни. ✅
  • Няма тайни, нови зависимости, мрежови повиквания или obfuscation. Скенът за зловреден код е чист.
  • Кохортната заявка е read-only; няма промяна на права или на състояние.

Цялост на данните

  • Членство в кохортата е консистентно. WHERE в getContractCohort (amount_eur > 0, value_flag = 'ok', наличен CPV) съвпада точно с WHERE на rollup-а — показан договор винаги е член на кохортата, срещу която го сравнявате. Коментарът твърди това и то се потвърждава.
  • Nearest-rank емулацията е коректна. CAST(n*q + 0.9999999 AS INTEGER) възпроизвежда ceil(q·n): за цяло n·q буферът не прескача нагоре (m + 0.9999999 < m+1), а за нецяло закръгля правилно; 7-цифреният буфер поглъща float грешката (~1e-13) без риск. Проверих ръчно граничните случаи от теста (n=20 и n=3) — верни. k ∈ [1, n] винаги, така че NOT NULL колоните не могат да получат NULL.
  • Праг MIN_COHORT = 12 огледва anomaly-report.mjs — договори без честна кохорта не получават сравнение. Дисклеймърите разделят „контекст за мащаба" от „оценка за нередност" ясно.
  • Тестове: поведенческият precompute-cohort.test.ts върху реален sqlite3 (шум: suspect/NULL/нула/липсващ CPV), идемпотентност и парити precompute↔refresh-slice; unit покритие на cohortBand (вкл. границите) и всички null пътища на getContractCohort. Силно покритие.

Съответствие с описанието

Имплементацията отговаря на PR описанието изцяло: rollup в двата пътя, четене на един ред вместо сканиране на дивизия (D1 таксува прочетени редове), праг 12, широки стъпала без фалшива точност, методология. Няма пряк issue — допълва посоката на #127/#188.

Дребни бележки (незадължителни, не блокират)

  1. cohortBand при много равни стойности. Проверките са >= отгоре надолу, така че договор точно на дадена персентилна стойност попада в по-горното стъпало. В дивизии с масови еднакви малки суми (p90_eur == p95_eur) това може леко да надцени позицията („топ 5%" вместо „топ 10%"). Стъпалата са съзнателно груби и дисклеймърът смекчава — само за информация.
  2. Кохортната заявка се изпълнява преди проверката за 404. В loader-а getContractCohort е в Promise.all заедно с getContract, така че за несъществуващ договор (или бот, удрящ невалиден slug) се правят излишни read-ове преди да хвърлим 404. Два индексирани single-row read-а — пренебрежимо, но може да се спести, ако някога стане тясно място.

Вердикт

Approve на същество — сигурност и цялост на данните чисти, SQL параметризиран, покритието е силно; двете бележки са козметични и не блокират мърджа.

@ydimitrof

Copy link
Copy Markdown

Ревю на PR #210 — „Подобни договори“ (ценови ориентир по CPV кохорта)

Благодаря за спретнатата и добре мотивирана промяна. Прегледах я обстойно с фокус върху сигурност, интегритет на данните и съответствие с OWASP; също прекарах дифа през локална проверка за SQL/XSS и злонамерени примеси. По-долу са наблюденията.

Сигурност (OWASP)

  • SQL injection (A03). И двете заявки в queries/cohort.ts са изцяло параметризирани — getCpvCohortStats (WHERE division = ?.bind(division)) и getContractCohort (WHERE c.id = ?.bind(contractId)). Няма конкатенация на вход в SQL. Rollup-заявките в precompute.sql/refresh-slice.sql оперират само върху вътрешни таблици, без външен вход. Чисто.
  • XSS (A03). Целият изход е JSX текстови възли (money(...), count(...), c.sector?.short, COHORT_BAND_LABELS[cohort.band]) — авто-ескейпва се от React. cohort.band е типизиран enum от сървъра, ключът в мапата не идва от потребител. Няма dangerouslySetInnerHTML. Чисто.
  • Отворено пренасочване / инжекция в линк. /contracts?sector=${cohort.stats.division}&sort=value-descdivision е substr(cpv_code,1,2) от БД (не потребителски вход), а /contracts валидира sector срещу KNOWN_SECTORS и разпознава value-desc. Линкът е консистентен и безопасен.
  • Секрети / зависимости / обфускация. Няма нови зависимости, няма URL промени, няма секрети, няма подозрителни или обфускирани конструкции. Чисто.

Интегритет на данните

  • Кохортата = множеството, спрямо което сравняваме. WHERE на getContractCohort (amount_eur > 0, value_flag = 'ok', наличен CPV) съвпада байт-по-смисъл с WHERE на rollup-а. Показан договор винаги е член на кохортата си — коректно.
  • Nearest-rank персентили. k = ceil(q·n), емулиран като CAST(n*q + 0.9999999 AS INTEGER) заради липсата на ceil() в SQLite — проверих граничните случаи (n=20 → 5/10/15/18/19/20; n=3 → 1/2/3/3/3/3), поведенческият тест на реален sqlite3 ги потвърждава, плюс идемпотентност.
  • Двата пътя не могат да се разминат. Тестът precompute-cohort.test.ts извлича INSERT INTO cpv_division_stats … ; от precompute.sql и refresh-slice.sql и ги сравнява с нормализиран whitespace — добра защита срещу дрейф. MIN_COHORT = 12 е приложен read-side и огледален на прага в anomaly report-а.
  • Честност на дисплея. Съзнателно широки стъпала, „включва самия договор“ е документирано и в кода, и в §6 методология; UI изрично казва, че това е контекст за мащаба, не оценка за нередност. Договори с непотвърдена стойност не получават сравнение — коректно.

Тестове / качество

Покритие на нулевите пътища (suspect / NULL / нула / липсващ CPV / малка кохорта), гранична инклузивност на бандовете, DTO мапинг и парити между двата SQL пътя. Миграция 0002 няма конфликт в номерацията; docs/etl-pipeline-state.md, compare-served-sqlite.mjs и методологията са обновени. Обхватът е един логически и без scope creep. PR-ът няма пряк issue — допълва посоката на #127/#188, което е описано коректно.

Дребни бележки (не блокери)

  • getContractCohort се изпълнява в Promise.all дори когато договорът в крайна сметка е 404 — един излишен single-row read при несъществуващ id. Пренебрежимо (два индексирани реда), но при желание може да се пропусне на липсващ контракт.
  • Rollup-ът включва самия договор, затова максималният по стойност договор в дивизия винаги пада в top1 — това е коректно документирано като компромис; отбелязвам го само за прозрачност пред други ревюъри.

Няма намерени уязвимости, изтичане на ресурси или следи от злонамерен код. Промяната е готова за merge.

Вердикт: APPROVE (на същество) — сигурност и интегритет на данните чисти, OWASP-съвместимо; дребните бележки са по желание.

@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Ре-проверих (4cfdfe0): percentile математиката е коректна (nearest-rank ceil emulation CAST(cnt*q + 0.9999999 AS INTEGER), ROW_NUMBER по дивизия); дивизия няма; MIN_COHORT=12 гейт; стойностната база (value_flag='ok' AND amount_eur>0) огледална между rollup-а и getContractCohort. Добре.

Неточност (medium): §4c на precompute.sql няма signed_at граница (WHERE amount_eur IS NOT NULL AND amount_eur>0 AND value_flag='ok'), но UI-ят твърди „(2020 г. - днес)" (contract.tsx hint + methodology). Договори с чиста стойност, но без/извън-диапазон дата, влизат в кохортата и персентилите → надписът лъже. Или добавете AND c.signed_at >= '2020-01-01' AND c.signed_at <= date('now') в §4c, или махнете „2020 г. - днес".

Low: „сред N договора" брои чистата кохорта, а линкът „Виж договорите в сектора →" води към /contracts?sector=X (всички, вкл. suspect/null) — по-голямо N от изречението.

Cross-PR: 0002_cpv_division_stats.sql се сблъсква с 0002 на #172/#212 — преномерирай преди merge.

@nedda76 nedda76 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Прегледах — PR-ът е внимателно направен: коректна nearest-rank статистика (буферът +0.9999999 безопасно поема IEEE-754 грешката), филтриране само по каноничен EUR (amount_eur IS NOT NULL AND > 0 AND value_flag='ok', което точно съвпада с rollup-а), параметризиран SQL, стабилен ROW_NUMBER() ... ORDER BY amount_eur, c.id, и неутрална формулировка („контекст на мащаба, не оценка за нередност"). Три бележки:

  1. (потвърдено) Колапс на горните ленти при малка кохорта. Стълбицата стига до топ 1%/5%, но подът е MIN_COHORT=12. При N≈12 nearest-rank дава p95 = p99 = max, тоест най-скъпият от 12 договора получава етикет „в най-горния 1%" — фалшива точност, точно обратното на целта на PR-а („никога фалшива точност"). Показвай фините ленти (топ 1%/5%) само когато N е достатъчно голямо, за да ги различи (anomaly-report.mjs също отбелязва, че кохорти 12–20 са крехки в горния край).

  2. (вероятно) Кохортата е само по 2-цифрен CPV раздел. Раздел 45 „Строителство" слага ремонт за 5 хил. до автомагистрала за 50 млн. в една група — процентилната мрежа смесва структурно различна работа. Съзнателен компромис (цена на precompute/D1 rows-read) и UI-ят е внимателен, но „подобни договори" обещава по-тясна група, отколкото реално има. Пълен CPV код / CPV клас би бил по-тесен, но взривява кардиналността на rollup-а.

  3. (вероятно) Излишно четене на договора на SSR hot path — getContractCohort пречита реда на договора (amount_eur, value_flag, CPV), който getContract вече е взел. В Promise.all са, тоест латентността е скрита, но D1 таксува прочетени редове (192k страници + sitemap). Подай вече заредения договор на cohort helper-а вместо второ четене.

Координация: #210 и #212 добавят по един 0002_* migration — който се мерджне втори, трябва да се преномерира на 0003, иначе един от двата тихо няма да се приложи в прод D1.

@B353N B353N requested a review from nedda76 July 8, 2026 05:30

@nedda76 nedda76 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Допълнение към прегледа (дълбок пас). Няколко находки, по-сериозни от първоначалната ми бележка за „фалшива точност при N≈12" — те я включват и я заменят.

Критични — грешни етикети върху обичайни данни

  1. Колапс при равни стойности → всичко е „в най-горния 1%". Каскадата в cohortBand е с >=, затова при равни стойности на/над p99 order statistic всеки такъв договор получава „в най-горния 1% по стойност". Раздел от 200 стандартизирани договора на еднаква цена (100 000 €) етикетира и 200-те като „топ 1%". Стреля при всяко N — а еднакви цени при рамкови/типови поръчки са често срещани. По-драматично от познатия N≈12 колапс.

  2. Най-скъпият в кохортата винаги е „топ 1%" (self-inclusion) + фалшива точност при пода. Договорът е в собствената си кохорта (не leave-one-out), тоест максимумът е по дефиниция „топ 1%"; при N=12 това е ~топ 8%, показано като „топ 1%". Собственият anomaly-report.mjs документира, че self-inclusion е ненадежден точно при 12–20 реда. Уговорката живее само в коментарите в кода — в UI-я я няма (обратно на твърдението в PR описанието), затова читателят не знае, че договорът се сравнява със себе си.

  3. Грешен етикет за медианата. Nearest-rank медианата е долно-средната стойност; с >= договор в долната половина при четно N получава „над медианата", а показаната „медиана" е изместена надолу с една стъпка. Тестът го фиксира като очаквано поведение.

Висок приоритет

  1. Най-тежкото изчисление е в споделения globals refresh batch. Пълният ~150k-редов window sort е добавен към @refresh-batch globals (който вече прави data_freshness/home_totals/sector_totals/facet_counts), вместо в собствен @refresh-batch cohort-stats, както другите тежки rollup-и. Рискува да пробие CPU бюджета на едната D1 заявка и да провали целия globals step.

Средно

  1. Две несъвместими дефиниции на процентил за едни и същи CPV раздели: тук nearest-rank + self-inclusion; в anomaly-report.mjs линейна интерполация + leave-one-out. Една и съща „медиана"/„p95" дава различни числа на два продуктови екрана. PR описанието признава само разликата leave-one-out, не и nearest-rank vs интерполация.

Чисто (проверено, за фокус)

Параметризиран SQL; division идва от DB реда, не от заявката; предикатът за членство в кохортата е byte-еднакъв между precompute и read-side (показан договор винаги е реален член); ceil емулацията е коректна; refresh parity (INSERT) е фиксиран с тест; Promise.all — без латентностна регресия; stats четенето е O(1) по PK върху ≤46-редова таблица.

Вердикт: Request Changes

#1#3 карат водещата функция да показва уверено грешни етикети върху обичайни данни — най-лошият режим за платформа за прозрачност, и нарушава собственото обещание „никога фалшива точност". Всички са евтини за поправка: показвай фините ленти (топ 1%/5%/медиана) само когато съответните персентили реално се различават и кохортата е достатъчно голяма; ползвай строго > / leave-one-out където е редно; премести recompute-а в собствен @refresh-batch. Основата (parity, сигурност, дизайн на precompute) е стабилна — това е поправка на логиката за етикети и на ETL разположението, не пренаписване.

(Заменя по-меката ми бележка „фалшива точност при N≈12" — тя е частен случай на #1/#2.)

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.

4 participants