Cos'è un'interfaccia vocale? O Vocal UI per dirla in modo più figo?

È un tipo di interfaccia che l'utente non vede, ma ascolta e ci interagisce con la voce. Degli esempi possono essere Cortana, Alexa, Siri, Google Coso, o qualunque cosa che si attivi con la voce, e ti risponde con tanto di voce.

Per quanto possa essere interessante l'idea di avere un assistente virtuale che parla e ascolta, costruire un'interfaccia di questo tipo non è per niente facile. O meglio, è (relativamente) facile se il tuo obiettivo è facile, ma si complica vertiginosamente all'aumentare della complessità dei task che vuoi realizzare.

Questo articolo contiene riflessioni e opinioni personali che ho sviluppato durante la creazione del progetto finale per il corso di Human Computer Interaction (HCI) alla magistrale che sto frequentando.

Insieme a quattro colleghi, abbiamo deciso di creare un'interfaccia vocale per manovrare dei CRM; nello specifico, abbiamo preso molta ispirazione da Monica, un CRM orientato alla persona invece che all'azienda. Abbiamo sviluppato la nostra interfaccia come skill per Mycroft, un assistente vocale open-source.

Molto semplicemente, un CRM è un programma per gestire relazioni con persone, ad esempio annotando l'ultima volta con abbiamo parlato con loro, come, quando, perché, etc. Immagina una rubrica, ma con i muscoli.

Già da questa descrizione è facile farsi una prima idea delle possibili difficoltà nel creare una cosa del genere. I punti che scrivo nell'articolo infatti, provengono da esperienze e esperimenti che abbiamo fatto durante lo sviluppo del progetto.

Partiamo.

Riconoscimento vocale

La prima ovvia cosa che un'interfaccia deve fare per capire cosa hai detto, è prendere la registrazione audio della tua bella vocetta, e darla in pasto ad una scatola nera che ne sputa fuori la trascrizione (speech to text). Tutto questo è chiamato speech recognition.

Quali sono i problemi nel fare una cosa del genere? Cosa può andare storto?

Qualità dell'input audio

Ecco qualche scenario:

  • Il volume della registrazione è troppo basso, e il motore di riconoscimento non riesce a fare correttamente il suo lavoro; potrebbe ad esempio saltare intere parole.
  • Cancellazione dell'eco: a seconda del dispositivo su cui fai girare il software di assistente vocale, potrebbe esserci o non esserci cancellazione dell'eco. In ogni caso, una scarsa o assente cancellazione dell'eco può compromettere la qualità, ad esempio se l'acustica della stanza è cattiva. Occhio, perché anche un'eccessiva cancellazione dell'eco può dare problemi.
  • Cancellazione del rumore: come per il punto precedente, se l'ambiente è molto rumoroso e la soglia del gate è troppo bassa, alcuni rumori ambientali potrebbero essere valutati come parole. Al contrario, una soglia troppo alta può avere l'effetto di scambiare parole correttamente pronunciate per rumore.
  • Timbri di voce: in base all'hardware del dispositivo e al modello di speech to text, alcuni timbri di voce possono essere più o meno riconoscibili. Ad esempio, durante i test del nostro progetto di esame ci siamo accorti che il motore STT di Mycroft su cui ci stavamo basando aveva problemi a riconoscere voci fine/alte.

Questi punti sono molto dipendenti dall'hardware che stai usando (dispositivo, microfono) e al software che ci gira sopra (SO, driver audio, filtri, assistente vocale).

Una cosa divertente che ho fatto per vedere se con qualche filtro riuscivo a risolvere i problemi di riconoscimento delle voci "fine", è stato mettere un octaver prima di Mycroft e... sorpresa, le voci fine venivano riconosciute.

Dialetti, lingue diverse, parole sconosciute

Nel mondo ideale tutti parlano correttamente un'unica lingua, in condizioni ambientali favorevoli, hanno una dizione impeccabile, non dicono cose come "uhmmmm, cioèèè tipooo, eeeeeee", non esistono i dialetti, non esistono neologismi e nessuno inventa nuovi termini.

