/** * Copyright (c) 2014-2017 by the respective copyright holders. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html */ package org.eclipse.smarthome.core.voice.text; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.ResourceBundle; import java.util.Set; import org.eclipse.smarthome.core.common.registry.RegistryChangeListener; import org.eclipse.smarthome.core.events.EventPublisher; import org.eclipse.smarthome.core.items.GroupItem; import org.eclipse.smarthome.core.items.Item; import org.eclipse.smarthome.core.items.ItemRegistry; import org.eclipse.smarthome.core.items.events.ItemEventFactory; import org.eclipse.smarthome.core.library.types.DecimalType; import org.eclipse.smarthome.core.library.types.StringType; import org.eclipse.smarthome.core.types.Command; import org.eclipse.smarthome.core.types.State; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * A human language command interpretation service. * * @author Tilman Kamp - Initial contribution and API * @author Kai Kreuzer - Improved error handling * */ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInterpreter { private static final String JSGF = "JSGF"; private static final Set<String> supportedGrammars = Collections.unmodifiableSet(Collections.singleton(JSGF)); private static final String OK = "ok"; private static final String SORRY = "sorry"; private static final String ERROR = "error"; private static final String STATE_CURRENT = "state_current"; private static final String STATE_ALREADY_SINGULAR = "state_already_singular"; private static final String MULTIPLE_OBJECTS = "multiple_objects"; private static final String NO_OBJECTS = "no_objects"; private static final String COMMAND_NOT_ACCEPTED = "command_not_accepted"; private static final String CMD = "cmd"; private static final String NAME = "name"; private static final String LANGUAGE_SUPPORT = "LanguageSupport"; private Logger logger = LoggerFactory.getLogger(AbstractRuleBasedInterpreter.class); private HashMap<Locale, ArrayList<Rule>> languageRules; private HashMap<Locale, HashSet<String>> allItemTokens = null; private HashMap<Locale, HashMap<Item, ArrayList<HashSet<String>>>> itemTokens = null; private ItemRegistry itemRegistry; private EventPublisher eventPublisher; private RegistryChangeListener<Item> registryChangeListener = new RegistryChangeListener<Item>() { @Override public void added(Item element) { invalidate(); } @Override public void removed(Item element) { invalidate(); } @Override public void updated(Item oldElement, Item element) { invalidate(); } }; /** * Called whenever the rules are to be (re)generated and added by {@link addRules} */ protected abstract void createRules(); @Override public String interpret(Locale locale, String text) throws InterpretationException { ResourceBundle language = ResourceBundle.getBundle(LANGUAGE_SUPPORT, locale); Rule[] rules = getRules(locale); if (language == null || rules.length == 0) { throw new InterpretationException( locale.getDisplayLanguage(Locale.ENGLISH) + " is not supported at the moment."); } TokenList tokens = new TokenList(tokenize(locale, text)); if (tokens.eof()) { throw new InterpretationException(language.getString(SORRY)); } InterpretationResult result; InterpretationResult lastResult = null; for (Rule rule : rules) { if ((result = rule.execute(language, tokens)).isSuccess()) { return result.getResponse(); } else { if (result != InterpretationResult.SYNTAX_ERROR) { lastResult = result; } } } if (lastResult == null) { throw new InterpretationException(language.getString(SORRY)); } else { throw lastResult.getException(); } } private void invalidate() { allItemTokens = null; itemTokens = null; languageRules = null; } /** * All the tokens (name parts) of the names of all the items in the {@link ItemRegistry}. * * @param locale The locale that is to be used for preparing the tokens. * @return the identifier tokens */ HashSet<String> getAllItemTokens(Locale locale) { if (allItemTokens == null) { allItemTokens = new HashMap<Locale, HashSet<String>>(); } HashSet<String> localeTokens = allItemTokens.get(locale); if (localeTokens == null) { allItemTokens.put(locale, localeTokens = new HashSet<String>()); for (Item item : itemRegistry.getAll()) { localeTokens.addAll(tokenize(locale, item.getLabel())); } } return localeTokens; } /** * Retrieves the list of identifier token sets per item currently contained in the {@link ItemRegistry}. * Each item entry in the resulting hash map will feature a list of different token sets. Each token set * represents one possible way "through" a chain of parent groups, where each groups tokenized name is * part of the set. * * @param locale The locale that is to be used for preparing the tokens. * @return the list of identifier token sets per item */ HashMap<Item, ArrayList<HashSet<String>>> getItemTokens(Locale locale) { if (itemTokens == null) { itemTokens = new HashMap<Locale, HashMap<Item, ArrayList<HashSet<String>>>>(); } HashMap<Item, ArrayList<HashSet<String>>> localeTokens = itemTokens.get(locale); if (localeTokens == null) { itemTokens.put(locale, localeTokens = new HashMap<Item, ArrayList<HashSet<String>>>()); for (Item item : itemRegistry.getItems()) { if (item.getGroupNames().isEmpty()) { addItem(locale, localeTokens, new HashSet<String>(), item); } } } return localeTokens; } private void addItem(Locale locale, HashMap<Item, ArrayList<HashSet<String>>> target, HashSet<String> tokens, Item item) { HashSet<String> nt = new HashSet<String>(tokens); nt.addAll(tokenize(locale, item.getLabel())); ArrayList<HashSet<String>> list = target.get(item); if (list == null) { target.put(item, list = new ArrayList<HashSet<String>>()); } list.add(nt); if (item instanceof GroupItem) { for (Item member : ((GroupItem) item).getMembers()) { addItem(locale, target, nt, member); } } } /** * Creates an item name placeholder expression. This expression is greedy: Only use it, if there are no other * expressions following this one. * It's safer to use {@link thingRule} instead. * * @return Expression that represents a name of an item. */ protected Expression name() { return name(null); } /** * Creates an item name placeholder expression. This expression is greedy: Only use it, if you are able to pass in * all possible stop tokens as excludes. * It's safer to use {@link thingRule} instead. * * @param stopper Stop expression that, if matching, will stop this expression from consuming further tokens. * @return Expression that represents a name of an item. */ protected Expression name(Expression stopper) { return tag(NAME, star(new ExpressionIdentifier(this, stopper))); } private HashMap<Locale, ArrayList<Rule>> getLanguageRules() { if (languageRules == null) { languageRules = new HashMap<Locale, ArrayList<Rule>>(); createRules(); } return languageRules; } /** * Retrieves all {@link Rule}s to a given {@link Locale}. It also retrieves all the same-language rules into greater * indexes of the array (lower match priority). * * @param locale Locale filter * @return Rules in descending match priority order. */ public Rule[] getRules(Locale locale) { HashMap<Locale, ArrayList<Rule>> lr = getLanguageRules(); ArrayList<Rule> rules = new ArrayList<Rule>(); HashSet<ArrayList<Rule>> ruleSets = new HashSet<ArrayList<Rule>>(); ArrayList<Rule> ruleSet = lr.get(locale); if (ruleSet != null) { ruleSets.add(ruleSet); rules.addAll(ruleSet); } String l = locale.getLanguage(); for (Locale rl : lr.keySet()) { if (rl.getLanguage().equals(l)) { ruleSet = lr.get(rl); if (!ruleSets.contains(ruleSet)) { ruleSets.add(ruleSet); rules.addAll(ruleSet); } } } return rules.toArray(new Rule[0]); } /** * Adds {@link Locale} specific rules to this interpreter. To be called from within {@link createRules}. * * @param locale Locale of the rules. * @param rules Rules to add. */ protected void addRules(Locale locale, Rule... rules) { ArrayList<Rule> ruleSet = languageRules.get(locale); if (ruleSet == null) { languageRules.put(locale, ruleSet = new ArrayList<Rule>()); } for (Rule rule : rules) { ruleSet.add(rule); } } /** * Creates an item rule on base of an expression, where the tail of the new rule's expression will consist of an * item * name expression. * * @param headExpression The head expression that should contain at least one {@link cmd} generated expression. The * corresponding {@link Command} will in case of a match be sent to the matching {@link Item}. * @return The created rule. */ protected Rule itemRule(Object headExpression) { return itemRule(headExpression, null); } /** * Creates an item rule on base of a head and a tail expression, where the middle part of the new rule's expression * will consist of an item * name expression. Either the head expression or the tail expression should contain at least one {@link cmd} * generated expression. * * @param headExpression The head expression. * @param tailExpression The tail expression. * @return The created rule. */ protected Rule itemRule(Object headExpression, Object tailExpression) { Expression tail = exp(tailExpression); Expression expression = tail == null ? seq(headExpression, name()) : seq(headExpression, name(tail), tail); return new Rule(expression) { @Override public InterpretationResult interpretAST(ResourceBundle language, ASTNode node) { String[] name = node.findValueAsStringArray(NAME); ASTNode cmdNode = node.findNode(CMD); Object tag = cmdNode.getTag(); Object value = cmdNode.getValue(); Command command; if (tag instanceof Command) { command = (Command) tag; } else if (value instanceof Number) { command = new DecimalType(((Number) value).longValue()); } else { command = new StringType(cmdNode.getValueAsString()); } if (name != null && command != null) { try { return new InterpretationResult(true, executeSingle(language, name, command)); } catch (InterpretationException ex) { return new InterpretationResult(ex); } } return InterpretationResult.SEMANTIC_ERROR; } }; } /** * Converts an object to an expression. Objects that are already instances of {@link Expression} are just returned. * All others are converted to {@link match} expressions. * * @param obj the object that's to be converted * @return resulting expression */ protected Expression exp(Object obj) { if (obj instanceof Expression) { return (Expression) obj; } else { return obj == null ? null : new ExpressionMatch(obj.toString()); } } /** * Converts all parameters to an expression array. Objects that are already instances of {@link Expression} are not * touched. * All others are converted to {@link match} expressions. * * @param obj the objects that are to be converted * @return resulting expression array */ protected Expression[] exps(Object... objects) { ArrayList<Expression> result = new ArrayList<Expression>(); for (int i = 0; i < objects.length; i++) { Expression e = exp(objects[i]); if (e != null) { result.add(e); } } return result.toArray(new Expression[0]); } /** * Adds a name to the resulting AST tree, if the given expression matches. * * @param name name to add * @param expression the expression that has to match * @return resulting expression */ protected Expression tag(String name, Object expression) { return tag(name, expression, null); } /** * Adds a value to the resulting AST tree, if the given expression matches. * * @param expression the expression that has to match * @param tag the tag that's to be set * @return resulting expression */ protected Expression tag(Object expression, Object tag) { return tag(null, expression, tag); } /** * Adds a name and a tag to the resulting AST tree, if the given expression matches. * * @param name name to add * @param expression the expression that has to match * @param tag the tag that's to be set * @return resulting expression */ protected Expression tag(String name, Object expression, Object tag) { return new ExpressionLet(name, exp(expression), null, tag); } /** * Adds a command to the resulting AST tree. If the expression evaluates to a * numeric value, it will get a {@link DecimalType}, otherwise a {@link StringType}. * * @param expression the expression that has to match * @return resulting expression */ protected Expression cmd(Object expression) { return cmd(expression, null); } /** * Adds a command to the resulting AST tree, if the expression matches. * * @param expression the expression that has to match * @param command the command that should be added * @return resulting expression */ protected Expression cmd(Object expression, Command command) { return tag(CMD, expression, command); } /** * Creates an alternatives expression. Matches, as soon as one of the given expressions matches. They are tested in * the provided order. The value of the matching expression will be used for the resulting nodes's value. * * @param expressions the expressions (alternatives) that are to be tested * @return resulting expression */ protected ExpressionAlternatives alt(Object... expressions) { return new ExpressionAlternatives(exps(expressions)); } /** * Creates a sequence expression. Matches, if all the given expressions match. They are tested in * the provided order. The resulting nodes's value will be an {@link Object[]} that contains all values of the * matching expressions. * * @param expressions the expressions (alternatives) that have to match in sequence * @return resulting expression */ protected ExpressionSequence seq(Object... expressions) { return new ExpressionSequence(exps(expressions)); } /** * Creates an optional expression. Always succeeds. The resulting nodes's value will be the one of the * matching expression or null. * * @param expression the optionally matching expression * @return resulting expression */ protected ExpressionCardinality opt(Object expression) { return new ExpressionCardinality(exp(expression), false, true); } /** * Creates a repeating expression that will match the given expression as often as possible. Always succeeds. The * resulting node's value will be an {@link Object[]} that contains all values of the * matches. * * @param expression the repeating expression * @return resulting expression */ protected ExpressionCardinality star(Object expression) { return new ExpressionCardinality(exp(expression), false, false); } /** * Creates a repeating expression that will match the given expression as often as possible. Only succeeds, if there * is at least one match. The resulting node's value will be an {@link Object[]} that contains all values of the * matches. * * @param expression the repeating expression * @return resulting expression */ protected ExpressionCardinality plus(Object expression) { return new ExpressionCardinality(exp(expression), true, false); } /** * Executes a command on one item that's to be found in the item registry by given name fragments. * Fails, if there is more than on item. * * @param language resource bundle used for producing localized response texts * @param labelFragments label fragments that are used to match an item's label. * For a positive match, the item's label has to contain every fragment - independently of their order. * They are treated case insensitive. * @param command command that should be executed * @return response text * @throws InterpretationException in case that there is no or more than on item matching the fragments */ protected String executeSingle(ResourceBundle language, String[] labelFragments, Command command) throws InterpretationException { ArrayList<Item> items = getMatchingItems(language, labelFragments, command.getClass()); if (items.size() < 1) { if (getMatchingItems(language, labelFragments, null).size() >= 1) { throw new InterpretationException( language.getString(COMMAND_NOT_ACCEPTED).replace("<cmd>", command.toString())); } else { throw new InterpretationException(language.getString(NO_OBJECTS)); } } else if (items.size() > 1) { throw new InterpretationException(language.getString(MULTIPLE_OBJECTS)); } else { Item item = items.get(0); if (command instanceof State) { try { State newState = (State) command; State oldState = item.getStateAs(newState.getClass()); if (oldState.equals(newState)) { String template = language.getString(STATE_ALREADY_SINGULAR); String cmdName = "state_" + command.toString().toLowerCase(); String stateText = null; try { stateText = language.getString(cmdName); } catch (Exception e) { stateText = language.getString(STATE_CURRENT); } return template.replace("<state>", stateText); } } catch (Exception ex) { logger.debug("Failed constructing response: {}", ex.getMessage()); return language.getString(ERROR); } } eventPublisher.post(ItemEventFactory.createCommandEvent(item.getName(), command)); return language.getString(OK); } } /** * Filters the item registry by matching each item's name with the provided name fragments. * The item's label and its parent group's labels are tokenizend {@link tokenize} and then altogether looked up * by each and every provided fragment. * For the item to get included into the result list, every provided fragment has to be found among the label * tokens. * If a command type is provided, the item also has to support it. * In case of channels and their owners being ambiguous due to sharing most of the label sequence, only the top * most item with support for the * given command type is kept. * * @param language Language information that is used for matching * @param labelFragments label fragments that are used to match an item's label. * For a positive match, the item's label has to contain every fragment - independently of their order. * They are treated case insensitive. * @param commandType optional command type that all items have to support. * Provide {null} if there is no need for a certain command to be supported. * @return All matching items from the item registry. */ protected ArrayList<Item> getMatchingItems(ResourceBundle language, String[] labelFragments, Class<?> commandType) { ArrayList<Item> items = new ArrayList<Item>(); HashMap<Item, ArrayList<HashSet<String>>> map = getItemTokens(language.getLocale()); for (Item item : map.keySet()) { for (HashSet<String> parts : map.get(item)) { boolean allMatch = true; for (String fragment : labelFragments) { if (!parts.contains(fragment.toLowerCase(language.getLocale()))) { allMatch = false; break; } } if (allMatch) { if (commandType == null || item.getAcceptedCommandTypes().contains(commandType)) { String name = item.getName(); boolean insert = true; for (Item si : items) { if (name.startsWith(si.getName())) { insert = false; } } if (insert) { for (int i = 0; i < items.size(); i++) { Item si = items.get(i); if (si.getName().startsWith(name)) { items.remove(i); i--; } } items.add(item); } } } } } return items; } /** * Tokenizes text. Filters out all unsupported punctuation. Tokens will be lower case. * * @param locale the locale that should be used for lower casing * @param text the text that should be tokenized * @return resulting tokens */ protected ArrayList<String> tokenize(Locale locale, String text) { ArrayList<String> parts = new ArrayList<String>(); if (text == null) { return parts; } String[] split; if ((locale != null) && locale.getLanguage().equalsIgnoreCase(Locale.FRENCH.getLanguage())) { split = text.toLowerCase(locale).replaceAll("[\\']", " ").replaceAll("[^\\w\\sàâäçéèêëîïôùûü]", " ") .split("\\s"); } else { split = text.toLowerCase(locale).replaceAll("[\\']", "").replaceAll("[^\\w\\s]", " ").split("\\s"); } for (int i = 0; i < split.length; i++) { String part = split[i].trim(); if (part.length() > 0) { parts.add(part); } } return parts; } @Override public Set<Locale> getSupportedLocales() { return Collections.unmodifiableSet(getLanguageRules().keySet()); } @Override public Set<String> getSupportedGrammarFormats() { return supportedGrammars; } /** * Helper class to generate a JSGF grammar from the rules of the interpreter. * * @author Tilman Kamp - Initial contribution and API * */ private class JSGFGenerator { private ResourceBundle language; private HashMap<Expression, Integer> ids = new HashMap<Expression, Integer>(); private HashSet<Expression> exported = new HashSet<Expression>(); private HashSet<Expression> shared = new HashSet<Expression>(); private int counter = 0; private HashSet<String> identifierExcludes = new HashSet<String>(); private HashSet<String> identifiers = new HashSet<String>(); private StringBuilder builder = new StringBuilder(); JSGFGenerator(ResourceBundle language) { this.language = language; } private void addChildren(Expression exp) { for (Expression se : exp.getChildExpressions()) { addExpression(se); } } private int addExpression(Expression exp) { if (ids.containsKey(exp)) { shared.add(exp); return ids.get(exp); } else { int id = counter++; ids.put(exp, id); addChildren(exp); return id; } } private int addExportedExpression(Expression exp) { shared.add(exp); exported.add(exp); int id = addExpression(exp); return id; } private Expression unwrapLet(Expression expression) { while (expression instanceof ExpressionLet) { expression = ((ExpressionLet) expression).getSubExpression(); } return expression; } private void emit(Object obj) { builder.append(obj); } private void emitName(Expression expression) { emit("r"); emit(ids.get(unwrapLet(expression))); } private void emitReference(Expression expression) { emit("<"); emitName(expression); emit(">"); } private void emitDefinition(Expression expression) { if (exported.contains(expression)) { emit("public "); } emit("<"); emitName(expression); emit("> = "); emitExpression(expression); emit(";\n\n"); } private void emitUse(Expression expression) { if (shared.contains(expression)) { emitReference(expression); } else { emitExpression(expression); } } private void emitExpression(Expression expression) { expression = unwrapLet(expression); if (expression instanceof ExpressionMatch) { emitMatchExpression((ExpressionMatch) expression); } else if (expression instanceof ExpressionSequence) { emitSequenceExpression((ExpressionSequence) expression); } else if (expression instanceof ExpressionAlternatives) { emitAlternativesExpression((ExpressionAlternatives) expression); } else if (expression instanceof ExpressionCardinality) { emitCardinalExpression((ExpressionCardinality) expression); } else if (expression instanceof ExpressionIdentifier) { emitItemIdentifierExpression((ExpressionIdentifier) expression); } } private void emitMatchExpression(ExpressionMatch expression) { emit(expression.getPattern()); } private void emitSequenceExpression(ExpressionSequence expression) { emitGroup(" ", expression.getChildExpressions()); } private void emitAlternativesExpression(ExpressionAlternatives expression) { emitGroup(" | ", expression.getChildExpressions()); } private void emitCardinalExpression(ExpressionCardinality expression) { if (!expression.isAtLeastOne() && !expression.isAtMostOne()) { emitUse(expression.getSubExpression()); emit("*"); } else if (expression.isAtLeastOne()) { emitUse(expression.getSubExpression()); emit("+"); } else if (expression.isAtMostOne()) { emit("["); emitUse(expression.getSubExpression()); emit("]"); } else { emitUse(expression.getSubExpression()); } } private void emitItemIdentifierExpression(ExpressionIdentifier expression) { HashSet<String> remainder = new HashSet<String>(identifierExcludes); Expression stopper = expression.getStopper(); HashSet<String> excludes = stopper == null ? new HashSet<String>() : stopper.getFirsts(language); if (excludes.size() > 0) { remainder.removeAll(excludes); if (remainder.size() > 0) { emit("("); } emit("<idbase>"); for (String token : remainder) { emit(" | "); emit(token); } if (remainder.size() > 0) { emit(")"); } } else { emit("<idpart>"); } } private void emitGroup(String separator, List<Expression> expressions) { int l = expressions.size(); if (l > 0) { emit("("); } for (int i = 0; i < l; i++) { if (i > 0) { emit(separator); } emitUse(expressions.get(i)); } if (l > 0) { emit(")"); } } private void emitSet(HashSet<String> set, String separator) { boolean sep = false; for (String p : set) { if (sep) { emit(separator); } else { sep = true; } emit(p); } } String getGrammar() { Rule[] rules = getRules(language.getLocale()); identifiers.addAll(getAllItemTokens(language.getLocale())); for (Rule rule : rules) { Expression e = rule.getExpression(); addExportedExpression(e); } for (Expression e : ids.keySet()) { if (e instanceof ExpressionIdentifier) { Expression stopper = ((ExpressionIdentifier) e).getStopper(); if (stopper != null) { identifierExcludes.addAll(stopper.getFirsts(language)); } } } emit("#JSGF V1.0;\n\n"); if (identifierExcludes.size() > 0) { HashSet<String> identifierBase = new HashSet<String>(identifiers); identifierBase.removeAll(identifierExcludes); emit("<idbase> = "); emitSet(identifierBase, " | "); emit(";\n\n<idpart> = <idbase> | "); emitSet(identifierExcludes, " | "); emit(";\n\n"); } else { emit("<idpart> = "); emitSet(identifiers, " | "); emit(";\n\n"); } for (Expression e : shared) { emitDefinition(e); } String grammar = builder.toString(); return grammar; } } @Override public String getGrammar(Locale locale, String format) { if (format != JSGF) { return null; } JSGFGenerator generator = new JSGFGenerator(ResourceBundle.getBundle(LANGUAGE_SUPPORT, locale)); return generator.getGrammar(); } public void setItemRegistry(ItemRegistry itemRegistry) { if (this.itemRegistry == null) { this.itemRegistry = itemRegistry; this.itemRegistry.addRegistryChangeListener(registryChangeListener); } } public void unsetItemRegistry(ItemRegistry itemRegistry) { if (itemRegistry == this.itemRegistry) { this.itemRegistry.removeRegistryChangeListener(registryChangeListener); this.itemRegistry = null; } } public void setEventPublisher(EventPublisher eventPublisher) { if (this.eventPublisher == null) { this.eventPublisher = eventPublisher; } } public void unsetEventPublisher(EventPublisher eventPublisher) { if (eventPublisher == this.eventPublisher) { this.eventPublisher = null; } } }