Chat, ekstrak: arsitektur chatbots yang kompleks

Pengguna, setelah berbicara dengan asisten suara cerdas, mengharapkan intelijen dari bot obrolan. Jika Anda mengembangkan bot untuk bisnis, harapannya bahkan lebih tinggi: pelanggan menginginkan pengguna untuk mengikuti skenario yang ditentukan sebelumnya, dan pengguna ingin robot secara cerdas dan lebih disukai secara manusiawi menjawab pertanyaan-pertanyaan yang diajukan, membantu menyelesaikan masalah, dan kadang-kadang hanya mendukung obrolan ringan.


Kami melakukan bot obrolan berbahasa Inggris yang berkomunikasi dengan pengguna melalui berbagai saluran - Facebook Messenger, SMS, Amazon Alexa, dan web. Bot kami menggantikan layanan dukungan, agen asuransi, dan hanya bisa mengobrol. Masing-masing tugas ini membutuhkan pendekatan pengembangannya sendiri.

Dalam artikel ini kami akan memberi tahu Anda modul apa yang terdiri dari layanan kami, bagaimana masing-masing dibuat, pendekatan apa yang kami pilih dan mengapa. Kami akan berbagi pengalaman kami dalam menganalisis alat yang berbeda: ketika jaringan saraf generatif bukan pilihan terbaik, mengapa alih-alih Doc2vec kita menggunakan Word2vec, apa pesona dan kengerian dari ChatScript dan sebagainya.


Pada pandangan pertama, tampaknya masalah yang kita selesaikan agak sepele. Namun, di bidang Pemrosesan Bahasa Alami ada sejumlah kesulitan yang terkait dengan implementasi teknis dan faktor manusia.
  1. Satu miliar orang berbicara bahasa Inggris, dan masing-masing penutur asli menggunakannya dengan cara mereka sendiri: ada berbagai dialek, fitur ucapan individu.
  2. Banyak kata, frasa, dan ekspresi yang ambigu: contoh tipikal ada di gambar ini.
  3. Penafsiran yang benar tentang makna kata membutuhkan konteks. Namun, bot yang meminta klien mengklarifikasi pertanyaan tidak terlihat sekeren yang dapat beralih ke topik apa pun atas permintaan pengguna dan menjawab pertanyaan apa pun.
  4. Seringkali dalam pidato hidup dan korespondensi, orang mengabaikan aturan tata bahasa atau menjawab dengan singkat sehingga hampir tidak mungkin untuk mengembalikan struktur kalimat.
  5. Terkadang, untuk menjawab pertanyaan pengguna, perlu membandingkan permintaannya dengan teks FAQ. Pada saat yang sama, Anda perlu memastikan bahwa teks yang ditemukan di FAQ memang jawabannya, dan tidak hanya berisi beberapa kata yang sesuai dengan permintaan.


Ini hanya beberapa aspek yang paling jelas, dan ada juga slang, jargon, humor, sarkasme, kesalahan ejaan dan pengucapan, singkatan dan masalah lain yang membuat pekerjaan di area ini sulit.

Untuk mengatasi masalah ini, kami mengembangkan bot yang menggunakan serangkaian pendekatan. Bagian AI dari sistem kami terdiri dari manajer dialog, layanan pengenalan, dan layanan microsoft kompleks penting yang memecahkan masalah spesifik: Intent Classifier, layanan-FAQ, Small Talk.

Mulai percakapan. Manajer dialog


Tugas Manajer Dialog dalam bot adalah simulasi perangkat lunak komunikasi dengan agen langsung: harus memandu pengguna melalui skenario percakapan ke beberapa tujuan yang bermanfaat.
Untuk melakukan ini, pertama, cari tahu apa yang diinginkan pengguna (misalnya, hitung biaya asuransi untuk mobil), dan kedua, cari tahu informasi yang diperlukan (alamat dan data pengguna lain, data tentang pengemudi dan mobil). Setelah itu, layanan harus memberikan jawaban yang bermanfaat - isi formulir dan berikan klien hasil dari data ini. Pada saat yang sama, kita seharusnya tidak bertanya kepada pengguna apa yang telah ditunjukkannya.

