Files
Szpitale-graph/PLAN.md
T

58 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.
  • PLAN2.md (delta) wchłonięty tutaj: rozszerzenie Person o title/pwzNumber (→ 2, 4.7), nowe encje Organization/Company z powiązaniami po KRS/NIP/CEIDG (→ 2, 4.8, 5.2), nowe zadania kolejki agenta (→ T9T13). Plik PLAN2.md usunięty po scaleniu.
  • Kolejka §11 przepisana pod konkretny model wykonawczy: Qwen-3.6-35B-A3B (MoE, ~3B aktywnych parametrów na token, mały kontekst efektywny). Reguły atomowości zadań i zakaz planowania (już obecne w poprzedniej wersji dla „małego lokalnego modelu") pozostają — dopisano jawne odniesienie do tego modelu, by przyszłe zadania kalibrować pod jego ograniczenia kontekstu i skłonność do zmyślania API.

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."],
      "title": "prof. dr hab. n. med.",        // tytuł skondensowany (jedno pole, do wyświetlania obok fullName)
      "pwzNumber": "1234567",                  // Prawo Wykonywania Zawodu, z rejestr.nil.org.pl; null gdy nieznaleziony
      "sourceUrls": ["https://uskwb.pl/dyrekcja-szpitala/"]
    }
  ],
  "organizations": [                           // NGO/fundacje/stowarzyszenia powiązane z osobami przez KRS
    {
      "id": "org:fundacja-xyz",
      "name": "Fundacja XYZ",
      "krs": "0000123456",
      "nip": null,
      "sourceUrls": ["https://ems.ms.gov.pl/..."]
    }
  ],
  "companies": [                               // spółki/działalności gospodarcze powiązane z osobami
    {
      "id": "company:abc-sp-zoo",
      "name": "ABC sp. z o.o.",
      "krs": "0000654321",
      "nip": "1234567890",
      "ceidgId": null,
      "sourceUrls": ["https://wyszukiwarka-krs.ms.gov.pl/..."]
    }
  ],
  "personOrganizationLinks": [                 // osoba ↔ organizacja, dopasowanie po KRS
    {
      "personId": "person:jan-kochanowicz",
      "organizationId": "org:fundacja-xyz",
      "roleLabel": "Prezes Zarządu",
      "basis": "KRS",                          // klucz, po którym dopasowano powiązanie
      "sourceUrls": ["https://ems.ms.gov.pl/..."]
    }
  ],
  "organizationLinks": [                       // organizacja ↔ organizacja, po KRS/NIP (np. wspólny adres/zarząd)
    {
      "fromOrganizationId": "org:fundacja-xyz",
      "toOrganizationId": "org:inna-fundacja",
      "basis": "NIP",                          // KRS | NIP
      "note": "wspólny adres siedziby",
      "sourceUrls": ["https://ems.ms.gov.pl/..."]
    }
  ],
  "personCompanyLinks": [                      // osoba ↔ firma, po KRS/CEIDG/NIP
    {
      "personId": "person:jan-kochanowicz",
      "companyId": "company:abc-sp-zoo",
      "roleLabel": "Wspólnik",
      "basis": "KRS",                          // KRS | CEIDG | NIP
      "sourceUrls": ["https://wyszukiwarka-krs.ms.gov.pl/..."]
    }
  ],
  "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. Nowy zamknięty enum LinkBasis (KRS, NIP, CEIDG) opisuje klucz dopasowania powiązań organizacja/firma.
  • Pola note i confidence przenoszą metadane jakościowe z materiału roboczego (kluczowe dla kontekstu dziennikarskiego).
  • id organizacji/firmy jest slugiem z nazwy + (gdy dostępny) KRS w nawiasie logiki dedupu — dwie różne organizacje o tej samej nazwie, ale różnym KRS, nie są scalane (analogicznie do zasady dla Person w 4.3 pkt 4).
  • pwzNumber bywa null — brak trafienia w rejestr.nil.org.pl to nie błąd, tylko luka odnotowywana w ingest-report.json (patrz 4.7).

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
Rejestry zewnętrzne rejestr.nil.org.pl (PWZ, formularz HTML — brak API), wyszukiwarka-krs.ms.gov.pl / api-krs (KRS), CEIDG API (dane.biznes.gov.pl)

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
│   │   ├── Organization.java  Company.java  PersonOrganizationLink.java  OrganizationLink.java  PersonCompanyLink.java
│   │   └── enums/  (LegalForm, RoleType, MandateType, Confidence, LinkBasis, ...)
│   ├── 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
│   │   │   ├── PwzRegistrySource.java     # uzupełnienie pwzNumber z rejestr.nil.org.pl (4.7)
│   │   │   ├── KrsSource.java             # organizacje/firmy + powiązania po KRS (4.8)
│   │   │   └── CeidgSource.java           # powiązania osoba↔firma po CEIDG/NIP (4.8)
│   │   ├── 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).
  • PwzRegistrySource — uzupełnia pwzNumber osoby z rejestr.nil.org.pl (formularz imię+nazwisko, patrz 4.7).
  • KrsSource — wyszukuje organizacje/firmy powiązane z osobą po KRS (patrz 4.8).
  • CeidgSource — kontrola krzyżowa działalności gospodarczych osoby po CEIDG/NIP (patrz 4.8).

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.7. Uzupełnienie numeru PWZ — PwzRegistrySource

