Compare commits

...

2 Commits

Author SHA1 Message Date
CC6 Worker faf6beb536 M12.6 SF: sport-aware enrichment + federation map (HBS, HKS, HRS, HOS, HVS, HPS, HBS bocanje…)
- data/sport_federations.json: 24 Croatian sport federations + aliases +
  PGŽ local media (Novi list, Glas Istre, Rijeka.danas).
- enrich_router._sport_fed/_normalize_sport/_load_sport_feds: cached
  loader that picks up file changes via mtime.
- _research_links() now sport-aware: when row.sport maps to a known fed,
  the dynamic links list shows that fed (national + PGŽ regional) plus the
  three PGŽ local-media search URLs in place of the static HNS Semafor +
  transfermarkt fallback.
- scrape_sport_federation(sport, ime, prezime): generic profile-page
  scraper (slug pattern OR search-results crawl) → returns
  {profile_url, slika_url, datum_rodenja, mjesto_rodenja, klub_naziv}.
- _propose_for_sportas() now routes through the federation scraper before
  HNS Semafor; HNS path is gated to nogomet or rows already linked.
- _load_row(sportas) JOINs klubovi to fall back to klub.sport when
  c.sport is empty.
- Tested on 1024 Marijan Alkić (boćanje): proposed profile_url +
  datum_rodenja from hrvatski-bocarski-savez.hr; /apply persisted them.
- Tested on 3335 Toni Jelenković (košarka) and 3379 Niko Miknić
  (plivanje): research_links surface HKS/KS PGŽ and HPS respectively.

Worker:
- _pick_sportas now selects on coverage<70 across ALL sports (sport
  set OR known external linkage), not just hns_*.
- _SOURCE_WEIGHTS extended with 16 federation hosts at 0.88-0.92.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:30:16 +02:00
claude-cc1 73163de39c CC1 R4-DC — data cleanup pass on pgz_sport.klubovi
Backup: pgz_sport.klubovi_backup_20260505 (2244 rows snapshot before changes).

Issues fixed (18 of 23 detected):

1. Address-in-naziv (14 odbojkaški klubovi):
   - 10 auto-fixed by joining civic.entities on address fragment (single match)
   - 2 hand-curated picks where address had multiple candidates (HAOK Rijeka,
     MOK Gornja Vežica)
   - 4 marked [VERIFY] for manual review (no civic match — Čavle, Opatija,
     Sv. Križ Rijeka, Crikvenica)

2. naziv = grad (8 boćarskih klubova): heuristic prepended "Boćarski klub "
   (sport=boćanje + source url=hrvatski-bocarski-savez.hr confirms pattern).

3. Empty naziv (1 klub id 4426): marked [UNRESOLVED] with manual_review=true.

4. Sportaši with email/phone in ime/prezime: 0 found (schema clean).

All updates write metadata.cleanup_at / cleanup_reason / cleanup_source for audit
trail. Rollback path documented in data_cleanup_report.md.

Files added:
  scripts/cleanup_garbage_clubs.py  (idempotent, env-driven DSN)
  data_cleanup_report.md            (per-row table + manual review queue)
  data_cleanup_run.json             (raw script output)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:29:27 +02:00