Manajer Dialog memungkinkan Anda untuk membuat skenario seperti itu: jelaskan secara terprogram, bangun dari batu bata kecil - masalah atau tindakan khusus yang harus terjadi pada titik tertentu. Sebenarnya, skenario adalah grafik terarah, di mana setiap node adalah pesan, pertanyaan, tindakan, dan edge menentukan urutan dan kondisi transisi antara node-node ini, jika ada beberapa pilihan transisi dari satu node ke node lainnya.
Jenis utama node
  • Node menunggu hingga mencapai antrian dan akan muncul di pesan.
  • Node menunggu pengguna untuk menunjukkan niat tertentu (misalnya, tulis: "Saya ingin mendapatkan asuransi").
  • Node menunggu data dari pengguna untuk memvalidasi dan menyimpan.
  • Node untuk implementasi berbagai desain algoritmik (loop, cabang, dll.).

Jika node ditutup, kontrol tidak akan lagi ditransfer ke sana, dan pengguna tidak akan melihat pertanyaan yang sudah ditanyakan. Jadi, jika kita melakukan pencarian mendalam dalam grafik seperti itu ke node terbuka pertama, kita mendapatkan pertanyaan yang perlu ditanyakan kepada pengguna pada waktu tertentu. Pada gilirannya menjawab pertanyaan-pertanyaan yang dihasilkan oleh Dialog Manager, pengguna akan secara bertahap menutup semua node dalam grafik, dan akan dianggap bahwa ia telah menjalankan skrip yang ditentukan. Kemudian, misalnya, kami memberikan kepada pengguna deskripsi tentang opsi asuransi yang dapat kami tawarkan.


"Aku sudah mengatakan semuanya!"


Misalkan kita meminta nama pengguna, dan dalam satu pesan dia juga akan memberikan tanggal lahir, nama, jenis kelamin, status perkawinan, alamat, atau mengirim foto SIM-nya. Sistem akan mengekstraksi semua data yang relevan dan menutup node yang sesuai, yaitu, pertanyaan tentang tanggal lahir dan jenis kelamin tidak akan lagi ditanyakan.

"Ngomong-ngomong ..."


Manajer Dialog juga menyediakan kemampuan untuk berkomunikasi secara bersamaan pada beberapa topik. Misalnya, seorang pengguna mengatakan: "Saya ingin mendapatkan asuransi." Kemudian, tanpa menyelesaikan dialog ini, ia menambahkan: "Saya ingin melakukan pembayaran berdasarkan kebijakan yang terlampir sebelumnya." Dalam kasus seperti itu, Manajer Dialog menyimpan konteks topik pertama, dan setelah penyelesaian skrip kedua, ia menawarkan untuk melanjutkan dialog sebelumnya dari tempat di mana ia terputus.

Dimungkinkan untuk kembali ke pertanyaan yang sudah dijawab pengguna. Untuk ini, sistem menyimpan snapshot grafik setelah menerima setiap pesan dari klien.

Apa saja pilihannya?


Selain kami, kami mempertimbangkan pendekatan AI lain untuk implementasi manajer dialog: niat dan parameter pengguna diumpankan ke input jaringan saraf, dan sistem itu sendiri menghasilkan status yang sesuai, pertanyaan berikutnya yang perlu ditanyakan. Namun, dalam praktiknya, metode ini membutuhkan penambahan pendekatan berbasis aturan. Mungkin opsi implementasi ini cocok untuk skenario sepele - misalnya, untuk memesan makanan, di mana Anda hanya perlu mendapatkan tiga parameter: apa yang ingin dipesan pengguna, kapan ia ingin menerima pesanan dan ke mana membawanya. Tetapi dalam kasus skenario kompleks, seperti di area subjek kami, ini masih tidak bisa dicapai. Saat ini, teknologi pembelajaran mesin tidak mampu membimbing pengguna secara kualitatif ke tujuan dalam skenario yang kompleks.

Manajer Dialog ditulis dalam kerangka Python, Tornado, karena pada awalnya bagian AI kami ditulis sebagai layanan tunggal. Sebuah bahasa dipilih di mana semua ini dapat direalisasikan tanpa menghabiskan sumber daya untuk komunikasi.

"Mari kita putuskan." Layanan Pengakuan


