Files
Szpitale-graph/PLAN.md
T

41 KiB
Raw Blame History

PLAN — Graf szpitali (wielowojewódzki), architektura warstwowa Java 21

Zakres i cel

  • Zbudować graf w Neo4j łączący szpitale w całej Polsce (wszystkie 16 województw, nie tylko podlaskie) z ich kadrą kierowniczą, organami nadzorczymi (rada społeczna / rada nadzorcza), partiami/komitetami wyborczymi oraz mandatami samorządowymi (radni sejmiku, radni powiatu, wójtowie/burmistrzowie, posłowie, senatorowie).
  • Każdy węzeł osoby i szpitala niesie właściwość sourceUrl (lista) wskazującą dokładną stronę źródłową.
  • Zachować metadane jakości danych obecne w materiale roboczym (brak/niepotwierdzona, NIEZWERYFIKOWANE, brak danych, rozbieżności źródeł), aby nie utracić kontekstu dziennikarskiego z research-podlaskie.md.

Historia (poprzedni plan, superseded)

  • Pierwsza wersja planu była jednoskryptowa (Python: requests + BeautifulSoup, ładowanie przez neo4j driver) i ograniczona do województwa podlaskiego. Zastąpiona przez ten plan: wielowojewódzki, warstwowy, jako aplikacja Java 21 + Maven + Spring Shell, gdzie każda warstwa to osobna komenda CLI.
  • Wkład starego planu wchłonięty tutaj: tabela źródeł/provenance (sekcja 3→ obecnie 4.6 + 7), zapytania walidacyjne i raport nakładek (→ 5.6), obsługa przypadków brzegowych i rate-limitu (→ 7), lista produktów i checklista wykonawcza (→ 9, 10). Pseudokod Pythona porzucony — logikę realizuje kod Javy warstw ingest/load.

Proces pracy: TDD (obowiązkowy)

Cały kod tego planu powstaje test-first. Agent nie pisze kodu produkcyjnego zanim nie istnieje test, który go wymaga. Pełne reguły w CLAUDE.md; tu skrót obowiązujący dla każdego zadania (§11) i każdej klasy z sekcji 3.

Cykl red → green → refactor (małe kroki, jeden zachowaniowy przyrost = jeden obieg):

  1. RED — napisz test opisujący pożądane zachowanie w src/test/java/... (mirror pakietu z src/main/java). Uruchom ./mvnw -q test -Dtest=NazwaTestu i zobacz, że failuje (kompiluje się, asercja nie przechodzi). Test bez uprzedniej porażki nic nie dowodzi.
  2. GREEN — napisz minimalny kod produkcyjny zazieleniający test. Bez funkcji, których żaden test nie wymaga.
  3. REFACTOR — posprzątaj kod i test przy zielonym pasku; testy nadal zielone.

Zasady wiążące:

  • Test najpierw. Bug fix zaczyna się od testu reprodukującego buga (RED), potem poprawka (np. T1 Deduplicator).
  • Dług techniczny: istniejący kod produkcyjny powstał bez testów. Dotykając klasy bez pokrycia — najpierw dopisz charakteryzujący test aktualnego zachowania (green), potem prowadź zmianę cyklem TDD (patrz T2T3).
  • Testy jednostkowe (bez Neo4j, szybkie, deterministyczne): normalizacja (NameNormalizer, PartyNormalizer), deduplikacja (Deduplicator), parsowanie źródeł (MarkdownRegistrySource), serializacja canonical (CanonicalWriter/CanonicalReader).
  • Testy integracyjne dla warstwy load: Neo4j przez Testcontainers (org.testcontainers:neo4j) — idempotencja MERGE, constrainty schematu, raporty walidatora (tag [NEO4J], wymaga Dockera).
  • Nazwy testów opisują zachowanie: metoda_warunek_oczekiwanyWynik (np. normalize_polishDiacritics_strippedToAsciiSlug).
  • Definicja ukończenia: zielony ./mvnw -q test jest warunkiem uznania zadania za zrobione. Jeśli testy nie mogą się uruchomić (brak mvn/Dockera) — zgłoś jawnie, nie deklaruj sukcesu (tag [~] + notatka).

1. Architektura warstwowa

Aplikacja to jeden artefakt Spring Boot / Spring Shell z dwiema wyraźnie rozdzielonymi warstwami. Warstwy komunikują się przez plik(i) w uwspólnionym formacie kanonicznym (JSON), dzięki czemu można je uruchamiać niezależnie, wznawiać i testować.

