Files
Claude VM a6c03a091e initial: split from gov-agreg — vreau.digital standalone platform
Moved from gov-agreg/src/pages/achizitii/* to root (drop prefix).
- 22 pages migrated, 127 files total
- All internal links: /achizitii/X → /X (176 occurrences fixed)
- AchizitiiLayout subnav rewritten: /X paths, top-right link to vreaudigital.ro hub
- BaseLayout new (vreau.digital branding, OG tags, site URL)
- astro.config.mjs: site https://vreau.digital, server output (was static)
- docker-compose: port 5096 (vreaudigital is 5095), container vreau-digital
- deploy.sh: paths /opt/vreau-digital, log /var/log/vreau-digital-deploy.log

Backend shared with gov-agreg:
- PostgreSQL satra (same schemas: seap, firms, anaf, anre, ...)
- Photon, Martin tiles
- Infisical /vreaudigital path (DATABASE_URL etc. shared)

build: PASS (npx astro check 0 errors, npm run build 5s vite + 10s server)
2026-05-13 00:10:32 +03:00

274 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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&reg: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":"<html><table>...</table></html>"}` — 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<string> {
// 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.