Produk kami dapat berkomunikasi melalui saluran yang berbeda, tetapi bagian AI sepenuhnya independen dari klien: komunikasi ini hanya datang dalam bentuk teks yang diproksi. Manajer dialog mentransfer konteks, respons pengguna, dan data yang dikumpulkan ke Layanan Pengakuan, yang bertanggung jawab untuk mengenali maksud pengguna dan mengambil data yang diperlukan.
Saat ini, Layanan Pengakuan terdiri dari dua bagian logis: Manajer Pengakuan, yang mengelola pipa pengakuan, dan ekstraktor.

Manajer Pengakuan


Recognition Manager bertanggung jawab untuk semua tahapan dasar mengenali makna ucapan: tokenization, lemmatization, dll. Ia juga menentukan urutan ekstraktor (objek yang mengenali entitas dan atribut dalam teks) yang dengannya sebuah pesan akan dilewati, dan memutuskan kapan akan berhenti pengakuan dan kembali hasil jadi. Ini memungkinkan Anda untuk menjalankan hanya ekstraktor yang diperlukan dalam urutan yang paling diharapkan.

Jika kami bertanya siapa nama pengguna itu, masuk akal untuk memeriksa terlebih dahulu apakah nama itu ada dalam jawaban. Nama telah datang, dan tidak ada teks yang lebih berguna - yang berarti bahwa pengakuan dapat diselesaikan pada langkah ini. Beberapa entitas lain yang bermanfaat telah datang, yang berarti pengakuan harus dilanjutkan. Kemungkinan besar, orang itu menambahkan beberapa data pribadi lainnya - karena itu, Anda perlu menjalankan ekstraktor untuk memproses data pribadi.

Tergantung pada konteksnya, urutan start-up ekstraktor dapat bervariasi. Pendekatan ini memungkinkan kami untuk secara signifikan mengurangi beban pada seluruh layanan.

Ekstraktor


Seperti disebutkan di atas, ekstraktor dapat mengenali entitas dan atribut tertentu dalam teks. Misalnya, satu ekstraktor mengenali nomor telepon; yang lain menentukan apakah seseorang menjawab pertanyaan secara positif atau negatif; yang ketiga - mengenali dan memverifikasi alamat dalam pesan; yang keempat adalah data tentang kendaraan pengguna. Melewati pesan melalui serangkaian ekstraktor - ini adalah proses mengenali pesan masuk kami.

Untuk operasi optimal dari setiap sistem yang kompleks, perlu untuk menggabungkan pendekatan. Kami mematuhi prinsip ini ketika mengerjakan ekstraktor. Saya akan menyoroti beberapa prinsip kerja yang kami gunakan dalam ekstraktor.

Menggunakan microservices kami dengan Machine Learning di dalam (extractors mengirim pesan ke layanan ini, kadang-kadang melengkapi dengan informasi yang mereka miliki dan mengembalikan hasilnya).

  • Menggunakan POS tagging, parsing sintaksis, parsing semantik (misalnya, menentukan maksud pengguna dengan kata kerja)
  • Menggunakan pencarian teks lengkap (dapat digunakan untuk menemukan merek dan model mesin dalam pesan)
  • Menggunakan Ekspresi dan Pola Respons Reguler
  • Penggunaan API pihak ketiga (seperti Google Maps API, SmartyStreets, dll.)
  • Pencarian kata demi kata (jika seseorang menjawab "yep" tidak lama, maka tidak ada gunanya meneruskannya melalui algoritma ML untuk mencari maksud).
  • Kami juga menggunakan solusi pemrosesan bahasa alami siap pakai dalam ekstraktor.

Apa saja pilihannya?


Kami melihat perpustakaan NLTK, Stanford CoreNLP, dan SpaCy. NLTK keluar pertama kali di Google SERP ketika Anda memulai tinjauan NLP. Ini sangat keren untuk solusi prototyping, memiliki fungsi yang luas dan cukup sederhana. Tetapi kinerjanya buruk.

Stanford CoreNLP memiliki minus yang serius: ia menarik mesin virtual Java dengan modul yang sangat besar, perpustakaan bawaan, dan menghabiskan banyak sumber daya. Selain itu, output dari perpustakaan ini sulit untuk disesuaikan.

Sebagai hasilnya, kami memilih SpaCy, karena memiliki fungsi yang cukup untuk kami dan memiliki rasio cahaya dan kecepatan yang optimal. Perpustakaan SpaCy berjalan puluhan kali lebih cepat daripada NLTK dan menawarkan kamus yang jauh lebih baik. Namun, ini jauh lebih mudah daripada Stanford CoreNLP.

