# 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.