Files

811 lines
58 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (→ T9T13). Plik `PLAN2.md` usunięty po scaleniu.
- Kolejka §11 przepisana pod konkretny model wykonawczy: **Qwen-3.6-35B-A3B** (MoE, ~3B aktywnych parametrów na token, mały kontekst efektywny). Reguły atomowości zadań i zakaz planowania (już obecne w poprzedniej wersji dla „małego lokalnego modelu") pozostają — dopisano jawne odniesienie do tego modelu, by przyszłe zadania kalibrować pod jego ograniczenia kontekstu i skłonność do zmyślania API.
---
## Proces pracy: TDD (obowiązkowy)
Cały kod tego planu powstaje test-first. Agent **nie pisze kodu produkcyjnego zanim nie istnieje test, który go wymaga**. Pełne reguły w [CLAUDE.md](CLAUDE.md); tu skrót obowiązujący dla każdego zadania (§11) i każdej klasy z sekcji 3.
**Cykl red → green → refactor (małe kroki, jeden zachowaniowy przyrost = jeden obieg):**
1. **RED** — napisz test opisujący pożądane zachowanie w `src/test/java/...` (mirror pakietu z `src/main/java`). Uruchom `./mvnw -q test -Dtest=NazwaTestu` i zobacz, że **failuje** (kompiluje się, asercja nie przechodzi). Test bez uprzedniej porażki nic nie dowodzi.
2. **GREEN** — napisz **minimalny** kod produkcyjny zazieleniający test. Bez funkcji, których żaden test nie wymaga.
3. **REFACTOR** — posprzątaj kod i test przy zielonym pasku; testy nadal zielone.
**Zasady wiążące:**
- **Test najpierw.** Bug fix zaczyna się od testu reprodukującego buga (RED), potem poprawka (np. T1 `Deduplicator`).
- **Dług techniczny:** istniejący kod produkcyjny powstał bez testów. Dotykając klasy bez pokrycia — najpierw dopisz **charakteryzujący** test aktualnego zachowania (green), potem prowadź zmianę cyklem TDD (patrz T2T3).
- **Testy jednostkowe** (bez Neo4j, szybkie, deterministyczne): normalizacja (`NameNormalizer`, `PartyNormalizer`), deduplikacja (`Deduplicator`), parsowanie źródeł (`MarkdownRegistrySource`), serializacja canonical (`CanonicalWriter`/`CanonicalReader`).
- **Testy integracyjne** dla warstwy `load`: Neo4j przez **Testcontainers** (`org.testcontainers:neo4j`) — idempotencja `MERGE`, constrainty schematu, raporty walidatora (tag **[NEO4J]**, wymaga Dockera).
- **Nazwy testów** opisują zachowanie: `metoda_warunek_oczekiwanyWynik` (np. `normalize_polishDiacritics_strippedToAsciiSlug`).
- **Definicja ukończenia:** zielony `./mvnw -q test` jest warunkiem uznania zadania za zrobione. Jeśli testy nie mogą się uruchomić (brak `mvn`/Dockera) — zgłoś jawnie, nie deklaruj sukcesu (tag `[~]` + notatka).
---
## 1. Architektura warstwowa
Aplikacja to jeden artefakt Spring Boot / Spring Shell z dwiema wyraźnie rozdzielonymi warstwami. Warstwy komunikują się przez **plik(i) w uwspólnionym formacie kanonicznym** (JSON), dzięki czemu można je uruchamiać niezależnie, wznawiać i testować.
```
┌─────────────────────────────────────────────────────────────┐
│ WARSTWA 1: Akwizycja + normalizacja │
│ komenda: ingest │
│ wejście: źródła per-województwo (pliki .md/.csv, konektory) │
│ wyjście: canonical/*.json (Canonical Model, jednolity) │
└─────────────────────────────────────────────────────────────┘
│ canonical JSON
┌─────────────────────────────────────────────────────────────┐
│ WARSTWA 2: Ładowanie do Neo4j + tworzenie powiązań │
│ komenda: load │
│ wejście: canonical/*.json │
│ wyjście: graf w Neo4j (węzły + relacje + provenance) │
└─────────────────────────────────────────────────────────────┘
```
Rozdział warstw plikiem kanonicznym daje:
- **niezależność** — warstwę 1 można uruchomić bez działającego Neo4j; warstwę 2 bez dostępu do sieci;
- **audytowalność** — plik kanoniczny to wersjonowalny, przeglądalny snapshot danych (można trzymać w git);
- **idempotencję** — warstwa 2 opiera się na `MERGE` po kluczach naturalnych, więc wielokrotne ładowanie tego samego pliku nie tworzy duplikatów.
---
## 2. Model kanoniczny (kontrakt między warstwami)
Jeden schemat dla **wszystkich** województw. Plik `canonical/<voivodeship>.json` (np. `podlaskie.json`, `mazowieckie.json`) lub jeden zbiorczy `canonical/all.json`.
```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<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)
```java
public interface VoivodeshipSource {
String voivodeship(); // "podlaskie", "mazowieckie", ...
List<RawHospitalRecord> fetch(); // surowe rekordy przed normalizacją
}
```
Implementacje (wybierane po fladze/konfiguracji):
- **`MarkdownRegistrySource`** — parsuje istniejące materiały robocze w formacie tabel Markdown (jak `research-podlaskie.md`). To ścieżka startowa: podlaskie już mamy. Kolejne województwa dokładamy jako `research-<voiv>.md` (patrz sekcja 4.6 — generowane skillem deep-research).
- **`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-<voiv>.md` w **dokładnie tym samym formacie tabel** co `research-podlaskie.md`, który następnie wchłania `MarkdownRegistrySource` bez żadnych zmian w kodzie. To domyka pętlę: skill znajduje i weryfikuje źródła → zapisuje kanoniczny materiał roboczy → warstwa 1 go normalizuje → warstwa 2 ładuje do grafu.
**Co zlecić skillowi (per województwo), pytania badawcze:**
1. **Lista szpitali** danego województwa z podziałem na: wojewódzkie/uniwersyteckie/resortowe (organ tworzący: samorząd województwa / uczelnia medyczna / MSWiA / MON) oraz powiatowe (organ tworzący: powiat), z formą prawną (SPZOZ vs spółka prawa handlowego).
2. **Kadra kierownicza** każdego szpitala (dyrektor, zastępcy, gł. księgowy) — ze stron „Dyrekcja"/„Kierownictwo" i BIP szpitala.
3. **Skład organu nadzorczego** — rada społeczna (SPZOZ) lub rada nadzorcza (spółka) — z BIP szpitala / uchwał organu tworzącego.
4. **Afiliacje polityczne** członków organów — kontrola krzyżowa w PKW 2024 (`samorzad2024.pkw.gov.pl`), na portalsamorzadowy.pl, w BIP sejmiku/rad powiatów; jawne oznaczenie pewności.
5. **Mandaty samorządowe** (radny sejmiku/powiatu/gminy, wójt/burmistrz/starosta, poseł/senator) osób zasiadających w organach.
**Kontrakt wyjścia skilla** — aby był bezpośrednio parsowalny przez `MarkdownRegistrySource`, deep-research musi wyprodukować:
- nagłówek metodologiczny z datą dostępu i legendą oznaczeń pewności (jak w `research-podlaskie.md`);
- sekcje per szpital z tabelą `| Funkcja | Imię i nazwisko | Afiliacja partyjna | Źródło (URL) |`;
- **`sourceUrl` przy każdym wierszu** (twardy wymóg — brak URL = wiersz odrzucany/oznaczony `BRAK_DANYCH`);
- markery pewności zgodne z mapowaniem na enum `confidence`: `brak/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`):
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/<voiv>.json (lub all.json)
// 4. zwróć podsumowanie: #szpitali, #osób, #ról, #mandatów, #luk
}
}
```
Przykłady użycia:
```
ingest --voivodeship podlaskie --source markdown
ingest --voivodeship all --split true
ingest --voivodeship mazowieckie --source html --out-dir canonical
```
### 4.5. Produkt warstwy 1
- `canonical/<voiv>.json` (lub `canonical/all.json`) zgodny ze schematem z sekcji 2.
- `ingest-report.json` — statystyki i lista luk (szpitale bez składu rady, afiliacje `NIEZWERYFIKOWANA`) — odpowiednik „Głównych luk" z materiału roboczego.
---
## 5. WARSTWA 2 — Ładowanie do Neo4j i tworzenie powiązań (`load`)
### 5.1. Zadanie
Odczytać plik(i) kanoniczne i zmaterializować graf w Neo4j: węzły, relacje i provenance. **Żadnej zależności od sieci/scrapingu.** Idempotentne (`MERGE`).
### 5.2. Model grafu
**Węzły (labels):**
- `:Hospital {id, name, shortName, city, voivodeship, legalForm, foundingBody, supervisoryBodyType, nip, krs, website, sourceUrls}`
- `:Person {id, fullName, displayName, titles, title, pwzNumber, sourceUrls}`
- `:Party {name, type}` (partia/komitet)
- `:GovBody {name, type, voivodeship, term}` (sejmik, rada powiatu, urząd gminy — dla mandatów)
- `:Voivodeship {slug, name}` (grupowanie i zapytania regionalne)
- `:Organization {id, name, krs, nip, sourceUrls}` (fundacje, stowarzyszenia)
- `:Company {id, name, krs, nip, ceidgId, sourceUrls}` (spółki, działalności gospodarcze)
**Relacje:**
| Relacja | Od → Do | Właściwości |
|---|---|---|
| `[:DYREKTOR]` | Hospital → Person | `roleLabel, status, validFrom, validTo, note, sourceUrls` |
| `[:ZASTEPCA_DYREKTORA]` | Hospital → Person | j.w. |
| `[:CZLONEK_ORGANU]` | Hospital → Person | `organ (RADA_SPOLECZNA/RADA_NADZORCZA), roleLabel, status, sourceUrls` |
| `[:PRZEWODNICZY_ORGANOWI]` | Person → Hospital | `organ, sourceUrls` |
| `[:CZLONEK_PARTII]` | Person → Party | `confidence, note, sourceUrls` |
| `[:PELNI_MANDAT]` | Person → GovBody | `mandateType, term, sourceUrls` |
| `[:W_WOJEWODZTWIE]` | Hospital → Voivodeship | — |
| `[:CZLONEK_ORGANIZACJI]` | Person → Organization | `roleLabel, basis (KRS), sourceUrls` |
| `[:POWIAZANA_Z]` | Organization → Organization | `basis (KRS/NIP), note, sourceUrls` |
| `[:POWIAZANY_Z_FIRMA]` | Person → Company | `roleLabel, basis (KRS/CEIDG/NIP), sourceUrls` |
> Uwaga: zamiast osobnych typów relacji dla każdej funkcji można użyć jednej `[:PELNI_FUNKCJE {roleType, organ, ...}]`. Wybór: **typowane relacje** dla czytelności zapytań Cypher + `roleType` jako property dla filtrowania.
### 5.3. Schemat (constraints + indeksy)
```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 ~510 tys. wierszy w jednej transakcji (tu wolumen mały, ale trzymamy wzorzec).
> Jeśli nie chcemy zależności od APOC — w `GraphLoader` mapujemy `roleType` na stały typ relacji przez `switch` i wykonujemy osobny sparametryzowany `MERGE` per typ.
### 5.5. Komenda Spring Shell
```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.25.3).
4. **`overlap_report.csv`** — osoby łączące funkcje w organach szpitali z mandatami/partiami/firmami/organizacjami, z `sourceUrls` i `confidence`.
5. **`ingest-report.json`** — statystyki i rejestr luk/rozbieżności per województwo (w tym wieloznaczne trafienia PWZ).
6. **Testy** — JUnit + Testcontainers (Neo4j) dla warstwy ładowania; testy jednostkowe normalizacji/deduplikacji.
7. **Encje `Organization`/`Company`** w modelu kanonicznym i grafie, z powiązaniami do osób po KRS/CEIDG/NIP (sekcja 4.8, 5.2).
---
## 10. Checklista wykonawcza
- [ ] `pom.xml`: Java 21, Spring Boot 3.x, Spring Shell, Neo4j driver, jsoup, Jackson, commons-text/csv.
- [ ] Rekordy modelu kanonicznego + enumy (sekcja 2).
- [ ] Warstwa 1: `VoivodeshipSource` + `MarkdownRegistrySource` (start: parser `research-podlaskie.md`).
- [ ] Normalizacja: `NameNormalizer`, `PartyNormalizer`, `Deduplicator`.
- [ ] Komenda `ingest` + zapis `canonical/*.json` + `ingest-report.json`.
- [ ] Warstwa 2: `GraphSchema`, `GraphLoader` (UNWIND/MERGE), `GraphValidator`.
- [ ] Komenda `load` + `overlap_report.csv`.
- [ ] `docker-compose.yml` (Neo4j 5) + `application.yml` (Bolt, credentials).
- [ ] Testy: parsowanie MD, normalizacja nazwisk, ładowanie do Testcontainers-Neo4j, zapytania walidacyjne.
- [ ] Weryfikacja end-to-end na podlaskim, następnie dołożenie kolejnego województwa: `deep-research``research-<voiv>.md``ingest``load`.
- [ ] Dla każdego nowego województwa: uruchomić skill `deep-research` (sekcja 4.6), zweryfikować oznaczenia `NIEZWERYFIKOWANA` przed użyciem, zapisać `research-<voiv>.md`.
- [ ] `Person` rozszerzony o `title`/`pwzNumber` (sekcja 2, 4.7) + `PwzRegistrySource`.
- [ ] Nowe rekordy modelu: `Organization`, `Company`, `PersonOrganizationLink`, `OrganizationLink`, `PersonCompanyLink` (sekcja 2) + `KrsSource`/`CeidgSource` (sekcja 4.8).
- [ ] Warstwa 2: węzły `:Organization`/`:Company` + relacje `CZLONEK_ORGANIZACJI`/`POWIAZANA_Z`/`POWIAZANY_Z_FIRMA` (sekcja 5.2, 5.4) + constrainty (5.3).
---
## 11. Kolejka zadań dla lokalnego agenta (TDD, kroki atomowe)
Ta sekcja jest pisana pod lokalny model wykonawczy **Qwen-3.6-35B-A3B** (MoE, ~3B aktywnych parametrów na token, mały efektywny kontekst, skłonność do zmyślania nieistniejących API przy niedoprecyzowanym zadaniu). Każde zadanie jest samowystarczalne: jeden plik lub jedna metoda, dokładna ścieżka, dokładny test, jedna komenda weryfikacji, twarde kryterium ukończenia. Agent **nie planuje** — bierze pierwsze niezrobione zadanie i wykonuje je w całości. Zasada 7 (poniżej) jest przy tym modelu szczególnie krytyczna: A3B chętnie "dopisuje" wygodne metody, których nie ma w `src/main/java` — zawsze czytaj plik przed wywołaniem metody.
### Reguły pracy (przeczytaj przed każdym zadaniem)
1. **Bierz DOKŁADNIE jedno zadanie** z listy poniżej, od góry. Nie łącz zadań.
2. **Nie dotykaj plików spoza zadania.** Jeśli zadanie mówi „plik X", edytuj tylko X i jego test.
3. **Cykl TDD (obowiązkowy, patrz `CLAUDE.md`):**
- napisz test w `src/test/java/...` (mirror pakietu),
- uruchom `./mvnw -q test -Dtest=NazwaTestu` i zobacz **RED** (czerwony),
- napisz minimalny kod produkcyjny → **GREEN**,
- posprzątaj przy zielonym → **REFACTOR**.
4. **Komenda z katalogu `szpitale-graph/`.** Zawsze `./mvnw` (jest wrapper), nigdy `mvn`.
5. **Definicja ukończenia zadania:** `./mvnw -q test` przechodzi na zielono ORAZ nie ma nowych ostrzeżeń kompilatora dla dotkniętego pliku.
6. **Jeśli utkniesz > 2 próby** — zostaw plik bez zmian, dopisz jednolinijkową notatkę `// TODO(local-agent): <opis blokady>` i przejdź dalej NIE psując builda.
7. **Nie wymyślaj API.** Używaj tylko klas/metod, które już istnieją w `src/main/java` (sprawdź plik zanim wywołasz metodę).
8. Zadania z tagiem **[NEO4J]** wymagają Dockera (Testcontainers). Jeśli `docker` niedostępny — pomiń, zostaw zadanie odhaczone jako `[~]` z notatką „brak Dockera".
9. **Zapisz punkt kontrolny po KAŻDYM zadaniu** (patrz §13). Bez zapisanego checkpointu zadanie NIE jest ukończone, nawet jeśli testy są zielone.
### Legenda statusów: `[ ]` niezrobione · `[x]` zrobione · `[~]` pominięte (z notatką)
---
**T1 — Naprawić kompilację w `Deduplicator` [BLOKER, robić pierwsze]**
- Plik: `src/main/java/com/developx/szpitale/ingest/normalize/Deduplicator.java`, metoda `checkFuzzyDuplicates`.
- Objaw: `groups` jest typu `Map<String, List<Person>>`, a kod wkłada do niego `List<String>` (błąd kompilacji ~linia 75). Zmienna `personIds` jest nieużywana, a pętla `for (String existingId : groups.keySet())` iteruje po pustej mapie — nigdy nie porówna z już-widzianymi osobami.
- Zamiar metody: dla każdej osoby znaleźć **już przetworzone** osoby o podobnym nazwisku (Jaro-Winkler w przedziale `[SUSPECT_THRESHOLD, DUPLICATE_THRESHOLD)`) i wypisać ostrzeżenie o możliwych duplikatach. **Nie scalać** (patrz sekcja 4.3 pkt 4).
- TDD:
1. Test `DeduplicatorTest.checkFuzzyDuplicates_similarNames_reportedNotMerged`: podaj listę 2 osób o bardzo podobnych `fullName` (np. `Jan Kowalski` / `Jan Kowaski`) o różnych `id`; asercja: obie osoby nadal istnieją jako odrębne węzły (brak scalenia). RED najpierw.
2. Minimalny fix: zmień typ na `Map<String, List<String>>` (id → lista podobnych id) i porównuj bieżącą osobę z **wcześniej odwiedzonymi** (np. utrzymuj `List<Person> seen` i iteruj po niej), a nie po `groups.keySet()`. Popraw wypis, by używał `candidates`, nie `personIds`; usuń `personIds`.
- DoD: `./mvnw -q compile` przechodzi; `./mvnw -q test -Dtest=DeduplicatorTest` zielony.
**T2 — Testy `NameNormalizer` (charakteryzujące + slug)**
- Plik prod: `src/main/java/com/developx/szpitale/ingest/normalize/NameNormalizer.java` (istnieje — najpierw go przeczytaj, nazwy metod bierz z pliku).
- Test: `NameNormalizerTest` w `src/test/java/com/developx/szpitale/ingest/normalize/`.
- Przypadki (po jednym asercie na test):
- `normalize_polishDiacritics_strippedToAsciiSlug` → wejście `Łukasz Żółć` daje slug bez diakrytyków, lower-case, separator `-` (np. `lukasz-zolc`).
- `normalize_academicTitles_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<String> lookupPwz(String fullName)` przyjmuje wynik zapytania do `rejestr.nil.org.pl` jako **wstrzykniętą zależność** (np. interfejs `PwzLookupClient` z metodą `List<String> query(String fullName)`), żeby test nie robił żywego HTTP.
- Fixture: `src/test/resources/fixtures/nil-single-match.json` z jednym wynikiem (`["1234567"]`) i `nil-multiple-matches.json` z dwoma.
- TDD:
1. Test `PwzRegistrySourceTest.lookupPwz_singleMatch_returnsPwzNumber` → mock `PwzLookupClient` zwraca jeden wynik → `lookupPwz` zwraca `Optional.of("1234567")`.
2. Test `PwzRegistrySourceTest.lookupPwz_multipleMatches_returnsEmpty` → mock zwraca 2 wyniki → `lookupPwz` zwraca `Optional.empty()` (wieloznaczność, patrz 4.7 pkt 2 — **nie zgadywać**, który wynik jest właściwy).
3. Test `PwzRegistrySourceTest.lookupPwz_noMatch_returnsEmpty` → mock zwraca pustą listę → `Optional.empty()`.
- DoD: `./mvnw -q test -Dtest=PwzRegistrySourceTest` zielony. Nie implementuj żywego klienta HTTP w tym zadaniu — tylko interfejs `PwzLookupClient` + logika `PwzRegistrySource` na nim.
**T11 — Rekordy `Organization`/`Company` + linki (round-trip)**
- Nowe pliki: `src/main/java/com/developx/szpitale/model/Organization.java`, `Company.java`, `PersonOrganizationLink.java`, `OrganizationLink.java`, `PersonCompanyLink.java` — pola dokładnie jak w sekcji 2 PLAN.md (przykłady JSON), jako rekordy Java z `@JsonProperty` (wzoruj się na `Hospital.java`/`Person.java`).
- Zamiar: `CanonicalDataset` (`src/main/java/com/developx/szpitale/model/CanonicalDataset.java`) dostaje 5 nowych pól-list: `organizations, companies, personOrganizationLinks, organizationLinks, personCompanyLinks` (domyślnie puste listy, nie `null`).
- TDD:
1. Test `OrganizationCompanyRoundTripTest.write_thenRead_preservesOrganizationsAndCompanies` w `src/test/java/com/developx/szpitale/ingest/` (mirror `CanonicalRoundTripTest` z T5): zbuduj `CanonicalDataset` z 1 `Organization`, 1 `Company`, po 1 linku każdego typu; zapisz `CanonicalWriter`, odczytaj `CanonicalReader`, porównaj pola. RED najpierw.
2. Minimalny kod: dodaj rekordy + pola w `CanonicalDataset`, sprawdź że Jackson serializuje/deserializuje bez adnotacji dodatkowych (jak reszta modelu).
- DoD: `./mvnw -q test -Dtest=OrganizationCompanyRoundTripTest` zielony.
**T12 — [NEO4J] `GraphLoader` ładuje `:Organization`/`:Company`**
- Plik prod: `src/main/java/com/developx/szpitale/load/GraphLoader.java` (przeczytaj istniejącą metodę ładującą `Hospital`/`Person`, wzoruj się na jej strukturze).
- Zamiar (sekcja 5.4): dodać metodę analogiczną do istniejącego ładowania węzłów, wykonującą `UNWIND $organizations ... MERGE (:Organization {id})`, `UNWIND $companies ... MERGE (:Company {id})` oraz 3 `MERGE` relacji: `CZLONEK_ORGANIZACJI`, `POWIAZANA_Z`, `POWIAZANY_Z_FIRMA` (dokładne zapytania Cypher w sekcji 5.4 PLAN.md).
- Test integracyjny: `GraphLoaderOrganizationIT` z `@Testcontainers` (wzoruj się na istniejącym wzorcu z T6, jeśli `GraphLoaderIT` już istnieje — skopiuj setup kontenera).
- `load_organizationAndCompany_nodesCreatedWithLinks` → załaduj 1 osobę + 1 organizację + 1 firmę + odpowiednie linki, sprawdź że istnieją węzły `:Organization`, `:Company` i relacje do `:Person`.
- Wymaga Dockera. Bez Dockera → `[~]` + notatka (jak T6/T7).
- DoD: `./mvnw -q test -Dtest=GraphLoaderOrganizationIT` zielony (lub pominięte z notatką).
**T13 — [NEO4J] `GraphValidator` — zapytanie o konflikt interesów**
- Plik prod: `src/main/java/com/developx/szpitale/load/GraphValidator.java`.
- Zamiar (sekcja 5.6): dodać metodę zwracającą wynik zapytania „osoby w organie szpitala z powiązaniem biznesowym" (dokładny Cypher w sekcji 5.6 PLAN.md, blok „konflikt interesów").
- Test integracyjny `GraphValidatorOrganizationIT`: wgraj dataset z 1 osobą mającą `CZLONEK_ORGANU` (szpital) i `POWIAZANY_Z_FIRMA` (firma); asercja: wynik zawiera tę osobę z nazwą firmy.
- Wymaga Dockera. Bez Dockera → `[~]` + notatka.
- DoD: `./mvnw -q test -Dtest=GraphValidatorOrganizationIT` zielony (lub pominięte z notatką).
---
## 12. Sekcja postępu (aktualizuje lokalny agent)
Po ukończeniu zadania agent zmienia jego status w liście poniżej i dopisuje jedną linię do dziennika.
- [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-java 3.4.0 in testcontainers 1.20.x hardcodes API v1.32, Daemon requires v1.44+)
- [~] T7 GraphValidator + CSV [NEO4J] (skipped: same docker-api issue as 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] (skipped: Docker API version mismatch - testcontainers docker-java client v1.32, min 1.44)
- [~] T13 GraphValidator: konflikt interesow [NEO4J] (skipped: Docker API too old — same cause as T6)
**Dziennik (data — zadanie — wynik):**
- 2026-07-07 — T1 — Deduplicator compile fix + unit tests [checkpoint] (commit 38a93d0)
- 2026-07-07 — T2 — NameNormalizer testy + transliteratePolish, token-strip titles [checkpoint] (commit afe107c)
- 2026-07-07 — T3 — PartyNormalizer testy + KO alias [checkpoint] (commit bc4eaac)
- 2026-07-07 — T4 — MarkdownRegistrySource test + fix isPersonTable check [checkpoint] (commit 7f636a1)
- 2026-07-07 — T5 — CanonicalWriter/Reader round-trip test [checkpoint] (commit b072cb3)
- 2026-07-07 — T6/T8 — skip NEO4J tests (Docker API zbyt stary) + pełny build zielony [checkpoint] (commit 59d27b1)
- 2026-07-08 — PLAN.md scalony z PLAN2.md (Person.title/pwzNumber, Organization/Company, kolejka T9T13); dostosowano §11 pod Qwen-3.6-35B-A3B.
- 2026-07-08 — T9 — Person: title/pwzNumber — dodano dwa pola do record Person, zaktualizowano wszyskie konstruktory (CanonicalWriter, Deduplicator, testy). 7 testow zielonych.
- 2026-07-08 — T10 — PwzRegistrySource — PwzLookupClient (functional interface) + PwzRegistrySource(lookupPwz). 4 testy zielone.
- 2026-07-08 — T11 — Organization/Company entites + round-trip — Organization, Company, PersonOrganizationLink, OrganizationLink, PersonCompanyLink + LinkBasis enum. CanonicalDataset + CanonicalReader zaktualizowane.
- 2026-07-08 — T12 — GraphLoader Organization/Company loading (prod code committed), GraphLoaderOrganizationIT (skipped: Docker API too old, same as T6/T7)
---
## 13. Punkty kontrolne i wznawianie (odporność na śmierć agenta)
Cel: jeśli lokalny agent zostanie ubity w połowie (crash, wyczerpany kontekst, restart), **następne uruchomienie musi kontynuować od ostatniego bezpiecznego stanu**, bez powtarzania zrobionej pracy i bez zostawiania połowicznych zmian.
Mechanizm opiera się na dwóch trwałych źródłach prawdy:
- **git** — jeden commit = jeden ukończony punkt kontrolny (kod jest w historii, nie tylko w drzewie roboczym);
- **`local-agent-progress.json`** (w katalogu `szpitale-graph/`) — maszynowy stan zadań, czytany na starcie.
### 13.1. Procedura STARTU (zawsze na początku uruchomienia)
1. `cd szpitale-graph`
2. Przeczytaj `local-agent-progress.json`.
3. `git status --porcelain` — sprawdź drzewo robocze:
- **Czyste** → wznów od pierwszego zadania o statusie `todo`.
- **Brudne** (są niezacommitowane zmiany) → oznacza to przerwanie w połowie zadania `in_progress`. Wykonaj **odzysk**: `git checkout -- . && git clean -fd` (odrzuć połowiczną pracę), ustaw to zadanie z powrotem na `todo` w JSON, i zacznij je od zera. Połowiczny kod jest zawsze do wyrzucenia, bo checkpoint zapisujemy tylko przy zielonych testach.
4. Zweryfikuj punkt startowy: `./mvnw -q test` powinno odzwierciedlać ostatni checkpoint (zielone dla zrobionych zadań; czerwone tylko dla T1 na samym starcie projektu).
### 13.2. Procedura na WEJŚCIU w zadanie
1. Ustaw status zadania w JSON na `in_progress`, wpisz `startedAt` (data z systemu).
2. **Zacommituj samą tę zmianę stanu**: `git add local-agent-progress.json && git commit -m "T<n>: start"`. Dzięki temu drzewo jest czyste w trakcie pracy nad kodem, a przerwanie łatwo wykryć (jakiekolwiek brudne pliki = połowiczna praca do odrzucenia).
### 13.3. Procedura ZAPISU punktu kontrolnego (po zielonych testach)
Wykonuj dopiero gdy `./mvnw -q test` (lub `-Dtest=...` dla danego zadania) jest **zielone**:
1. Zaktualizuj `local-agent-progress.json`: status `done`, `finishedAt`, krótka `note`.
2. Zaktualizuj checklistę w §12 (`[ ]``[x]` / `[~]`) i dopisz linię do dziennika §12.
3. Jeden atomowy commit obejmujący kod + testy + JSON + PLAN:
```
git add -A && git commit -m "T<n>: <krótki opis> [checkpoint]"
```
4. Po commicie drzewo jest czyste → to jest bezpieczny punkt wznowienia.
### 13.4. Niezmienniki (muszą zachodzić między zadaniami)
- **Każdy commit buduje się** — nigdy nie commituj czerwonego builda (wyjątek: jedyny dozwolony czerwony stan to punkt WYJŚCIOWY projektu przed T1).
- **Jeden checkpoint = jedno zadanie** — nie łącz dwóch zadań w jeden commit.
- **Brudne drzewo == praca w toku do odrzucenia** — nigdy nie da się „częściowo ukończonego" zadania odzyskać; zawsze restart zadania od zera. To celowe uproszczenie: agent nie musi rozumować o połowicznym stanie.
- **JSON i git zgadzają się** — jeśli JSON mówi `done` a `git log` nie ma checkpointu tego zadania (lub odwrotnie), źródłem prawdy jest **git**: napraw JSON do stanu wynikającego z historii commitów, potem kontynuuj.
### 13.5. Format `local-agent-progress.json`
Pełny bieżący stan jest w pliku `szpitale-graph/local-agent-progress.json`. Schemat pola `tasks[]`:
- `id` (np. `"T1"`), `title`, `status` (`todo` | `in_progress` | `done` | `skipped`),
- `startedAt`, `finishedAt` (ISO-8601 lub `null`), `note` (string).
Wznawianie sprowadza się do: wczytaj JSON → znajdź pierwszy `todo`/`in_progress` → (jeśli `in_progress` + brudne drzewo) odzysk wg §13.1 → wykonuj wg §13.213.3.
---
**Koniec planu**