rejestr.nil.org.pl (Naczelna Izba Lekarska) udostępnia wyszukiwarkę lekarzy wyłącznie jako formularz HTML (imię + nazwisko), bez publicznego API. Wzbogacenie działa jako osobny krok w ingest, po normalizacji osób (potrzebuje fullName):

  1. Dla każdej osoby o titles sugerujących zawód medyczny (dr, dr n. med., prof., lek., itp. — heurystyka w PwzRegistrySource, nie każda osoba w radzie jest lekarzem) wyślij zapytanie z fullName.
  2. Wiele trafień (częste nazwiska) → pwzNumber = null, note = "wieloznaczne trafienie NIL, wymaga ręcznej weryfikacji", wpis w ingest-report.json.
  3. Brak trafieniapwzNumber = null, brak note (osoba może nie być lekarzem — to nie błąd).
  4. Rate-limiting jak dla HtmlScraperSource (4.2/7) — to ten sam serwis żywy, nie plik .md.

Granica odpowiedzialności: to wzbogacenie jest deterministyczne i automatyczne (1:1 zapytanie→wynik po nazwisku), inaczej niż deep-research (4.6) — nie wymaga oceny dziennikarskiej, tylko technicznej weryfikacji przy wieloznaczności.

4.8. Organizacje i firmy — KrsSource / CeidgSource

Rozszerzenie modelu o encje Organization (fundacje, stowarzyszenia) i Company (spółki, działalności gospodarcze) powiązane z osobami z organów szpitali — cel: ujawnić potencjalne konflikty interesów.

  1. KrsSource — dla każdej osoby: wyszukanie w KRS (wyszukiwarka-krs.ms.gov.pl / api-krs) wpisów, gdzie figuruje jako zarząd/wspólnik/fundator. Zwraca Organization/Company + personOrganizationLinks/personCompanyLinks z basis=KRS.
  2. Powiązania organizacja↔organizacja (organizationLinks) — wspólny KRS-owy adres, zarząd lub nip między dwiema organizacjami; basis=KRS lub basis=NIP.
  3. CeidgSource — kontrola krzyżowa jednoosobowych działalności gospodarczych osoby po CEIDG (dane.biznes.gov.pl, API publiczne) i po nip; basis=CEIDG lub basis=NIP.
  4. Deduplikacja: Organization/Company scalane po krs (klucz twardy, jednoznaczny) — w przeciwieństwie do Person, gdzie deduplikacja jest fuzzy i ostrożna (4.3 pkt 4), bo KRS/NIP są identyfikatorami unikalnymi z rejestru państwowego.
  5. Brak dopasowania po KRS/CEIDG/NIP dla danej osoby = brak wpisu (nie każda osoba w radzie ma powiązania biznesowe) — nie jest to luka do raportowania.

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, title, pwzNumber, 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)
  • :Organization {id, name, krs, nip, sourceUrls} (fundacje, stowarzyszenia)
  • :Company {id, name, krs, nip, ceidgId, sourceUrls} (spółki, działalności gospodarcze)

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
[:CZLONEK_ORGANIZACJI] Person → Organization roleLabel, basis (KRS), sourceUrls
[:POWIAZANA_Z] Organization → Organization basis (KRS/NIP), note, sourceUrls
[:POWIAZANY_Z_FIRMA] Person → Company roleLabel, basis (KRS/CEIDG/NIP), sourceUrls

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 CONSTRAINT org_id      IF NOT EXISTS FOR (o:Organization) REQUIRE o.id IS UNIQUE;
CREATE CONSTRAINT company_id  IF NOT EXISTS FOR (c:Company)   REQUIRE c.id IS UNIQUE;
CREATE INDEX person_name IF NOT EXISTS FOR (p:Person) ON (p.fullName);
CREATE INDEX person_pwz  IF NOT EXISTS FOR (p:Person) ON (p.pwzNumber);

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, title:p.title, pwzNumber:p.pwzNumber, sourceUrls:p.sourceUrls};