Nel mondo reale, tutti parliamo a modo nostro, anche più di una lingua, usiamo parole di lingue diverse nella stessa frase, parliamo mentre il cane abbaia, facciamo dei suoni mentre pensiamo, ci inventiamo le parole e a volte sostituiamo nomi di oggetti con "coso".

Noi umani siamo bravi a disambiguare tutte queste cose; un algoritmo non proprio.

Intent parsing

Il testo estratto dalla utterance dell'utente (ciò che ha detto), deve essere in qualche modo compreso: il sistema deve ricavarne un significato.

Il concetto di significato può essere ridotto al concetto di intent, ovvero ciò che il sistema crede sia l'intenzione dell'utente.

Per fare un esempio, immaginiamo che la nostra interfaccia vocale gestisca un piccolo sistema di smart home, in cui i possibili intent sono:

  • Accendi la luce numero X.
  • Spegni la luce numero X.
  • Aumenta la luminosità della luce numero X.
  • Diminuisci la luminosità della luce numero X.

Quando l'utente parla, il sistema deve capire cosa fare tra le cose in questa lista; per farlo ha bisogno di qualche regola. Questo processo è chiamato intent parsing, e consiste in breve nel trovare una corrispondenza tra la utterance dell'utente e un intent definito da chi ha creato la skill.

Gli intent parser possono funzionare in modo diverso: per esempio Padatious è basato su reti neurali, mentre Adapt è basato sul riconoscimento di parole chiavi.

A seconda della complessità del task e delle caratteristiche dell'intent parser, le cose possono complicarsi, specialmente se il sistema deve anche estrarre delle entità. Per esempio, la nostra intent definition per Padatious del nostro stupido sistemino di smart home, potrebbe essere questa.

(accendi|accendere) (|la) luce (|numero) {NomeLuce}
Task di accensione di una luce
(aumenta|alza) (|la) luminosità (|della) luce (|numero) {NomeLuce}
(aumenta|alza) (|la) luce (|numero) {NomeLuce}
rendi {NomeLuce} più luminosa
Task di aumento della luminosità

Non mi va di scrivere Per brevità ometto i task complementari di spegnimento e diminuzione.

La sintassi è facile:

  • (a|b|c) denota opzionalità delle stringhe {a, b, c}.
  • (|a|b|c) aggiunge la stringa vuota all'opzionalità.
  • {Entità} è un placeholder per un'entità chiamata Entità, che dopo essere estratta possiamo farci qualcosa.

Sebbene questi siano task molto semplici, sono comunque adatti per ragionare su cosa può andare storto. Cosa succede se l'utente dice qualcosa come:

Ehmm, allora, puoi alzarmi per favore la luminosità della luceeeehin cucina? Grazie.

Basta fare qualche test di usabilità per accorgersi che gli utenti tendono a sparare utterance di questo tipo. Ovviamente meno l'utente ha esperienza con assistenti vocali, più tenderà a costruire utterance "gentili", destrutturate, complesse, più lunghe del necessario.

Se l'intent parser è bravo a filtrare tutte le possibili stopword e altre parole non gradite (grazie, per favore, ehm, allora, etc), l'interazione sarà abbastanza piacevole, e noi non dovremmo faticare molto per scrivere delle buone intent definition e gestirle. Se prendiamo in considerazione Padatious, scopriremo che non è per niente bravo a farlo. In particolare, durante il nostro progetto ci siamo resi conto che:

  • Intent definition con e senza entità lavorano male insieme.
  • Per qualche ragione, il parser lavora male quando ci sono molte opzionalità.
  • In alcune occasioni, il contenuto delle entità estratte è sbagliato.[1][2]