Saat ini, kami menggunakan spaCy untuk tokenization, vektorisasi pesan (menggunakan built-in neural network terlatih), pengenalan utama parameter dari teks. Karena perpustakaan hanya mencakup 5% dari kebutuhan pengenalan kami, kami harus menambahkan banyak fungsi.

"Dulu ..."


Layanan Pengakuan tidak selalu merupakan struktur dua bagian. Versi pertama adalah yang paling sepele: kami bergantian menggunakan ekstraktor yang berbeda dan mencoba memahami jika ada parameter atau niat dalam teks. AI bahkan tidak mencium bau di sana - itu adalah pendekatan yang sepenuhnya berdasarkan aturan. Kesulitannya adalah bahwa niat yang sama dapat diekspresikan dalam banyak cara, yang masing-masing harus dijelaskan dalam aturan. Dalam hal ini, perlu mempertimbangkan konteksnya, karena frasa pengguna yang sama, tergantung pada pertanyaan yang diajukan, mungkin memerlukan tindakan yang berbeda. Misalnya, dari dialog: "Apakah Anda sudah menikah?" - “Sudah dua tahun”, Anda dapat memahami bahwa pengguna sudah menikah (makna boolean). Dan dari dialog "Berapa lama Anda mengendarai mobil ini?" - “Sudah dua tahun” Anda perlu mengekstraksi nilai “2 tahun”.

Sejak awal, kami memahami bahwa mendukung solusi berbasis aturan akan membutuhkan banyak upaya, dan ketika jumlah niat yang didukung meningkat, jumlah aturan akan meningkat lebih cepat daripada dalam kasus sistem berbasis ML. Namun, dari sudut pandang bisnis. kami perlu menjalankan MVP, pendekatan berbasis aturan memungkinkan kami melakukan ini dengan cepat. Oleh karena itu, kami menggunakannya, dan secara paralel bekerja pada ML-model pengakuan niat. Begitu muncul dan mulai memberikan hasil yang memuaskan, mereka secara bertahap mulai menjauh dari pendekatan berbasis aturan.

Untuk sebagian besar kasus ekstraksi informasi, kami menggunakan ChatScript. Teknologi ini menyediakan bahasa deklaratifnya sendiri yang memungkinkan Anda menulis templat untuk mengekstraksi data dari bahasa alami. Berkat WordNet, solusi ini sangat kuat di bawah tenda (misalnya, Anda dapat menentukan "warna" di templat pengenalan, dan WordNet mengenali konsep penyempitan, seperti "merah"). Kami tidak melihat analog pada waktu itu. Tetapi ChatScript ditulis sangat bengkok dan buggy, dengan penggunaannya hampir tidak mungkin untuk mengimplementasikan logika yang kompleks.

Pada akhirnya, kerugiannya melebihi, dan kami meninggalkan ChatScript demi pustaka NLP dengan Python.
Dalam versi pertama Layanan Pengakuan, kami mencapai batas tertinggi untuk fleksibilitas. Pengenalan setiap fitur baru sangat memperlambat keseluruhan sistem secara keseluruhan.

Jadi kami memutuskan untuk sepenuhnya menulis ulang Layanan Pengakuan, membaginya menjadi dua bagian logis: kecil, ekstraktor ringan dan Manajer Pengakuan, yang akan mengelola proses.

"Apa yang kamu inginkan?" Intent Classifier


Agar bot berkomunikasi secara memadai - untuk memberikan informasi yang diperlukan berdasarkan permintaan dan untuk merekam data pengguna - perlu untuk menentukan niat (maksud) pengguna berdasarkan teks yang dikirimkan kepadanya. Daftar niat yang dengannya kami dapat berinteraksi dengan pengguna dibatasi oleh tugas-tugas bisnis klien: mungkin niat untuk mengetahui kondisi asuransi, mengisi informasi tentang diri Anda, mendapatkan jawaban atas pertanyaan yang sering diajukan, dan sebagainya.