UNWIND $organizations AS o
MERGE (org:Organization {id: o.id})
SET org += {name:o.name, krs:o.krs, nip:o.nip, sourceUrls:o.sourceUrls};

UNWIND $companies AS c
MERGE (comp:Company {id: c.id})
SET comp += {name:c.name, krs:c.krs, nip:c.nip, ceidgId:c.ceidgId, sourceUrls:c.sourceUrls};

UNWIND $personOrganizationLinks AS pol
MATCH (p:Person {id:pol.personId}), (org:Organization {id:pol.organizationId})
MERGE (p)-[m:CZLONEK_ORGANIZACJI]->(org)
SET m += {roleLabel:pol.roleLabel, basis:pol.basis, sourceUrls:pol.sourceUrls};

UNWIND $organizationLinks AS ol
MATCH (a:Organization {id:ol.fromOrganizationId}), (b:Organization {id:ol.toOrganizationId})
MERGE (a)-[m:POWIAZANA_Z]->(b)
SET m += {basis:ol.basis, note:ol.note, sourceUrls:ol.sourceUrls};

UNWIND $personCompanyLinks AS pcl
MATCH (p:Person {id:pcl.personId}), (comp:Company {id:pcl.companyId})
MERGE (p)-[m:POWIAZANY_Z_FIRMA]->(comp)
SET m += {roleLabel:pcl.roleLabel, basis:pcl.basis, sourceUrls:pcl.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;

// Osoby w organie szpitala mające jednocześnie powiązanie biznesowe (konflikt interesów)
MATCH (h:Hospital)-[:CZLONEK_ORGANU]->(p:Person)
WHERE (p)-[:POWIAZANY_Z_FIRMA]->() OR (p)-[:CZLONEK_ORGANIZACJI]->()
OPTIONAL MATCH (p)-[:POWIAZANY_Z_FIRMA]->(comp:Company)
OPTIONAL MATCH (p)-[:CZLONEK_ORGANIZACJI]->(org:Organization)
RETURN p.fullName, h.name, collect(DISTINCT comp.name) AS firmy, collect(DISTINCT org.name) AS organizacje;

Eksport overlap_report.csv (osoba, szpitale, funkcje, partia, mandaty, firmy/organizacje, 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
Wieloznaczne trafienie w rejestr.nil.org.pl (popularne nazwisko) pwzNumber=null, note="wieloznaczne trafienie NIL, wymaga ręcznej weryfikacji", wpis w ingest-report.json
Brak trafienia w KRS/CEIDG dla osoby Brak wpisu w personOrganizationLinks/personCompanyLinks — to nie luka (nie każdy ma powiązania biznesowe)
Ta sama nazwa organizacji, różny KRS Osobne węzły :Organization (dedup po krs, nie po nazwie)

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/firmami/organizacjami, z sourceUrls i confidence.
  5. ingest-report.json — statystyki i rejestr luk/rozbieżności per województwo (w tym wieloznaczne trafienia PWZ).
  6. Testy — JUnit + Testcontainers (Neo4j) dla warstwy ładowania; testy jednostkowe normalizacji/deduplikacji.
  7. Encje Organization/Company w modelu kanonicznym i grafie, z powiązaniami do osób po KRS/CEIDG/NIP (sekcja 4.8, 5.2).

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.
  • Person rozszerzony o title/pwzNumber (sekcja 2, 4.7) + PwzRegistrySource.
  • Nowe rekordy modelu: Organization, Company, PersonOrganizationLink, OrganizationLink, PersonCompanyLink (sekcja 2) + KrsSource/CeidgSource (sekcja 4.8).
  • Warstwa 2: węzły :Organization/:Company + relacje CZLONEK_ORGANIZACJI/POWIAZANA_Z/POWIAZANY_Z_FIRMA (sekcja 5.2, 5.4) + constrainty (5.3).

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

Ta sekcja jest pisana pod lokalny model wykonawczy Qwen-3.6-35B-A3B (MoE, ~3B aktywnych parametrów na token, mały efektywny kontekst, skłonność do zmyślania nieistniejących API przy niedoprecyzowanym zadaniu). 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. Zasada 7 (poniżej) jest przy tym modelu szczególnie krytyczna: A3B chętnie "dopisuje" wygodne metody, których nie ma w src/main/java — zawsze czytaj plik przed wywołaniem metody.

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

T9 — Rozszerzyć Person o title i pwzNumber

  • Plik: src/main/java/com/developx/szpitale/model/Person.java (rekord z 5 polami: id, fullName, displayName, titles, sourceUrls).
  • Zamiar: dodać dwa nowe pola rekordu: @JsonProperty("title") String title (może być null) i @JsonProperty("pwzNumber") String pwzNumber (może być null), jako ostatnie dwa parametry konstruktora rekordu.
  • TDD:
    1. Test PersonTest.person_withTitleAndPwzNumber_fieldsAccessible w src/test/java/com/developx/szpitale/model/PersonTest.java: zbuduj Person z niepustym title i pwzNumber, asercja że person.title() i person.pwzNumber() zwracają wpisane wartości. RED (kompilacja nie przejdzie, bo konstruktor ma 5 argumentów) → dodaj pola → GREEN.
    2. Znajdź wszystkie miejsca konstruujące new Person(...) (grep -rn "new Person(" src/) i dopisz null, null (lub realną wartość jeśli już znana) na końcu wywołania — inaczej build nie skompiluje się.
  • DoD: ./mvnw -q compile przechodzi; ./mvnw -q test -Dtest=PersonTest zielony.

T10 — PwzRegistrySource (fixture, bez żywego HTTP w teście)

  • Nowy plik: src/main/java/com/developx/szpitale/ingest/source/PwzRegistrySource.java.
  • Zamiar (sekcja 4.7): metoda Optional<String> lookupPwz(String fullName) przyjmuje wynik zapytania do rejestr.nil.org.pl jako wstrzykniętą zależność (np. interfejs PwzLookupClient z metodą List<String> query(String fullName)), żeby test nie robił żywego HTTP.
  • Fixture: src/test/resources/fixtures/nil-single-match.json z jednym wynikiem (["1234567"]) i nil-multiple-matches.json z dwoma.
  • TDD:
    1. Test PwzRegistrySourceTest.lookupPwz_singleMatch_returnsPwzNumber → mock PwzLookupClient zwraca jeden wynik → lookupPwz zwraca Optional.of("1234567").
    2. Test PwzRegistrySourceTest.lookupPwz_multipleMatches_returnsEmpty → mock zwraca 2 wyniki → lookupPwz zwraca Optional.empty() (wieloznaczność, patrz 4.7 pkt 2 — nie zgadywać, który wynik jest właściwy).
    3. Test PwzRegistrySourceTest.lookupPwz_noMatch_returnsEmpty → mock zwraca pustą listę → Optional.empty().
  • DoD: ./mvnw -q test -Dtest=PwzRegistrySourceTest zielony. Nie implementuj żywego klienta HTTP w tym zadaniu — tylko interfejs PwzLookupClient + logika PwzRegistrySource na nim.

T11 — Rekordy Organization/Company + linki (round-trip)

  • Nowe pliki: src/main/java/com/developx/szpitale/model/Organization.java, Company.java, PersonOrganizationLink.java, OrganizationLink.java, PersonCompanyLink.java — pola dokładnie jak w sekcji 2 PLAN.md (przykłady JSON), jako rekordy Java z @JsonProperty (wzoruj się na Hospital.java/Person.java).
  • Zamiar: CanonicalDataset (src/main/java/com/developx/szpitale/model/CanonicalDataset.java) dostaje 5 nowych pól-list: organizations, companies, personOrganizationLinks, organizationLinks, personCompanyLinks (domyślnie puste listy, nie null).
  • TDD:
    1. Test OrganizationCompanyRoundTripTest.write_thenRead_preservesOrganizationsAndCompanies w src/test/java/com/developx/szpitale/ingest/ (mirror CanonicalRoundTripTest z T5): zbuduj CanonicalDataset z 1 Organization, 1 Company, po 1 linku każdego typu; zapisz CanonicalWriter, odczytaj CanonicalReader, porównaj pola. RED najpierw.
    2. Minimalny kod: dodaj rekordy + pola w CanonicalDataset, sprawdź że Jackson serializuje/deserializuje bez adnotacji dodatkowych (jak reszta modelu).
  • DoD: ./mvnw -q test -Dtest=OrganizationCompanyRoundTripTest zielony.

T12 — [NEO4J] GraphLoader ładuje :Organization/:Company

  • Plik prod: src/main/java/com/developx/szpitale/load/GraphLoader.java (przeczytaj istniejącą metodę ładującą Hospital/Person, wzoruj się na jej strukturze).
  • Zamiar (sekcja 5.4): dodać metodę analogiczną do istniejącego ładowania węzłów, wykonującą UNWIND $organizations ... MERGE (:Organization {id}), UNWIND $companies ... MERGE (:Company {id}) oraz 3 MERGE relacji: CZLONEK_ORGANIZACJI, POWIAZANA_Z, POWIAZANY_Z_FIRMA (dokładne zapytania Cypher w sekcji 5.4 PLAN.md).
  • Test integracyjny: GraphLoaderOrganizationIT z @Testcontainers (wzoruj się na istniejącym wzorcu z T6, jeśli GraphLoaderIT już istnieje — skopiuj setup kontenera).
    • load_organizationAndCompany_nodesCreatedWithLinks → załaduj 1 osobę + 1 organizację + 1 firmę + odpowiednie linki, sprawdź że istnieją węzły :Organization, :Company i relacje do :Person.
  • Wymaga Dockera. Bez Dockera → [~] + notatka (jak T6/T7).
  • DoD: ./mvnw -q test -Dtest=GraphLoaderOrganizationIT zielony (lub pominięte z notatką).

T13 — [NEO4J] GraphValidator — zapytanie o konflikt interesów

  • Plik prod: src/main/java/com/developx/szpitale/load/GraphValidator.java.
  • Zamiar (sekcja 5.6): dodać metodę zwracającą wynik zapytania „osoby w organie szpitala z powiązaniem biznesowym" (dokładny Cypher w sekcji 5.6 PLAN.md, blok „konflikt interesów").
  • Test integracyjny GraphValidatorOrganizationIT: wgraj dataset z 1 osobą mającą CZLONEK_ORGANU (szpital) i POWIAZANY_Z_FIRMA (firma); asercja: wynik zawiera tę osobę z nazwą firmy.
  • Wymaga Dockera. Bez Dockera → [~] + notatka.
  • DoD: ./mvnw -q test -Dtest=GraphValidatorOrganizationIT zielony (lub pominięte z notatką).

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] (skipped: Docker API too old)
  • [~] T7 GraphValidator + CSV [NEO4J] (skipped: Docker API too old, jak T6)
  • T8 Zielony pełny build
  • T9 Person: title/pwzNumber
  • T10 PwzRegistrySource
  • T11 Organization/Company rekordy + round-trip
  • [~] T12 GraphLoader: Organization/Company [NEO4J] (skipped: Docker API version mismatch - testcontainers docker-java client v1.32, min 1.44)
  • [~] T13 GraphValidator: konflikt interesow [NEO4J] (skipped: Docker API too old — same cause as T6)