Trovati questi problemi, potremmo ben pensare di passare ad un altro intent parser, come Adapt. Migrare è semplice: basta riscrivere tutte le intent definition come RegEx, definire dei file contenenti parole chiave, e annotare in modo dichiarativo il modo con cui ci aspettiamo che l'utente utilizzi la combinazione di parole chiave e intent definition.

Faccio un esempio, così si capisce meglio:

.* (?P<LightName>.*)
Task di accensione luce
.* (alza|aumenta) (?P<LightName>.*)
Task di aumento della luminosità

Per gestire il primo task potremmo avere:

@intent_handler(IntentBuilder("LightOn")
	.require("LightName")
	.require("PowerOnKeyword")
)
def handle_light_on(self, message):
	# ...code...

Dove LightName è obbligatorio, e corrisponde al nome dell'entità da estrarre, e PowerOnKeyword è altrettanto obbligatorio ed è il nome di un file contenente un elenco di parole chiave che ci aspettiamo che l'utente dica per accendere una luce, come ad esempio {accendi, accendere}.

Questo intent parser si presenta molto bene e sembra molto flessibile, infatti lo è. Peccato che apra ad un mondo completamente nuovo di problemi da gestire:

  • Scrivere intent definition troppo generiche può provocare conflitti (che vedremo tra poco).
  • .* è molto comodo, ma se abusato può provocare conflitti.
  • Più RegEx possono matchare la stessa utterance; è un problema quando ci aspettiamo di trovare un'entità ma viene matchata la RegEx senza entità.
  • In alcuni casi, specialmente se le RegEx sono complesse o molto diversificate, Adapt fallisce il matching.

Nel nostro progetto l'ultimo punto ci ha dato molto da lavorare; in particolare per far atterrare l'utente sul task corretto anche in assenza di un match completo, abbiamo dovuto togliere la valutazione delle RegEx da Adapt:

  • Con Adapt controllavamo solo la presenza di determinate parole chiave.
  • La valutazione delle RegEx era gestita da noi, per estrarre le giuste entità.

In pratica ci siamo riscritti il codice per fare matching sulle RegEx, perché quello usato da Adapt non era affidabile e falliva.

Intent in conflitto tra loro

La probabilità di avere conflitti aumenta all'aumentare del numero di task che gestisci e al numero di skill installate dall'utente.

Un conflitto si ha quando una utterance è riducibile a più di un intent. Più le intent definition sono generiche, più è facile avere conflitti.

Se un tuo task va in conflitto con quello di un'altra skill, auguri.

Correzione degli errori

Quando usiamo interfacce grafiche è (relativamente) facile gestire gli errori. È una pratica abbastanza comune usare un codice di colori, icone e testi per evidenziare gli errori (pensa ad esempio a un form da compilare).

Gestire gli errori soltanto con la voce, senza un riferimento visivo, può essere frustrante, fastidioso, lento, confusionario. Il caso peggiore si ha quando l'utente non capisce dove sta l'errore; per questo dobbiamo cercare di offrire sempre un aiuto contestuale.

Cosa succede se chiediamo all'utente di dare un nome ad un nuovo contatto in rubrica, e l'utente risponde "il cognome è Di Paolo"? Chiaramente dobbiamo essere bravi a filtrare via tutta quella zozzeria che gli utenti maldestri tendono a pronunciare.

Ma cosa succede se l'utente dice qualcosa di veramente scomodo, che supera i nostri filtri?

Cosa succede se l'utente dice "il cognome di questa persona è Di Paolo"

Supponiamo di avere un filtro semplice che:

  • Elimina {nome} in quanto parola che consideriamo indesiderata.
  • Elimina {il, di, è} in quanto stopword.

Rimaniamo con "questa persona Paolo".

Brutta cosa: abbiamo perso un pezzo del cognome. Come dobbiamo trattare questa frase? La prendiamo per buona? Ci serve un filtro migliore?

Durante il progetto abbiamo dovuto gestire queste situazioni, che nel nostro caso erano abbastanza frequenti perché a differenza del sistemino per accendere e spegnere le luci, nel nostro caso l'utente doveva generare dei contenuti.