┌─────────────────────────────────────────────────────────────┐
│  WARSTWA 1: Akwizycja + normalizacja                          │
│  komenda:  ingest                                             │
│  wejście:  źródła per-województwo (pliki .md/.csv, konektory) │
│  wyjście:  canonical/*.json  (Canonical Model, jednolity)     │
└─────────────────────────────────────────────────────────────┘
                              │  canonical JSON
                              ▼
┌─────────────────────────────────────────────────────────────┐
│  WARSTWA 2: Ładowanie do Neo4j + tworzenie powiązań           │
│  komenda:  load                                               │
│  wejście:  canonical/*.json                                   │
│  wyjście:  graf w Neo4j (węzły + relacje + provenance)        │
└─────────────────────────────────────────────────────────────┘

Rozdział warstw plikiem kanonicznym daje:

  • niezależność — warstwę 1 można uruchomić bez działającego Neo4j; warstwę 2 bez dostępu do sieci;
  • audytowalność — plik kanoniczny to wersjonowalny, przeglądalny snapshot danych (można trzymać w git);
  • idempotencję — warstwa 2 opiera się na MERGE po kluczach naturalnych, więc wielokrotne ładowanie tego samego pliku nie tworzy duplikatów.

2. Model kanoniczny (kontrakt między warstwami)

Jeden schemat dla wszystkich województw. Plik canonical/<voivodeship>.json (np. podlaskie.json, mazowieckie.json) lub jeden zbiorczy canonical/all.json.

{
  "schemaVersion": "1.0",
  "voivodeship": "podlaskie",           // slug województwa (16 możliwych wartości)
  "generatedAt": "2026-07-07T10:00:00Z",
  "hospitals": [
    {
      "id": "hosp:podlaskie:usk-bialystok",   // stabilny slug (voiv + nazwa)
      "name": "Uniwersytecki Szpital Kliniczny w Białymstoku",
      "shortName": "USK",
      "city": "Białystok",
      "voivodeship": "podlaskie",
      "legalForm": "SPZOZ",                    // SPZOZ | SP_PSYCHIATRYCZNY_ZOZ | SPOLKA_Z_OO | ...
      "foundingBody": "Uniwersytet Medyczny w Białymstoku",
      "supervisoryBodyType": "RADA_SPOLECZNA", // RADA_SPOLECZNA | RADA_NADZORCZA
      "nip": null,                             // gdy dostępny
      "krs": null,                             // dla spółek
      "website": "https://uskwb.pl",
      "sourceUrls": ["https://uskwb.pl/dyrekcja-szpitala/"]
    }
  ],
  "people": [
    {
      "id": "person:jan-kochanowicz",          // slug znormalizowanego imienia+nazwiska
      "fullName": "Jan Kochanowicz",
      "displayName": "prof. dr hab. n. med. Jan Kochanowicz",  // z tytułami, jak w źródle
      "titles": ["prof.", "dr hab.", "n. med."],
      "sourceUrls": ["https://uskwb.pl/dyrekcja-szpitala/"]
    }
  ],
  "affiliations": [                            // przynależność partyjna/komitetowa osoby
    {
      "personId": "person:karol-pilecki",
      "type": "PARTIA",                        // PARTIA | KOMITET_WYBORCZY | KWW_LOKALNY
      "name": "Koalicja Obywatelska",
      "confidence": "POTWIERDZONA",            // POTWIERDZONA | NIEPOTWIERDZONA | NIEZWERYFIKOWANA | BRAK_DANYCH
      "note": "jedno źródło prasowe: PO; historycznie PSL",
      "sourceUrls": ["https://bip-spzozwszjs.podlaskie.eu/rada.html"]
    }
  ],
  "roles": [                                   // funkcja osoby w szpitalu (krawędź hosp↔person)
    {
      "hospitalId": "hosp:podlaskie:usk-bialystok",
      "personId": "person:jan-kochanowicz",
      "roleType": "DYREKTOR",                  // DYREKTOR | ZASTEPCA_DYREKTORA | GLOWNY_KSIEGOWY
                                               // | CZLONEK_ORGANU_NADZORCZEGO | PRZEWODNICZACY_ORGANU | ...
      "roleLabel": "Dyrektor Naczelny",        // oryginalna etykieta ze źródła
      "organ": "DYREKCJA",                     // DYREKCJA | RADA_SPOLECZNA | RADA_NADZORCZA
      "status": "AKTUALNY",                    // AKTUALNY | PELNIACY_OBOWIAZKI | ELEKT | BYLY
      "validFrom": null,                       // ISO data, gdy znana
      "validTo": null,
      "note": "p.o. od 3.03.2025",
      "sourceUrls": ["https://uskwb.pl/dyrekcja-szpitala/"]
    }
  ],
  "mandates": [                                // mandat samorządowy/publiczny osoby (niezależny od szpitala)
    {
      "personId": "person:marek-olbrys",
      "mandateType": "RADNY_SEJMIKU",          // RADNY_SEJMIKU | RADNY_POWIATU | RADNY_GMINY
                                               // | WOJT_BURMISTRZ_PREZYDENT | STAROSTA | POSEL | SENATOR | MARSZALEK
      "body": "Sejmik Województwa Podlaskiego",
      "term": "VII",
      "voivodeship": "podlaskie",
      "sourceUrls": ["https://www.portalsamorzadowy.pl/osoba/marek-olbrys,876.html"]
    }
  ]
}

Uwagi projektowe:

  • id osoby jest generowane deterministycznie z znormalizowanego imienia i nazwiska (patrz normalizacja) — to podstawa deduplikacji między szpitalami i województwami (np. osoba zasiadająca w radach kilku szpitali).
  • Enumy (legalForm, roleType, mandateType, confidence, status) są zamknięte i wspólne dla wszystkich województw — to gwarantuje uwspólniony format.
  • Pola note i confidence przenoszą metadane jakościowe z materiału roboczego (kluczowe dla kontekstu dziennikarskiego).

3. Stack technologiczny

Element Wybór
Język Java 21 (records, sealed interfaces, pattern matching, virtual threads)
Build Maven (multi-module opcjonalnie; na start single-module)
Framework Spring Boot 3.x + Spring Shell 3.x (komendy CLI)
Neo4j spring-boot-starter-data-neo4j lub czysty neo4j-java-driver (Bolt)
HTTP/scraping java.net.http.HttpClient + jsoup (parsowanie HTML)
JSON Jackson (jackson-databind + jackson-datatype-jsr310)
Fuzzy-match commons-text (Levenshtein / Jaro-Winkler) do deduplikacji osób
CSV (raporty) commons-csv
Testy JUnit 5, Testcontainers (neo4j container) do testów warstwy ładowania
Konteneryzacja Neo4j Docker (neo4j:5), Bolt na 7687, UI na 7474

Szkielet projektu Maven

szpitale-graph/
├── pom.xml
├── docker-compose.yml                # Neo4j 5
├── src/main/java/com/developx/szpitale/
│   ├── SzpitaleGraphApplication.java  # @SpringBootApplication, entrypoint Spring Shell
│   ├── model/                         # rekordy modelu kanonicznego
│   │   ├── CanonicalDataset.java
│   │   ├── Hospital.java  Person.java  Affiliation.java  Role.java  Mandate.java
│   │   └── enums/  (LegalForm, RoleType, MandateType, Confidence, ...)
│   ├── ingest/                        # WARSTWA 1
│   │   ├── IngestCommand.java         # @ShellComponent — komenda `ingest`
│   │   ├── source/                    # źródła per-województwo
│   │   │   ├── VoivodeshipSource.java     # interfejs: List<RawRecord> fetch()
│   │   │   ├── MarkdownRegistrySource.java# parser materiałów typu research-*.md
│   │   │   ├── HtmlScraperSource.java     # jsoup: strony szpitali/BIP
│   │   │   └── PkwSource.java             # weryfikacja afiliacji w PKW 2024
│   │   ├── normalize/
│   │   │   ├── NameNormalizer.java    # diakrytyki, tytuły, wielkość liter, slug
│   │   │   ├── PartyNormalizer.java   # mapowanie nazw partii/komitetów
│   │   │   └── Deduplicator.java      # scalanie osób (fuzzy + reguły)
│   │   └── CanonicalWriter.java       # zapis canonical/*.json
│   └── load/                          # WARSTWA 2
│       ├── LoadCommand.java           # @ShellComponent — komenda `load`
│       ├── CanonicalReader.java       # odczyt canonical/*.json
│       ├── GraphSchema.java           # constraints + indeksy (Cypher)
│       ├── GraphLoader.java           # UNWIND/MERGE węzłów i relacji
│       └── GraphValidator.java        # zapytania walidacyjne + raport CSV
└── src/test/java/...                  # JUnit + Testcontainers

4. WARSTWA 1 — Akwizycja i normalizacja (ingest)

4.1. Zadanie

Pobrać dane z heterogenicznych źródeł dla wybranego województwa (lub wszystkich) i wyprodukować plik(i) w modelu kanonicznym. Żadnej zależności od Neo4j.

4.2. Interfejs źródła (pluginowalny per województwo)

public interface VoivodeshipSource {
    String voivodeship();                 // "podlaskie", "mazowieckie", ...
    List<RawHospitalRecord> fetch();      // surowe rekordy przed normalizacją
}

Implementacje (wybierane po fladze/konfiguracji):

  • MarkdownRegistrySource — parsuje istniejące materiały robocze w formacie tabel Markdown (jak research-podlaskie.md). To ścieżka startowa: podlaskie już mamy. Kolejne województwa dokładamy jako research-<voiv>.md (patrz sekcja 4.6 — generowane skillem deep-research).
  • HtmlScraperSourceHttpClient + jsoup do stron szpitali (dyrekcja) i BIP (rady). Rate-limiting + retry z backoffem, User-Agent, poszanowanie robots.txt.
  • PkwSource — kontrola krzyżowa afiliacji radnych w samorzad2024.pkw.gov.pl (podnosi confidence).

Rejestr źródeł spina wszystkie województwa; brak konfiguracji dla danego województwa = pomijane z ostrzeżeniem w logu (żadnych cichych luk).

4.6. Odkrywanie źródeł dla kolejnych województw — skill deep-research

Dla podlaskiego materiał źródłowy (research-podlaskie.md) już istnieje. Dla pozostałych 15 województw nie znamy z góry ani listy szpitali, ani adresów BIP z imiennymi składami rad, ani afiliacji. Ten etap rozpoznawczy realizuje skill deep-research (fan-out zapytań web, pobranie źródeł, adwersaryjna weryfikacja twierdzeń, synteza raportu z cytowaniami) — jest to krok poprzedzający warstwę 1, wykonywany przez agenta, nie przez kod Javy.

Rola: deep-research pełni funkcję „discovery" — produkuje dla danego województwa materiał research-<voiv>.md w dokładnie tym samym formacie tabel co research-podlaskie.md, który następnie wchłania MarkdownRegistrySource bez żadnych zmian w kodzie. To domyka pętlę: skill znajduje i weryfikuje źródła → zapisuje kanoniczny materiał roboczy → warstwa 1 go normalizuje → warstwa 2 ładuje do grafu.

Co zlecić skillowi (per województwo), pytania badawcze:

  1. Lista szpitali danego województwa z podziałem na: wojewódzkie/uniwersyteckie/resortowe (organ tworzący: samorząd województwa / uczelnia medyczna / MSWiA / MON) oraz powiatowe (organ tworzący: powiat), z formą prawną (SPZOZ vs spółka prawa handlowego).
  2. Kadra kierownicza każdego szpitala (dyrektor, zastępcy, gł. księgowy) — ze stron „Dyrekcja"/„Kierownictwo" i BIP szpitala.
  3. Skład organu nadzorczego — rada społeczna (SPZOZ) lub rada nadzorcza (spółka) — z BIP szpitala / uchwał organu tworzącego.
  4. Afiliacje polityczne członków organów — kontrola krzyżowa w PKW 2024 (samorzad2024.pkw.gov.pl), na portalsamorzadowy.pl, w BIP sejmiku/rad powiatów; jawne oznaczenie pewności.
  5. Mandaty samorządowe (radny sejmiku/powiatu/gminy, wójt/burmistrz/starosta, poseł/senator) osób zasiadających w organach.

Kontrakt wyjścia skilla — aby był bezpośrednio parsowalny przez MarkdownRegistrySource, deep-research musi wyprodukować:

  • nagłówek metodologiczny z datą dostępu i legendą oznaczeń pewności (jak w research-podlaskie.md);
  • sekcje per szpital z tabelą | Funkcja | Imię i nazwisko | Afiliacja partyjna | Źródło (URL) |;
  • sourceUrl przy każdym wierszu (twardy wymóg — brak URL = wiersz odrzucany/oznaczony BRAK_DANYCH);
  • markery pewności zgodne z mapowaniem na enum confidence: brak/niepotwierdzonaNIEPOTWIERDZONA, NIEZWERYFIKOWANENIEZWERYFIKOWANA, brak danychBRAK_DANYCH;
  • sekcję „Rozbieżności, luki i rekomendacje weryfikacyjne" (jak część III materiału podlaskiego).

Jak wywołać (przykład dla jednego województwa):

/deep-research  Zbuduj materiał źródłowy o kadrze kierowniczej, radach społecznych/nadzorczych
                i afiliacjach politycznych szpitali w województwie mazowieckim. Zachowaj format
                tabel i legendę oznaczeń pewności identyczne jak w research-podlaskie.md. Przy
                każdym wpisie podaj dokładny URL źródła (BIP szpitala, uchwały organu tworzącego,
                PKW 2024, portalsamorzadowy.pl). Oznacz luki i rozbieżności źródeł.

Wynik zapisujemy jako research-mazowieckie.md. Skalowanie na wszystkie województwa: uruchamiać skill po jednym województwie (koszt i wolumen źródeł są duże) albo w partiach; każde uruchomienie to osobny plik research-<voiv>.md.

Granica odpowiedzialności: deep-research znajduje i weryfikuje źródła (etap dziennikarsko-badawczy, wymaga oceny człowieka przed publikacją, zwłaszcza dla oznaczeń NIEZWERYFIKOWANA). Kod Javy (warstwa 1) nie prowadzi researchu — jedynie deterministycznie normalizuje gotowy materiał .md do modelu kanonicznego. Ten podział utrzymuje warstwę 1 testowalną i powtarzalną, a odkrywanie źródeł — audytowalne i cytowane.

4.3. Normalizacja do formatu uwspólnionego

  1. Nazwiska (NameNormalizer): usunięcie tytułów do titles[], displayName zachowuje oryginał, fullName = imię+nazwisko, id = slug z NFKD bez diakrytyków, lower-case, -. Uwaga na warianty (np. Wojciech Łuczaj występuje w kilku szpitalach).
  2. Partie/komitety (PartyNormalizer): mapowanie na kanoniczne nazwy (Koalicja Obywatelska, PiS, PSL, Polska 2050, Trzecia Droga, ...) + typ PARTIA/KOMITET_WYBORCZY/KWW_LOKALNY. confidence z markerów źródła (NIEZWERYFIKOWANENIEZWERYFIKOWANA, brak danychBRAK_DANYCH, brak/niepotwierdzonaNIEPOTWIERDZONA).
  3. Funkcje (roleType/organ/status): mapowanie etykiet („Dyrektor", „p.o. Dyrektora" → status PELNIACY_OBOWIAZKI, „Dyrektor-elekt" → ELEKT, „Rada Społeczna — Przewodniczący" → PRZEWODNICZACY_ORGANU + organ=RADA_SPOLECZNA).
  4. Deduplikacja osób (Deduplicator): scalenie po id; przy zbieżnych, ale niepewnych dopasowaniach (pospolite nazwiska: Możejko, Jabłoński, Dzięcioł) — nie scalać, dodać note="możliwy duplikat" i zachować odrębne węzły. Fuzzy-match (Jaro-Winkler) tylko jako podpowiedź do przeglądu, nie jako automatyczne scalenie.

4.4. Komenda Spring Shell

@ShellComponent
public class IngestCommand {

    @ShellMethod(key = "ingest",
        value = "Warstwa 1: pobiera i normalizuje dane szpitali do modelu kanonicznego (JSON).")
    public String ingest(
        @ShellOption(defaultValue = "all") String voivodeship,  // np. podlaskie | mazowieckie | all
        @ShellOption(defaultValue = "markdown") String source,  // markdown | html | pkw
        @ShellOption(defaultValue = "canonical") String outDir, // katalog wyjściowy
        @ShellOption(defaultValue = "false") boolean split      // plik per województwo vs all.json
    ) {
        // 1. wybierz źródła dla województw
        // 2. fetch -> normalize -> dedup
        // 3. zapisz canonical/<voiv>.json (lub all.json)
        // 4. zwróć podsumowanie: #szpitali, #osób, #ról, #mandatów, #luk
    }
}

Przykłady użycia:

ingest --voivodeship podlaskie --source markdown
ingest --voivodeship all --split true
ingest --voivodeship mazowieckie --source html --out-dir canonical

4.5. Produkt warstwy 1

  • canonical/<voiv>.json (lub canonical/all.json) zgodny ze schematem z sekcji 2.
  • ingest-report.json — statystyki i lista luk (szpitale bez składu rady, afiliacje NIEZWERYFIKOWANA) — odpowiednik „Głównych luk" z materiału roboczego.

5. WARSTWA 2 — Ładowanie do Neo4j i tworzenie powiązań (load)

5.1. Zadanie

Odczytać plik(i) kanoniczne i zmaterializować graf w Neo4j: węzły, relacje i provenance. Żadnej zależności od sieci/scrapingu. Idempotentne (MERGE).

5.2. Model grafu

Węzły (labels):

  • :Hospital {id, name, shortName, city, voivodeship, legalForm, foundingBody, supervisoryBodyType, nip, krs, website, sourceUrls}
  • :Person {id, fullName, displayName, titles, sourceUrls}
  • :Party {name, type} (partia/komitet)
  • :GovBody {name, type, voivodeship, term} (sejmik, rada powiatu, urząd gminy — dla mandatów)
  • :Voivodeship {slug, name} (grupowanie i zapytania regionalne)

Relacje:

Relacja Od → Do Właściwości
[:DYREKTOR] Hospital → Person roleLabel, status, validFrom, validTo, note, sourceUrls
[:ZASTEPCA_DYREKTORA] Hospital → Person j.w.
[:CZLONEK_ORGANU] Hospital → Person organ (RADA_SPOLECZNA/RADA_NADZORCZA), roleLabel, status, sourceUrls
[:PRZEWODNICZY_ORGANOWI] Person → Hospital organ, sourceUrls
[:CZLONEK_PARTII] Person → Party confidence, note, sourceUrls
[:PELNI_MANDAT] Person → GovBody mandateType, term, sourceUrls
[:W_WOJEWODZTWIE] Hospital → Voivodeship

Uwaga: zamiast osobnych typów relacji dla każdej funkcji można użyć jednej [:PELNI_FUNKCJE {roleType, organ, ...}]. Wybór: typowane relacje dla czytelności zapytań Cypher + roleType jako property dla filtrowania.

5.3. Schemat (constraints + indeksy)

CREATE CONSTRAINT hospital_id IF NOT EXISTS FOR (h:Hospital) REQUIRE h.id IS UNIQUE;
CREATE CONSTRAINT person_id   IF NOT EXISTS FOR (p:Person)   REQUIRE p.id IS UNIQUE;
CREATE CONSTRAINT party_name  IF NOT EXISTS FOR (x:Party)    REQUIRE x.name IS UNIQUE;
CREATE CONSTRAINT voiv_slug   IF NOT EXISTS FOR (v:Voivodeship) REQUIRE v.slug IS UNIQUE;
CREATE INDEX person_name IF NOT EXISTS FOR (p:Person) ON (p.fullName);

5.4. Ładowanie (parametryzowane, wsadowe)

UNWIND $hospitals AS h
MERGE (hosp:Hospital {id: h.id})
SET hosp += {name:h.name, city:h.city, voivodeship:h.voivodeship,
             legalForm:h.legalForm, supervisoryBodyType:h.supervisoryBodyType,
             website:h.website, sourceUrls:h.sourceUrls}
MERGE (v:Voivodeship {slug: h.voivodeship})
MERGE (hosp)-[:W_WOJEWODZTWIE]->(v);

UNWIND $people AS p
MERGE (person:Person {id: p.id})
SET person += {fullName:p.fullName, displayName:p.displayName,
               titles:p.titles, sourceUrls:p.sourceUrls};

UNWIND $roles AS r
MATCH (h:Hospital {id:r.hospitalId}), (p:Person {id:r.personId})
CALL apoc.merge.relationship(h, r.relType, {}, r.props, p) YIELD rel  // lub CASE per typ bez APOC
RETURN count(*);

UNWIND $affiliations AS a
MATCH (p:Person {id:a.personId})
MERGE (party:Party {name:a.name}) SET party.type = a.type
MERGE (p)-[m:CZLONEK_PARTII]->(party)
SET m += {confidence:a.confidence, note:a.note, sourceUrls:a.sourceUrls};

UNWIND $mandates AS md
MATCH (p:Person {id:md.personId})
MERGE (g:GovBody {name:md.body}) SET g.type = md.mandateType, g.voivodeship = md.voivodeship, g.term = md.term
MERGE (p)-[:PELNI_MANDAT {mandateType:md.mandateType, term:md.term, sourceUrls:md.sourceUrls}]->(g);

Batche po ~510 tys. wierszy w jednej transakcji (tu wolumen mały, ale trzymamy wzorzec).

Jeśli nie chcemy zależności od APOC — w GraphLoader mapujemy roleType na stały typ relacji przez switch i wykonujemy osobny sparametryzowany MERGE per typ.

5.5. Komenda Spring Shell

@ShellComponent
public class LoadCommand {

    @ShellMethod(key = "load",
        value = "Warstwa 2: ładuje model kanoniczny do Neo4j i tworzy powiązania.")
    public String load(
        @ShellOption(defaultValue = "canonical") String inDir,   // katalog z canonical/*.json
        @ShellOption(defaultValue = "all") String voivodeship,   // filtr: które pliki załadować
        @ShellOption(defaultValue = "true") boolean createSchema,// czy zakładać constraints/indeksy
        @ShellOption(defaultValue = "false") boolean wipe,       // wyczyścić graf przed ładowaniem
        @ShellOption(defaultValue = "true") boolean validate     // uruchomić walidację po ładowaniu
    ) {
        // 1. (opc.) wipe + createSchema
        // 2. read canonical -> UNWIND/MERGE nodes -> relationships
        // 3. (opc.) validate + zapis overlap_report.csv
        // 4. podsumowanie: #węzłów, #relacji per typ
    }
}

Konfiguracja połączenia w application.yml:

spring:
  neo4j:
    uri: bolt://localhost:7687
    authentication: { username: neo4j, password: password }

Przykłady:

load --in-dir canonical --voivodeship all --wipe true
load --voivodeship podlaskie --create-schema false

5.6. Walidacja i raport (GraphValidator)

// Szpitale bez dyrektora (luki do uzupełnienia)
MATCH (h:Hospital) WHERE NOT (h)-[:DYREKTOR]->() RETURN h.name, h.voivodeship;

// Osoby łączące funkcję w organie szpitala z mandatem samorządowym (kluczowy wniosek analityczny)
MATCH (h:Hospital)-[:CZLONEK_ORGANU]->(p:Person)-[:PELNI_MANDAT]->(g:GovBody)
RETURN p.fullName, collect(DISTINCT h.name) AS szpitale, collect(DISTINCT g.name) AS mandaty;

// Osoby zasiadające w organach wielu szpitali (powiązania międzyszpitalne / międzywojewódzkie)
MATCH (h:Hospital)-[:CZLONEK_ORGANU]->(p:Person)
WITH p, collect(DISTINCT h) AS hs WHERE size(hs) > 1
RETURN p.fullName, [x IN hs | x.name] AS szpitale;

Eksport overlap_report.csv (osoba, szpitale, funkcje, partia, mandaty, confidence, sourceUrls).


6. Rozszerzanie na kolejne województwa

Aby dodać województwo (np. mazowieckie):

  1. Odkrycie źródeł skillem deep-research (sekcja 4.6): wygenerować research-mazowieckie.md w formacie tabel identycznym z research-podlaskie.md — z URL-ami źródeł, oznaczeniami pewności i sekcją luk/rozbieżności.
  2. Dostarczyć źródło danych warstwie 1: materiał research-mazowieckie.md (obsłuży MarkdownRegistrySource) lub — dla źródeł ustrukturyzowanych — konfigurację scrapera BIP dla HtmlScraperSource.
  3. Zarejestrować źródło w rejestrze województw (bean/konfiguracja) — bez zmian w warstwie 2.
  4. Uruchomić ingest --voivodeship mazowieckieload --voivodeship mazowieckie.

Model kanoniczny, schemat grafu i komendy pozostają niezmienne — nowe województwo to tylko nowe dane w tym samym kontrakcie. Slug województwa jest wymiarem we wszystkich zapytaniach, co umożliwia analizy krajowe i porównania międzyregionalne.


7. Obsługa błędów i przypadków brzegowych

Sytuacja Strategia
Brak strony ze składem organu role/affiliation z confidence=BRAK_DANYCH, wpis w ingest-report.json (luka)
Identyczne nazwiska, różne osoby Nie scalać; odrębne węzły + note „możliwy duplikat"; fuzzy-match tylko jako podpowiedź
Rozbieżne źródła afiliacji Zachować obie w note, confidence=NIEZWERYFIKOWANA, obie w sourceUrls
Migracja domen BIP (wrotapodlasia.plpodlaskie.eu) Retry + fallback URL; log nieodpowiadających adresów
Dane dynamiczne (zmiany kadr) validFrom/validTo + status na relacji funkcji (AKTUALNY/PELNIACY_OBOWIAZKI/ELEKT/BYLY)
Rate-limit / blokada scrapera Exponential backoff; przełączenie na materiał .md jako cache; adnotacja źródła
Spółka z o.o. (rada nadzorcza) vs SPZOZ (rada społeczna) supervisoryBodyType + organ na relacji rozróżniają organy

8. Uruchomienie (end-to-end)

# 0. Neo4j
docker compose up -d          # neo4j:5, Bolt 7687, UI 7474

# 1. Build
mvn clean package             # Java 21, Spring Boot fat-jar

# 2. Uruchom powłokę Spring Shell
java -jar target/szpitale-graph.jar

# 3. W powłoce — warstwa 1, potem warstwa 2
shell:> ingest --voivodeship all --split true
shell:> load   --voivodeship all --wipe true --validate true

# alternatywnie tryb nieinteraktywny (jedna komenda na wywołanie):
java -jar target/szpitale-graph.jar ingest --voivodeship podlaskie
java -jar target/szpitale-graph.jar load   --voivodeship podlaskie

9. Produkty (deliverables)

  1. Aplikacja Java 21 / Maven / Spring Shell z komendami ingest (warstwa 1) i load (warstwa 2).
  2. Model kanoniczny (canonical/*.json) — wersjonowalny, wielowojewódzki snapshot danych.
  3. Schemat Neo4j — udokumentowane labels, typy relacji i właściwości (sekcja 5.25.3).
  4. overlap_report.csv — osoby łączące funkcje w organach szpitali z mandatami/partiami, z sourceUrls i confidence.
  5. ingest-report.json — statystyki i rejestr luk/rozbieżności per województwo.
  6. Testy — JUnit + Testcontainers (Neo4j) dla warstwy ładowania; testy jednostkowe normalizacji/deduplikacji.

10. Checklista wykonawcza

  • pom.xml: Java 21, Spring Boot 3.x, Spring Shell, Neo4j driver, jsoup, Jackson, commons-text/csv.
  • Rekordy modelu kanonicznego + enumy (sekcja 2).
  • Warstwa 1: VoivodeshipSource + MarkdownRegistrySource (start: parser research-podlaskie.md).
  • Normalizacja: NameNormalizer, PartyNormalizer, Deduplicator.
  • Komenda ingest + zapis canonical/*.json + ingest-report.json.
  • Warstwa 2: GraphSchema, GraphLoader (UNWIND/MERGE), GraphValidator.
  • Komenda load + overlap_report.csv.
  • docker-compose.yml (Neo4j 5) + application.yml (Bolt, credentials).
  • Testy: parsowanie MD, normalizacja nazwisk, ładowanie do Testcontainers-Neo4j, zapytania walidacyjne.
  • Weryfikacja end-to-end na podlaskim, następnie dołożenie kolejnego województwa: deep-researchresearch-<voiv>.mdingestload.
  • Dla każdego nowego województwa: uruchomić skill deep-research (sekcja 4.6), zweryfikować oznaczenia NIEZWERYFIKOWANA przed użyciem, zapisać research-<voiv>.md.

11. Kolejka zadań dla lokalnego agenta (TDD, kroki atomowe)

Ta sekcja jest pisana pod mały lokalny model o ograniczonym kontekście. Każde zadanie jest samowystarczalne: jeden plik lub jedna metoda, dokładna ścieżka, dokładny test, jedna komenda weryfikacji, twarde kryterium ukończenia. Agent nie planuje — bierze pierwsze niezrobione zadanie i wykonuje je w całości.

Reguły pracy (przeczytaj przed każdym zadaniem)

  1. Bierz DOKŁADNIE jedno zadanie z listy poniżej, od góry. Nie łącz zadań.
  2. Nie dotykaj plików spoza zadania. Jeśli zadanie mówi „plik X", edytuj tylko X i jego test.
  3. Cykl TDD (obowiązkowy, patrz CLAUDE.md):
    • napisz test w src/test/java/... (mirror pakietu),
    • uruchom ./mvnw -q test -Dtest=NazwaTestu i zobacz RED (czerwony),
    • napisz minimalny kod produkcyjny → GREEN,
    • posprzątaj przy zielonym → REFACTOR.
  4. Komenda z katalogu szpitale-graph/. Zawsze ./mvnw (jest wrapper), nigdy mvn.
  5. Definicja ukończenia zadania: ./mvnw -q test przechodzi na zielono ORAZ nie ma nowych ostrzeżeń kompilatora dla dotkniętego pliku.
  6. Jeśli utkniesz > 2 próby — zostaw plik bez zmian, dopisz jednolinijkową notatkę // TODO(local-agent): <opis blokady> i przejdź dalej NIE psując builda.
  7. Nie wymyślaj API. Używaj tylko klas/metod, które już istnieją w src/main/java (sprawdź plik zanim wywołasz metodę).
  8. Zadania z tagiem [NEO4J] wymagają Dockera (Testcontainers). Jeśli docker niedostępny — pomiń, zostaw zadanie odhaczone jako [~] z notatką „brak Dockera".
  9. Zapisz punkt kontrolny po KAŻDYM zadaniu (patrz §13). Bez zapisanego checkpointu zadanie NIE jest ukończone, nawet jeśli testy są zielone.

Legenda statusów: [ ] niezrobione · [x] zrobione · [~] pominięte (z notatką)


T1 — Naprawić kompilację w Deduplicator [BLOKER, robić pierwsze]

  • Plik: src/main/java/com/developx/szpitale/ingest/normalize/Deduplicator.java, metoda checkFuzzyDuplicates.
  • Objaw: groups jest typu Map<String, List<Person>>, a kod wkłada do niego List<String> (błąd kompilacji ~linia 75). Zmienna personIds jest nieużywana, a pętla for (String existingId : groups.keySet()) iteruje po pustej mapie — nigdy nie porówna z już-widzianymi osobami.
  • Zamiar metody: dla każdej osoby znaleźć już przetworzone osoby o podobnym nazwisku (Jaro-Winkler w przedziale [SUSPECT_THRESHOLD, DUPLICATE_THRESHOLD)) i wypisać ostrzeżenie o możliwych duplikatach. Nie scalać (patrz sekcja 4.3 pkt 4).
  • TDD:
    1. Test DeduplicatorTest.checkFuzzyDuplicates_similarNames_reportedNotMerged: podaj listę 2 osób o bardzo podobnych fullName (np. Jan Kowalski / Jan Kowaski) o różnych id; asercja: obie osoby nadal istnieją jako odrębne węzły (brak scalenia). RED najpierw.
    2. Minimalny fix: zmień typ na Map<String, List<String>> (id → lista podobnych id) i porównuj bieżącą osobę z wcześniej odwiedzonymi (np. utrzymuj List<Person> seen i iteruj po niej), a nie po groups.keySet(). Popraw wypis, by używał candidates, nie personIds; usuń personIds.
  • DoD: ./mvnw -q compile przechodzi; ./mvnw -q test -Dtest=DeduplicatorTest zielony.

T2 — Testy NameNormalizer (charakteryzujące + slug)

  • Plik prod: src/main/java/com/developx/szpitale/ingest/normalize/NameNormalizer.java (istnieje — najpierw go przeczytaj, nazwy metod bierz z pliku).
  • Test: NameNormalizerTest w src/test/java/com/developx/szpitale/ingest/normalize/.
  • Przypadki (po jednym asercie na test):
    • normalize_polishDiacritics_strippedToAsciiSlug → wejście Łukasz Żółć daje slug bez diakrytyków, lower-case, separator - (np. lukasz-zolc).
    • normalize_academicTitles_movedToTitlesListprof. dr hab. Jan KochanowiczfullName == "Jan Kochanowicz", titles zawiera prof., dr hab..
    • normalize_displayName_keepsOriginaldisplayName zachowuje oryginał z tytułami.
  • DoD: ./mvnw -q test -Dtest=NameNormalizerTest zielony. Jeśli zachowanie kodu odbiega od oczekiwań — to jest RED; napraw kod minimalnie, chyba że wymagałoby to zmiany API (wtedy // TODO(local-agent) i pomiń dany przypadek).

T3 — Testy PartyNormalizer (mapowanie nazw + confidence)

  • Plik prod: .../ingest/normalize/PartyNormalizer.java (przeczytaj metody).
  • Test: PartyNormalizerTest.
  • Przypadki:
    • mapParty_knownAlias_canonicalNamePO / KOKoalicja Obywatelska (użyj aliasu, który realnie jest w mapie w kodzie).
    • mapConfidence_marker_toEnum → marker NIEZWERYFIKOWANEConfidenceLevel.NIEZWERYFIKOWANA; brak danychBRAK_DANYCH; brak/niepotwierdzonaNIEPOTWIERDZONA.
  • DoD: ./mvnw -q test -Dtest=PartyNormalizerTest zielony.

T4 — Test MarkdownRegistrySource na małym fixture

  • Plik prod: .../ingest/source/MarkdownRegistrySource.java (przeczytaj sygnaturę fetch() / konstruktor).
  • Fixture: utwórz src/test/resources/fixtures/research-mini.md z JEDNYM szpitalem i tabelą | Funkcja | Imię i nazwisko | Afiliacja partyjna | Źródło (URL) | z 1 wierszem zawierającym URL.
  • Test: MarkdownRegistrySourceTest.fetch_miniFixture_returnsOneHospitalRecord → asercja: dokładnie 1 RawHospitalRecord, z niepustym sourceUrl.
  • Zasada twarda (sekcja 4.6): wiersz bez URL → odrzucony lub oznaczony BRAK_DANYCH. Dodaj drugi test fetch_rowWithoutUrl_flaggedOrSkipped na to.
  • DoD: ./mvnw -q test -Dtest=MarkdownRegistrySourceTest zielony.

T5 — Round-trip CanonicalWriterCanonicalReader

  • Pliki prod: .../ingest/CanonicalWriter.java, .../load/CanonicalReader.java.
  • Test: CanonicalRoundTripTest.write_thenRead_yieldsEqualDataset → zbuduj mały CanonicalDataset (1 szpital, 1 osoba, 1 rola), zapisz do pliku tymczasowego (@TempDir), odczytaj i porównaj pola. RED najpierw (może ujawnić brak modułu JSR-310 lub złą serializację enumów).
  • DoD: ./mvnw -q test -Dtest=CanonicalRoundTripTest zielony.

T6 — [NEO4J] Idempotencja GraphLoader (Testcontainers)

  • Pliki prod: .../load/GraphSchema.java, .../load/GraphLoader.java.
  • Test integracyjny: GraphLoaderIT z @Testcontainers + Neo4jContainer (dependency już w pom.xml).
  • Przypadki:
    • load_twice_isIdempotent → załaduj ten sam mały dataset 2× i sprawdź, że liczba węzłów :Hospital/:Person się nie podwaja (skutek MERGE).
    • schema_constraints_created → po GraphSchema istnieje constraint unikalności na Hospital.id.
  • Wymaga Dockera. Bez Dockera → [~] + notatka.
  • DoD: ./mvnw -q test -Dtest=GraphLoaderIT zielony (lub pominięte z notatką).

T7 — [NEO4J] Zapytania walidatora + overlap_report.csv

  • Plik prod: .../load/GraphValidator.java.
  • Test integracyjny GraphValidatorIT: wgraj dataset, w którym jedna osoba ma i CZLONEK_ORGANU (szpital) i PELNI_MANDAT (GovBody); asercja: raport nakładek zawiera tę osobę; plik overlap_report.csv powstaje i ma nagłówek z sekcji 5.6.
  • DoD: ./mvnw -q test -Dtest=GraphValidatorIT zielony (lub pominięte z notatką).

T8 — Zielony pełny build

  • Uruchom ./mvnw -q test (wszystko). Cel: brak błędów, brak nowych ostrzeżeń.
  • Jeśli któreś [NEO4J] pominięte — odnotuj w tej checklistcie jako [~] z powodem.
  • DoD: ./mvnw -q verify przechodzi (pomijając wyłącznie zadania bez Dockera).

12. Sekcja postępu (aktualizuje lokalny agent)

Po ukończeniu zadania agent zmienia jego status w liście poniżej i dopisuje jedną linię do dziennika.

  • T1 Deduplicator compile fix
  • T2 NameNormalizer testy
  • T3 PartyNormalizer testy
  • T4 MarkdownRegistrySource test
  • T5 Canonical round-trip
  • T6 GraphLoader idempotencja [NEO4J]
  • T7 GraphValidator + CSV [NEO4J]
  • T8 Zielony pełny build

Dziennik (data — zadanie — wynik):

  • (pusto)

13. Punkty kontrolne i wznawianie (odporność na śmierć agenta)

Cel: jeśli lokalny agent zostanie ubity w połowie (crash, wyczerpany kontekst, restart), następne uruchomienie musi kontynuować od ostatniego bezpiecznego stanu, bez powtarzania zrobionej pracy i bez zostawiania połowicznych zmian.

Mechanizm opiera się na dwóch trwałych źródłach prawdy:

  • git — jeden commit = jeden ukończony punkt kontrolny (kod jest w historii, nie tylko w drzewie roboczym);
  • local-agent-progress.json (w katalogu szpitale-graph/) — maszynowy stan zadań, czytany na starcie.

13.1. Procedura STARTU (zawsze na początku uruchomienia)

  1. cd szpitale-graph
  2. Przeczytaj local-agent-progress.json.
  3. git status --porcelain — sprawdź drzewo robocze:
    • Czyste → wznów od pierwszego zadania o statusie todo.
    • Brudne (są niezacommitowane zmiany) → oznacza to przerwanie w połowie zadania in_progress. Wykonaj odzysk: git checkout -- . && git clean -fd (odrzuć połowiczną pracę), ustaw to zadanie z powrotem na todo w JSON, i zacznij je od zera. Połowiczny kod jest zawsze do wyrzucenia, bo checkpoint zapisujemy tylko przy zielonych testach.
  4. Zweryfikuj punkt startowy: ./mvnw -q test powinno odzwierciedlać ostatni checkpoint (zielone dla zrobionych zadań; czerwone tylko dla T1 na samym starcie projektu).

13.2. Procedura na WEJŚCIU w zadanie

  1. Ustaw status zadania w JSON na in_progress, wpisz startedAt (data z systemu).
  2. Zacommituj samą tę zmianę stanu: git add local-agent-progress.json && git commit -m "T<n>: start". Dzięki temu drzewo jest czyste w trakcie pracy nad kodem, a przerwanie łatwo wykryć (jakiekolwiek brudne pliki = połowiczna praca do odrzucenia).

13.3. Procedura ZAPISU punktu kontrolnego (po zielonych testach)

Wykonuj dopiero gdy ./mvnw -q test (lub -Dtest=... dla danego zadania) jest zielone:

  1. Zaktualizuj local-agent-progress.json: status done, finishedAt, krótka note.
  2. Zaktualizuj checklistę w §12 ([ ][x] / [~]) i dopisz linię do dziennika §12.
  3. Jeden atomowy commit obejmujący kod + testy + JSON + PLAN:
    git add -A && git commit -m "T<n>: <krótki opis> [checkpoint]"
    
  4. Po commicie drzewo jest czyste → to jest bezpieczny punkt wznowienia.

13.4. Niezmienniki (muszą zachodzić między zadaniami)

  • Każdy commit buduje się — nigdy nie commituj czerwonego builda (wyjątek: jedyny dozwolony czerwony stan to punkt WYJŚCIOWY projektu przed T1).
  • Jeden checkpoint = jedno zadanie — nie łącz dwóch zadań w jeden commit.
  • Brudne drzewo == praca w toku do odrzucenia — nigdy nie da się „częściowo ukończonego" zadania odzyskać; zawsze restart zadania od zera. To celowe uproszczenie: agent nie musi rozumować o połowicznym stanie.
  • JSON i git zgadzają się — jeśli JSON mówi done a git log nie ma checkpointu tego zadania (lub odwrotnie), źródłem prawdy jest git: napraw JSON do stanu wynikającego z historii commitów, potem kontynuuj.

13.5. Format local-agent-progress.json

Pełny bieżący stan jest w pliku szpitale-graph/local-agent-progress.json. Schemat pola tasks[]:

  • id (np. "T1"), title, status (todo | in_progress | done | skipped),
  • startedAt, finishedAt (ISO-8601 lub null), note (string).

Wznawianie sprowadza się do: wczytaj JSON → znajdź pierwszy todo/in_progress → (jeśli in_progress + brudne drzewo) odzysk wg §13.1 → wykonuj wg §13.213.3.


Koniec planu