Dziennik (data — zadanie — wynik):

  • 2026-07-07 — T1 — Deduplicator compile fix + unit tests [checkpoint] (commit 38a93d0)
  • 2026-07-07 — T2 — NameNormalizer testy + transliteratePolish, token-strip titles [checkpoint] (commit afe107c)
  • 2026-07-07 — T3 — PartyNormalizer testy + KO alias [checkpoint] (commit bc4eaac)
  • 2026-07-07 — T4 — MarkdownRegistrySource test + fix isPersonTable check [checkpoint] (commit 7f636a1)
  • 2026-07-07 — T5 — CanonicalWriter/Reader round-trip test [checkpoint] (commit b072cb3)
  • 2026-07-07 — T6/T8 — skip NEO4J tests (Docker API zbyt stary) + pełny build zielony [checkpoint] (commit 59d27b1)
  • 2026-07-08 — PLAN.md scalony z PLAN2.md (Person.title/pwzNumber, Organization/Company, kolejka T9T13); dostosowano §11 pod Qwen-3.6-35B-A3B.
  • 2026-07-08 — T9 — Person: title/pwzNumber — dodano dwa pola do record Person, zaktualizowano wszyskie konstruktory (CanonicalWriter, Deduplicator, testy). 7 testow zielonych.
  • 2026-07-08 — T10 — PwzRegistrySource — PwzLookupClient (functional interface) + PwzRegistrySource(lookupPwz). 4 testy zielone.
  • 2026-07-08 — T11 — Organization/Company entites + round-trip — Organization, Company, PersonOrganizationLink, OrganizationLink, PersonCompanyLink + LinkBasis enum. CanonicalDataset + CanonicalReader zaktualizowane.

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