Abbiamo usato due approcci a seconda dei momenti in cui queste situazioni accadevano:

  • In certi casi, ad esempio quando l'utente doveva fornire il nome e il cognome di un nuovo contatto, se dopo la rimozione di parole indesiderate ovvie rimanevano delle stopwords, l'interfaccia chiedeva una conferma.
  • In casi meno critici, semplicemente l'interfaccia accettava l'utterance e la ripeteva all'utente, in modo che quest'ultimo potesse valutare autonomamente se ripetere oppure no.
  • In casi in cui la completa integrità della utterance non era importante (es. su una ricerca), l'interfaccia usava l'output completamente filtrato.

Nessuno di questi approcci è corretto in assoluto: dipende dal contesto.

Controllo di flusso

Questo punto è strettamente legato al precedente. Se il tuo task è complesso (composto da più passaggi, un dialogo), è opportuno dare all'utente la possibilità di gestire il flusso del task, ad esempio ripetendo qualcosa, tornando indietro, saltando dei passaggi non importanti, o di annullare l'esecuzione.

Fornire controllo di flusso è semplice, ma apre ad un mondo di complicazioni: supponiamo di fissare delle parole chiave per il controllo di flusso: {avanti, dietro, salta, annulla}, più qualche variante, sinonimo, o parole con significato simile (ad esempio nel nostro progetto in certi casi valutavamo espressioni come "non me lo ricordo" come un modo per saltare una domanda).

A questo punto gestire queste parole si complica un po': cosa succede se il cognome di un contatto include "salta"?

Nel nostro progetto l'interfaccia chiedeva una conferma quando la situazione era ambigua; quando non la chiedeva, l'utente poteva eventualmente correggere il suo errore perché in ogni caso riceveva un feedback sull'input "compreso" dall'interfaccia, e poteva correggere l'errore ripetendo il pezzo di task.

Giusto, sbagliato, elegante, scomodo? Forse importa poco: i risultati dei test di usabilità ci raccontano che gli utenti riuscivano a concludere i task con meno errori e con più semplicità.

Gestione del contesto

Quando parliamo con qualcuno si viene a formare un contesto, ovvero un insieme di informazioni collegate alla conversazione, che ne descrivono lo stato. Il contesto si arricchisce di continuo, man mano che la conversazione prosegue.

In un interfaccia vocale con task complessi si verifica lo stesso fenomeno, o meglio, si deve verificare; siamo noi sviluppatori a doverlo riprodurre.

In particolare, dobbiamo fare in modo che tutti i dati che l'utente ci fornisce man mano, vengano aggiunti a questo contesto, così da fornire una specie di memoria a breve termine alla nostra interfaccia, o anche a lungo termine se siamo particolarmente ambiziosi.

Gestire il contesto non è affatto banale, ma ad esempio Mycroft ci aiuta tramite un pattern chiamato Conversational Context, in cui ad ogni passo del task corrisponde una funzione con un'annotazione specifica.

Conclusioni

Creare interfacce vocali è difficile. Ci sono molti fattori che influiscono sulla qualità e sull'usabilità del prodotto finale:

  • Tutto ciò che riguarda l'hardware del dispositivo fisico su cui gira il sistema, incluso il microfono.
  • Il sistema operativo e i driver audio installati.
  • Eventuali filtri a monte.
  • Il motore di speech to text.
  • L'intent parser e la qualità delle intent definition.
  • Non ne abbiamo parlato perché non rientra nello scopo dell'articolo, ma anche il motore di text to speech influisce sulla qualità: pensiamo a voci robotiche o che per qualche ragione non sono gradevoli.

Insomma, ci sono molte variabili in gioco, e le scelte tecnologiche sono fondamentali.


Perfetto, se l'articolo ti è piaciuto fammelo sapere in qualche modo. Se poi conosci qualcuno a cui può essere utile, considera di mandarglielo :)