Ada banyak pendekatan untuk klasifikasi maksud berdasarkan jaringan saraf, khususnya, pada LSTM / GRU berulang. Mereka telah membuktikan diri dalam studi terbaru, tetapi mereka memiliki kelemahan umum: sampel yang sangat besar diperlukan untuk operasi yang tepat. Pada sejumlah kecil data, jaringan saraf seperti itu sulit untuk dilatih, atau mereka memberikan hasil yang tidak memuaskan. Hal yang sama berlaku untuk kerangka kerja Teks Cepat Facebook (kami meninjaunya karena ini adalah solusi canggih untuk memproses frasa pendek dan sedang).

Sampel pelatihan kami sangat berkualitas tinggi: kumpulan data terdiri dari tim ahli bahasa penuh waktu yang mahir berbahasa Inggris dan mengetahui spesifikasi bidang asuransi. Namun, sampel kami relatif kecil. Kami mencoba mencairkannya dengan dataset publik, tetapi yang, dengan pengecualian langka, tidak cocok dengan spesifikasi kami. Kami juga mencoba menarik pekerja lepas dengan Amazon Mechanical Turk, tetapi metode ini juga ternyata tidak beroperasi: data yang mereka kirim sebagian berkualitas buruk, sampel harus diperiksa ulang.

Oleh karena itu, kami mencari solusi yang akan bekerja pada sampel kecil. Kualitas pemrosesan data yang baik ditunjukkan oleh pengelompokan Acak Hutan, dilatih tentang data yang dikonversi menjadi vektor model bag-of-words kami. Menggunakan validasi silang, kami memilih parameter optimal. Di antara kelebihan model kami adalah kecepatan dan ukuran, serta relatif mudahnya penempatan dan pelatihan ulang.

Dalam proses pengerjaan Intent Classifier, menjadi jelas bahwa untuk beberapa tugas penggunaannya tidak optimal. Misalkan seorang pengguna ingin mengubah nama yang tertera di asuransi atau nomor mobil. Agar penggolong untuk menentukan maksud ini dengan benar, kita harus menambahkan secara manual ke dataset semua frasa templat yang digunakan dalam kasus ini. Kami menemukan cara lain: membuat ekstraktor kecil untuk Layanan Pengakuan, yang menentukan niat dengan kata kunci dan metode NLP, dan menggunakan Intent Classifier untuk frasa non-standar di mana metode dengan kata kunci tidak berfungsi.

"Mereka selalu bertanya tentang ini." Faq


Banyak pelanggan kami memiliki bagian FAQ. Agar pengguna dapat menerima jawaban seperti itu langsung dari chatbot, penting untuk memberikan solusi yang akan a) mengenali permintaan FAQ; b) akan menemukan jawaban yang paling relevan dalam database kami dan mengeluarkannya.

Ada sejumlah model yang dilatih tentang data Stanford SQUAD. Mereka bekerja dengan baik ketika teks respons dari FAQ berisi kata-kata dari pertanyaan pengguna. Katakanlah FAQ mengatakan: "Frodo mengatakan dia akan membawa Cincin itu ke Mordor, meskipun dia tidak tahu jalan ke sana." Jika pengguna bertanya: "Di mana Frodo akan mengambil Cincin?", Sistem akan menjawab: "Ke Mordor."

Skenario kami, sebagai suatu peraturan, berbeda. Misalnya, untuk dua permintaan serupa - "Bisakah saya membayar?" dan "Bisakah saya membayar online?" Bot harus merespons secara berbeda: dalam kasus pertama, menawarkan seseorang bentuk pembayaran, dalam jawaban kedua - ya, Anda dapat membayar online, di sini adalah alamat halaman.

Kelas lain dari solusi untuk menilai kemiripan dokumen difokuskan pada jawaban panjang - setidaknya beberapa kalimat, di antaranya berisi informasi yang menarik bagi pengguna. Sayangnya, dalam kasus dengan pertanyaan dan jawaban singkat ("Bagaimana saya membayar online?" - "Anda dapat membayar menggunakan PayPal") mereka bekerja sangat tidak stabil.

Solusi lain adalah pendekatan Doc2vec: teks besar didistilasi menjadi representasi vektor, yang kemudian dibandingkan dengan dokumen lain dalam bentuk yang sama dan koefisien kesamaan terungkap. Pendekatan ini juga harus diambil: itu berfokus pada teks yang panjang, tetapi kami terutama berurusan dengan pertanyaan dan jawaban dari satu atau dua kalimat.

