Chatboty w bankowości. Dlaczego w Commerzbank wybraliśmy Symphony
Chatboty mogą wspomagać pracę, dostarczać rozrywki, służyć informacją, a nawet zamawiać jedzenie czy też wysyłać SMS-y. Słowem: chatboty mogą zrobić wszystko, czego odbiorca końcowy zapragnie, a co zespół developerski będzie w stanie stworzyć. Aktualnie można spotkać kilka rodzajów chatbotów: od oferujących rozmowę w formie naturalnej pogawędki, całkiem sensownie udającej rozmowę z drugim człowiekiem, po takie bardziej „mrukliwe”, reagujące jedynie na wyspecyfikowane komendy i wykonujące ściśle określoną funkcjonalność.
Przykładem prostego bota może być ankieta/wywiad przeprowadzana/-y na stronach przychodni lekarskich. Taki automat dzięki zadaniu użytkownikowi serii pytań potrafi postawić diagnozę i zaproponować leczenie, oczywiście o ile udzielone odpowiedzi nie będą wskazywały na poważny stan wymagający konsultacji z lekarzem. Wspomniana aplikacja z pewnością ukrywa w tle zaawansowany system ekspertowy, a sam robot jest zgrabnym, zrozumiałym dla normalnego człowieka interfejsem. Zastosowanie botów jest o wiele szersze niż jedynie przeprowadzanie ankiet i podejmowanie decyzji.
Spis treści
Cena wymiany waluty
W bankach chatboty znalazły zastosowanie w komunikatorach, gdzie dostarczają narzędzi, które mogą wspierać pracę zespołów w wykonywaniu ich codziennych zadań. O wiele łatwiej napisać w okienku czatu komunikatora pytanie o listę dzisiejszych zadań, sprawdzenie zaplanowanych spotkań czy nawet przetłumaczyć podany tekst na inny język, niż wchodzić na odpowiednie strony i wyszukiwać ręcznie potrzebnych informacji. Boty często potrafią zapamiętać kontekst rozmowy i poruszać się w ramach niego, dzięki czemu dobrze nadają się do przeprowadzania różnego rodzaju ankiet lub obsługi pomocy kontekstowej.
Miejsce dla wirtualnych asystentów znalazło się również w naszym banku – przykładem może być News Bot, którego zadaniem jest dostarczanie wiadomości ze świata, agregując je z różnych źródeł. Użytkownik może sprecyzować swoje wyszukiwanie podając region, słowa kluczowe, które go interesują, wskazać zakres dat, z których chciałby zobaczyć wiadomości, czy nawet wybrać język wiadomości. To wszystko jest dostępne w standardowym, używanym przez niego na co dzień komunikatorze. Całość sprowadza się do wybrania odpowiedniego bota z listy użytkowników bota i rozpoczęcie zwykłego czatu. Ostatecznie użytkownik otrzymuje newsy wyświetlone w bardzo czytelny i przejrzysty sposób, mając jednocześnie możliwość poproszenia o zapisanie ich do wybranego formatu plików.
Innym przykładem bota jest taki, który można dołączyć do konwersacji między dwoma lub więcej osobami i jeśli użytkownik wyda odpowiednie polecenia, bot będzie tłumaczył wpisywane przez niego zdania na wybrany język. Takie rozwiązanie bardzo ułatwia współpracę w wielojęzycznych zespołach, w szczególności, kiedy jedna ze stron nie czuje się dość komfortowo, żeby samemu pisać w innym języku.
Bardzo ciekawym przykładem bota wykorzystywanego w bankowości inwestycyjnej jest rozwiązanie zaprezentowane podczas oficjalnej konferencji Symphony. Polega ono na stworzeniu takiego bota, który będzie wspierał handlowca bankowego obsługującego sprzedaż dowolnych aktywów różnym klientom korporacyjnym. Tacy handlowcy już teraz dostają pytania o cenę jakiejś wymiany dla zadanych kwot (rzędu milionów euro) na różnego rodzaju komunikatorach.
Jak to działa w tradycyjnym systemie: Klient reprezentujący instytucję, inny bank lub dużą korporację wysyła do handlowca prośbę o możliwość i wycenę wymiany. Przedstawiciel banku otwiera odpowiednie serwisy, w których najpierw sprawdza dane klienta i jego uprawnienia do wykonania takiej transakcji, a potem, jeśli taka transakcja jest możliwa do wykonania, pobiera z innego systemu koszt wymiany i ostatecznie negocjuje z klientem cenę. Jeśli dojdą do porozumienia, to transakcja jest zawierana. To wszystko jest jednak czasochłonne i bardzo angażuje handlowca, przez co może on wykonywać tylko jedną transakcję naraz.
Przedstawione przez Symphony rozwiązanie mocno optymalizuje ten proces przez użycie chatbota:
Pytanie przychodzące przez klienta na odpowiednim kanale komunikacji, do którego przypisany jest również nasz chatbot, jest analizowane w tle i odpowiednio klasyfikowane. O ile w tym miejscu nie następuje komunikacja samego chatbota z klientem (tym zajmuje się nadal handlowiec), to w tle chatbot uruchamia cały proces weryfikacji klienta i na osobnym kanale komunikuje się z handlowcem. Przesyła mu informacje o wynikach analiz i możliwościach handlowych klienta.
Dzięki odpowiednio skonfigurowanemu kontu użytkownika zna jego identyfikator w systemach bankowych i jest w stanie od razu sprawdzić historię i podstawowe parametry konieczne do potwierdzenia wymiany. Na życzenie handlowca może dokonać dodatkowych weryfikacji lub zmienić parametry w celu sprawdzenia innej oferty. Handlowiec w tym czasie może obsługiwać inną transakcję, dla której również na innym kanale chatbot dostarczy szczegółowych informacji. Przedstawiciel może zmodyfikować ofertę lub nie i zaprezentować ją klientowi. Jeśli klient zgodzi się, wpisując powiedzmy „biorę”, chatbot automatycznie sfinalizuje transakcję po przedstawionym kursie.
Takie użycie chatbotów znacznie zwiększa szybkość obsługiwania transakcji, a tym samym stwarza więcej możliwości na pozyskanie nowych klientów i wykonanie więcej operacji przynoszących zyski bankowi.
Wybór języka
Chatboty mogą być tworzone praktycznie dla każdej platformy komunikacyjnej wykorzystującej odpowiednie protokoły komunikacyjne (XMPP, WebSocket), mogą podłączyć się do wielu popularnych komunikatorów, takich jak Messenger, Slack, Smoch, a także mogą być wykorzystane do prostych czatów na stronach. W ramach bankowości inwestycyjnej skorzystaliśmy z używanego wewnętrznie komunikatora Symphony (wykorzystującego technologię IP Messagiging i WebRTC). Na potrzeby artykułu omówię, jak w prosty sposób stworzyć bota przy pomocy tego właśnie komunikatora.
Do stworzenia chatbota konieczne jest napisanie aplikacji uruchamianej w dowolnym miejscu, która będzie potrafiła zautentykować się w jednym z serwerów Symphony. Dostarczone od producenta API pozwala na wybór języka programowania wspieranego przez SDK spośród:
- Node.js,
- Java,
- .NET,
- Python.
W naszym przypadku skorzystamy z języka Java. Może to być zwykła javowa klasa main korzystająca z bibliotek Symphony (oferowanych w różnych SDK). W naszym przypadku do stworzenia aplikacji wykorzystamy framework Spring – SpringBoot, Spring Web, Spring Integration. Dodatkowe biblioteki pomocnicze zależą już od specyfiki czynności realizowanych przez naszego bota.
W pierwszej kolejności konieczne jest wygenerowanie nowego projektu, w którym będziemy tworzyć naszą aplikację. W IntelliJ wybieramy nowy projekt maven. Dla naszych potrzeb struktura wygenerowana automatycznie będzie wystarczająca. Do naszego projektu warto dodać zależności do spring boota:
spring-boot-starter
Oraz zależności do symphony:
symphony-api-client-java
symphony-client
W pierwszej kolejności nasza aplikacja musi wczytać klucze autentykacyjne dostarczone przez administratorów serwera symphony i zautentykować się w nim.
Najpierw konieczne jest wygenerowanie requestu autentykującego zawierającego informacje o użytkowniku zgodnym z użytkownikiem istniejącym na serwerze, expiration time – nie dłuższym niż 5 minut i podpisie z prywatnego klucza RSA odpowiadającego temu znajdującemu się na serwerze.
Przykładowa implementacja została zaprezentowana na stronie Symphony:
package com.symphony.util.jwt; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.bouncycastle.asn1.pkcs.RSAPrivateKey; import org.bouncycastle.crypto.params.RSAPrivateCrtKeyParameters; import org.bouncycastle.crypto.util.PrivateKeyInfoFactory; import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; import org.bouncycastle.util.io.pem.PemObject; import org.bouncycastle.util.io.pem.PemReader; import java.io.FileNotFoundException; import java.io.IOException; import java.io.StringReader; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.security.GeneralSecurityException; import java.security.Key; import java.security.KeyFactory; import java.security.PrivateKey; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Base64; import java.util.Date; import java.util.stream.Stream; /** * Class used to generate JWT tokens signed by a specified private RSA key. * Libraries needed as dependencies: * - BouncyCastle (org.bouncycastle.bcpkix-jdk15on) version 1.59. * - JJWT (io.jsonwebtoken.jjwt) version 0.9.1. */ public class JwtHelper { // PKCS#8 format private static final String PEM_PRIVATE_START = "-----BEGIN PRIVATE KEY-----"; private static final String PEM_PRIVATE_END = "-----END PRIVATE KEY-----"; // PKCS#1 format private static final String PEM_RSA_PRIVATE_START = "-----BEGIN RSA PRIVATE KEY-----"; private static final String PEM_RSA_PRIVATE_END = "-----END RSA PRIVATE KEY-----"; /** * Get file as string without spaces * @param filePath: filepath for the desired file. * @return */ public static String getFileAsString(String filePath) throws IOException { StringBuilder message = new StringBuilder(); String newline = System.getProperty("line.separator"); if (!Files.exists(Paths.get(filePath))) { throw new FileNotFoundException("File " + filePath + " was not found."); } try (Stream<String> stream = Files.lines(Paths.get(filePath))) { stream.forEach(line -> message .append(line) .append(newline)); // Remove last new line. message.deleteCharAt(message.length() -1); } catch (IOException e) { System.out.println(String.format("Could not load content from file: %s due to %s",filePath, e)); System.exit(1); } return message.toString(); } /** * Creates a JWT with the provided user name and expiration date, signed with the provided private key. * @param user the username to authenticate; will be verified by the pod * @param expiration of the authentication request in milliseconds; cannot be longer than the value defined on the pod * @param privateKey the private RSA key to be used to sign the authentication request; will be checked on the pod against * the public key stored for the user */ private static String createSignedJwt(String user, long expiration, Key privateKey) { return Jwts.builder() .setSubject(user) .setExpiration(new Date(System.currentTimeMillis() + expiration)) .signWith(SignatureAlgorithm.RS512, privateKey) .compact(); } /** * Create a RSA Private Key from a PEM String. It supports PKCS#1 and PKCS#8 string formats */ private static PrivateKey parseRSAPrivateKey(String privateKeyFilePath) throws GeneralSecurityException, IOException { String pemPrivateKey = getFileAsString(privateKeyFilePath); try { if (pemPrivateKey.contains(PEM_PRIVATE_START)) { // PKCS#8 format String privateKeyString = pemPrivateKey .replace(PEM_PRIVATE_START, "") .replace(PEM_PRIVATE_END, "") .replace("\n", "n") .replaceAll("\s", ""); byte[] keyBytes = Base64.getDecoder().decode(privateKeyString.getBytes(StandardCharsets.UTF_8)); PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); KeyFactory fact = KeyFactory.getInstance("RSA"); return fact.generatePrivate(keySpec); } else if (pemPrivateKey.contains(PEM_RSA_PRIVATE_START)) { // PKCS#1 format try (PemReader pemReader = new PemReader(new StringReader(pemPrivateKey))) { PemObject privateKeyObject = pemReader.readPemObject(); RSAPrivateKey rsa = RSAPrivateKey.getInstance(privateKeyObject.getContent()); RSAPrivateCrtKeyParameters privateKeyParameter = new RSAPrivateCrtKeyParameters( rsa.getModulus(), rsa.getPublicExponent(), rsa.getPrivateExponent(), rsa.getPrime1(), rsa.getPrime2(), rsa.getExponent1(), rsa.getExponent2(), rsa.getCoefficient() ); return new JcaPEMKeyConverter().getPrivateKey(PrivateKeyInfoFactory.createPrivateKeyInfo(privateKeyParameter)); } catch (IOException e) { throw new GeneralSecurityException("Invalid private key."); } } else { throw new GeneralSecurityException("Invalid private key."); } } catch (Exception e) { throw new GeneralSecurityException(e); } } public static String createJwt(String username, String privateKeyFilePath) throws IOException, GeneralSecurityException { final long expiration = 300000L; final PrivateKey privateKey = parseRSAPrivateKey(privateKeyFilePath); return createSignedJwt(username, expiration, privateKey); } public static void main(String[] args) throws IOException, GeneralSecurityException { final String username = System.getProperty("user"); final String privateKeyFile = System.getProperty("key"); final String jwt = createJwt(username, privateKeyFile); System.out.println(jwt); } }
Token otrzymany w wyniku wykonania powyższego kodu w postaci:
eyJhbGciOiJSUzUxMiJ9.eyJzdWIiOiJib3QudXNlcjEiLCJleHAiOjMwNDMwOTY0ODV9.X9vZReZigFtJ8NDsaJ9viUp2jtc-_ktVFLm17ubEzmSJbHXS_LNy5nL6E6R8GY71g8Vuonb8qSIwy8zoR_TcUvuPAQLxCAlvQn96jFnjg4aFO3kWkFMLFgwJWWR4hn2UocdTS_pu7ROafn6rjvLJdKGEWDOHKw6JX2_Qj3uzU3LeAFhUVU8Tmop3A2OTVUkPlWJwJimIas66kFgq61uGps8RT9YMs74bxGvOJvInidK2N_dqJMDPgb4ySOBHewlhe1ziUWM-21HDq1RvmadTWoPRKRXdt4oPRoxr4KRgmluaQpz8njL7Em9Sh1bCKJWuIjlXQPOcF3SFibbAcLwr40UnT2sM2LMJtkj0BHIU_5Ans0fN1x8hKtfWX_ArzLJTCBCCqswmq8Q3vxo0-SHe33Idy99TfkrY-C8G-fgPFvs9L7695MOcYAq8SpbZQlX-anpcqLQfsw6V-V0ZEAUeSHpnZrHvwmQjEmU9wXWzvAgCpF9kEt_I4Hpu8DTx2VzVj7CRU1Lu5NPHoESjI6VKJWcCH68TvkBB88jJqflXcQfbLUdK1sjDwDKl3BurmGBZSlD0ymuBXaQe4yol4zxXzSuWo6VCy5ykXee0mZm5t9-9wJujcjnGyKjNNSVLhajrmo6BRDN86I_xgV33SHgdrJKyQCO8LzUK4ArEMYlEY0I
Używamy do wysyłania requestu atentykującego do serwera symphony:
https://your-pod.symphony.com/login/pubkey/authenticate
Jak parametr (w formacie JSON) podając powyższy klucz:
{"token":"eyJhbGciOiJSUzUxMiJ9...7oqG1Kd28l1FpQ","name":"sessionToken"}
W następnej kolejności aplikacja musi wykonać request z otrzymanym sessionTokenem
do key managera:
https://your-pod.symphony.com/relay/pubkey/authenticate
Parametrem podobnie jak poprzednio jest JSON zawierający odpowiedni token:
{"token":"0100e4fe...REDACTED...f729d1866f","name":"keyManagerToken"}
Otrzymany w ten sposób token pozwala nam bez przeszkód komunikować się z serwerem komunikatora. Aby utworzyć instancję klienta dla naszego bota wykorzystujemy uzyskane powyżej informacje autentykacyjne i konfigurację dla naszego bota podając je w wywołaniu:
SymBotClient botClient = SymBotClient.initBot(config, botAuth);
Zasada działania bota polega na ciągłym nasłuchu utworzonego kanału między klientem i serwerem. W ten sposób bot może reagować na wszystko co zostanie do niego wysłane. W przypadku shymphony są one opisane odpowiednimi interfacami Listenera. Dwa podstawowe listenery które możemy zaimplementować to:
- ImListener – reaguje na wiadomości przychodzące do bota,
- RoomListener – reaguje na zdarzenia.
Dodajemy je do naszego clienta przez wywołanie metody addListeners:
botClient.getDatafeedEventsService().addListeners( new IMListenerImpl(botClient), new RoomListenerImpl(botClient) );
Mając tak zbudowanego clienta dla naszego bota możemy rozpocząć implementację wymaganych funkcjonalności.
Wiadomość przychodząca do serwera zawiera komplet informacji o użytkowniku, który ją wysłał, treść wysłanej wiadomości, a nawet załączniki w niej zawarte – wszystko to jest opakowane w obiekt InboundMessage
. Możemy do nich dostać się przez:
message.getUser().getDisplayName() message.getMessageText(); message.getAttachments();
Mając te informacje możemy w łatwy sposób obsłużyć request przychodzący z serwera. Najprostszym przykładem jest zbudowanie i odesłanie odpowiedzi opakowując ją w obiekt: OutboundMessage
:
String messageOut = String.format("Hello %s!", message.getUser().getDisplayName()); bot.getMessagesClient().sendMessage(streamId, new OutboundMessage(messageOut));
Architektura bota (CQRS)
Jeśli chcielibyśmy, żeby bot reagował na konkretne komendy w inny sposób, a jednocześnie potrafił obsługiwać skomplikowane składniowo polecenia, warto przyjrzeć się wzorcowi CQRS (Command Query Responsibility Segregation). Traktując w takim podejściu wysyłaną przez użytkownika wiadomość jako obiekt wejściowy, przetwarzamy go we wzorcu CQRS.
W pierwszej kolejności implementujemy klasę handler, która ma za zadanie sprawdzić, czy dana komenda jest przez nią obsługiwana, a dalej metodę handle, która dany obiekt przetworzy.
Załóżmy, że użytkownik chciałby zapytać o cenę akcji. W takim przypadku możemy spodziewać się komendy w stylu:
“Podaj cenę akcji IBM”
Gdzie:
- „podaj cenę” – jest nazwą czynności,
- „akcji” – jest specyfikacją produktu finansowego,
- „IBM” – jest identyfikatorem produktu finansowego.
Moglibyśmy tutaj dołożyć kolejne parametry specyfikujące, np. datę lub giełdę, której zapytanie dotyczy. Nasza implementacją Handlera (StocksPriceCommandHandler
) mogłaby wyglądać następująco:
public boolean isApplicable(InboundMessage command) { if(command.getMessageText().equalsIgnoreCase("podaj cenę") { return true; } return false; } public OutboundMessage handle(InboundMessage command) { }
Wykorzystanie NLP w botach
W chatbotach jedną z pożądanych funkcjonalności jest symulacja normalnej rozmowy jak ze zwykłym człowiekiem. I nawet jeśli jest to bot realizujący ściśle określone zadania biznesowe, to warto żeby znał i rozpoznawał przynajmniej kilka podstawowych zwrotów z mowy potocznej. Z pomocą przychodzi tutaj NLU czyli (z ang. natural language understanding). NLU jest jednym z zagadnień NLP (z ang. natural language processing) i jego zadaniem jest klasyfikacja wprowadzonego tekstu na podstawie dostarczonego zbioru danych klasyfikujących. W stworzeniu takiego rozwiązania pomaga wiele bibliotek i/lub aplikacji takich jak Stanford Library, RASA, Siri, Alexa, czy Google Assistant.
Poniżej pokażemy wykorzystanie rozwiązania proponowanego przez firmę RASA. RASA oferuje nam zewnętrzny serwer Pythona, na którym poprzez REST wystawiona jest usługa interpretacji tekstu wejściowego na odpowiednią klasyfikację na podstawie modelu dostarczonego w naszym projekcie. Proces analizy tekstu wygląda tak:
Do utworzenia i wystartowania podstawowego projektu NLP przy użyciu RASA potrzebujemy naprawdę nie dużo. Instalujemy RASA zgodnie z instrukcją lub ściągamy i używamy gotowego docker image. Następnie Tworzymy nowy projekt:
rasa init --no-prompt
To spowoduje utworzenie struktury dla nowego projektu z domyślnymi ustawieniami praktycznie gotowego do pracy.
__init__.py an empty file that helps python find your actions actions.py code for your custom actions config.yml ‘*’ configuration of your NLU and Core models credentials.yml details for connecting to other services data/nlu.md ‘*’ your NLU training data data/stories.md ‘*’ your stories domain.yml ‘*’ your assistant’s domain endpoints.yml details for connecting to channels like fb messenger models/<timestamp>.tar.gz your initial model
Dalej trzeba stworzyć zestaw danych do trenowania naszego modelu. W tym obszarze RASA obsługuje dwa możliwe formaty danych.
Markdown Format
## intent:sprawdz_stan_konta - jaki jest stan mojego konta <!-- no entity -->
Można również używać wyrażeń regularnych:
## regex:kodpocztowy - [0-9]{5}
Przy tworzeniu danych można również używać parametrów:
## intent:jakijestaktualnykurs - Jaki jest aktualny kurs [euro](currency)? <!-- entity matched by lookup table --> ## lookup:dodatkowe_waluty <!-- specify lookup tables in an external file --> path/to/currencies.txt
JSON Format
Ten format jest trochę bardziej skomplikowany w budowie, dostarcza jednak takie same funkcjonalności:
"rasa_nlu_data": { "common_examples": [], "regex_features" : [], "lookup_tables" : [], "entity_synonyms": [] } }
Podstawowy zestaw treningowy powinien zawierać przynajmniej kilka sekcji intent, w których znajdzie przynajmniej kilka wersji pytania/zdania/formuły, która będzie prowadziła do tego samego wyniku. Na przykład:
- jaki jest aktualny kurs [waluta] - za ile można kupić [waluta] - po ile dzisiaj można kupić [waluta]
Po zbudowaniu danych warto zbudować stories
, które są przykładami rzeczywistych rozmów. Na tej podstawie wytrenowany model będzie mógł podejmować decyzję, którą z odpowiedzi wybrać.
Teraz możemy wytrenować model:
rasa train
Teraz możemy sprawdzić działanie naszego modelu przez narzędzia dostarczony wraz z RASA:
rasa shell
lub wystartować REST service, który udostępnia API do wykonywania zapytań do RASA, a także umożliwia wykonywanie różnego rodzaju akcji na samym modelu.
rasa run --enable-api --log-file out.log --endpoints my_endpoints.yml
Teraz możemy z naszej aplikacji wywoływać zapytania do modelu przy pomocy REST.
/model/parse Content type: application/json { "text": "Hello, I am Rasa!", "message_id": "b2831e73-1407-4ba0-a861-0f30a42a2a5a" }
W odpowiedzi RASA wyśle nam informację o klasyfikacji, jaką przyznała danemu tekstowi i o tym, z jaką pewnością tę klasyfikację wybrała.
{ "entities": [ { "start": 0, "end": 0, "value": "string", "entity": "string", "confidence": 0 } ], "intent": { "confidence": 0.6323, "name": "greet" }, "intent_ranking": [ { "confidence": 0.6323, "name": "greet" } ], "text": "Hello!" }
Dzięki temu wiemy, że RASA sklasyfikowała tekst wejściowy jak powitanie i teraz możemy dowolnie na nie zareagować na podstawie wyuczonego modelu, wybierając jedną z możliwych odpowiedzi.
Dodatkowo modele można walidować i ewaluować, żeby poprawić ich efektywność. Jednym z najlepszych sposobów poprawienia efektywności modelu jest jego douczanie na podstawie już wcześniej odbytych konwersacji. W tym celu trzeba zapisywać logi z konwersacji w takiej formie, żeby w łatwy sposób mogła być przerobiona na kolejne zestawy danych treningowych. Dobrze jest też poprosić użytkownika końcowego o określenie, w jakim stopniu rozmowa była satysfakcjonująca. W ten sposób wzbogacamy odpowiedzi chatbota i poprawiamy trafność dopasowań.
Podsumowanie
W bankowości inwestycyjnej najbardziej pomocne jest samo NLU, czyli rozpoznawanie życzeń klienta, rozbijanie ich na poszczególne kategorie i klasyfikowanie parametrów. Dzięki takiemu użyciu jesteśmy w stanie szybko reagować na potrzeby użytkowników, dostarczając do odpowiedniego procesu taki zestaw danych jaki jest wymagany żeby sprawdzić i/lub przeprowadzić daną operację.
Zdjęcie główne artykułu pochodzi z unsplash.com.