Jak nauczyłem program klikać w aplikacji stworzonej w Delphi, której siatki nie da się odczytać prosto dostępnymi bibliotekami do automatyzacji w pythonie.
Testowe wdrożenie produkcyjne: 14 października 2026 – właśnie minęła pierwsza pełna doba działania.
W biurze poczta dotycząca wspólnot spływa na dwie wspólne skrzynki. Obie są podpięte do modułu Obieg Dokumentów w systemie DOM5. Każdą wiadomość ktoś musi przyjąć (zaakceptować jako dokument obiegu), wskazać nadawcę, wspólnotę i zasób, a potem przekazać osobie, która tę wspólnotę obsługuje. To praca żmudna i powtarzalna. Na tylko jednej ze skrzynek od grudnia 2022 roku uzbierało się ponad 40 tysięcy takich przekazań do ponad 40 różnych osób.
Wcześniej, od około dwóch lat robiło to klasyczne RPA – Power Automate desktop + LLM.
Postanowiłem zrobić eksperyment. Ponieważ całkiem sporo wiadomości system nie był w stanie „obrobić” i trafiały do przejrzenia przez człowieka, nawet 30 dziennie i czasem więcej (oczywiście, czasem też mniej), chciałem sprawdzić, czy mogę to zrobić tak, żeby jeszcze część z nich obsłużyć z automatu.
Okazało się, że da się, miesiąc pracy, testów, wraz z Claude Code analizy (sam wcześniej z RPA nic nie robiłem) i udało się.
Od 14 października robi to mój robot, napisany w Pythonie, uruchomiony na serwerze. Pierwszego dnia przekazał księgowym 95 dokumentów. Poniżej opisuję, jak działa, z czego jest zbudowany i na czym się po drodze wywracał.
95
dokumentów przekazanych pierwszego dnia produkcji
96,3%
trafność
~12 tys.
linii Pythona, bez frameworka RPA
DOM5 od środka
Aplikacja kliencka DOM5 napisana jest w Delphi (VCL). Pierwsze dni poszły na rekonesans, bo warstwy interfejsu są mieszane i to rozróżnienie okazało się kluczowe:
- Ekran główny i menu modułów to dokumenty HTML w osadzonym MSHTML, adresowane pseudo-URL-ami w rodzaju
http:\POMClient\ObiegDokMod. Da się je wyciągnąć przez COM jako czysty HTML. - Widoki modułów to natywne formularze Delphi:
TPanel,TPOMButton,TPOMEdit,TPOMMemo,TPOMComboBox. Tu HTML-a już nie ma.
Widok „Wiadomości poczty elektronicznej” ma listę wiadomości w kontrolce TPOMListBrowser2. Ta siatka jest całkowicie nieprzezroczysta: zero okien potomnych Win32, zero elementów UI Automation, zero dzieci MSAA. Jest w całości rysowana własnym kodem. Nie da się z niej odczytać ani tytułu, ani nadawcy, ani tego, który wiersz jest zaznaczony.
Ratunkiem jest panel podglądu. TPOMMemo pod siatką pokazuje treść zaznaczonej wiadomości i oddaje ją zwykłym komunikatem WM_GETTEXT, z polskimi znakami. Do odczytu używam SendMessageTimeoutW, a nie SendMessage, bo DOM5 potrafi zawisnąć na oknie modalnym, a zwykłe wywołanie zawiesiłoby wtedy też robota:
def _zapytaj(hwnd, komunikat, wparam, lparam, limit_ms=LIMIT_MS) -> int:
wynik = ctypes.c_size_t(0)
ok = user32.SendMessageTimeoutW(hwnd, komunikat, wparam, lparam,
SMTO_ABORTIFHUNG, limit_ms,
ctypes.byref(wynik))
if not ok:
raise OknoNieOdpowiada(f"hwnd={hwnd} nie odpowiedział w {limit_ms} ms")
return int(wynik.value)
def get_text(hwnd, limit_ms=LIMIT_MS) -> str:
length = _zapytaj(hwnd, WM_GETTEXTLENGTH, 0, 0, limit_ms)
buf = ctypes.create_unicode_buffer(max(length + 1, 256))
_zapytaj(hwnd, WM_GETTEXT, len(buf), ctypes.addressof(buf), limit_ms)
return buf.valueBaza zamiast interfejsu
Skoro siatka nic nie mówi, metadanych trzeba szukać gdzie indziej. DOM5 trzyma wszystko w SQL Serverze, więc podstawowa droga odczytu to baza, a interfejs służy tylko do zapisu. Warstwa bazodanowa robota jest wyłącznie do odczytu: łączy się przez pyodbc i ODBC Driver 17, bez żadnego INSERT ani UPDATE.
| Tabela | Co w niej jest |
|---|---|
OBD_WiadomoscPocztyEl | wiadomości: tytuł, nadawca, data, folder, skompresowany MIME, a po akceptacji odnośnik do dokumentu |
OBD_FolderPocztyEl | foldery skrzynek („Skrzynka odbiorcza”, „Do przejrzenia”…), numerowane osobno dla każdego konta |
OBD_DokObDok | dokument obiegu, który powstaje dopiero przy akceptacji |
OBD_PrzeslanieDok | przekazania dokumentu; akcja „Przekazanie do księgowości” to etykieta, którą robot ma przewidzieć |
Zas_Nieruchomosc, Zas_PracownikNieruchomLacz | nieruchomości i przypisane do nich osoby według ról (rola 2 to księgowy) |
OBD_Zastepstwo | zastępstwa z zakresem dat |
Kolumna Tresc kryje surową wiadomość: MIME zapisany w UTF-16LE i skompresowany zlibem. Po rozpakowaniu to zwykły RFC 822 z nagłówkami, treścią i wszystkimi załącznikami. Moduł email z biblioteki standardowej robi resztę.
Prawie. Dopiero na produkcji wyszło, że DOM5 zapisuje bajty wiadomości odczytane jako cp1250. Mail wysłany jako 8-bitowe UTF-8 wychodził przez to jako „WspĂłlnota”. Pomogło zakodować tekst z powrotem w cp1250, żeby odzyskać oryginalne bajty, i dopiero je parsować:
def rozpakuj(surowe: bytes, *, skompresowana=True) -> bytes:
return zlib.decompress(surowe) if skompresowana else surowe
def _wiadomosc_mime(surowe_mime: bytes) -> EmailMessage:
tekst = _na_tekst(surowe_mime) <em># UTF-16LE z BOM albo bez</em>
try:
<em># DOM5 zapisał bajty odczytane jako cp1250 — odwracamy to</em>
return email.message_from_bytes(tekst.encode("cp1250"), policy=policy.default)
except UnicodeEncodeError:
return email.message_from_string(tekst, policy=policy.default)Model wskazuje adres, kod wskazuje osobę
Najważniejsza decyzja architektoniczna zapadła po pomiarach na historii. Brzmi tak:
Model językowy ustala, o którą nieruchomość chodzi. Kod wylicza, kto ją obsługuje.
Model nigdy nie wskazuje księgowej. Wskazuje adres, NIP albo numer rachunku, a te albo istnieją w rejestrze DOM5, albo nie, więc odpowiedź da się sprawdzić. Gdy zmienia się obsada wspólnoty, zmienia się baza, a nie prompt. Rejestr nieruchomości buduję z tych samych tabel, w których zapisuje się dekretacja. Liczy też zastępstwa na konkretny dzień, bo bywają jednodniowe.
Dopasowanie bez modelu
Zanim dołożyłem LLM, zmierzyłem, ile da samo dopasowanie tytułu wiadomości do rejestru. Porównywanie podciągów zawodziło na skrótach („Aleja” zamiast „Al.”), kodach zarządcy z cyfrą w nazwie i skróconych nazwach ulic. Dopasowanie po tokenach rozbija nazwę na wyrazy rozróżniające, a numer budynku i miasto rozstrzygają wieloznaczności. Na 3000 historycznych dekretacjach samo dopasowanie rozstrzygało połowę wiadomości z trafnością 93,7%. Resztę dostaje model.
Warstwa LLM
Robot wysyła do modelu nagłówki, treść i załączniki. PDF-y idą jako pliki, nie jako tekst: skany nie mają warstwy tekstowej (pypdf zwracał zero znaków na 27 stronach).
parametry = {
"model": self.model,
"instructions": SYSTEM,
"input": [{"role": "user", "content": [
*[{"type": "input_file", "filename": d.nazwa, "file_data": d.data_url()}
for d in wejscie.dokumenty],
{"type": "input_text", "text": wejscie.tekst},
]}],
"text": {"format": {"type": "json_schema", "name": NAZWA_WYNIKU,
"schema": SCHEMAT, "strict": True}},
"reasoning": {"effort": self.effort},
}
odp = self.client.responses.create(**parametry)Schemat wymaga, żeby każdy adres miał źródło (temat, treść, stopka, załącznik albo cytowany wątek), poziom pewności i dosłowny cytat. Bez cytatu nie da się sprawdzić, czy model adresu nie zmyślił. Przykładowa odpowiedź dla wiadomości z pismem w załączniku:
AdresZWiadomosci(
ulica='Racławówka', nr_budynku='174', miasto='Rzeszów',
nip='813…', zrodlo='zalacznik', pewnosc='wysoka',
cytat='WSPÓLNOTA MIESZKANIOWA NIERUCHOMOŚCI RACŁAWÓWKA 174')Kiedy klasyczne dopasowanie zawodzi, kod przechodzi kolejne sita: model wybiera spośród kandydatów z rejestru, potem liczy się historia adresu nadawcy, a na końcu księgowa z wcześniejszych wiadomości tego samego wątku. Gdy żadne nie daje jednej odpowiedzi, wiadomość trafia do człowieka.
Ile to trafia
Benchmark: 100 ostatnich wiadomości, które człowiek przekazał do księgowości. Pytanie brzmi, czy robot wysłałby każdą tam, gdzie faktycznie trafiła.
| Wariant | Rozstrzygnięte | Trafność | Tokeny wejścia |
|---|---|---|---|
| jedno wywołanie modelu | 81 / 100 | 96,3% | 732 tys. |
| agent z 5 narzędziami | 83 / 100 | 96,4% | 2 174 tys. |
Tryb agentowy nie zarabia na siebie: dwa punkty pokrycia więcej za trzykrotny koszt. Domyślne jest więc jedno wywołanie. Koszt rośnie z załącznikami, nie z liczbą wiadomości: pięć największych wiadomości odpowiadało za 64% zużytych tokenów, dlatego załączniki mają limit wielkości, a model dostaje spis tego, czego nie zobaczył.
Klikanie bez myszy
Zapis musi się odbyć przez interfejs DOM5, bo tak powstaje dokument obiegu z pełną historią. Robot nie używa myszy ani klawiatury. Wysyła komunikaty Windows bezpośrednio do kontrolek, dzięki czemu działa przy zablokowanym ekranie i rozłączonym pulpicie zdalnym.
Dwie reguły wynikły z pomiarów, nie z teorii, i są wymuszone w kodzie:
- Akcje tylko przez
PostMessage, nigdy przezSendMessage.SendMessageczeka na koniec obsługi zdarzenia. Gdy DOM5 pokaże w tej obsłudze okno modalne, czeka się bez końca. Kliknięcie „Szukaj” przezSendMessagezawiesiło skrypt na stałe, bo DOM5 wyświetlił „Nie znaleziono obiektu”. - Okno rozpoznaje się po procesie, nie po klasie.
#32770to klasa wszystkich okien dialogowych Windows. Filtrując tylko po niej, raz trafiłem w dialog innej aplikacji i kliknąłem w nim OK.
Siatka, której nie widać
Skoro siatki nie da się odczytać, robot chodzi po niej na ślepo. Bierze z bazy listę wiadomości w folderze w tej samej kolejności co DOM5, naciska Home i strzałkę w dół tyle razy, ile trzeba. Potem sprawdza, gdzie stoi. Czyta podgląd i porównuje go z treściami wszystkich wiadomości w folderze.
Porównanie musi znieść różnice: podgląd inaczej łamie wiersze, gubi znaczniki cytowania i bywa ucięty. Obie strony sprowadzam do samych liter i cyfr, a z podglądu wycinam kilka fragmentów z różnych miejsc i z końca. Jeden fragment może trafić w stopkę wspólną dla wszystkich maili, kilka już nie:
def uprosc(tekst: str) -> str:
return re.sub(r"[^0-9a-ząćęłńóśźż ]+", " ",
html.unescape(tekst).lower().replace("\xa0", " ")).strip()
def _wycinki(p: str) -> list:
wycinki = [p[o:o + 60] for o in (0, 60, 150, 300, 600) if len(p) >= o + 60]
if len(p) >= 120 and p[-60:] not in wycinki:
wycinki.append(p[-60:]) <em># koniec rozróżnia pisma o tym samym nagłówku</em>
return wycinkiOdpowiedź cytuje oryginał, więc oba maile pasują do podglądu oryginału równie dobrze. Wtedy wygrywa ten, w którym tekst podglądu stoi najwcześniej, bo oryginał od niego zaczyna, a odpowiedź ma go dopiero w cytacie. Gdy podgląd nie pozwala ustalić pozycji, robot robi zrzut okna (PrintWindow) i pyta model, który wiersz jest zaznaczony. Ta odpowiedź służy tylko za wskazówkę, w którą stronę się przesunąć. O tożsamości wiadomości zawsze rozstrzyga podgląd.
Zasada nadrzędna: gdy robot nie jest pewien, którą wiadomość ma zaznaczoną, nie zapisuje nic. Przerywa akceptację i odkłada wiadomość do folderu „Do przejrzenia” dla człowieka.
Bezpieczniki
- Weryfikacja w bazie po każdym zapisie. Po akceptacji robot czyta z bazy, jaki dokument powstał, z jaką procedurą i na kogo. Zły zasób albo rachunek blokuje przekazanie.
- Nigdy „Elementy odrzucone”. Robot niczego nie usuwa i nie odrzuca. Niepewne wiadomości przenosi z odbiorczej do „Do przejrzenia”, a tam je zostawia.
- Wiadomości o tym samym tytule. Wyciągi bankowe przychodzą seriami z identycznym tytułem i treścią, a różnią się tylko załącznikiem. Okno akceptacji ich nie rozróżni. Robot przyjmuje taką serię tylko wtedy, gdy każda wiadomość z niej idzie do tej samej osoby.
- Duplikaty. Jeden mail wysłany na kilka skrzynek DOM5 zakłada osobny rekord na każde konto. Kluczem jest
Message-IDz nagłówków, trzymany w osobnej bazie SQLite obok projektu. Trafienie nie oznacza „pomiń”, tylko „pokaż człowiekowi historię poprzednich egzemplarzy”. - Archiwalne nieruchomości. Wspólnoty, które już nie są obsługiwane odpadają z kandydatów. Gdy zostają same, wiadomość idzie do człowieka.
Serwer: czuwanie, cykl, blokada
Na serwerze działa jeden długo żyjący proces, czuwanie.py. Co 30 minut uruchamia cykl jako osobny podproces. Dzięki temu wgranie poprawionego pliku działa od następnego cyklu, bez restartu robota.
- Blokada Plik
cykl.lockz nazwą komputera, PID i pulsem odświeżanym co minutę. Bez pulsu przez 10 minut blokada jest uznana za porzuconą. - DOM5 Podłączenie do działającego klienta albo jego uruchomienie i logowanie kontem Windows.
- Zaległości Dokumenty zaakceptowane, a nieprzekazane; robot próbuje je dokończyć.
- Zadania Skrzynki odbiorcze obu kont, po kolei, najwyżej 40 wiadomości na zadanie.
- Dziennik Wiersz JSON na cykl i pełny log każdego zadania w
audit\.
Robotem steruje się przez wspólny folder. Plik JSON w zlecenia\ robot wykona między cyklami, pod tą samą blokadą. Zlecenie może kazać przejść folder „Do przejrzenia”, zrobić cykl od razu, wstrzymać pracę do podanej godziny albo zakończyć czuwanie. Nie poszerza przy tym uprawnień: dozwolone są tylko dwie skrzynki i dwa foldery. Pusty plik stop kończy czuwanie po bieżącym cyklu.
Rzeczy, których nie przewidziałem
- Python 3.10 na serwerze. Lokalnie pracowałem na 3.12. Moduł
tomllibpojawił się w 3.11, więc na serwerze ten sam parser wchodzi jako pakiettomli. - Konsola serwera wywracała Pythona na polskich znakach, także po
chcp 65001. Pliki startowe piszą więc wszystko do logów UTF-8 zamiast na ekran. - Okno „Uwaga” o nieobsługiwanej wersji Windows przy każdym starcie DOM5 na serwerze. Robot zamyka je przyciskiem OK, ale tylko to jedno. Każdy inny komunikat przy starcie przerywa pracę z jego treścią w logu.
- Sesja wygasła po nocy. Pierwszego ranka DOM5 stał na oknie wygasłej sesji, a każda akceptacja kończyła się błędem nawigacji, bez zapisu. Teraz robot rozpoznaje takie okno po treści, zamyka klienta i uruchamia go od nowa:
def okno_martwej_sesji(tytul: str, napisy: list[str]) -> bool:
napisy = [W.strip_accelerator(n or "") for n in napisy]
if tytul == "Informacja" and "Zaloguj ponownie" in napisy:
return True
tekst = " ".join([tytul, *napisy]).lower()
return "sesj" in tekst and any(s in tekst for s in ("wygas", "utracon", "zakończon"))Stos technologiczny
| Element | Do czego |
|---|---|
| Python 3.12 / 3.10 | całość; lokalnie 3.12, na serwerze 3.10 |
ctypes + Win32 API | SendMessageTimeoutW, PostMessageW, EnumWindows, PrintWindow – odczyt kontrolek i akcje |
comtypes | dostęp do HTML w osadzonym MSHTML (ekran główny, menu modułów) |
uiautomation | rekonesans: sprawdzenie, czego UI Automation nie widzi |
pyodbc + ODBC Driver 17 | odczyt z bazy DOM5 kontem domenowym |
email, zlib (stdlib) | rozpakowanie i parsowanie MIME z bazy |
pypdf | wycinanie stron i ocena, czy PDF ma warstwę tekstową |
openpyxl | zestawienia do weryfikacji przez zespół |
pillow, mss | zrzuty ekranu w diagnostyce i do „wzroku” przy siatce |
python-dotenv, tomli | klucze z .env, konfiguracja z TOML |
| SQLite | rejestr duplikatów po Message-ID |
Nie ma tu frameworka RPA ani nagrywanych makr. Nawigacja to zwykły, wersjonowany kod w gicie, a każda decyzja modelu ląduje w dzienniku. Dzięki temu zmianę reguły albo modelu sprawdzam najpierw na historii, bez dotykania produkcji.
Czego się nauczyłem
- Interfejs służy do zapisu, a baza do odczytu. Gdy aplikacja ma bazę, której da się zaufać, nie warto wyciągać danych z ekranu.
- Model ma zwracać coś, co da się sprawdzić. Adres z cytatem da się porównać z rejestrem. Imienia księgowej sprawdzić się nie da.
- Mierz na historii, zanim cokolwiek kliknie. Dziesiątki tysięcy dawnych dekretacji to gotowy zbiór testowy. Każdy pomysł, od tokenów ulic po tryb agentowy, przeszedł przez pomiar, zanim trafił na produkcję. Część odpadła właśnie po pomiarze.
- Niepewność to też wynik. Folder „Do przejrzenia” to nie porażka robota, tylko jego najważniejszy bezpiecznik.
Projekt powstawał w ścisłej współpracy z Claude Code, od rekonesansu DOM5 po poprawki na produkcji. Nazwy osób, wspólnot i serwerów zostały pominięte.
