# CNSC — Consiliul Național de Soluționare a Contestațiilor Status: **Stage 1 ingest live**. Stage 2 (PDF parse) is the next step. Sursa: `http://portal.cnsc.ro/decizii.html` — registru oficial al deciziilor pe contestații depuse împotriva procedurilor SEAP. Bază legală: Legea 101/2016. --- ## 1. Ce s-a livrat (Stage 1) | Artifact | Path | |---|---| | Schema migration | `services/seap-scraper/sql/033_cnsc.sql` | | Scraper TS | `services/seap-scraper/src/scrape-cnsc.ts` | | Cron wrapper | `services/seap-scraper/cron/scrape-cnsc.sh` | | Plan / handoff | `services/seap-scraper/CNSC-PLAN.md` (this file) | DB obiecte (schema `cnsc`): - `cnsc.decizii` — tabel principal, PK natural `(decision_no, decision_year)` - `cnsc.scrape_log` — istoric run-uri scraper - `cnsc.mv_per_authority_cui` — rollup per autoritate contractantă - `cnsc.mv_per_contestator_cui` — rollup per contestator (firmă) Smoke test (3 pagini, run 2026-05-10): - 150 decizii ingerate, 100% cu PDF URL - 53% au CUI autoritate, 91% au CUI contestator (în listing-ul CNSC) - Cross-join cu `seap.announcements`: **26,046 hits via authority_cui**, **6,260 via contestator_cui**. --- ## 2. Cum funcționează scraping-ul Portalul CNSC e ASP.NET WebForms cu un quirk: **paginarea e stateful pe sesiune**. AJAX-ul nu acceptă pagina în body — server-ul citește pagina curentă din state-ul de sesiune, setat de un GET prealabil pe `/decizii.html?page=N`. Flow per pagină (sesiune partajată cu `ASP.NET_SessionId` cookie): 1. `GET /decizii.html?a=search®:registrationDate=-&page=N` — setează state-ul 2. `POST /Default.aspx/CallWebMethod` cu body `{sender, methodName:'get', senderParams, isBuletin:'0'}` 3. Răspunsul e JSON `{"d":"...
"}` — 50 rânduri / pagină Total: ~617 pagini × 50 rânduri ≈ **30,800 decizii**, datate 2016 → prezent. Pagina 617 are doar 13 rânduri (rest 2016). Listing-ul oferă DEJA, fără să descarci PDF-ul: - numărul deciziei + anul + data înregistrării - numele și CUI-ul contestatorului (uneori multiplii — asociere) - numele și CUI-ul autorității contractante - numărul de înregistrare CNSC - URL-ul PDF (`sivadoc/download.aspx?docUID=...&filename=...`) Asta e **80% din valoare** — joinabil direct cu `seap.announcements` (CUI ↔ CUI), cu `firms.entities`, etc. ### Idempotență `ON CONFLICT (decision_no, decision_year) DO UPDATE` — re-run-uri zilnice sunt fără efecte secundare. Decizii noi: INSERT. Decizii existente: UPDATE doar `fetched_at`. ### Run ```bash # Smoke test (2 pagini ≈ 100 rânduri, ~15s) sudo MAX_PAGES=2 /opt/vreaudigital/services/seap-scraper/cron/scrape-cnsc.sh # Full crawl (estimat: 7-10 min, ~617 pagini × 250ms politețe + ~7s/pagină) sudo /opt/vreaudigital/services/seap-scraper/cron/scrape-cnsc.sh # Resume după întrerupere parțială sudo START_PAGE=400 /opt/vreaudigital/services/seap-scraper/cron/scrape-cnsc.sh ``` Cron sugerat (zilnic, prinde decizii noi): ``` 30 5 * * * /opt/vreaudigital/services/seap-scraper/cron/scrape-cnsc.sh ``` --- ## 3. Recipe-uri cross-source posibile (LIVE acum, Stage 1) ### 3.1. Top autorități contestate Câte contestații a primit fiecare autoritate contractantă, în trecut. Indicator de **risc procedural**. ```sql SELECT ac AS authority_cui, e.name AS authority_name, COUNT(*) AS contestations_count, COUNT(DISTINCT cc) AS distinct_challengers, MIN(d.registration_date) AS first_seen, MAX(d.registration_date) AS last_seen FROM cnsc.decizii d, unnest(d.authority_cuis) ac, unnest(d.contestator_cuis) cc LEFT JOIN firms.entities e ON e.cui = ac GROUP BY ac, e.name HAVING COUNT(*) >= 5 ORDER BY contestations_count DESC LIMIT 50; ``` ### 3.2. Cei mai litigioși ofertanți Firme care contestă cel mai mult. La ANAF poate fi un semnal de "vexatious bidder" sau, invers, de actor care apără concurența contra abuzurilor. ```sql SELECT cc AS contestator_cui, e.name, COUNT(*) AS contestations_filed, COUNT(DISTINCT ac) AS distinct_targets FROM cnsc.decizii d, unnest(d.contestator_cuis) cc, unnest(d.authority_cuis) ac LEFT JOIN firms.entities e ON e.cui = cc GROUP BY cc, e.name HAVING COUNT(*) >= 3 ORDER BY contestations_filed DESC LIMIT 50; ``` ### 3.3. Contestator vs SEAP-supplier overlap Câte din contestațiile depuse de o firmă sunt împotriva unei proceduri pe care a câștigat-o ulterior cineva din vecinătate. ```sql SELECT d.decision_no, d.decision_year, d.registration_date, d.contestator_name_raw, d.authority_name, COUNT(s.id) AS seap_announcements_with_same_supplier, SUM(s.awarded_value) AS total_won_by_contestator_at_same_authority FROM cnsc.decizii d, unnest(d.contestator_cuis) cc, unnest(d.authority_cuis) ac JOIN seap.announcements s ON s.supplier_cui = cc AND s.authority_cui = ac GROUP BY d.id ORDER BY total_won_by_contestator_at_same_authority DESC NULLS LAST LIMIT 25; ``` --- ## 4. Killer queries (UNLOCKED de Stage 2 — PDF parse) Aceste rapoarte cer `decision_type` (admis/respins) extras din PDF. ### 4.1. Autoritățile cu cea mai mare RATĂ DE CONTESTAȚII PIERDUTE Semnal puternic de **procedură vicioasă**: autoritatea pierde la CNSC mai des decât media → fie scrie caiete de sarcini deficitare, fie evaluează vădit părtinitor. ```sql SELECT ac AS cui, e.name, COUNT(*) FILTER (WHERE decision_type IN ('admis','admis_in_parte')) AS lost, COUNT(*) FILTER (WHERE decision_type = 'respins') AS won, COUNT(*) FILTER (WHERE decision_type IS NOT NULL) AS resolved, ROUND( 100.0 * COUNT(*) FILTER (WHERE decision_type IN ('admis','admis_in_parte')) / NULLIF(COUNT(*) FILTER (WHERE decision_type IS NOT NULL), 0) , 1) AS pct_lost FROM cnsc.decizii d, unnest(d.authority_cuis) ac LEFT JOIN firms.entities e ON e.cui = ac WHERE d.decision_type IS NOT NULL GROUP BY ac, e.name HAVING COUNT(*) FILTER (WHERE decision_type IS NOT NULL) >= 5 ORDER BY pct_lost DESC, resolved DESC LIMIT 50; ``` ### 4.2. SEAP procedure → CNSC outcome → award ```sql SELECT s.ref_number, s.title, s.authority_name, s.awarded_value, s.supplier_name, d.decision_no, d.decision_type, d.contestator_name_raw FROM seap.announcements s JOIN cnsc.decizii d ON d.seap_procedure_ref = s.ref_number WHERE s.awarded_value > 1000000 AND d.decision_type = 'admis' ORDER BY s.awarded_value DESC LIMIT 100; ``` → "Tendere mari unde contestația A FOST admisă (procedura era vicioasă) DAR procedura totuși s-a finalizat cu un câștigător." Multe au fost adjudecate exact acelorași firme atacate inițial — pattern de captură. --- ## 5. Stage 2 — Estimare PDF parse (15-25h) ### Ce trebuie extras din fiecare PDF 1. **`seap_procedure_ref`** — pattern variabil în text liber: - "în cadrul procedurii simplificată...nr. CN1234567" - "anunț de participare nr. ADV2024XXXXX" - "concurs de soluții...SCN2023..." - Uneori e absent (decizii pe contestații de clarificări — ~15-20%) 2. **`decision_type`** — căutat în zonă "DISPUNE / DISPOZITIV / DECIDE": - "admite contestația" → `admis` - "admite în parte" → `admis_in_parte` - "respinge contestația" → `respins` - "redirecționează" → `redirectionat` - "arhivează" → `arhivat` - "constată inadmisibilitatea" → `respins` (subtype) 3. **`decision_date`** — data deciziei (≠ data înregistrării; e mai târziu) 4. **`decision_summary`** — primele 500 chars după "DECIDE" ### Parser pseudocode ```typescript import { execFile } from 'child_process'; async function pdfText(pdfUrl: string): Promise { // Fetch PDF, save to temp, run pdftotext -layout, return text // Cache by sha1 of bytes; idempotent. } function parseDecision(text: string) { const seapRefMatch = text.match(/\b(CN[0-9]{6,}|SCN[0-9]+|ADV[0-9]+|RFQ[0-9]+)\b/i); // Decision type — search after dispositive heading const dispoIdx = Math.max(text.indexOf('DISPUNE'), text.indexOf('DISPOZITIV'), text.indexOf('Decide')); const dispo = dispoIdx > 0 ? text.slice(dispoIdx, dispoIdx + 1500).toLowerCase() : ''; let decisionType: string | null = null; if (/admite[^a-zăîâșț]+\s*(în parte|in parte)/.test(dispo)) decisionType = 'admis_in_parte'; else if (/admite\b/.test(dispo)) decisionType = 'admis'; else if (/respinge\b/.test(dispo)) decisionType = 'respins'; else if (/redirec[țt]ion/.test(dispo)) decisionType = 'redirectionat'; else if (/arhiv/.test(dispo)) decisionType = 'arhivat'; const dateMatch = text.match(/Data:?\s*(\d{1,2})[./](\d{1,2})[./](\d{4})/); return { seapRef: seapRefMatch?.[0] ?? null, decisionType, decisionDate: dateMatch ? `${dateMatch[3]}-${dateMatch[2].padStart(2,'0')}-${dateMatch[1].padStart(2,'0')}` : null }; } ``` ### Effort breakdown (15-25h) | Task | h | |---|---| | Set up `pdftotext` invocation + tempfile cleanup, retry on transient HTTP errors | 1.5 | | Download throttling (1 PDF/s polite) + resumable per-doc state | 1 | | First-pass parser (regex above) on 500-PDF eval set + measure coverage | 3 | | Iterate on edge cases (admite parțial, multi-procedure decisions, scanned PDFs that need OCR) | 4-6 | | OCR fallback (~5-10% of older PDFs are images) — `tesseract -l ron` | 3-5 | | Concurrency runner with rate limit, persistent skip log, MV refresh | 2 | | Productionize cron + monitoring | 1 | | Documentation + recipe pages on UI | 1-2 | Total descărcare: ~30K PDF × ~100 KB = ~3 GB → trivial pe satra. --- ## 6. Riscuri și ce să nu facem - **NU îmbunătățim Stage 2 fără să avem un eval set adnotat manual.** Pe 30K PDF-uri o regexă poate avea 20% fals-pozitivi pe `decision_type` — aproape inutilizabil pentru recipe-ul "rate de contestații pierdute" (semnalul e zgomotos). Investește 2h să adnotezi 200 PDF-uri pe mână, apoi măsoară. - **Scrape rate**: serverul portal.cnsc.ro pare modest (vechi); 250ms / pagină politețe e setat în scraper, NU coborî sub 100ms. - **Schema cnsc.decizii NU stochează PDF-ul** (doar URL + docuid_b64). PDF-urile rămân la sursă; refeed e oricând posibil. Asta evită 3 GB în DB. - **CUI-uri în listing au prefix uneori (RO123)**, alteori cifre pure. Normalizat la cifre-only în array, raw păstrat în `*_raw`. Joinabil cu `firms.entities.cui` (care e la fel cifre-only). - Listing-ul are inconsistențe: `1378/2025` poate apărea pe pagină 2 (între numerele 2026), pentru că numerotarea e per-comisie (`Cx`), nu strict cronologică. UNIQUE pe `(decision_no, decision_year)` previne duplicarea. --- ## 7. Plan imediat / next steps 1. **Run full Stage 1** (~10 min): `sudo /opt/vreaudigital/services/seap-scraper/cron/scrape-cnsc.sh` → ~30K rânduri în `cnsc.decizii`. 2. **Adaugă cron daily** (5:30 AM) — capturează decizii noi. 3. **Schiță 2 recipe-uri pe `src/lib/recipes.ts`** (de către agent UI): - `cnscTopAutoritatiContestate` (3.1) - `cnscTopContestatori` (3.2) 4. **Stage 2 PDF parse** — programează după ce avem o sesiune dedicată ~25h. 5. **(Opțional)** verifică dacă portal.cnsc.ro publică un buletin oficial structurat (am văzut `/buletinoficial.html` — 1.1MB de CPV-uri; n-am explorat) care ar putea oferi mai mult metadata per-decizie.