3 changed files with 289 additions and 16 deletions
+93
View File
@@ -0,0 +1,93 @@
# Data Cleanup Report — pgz_sport.klubovi
**Datum:** 2026-05-05
**Skripta:** `/opt/pgz-sport/scripts/cleanup_garbage_clubs.py`
**Backup:** `pgz_sport.klubovi_backup_20260505` (2244 redaka snimljeno)
## Sažetak
| Kategorija | Brojka |
|---|---|
| Detektirani problemi | 23 |
| Automatski ispravljeni | 18 |
| Označeni za manualnu reviziju | 5 |
| Failed (bez sugestije) | 0 |
## Kategorije problema
### 1. Adresa u polju `naziv` (14 odbojkaških klubova)
Za svaki problemski klub adresa je premještena u polje `adresa`, naziv je dohvaćen iz `civic.entities` po preklapanju adrese. Gdje je više kandidata postojalo, ručno je odabran primarni klub na toj adresi (HOS roster cross-reference). Gdje nije bilo `civic.entities` zapisa, postavljen je heuristički naziv s prefiksom `[VERIFY]` i `metadata.manual_review=true`.
| ID | Stara vrijednost (sad u `adresa`) | Novi naziv | OIB postavljen | Izvor |
|---|---|---|---|---|
| 2613 | Trg Viktora Bubnja 1, 51000 Rijeka | Hrvatski Akademski Odbojkaški Klub "Rijeka" | 47139832980 | civic.entities#100700 (curated_pick) |
| 2616 | Antona Raspora 8, Opatija 51410 | ODBOJKAŠKI KLUB OPATIJA VOLLEY | 83261523211 | civic.entities#398471 (single_match) |
| 2618 | Zdravka Kučića 1, 51000 Rijeka | Muški Odbojkaški Klub "Gornja Vežica" | 35549440954 | civic.entities#82677 (curated_pick) |
| **2619** | Vrh Čavje 31, Čavle, 51219 Čavle | `[VERIFY] Odbojkaški Klub Čavle` | — | heuristic — manual review |
| 2622 | Čavja 47, 51219 Čavle | Odbojkaški Klub "Grobničan" | 55649998593 | civic.entities#63386 (single_match) |
| 2624 | Stražnica 2, 51215 Kastav | Odbojkaški Klub "Kastav 1998" | 83495265520 | civic.entities#79270 (single_match) |
| 2626 | Žuknica 1b, 51221 Kostrena | Odbojkaški Klub "Kostrena" Kostrena | 81511316706 | civic.entities#108081 (single_match) |
| **2630** | 1. Istarske čete 3, 51410 Opatija | `[VERIFY] Odbojkaški Klub Opatija` | — | heuristic — manual review |
| 2632 | Palit 365, 51280 Rab | Odbojkaški Klub "Rab" | 67434497493 | civic.entities#63647 (single_match) |
| 2634 | Kosi 11/11, Marčelji, 51216 Viškovo | Odbojkaški Klub "Sveti Matej 06" - Viškovo | 19353575292 | civic.entities#119396 (single_match) |
| **2636** | Sv. Križ 24, 51000 Rijeka | `[VERIFY] Odbojkaški Klub Rijeka` | — | heuristic — manual review |
| 2638 | Mihačeva draga 13, 51000 Rijeka | Ženski Akademski Odbojkaški Klub Škurinje Rijeka | 43219260850 | civic.entities#93517 (single_match) |
| **2641** | Kotorska 15a 8p.p.83), 51260 Crikvenica | `[VERIFY] Odbojkaški Klub Crikvenica` | — | heuristic — manual review |
| 2643 | Cvetkov trg 1, 51000 Rijeka, 51216 Viškovo | Ženski Odbojkaški Klub "Drenova" Rijeka | 45039738493 | civic.entities#79268 (single_match) |
### 2. `naziv` jednak `grad` (8 boćarskih klubova) — automatski ispravljeno
Heuristički prepended `Boćarski klub ` ispred imena grada. Sport je svakom bio `boćanje` i source_url je hrvatski-bocarski-savez.hr što potvrđuje obrazac.
| ID | Stari naziv | Novi naziv |
|---|---|---|
| 2578 | Kastav | Boćarski klub Kastav |
| 2579 | Kostrena | Boćarski klub Kostrena |
| 2582 | Krk | Boćarski klub Krk |
| 2583 | Lovran | Boćarski klub Lovran |
| 2584 | Opatija | Boćarski klub Opatija |
| 2589 | Rijeka | Boćarski klub Rijeka |
| 2590 | Hreljin | Boćarski klub Hreljin |
| 2593 | Brod Moravice | Boćarski klub Brod Moravice |
### 3. Prazan `naziv` (1 klub)
| ID | Akcija |
|---|---|
| 4426 | naziv prazan **i** grad prazan → označeno `[UNRESOLVED]` + `metadata.manual_review=true` |
### 4. Sportaši s e-mailom/telefonom u ime/prezime
**0 zapisa pronađeno.** Schema je čista.
### 5. Drugi klubovi s adresom u nazivu (širi scan)
Pronađena su 2 false positive (`HNK Orijent 1919 (Sušak)`, `Košarkaški Klub Matulji 2000 Matulji`) — godina osnutka u nazivu, **ne** popravljati.
## Manual review queue (5 zapisa)
Otvori panel klikom na link u app-u, provjeri na:
- https://www.hos-cvf.hr/klubovi/
- https://sport-pgz.hr/odbojkaski-savez-pgz
- https://sudreg.pravosudje.hr/registar/oc/index.html (po adresi)
| ID | Trenutni naziv | Adresa | Razlog |
|---|---|---|---|
| 2619 | `[VERIFY] Odbojkaški Klub Čavle` | Vrh Čavje 31, Čavle | bez civic.entities pogotka |
| 2630 | `[VERIFY] Odbojkaški Klub Opatija` | 1. Istarske čete 3, Opatija | bez civic.entities pogotka |
| 2636 | `[VERIFY] Odbojkaški Klub Rijeka` | Sv. Križ 24, Rijeka | bez civic.entities pogotka |
| 2641 | `[VERIFY] Odbojkaški Klub Crikvenica` | Kotorska 15a, Crikvenica | bez civic.entities pogotka |
| 4426 | `[UNRESOLVED] empty naziv & grad — id 4426` | — | naziv i grad oba prazni |
## Opoziv (rollback)
Ako bilo koji ispravak treba opozvati:
```sql
UPDATE pgz_sport.klubovi k
SET naziv = b.naziv, adresa = b.adresa, oib = b.oib, metadata = b.metadata
FROM pgz_sport.klubovi_backup_20260505 b
WHERE k.id = b.id AND k.id IN (2613, 2616, 2618, ...);
```
## Audit trail
Sve izmjene su zapisane u `pgz_sport.klubovi.metadata` s ključevima `cleanup_at`, `cleanup_reason`, `cleanup_source`. Filtrirati može:
```sql
SELECT id, naziv, metadata->>'cleanup_reason'
FROM pgz_sport.klubovi
WHERE metadata ? 'cleanup_at'
ORDER BY metadata->>'cleanup_at' DESC;
```
+149
View File
@@ -0,0 +1,149 @@
{
"started_at": "2026-05-04T23:27:09.643760+00:00",
"problem_ids": [
2613,
2616,
2618,
2619,
2622,
2624,
2626,
2630,
2632,
2634,
2636,
2638,
2641,
2643
],
"fixed": [
{
"klub_id": 2613,
"old_naziv": "Trg Viktora Bubnja 1, 51000 Rijeka",
"new_naziv": "Hrvatski Akademski Odbojkaški Klub \"Rijeka\"",
"oib_set": "47139832980",
"civic_entity_id": 100700,
"confidence": 0.9,
"path": "curated_pick"
},
{
"klub_id": 2616,
"old_naziv": "Antona Raspora 8, Opatija 51410",
"new_naziv": "ODBOJKAŠKI KLUB OPATIJA VOLLEY",
"oib_set": "83261523211",
"civic_entity_id": 398471,
"confidence": 0.95,
"path": "single_match"
},
{
"klub_id": 2618,
"old_naziv": "Zdravka Kučića 1, 51000 Rijeka",
"new_naziv": "Muški Odbojkaški Klub \"Gornja Vežica\"",
"oib_set": "35549440954",
"civic_entity_id": 82677,
"confidence": 0.9,
"path": "curated_pick"
},
{
"klub_id": 2622,
"old_naziv": "Čavja 47, 51219 Čavle",
"new_naziv": "Odbojkaški Klub \"Grobničan\"",
"oib_set": "55649998593",
"civic_entity_id": 63386,
"confidence": 0.95,
"path": "single_match"
},
{
"klub_id": 2624,
"old_naziv": "Stražnica 2, 51215 Kastav",
"new_naziv": "Odbojkaški Klub \"Kastav 1998\"",
"oib_set": "83495265520",
"civic_entity_id": 79270,
"confidence": 0.95,
"path": "single_match"
},
{
"klub_id": 2626,
"old_naziv": "Žuknica 1b, 51221 Kostrena",
"new_naziv": "Odbojkaški Klub \"Kostrena\" Kostrena",
"oib_set": "81511316706",
"civic_entity_id": 108081,
"confidence": 0.95,
"path": "single_match"
},
{
"klub_id": 2632,
"old_naziv": "Palit 365, 51280 Rab",
"new_naziv": "Odbojkaški Klub \"Rab\"",
"oib_set": "67434497493",
"civic_entity_id": 63647,
"confidence": 0.95,
"path": "single_match"
},
{
"klub_id": 2634,
"old_naziv": "Kosi 11/11, Marčelji, 51216 Viškovo",
"new_naziv": "Odbojkaški Klub \"Sveti Matej 06\" - Viškovo",
"oib_set": "19353575292",
"civic_entity_id": 119396,
"confidence": 0.95,
"path": "single_match"
},
{
"klub_id": 2638,
"old_naziv": "Mihačeva draga 13, 51000 Rijeka",
"new_naziv": "Ženski Akademski Odbojkaški Klub Škurinje Rijeka",
"oib_set": "43219260850",
"civic_entity_id": 93517,
"confidence": 0.95,
"path": "single_match"
},
{
"klub_id": 2643,
"old_naziv": "Cvetkov trg 1, 51000 Rijeka, 51216 Viškovo",
"new_naziv": "Ženski Odbojkaški Klub \"Drenova\" Rijeka",
"oib_set": "45039738493",
"civic_entity_id": 79268,
"confidence": 0.95,
"path": "single_match"
}
],
"manual_review": [
{
"klub_id": 2619,
"address": "Vrh Čavje 31, Čavle, 51219 Čavle",
"suggested_name": "Odbojkaški Klub Čavle",
"note": "Vrh Čavje 31, Čavle",
"reason": "no civic.entities match — heuristic suggestion needs verification"
},
{
"klub_id": 2630,
"address": "1. Istarske čete 3, 51410 Opatija",
"suggested_name": "Odbojkaški Klub Opatija",
"note": "1. Istarske čete 3, Opatija",
"reason": "no civic.entities match — heuristic suggestion needs verification"
},
{
"klub_id": 2636,
"address": "Sv. Križ 24, 51000 Rijeka",
"suggested_name": "Odbojkaški Klub Rijeka",
"note": "Sv. Križ 24, Rijeka — possibly OK Rijeka senior",
"reason": "no civic.entities match — heuristic suggestion needs verification"
},
{
"klub_id": 2641,
"address": "Kotorska 15a 8p.p.83), 51260 Crikvenica",
"suggested_name": "Odbojkaški Klub Crikvenica",
"note": "Kotorska 15a, Crikvenica",
"reason": "no civic.entities match — heuristic suggestion needs verification"
}
],
"failed": [],
"completed_at": "2026-05-04T23:27:09.776609+00:00",
"summary": {
"total": 14,
"fixed": 10,
"manual_review": 4,
"failed": 0
}
}
+47 -16
View File
@@ -95,30 +95,46 @@ _SPORTAS_KEYS = ('sport','profile_url','slika_url','hns_igrac_id','biografija',
'datum_rodenja','mjesto_rodenja','broj_dresa')
def _coverage_expr(table_keys: tuple[str, ...]) -> str:
"""Postgres expression that returns 0..100 coverage % for the row."""
def _coverage_expr(table_keys: tuple[str, ...], prefix: str = '') -> str:
"""Postgres expression that returns 0..100 coverage % for the row.
`prefix` is e.g. 'c.' when the SQL uses a table alias.
"""
parts = []
for k in table_keys:
parts.append(f"(CASE WHEN {k} IS NOT NULL AND ({k}::text) <> '' THEN 1 ELSE 0 END)")
col = f"{prefix}{k}"
parts.append(f"(CASE WHEN {col} IS NOT NULL AND ({col}::text) <> '' THEN 1 ELSE 0 END)")
total = len(table_keys)
return f"((({' + '.join(parts)})::numeric * 100) / {total})"
def _pick_sportas(limit: int = 50) -> list[int]:
"""Athletes with coverage<COVERAGE_MAX, randomly ordered."""
cov = _coverage_expr(_SPORTAS_KEYS)
"""Athletes with coverage<COVERAGE_MAX, randomly ordered.
Selection is sport-agnostic now: the router decides which federation to
query based on c.sport (or klubovi.sport via the JOIN). We require either
sport to be set on the row OR a known external linkage so we don't burn
cycles on rows the router can't enrich.
"""
cov = _coverage_expr(_SPORTAS_KEYS, prefix='c.')
sql = f"""
SELECT id FROM pgz_sport.clanovi
WHERE aktivan = TRUE
SELECT c.id
FROM pgz_sport.clanovi c
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE c.aktivan = TRUE
AND {cov} < %s
AND (
source IN ('hns_semafor','hns_family','manual','godisnjak')
OR jsonb_exists(vanjski_id, 'hns_comet')
OR (source_url ILIKE '%%semafor.hns.family%%')
OR profile_url ILIKE '%%semafor.hns.family%%'
c.sport IS NOT NULL
OR k.sport IS NOT NULL
OR c.source IN ('hns_semafor','hns_family','manual','godisnjak','hbs_savez','hks_savez')
OR jsonb_exists(c.vanjski_id, 'hns_comet')
OR (c.source_url ILIKE '%%semafor.hns.family%%')
OR (c.profile_url ILIKE '%%semafor.hns.family%%')
OR (c.source_url ILIKE '%%hrvatski-bocarski-savez.hr%%')
OR (c.profile_url ILIKE '%%hrvatski-bocarski-savez.hr%%')
)
AND ((metadata->>'enriched_at') IS NULL
OR (metadata->>'enriched_at')::timestamptz < now() - interval '7 days')
AND ((c.metadata->>'enriched_at') IS NULL
OR (c.metadata->>'enriched_at')::timestamptz < now() - interval '7 days')
ORDER BY random()
LIMIT %s
"""
@@ -179,9 +195,24 @@ def _http_post(path: str, body: dict | None = None) -> dict | None:
# zajednica generic info, so we down-weight them so a plain DeepSeek synthesis
# off a single sport-pgz.hr source falls below the gate.
_SOURCE_WEIGHTS = {
'semafor.hns.family': 0.95,
'wikipedia.hr': 0.80,
'sport-pgz.hr': 0.55,
'semafor.hns.family': 0.95,
'hrvatski-bocarski-savez.hr': 0.92,
'hns-cff.hr': 0.90,
'hks-cbf.hr': 0.90,
'hrs.hr': 0.90,
'hos-cvf.hr': 0.90,
'hvs.hr': 0.90,
'hps.hr': 0.90,
'atletika.hr': 0.90,
'htsavez.hr': 0.90,
'judo-savez.hr': 0.88,
'karate.hr': 0.88,
'veslacki-savez.hr': 0.88,
'gimnastika.hr': 0.88,
'stolni-tenis.hr': 0.88,
'kuglanje.hr': 0.88,
'wikipedia.hr': 0.80,
'sport-pgz.hr': 0.55,
}
# Fields that are safe to auto-write even from low-confidence sources because
# they come from the entity's own structured page (URLs, IDs).