[voice] Support custom rules on item metadata (#3897)

* [voice] Support custom rules on item metadata
* fix isTemplate functionality and test
* fix filter location based for non labeled rules

Signed-off-by: Miguel Álvarez <miguelwork92@gmail.com>
This commit is contained in:
GiviMAD 2023-12-05 22:18:44 -08:00 committed by GitHub
parent 76b10ac1c1
commit 075bcea8c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 613 additions and 171 deletions

View File

@ -64,9 +64,9 @@ import org.slf4j.LoggerFactory;
@NonNullByDefault @NonNullByDefault
@Component(service = HumanLanguageInterpreter.class) @Component(service = HumanLanguageInterpreter.class)
public class StandardInterpreter extends AbstractRuleBasedInterpreter { public class StandardInterpreter extends AbstractRuleBasedInterpreter {
public static final String VOICE_SYSTEM_NAMESPACE = "voice-system";
private Logger logger = LoggerFactory.getLogger(StandardInterpreter.class); private Logger logger = LoggerFactory.getLogger(StandardInterpreter.class);
private final ItemRegistry itemRegistry; private final ItemRegistry itemRegistry;
private final String metadataNamespace = "voice-system";
private final MetadataRegistry metadataRegistry; private final MetadataRegistry metadataRegistry;
@Activate @Activate
@ -164,7 +164,7 @@ public class StandardInterpreter extends AbstractRuleBasedInterpreter {
); );
/* Item description commands */ /* Item description commands */
addRules(Locale.ENGLISH, createItemDescriptionRules( // addRules(Locale.ENGLISH, createItemDescriptionRules( //
(allowedItemNames, labeledCmd) -> restrictedItemRule(allowedItemNames, // (allowedItems, labeledCmd) -> restrictedItemRule(allowedItems, //
seq(alt("set", "change"), opt(the)), /* item */ seq(to, labeledCmd)// seq(alt("set", "change"), opt(the)), /* item */ seq(to, labeledCmd)//
), // ), //
Locale.ENGLISH).toArray(Rule[]::new)); Locale.ENGLISH).toArray(Rule[]::new));
@ -233,7 +233,7 @@ public class StandardInterpreter extends AbstractRuleBasedInterpreter {
/* Item description commands */ /* Item description commands */
addRules(Locale.GERMAN, createItemDescriptionRules( // addRules(Locale.GERMAN, createItemDescriptionRules( //
(allowedItemNames, labeledCmd) -> restrictedItemRule(allowedItemNames, // (allowedItems, labeledCmd) -> restrictedItemRule(allowedItems, //
seq(schalte, denDieDas), /* item */ seq(opt("auf"), labeledCmd)// seq(schalte, denDieDas), /* item */ seq(opt("auf"), labeledCmd)//
), // ), //
Locale.GERMAN).toArray(Rule[]::new)); Locale.GERMAN).toArray(Rule[]::new));
@ -301,7 +301,7 @@ public class StandardInterpreter extends AbstractRuleBasedInterpreter {
/* Item description commands */ /* Item description commands */
addRules(Locale.FRENCH, createItemDescriptionRules( // addRules(Locale.FRENCH, createItemDescriptionRules( //
(allowedItemNames, labeledCmd) -> restrictedItemRule(allowedItemNames, // (allowedItems, labeledCmd) -> restrictedItemRule(allowedItems, //
seq("mets", lela), /* item */ seq(poursurdude, lela, labeledCmd)// seq("mets", lela), /* item */ seq(poursurdude, lela, labeledCmd)//
), // ), //
Locale.FRENCH).toArray(Rule[]::new)); Locale.FRENCH).toArray(Rule[]::new));
@ -351,11 +351,18 @@ public class StandardInterpreter extends AbstractRuleBasedInterpreter {
/* NextPreviousType */ /* NextPreviousType */
itemRule(cambiar, itemRule(seq(cambiar, opt(articulo)),
/* item */ seq(opt("a"), /* item */ seq(opt("a"),
alt(cmd("siguiente", NextPreviousType.NEXT), alt(cmd("siguiente", NextPreviousType.NEXT),
cmd("anterior", NextPreviousType.PREVIOUS)))), cmd("anterior", NextPreviousType.PREVIOUS)))),
itemRule(
seq(opt(poner),
alt(cmd("siguiente", NextPreviousType.NEXT),
cmd("anterior", NextPreviousType.PREVIOUS)),
"en"),
opt(articulo) /* item */ ),
/* PlayPauseType */ /* PlayPauseType */
itemRule(seq(cmd(alt("continuar", "continúa", "reanudar", "reanuda", "play"), PlayPauseType.PLAY), itemRule(seq(cmd(alt("continuar", "continúa", "reanudar", "reanuda", "play"), PlayPauseType.PLAY),
@ -392,7 +399,7 @@ public class StandardInterpreter extends AbstractRuleBasedInterpreter {
/* Item description commands */ /* Item description commands */
addRules(localeES, createItemDescriptionRules( // addRules(localeES, createItemDescriptionRules( //
(allowedItemNames, labeledCmd) -> restrictedItemRule(allowedItemNames, // (allowedItems, labeledCmd) -> restrictedItemRule(allowedItems, //
seq(alt(cambiar, poner), opt(articulo)), /* item */ seq(preposicion, labeledCmd)// seq(alt(cambiar, poner), opt(articulo)), /* item */ seq(preposicion, labeledCmd)//
), // ), //
localeES).toArray(Rule[]::new)); localeES).toArray(Rule[]::new));
@ -409,12 +416,12 @@ public class StandardInterpreter extends AbstractRuleBasedInterpreter {
return "Built-in Interpreter"; return "Built-in Interpreter";
} }
private List<Rule> createItemDescriptionRules(CreateItemDescriptionRule creator, @Nullable Locale locale) { private List<Rule> createItemDescriptionRules(CreateItemDescriptionRule creator, Locale locale) {
// Map different item state/command labels with theirs values by item // Map different item state/command labels with theirs values by item
HashMap<String, HashMap<Item, String>> options = new HashMap<>(); HashMap<String, HashMap<Item, String>> options = new HashMap<>();
List<Rule> customRules = new ArrayList<>(); List<Rule> customRules = new ArrayList<>();
for (var item : itemRegistry.getItems()) { for (var item : itemRegistry.getItems()) {
customRules.addAll(createItemCustomRules(item)); customRules.addAll(createItemMetadataRules(locale, item));
var stateDesc = item.getStateDescription(locale); var stateDesc = item.getStateDescription(locale);
if (stateDesc != null) { if (stateDesc != null) {
stateDesc.getOptions().forEach(op -> { stateDesc.getOptions().forEach(op -> {
@ -445,34 +452,28 @@ public class StandardInterpreter extends AbstractRuleBasedInterpreter {
.map(entry -> { .map(entry -> {
String label = entry.getKey(); String label = entry.getKey();
Map<Item, String> commandByItem = entry.getValue(); Map<Item, String> commandByItem = entry.getValue();
List<String> itemNames = commandByItem.keySet().stream().map(Item::getName).toList();
String[] labelParts = Arrays.stream(label.split("\\s")).filter(p -> !p.isBlank()) String[] labelParts = Arrays.stream(label.split("\\s")).filter(p -> !p.isBlank())
.toArray(String[]::new); .toArray(String[]::new);
Expression labeledCmd = cmd(seq((Object[]) labelParts), Expression labeledCmd = cmd(seq((Object[]) labelParts),
new ItemStateCommandSupplier(label, commandByItem)); new ItemStateCommandSupplier(label, commandByItem));
return creator.itemDescriptionRule(itemNames, labeledCmd); return creator.itemDescriptionRule(commandByItem.keySet(), labeledCmd);
})) // })) //
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
private List<Rule> createItemCustomRules(Item item) { private List<Rule> createItemMetadataRules(Locale locale, Item item) {
var interpreterMetadata = metadataRegistry.get(new MetadataKey(metadataNamespace, item.getName())); var interpreterMetadata = metadataRegistry.get(new MetadataKey(VOICE_SYSTEM_NAMESPACE, item.getName()));
if (interpreterMetadata == null) { if (interpreterMetadata == null) {
return List.of(); return List.of();
} }
List<Rule> list = new ArrayList<>(); return Arrays.stream(interpreterMetadata.getValue().split("\n")) //
for (String s : interpreterMetadata.getValue().split("\n")) { .map(line -> this.parseItemCustomRules(locale, item, line.trim(), interpreterMetadata)) //
String line = s.trim(); .flatMap(List::stream) //
Rule rule = this.parseItemCustomRule(item, line); .collect(Collectors.toList());
if (rule != null) {
list.add(rule);
}
}
return list;
} }
private interface CreateItemDescriptionRule { private interface CreateItemDescriptionRule {
Rule itemDescriptionRule(List<String> allowedItemNames, Expression labeledCmd); Rule itemDescriptionRule(Set<Item> allowedItemNames, Expression labeledCmd);
} }
private record ItemStateCommandSupplier(String label, private record ItemStateCommandSupplier(String label,

View File

@ -21,14 +21,17 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Map.Entry; import java.util.Map.Entry;
import java.util.Objects;
import java.util.ResourceBundle; import java.util.ResourceBundle;
import java.util.Set; import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNull; import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.common.registry.RegistryChangeListener; import org.openhab.core.common.registry.RegistryChangeListener;
import org.openhab.core.config.core.ConfigParser;
import org.openhab.core.events.EventPublisher; import org.openhab.core.events.EventPublisher;
import org.openhab.core.items.GroupItem; import org.openhab.core.items.GroupItem;
import org.openhab.core.items.Item; import org.openhab.core.items.Item;
@ -73,15 +76,43 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
private static final String CMD = "cmd"; private static final String CMD = "cmd";
private static final String NAME = "name"; private static final String NAME = "name";
private static final String VALUE = "name"; private static final String VALUE = "value";
public static final String IS_TEMPLATE_CONFIGURATION = "isTemplate";
public static final String IS_SILENT_CONFIGURATION = "isSilent";
/**
* Reserved token to use in custom item rules.
* Represents the name place in the phrase.
*/
private static final String NAME_TOKEN = "$name$";
/**
* Reserved token to use in custom item rules.
* Represents the command place in the phrase,
* which possible values are defined by the command description
* of the item registering these rule.
*/
private static final String CMD_TOKEN = "$cmd$";
/**
* Reserved token to use in custom item rules.
* Represents the command place in the phrase,
* allows capturing multiple tokens.
*/
private static final String DYN_CMD_TOKEN = "$*$";
/**
* Set of reserved tokens in custom item rules.
*/
private static final Set<String> CUSTOM_RULE_TOKENS = Set.of(NAME_TOKEN, CMD_TOKEN, DYN_CMD_TOKEN);
private static final String LANGUAGE_SUPPORT = "LanguageSupport"; private static final String LANGUAGE_SUPPORT = "LanguageSupport";
private static final String SYNONYMS_NAMESPACE = "synonyms"; private static final String SYNONYMS_NAMESPACE = "synonyms";
private static final String SEMANTICS_NAMESPACE = "semantics";
private final MetadataRegistry metadataRegistry; private final MetadataRegistry metadataRegistry;
private Logger logger = LoggerFactory.getLogger(AbstractRuleBasedInterpreter.class); private final Logger logger = LoggerFactory.getLogger(AbstractRuleBasedInterpreter.class);
private final Map<Locale, List<Rule>> languageRules = new HashMap<>(); private final Map<Locale, List<Rule>> languageRules = new HashMap<>();
private final Map<Locale, Set<String>> allItemTokens = new HashMap<>(); private final Map<Locale, Set<String>> allItemTokens = new HashMap<>();
@ -90,7 +121,7 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
private final ItemRegistry itemRegistry; private final ItemRegistry itemRegistry;
private final EventPublisher eventPublisher; private final EventPublisher eventPublisher;
private RegistryChangeListener<Item> registryChangeListener = new RegistryChangeListener<Item>() { private final RegistryChangeListener<Item> registryChangeListener = new RegistryChangeListener<>() {
@Override @Override
public void added(Item element) { public void added(Item element) {
invalidate(); invalidate();
@ -106,7 +137,7 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
invalidate(); invalidate();
} }
}; };
private RegistryChangeListener<Metadata> synonymsChangeListener = new RegistryChangeListener<Metadata>() { private final RegistryChangeListener<Metadata> synonymsChangeListener = new RegistryChangeListener<>() {
@Override @Override
public void added(Metadata element) { public void added(Metadata element) {
invalidateIfSynonymsMetadata(element); invalidateIfSynonymsMetadata(element);
@ -178,11 +209,10 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
} }
} }
} }
if (lastResult == null) { if (lastResult != null && lastResult.getException() != null) {
throw new InterpretationException(language.getString(SORRY));
} else {
throw lastResult.getException(); throw lastResult.getException();
} }
throw new InterpretationException(language.getString(SORRY));
} }
private void invalidate() { private void invalidate() {
@ -288,6 +318,17 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
return tag(NAME, star(new ExpressionIdentifier(this, stopper))); return tag(NAME, star(new ExpressionIdentifier(this, stopper)));
} }
/**
* Creates an item value 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 #itemRule} instead.
*
* @return Expression that represents a name of an item.
*/
private Expression value() {
return value(null);
}
/** /**
* Creates an item value placeholder expression. This expression is greedy: Only use it, if you are able to pass in * Creates an item value placeholder expression. This expression is greedy: Only use it, if you are able to pass in
* all possible stop tokens as excludes. * all possible stop tokens as excludes.
@ -343,13 +384,8 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
* @param rules Rules to add. * @param rules Rules to add.
*/ */
protected void addRules(Locale locale, Rule... rules) { protected void addRules(Locale locale, Rule... rules) {
List<Rule> ruleSet = languageRules.get(locale); List<Rule> ruleSet = languageRules.computeIfAbsent(locale, k -> new ArrayList<>());
if (ruleSet == null) { ruleSet.addAll(Arrays.asList(rules));
languageRules.put(locale, ruleSet = new ArrayList<>());
}
for (Rule rule : rules) {
ruleSet.add(rule);
}
} }
/** /**
@ -376,7 +412,23 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
* @return The created rule. * @return The created rule.
*/ */
protected Rule itemRule(Object headExpression, @Nullable Object tailExpression) { protected Rule itemRule(Object headExpression, @Nullable Object tailExpression) {
return restrictedItemRule(List.of(), headExpression, tailExpression); return restrictedItemRule(ItemFilter.all(), headExpression, tailExpression, false);
}
/**
* 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 allowedItems Allowed item targets.
* @param headExpression The head expression.
* @param tailExpression The tail expression.
* @return The created rule.
*/
protected Rule restrictedItemRule(Set<Item> allowedItems, Object headExpression, @Nullable Object tailExpression) {
return restrictedItemRule(ItemFilter.forItems(allowedItems), headExpression, tailExpression, false);
} }
/** /**
@ -384,18 +436,18 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
* will consist of an item * will consist of an item
* name expression. Either the head expression or the tail expression should contain at least one {@link #cmd} * name expression. Either the head expression or the tail expression should contain at least one {@link #cmd}
* generated expression. * generated expression.
* Rule will be restricted to the provided item names if any. * Rule will be restricted by the provided filter.
* *
* @param allowedItemNames List of allowed item names, empty for disabled. * @param itemFilter Filters allowed items.
* @param headExpression The head expression. * @param headExpression The head expression.
* @param tailExpression The tail expression. * @param tailExpression The tail expression.
* @return The created rule. * @return The created rule.
*/ */
protected Rule restrictedItemRule(List<String> allowedItemNames, Object headExpression, protected Rule restrictedItemRule(ItemFilter itemFilter, Object headExpression, @Nullable Object tailExpression,
@Nullable Object tailExpression) { boolean isSilent) {
Expression tail = exp(tailExpression); Expression tail = exp(tailExpression);
Expression expression = tail == null ? seq(headExpression, name()) : seq(headExpression, name(tail), tail); Expression expression = tail == null ? seq(headExpression, name()) : seq(headExpression, name(tail), tail);
return new Rule(expression, allowedItemNames) { return new Rule(expression, itemFilter, isSilent) {
@Override @Override
public InterpretationResult interpretAST(ResourceBundle language, ASTNode node, public InterpretationResult interpretAST(ResourceBundle language, ASTNode node,
InterpretationContext context) { InterpretationContext context) {
@ -424,11 +476,54 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
} }
/** /**
* Creates a custom rule on base of a head and a tail expression, where the middle part of the new rule's * Creates an item rule which two dynamic capture values on base of a head a middle and an optional tail expression,
* expression * where one of the values is an item name expression and the other a free captured value.
* will consist of a free command to be captured. Either the head expression or the tail expression should contain * Rule will be restricted by the provided filter.
* at least one {@link #cmd} *
* generated expression. * @param item Item registering the rule.
* @param itemFilter Filters allowed items.
* @param headExpression The head expression.
* @param midExpression The middle expression.
* @param tailExpression The optional tail expression.
* @param isNameFirst Indicates whether the name goes between the head and the middle expressions.
* @return The created rule.
*/
protected Rule restrictedDynamicItemRule(Item item, ItemFilter itemFilter, Object headExpression,
Object midExpression, @Nullable Object tailExpression, boolean isNameFirst, boolean isSilent) {
Expression head = Objects.requireNonNull(exp(headExpression));
Expression mid = Objects.requireNonNull(exp(midExpression));
@Nullable
Expression tail = exp(tailExpression);
Expression firstValue = isNameFirst ? name(mid) : value(mid);
Expression secondValue = tail != null ? (isNameFirst ? value(tail) : name(tail))
: (isNameFirst ? value() : name());
Expression expression = tail == null ? //
seq(head, firstValue, mid, secondValue) : //
seq(head, firstValue, mid, secondValue, tail);
Map<String, String> itemValuesByLabel = getItemValuesByLabel(item);
return new Rule(expression, itemFilter, isSilent) {
@Override
public InterpretationResult interpretAST(ResourceBundle language, ASTNode node,
InterpretationContext context) {
String[] name = node.findValueAsStringArray(NAME);
String[] value = node.findValueAsStringArray(VALUE);
if (name != null && value != null) {
try {
ItemCommandSupplier commandSupplier = new TextCommandSupplier(String.join(" ", value),
item.getAcceptedCommandTypes(), itemValuesByLabel);
return new InterpretationResult(true, executeSingle(language, name, commandSupplier, context));
} catch (InterpretationException ex) {
return new InterpretationResult(ex);
}
}
return InterpretationResult.SEMANTIC_ERROR;
}
};
}
/**
* Creates a custom rule on base of a head and a tail expression,
* where the middle part of the new rule's expression will consist of a free command to be captured.
* Rule will be restricted to the provided item name. * Rule will be restricted to the provided item name.
* *
* @param item Item target * @param item Item target
@ -436,11 +531,32 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
* @param tailExpression The tail expression. * @param tailExpression The tail expression.
* @return The created rule. * @return The created rule.
*/ */
protected Rule customItemRule(Item item, Object headExpression, @Nullable Object tailExpression) { protected Rule customDynamicRule(Item item, ItemFilter itemFilter, Object headExpression,
@Nullable Object tailExpression, boolean isSilent) {
Expression tail = exp(tailExpression); Expression tail = exp(tailExpression);
Expression expression = tail == null ? seq(headExpression, value(null)) Expression expression = tail == null ? seq(headExpression, value(null))
: seq(headExpression, value(tail), tail); : seq(headExpression, value(tail), tail);
HashMap<String, String> valuesByLabel = getItemValuesByLabel(item);
return new Rule(expression, itemFilter, isSilent) {
@Override
public InterpretationResult interpretAST(ResourceBundle language, ASTNode node,
InterpretationContext context) {
String[] commandParts = node.findValueAsStringArray(VALUE);
if (commandParts != null && commandParts.length > 0) {
try {
ItemCommandSupplier commandSupplier = new TextCommandSupplier(
String.join(" ", commandParts).trim(), item.getAcceptedCommandTypes(), valuesByLabel);
return new InterpretationResult(true, executeCustom(language, commandSupplier, context));
} catch (InterpretationException ex) {
return new InterpretationResult(ex);
}
}
return InterpretationResult.SEMANTIC_ERROR;
}
};
}
private HashMap<String, String> getItemValuesByLabel(Item item) {
HashMap<String, String> valuesByLabel = new HashMap<>(); HashMap<String, String> valuesByLabel = new HashMap<>();
var stateDescription = item.getStateDescription(); var stateDescription = item.getStateDescription();
if (stateDescription != null) { if (stateDescription != null) {
@ -460,20 +576,38 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
} }
}); });
} }
return new Rule(expression, List.of(item.getName())) { return valuesByLabel;
}
/**
* Creates a custom rule on base of a expression.
* The expression should contain at least one {@link #cmd} generated expression.
*
* @param itemFilter Filters the allowed items.
* @param cmdExpression The expression.
* @return The created rule.
*/
protected Rule customCommandRule(ItemFilter itemFilter, Object cmdExpression, boolean isSilent) {
return new Rule(Objects.requireNonNull(exp(cmdExpression)), itemFilter, isSilent) {
@Override @Override
public InterpretationResult interpretAST(ResourceBundle language, ASTNode node, public InterpretationResult interpretAST(ResourceBundle language, ASTNode node,
InterpretationContext context) { InterpretationContext context) {
String[] commandParts = node.findValueAsStringArray(VALUE); ASTNode cmdNode = node.findNode(CMD);
if (commandParts != null && commandParts.length > 0) { Object tag = cmdNode.getTag();
try { Object value = cmdNode.getValue();
return new InterpretationResult(true, ItemCommandSupplier commandSupplier;
executeCustom(language, item, String.join(" ", commandParts).trim(), valuesByLabel)); if (tag instanceof ItemCommandSupplier supplier) {
} catch (InterpretationException ex) { commandSupplier = supplier;
return new InterpretationResult(ex); } else if (value instanceof Number number) {
} commandSupplier = new SingleCommandSupplier(new DecimalType(number.longValue()));
} else {
commandSupplier = new SingleCommandSupplier(new StringType(cmdNode.getValueAsString()));
}
try {
return new InterpretationResult(true, executeCustom(language, commandSupplier, context));
} catch (InterpretationException ex) {
return new InterpretationResult(ex);
} }
return InterpretationResult.SEMANTIC_ERROR;
} }
}; };
} }
@ -669,36 +803,50 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
logger.warn("Failed resolving item command"); logger.warn("Failed resolving item command");
return language.getString(ERROR); return language.getString(ERROR);
} }
return trySendCommand(language, item, command); return trySendCommand(language, item, command, context.isSilent());
} }
} }
/** /**
* Executes a custom rule command. * Executes a custom rule command.
* *
* @param item the rule target.
* @param options replacement values from item description.
* @param language resource bundle used for producing localized response texts * @param language resource bundle used for producing localized response texts
* @param commandText label fragments that are used to match an item's label. * @param itemCommandSupplier the rule command supplier.
* For a positive match, the item's label has to contain every fragment - independently of their order. * @param context to propagate the interpretation context.
* They are treated case-insensitive.
* @return response text * @return response text
* @throws InterpretationException in case that there is no or more than on item matching the fragments * @throws InterpretationException in case that there is no or more than on item matching the fragments
*/ */
protected String executeCustom(ResourceBundle language, Item item, String commandText, protected String executeCustom(ResourceBundle language, ItemCommandSupplier itemCommandSupplier,
HashMap<String, String> options) throws InterpretationException { Rule.InterpretationContext context) throws InterpretationException {
@Nullable Map<Item, ItemInterpretationMetadata> itemsMap = getItemTokens(language.getLocale());
String commandReplacement = options.get(commandText); Set<Entry<Item, ItemInterpretationMetadata>> compatibleItemEntries = itemsMap.entrySet().stream() //
Command command = TypeParser.parseCommand(item.getAcceptedCommandTypes(), .filter(itemEntry -> context.itemFilter().filterItem(itemEntry.getKey(), metadataRegistry)) //
commandReplacement != null ? commandReplacement : commandText); .collect(Collectors.toSet());
if (compatibleItemEntries.isEmpty()) {
throw new InterpretationException(language.getString(NO_OBJECTS));
}
if (compatibleItemEntries.size() > 1 && context.locationItem() != null) {
var filteredCompatibleEntries = compatibleItemEntries.stream() //
.filter(itemEntry -> itemEntry.getValue().locationParentNames.contains(context.locationItem())) //
.collect(Collectors.toSet());
if (filteredCompatibleEntries.size() == 1) {
logger.debug("Collision resolved based on location context");
compatibleItemEntries = filteredCompatibleEntries;
}
}
if (compatibleItemEntries.size() > 1) {
throw new InterpretationException(language.getString(MULTIPLE_OBJECTS));
}
Item item = compatibleItemEntries.stream().map(Entry::getKey).findFirst().get();
Command command = itemCommandSupplier.getItemCommand(item);
if (command == null) { if (command == null) {
logger.warn("Failed creating command for {} from {}", item, commandText); logger.warn("Failed creating command");
return language.getString(ERROR); return language.getString(ERROR);
} }
return trySendCommand(language, item, command); return trySendCommand(language, item, command, context.isSilent());
} }
private String trySendCommand(ResourceBundle language, Item item, Command command) { private String trySendCommand(ResourceBundle language, Item item, Command command, boolean isSilent) {
if (command instanceof State newState) { if (command instanceof State newState) {
try { try {
State oldState = item.getStateAs(newState.getClass()); State oldState = item.getStateAs(newState.getClass());
@ -719,7 +867,7 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
} }
} }
eventPublisher.post(ItemEventFactory.createCommandEvent(item.getName(), command)); eventPublisher.post(ItemEventFactory.createCommandEvent(item.getName(), command));
return language.getString(OK); return !isSilent ? language.getString(OK) : "";
} }
/** /**
@ -745,31 +893,40 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
@Nullable ItemCommandSupplier commandSupplier, Rule.InterpretationContext context) { @Nullable ItemCommandSupplier commandSupplier, Rule.InterpretationContext context) {
Map<Item, ItemInterpretationMetadata> itemsData = new HashMap<>(); Map<Item, ItemInterpretationMetadata> itemsData = new HashMap<>();
Map<Item, ItemInterpretationMetadata> exactMatchItemsData = new HashMap<>(); Map<Item, ItemInterpretationMetadata> exactMatchItemsData = new HashMap<>();
Map<Item, ItemInterpretationMetadata> exactMatchOnTargetItemsData = new HashMap<>();
Map<Item, ItemInterpretationMetadata> map = getItemTokens(language.getLocale()); Map<Item, ItemInterpretationMetadata> map = getItemTokens(language.getLocale());
for (Entry<Item, ItemInterpretationMetadata> entry : map.entrySet()) { for (Entry<Item, ItemInterpretationMetadata> entry : map.entrySet()) {
Item item = entry.getKey(); Item item = entry.getKey();
ItemInterpretationMetadata interpretationMetadata = entry.getValue(); ItemInterpretationMetadata interpretationMetadata = entry.getValue();
if (!context.allowedItems().isEmpty() && !context.allowedItems().contains(item.getName())) { if (!context.itemFilter().filterItem(item, metadataRegistry)) {
logger.trace("Item {} discarded, not allowed for this rule", item.getName()); logger.trace("Item {} discarded, not allowed for this rule", item.getName());
continue; continue;
} }
for (List<List<String>> itemLabelFragmentsPath : interpretationMetadata.pathToItem) { for (List<List<String>> itemLabelFragmentsPath : interpretationMetadata.pathToItem) {
boolean exactMatch = false; boolean exactMatch = false;
boolean exactMatchOnTarget = false;
logger.trace("Checking tokens {} against the item tokens {}", labelFragments, itemLabelFragmentsPath); logger.trace("Checking tokens {} against the item tokens {}", labelFragments, itemLabelFragmentsPath);
List<String> lowercaseLabelFragments = Arrays.stream(labelFragments) List<String> lowercaseLabelFragments = Arrays.stream(labelFragments)
.map(lf -> lf.toLowerCase(language.getLocale())).toList(); .map(lf -> lf.toLowerCase(language.getLocale())).toList();
List<String> unmatchedFragments = new ArrayList<>(lowercaseLabelFragments); List<String> unmatchedFragments = new ArrayList<>(lowercaseLabelFragments);
for (List<String> itemLabelFragments : itemLabelFragmentsPath) { if (itemLabelFragmentsPath.get(itemLabelFragmentsPath.size() - 1).equals(lowercaseLabelFragments)) {
if (itemLabelFragments.equals(lowercaseLabelFragments)) { exactMatch = true;
exactMatch = true; exactMatchOnTarget = true;
unmatchedFragments.clear(); unmatchedFragments.clear();
break; } else {
for (List<String> itemLabelFragments : itemLabelFragmentsPath) {
if (itemLabelFragments.equals(lowercaseLabelFragments)) {
exactMatch = true;
unmatchedFragments.clear();
break;
}
unmatchedFragments.removeAll(itemLabelFragments);
} }
unmatchedFragments.removeAll(itemLabelFragments);
} }
boolean allMatched = unmatchedFragments.isEmpty(); boolean allMatched = unmatchedFragments.isEmpty();
logger.trace("Matched: {}", allMatched); logger.trace("Matched: {}", allMatched);
logger.trace("Exact match: {}", exactMatch); logger.trace("Exact match: {}", exactMatch);
logger.trace("Exact match on target: {}", exactMatchOnTarget);
if (allMatched) { if (allMatched) {
List<Class<? extends Command>> commandTypes = commandSupplier != null List<Class<? extends Command>> commandTypes = commandSupplier != null
? commandSupplier.getCommandClasses(null) ? commandSupplier.getCommandClasses(null)
@ -780,11 +937,14 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
if (exactMatch) { if (exactMatch) {
insertDiscardingMembers(exactMatchItemsData, item, interpretationMetadata); insertDiscardingMembers(exactMatchItemsData, item, interpretationMetadata);
} }
if (exactMatchOnTarget) {
insertDiscardingMembers(exactMatchOnTargetItemsData, item, interpretationMetadata);
}
} }
} }
} }
} }
if (logger.isDebugEnabled()) { if (logger.isTraceEnabled()) {
List<Class<? extends Command>> commandTypes = commandSupplier != null List<Class<? extends Command>> commandTypes = commandSupplier != null
? commandSupplier.getCommandClasses(null) ? commandSupplier.getCommandClasses(null)
: List.of(); : List.of();
@ -792,30 +952,48 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
? " that accept " + commandTypes.stream().map(Class::getSimpleName).distinct() ? " that accept " + commandTypes.stream().map(Class::getSimpleName).distinct()
.collect(Collectors.joining(" or ")) .collect(Collectors.joining(" or "))
: ""; : "";
logger.debug("Partial matched items against {}{}: {}", labelFragments, typeDetails, logger.trace("Partial matched items against {}{}: {}", labelFragments, typeDetails,
itemsData.keySet().stream().map(Item::getName).collect(Collectors.joining(", "))); itemsData.keySet().stream().map(Item::getName).collect(Collectors.joining(", ")));
logger.debug("Exact matched items against {}{}: {}", labelFragments, typeDetails, logger.trace("Exact matched items against {}{}: {}", labelFragments, typeDetails,
exactMatchItemsData.keySet().stream().map(Item::getName).collect(Collectors.joining(", "))); exactMatchItemsData.keySet().stream().map(Item::getName).collect(Collectors.joining(", ")));
logger.trace("Exact matched on target items against {}{}: {}", labelFragments, typeDetails,
exactMatchOnTargetItemsData.keySet().stream().map(Item::getName).collect(Collectors.joining(", ")));
} }
@Nullable @Nullable
String locationContext = context.locationItem(); String locationContext = context.locationItem();
if (locationContext != null && itemsData.size() > 1) { if (locationContext != null && itemsData.size() > 1) {
logger.debug("Filtering {} matched items based on location '{}'", itemsData.size(), locationContext); logger.trace("Filtering {} matched items based on location '{}'", itemsData.size(), locationContext);
Item matchByLocation = filterMatchedItemsByLocation(itemsData, locationContext); Item matchByLocation = filterMatchedItemsByLocation(itemsData, locationContext);
if (matchByLocation != null) { if (matchByLocation != null) {
return List.of(matchByLocation); return List.of(matchByLocation);
} }
} }
if (locationContext != null && exactMatchItemsData.size() > 1) { if (locationContext != null && exactMatchItemsData.size() > 1) {
logger.debug("Filtering {} exact matched items based on location '{}'", exactMatchItemsData.size(), logger.trace("Filtering {} exact matched items based on location '{}'", exactMatchItemsData.size(),
locationContext); locationContext);
Item matchByLocation = filterMatchedItemsByLocation(exactMatchItemsData, locationContext); Item matchByLocation = filterMatchedItemsByLocation(exactMatchItemsData, locationContext);
if (matchByLocation != null) { if (matchByLocation != null) {
return List.of(matchByLocation); return List.of(matchByLocation);
} }
} }
return new ArrayList<>(itemsData.size() != 1 && exactMatchItemsData.size() == 1 ? exactMatchItemsData.keySet() if (locationContext != null && exactMatchOnTargetItemsData.size() > 1) {
: itemsData.keySet()); logger.trace("Filtering {} exact matched on target items based on location '{}'",
exactMatchOnTargetItemsData.size(), locationContext);
Item matchByLocation = filterMatchedItemsByLocation(exactMatchOnTargetItemsData, locationContext);
if (matchByLocation != null) {
return List.of(matchByLocation);
}
}
if (itemsData.size() == 1) {
return new ArrayList<>(itemsData.keySet());
}
if (exactMatchItemsData.size() == 1) {
return new ArrayList<>(exactMatchItemsData.keySet());
}
if (exactMatchOnTargetItemsData.size() == 1) {
return new ArrayList<>(exactMatchOnTargetItemsData.keySet());
}
return new ArrayList<>(itemsData.keySet());
} }
@Nullable @Nullable
@ -825,7 +1003,7 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
if (itemsFilteredByLocation.size() != 1) { if (itemsFilteredByLocation.size() != 1) {
return null; return null;
} }
logger.debug("Unique match by location found in '{}', taking prevalence", locationContext); logger.trace("Unique match by location found in '{}', taking prevalence", locationContext);
return itemsFilteredByLocation.get(0).getKey(); return itemsFilteredByLocation.get(0).getKey();
} }
@ -847,96 +1025,195 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
* @return resulting tokens * @return resulting tokens
*/ */
protected List<String> tokenize(Locale locale, @Nullable String text) { protected List<String> tokenize(Locale locale, @Nullable String text) {
List<String> parts = new ArrayList<>();
if (text == null) { if (text == null) {
return parts; return List.of();
} }
String[] split; String specialCharactersRegex;
if (Locale.FRENCH.getLanguage().equalsIgnoreCase(locale.getLanguage())) { if (Locale.FRENCH.getLanguage().equalsIgnoreCase(locale.getLanguage())) {
split = text.toLowerCase(locale).replaceAll("[\\']", " ").replaceAll("[^\\w\\sàâäçéèêëîïôùûü]", " ") specialCharactersRegex = "[^\\w\\sàâäçéèêëîïôùûü$|?]";
.split("\\s");
} else if ("es".equalsIgnoreCase(locale.getLanguage())) { } else if ("es".equalsIgnoreCase(locale.getLanguage())) {
split = text.toLowerCase(locale).replaceAll("[\\']", " ").replaceAll("[^\\w\\sáéíóúïüñç]", " ") specialCharactersRegex = "[^\\w\\sáéíóúïüñç$|?]";
.split("\\s");
} else { } else {
split = text.toLowerCase(locale).replaceAll("[\\']", "").replaceAll("[^\\w\\s]", " ").split("\\s"); specialCharactersRegex = "[^\\w\\s$|?]";
} }
for (String s : split) { return Arrays.stream(text.toLowerCase(locale) //
String part = s.trim(); .replaceAll("[\\']", "") //
if (part.length() > 0) { .replaceAll(specialCharactersRegex, " ") //
parts.add(part); .split("\\s")) //
} .filter(i -> !i.isBlank()) //
} .map(String::trim) //
return parts; .toList();
} }
/** /**
* Parses a rule as text into a {@link Rule} instance. * Parses a rule as text into a {@link Rule} instance.
* * <p>
* The rule text should be a list of space separated expressions, * The rule text should be a list of space separated expressions,
* one of them but not the first should be the character '*' (which indicates dynamic part to capture), * one of them but not the first should be the character '*' (which indicates dynamic part to capture),
* the other expressions can be conformed by a single word, alternative words separated by '|', * the other expressions can be conformed by a single word, alternative words separated by '|',
* and can be marked as optional by adding '?' at the end. * and can be marked as optional by adding '?' at the end.
* There must be at least one non-optional expression at the beginning of the rule. * There must be at least one non-optional expression at the beginning of the rule.
* * <p>
* An example of a valid text will be 'watch * on|at? the tv'. * An example of a valid text will be 'watch * on|at? the tv'.
* *
* @param item will be the target of the rule. * @param item will be the target of the rule.
* @param ruleText the text to parse into a {@link Rule} * @param ruleText the text to parse into a {@link Rule}
* * @param metadata voice-system metadata.
* @return The created rule. * @return The created rule.
*/ */
protected @Nullable Rule parseItemCustomRule(Item item, String ruleText) { protected List<Rule> parseItemCustomRules(Locale locale, Item item, String ruleText, Metadata metadata) {
String[] ruleParts = ruleText.split("\\*"); boolean isTemplate = ConfigParser.valueAsOrElse(metadata.getConfiguration().get(IS_TEMPLATE_CONFIGURATION),
Expression headExpression; Boolean.class, false);
@Nullable boolean isSilent = ConfigParser.valueAsOrElse(metadata.getConfiguration().get(IS_SILENT_CONFIGURATION),
Expression tailExpression = null; Boolean.class, false);
boolean isItemRule = false;
boolean isCommandRule = false;
boolean isDynamicRule = false;
if (ruleText.startsWith(NAME_TOKEN) || ruleText.startsWith(DYN_CMD_TOKEN)) {
logger.warn("Rule can not start with {} or {}: {}", NAME_TOKEN, DYN_CMD_TOKEN, ruleText);
return List.of();
}
boolean containsMultiple = CUSTOM_RULE_TOKENS.stream().anyMatch(token -> {
int firstIndex = token.indexOf(token);
return firstIndex != -1 && token.indexOf(token, firstIndex + 1) != -1;
});
if (containsMultiple) {
logger.warn("Rule can not contains {}, {} or {} multiple times: {}", NAME_TOKEN, CMD_TOKEN, DYN_CMD_TOKEN,
ruleText);
return List.of();
}
if (ruleText.contains(NAME_TOKEN)) {
isItemRule = true;
}
if (ruleText.contains(CMD_TOKEN)) {
isCommandRule = true;
}
if (ruleText.contains(DYN_CMD_TOKEN)) {
isDynamicRule = true;
}
if (isCommandRule && isDynamicRule) {
logger.warn("Rule can not contain {} and {}: {}", CMD_TOKEN, DYN_CMD_TOKEN, ruleText);
return List.of();
}
if (!isCommandRule && !isDynamicRule) {
logger.warn("Rule should contain {} or {}: {}", CMD_TOKEN, DYN_CMD_TOKEN, ruleText);
return List.of();
}
try { try {
if (ruleText.startsWith("*") || !ruleText.contains(" *") || ruleParts.length > 2) { ItemFilter itemsFilter = isTemplate
throw new ParseException("Incorrect usage of character '*'", 0); ? ItemFilter.forSimilarItem(item,
} metadataRegistry.get(new MetadataKey(SEMANTICS_NAMESPACE, item.getName())))
List<Expression> headExpressions = new ArrayList<>(); : ItemFilter.forItem(item);
boolean headHasNonOptional = true; if (isItemRule && isCommandRule) {
for (String s : ruleParts[0].split("\\s")) { String[] ruleParts = ruleText.split(Pattern.quote(NAME_TOKEN));
if (!s.isBlank()) { String headPart = ruleParts[0];
String trim = s.trim(); @Nullable
Expression expression = parseItemRuleTokenText(trim); String tailPart = ruleParts.length > 1 ? ruleParts[1] : null;
if (expression instanceof ExpressionCardinality expressionCardinality) { var itemCMDs = item.getCommandDescription();
if (!expressionCardinality.isAtLeastOne()) { if (itemCMDs == null || itemCMDs.getCommandOptions().isEmpty()) {
headHasNonOptional = false; throw new ParseException("Missing item " + item.getName() + " command description.", 0);
} }
} else { ArrayList<Rule> rules = new ArrayList<>();
headHasNonOptional = false; for (var cmd : itemCMDs.getCommandOptions()) {
String label = cmd.getLabel();
if (label == null) {
label = cmd.getCommand();
} }
headExpressions.add(expression); String value = cmd.getCommand();
String[] cmdInfo = new String[] { label, value };
var head = Objects.requireNonNull(parseCustomRuleSegment(locale, headPart, false, item, cmdInfo));
var tail = tailPart != null ? parseCustomRuleSegment(locale, tailPart, true, item, cmdInfo) : null;
rules.add(restrictedItemRule(itemsFilter, head, tail, isSilent));
} }
} return rules;
if (headHasNonOptional) { } else if (isItemRule && isDynamicRule) {
throw new ParseException("Rule head only contains optional expressions", 0); String[] ruleParts = Arrays.stream(ruleText.split(Pattern.quote(NAME_TOKEN))) //
} .map(s -> s.split(Pattern.quote(DYN_CMD_TOKEN))) //
headExpression = seq(headExpressions.toArray()); .flatMap(Arrays::stream).toArray(String[]::new);
if (ruleParts.length == 2) { if (ruleParts.length > 3) {
List<Expression> tailExpressions = new ArrayList<>(); throw new ParseException("Incorrectly rule segments: " + ruleText, 0);
for (String s : ruleParts[1].split("\\s")) { }
if (!s.isBlank()) { Expression head = Objects
String trim = s.trim(); .requireNonNull(parseCustomRuleSegment(locale, ruleParts[0], false, item, null));
Expression expression = parseItemRuleTokenText(trim); Expression mid = Objects
tailExpressions.add(expression); .requireNonNull(parseCustomRuleSegment(locale, ruleParts[1], false, item, null));
@Nullable
Expression tail = ruleParts.length > 2 ? parseCustomRuleSegment(locale, ruleParts[2], true, item, null)
: null;
boolean isNameFirst = ruleText.indexOf(NAME_TOKEN) < ruleText.indexOf(DYN_CMD_TOKEN);
return List.of(restrictedDynamicItemRule(item, itemsFilter, head, mid, tail, isNameFirst, isSilent));
} else if (isDynamicRule) {
String[] ruleParts = ruleText.split(Pattern.quote(DYN_CMD_TOKEN));
if (ruleParts.length > 2) {
throw new ParseException("Incorrectly rule segments: " + ruleText, 0);
}
Expression head = Objects
.requireNonNull(parseCustomRuleSegment(locale, ruleParts[0], false, item, null));
@Nullable
Expression tail = ruleParts.length > 1 ? parseCustomRuleSegment(locale, ruleParts[1], true, item, null)
: null;
return List.of(customDynamicRule(item, itemsFilter, head, tail, isSilent));
} else if (isCommandRule) {
var itemCMDs = item.getCommandDescription();
if (itemCMDs == null || itemCMDs.getCommandOptions().isEmpty()) {
throw new ParseException("Missing item " + item.getName() + " command description.", 0);
}
ArrayList<Rule> rules = new ArrayList<>();
for (var cmd : itemCMDs.getCommandOptions()) {
String label = cmd.getLabel();
if (label == null) {
label = cmd.getCommand();
} }
String value = cmd.getCommand();
String[] cmdInfo = new String[] { label, value };
var expression = Objects
.requireNonNull(parseCustomRuleSegment(locale, ruleText, false, item, cmdInfo));
rules.add(customCommandRule(itemsFilter, expression, isSilent));
} }
if (!tailExpressions.isEmpty()) { return rules;
tailExpression = seq(tailExpressions.toArray()); } else {
} throw new ParseException("Unable to parse rule: " + ruleText, 0);
} }
} catch (ParseException e) { } catch (ParseException e) {
logger.warn("Unable to parse item {} rule '{}': {}", item.getName(), ruleText, e.getMessage()); logger.warn("Unable to parse item {} rule '{}': {}", item.getName(), ruleText, e.getMessage());
return null; return List.of();
} }
return customItemRule(item, headExpression, tailExpression);
} }
private Expression parseItemRuleTokenText(String tokenText) throws ParseException { private @Nullable Expression parseCustomRuleSegment(Locale locale, String text, boolean allowEmpty, Item item,
String @Nullable [] cmdData) throws ParseException {
List<Expression> subExpressions = new ArrayList<>();
Expression sequenceExpression;
boolean headHasNonOptional = true;
for (String s : tokenize(locale, text)) {
String trim = s.trim();
Expression expression = parseItemRuleTokenText(locale, trim, item, cmdData);
if (expression instanceof ExpressionCardinality expressionCardinality) {
if (!expressionCardinality.isAtLeastOne()) {
headHasNonOptional = false;
}
} else {
headHasNonOptional = false;
}
subExpressions.add(expression);
}
if (headHasNonOptional) {
if (allowEmpty) {
return null;
}
throw new ParseException("Rule segment contains only optional elements: " + text, 0);
}
sequenceExpression = seq(subExpressions.toArray());
return sequenceExpression;
}
private Expression parseItemRuleTokenText(Locale locale, String tokenText, Item item, String @Nullable [] cmdData)
throws ParseException {
boolean optional = false; boolean optional = false;
if (tokenText.equals(CMD_TOKEN) && cmdData != null) {
Expression cmdExpression = seq(tokenize(locale, cmdData[0]).toArray());
return cmd(cmdExpression, TypeParser.parseCommand(item.getAcceptedCommandTypes(), cmdData[1]));
}
if (tokenText.endsWith("?")) { if (tokenText.endsWith("?")) {
tokenText = tokenText.substring(0, tokenText.length() - 1); tokenText = tokenText.substring(0, tokenText.length() - 1);
optional = true; optional = true;
@ -1143,7 +1420,7 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
} }
} }
String getGrammar() { protected String getGrammar() {
Rule[] rules = getRules(language.getLocale()); Rule[] rules = getRules(language.getLocale());
identifiers.addAll(getAllItemTokens(language.getLocale())); identifiers.addAll(getAllItemTokens(language.getLocale()));
for (Rule rule : rules) { for (Rule rule : rules) {
@ -1224,4 +1501,64 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
return List.of(command.getClass()); return List.of(command.getClass());
} }
} }
private record TextCommandSupplier(String text, List<Class<? extends Command>> allowedCommands,
Map<String, String> transformations) implements ItemCommandSupplier {
@Override
public @Nullable Command getItemCommand(Item item) {
return TypeParser.parseCommand(item.getAcceptedCommandTypes(), transformations.getOrDefault(text, text));
}
@Override
public String getCommandLabel() {
return text;
}
@Override
public List<Class<? extends Command>> getCommandClasses(@Nullable Item ignored) {
return allowedCommands;
}
}
public record ItemFilter(Set<String> itemNames, Set<String> excludedItemNames, Set<String> itemTags,
Set<String> itemSemantics) {
private static final ItemFilter allInstance = new ItemFilter(Set.of(), Set.of(), Set.of(), Set.of());
public boolean filterItem(Item item, MetadataRegistry metadataRegistry) {
if (!itemNames.isEmpty() && !itemNames.contains(item.getName())) {
return false;
}
if (!excludedItemNames.isEmpty() && excludedItemNames.contains(item.getName())) {
return false;
}
if (!itemTags.isEmpty()
&& (item.getTags().size() != itemTags.size() || !item.getTags().containsAll(itemTags))) {
return false;
}
Metadata semanticsMetadata = metadataRegistry.get(new MetadataKey(SEMANTICS_NAMESPACE, item.getName()));
if (!itemSemantics.isEmpty()
&& (semanticsMetadata == null || !itemSemantics.contains(semanticsMetadata.getValue()))) {
return false;
}
return true;
}
public static ItemFilter all() {
return allInstance;
}
public static ItemFilter forItem(Item item) {
return new ItemFilter(Set.of(item.getName()), Set.of(), Set.of(), Set.of());
}
public static ItemFilter forItems(Set<Item> item) {
return new ItemFilter(item.stream().map(Item::getName).collect(Collectors.toSet()), Set.of(), Set.of(),
Set.of());
}
public static ItemFilter forSimilarItem(Item item, @Nullable Metadata semantic) {
return new ItemFilter(Set.of(), Set.of(item.getName()), item.getTags(),
semantic != null ? Set.of(semantic.getValue()) : Set.of());
}
}
} }

View File

@ -12,7 +12,6 @@
*/ */
package org.openhab.core.voice.text; package org.openhab.core.voice.text;
import java.util.List;
import java.util.ResourceBundle; import java.util.ResourceBundle;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
@ -28,17 +27,20 @@ import org.eclipse.jdt.annotation.Nullable;
public abstract class Rule { public abstract class Rule {
private final Expression expression; private final Expression expression;
private final List<String> allowedItemNames; private final AbstractRuleBasedInterpreter.ItemFilter itemFilter;
private final boolean isSilent;
/** /**
* Constructs a new instance. * Constructs a new instance.
* *
* @param expression the expression that has to parse successfully, before {@link #interpretAST} is called * @param expression the expression that has to parse successfully, before {@link #interpretAST} is called
* @param allowedItemNames List of allowed items or empty for disabled. * @param itemFilter Filters allowed items for rule.
* @param isSilent Rule will emit no response on success.
*/ */
public Rule(Expression expression, List<String> allowedItemNames) { public Rule(Expression expression, AbstractRuleBasedInterpreter.ItemFilter itemFilter, boolean isSilent) {
this.expression = expression; this.expression = expression;
this.allowedItemNames = allowedItemNames; this.itemFilter = itemFilter;
this.isSilent = isSilent;
} }
/** /**
@ -55,7 +57,7 @@ public abstract class Rule {
InterpretationResult execute(ResourceBundle language, TokenList list, @Nullable String locationItem) { InterpretationResult execute(ResourceBundle language, TokenList list, @Nullable String locationItem) {
ASTNode node = expression.parse(language, list); ASTNode node = expression.parse(language, list);
if (node.isSuccess() && node.getRemainingTokens().eof()) { if (node.isSuccess() && node.getRemainingTokens().eof()) {
return interpretAST(language, node, new InterpretationContext(this.allowedItemNames, locationItem)); return interpretAST(language, node, new InterpretationContext(this.itemFilter, isSilent, locationItem));
} }
return InterpretationResult.SYNTAX_ERROR; return InterpretationResult.SYNTAX_ERROR;
} }
@ -72,9 +74,10 @@ public abstract class Rule {
* *
* @author Miguel Álvarez - Initial contribution * @author Miguel Álvarez - Initial contribution
* *
* @param allowedItems List of item names to restrict rule compatibility to, empty for disabled. * @param itemFilter Restricts rule compatibility to allowed items.
* @param locationItem Location item to prioritize item matches or null. * @param locationItem Location item to prioritize item matches or null.
*/ */
public record InterpretationContext(List<String> allowedItems, @Nullable String locationItem) { public record InterpretationContext(AbstractRuleBasedInterpreter.ItemFilter itemFilter, boolean isSilent,
@Nullable String locationItem) {
} }
} }

View File

@ -17,7 +17,11 @@ import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.openhab.core.voice.internal.text.StandardInterpreter.VOICE_SYSTEM_NAMESPACE;
import static org.openhab.core.voice.text.AbstractRuleBasedInterpreter.IS_SILENT_CONFIGURATION;
import static org.openhab.core.voice.text.AbstractRuleBasedInterpreter.IS_TEMPLATE_CONFIGURATION;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Set; import java.util.Set;
@ -136,7 +140,8 @@ public class StandardInterpreterTest {
MetadataKey computerMetadataKey = new MetadataKey("synonyms", computerItem.getName()); MetadataKey computerMetadataKey = new MetadataKey("synonyms", computerItem.getName());
when(metadataRegistryMock.get(computerMetadataKey)) when(metadataRegistryMock.get(computerMetadataKey))
.thenReturn(new Metadata(computerMetadataKey, "PC,Bedroom PC", null)); .thenReturn(new Metadata(computerMetadataKey, "PC,Bedroom PC", null));
when(metadataRegistryMock.get(new MetadataKey("voice-system", computerItem.getName()))).thenReturn(null); when(metadataRegistryMock.get(new MetadataKey(VOICE_SYSTEM_NAMESPACE, computerItem.getName())))
.thenReturn(null);
List<Item> items = List.of(computerItem); List<Item> items = List.of(computerItem);
when(itemRegistryMock.getItems()).thenReturn(items); when(itemRegistryMock.getItems()).thenReturn(items);
assertEquals(OK_RESPONSE, standardInterpreter.interpret(Locale.ENGLISH, "turn off computer")); assertEquals(OK_RESPONSE, standardInterpreter.interpret(Locale.ENGLISH, "turn off computer"));
@ -154,17 +159,11 @@ public class StandardInterpreterTest {
@Test @Test
public void allowUseItemDescription() throws InterpretationException { public void allowUseItemDescription() throws InterpretationException {
var cmdDescription = new CommandDescription() {
@Override
public List<CommandOption> getCommandOptions() {
return List.of(new CommandOption("10", "low"), new CommandOption("50", "medium"),
new CommandOption("90", "high"), new CommandOption("100", "high two"));
}
};
var brightness = new DimmerItem("brightness") { var brightness = new DimmerItem("brightness") {
@Override @Override
public @Nullable CommandDescription getCommandDescription() { public @Nullable CommandDescription getCommandDescription() {
return cmdDescription; return () -> List.of(new CommandOption("10", "low"), new CommandOption("50", "medium"),
new CommandOption("90", "high"), new CommandOption("100", "high two"));
} }
@Override @Override
@ -193,14 +192,116 @@ public class StandardInterpreterTest {
} }
@Test @Test
public void allowUseCustomCommands() throws InterpretationException { public void allowUseCustomItemCommands() throws InterpretationException {
var virtualItem = new StringItem("virtual"); var tvItem = new StringItem("virtual") {
MetadataKey voiceMetadataKey = new MetadataKey("voice-system", virtualItem.getName()); @Override
public @Nullable CommandDescription getCommandDescription() {
return () -> List.of(new CommandOption("KEY_4", "channel 4"));
}
@Override
public @Nullable CommandDescription getCommandDescription(@Nullable Locale locale) {
return getCommandDescription();
}
};
tvItem.setLabel("tv");
MetadataKey voiceMetadataKey = new MetadataKey(VOICE_SYSTEM_NAMESPACE, tvItem.getName());
when(metadataRegistryMock.get(voiceMetadataKey)) when(metadataRegistryMock.get(voiceMetadataKey))
.thenReturn(new Metadata(voiceMetadataKey, "watch|play * on|at? the? tv", null)); .thenReturn(new Metadata(voiceMetadataKey, "watch|play $cmd$ on|at the? $name$", null));
List<Item> items = List.of(virtualItem); List<Item> items = List.of(tvItem);
when(itemRegistryMock.getItems()).thenReturn(items); when(itemRegistryMock.getItems()).thenReturn(items);
assertEquals(OK_RESPONSE, standardInterpreter.interpret(Locale.ENGLISH, "watch channel 4 on the tv")); assertEquals(OK_RESPONSE, standardInterpreter.interpret(Locale.ENGLISH, "watch channel 4 on the tv"));
verify(eventPublisherMock, times(1))
.post(ItemEventFactory.createCommandEvent(tvItem.getName(), new StringType("KEY_4")));
reset(eventPublisherMock);
}
@Test
public void allowUseCustomCommandCommands() throws InterpretationException {
var tvItem = new StringItem("tv") {
@Override
public @Nullable CommandDescription getCommandDescription() {
return () -> List.of(new CommandOption("KEY_4", "channel 4"));
}
@Override
public @Nullable CommandDescription getCommandDescription(@Nullable Locale locale) {
return getCommandDescription();
}
};
MetadataKey voiceMetadataKey = new MetadataKey(VOICE_SYSTEM_NAMESPACE, tvItem.getName());
when(metadataRegistryMock.get(voiceMetadataKey))
.thenReturn(new Metadata(voiceMetadataKey, "watch|play $cmd$ on|at the? tv", null));
List<Item> items = List.of(tvItem);
when(itemRegistryMock.getItems()).thenReturn(items);
assertEquals(OK_RESPONSE, standardInterpreter.interpret(Locale.ENGLISH, "watch channel 4 on the tv"));
verify(eventPublisherMock, times(1))
.post(ItemEventFactory.createCommandEvent(tvItem.getName(), new StringType("KEY_4")));
reset(eventPublisherMock);
}
@Test
public void allowUseCustomItemDynamicCommands() throws InterpretationException {
var tvItem = new StringItem("tv");
tvItem.setLabel("tv");
MetadataKey voiceMetadataKey = new MetadataKey(VOICE_SYSTEM_NAMESPACE, tvItem.getName());
when(metadataRegistryMock.get(voiceMetadataKey))
.thenReturn(new Metadata(voiceMetadataKey, "watch|play $*$ on|at the? $name$", null));
List<Item> items = List.of(tvItem);
when(itemRegistryMock.getItems()).thenReturn(items);
assertEquals(OK_RESPONSE, standardInterpreter.interpret(Locale.ENGLISH, "watch channel 4 on the tv"));
verify(eventPublisherMock, times(1))
.post(ItemEventFactory.createCommandEvent(tvItem.getName(), new StringType("channel 4")));
reset(eventPublisherMock);
}
@Test
public void allowUseCommandsWithoutAnswer() throws InterpretationException {
var tvItem = new StringItem("tv");
tvItem.setLabel("tv");
MetadataKey voiceMetadataKey = new MetadataKey(VOICE_SYSTEM_NAMESPACE, tvItem.getName());
HashMap<String, Object> configuration = new HashMap<>();
configuration.put(IS_SILENT_CONFIGURATION, true);
when(metadataRegistryMock.get(voiceMetadataKey))
.thenReturn(new Metadata(voiceMetadataKey, "watch|play $*$ on|at the? $name$", configuration));
List<Item> items = List.of(tvItem);
when(itemRegistryMock.getItems()).thenReturn(items);
assertEquals("", standardInterpreter.interpret(Locale.ENGLISH, "watch channel 4 on the tv"));
verify(eventPublisherMock, times(1))
.post(ItemEventFactory.createCommandEvent(tvItem.getName(), new StringType("channel 4")));
reset(eventPublisherMock);
}
@Test
public void allowUseCommandsFromTemplate() throws InterpretationException {
var virtualItem = new StringItem("virtual");
virtualItem.setLabel("tv rule");
virtualItem.addTag("tv");
var tvItem = new StringItem("tv");
tvItem.setLabel("tv");
tvItem.addTag("tv");
MetadataKey voiceMetadataKey = new MetadataKey(VOICE_SYSTEM_NAMESPACE, virtualItem.getName());
HashMap<String, Object> configuration = new HashMap<>();
configuration.put(IS_TEMPLATE_CONFIGURATION, true);
when(metadataRegistryMock.get(voiceMetadataKey))
.thenReturn(new Metadata(voiceMetadataKey, "watch|play $*$ on|at the? $name$", configuration));
List<Item> items = List.of(virtualItem, tvItem);
when(itemRegistryMock.getItems()).thenReturn(items);
assertEquals(OK_RESPONSE, standardInterpreter.interpret(Locale.ENGLISH, "watch channel 4 on the tv"));
verify(eventPublisherMock, times(1))
.post(ItemEventFactory.createCommandEvent(tvItem.getName(), new StringType("channel 4")));
reset(eventPublisherMock);
}
@Test
public void allowUseCustomDynamicCommands() throws InterpretationException {
var virtualItem = new StringItem("tv");
MetadataKey voiceMetadataKey = new MetadataKey(VOICE_SYSTEM_NAMESPACE, virtualItem.getName());
when(metadataRegistryMock.get(voiceMetadataKey))
.thenReturn(new Metadata(voiceMetadataKey, "watch|play $*$", null));
List<Item> items = List.of(virtualItem);
when(itemRegistryMock.getItems()).thenReturn(items);
assertEquals(OK_RESPONSE, standardInterpreter.interpret(Locale.ENGLISH, "watch channel 4"));
verify(eventPublisherMock, times(1)) verify(eventPublisherMock, times(1))
.post(ItemEventFactory.createCommandEvent(virtualItem.getName(), new StringType("channel 4"))); .post(ItemEventFactory.createCommandEvent(virtualItem.getName(), new StringType("channel 4")));
reset(eventPublisherMock); reset(eventPublisherMock);
@ -225,9 +326,9 @@ public class StandardInterpreterTest {
return getCommandDescription(); return getCommandDescription();
} }
}; };
MetadataKey voiceMetadataKey = new MetadataKey("voice-system", virtualItem.getName()); MetadataKey voiceMetadataKey = new MetadataKey(VOICE_SYSTEM_NAMESPACE, virtualItem.getName());
when(metadataRegistryMock.get(voiceMetadataKey)) when(metadataRegistryMock.get(voiceMetadataKey))
.thenReturn(new Metadata(voiceMetadataKey, "watch|play * on|at? the? tv", null)); .thenReturn(new Metadata(voiceMetadataKey, "watch|play $*$ on|at? the? tv", null));
List<Item> items = List.of(virtualItem); List<Item> items = List.of(virtualItem);
when(itemRegistryMock.getItems()).thenReturn(items); when(itemRegistryMock.getItems()).thenReturn(items);
assertEquals(OK_RESPONSE, standardInterpreter.interpret(Locale.ENGLISH, "watch channel 4 on the tv")); assertEquals(OK_RESPONSE, standardInterpreter.interpret(Locale.ENGLISH, "watch channel 4 on the tv"));