diff --git a/lib/prediction/localparserclient.ts b/lib/prediction/localparserclient.ts index 399899638..1a3142453 100644 --- a/lib/prediction/localparserclient.ts +++ b/lib/prediction/localparserclient.ts @@ -35,12 +35,14 @@ import { PredictionCandidate, PredictionResult, GenerationResult, - ExactMatcher + ExactMatcher, + GenerationOptions } from './types'; const SEMANTIC_PARSING_TASK = 'almond'; const NLU_TASK = 'almond_dialogue_nlu'; const NLG_TASK = 'almond_dialogue_nlg'; +const TRANSLATION_TASK = 'almond_translate'; const NLG_QUESTION = 'what should the agent say ?'; export interface LocalParserOptions { @@ -262,4 +264,15 @@ export default class LocalParserClient { }; }); } + + async translateUtterance(input : string[], entities : string[]|undefined, generationOptions : GenerationOptions) : Promise { + input = Utils.qpisEntities(input, entities); + const candidates = await this._predictor.predict('', input.join(' '), undefined, TRANSLATION_TASK, undefined, generationOptions); + return candidates.map((cand) => { + return { + answer: cand.answer, + score: cand.score.confidence ?? 1 + }; + }); + } } diff --git a/lib/prediction/predictor.ts b/lib/prediction/predictor.ts index 7bcde17d8..15e525068 100644 --- a/lib/prediction/predictor.ts +++ b/lib/prediction/predictor.ts @@ -24,6 +24,7 @@ import * as child_process from 'child_process'; import * as Tp from 'thingpedia'; import JsonDatagramSocket from '../utils/json_datagram_socket'; +import {GenerationOptions} from "./types"; const DEFAULT_QUESTION = 'translate from english to thingtalk'; @@ -151,14 +152,14 @@ class LocalWorker extends events.EventEmitter { this._requests.clear(); } - request(task : string, minibatch : Example[]) : Promise { + request(task : string, minibatch : Example[], options : GenerationOptions) : Promise { const id = this._nextId ++; return new Promise((resolve, reject) => { this._requests.set(id, { resolve, reject }); //console.error(`${this._requests.size} pending requests`); - this._stream!.write({ id, task, instances: minibatch }, (err : Error | undefined | null) => { + this._stream!.write({ id, task, instances: minibatch, options: options }, (err : Error | undefined | null) => { if (err) { console.error(err); reject(err); @@ -179,10 +180,11 @@ class RemoteWorker extends events.EventEmitter { start() {} stop() {} - async request(task : string, minibatch : Example[]) : Promise { + async request(task : string, minibatch : Example[], options : GenerationOptions) : Promise { const response = await Tp.Helpers.Http.post(this._url, JSON.stringify({ task, - instances: minibatch + instances: minibatch, + options: options }), { dataContentType: 'application/json', accept: 'application/json' }); return JSON.parse(response).predictions.map((instance : any) : RawPredictionCandidate[] => { if (instance.candidates) { @@ -209,6 +211,7 @@ export default class Predictor { private _maxLatency : number; private _minibatchTask = ''; + private _minitbatchOptions = {}; private _minibatch : Example[] = []; private _minibatchStartTime = 0; @@ -225,13 +228,14 @@ export default class Predictor { private _flushRequest() { const minibatch = this._minibatch; const task = this._minibatchTask; + const options = this._minitbatchOptions; + this._minibatch = []; this._minibatchTask = ''; + this._minitbatchOptions = {}; this._minibatchStartTime = 0; - //console.error(`minibatch: ${minibatch.length} instances`); - - this._worker!.request(task, minibatch).then((candidates) => { + this._worker!.request(task, minibatch, options).then((candidates) => { assert(candidates.length === minibatch.length); for (let i = 0; i < minibatch.length; i++) minibatch[i].resolve(candidates[i]); @@ -241,10 +245,11 @@ export default class Predictor { }); } - private _startRequest(ex : Example, task : string, now : number) { + private _startRequest(ex : Example, task : string, options : GenerationOptions, now : number) { assert(this._minibatch.length === 0); this._minibatch.push(ex); this._minibatchTask = task; + this._minitbatchOptions = options; this._minibatchStartTime = now; setTimeout(() => { @@ -253,23 +258,21 @@ export default class Predictor { }, this._maxLatency); } - private _addRequest(ex : Example, task : string) { + private _addRequest(ex : Example, task : string, options : GenerationOptions) { const now = Date.now(); if (this._minibatch.length === 0) { - this._startRequest(ex, task, now); + this._startRequest(ex, task, options, now); } else if (this._minibatchTask === task && (now - this._minibatchStartTime < this._maxLatency) && this._minibatch.length < this._minibatchSize) { this._minibatch.push(ex); } else { this._flushRequest(); - this._startRequest(ex, task, now); + this._startRequest(ex, task, options, now); } } - predict(context : string, question = DEFAULT_QUESTION, answer ?: string, task = 'almond', example_id ?: string) : Promise { - assert(typeof context === 'string'); - assert(typeof question === 'string'); + predict(context : string, question : string = DEFAULT_QUESTION, answer ?: string, task = 'almond', example_id ?: string, options : GenerationOptions = {}) : Promise { // ensure we have a worker, in case it recently died if (!this._worker) @@ -281,7 +284,7 @@ export default class Predictor { resolve = _resolve; reject = _reject; }); - this._addRequest({ context, question, answer, resolve, reject }, task); + this._addRequest({ context, question, answer, resolve, reject }, task, options); return promise; } diff --git a/lib/prediction/remoteparserclient.ts b/lib/prediction/remoteparserclient.ts index ad744b7c5..0d9798666 100644 --- a/lib/prediction/remoteparserclient.ts +++ b/lib/prediction/remoteparserclient.ts @@ -21,6 +21,7 @@ import * as ThingTalk from 'thingtalk'; import * as Tp from 'thingpedia'; +import * as Utils from '../utils/misc-utils'; import qs from 'qs'; import { EntityMap } from '../utils/entity-utils'; @@ -30,6 +31,7 @@ import { PredictionResult, GenerationResult, ExactMatcher, + GenerationOptions, } from './types'; import ExactMatcherBuilder from './exactbuilder'; @@ -168,4 +170,30 @@ export default class RemoteParserClient { return parsed.candidates; } + + async translateUtterance(input : string[], entities : string[]|undefined, generationOptions : GenerationOptions) : Promise { + input = Utils.qpisEntities(input, entities); + + const data = { + input: input.join(' '), + tgt_locale: generationOptions.tgt_locale, + entities: entities, + alignment: generationOptions.do_alignment, + src_locale: generationOptions.src_locale, + // always remove quotation marks in the output string used to mark entity boundaries + align_remove_output_quotation: true, + // always break input utterance into individual sentences before translation + translate_example_split: true + }; + + const response = await Tp.Helpers.Http.post(`${this._baseUrl}/translate`, JSON.stringify(data), { + dataContentType: 'application/json' //' + }); + const parsed = JSON.parse(response); + if (parsed.error) + throw new Error('Error received from Genie server: ' + parsed.error); + + return parsed.candidates; + } + } diff --git a/lib/prediction/types.ts b/lib/prediction/types.ts index c59f675df..fd18eb77c 100644 --- a/lib/prediction/types.ts +++ b/lib/prediction/types.ts @@ -24,6 +24,26 @@ export interface ExactMatcher { get(tokens : string[]) : string[][]|null; } +export interface GenerationOptions { + num_beams ?: number + num_beam_groups ?: number + diversity_penalty ?: number + num_outputs ?: number + no_repeat_ngram_size ?: number + top_p ?: number + top_k ?: number + repetition_penalty ?: number + temperature ?: number + max_output_length ?: number + src_locale ?: string + tgt_locale ?: string + do_alignment ?: boolean + align_preserve_input_quotation ?: boolean + align_remove_output_quotation ?: boolean + translate_example_split ?: boolean +} + + export interface ParseOptions { thingtalk_version ?: string; store ?: string; diff --git a/lib/utils/misc-utils.ts b/lib/utils/misc-utils.ts index 3939c6ede..f5f74ca0e 100644 --- a/lib/utils/misc-utils.ts +++ b/lib/utils/misc-utils.ts @@ -200,6 +200,39 @@ function isHumanEntity(type : Type|string) : boolean { return false; } +function substringSpan(sequence : string[], substring : string[]) : [number, number] | null { + for (let i=0; i < sequence.length; i++) { + let found = true; + for (let j = 0; j < substring.length; j++) { + if (sequence[i+j] !== substring[j]) { + found = false; + break; + } + } + if (found) + return [i, i + substring.length]; + } + return null; +} + + +function qpisEntities(input : string[], entities : string[]|undefined) : string[] { + if (entities) { + const entityTokens = entities.map((ent) => ent.split(' ')); + for (const entity of entityTokens) { + const span = substringSpan(input, entity); + if (span) { + input.splice(span[0], 0, '"'); + + // add 1 cause previous splice shift tokens to the right + input.splice(span[1] + 1, 0, '"'); + } + } + } + return input; +} + + export { splitParams, split, @@ -212,4 +245,6 @@ export { makeDummyEntity, makeDummyEntities, renumberEntities, + + qpisEntities }; diff --git a/tool/server.ts b/tool/server.ts index 45865cb34..f93ed1d1b 100644 --- a/tool/server.ts +++ b/tool/server.ts @@ -32,6 +32,7 @@ import * as Utils from '../lib/utils/misc-utils'; import { EntityMap } from '../lib/utils/entity-utils'; import LocalParserClient from '../lib/prediction/localparserclient'; import * as I18n from '../lib/i18n'; +import {GenerationOptions} from "../lib/prediction/types"; interface Backend { schemas : ThingTalk.SchemaRetriever; @@ -39,6 +40,7 @@ interface Backend { tokenizer : I18n.BaseTokenizer; nlu : LocalParserClient; nlg ?: LocalParserClient; + translator ?: LocalParserClient; } declare global { @@ -148,6 +150,56 @@ async function queryNLG(params : Record, }); } + +interface TranslationData { + input : string; + tgt_locale : string + entities ?: string[]; + limit ?: string; + alignment ?: boolean; + src_locale ?: string; + align_remove_output_quotation ?: boolean +} +const Translation_PARAMS = { + input: 'string', + tgt_locale: 'string', + entities: '?array', + limit: '?number', + alignment: '?boolean', + src_locale: '?string', + align_remove_output_quotation: '?boolean' +}; + +async function queryTranslate(params : Record, + data : TranslationData, + res : express.Response) { + const app = res.app; + + if (params.locale !== app.args.locale) { + res.status(400).json({ error: 'Unsupported language' }); + return; + } + + if (!data.src_locale) + data.src_locale = 'en-US'; + + const generationOptions : GenerationOptions = { + 'src_locale': data.src_locale, + 'tgt_locale': data.tgt_locale, + 'do_alignment': data.alignment, + 'align_remove_output_quotation': data.align_remove_output_quotation, + + }; + + const result = await res.app.backend.translator!.translateUtterance( + data.input.split(' '), data.entities, generationOptions); + res.json({ + candidates: result.slice(0, data.limit ? parseInt(data.limit) : undefined), + }); +} + + + export function initArgparse(subparsers : argparse.SubParser) { const parser = subparsers.add_parser('server', { add_help: true, @@ -159,13 +211,17 @@ export function initArgparse(subparsers : argparse.SubParser) { default: 8400, }); parser.add_argument('--nlu-model', { - required: true, + required: false, help: "Path to the NLU model, pointing to a model directory.", }); parser.add_argument('--nlg-model', { required: false, help: "Path to the NLG model, pointing to a model directory.", }); + parser.add_argument('--translation-model', { + required: false, + help: "Path to the Translation model, pointing to a model directory.", + }); parser.add_argument('--thingpedia', { required: true, help: 'Path to ThingTalk file containing class definitions.' @@ -199,7 +255,9 @@ export async function execute(args : any) { tokenizer: i18n.getTokenizer(), nlu: new LocalParserClient(args.nlu_model, args.locale, undefined, undefined, tpClient) }; - app.backend.nlu.start(); + + if (args.nlu_model) + app.backend.nlu.start(); if (args.nlg_model && args.nlg_model !== args.nlu_model) { app.backend.nlg = new LocalParserClient(args.nlg_model, args.locale, undefined, undefined, tpClient); app.backend.nlg.start(); @@ -207,6 +265,11 @@ export async function execute(args : any) { app.backend.nlg = app.backend.nlu; } + if (args.translation_model) { + app.backend.translator = new LocalParserClient(args.translation_model, args.locale, undefined, undefined, tpClient); + app.backend.translator.start(); + } + app.args = args; app.set('port', args.port); @@ -227,6 +290,10 @@ export async function execute(args : any) { queryNLG(req.params, req.body, res).catch(next); }); + app.post('/:locale/translate', qv.validatePOST(Translation_PARAMS, { accept: 'application/json' }), (req, res, next) => { + queryTranslate(req.params, req.body, res).catch(next); + }); + app.post('/:locale/tokenize', qv.validatePOST({ q: 'string', entities: '?object' }, { accept: 'application/json' }), (req, res, next) => { tokenize(req.params, req.body, res).catch(next); }); @@ -246,8 +313,11 @@ export async function execute(args : any) { process.on('SIGTERM', resolve); }); - await app.backend.nlu.stop(); + if (app.backend.nlu) + await app.backend.nlu.stop(); if (app.backend.nlg !== app.backend.nlu) await app.backend.nlg.stop(); + if (app.backend.translator) + await app.backend.translator.stop(); server.close(); }