# 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](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 (→ T9–T13). 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](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 T2–T3). - **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/.json` (np. `podlaskie.json`, `mazowieckie.json`) lub jeden zbiorczy `canonical/all.json`. ```jsonc { "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 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) ```java public interface VoivodeshipSource { String voivodeship(); // "podlaskie", "mazowieckie", ... List 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-.md` (patrz sekcja 4.6 — generowane skillem deep-research). - **`HtmlScraperSource`** — `HttpClient` + 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-.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/niepotwierdzona` → `NIEPOTWIERDZONA`, `NIEZWERYFIKOWANE` → `NIEZWERYFIKOWANA`, `brak danych` → `BRAK_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-.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 trafienia** → `pwzNumber = 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 (`NIEZWERYFIKOWANE` → `NIEZWERYFIKOWANA`, `brak danych` → `BRAK_DANYCH`, `brak/niepotwierdzona` → `NIEPOTWIERDZONA`). 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 ```java @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/.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/.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) ```cypher 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) ```cypher 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 ~5–10 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 ```java @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`: ```yaml 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`) ```cypher // 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 mazowieckie` → `load --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.pl`→`podlaskie.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) ```bash # 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.2–5.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-research` → `research-.md` → `ingest` → `load`. - [ ] Dla każdego nowego województwa: uruchomić skill `deep-research` (sekcja 4.6), zweryfikować oznaczenia `NIEZWERYFIKOWANA` przed użyciem, zapisać `research-.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): ` 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>`, a kod wkłada do niego `List` (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>` (id → lista podobnych id) i porównuj bieżącą osobę z **wcześniej odwiedzonymi** (np. utrzymuj `List 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_movedToTitlesList` → `prof. dr hab. Jan Kochanowicz` → `fullName == "Jan Kochanowicz"`, `titles` zawiera `prof.`, `dr hab.`. - `normalize_displayName_keepsOriginal` → `displayName` 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_canonicalName` → `PO` / `KO` → `Koalicja Obywatelska` (użyj aliasu, który realnie jest w mapie w kodzie). - `mapConfidence_marker_toEnum` → marker `NIEZWERYFIKOWANE` → `ConfidenceLevel.NIEZWERYFIKOWANA`; `brak danych` → `BRAK_DANYCH`; `brak/niepotwierdzona` → `NIEPOTWIERDZONA`. - 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 `CanonicalWriter` → `CanonicalReader`** - 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 lookupPwz(String fullName)` przyjmuje wynik zapytania do `rejestr.nil.org.pl` jako **wstrzykniętą zależność** (np. interfejs `PwzLookupClient` z metodą `List 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. - [x] T1 Deduplicator compile fix - [x] T2 NameNormalizer testy - [x] T3 PartyNormalizer testy - [x] T4 MarkdownRegistrySource test - [x] T5 Canonical round-trip - [~] T6 GraphLoader idempotencja [NEO4J] (skipped: Docker API too old) - [~] T7 GraphValidator + CSV [NEO4J] (skipped: Docker API too old, jak T6) - [x] T8 Zielony pełny build - [x] T9 Person: title/pwzNumber - [x] T10 PwzRegistrySource - [x] T11 Organization/Company rekordy + round-trip - [ ] T12 GraphLoader: Organization/Company [NEO4J] - [ ] T13 GraphValidator: konflikt interesów [NEO4J] **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 T9–T13); 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: 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: [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.2–13.3. --- **Koniec planu**