Keputusan kami didasarkan pada dua langkah. Pertama: menggunakan embeddings, kami menerjemahkan setiap kata dalam kalimat ke dalam vektor menggunakan model Google Word2vec.Setelah itu, kami mempertimbangkan vektor rata-rata untuk semua kata, mewakili satu kalimat dalam bentuk satu vektor. Langkah kedua, kami mengambil vektor dari pertanyaan dan ditemukan di database FAQ, disimpan dalam bentuk vektor yang sama, jawaban terdekat sampai batas tertentu, dalam kasus kami, cosinus.

Keuntungannya termasuk kemudahan implementasi, ekstensibilitas yang sangat mudah dan interpretabilitas yang cukup sederhana. Kerugiannya adalah peluang optimisasi yang lemah: model ini sulit untuk dimodifikasi - itu berfungsi dengan baik di sebagian besar kasus pengguna Anda, atau Anda harus meninggalkannya.

"Dan bicara?" Obrolan ringan


Terkadang pengguna menulis sesuatu yang sama sekali tidak relevan, misalnya: "Cuaca cerah hari ini." Ini tidak termasuk dalam daftar niat yang menarik minat kami, tetapi kami masih ingin menjawab dengan bermakna, menunjukkan kecerdasan sistem kami.

Untuk keputusan seperti itu, kombinasi dari pendekatan yang dijelaskan di atas digunakan: mereka didasarkan pada solusi berbasis aturan yang sangat sederhana atau jaringan saraf generatif. Kami ingin mendapatkan prototipe lebih awal, jadi kami mengambil dataset publik dari Internet dan menggunakan pendekatan yang sangat mirip dengan yang digunakan untuk FAQ. Sebagai contoh, pengguna menulis sesuatu tentang cuaca - dan menggunakan algoritma yang membandingkan representasi vektor dari dua kalimat dengan ukuran cosinus tertentu, kami mencari kalimat dalam dataset publik yang akan sedekat mungkin dengan tema cuaca.

Pelatihan


, , : -, , (, IBM Watson , , Twitter- Microsoft ). -, ; — -. QA-, , .

Meskipun demikian, bot kami sepertinya sudah siap untuk lulus tes Turing. Beberapa pengguna memulai percakapan serius dengannya, percaya bahwa mereka sedang berbicara dengan agen asuransi, dan satu bahkan mulai mengancam keluhan kepada bos ketika bot salah paham dengannya.

Paket


Sekarang kami sedang mengerjakan bagian visual: menampilkan seluruh grafik skrip dan kemampuan untuk menyusunnya menggunakan GUI.

Di sisi Layanan Pengakuan, kami memperkenalkan analisis linguistik untuk mengenali dan memahami makna setiap kata dalam pesan. Ini akan meningkatkan keakuratan reaksi dan mengekstrak data tambahan. Misalnya, jika seseorang mengisi asuransi mobil dan menyebutkan bahwa ia memiliki rumah yang tidak diasuransikan, bot akan dapat mengingat pesan ini dan mengirimkannya ke operator untuk menghubungi pelanggan dan menawarkan asuransi rumah.

Fitur lain dalam pekerjaan ini adalah pemrosesan umpan balik. Setelah menyelesaikan dialog dengan bot, kami bertanya kepada pengguna apakah dia menyukai layanan tersebut. Jika Analisis Sentimen mengakui ulasan pengguna sebagai positif, kami mengundang pengguna untuk membagikan pendapat mereka di jejaring sosial. Jika analisis menunjukkan bahwa pengguna bereaksi negatif, bot mengklarifikasi apa yang salah, memperbaiki jawabannya, mengatakan: "Oke, kami akan memperbaikinya", dan tidak menawarkan untuk membagikan ulasan di arus.

Salah satu kunci untuk membuat komunikasi dengan bot sealami mungkin adalah membuat bot modular dan memperluas jangkauan reaksi yang tersedia. Kami sedang mengusahakannya. Mungkin karena ini, pengguna siap untuk dengan tulus mengambil bot kami untuk agen asuransi. Langkah selanjutnya: pastikan orang itu mencoba mengucapkan terima kasih kepada bot.



Artikel itu ditulis bersama Sergei Kondratyuk dan Mikhail Kazakov . Tuliskan pertanyaan Anda di komentar, kami akan menyiapkan materi yang lebih praktis.

Source: https://habr.com/ru/post/id429638/


All Articles