58 KiB
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 przezneo4jdriver) 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: rozszerzeniePersonotitle/pwzNumber(→ 2, 4.7), nowe encjeOrganization/Companyz powiązaniami po KRS/NIP/CEIDG (→ 2, 4.8, 5.2), nowe zadania kolejki agenta (→ T9–T13). PlikPLAN2.mdusunię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):
- RED — napisz test opisujący pożądane zachowanie w
src/test/java/...(mirror pakietu zsrc/main/java). Uruchom./mvnw -q test -Dtest=NazwaTestui zobacz, że failuje (kompiluje się, asercja nie przechodzi). Test bez uprzedniej porażki nic nie dowodzi. - GREEN — napisz minimalny kod produkcyjny zazieleniający test. Bez funkcji, których żaden test nie wymaga.
- 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) — idempotencjaMERGE, 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 testjest warunkiem uznania zadania za zrobione. Jeśli testy nie mogą się uruchomić (brakmvn/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
MERGEpo 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:
idosoby 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 enumLinkBasis(KRS,NIP,CEIDG) opisuje klucz dopasowania powiązań organizacja/firma. - Pola
noteiconfidenceprzenoszą metadane jakościowe z materiału roboczego (kluczowe dla kontekstu dziennikarskiego). idorganizacji/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 dlaPersonw 4.3 pkt 4).pwzNumberbywanull— brak trafienia w rejestr.nil.org.pl to nie błąd, tylko luka odnotowywana wingest-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 (jakresearch-podlaskie.md). To ścieżka startowa: podlaskie już mamy. Kolejne województwa dokładamy jakoresearch-<voiv>.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, poszanowanierobots.txt.PkwSource— kontrola krzyżowa afiliacji radnych wsamorzad2024.pkw.gov.pl(podnosiconfidence).PwzRegistrySource— uzupełniapwzNumberosoby zrejestr.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:
- 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).
- Kadra kierownicza każdego szpitala (dyrektor, zastępcy, gł. księgowy) — ze stron „Dyrekcja"/„Kierownictwo" i BIP szpitala.
- Skład organu nadzorczego — rada społeczna (SPZOZ) lub rada nadzorcza (spółka) — z BIP szpitala / uchwał organu tworzącego.
- 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. - 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) |; sourceUrlprzy każdym wierszu (twardy wymóg — brak URL = wiersz odrzucany/oznaczonyBRAK_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-<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):
- Dla każdej osoby o
titlessugerujących zawód medyczny (dr,dr n. med.,prof.,lek., itp. — heurystyka wPwzRegistrySource, nie każda osoba w radzie jest lekarzem) wyślij zapytanie zfullName. - Wiele trafień (częste nazwiska) →
pwzNumber = null,note = "wieloznaczne trafienie NIL, wymaga ręcznej weryfikacji", wpis wingest-report.json. - Brak trafienia →
pwzNumber = null, braknote(osoba może nie być lekarzem — to nie błąd). - 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.
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. ZwracaOrganization/Company+personOrganizationLinks/personCompanyLinkszbasis=KRS.- Powiązania organizacja↔organizacja (
organizationLinks) — wspólny KRS-owy adres, zarząd lubnipmiędzy dwiema organizacjami;basis=KRSlubbasis=NIP. CeidgSource— kontrola krzyżowa jednoosobowych działalności gospodarczych osoby po CEIDG (dane.biznes.gov.pl, API publiczne) i ponip;basis=CEIDGlubbasis=NIP.- Deduplikacja:
Organization/Companyscalane pokrs(klucz twardy, jednoznaczny) — w przeciwieństwie doPerson, gdzie deduplikacja jest fuzzy i ostrożna (4.3 pkt 4), bo KRS/NIP są identyfikatorami unikalnymi z rejestru państwowego. - 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
- Nazwiska (
NameNormalizer): usunięcie tytułów dotitles[],displayNamezachowuje oryginał,fullName= imię+nazwisko,id= slug z NFKD bez diakrytyków, lower-case,-. Uwaga na warianty (np.Wojciech Łuczajwystępuje w kilku szpitalach). - Partie/komitety (
PartyNormalizer): mapowanie na kanoniczne nazwy (Koalicja Obywatelska,PiS,PSL,Polska 2050,Trzecia Droga, ...) + typPARTIA/KOMITET_WYBORCZY/KWW_LOKALNY.confidencez markerów źródła (NIEZWERYFIKOWANE→NIEZWERYFIKOWANA,brak danych→BRAK_DANYCH,brak/niepotwierdzona→NIEPOTWIERDZONA). - Funkcje (
roleType/organ/status): mapowanie etykiet („Dyrektor", „p.o. Dyrektora" → statusPELNIACY_OBOWIAZKI, „Dyrektor-elekt" →ELEKT, „Rada Społeczna — Przewodniczący" →PRZEWODNICZACY_ORGANU+organ=RADA_SPOLECZNA). - Deduplikacja osób (
Deduplicator): scalenie poid; 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(lubcanonical/all.json) zgodny ze schematem z sekcji 2.ingest-report.json— statystyki i lista luk (szpitale bez składu rady, afiliacjeNIEZWERYFIKOWANA) — 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 +roleTypejako 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 ~5–10 tys. wierszy w jednej transakcji (tu wolumen mały, ale trzymamy wzorzec).
Jeśli nie chcemy zależności od APOC — w
GraphLoadermapujemyroleTypena stały typ relacji przezswitchi wykonujemy osobny sparametryzowanyMERGEper 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):
- Odkrycie źródeł skillem
deep-research(sekcja 4.6): wygenerowaćresearch-mazowieckie.mdw formacie tabel identycznym zresearch-podlaskie.md— z URL-ami źródeł, oznaczeniami pewności i sekcją luk/rozbieżności. - Dostarczyć źródło danych warstwie 1: materiał
research-mazowieckie.md(obsłużyMarkdownRegistrySource) lub — dla źródeł ustrukturyzowanych — konfigurację scrapera BIP dlaHtmlScraperSource. - Zarejestrować źródło w rejestrze województw (bean/konfiguracja) — bez zmian w warstwie 2.
- 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)
# 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)
- Aplikacja Java 21 / Maven / Spring Shell z komendami
ingest(warstwa 1) iload(warstwa 2). - Model kanoniczny (
canonical/*.json) — wersjonowalny, wielowojewódzki snapshot danych. - Schemat Neo4j — udokumentowane labels, typy relacji i właściwości (sekcja 5.2–5.3).
overlap_report.csv— osoby łączące funkcje w organach szpitali z mandatami/partiami/firmami/organizacjami, zsourceUrlsiconfidence.ingest-report.json— statystyki i rejestr luk/rozbieżności per województwo (w tym wieloznaczne trafienia PWZ).- Testy — JUnit + Testcontainers (Neo4j) dla warstwy ładowania; testy jednostkowe normalizacji/deduplikacji.
- Encje
Organization/Companyw 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: parserresearch-podlaskie.md). - Normalizacja:
NameNormalizer,PartyNormalizer,Deduplicator. - Komenda
ingest+ zapiscanonical/*.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-<voiv>.md→ingest→load. - Dla każdego nowego województwa: uruchomić skill
deep-research(sekcja 4.6), zweryfikować oznaczeniaNIEZWERYFIKOWANAprzed użyciem, zapisaćresearch-<voiv>.md. Personrozszerzony otitle/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+ relacjeCZLONEK_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)
- Bierz DOKŁADNIE jedno zadanie z listy poniżej, od góry. Nie łącz zadań.
- Nie dotykaj plików spoza zadania. Jeśli zadanie mówi „plik X", edytuj tylko X i jego test.
- Cykl TDD (obowiązkowy, patrz
CLAUDE.md):- napisz test w
src/test/java/...(mirror pakietu), - uruchom
./mvnw -q test -Dtest=NazwaTestui zobacz RED (czerwony), - napisz minimalny kod produkcyjny → GREEN,
- posprzątaj przy zielonym → REFACTOR.
- napisz test w
- Komenda z katalogu
szpitale-graph/. Zawsze./mvnw(jest wrapper), nigdymvn. - Definicja ukończenia zadania:
./mvnw -q testprzechodzi na zielono ORAZ nie ma nowych ostrzeżeń kompilatora dla dotkniętego pliku. - 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. - Nie wymyślaj API. Używaj tylko klas/metod, które już istnieją w
src/main/java(sprawdź plik zanim wywołasz metodę). - Zadania z tagiem [NEO4J] wymagają Dockera (Testcontainers). Jeśli
dockerniedostępny — pomiń, zostaw zadanie odhaczone jako[~]z notatką „brak Dockera". - 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, metodacheckFuzzyDuplicates. - Objaw:
groupsjest typuMap<String, List<Person>>, a kod wkłada do niegoList<String>(błąd kompilacji ~linia 75). ZmiennapersonIdsjest nieużywana, a pętlafor (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:
- Test
DeduplicatorTest.checkFuzzyDuplicates_similarNames_reportedNotMerged: podaj listę 2 osób o bardzo podobnychfullName(np.Jan Kowalski/Jan Kowaski) o różnychid; asercja: obie osoby nadal istnieją jako odrębne węzły (brak scalenia). RED najpierw. - Minimalny fix: zmień typ na
Map<String, List<String>>(id → lista podobnych id) i porównuj bieżącą osobę z wcześniej odwiedzonymi (np. utrzymujList<Person> seeni iteruj po niej), a nie pogroups.keySet(). Popraw wypis, by używałcandidates, niepersonIds; usuńpersonIds.
- Test
- DoD:
./mvnw -q compileprzechodzi;./mvnw -q test -Dtest=DeduplicatorTestzielony.
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:
NameNormalizerTestwsrc/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",titleszawieraprof.,dr hab..normalize_displayName_keepsOriginal→displayNamezachowuje oryginał z tytułami.
- DoD:
./mvnw -q test -Dtest=NameNormalizerTestzielony. 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→ markerNIEZWERYFIKOWANE→ConfidenceLevel.NIEZWERYFIKOWANA;brak danych→BRAK_DANYCH;brak/niepotwierdzona→NIEPOTWIERDZONA.
- DoD:
./mvnw -q test -Dtest=PartyNormalizerTestzielony.
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.mdz 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 1RawHospitalRecord, z niepustymsourceUrl. - Zasada twarda (sekcja 4.6): wiersz bez URL → odrzucony lub oznaczony
BRAK_DANYCH. Dodaj drugi testfetch_rowWithoutUrl_flaggedOrSkippedna to. - DoD:
./mvnw -q test -Dtest=MarkdownRegistrySourceTestzielony.
T5 — Round-trip CanonicalWriter → CanonicalReader
- Pliki prod:
.../ingest/CanonicalWriter.java,.../load/CanonicalReader.java. - Test:
CanonicalRoundTripTest.write_thenRead_yieldsEqualDataset→ zbuduj małyCanonicalDataset(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=CanonicalRoundTripTestzielony.
T6 — [NEO4J] Idempotencja GraphLoader (Testcontainers)
- Pliki prod:
.../load/GraphSchema.java,.../load/GraphLoader.java. - Test integracyjny:
GraphLoaderITz@Testcontainers+Neo4jContainer(dependency już wpom.xml). - Przypadki:
load_twice_isIdempotent→ załaduj ten sam mały dataset 2× i sprawdź, że liczba węzłów:Hospital/:Personsię nie podwaja (skutekMERGE).schema_constraints_created→ poGraphSchemaistnieje constraint unikalności naHospital.id.
- Wymaga Dockera. Bez Dockera →
[~]+ notatka. - DoD:
./mvnw -q test -Dtest=GraphLoaderITzielony (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 iCZLONEK_ORGANU(szpital) iPELNI_MANDAT(GovBody); asercja: raport nakładek zawiera tę osobę; plikoverlap_report.csvpowstaje i ma nagłówek z sekcji 5.6. - DoD:
./mvnw -q test -Dtest=GraphValidatorITzielony (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 verifyprzechodzi (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:
- Test
PersonTest.person_withTitleAndPwzNumber_fieldsAccessiblewsrc/test/java/com/developx/szpitale/model/PersonTest.java: zbudujPersonz niepustymtitleipwzNumber, asercja żeperson.title()iperson.pwzNumber()zwracają wpisane wartości. RED (kompilacja nie przejdzie, bo konstruktor ma 5 argumentów) → dodaj pola → GREEN. - Znajdź wszystkie miejsca konstruujące
new Person(...)(grep -rn "new Person(" src/) i dopisznull, null(lub realną wartość jeśli już znana) na końcu wywołania — inaczej build nie skompiluje się.
- Test
- DoD:
./mvnw -q compileprzechodzi;./mvnw -q test -Dtest=PersonTestzielony.
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 dorejestr.nil.org.pljako wstrzykniętą zależność (np. interfejsPwzLookupClientz metodąList<String> query(String fullName)), żeby test nie robił żywego HTTP. - Fixture:
src/test/resources/fixtures/nil-single-match.jsonz jednym wynikiem (["1234567"]) inil-multiple-matches.jsonz dwoma. - TDD:
- Test
PwzRegistrySourceTest.lookupPwz_singleMatch_returnsPwzNumber→ mockPwzLookupClientzwraca jeden wynik →lookupPwzzwracaOptional.of("1234567"). - Test
PwzRegistrySourceTest.lookupPwz_multipleMatches_returnsEmpty→ mock zwraca 2 wyniki →lookupPwzzwracaOptional.empty()(wieloznaczność, patrz 4.7 pkt 2 — nie zgadywać, który wynik jest właściwy). - Test
PwzRegistrySourceTest.lookupPwz_noMatch_returnsEmpty→ mock zwraca pustą listę →Optional.empty().
- Test
- DoD:
./mvnw -q test -Dtest=PwzRegistrySourceTestzielony. Nie implementuj żywego klienta HTTP w tym zadaniu — tylko interfejsPwzLookupClient+ logikaPwzRegistrySourcena 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ę naHospital.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, nienull). - TDD:
- Test
OrganizationCompanyRoundTripTest.write_thenRead_preservesOrganizationsAndCompanieswsrc/test/java/com/developx/szpitale/ingest/(mirrorCanonicalRoundTripTestz T5): zbudujCanonicalDatasetz 1Organization, 1Company, po 1 linku każdego typu; zapiszCanonicalWriter, odczytajCanonicalReader, porównaj pola. RED najpierw. - Minimalny kod: dodaj rekordy + pola w
CanonicalDataset, sprawdź że Jackson serializuje/deserializuje bez adnotacji dodatkowych (jak reszta modelu).
- Test
- DoD:
./mvnw -q test -Dtest=OrganizationCompanyRoundTripTestzielony.
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 3MERGErelacji:CZLONEK_ORGANIZACJI,POWIAZANA_Z,POWIAZANY_Z_FIRMA(dokładne zapytania Cypher w sekcji 5.4 PLAN.md). - Test integracyjny:
GraphLoaderOrganizationITz@Testcontainers(wzoruj się na istniejącym wzorcu z T6, jeśliGraphLoaderITjuż istnieje — skopiuj setup kontenera).load_organizationAndCompany_nodesCreatedWithLinks→ załaduj 1 osobę + 1 organizację + 1 firmę + odpowiednie linki, sprawdź że istnieją węzły:Organization,:Companyi relacje do:Person.
- Wymaga Dockera. Bez Dockera →
[~]+ notatka (jak T6/T7). - DoD:
./mvnw -q test -Dtest=GraphLoaderOrganizationITzielony (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) iPOWIAZANY_Z_FIRMA(firma); asercja: wynik zawiera tę osobę z nazwą firmy. - Wymaga Dockera. Bez Dockera →
[~]+ notatka. - DoD:
./mvnw -q test -Dtest=GraphValidatorOrganizationITzielony (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]
- 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.
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 kataloguszpitale-graph/) — maszynowy stan zadań, czytany na starcie.
13.1. Procedura STARTU (zawsze na początku uruchomienia)
cd szpitale-graph- Przeczytaj
local-agent-progress.json. 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 natodow JSON, i zacznij je od zera. Połowiczny kod jest zawsze do wyrzucenia, bo checkpoint zapisujemy tylko przy zielonych testach.
- Czyste → wznów od pierwszego zadania o statusie
- Zweryfikuj punkt startowy:
./mvnw -q testpowinno odzwierciedlać ostatni checkpoint (zielone dla zrobionych zadań; czerwone tylko dla T1 na samym starcie projektu).
13.2. Procedura na WEJŚCIU w zadanie
- Ustaw status zadania w JSON na
in_progress, wpiszstartedAt(data z systemu). - 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:
- Zaktualizuj
local-agent-progress.json: statusdone,finishedAt, krótkanote. - Zaktualizuj checklistę w §12 (
[ ]→[x]/[~]) i dopisz linię do dziennika §12. - Jeden atomowy commit obejmujący kod + testy + JSON + PLAN:
git add -A && git commit -m "T<n>: <krótki opis> [checkpoint]" - 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
doneagit lognie 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 lubnull),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