]> git.basschouten.com Git - openhab-addons.git/blob
63fcc8681e75fcfb850d18563462d81f212bc5d4
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.voice.actiontemplatehli.internal;
14
15 import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.DYNAMIC_PLACEHOLDER;
16 import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.DYNAMIC_PLACEHOLDER_SYMBOL;
17 import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.GROUP_LABEL_PLACEHOLDER_SYMBOL;
18 import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.ITEM_LABEL_PLACEHOLDER;
19 import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.ITEM_LABEL_PLACEHOLDER_SYMBOL;
20 import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.ITEM_OPTION_PLACEHOLDER;
21 import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.ITEM_OPTION_PLACEHOLDER_SYMBOL;
22 import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.NER_FOLDER;
23 import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.NLP_FOLDER;
24 import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.POS_FOLDER;
25 import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.SERVICE_CATEGORY;
26 import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.SERVICE_ID;
27 import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.SERVICE_NAME;
28 import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.SERVICE_PID;
29 import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.STATE_PLACEHOLDER;
30 import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.STATE_PLACEHOLDER_SYMBOL;
31 import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.TYPE_ACTION_CONFIGS_FOLDER;
32
33 import java.awt.Color;
34 import java.io.File;
35 import java.io.FileInputStream;
36 import java.io.IOException;
37 import java.io.InputStream;
38 import java.nio.file.Path;
39 import java.util.ArrayList;
40 import java.util.Arrays;
41 import java.util.Collections;
42 import java.util.HashMap;
43 import java.util.List;
44 import java.util.Locale;
45 import java.util.Map;
46 import java.util.Objects;
47 import java.util.Set;
48 import java.util.regex.Pattern;
49 import java.util.stream.Collectors;
50 import java.util.stream.Stream;
51
52 import org.eclipse.jdt.annotation.NonNullByDefault;
53 import org.eclipse.jdt.annotation.Nullable;
54 import org.openhab.core.common.registry.RegistryChangeListener;
55 import org.openhab.core.config.core.ConfigurableService;
56 import org.openhab.core.config.core.Configuration;
57 import org.openhab.core.events.EventPublisher;
58 import org.openhab.core.items.GroupItem;
59 import org.openhab.core.items.Item;
60 import org.openhab.core.items.ItemRegistry;
61 import org.openhab.core.items.Metadata;
62 import org.openhab.core.items.MetadataKey;
63 import org.openhab.core.items.MetadataRegistry;
64 import org.openhab.core.items.events.ItemEventFactory;
65 import org.openhab.core.library.types.HSBType;
66 import org.openhab.core.library.types.OnOffType;
67 import org.openhab.core.library.types.OpenClosedType;
68 import org.openhab.core.library.types.StringType;
69 import org.openhab.core.types.Command;
70 import org.openhab.core.types.State;
71 import org.openhab.core.types.TypeParser;
72 import org.openhab.core.types.UnDefType;
73 import org.openhab.core.voice.text.HumanLanguageInterpreter;
74 import org.openhab.core.voice.text.InterpretationException;
75 import org.openhab.voice.actiontemplatehli.internal.configuration.ActionTemplateConfiguration;
76 import org.openhab.voice.actiontemplatehli.internal.configuration.ActionTemplateGroupTargets;
77 import org.openhab.voice.actiontemplatehli.internal.configuration.ActionTemplatePlaceholder;
78 import org.osgi.framework.Constants;
79 import org.osgi.service.component.annotations.Activate;
80 import org.osgi.service.component.annotations.Component;
81 import org.osgi.service.component.annotations.Deactivate;
82 import org.osgi.service.component.annotations.Modified;
83 import org.osgi.service.component.annotations.Reference;
84 import org.slf4j.Logger;
85 import org.slf4j.LoggerFactory;
86
87 import opennlp.tools.dictionary.Dictionary;
88 import opennlp.tools.lemmatizer.DictionaryLemmatizer;
89 import opennlp.tools.lemmatizer.Lemmatizer;
90 import opennlp.tools.lemmatizer.LemmatizerME;
91 import opennlp.tools.lemmatizer.LemmatizerModel;
92 import opennlp.tools.namefind.DictionaryNameFinder;
93 import opennlp.tools.namefind.NameFinderME;
94 import opennlp.tools.namefind.TokenNameFinderModel;
95 import opennlp.tools.postag.POSDictionary;
96 import opennlp.tools.postag.POSModel;
97 import opennlp.tools.postag.POSTaggerME;
98 import opennlp.tools.tokenize.SimpleTokenizer;
99 import opennlp.tools.tokenize.Tokenizer;
100 import opennlp.tools.tokenize.TokenizerME;
101 import opennlp.tools.tokenize.TokenizerModel;
102 import opennlp.tools.tokenize.WhitespaceTokenizer;
103 import opennlp.tools.util.Span;
104 import opennlp.tools.util.StringList;
105
106 /**
107  * The {@link ActionTemplateInterpreter} is a configurable interpreter powered by OpenNLP
108  *
109  * @author Miguel Álvarez - Initial contribution
110  */
111 @NonNullByDefault
112 @Component(configurationPid = SERVICE_PID, property = Constants.SERVICE_PID + "=" + SERVICE_PID)
113 @ConfigurableService(category = SERVICE_CATEGORY, label = SERVICE_NAME, description_uri = SERVICE_CATEGORY + ":"
114         + SERVICE_ID)
115 public class ActionTemplateInterpreter implements HumanLanguageInterpreter {
116     static {
117         Logger logger = LoggerFactory.getLogger(ActionTemplateInterpreter.class);
118         createFolder(logger, NLP_FOLDER);
119         createFolder(logger, NER_FOLDER);
120         createFolder(logger, POS_FOLDER);
121         createFolder(logger, TYPE_ACTION_CONFIGS_FOLDER);
122     }
123     private static final Pattern COLOR_HEX_PATTERN = Pattern.compile("^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$");
124     private final Logger logger = LoggerFactory.getLogger(ActionTemplateInterpreter.class);
125     private final ItemRegistry itemRegistry;
126     private final MetadataRegistry metadataRegistry;
127     private final EventPublisher eventPublisher;
128     private ActionTemplateInterpreterConfiguration config = new ActionTemplateInterpreterConfiguration();
129     private Tokenizer tokenizer = WhitespaceTokenizer.INSTANCE;
130     private List<String> optionalLanguageTags = List.of();
131     @Nullable
132     private NLPItemMaps nlpItemMaps;
133
134     private final RegistryChangeListener<Item> registryChangeListener = new RegistryChangeListener<>() {
135         @Override
136         public void added(Item element) {
137             invalidate();
138         }
139
140         @Override
141         public void removed(Item element) {
142             invalidate();
143         }
144
145         @Override
146         public void updated(Item oldElement, Item element) {
147             invalidate();
148         }
149     };
150
151     @Activate
152     public ActionTemplateInterpreter(@Reference ItemRegistry itemRegistry, @Reference MetadataRegistry metadataRegistry,
153             @Reference EventPublisher eventPublisher) {
154         this.itemRegistry = itemRegistry;
155         this.metadataRegistry = metadataRegistry;
156         this.eventPublisher = eventPublisher;
157         itemRegistry.addRegistryChangeListener(registryChangeListener);
158     }
159
160     @Activate
161     protected void activate(Map<String, Object> config) {
162         modified(config);
163     }
164
165     @Modified
166     protected void modified(Map<String, Object> config) {
167         this.config = new Configuration(config).as(ActionTemplateInterpreterConfiguration.class);
168         reloadConfigs();
169     }
170
171     @Deactivate
172     protected void deactivate() {
173         itemRegistry.removeRegistryChangeListener(registryChangeListener);
174     }
175
176     @Override
177     public String getId() {
178         return SERVICE_ID;
179     }
180
181     @Override
182     public String getLabel(@Nullable Locale locale) {
183         return SERVICE_NAME;
184     }
185
186     @Override
187     public @Nullable String getGrammar(@Nullable Locale locale, @Nullable String s) {
188         return null;
189     }
190
191     @Override
192     public Set<Locale> getSupportedLocales() {
193         // all are supported
194         return Set.of();
195     }
196
197     @Override
198     public Set<String> getSupportedGrammarFormats() {
199         return Set.of();
200     }
201
202     @Override
203     public String interpret(Locale locale, String words) throws InterpretationException {
204         if (words.isEmpty()) {
205             throw new InterpretationException(config.unhandledMessage);
206         }
207         try {
208             var finalWords = config.lowerText ? words.toLowerCase(locale) : words;
209             var info = getNLPInfo(finalWords);
210             if (info.tokens.length == 0) {
211                 logger.debug("no tokens produced; aborting");
212                 throw new InterpretationException(config.failureMessage);
213             }
214             String response = processAction(finalWords,
215                     checkActionConfigs(finalWords, info.tokens, info.tags, info.lemmas));
216             if (response == null) {
217                 logger.debug("silent mode; no response");
218                 return "";
219             }
220             logger.debug("response: {}", response);
221             return response;
222         } catch (IOException e) {
223             logger.debug("IOException while interpreting: {}", e.getMessage(), e);
224             var message = e.getMessage();
225             throw new InterpretationException(message != null ? message : "Unknown error");
226         } catch (RuntimeException e) {
227             var message = e.getMessage();
228             logger.debug("RuntimeException while interpreting: {}", e.getMessage(), e);
229             throw new InterpretationException(message != null ? message : "Unknown error");
230         }
231     }
232
233     private @Nullable String processAction(String words, @Nullable NLPInterpretationResult result)
234             throws InterpretationException, IOException {
235         if (result != null) {
236             if (!result.actionConfig.read) {
237                 return sendItemCommand(result.targetItem, words, result.actionConfig, result.placeholderValues);
238             } else {
239                 return readItemState(result.targetItem, result.actionConfig);
240             }
241         } else {
242             throw new InterpretationException(config.unhandledMessage);
243         }
244     }
245
246     private NLPInfo getNLPInfo(String text) throws IOException {
247         logger.debug("Processing: '{}'", text);
248         var tokens = tokenizeText(text);
249         var tags = languagePOSTagging(tokens);
250         var lemmas = languageLemmatize(tokens, tags);
251         logger.debug("tokens: {}", List.of(tokens));
252         logger.debug("tags: {}", List.of(tags));
253         logger.debug("lemmas: {}", List.of(lemmas));
254         return new NLPInfo(tokens, lemmas, tags);
255     }
256
257     private @Nullable NLPInterpretationResult checkActionConfigs(String text, String[] tokens, String[] tags,
258             String[] lemmas) throws IOException {
259         // item defined actions have priority over type defined actions
260         var result = checkItemActions(text, tokens, tags, lemmas);
261         if (result != null) {
262             return result;
263         }
264         return checkTypeActionsConfigs(text, tokens, tags, lemmas);
265     }
266
267     private @Nullable NLPInterpretationResult checkItemActions(String text, String[] tokens, String[] tags,
268             String[] lemmas) throws IOException {
269         // Check item with action config
270         var itemsWithActions = getItemsWithActionConfigs();
271         Item targetItem = null;
272         ActionTemplateConfiguration targetActionConfig = null;
273         // store data to restore placeholder values
274         List<NLPPlaceholderData> placeholderValues = null;
275         // store span of dynamic placeholder, to invalidate others
276         Span dynamicSpan = null;
277         int matchScore = 0;
278         for (var entry : itemsWithActions.entrySet()) {
279             var actionConfigs = entry.getValue();
280             for (var actionConfig : actionConfigs) {
281                 var templates = Arrays.stream(actionConfig.template.split(";")).map(String::trim)
282                         .collect(Collectors.toList());
283                 for (var template : templates) {
284                     List<NLPPlaceholderData> currentPlaceholderValues = new ArrayList<>();
285                     var currentItem = entry.getKey();
286                     var scoreResult = getScoreWithPlaceholders(text, currentItem, actionConfig.memberTargets,
287                             actionConfig.read, tokens, tags, lemmas, actionConfig, template, currentPlaceholderValues);
288                     if (scoreResult.score != 0 && scoreResult.score == matchScore) {
289                         if (targetItem == currentItem) {
290                             logger.warn(
291                                     "multiple alternative templates for item '{}' has the same score, '{}' can be removed",
292                                     targetItem.getName(), template);
293                         } else {
294                             logger.warn(
295                                     "multiple templates with same score for items '{}' and '{}', the action with template '{}' can be removed",
296                                     targetItem.getName(), currentItem.getName(), template);
297                         }
298                     }
299                     if (scoreResult.score > matchScore) {
300                         targetItem = currentItem;
301                         targetActionConfig = actionConfig;
302                         placeholderValues = currentPlaceholderValues;
303                         matchScore = scoreResult.score;
304                         dynamicSpan = scoreResult.dynamicSpan;
305                     }
306                 }
307             }
308         }
309         if (targetItem != null && targetActionConfig != null && placeholderValues != null) {
310             if (dynamicSpan != null) {
311                 placeholderValues = updatePlaceholderValues(text, tokens, placeholderValues, dynamicSpan);
312             }
313             return new NLPInterpretationResult(targetItem, targetActionConfig, placeholderValues.stream()
314                     .collect(Collectors.toMap(i -> i.placeholderName, i -> i.placeholderValue)));
315         }
316         return null;
317     }
318
319     private @Nullable NLPInterpretationResult checkTypeActionsConfigs(String text, String[] tokens, String[] tags,
320             String[] lemmas) throws IOException {
321         // Check item command
322         var itemLabelSpans = nerItemLabels(tokens);
323         logger.debug("itemLabelSpans: {}", List.of(itemLabelSpans));
324         if (itemLabelSpans.length == 0) {
325             logger.debug("No item labels found!");
326             return null;
327         }
328         Item finalTargetItem = null;
329         ActionTemplateConfiguration targetActionConfig = null;
330         // store data to restore placeholder values
331         List<NLPPlaceholderData> placeholderValues = null;
332         // store span of dynamic placeholder, to invalidate others
333         Span dynamicSpan = null;
334         int matchScore = 0;
335         // iterate itemLabelSpan to score the templates with each of them
336         for (var itemLabelSpan : itemLabelSpans) {
337             var labelTokens = getTargetItemTokens(tokens, itemLabelSpan);
338             var targetItem = getTargetItemByLabelTokens(labelTokens);
339             if (targetItem == null) {
340                 return null;
341             }
342             var tokensWithGenericLabel = replacePlaceholder(text, tokens, itemLabelSpan, ITEM_LABEL_PLACEHOLDER, null,
343                     null);
344             var lemmasWithGenericLabel = lemmas.length > 0
345                     ? replacePlaceholder(text, lemmas, itemLabelSpan, ITEM_LABEL_PLACEHOLDER, null, null)
346                     : new String[] {};
347             var tagsWithGenericLabel = tags.length > 0
348                     ? replacePlaceholder(text, tags, itemLabelSpan, ITEM_LABEL_PLACEHOLDER, null, null)
349                     : new String[] {};
350             logger.debug("Target item {}", targetItem.getName());
351             // load templates defined for this item type
352             var typeActionConfigs = getTypeActionConfigs(targetItem.getType());
353             for (var actionConfig : typeActionConfigs) {
354                 // check required item tags
355                 if (actionConfig.requiredItemTags.length != 0) {
356                     var itemLabels = targetItem.getTags();
357                     if (!Arrays.stream(actionConfig.requiredItemTags).allMatch(itemLabels::contains)) {
358                         logger.debug("action '{}' skipped, tags constrain '{}'", actionConfig.template,
359                                 List.of(actionConfig.requiredItemTags));
360                         continue;
361                     }
362                 }
363                 var templates = Arrays.stream(actionConfig.template.split(";")).map(String::trim)
364                         .collect(Collectors.toList());
365                 for (var template : templates) {
366                     var replacedValues = new ArrayList<NLPPlaceholderData>();
367                     var scoreResult = getScoreWithPlaceholders(text, targetItem, actionConfig.memberTargets,
368                             actionConfig.read, tokensWithGenericLabel, tagsWithGenericLabel, lemmasWithGenericLabel,
369                             actionConfig, template, replacedValues);
370                     if (scoreResult.score != 0 && scoreResult.score == matchScore
371                             && actionConfig.requiredItemTags.length == targetActionConfig.requiredItemTags.length) {
372                         if (targetActionConfig == actionConfig) {
373                             logger.warn(
374                                     "multiple alternative templates with same score, you can remove the alternative '{}'",
375                                     template);
376                         } else {
377                             logger.warn(
378                                     "multiple templates with same score, the action with template '{}' can be removed",
379                                     template);
380                         }
381                     }
382                     // for rules with same score the one with more restrictions have prevalence
383                     if (scoreResult.score > matchScore || (scoreResult.score == matchScore && targetActionConfig != null
384                             && actionConfig.requiredItemTags.length > targetActionConfig.requiredItemTags.length)) {
385                         finalTargetItem = targetItem;
386                         placeholderValues = replacedValues;
387                         targetActionConfig = actionConfig;
388                         matchScore = scoreResult.score;
389                         dynamicSpan = scoreResult.dynamicSpan;
390                     }
391                 }
392             }
393         }
394         if (finalTargetItem != null && targetActionConfig != null && placeholderValues != null) {
395             if (dynamicSpan != null) {
396                 placeholderValues = updatePlaceholderValues(text, tokens, placeholderValues, dynamicSpan);
397             }
398             return NLPInterpretationResult.from(finalTargetItem, targetActionConfig, placeholderValues);
399         }
400         return null;
401     }
402
403     private List<NLPPlaceholderData> updatePlaceholderValues(String text, String[] tokens,
404             List<NLPPlaceholderData> placeholderValues, Span dynamicSpan) {
405         // we should clean up placeholder values detected inside the dynamic template
406         var validPlaceholderValues = placeholderValues.stream().filter(i -> !dynamicSpan.contains(i.placeholderSpan))
407                 .collect(Collectors.toList());
408         // add dynamic content to values
409         validPlaceholderValues.add(new NLPPlaceholderData(DYNAMIC_PLACEHOLDER,
410                 detokenize(Arrays.copyOfRange(tokens, dynamicSpan.getStart(), dynamicSpan.getEnd()), text),
411                 dynamicSpan));
412         return validPlaceholderValues;
413     }
414
415     private Set<Item> getMembersByTypeRecursive(GroupItem group, String itemType, String[] requiredMemberTags) {
416         Stream<Item> targetMembersStream = getMembersByType(group, itemType, requiredMemberTags).stream();
417         var childGroups = getMembersByType(group, "Group", new String[] {});
418         for (var childGroup : childGroups) {
419             targetMembersStream = Stream.concat(targetMembersStream,
420                     getMembersByTypeRecursive((GroupItem) childGroup, itemType, requiredMemberTags).stream());
421         }
422         return targetMembersStream.collect(Collectors.toUnmodifiableSet());
423     }
424
425     private State mergeSwitchMembersState(GroupItem group, String[] requiredMemberTags, boolean recursive) {
426         var result = OnOffType.OFF;
427         var targetMembers = recursive ? getMembersByTypeRecursive(group, "Switch", requiredMemberTags)
428                 : getMembersByType(group, "Switch", requiredMemberTags);
429         for (var member : targetMembers) {
430             if (UnDefType.UNDEF.equals(member.getState())) {
431                 return UnDefType.UNDEF;
432             }
433             if (UnDefType.NULL.equals(member.getState())) {
434                 return UnDefType.NULL;
435             }
436             if (OnOffType.ON.equals(member.getState())) {
437                 result = OnOffType.ON;
438             }
439         }
440         return result;
441     }
442
443     private State mergeContactMembersState(GroupItem group, String[] requiredMemberTags, boolean recursive) {
444         var result = OpenClosedType.CLOSED;
445         var targetMembers = recursive ? getMembersByTypeRecursive(group, "Contact", requiredMemberTags)
446                 : getMembersByType(group, "Contact", requiredMemberTags);
447         for (var member : targetMembers) {
448             if (UnDefType.UNDEF.equals(member.getState())) {
449                 return UnDefType.UNDEF;
450             }
451             if (UnDefType.NULL.equals(member.getState())) {
452                 return UnDefType.NULL;
453             }
454             if (OpenClosedType.OPEN.equals(member.getState())) {
455                 result = OpenClosedType.OPEN;
456             }
457         }
458         return result;
459     }
460
461     private String readItemState(Item targetItem, ActionTemplateConfiguration actionConfigMatch)
462             throws IOException, InterpretationException {
463         var memberTargets = actionConfigMatch.memberTargets;
464         String state = null;
465         String itemLabel = targetItem.getLabel();
466         String groupLabel = null;
467         Item finalTargetItem = targetItem;
468         if (finalTargetItem.getType().equals("Group") && memberTargets != null) {
469             if (memberTargets.mergeState && memberTargets.itemName.isEmpty() && !memberTargets.itemType.isEmpty()) {
470                 // handle states that can be merged
471                 switch (memberTargets.itemType) {
472                     case "Switch":
473                         state = mergeSwitchMembersState((GroupItem) finalTargetItem, memberTargets.requiredItemTags,
474                                 memberTargets.recursive).toFullString();
475                         break;
476                     case "Contact":
477                         state = mergeContactMembersState((GroupItem) finalTargetItem, memberTargets.requiredItemTags,
478                                 memberTargets.recursive).toFullString();
479                         break;
480                     default:
481                         logger.warn("state merge is not available for members of type {}", memberTargets.itemType);
482                         throw new InterpretationException(config.failureMessage);
483                 }
484             }
485             if (state == null) {
486                 Set<Item> targetMembers = getTargetMembers((GroupItem) finalTargetItem, memberTargets);
487                 if (!targetMembers.isEmpty()) {
488                     if (targetMembers.size() > 1) {
489                         logger.warn("read action matches {} item members inside a group, using the first one",
490                                 targetMembers.size());
491                     }
492                     var targetMember = targetMembers.iterator().next();
493                     // only one target in the group, adding groupLabel placeholder value
494                     groupLabel = itemLabel;
495                     itemLabel = targetMember.getLabel();
496                     state = targetMember.getState().toFullString();
497                     finalTargetItem = targetMember;
498                 } else {
499                     logger.warn("configured targetMembers were not found in group '{}'", finalTargetItem.getName());
500                     throw new InterpretationException(config.failureMessage);
501                 }
502             }
503         }
504         if (state == null) {
505             state = finalTargetItem.getState().toFullString();
506         }
507         var statePlaceholder = actionConfigMatch.placeholders.stream().filter(p -> p.label.equals(STATE_PLACEHOLDER))
508                 .findFirst();
509         var itemState = state;
510         if (statePlaceholder.isPresent()) {
511             state = applyPOSTransformation(state, statePlaceholder.get());
512         }
513         var template = actionConfigMatch.value;
514         if (!actionConfigMatch.emptyValue.isEmpty() && (state.isEmpty()
515                 || (UnDefType.UNDEF.toFullString().equals(state) || UnDefType.NULL.toFullString().equals(state)))) {
516             // use alternative template for empty values
517             template = actionConfigMatch.emptyValue;
518         }
519         if (template instanceof String) {
520             String templateText = (String) template;
521             if (templateText.contains(ITEM_OPTION_PLACEHOLDER)) {
522                 var itemOptionPlaceholder = getItemOptionPlaceholder(finalTargetItem, true, null);
523                 if (itemOptionPlaceholder != null) {
524                     itemState = applyPOSTransformation(itemState, itemOptionPlaceholder);
525                 }
526             }
527             return templateText.replace(STATE_PLACEHOLDER_SYMBOL, state)
528                     .replace(ITEM_OPTION_PLACEHOLDER_SYMBOL, itemState)
529                     .replace(ITEM_LABEL_PLACEHOLDER_SYMBOL, itemLabel != null ? itemLabel : "")
530                     .replace(GROUP_LABEL_PLACEHOLDER_SYMBOL, groupLabel != null ? groupLabel : "");
531         }
532         return state;
533     }
534
535     private NLPTokenComparisonResult getScoreWithPlaceholders(String text, Item targetItem,
536             @Nullable ActionTemplateGroupTargets targetMembers, boolean isRead, String[] tokens, String[] tags,
537             String[] lemmas, ActionTemplateConfiguration actionConfiguration, String template,
538             List<NLPPlaceholderData> placeholderValues) throws IOException {
539         var placeholders = new ArrayList<>(actionConfiguration.placeholders);
540         var finalTokens = tokens;
541         var finalLemmas = lemmas;
542         var finalTags = tags;
543         if (template.contains(ITEM_OPTION_PLACEHOLDER_SYMBOL)) {
544             var itemOptionPlaceholder = getItemOptionPlaceholder(targetItem, isRead, targetMembers);
545             if (itemOptionPlaceholder == null) {
546                 return NLPTokenComparisonResult.ZERO;
547             }
548             placeholders.add(itemOptionPlaceholder);
549         }
550         for (var placeholder : placeholders) {
551             if (actionConfiguration.read && placeholder.label.equals(STATE_PLACEHOLDER)) {
552                 // This placeholder is reserved on read mode should not be replaced now
553                 continue;
554             }
555             if (placeholder.label.equals(DYNAMIC_PLACEHOLDER)) {
556                 logger.warn("the name {} is reserved for the dynamic placeholder", DYNAMIC_PLACEHOLDER);
557                 continue;
558             }
559             var nerStaticValues = placeholder.nerStaticValues;
560             var nerFile = placeholder.nerFile;
561             Span[] nerSpans;
562             Map<String[], String> possibleValuesByTokensMap = null;
563             if (nerStaticValues != null) {
564                 possibleValuesByTokensMap = getStringsByTokensMap(nerStaticValues);
565                 nerSpans = nerValues(finalTokens, possibleValuesByTokensMap.keySet().toArray(String[][]::new),
566                         placeholder.label);
567             } else if (nerFile != null) {
568                 nerSpans = nerWithFile(finalTokens, nerFile);
569             } else {
570                 logger.warn("Placeholder {} could not be applied due to missing ner config", placeholder.label);
571                 continue;
572             }
573             for (Span nerSpan : nerSpans) {
574                 var placeholderName = placeholder.label;
575                 finalTokens = replacePlaceholder(text, finalTokens, nerSpan, placeholderName, placeholderValues,
576                         possibleValuesByTokensMap);
577                 if (finalLemmas.length > 0) {
578                     finalLemmas = replacePlaceholder(text, finalLemmas, nerSpan, placeholderName, null, null);
579                 }
580                 if (finalTags.length > 0) {
581                     finalTags = replacePlaceholder(text, finalTags, nerSpan, placeholderName, null, null);
582                 }
583             }
584         }
585         return getScore(finalTokens, finalTags, finalLemmas, actionConfiguration, template);
586     }
587
588     private NLPTokenComparisonResult getScore(String[] tokens, String[] tags, String[] lemmas,
589             ActionTemplateConfiguration actionConfiguration, String template) {
590         switch (actionConfiguration.type) {
591             case "tokens":
592                 String[] tokensTemplate = splitString(template, "\\s");
593                 var scoreByTokens = compareTokens(tokens, tags, tokensTemplate);
594                 logger.debug("tokens '{}' score: {}", List.of(tokensTemplate), scoreByTokens.score);
595                 return scoreByTokens;
596             case "lemmas":
597                 String[] lemmasTemplate = splitString(template, "\\s");
598                 var scoreByLemmas = compareTokens(lemmas, tags, lemmasTemplate);
599                 logger.debug("lemmas '{}' score: {}", List.of(lemmasTemplate), scoreByLemmas.score);
600                 return scoreByLemmas;
601             default:
602                 logger.warn("Unsupported template type '{}'", actionConfiguration.type);
603                 return NLPTokenComparisonResult.ZERO;
604         }
605     }
606
607     private @Nullable String sendItemCommand(Item item, String text, ActionTemplateConfiguration actionConfiguration,
608             Map<String, String> placeholderValues) throws IOException, InterpretationException {
609         Object valueTemplate = actionConfiguration.value;
610         boolean silent = actionConfiguration.silent;
611         String replacedValue = null;
612         Command command = null;
613         // Special type handling
614         switch (item.getType()) {
615             case "Color":
616                 if (valueTemplate instanceof String) {
617                     replacedValue = templatePlaceholders((String) valueTemplate, item, placeholderValues,
618                             actionConfiguration.placeholders);
619                     if (COLOR_HEX_PATTERN.matcher(replacedValue).matches()) {
620                         Color rgb = Color.decode(replacedValue);
621                         try {
622                             command = HSBType.fromRGB(rgb.getRed(), rgb.getGreen(), rgb.getBlue());
623                         } catch (NumberFormatException e) {
624                             logger.warn("Unable to parse value '{}' as color", replacedValue);
625                             throw new InterpretationException(config.failureMessage);
626                         }
627                     }
628                 }
629                 break;
630             case "Group":
631                 var groupItem = (GroupItem) item;
632                 var memberTargetsConfig = actionConfiguration.memberTargets;
633                 if (memberTargetsConfig != null) {
634                     Set<Item> targetMembers = getTargetMembers(groupItem, memberTargetsConfig);
635                     logger.debug("{} target members were found in group {}", targetMembers.size(), groupItem.getName());
636                     if (!targetMembers.isEmpty()) {
637                         // swap the command target by the matched members
638                         boolean ok = true;
639                         boolean groupsilent = true;
640                         for (var targetMember : targetMembers) {
641                             var response = sendItemCommand(targetMember, text, actionConfiguration, placeholderValues);
642                             if (config.failureMessage.equals(response)) {
643                                 ok = false;
644                             }
645                             if (response != null) {
646                                 groupsilent = false;
647                             }
648                         }
649                         return ok ? (groupsilent ? null : config.commandSentMessage) : config.failureMessage;
650                     } else {
651                         logger.warn("configured targetMembers were not found in group '{}'", groupItem.getName());
652                         throw new InterpretationException(config.failureMessage);
653                     }
654                 }
655                 break;
656         }
657         if (command == null) {
658             // Common behavior
659             var objectValue = actionConfiguration.value;
660             if (objectValue != null) {
661                 if (replacedValue == null) {
662                     var stringValue = String.valueOf(objectValue);
663                     replacedValue = templatePlaceholders(stringValue, item, placeholderValues,
664                             actionConfiguration.placeholders);
665                 }
666                 command = TypeParser.parseCommand(item.getAcceptedCommandTypes(), replacedValue);
667             } else if ("String".equals(item.getType())) {
668                 // We interpret processing will continue in a rule
669                 silent = true;
670                 command = new StringType(text);
671             }
672         }
673         if (command == null) {
674             logger.warn("Command '{}' is not valid for item '{}'.", actionConfiguration.value, item.getName());
675             throw new InterpretationException(config.failureMessage);
676         }
677         eventPublisher.post(ItemEventFactory.createCommandEvent(item.getName(), command));
678         if (silent) {
679             // when silent mode give no result
680             return null;
681         } else {
682             return config.commandSentMessage;
683         }
684     }
685
686     private @Nullable ActionTemplatePlaceholder getItemOptionPlaceholder(Item targetItem, boolean isRead,
687             @Nullable ActionTemplateGroupTargets memberTargets) {
688         if ("Group".equals(targetItem.getType()) && memberTargets != null) {
689             var targetMembers = getTargetMembers((GroupItem) targetItem, memberTargets);
690             logger.debug("{} target members were found in group {}", targetMembers.size(), targetItem.getName());
691             if (!targetMembers.isEmpty()) {
692                 return targetMembers.stream().map(member -> getItemOptionPlaceholder(member, isRead, null))
693                         .reduce(ActionTemplatePlaceholder.withLabel(ITEM_OPTION_PLACEHOLDER), (a, b) -> {
694                             a.nerStaticValues = a.nerStaticValues != null
695                                     ? Stream.concat(Arrays.stream(a.nerStaticValues), Arrays.stream(b.nerStaticValues))
696                                             .distinct().toArray(String[]::new)
697                                     : b.nerStaticValues;
698                             return a;
699                         });
700             }
701         }
702         var cmdDescription = targetItem.getCommandDescription();
703         var stateDescription = targetItem.getStateDescription();
704         var itemOptionPlaceholder = ActionTemplatePlaceholder.withLabel(ITEM_OPTION_PLACEHOLDER);
705         if (!isRead && cmdDescription != null) {
706             itemOptionPlaceholder.nerStaticValues = cmdDescription.getCommandOptions().stream()
707                     .map(option -> option.getLabel() != null ? option.getLabel() : option.getCommand())
708                     .filter(Objects::nonNull).toArray(String[]::new);
709             itemOptionPlaceholder.posStaticValues = cmdDescription.getCommandOptions().stream()
710                     .collect(Collectors.toMap(
711                             option -> option.getLabel() != null ? option.getLabel().replaceAll(" ", "__")
712                                     : option.getCommand().replaceAll(" ", "__"),
713                             option -> option.getCommand().replaceAll(" ", "__")));
714             return itemOptionPlaceholder;
715         } else if (stateDescription != null) {
716             itemOptionPlaceholder.nerStaticValues = stateDescription.getOptions().stream()
717                     .map(option -> option.getLabel() != null ? option.getLabel() : option.getValue())
718                     .filter(Objects::nonNull).toArray(String[]::new);
719             if (isRead) {
720                 itemOptionPlaceholder.posStaticValues = stateDescription.getOptions().stream()
721                         .collect(Collectors.toMap(option -> option.getValue().replaceAll(" ", "__"),
722                                 option -> option.getLabel() != null ? option.getLabel().replaceAll(" ", "__")
723                                         : option.getValue().replaceAll(" ", "__")));
724             } else {
725                 itemOptionPlaceholder.posStaticValues = stateDescription.getOptions().stream()
726                         .collect(Collectors.toMap(
727                                 option -> option.getLabel() != null ? option.getLabel().replaceAll(" ", "__")
728                                         : option.getValue().replaceAll(" ", "__"),
729                                 option -> option.getValue().replaceAll(" ", "__")));
730             }
731             return itemOptionPlaceholder;
732         }
733         logger.warn(
734                 "'{}' is the target item for an action that uses the '{}' placeholder but hasn't got any state/command description",
735                 targetItem.getName(), ITEM_OPTION_PLACEHOLDER_SYMBOL);
736         return null;
737     }
738
739     private Set<Item> getTargetMembers(GroupItem groupItem, ActionTemplateGroupTargets memberTargets) {
740         var childName = memberTargets.itemName;
741         if (!childName.isEmpty()) {
742             return groupItem.getMembers(i -> i.getName().equals(childName));
743         }
744         var itemType = memberTargets.itemType;
745         var requiredItemTags = memberTargets.requiredItemTags;
746         if (!itemType.isEmpty()) {
747             return memberTargets.recursive ? getMembersByTypeRecursive(groupItem, itemType, requiredItemTags)
748                     : getMembersByType(groupItem, itemType, requiredItemTags);
749         }
750         return Set.of();
751     }
752
753     private Set<Item> getMembersByType(GroupItem groupItem, String itemType, String[] requiredItemTags) {
754         return groupItem.getMembers(i -> i.getType().equals(itemType)
755                 && (requiredItemTags.length == 0 || Arrays.stream(requiredItemTags).allMatch(i.getTags()::contains)));
756     }
757
758     private String templatePlaceholders(String text, Item targetItem, Map<String, String> placeholderValues,
759             List<ActionTemplatePlaceholder> placeholders) throws IOException {
760         var placeholdersCopy = new ArrayList<>(placeholders);
761         if (placeholderValues.containsKey(ITEM_OPTION_PLACEHOLDER)) {
762             var itemOptionPlaceholder = getItemOptionPlaceholder(targetItem, false, null);
763             if (itemOptionPlaceholder != null) {
764                 placeholdersCopy.add(itemOptionPlaceholder);
765             }
766         }
767         String finalText = text;
768         // replace placeholder symbols
769         for (var placeholder : placeholdersCopy) {
770             var placeholderValue = placeholderValues.getOrDefault(placeholder.label, "");
771             if (!placeholderValue.isBlank()) {
772                 placeholderValue = applyPOSTransformation(placeholderValue, placeholder);
773             }
774             finalText = finalText.replace(getPlaceholderSymbol(placeholder.label), placeholderValue);
775         }
776         // replace dynamic placeholder symbol
777         var dynamicValue = placeholderValues.getOrDefault(DYNAMIC_PLACEHOLDER, "");
778         if (!dynamicValue.isBlank()) {
779             finalText = finalText.replace(DYNAMIC_PLACEHOLDER_SYMBOL, dynamicValue);
780         }
781         return finalText;
782     }
783
784     protected ActionTemplateConfiguration[] getTypeActionConfigs(String itemType) {
785         File actionConfigsFile = Path.of(TYPE_ACTION_CONFIGS_FOLDER, itemType + ".json").toFile();
786         logger.debug("loading action templates configuration file {}", actionConfigsFile);
787         if (actionConfigsFile.exists() && !actionConfigsFile.isDirectory()) {
788             try {
789                 return ActionTemplateConfiguration.fromJSON(actionConfigsFile);
790             } catch (IOException e) {
791                 logger.warn("unable to parse action templates configuration for type {}: {}", itemType, e.getMessage());
792             }
793         }
794         logger.debug("action templates configuration for type {} not available", itemType);
795         return new ActionTemplateConfiguration[] {};
796     }
797
798     private @Nullable Item getTargetItemByLabelTokens(String[] tokens) {
799         var label = getItemsByLabelTokensMap().entrySet().stream()
800                 .filter(entry -> Arrays.equals(tokens, entry.getKey())).findFirst();
801         if (label.isEmpty()) {
802             return null;
803         }
804         return label.get().getValue();
805     }
806
807     private String[] getTargetItemTokens(String[] tokens, Span itemLabelSpan) {
808         return Arrays.copyOfRange(tokens, itemLabelSpan.getStart(), itemLabelSpan.getEnd());
809     }
810
811     private String[] replacePlaceholder(String text, String[] tokens, Span span, String placeholderName,
812             @Nullable List<NLPPlaceholderData> replacements, @Nullable Map<String[], String> valueByTokensMap) {
813         if (replacements != null) {
814             var spanTokens = Arrays.copyOfRange(tokens, span.getStart(), span.getEnd());
815             String value;
816             if (valueByTokensMap != null) {
817                 var match = valueByTokensMap.entrySet().stream()
818                         .filter(entry -> Arrays.equals(spanTokens, entry.getKey())).findFirst();
819                 if (match.isPresent()) {
820                     value = match.get().getValue();
821                 } else {
822                     value = getSpanTokens(tokens, span, text);
823                 }
824             } else {
825                 value = getSpanTokens(tokens, span, text);
826             }
827             replacements.add(new NLPPlaceholderData(placeholderName, value, span));
828         }
829         var spanStart = span.getStart();
830         try (Stream<String> dataStream = Stream.concat(
831                 spanStart != 0 ? Arrays.stream(Arrays.copyOfRange(tokens, 0, spanStart)) : Stream.of(),
832                 Arrays.stream(new String[] { getPlaceholderSymbol(placeholderName) }))) {
833             var spanEnd = span.getEnd();
834             if (spanEnd != tokens.length) {
835                 return Stream.concat(dataStream, Arrays.stream(Arrays.copyOfRange(tokens, spanEnd, tokens.length)))
836                         .toArray(String[]::new);
837             }
838             return dataStream.toArray(String[]::new);
839         }
840     }
841
842     private String getSpanTokens(String[] tokens, Span span, String original) {
843         return detokenize(Arrays.copyOfRange(tokens, span.getStart(), span.getEnd()), original);
844     }
845
846     private String detokenize(String[] tokens, String text) {
847         if (tokens.length == 1) {
848             return tokens[0];
849         }
850         if (config.detokenizeOptimization) {
851             // this is a dynamic regex to de-tokenize a part of the text based on the original text,
852             // this way we don't miss special characters between tokens.
853             var detokenizeRegex = String.join("[^a-bA-B0-9]?", tokens);
854             var match = Pattern.compile(detokenizeRegex).matcher(text);
855             if (match.find()) {
856                 return match.group();
857             }
858             logger.warn("Unable to detokenize using build-in optimization, consider reporting this case");
859         }
860         // Detokenize should be improved in the future, Detokenizer API seems to be a work in progress in OpenNLP
861         return String.join(" ", tokens);
862     }
863
864     private Tokenizer getTokenizer() {
865         try {
866             Tokenizer tokenizer;
867             var tokenModelFile = Path.of(NLP_FOLDER, "token.bin").toFile();
868             if (tokenModelFile.exists()) {
869                 logger.debug("Tokenizing with model {}", tokenModelFile);
870                 InputStream inputStream = new FileInputStream(tokenModelFile);
871                 TokenizerModel model = new TokenizerModel(inputStream);
872                 tokenizer = new TokenizerME(model);
873             } else {
874                 if (config.useSimpleTokenizer) {
875                     logger.debug("Using simple tokenizer");
876                     tokenizer = SimpleTokenizer.INSTANCE;
877                 } else {
878                     logger.debug("Using white space tokenizer");
879                     tokenizer = WhitespaceTokenizer.INSTANCE;
880                 }
881             }
882             return tokenizer;
883         } catch (IOException e) {
884             logger.warn("IOException while loading tokenizer: {}", e.getMessage());
885             if (config.useSimpleTokenizer) {
886                 logger.warn("Fallback to simple tokenizer");
887                 return SimpleTokenizer.INSTANCE;
888             } else {
889                 logger.warn("Fallback to white space tokenizer");
890                 return WhitespaceTokenizer.INSTANCE;
891             }
892         }
893     }
894
895     private String[] tokenizeText(String text) {
896         return tokenizer.tokenize(text);
897     }
898
899     private Span[] nerWithFile(String[] tokens, String placeholderFileName) throws IOException {
900         File nerModelFile = Path.of(NER_FOLDER, placeholderFileName + ".bin").toFile();
901         File nerDictionaryFile = Path.of(NER_FOLDER, placeholderFileName + ".xml").toFile();
902         if (nerModelFile.exists()) {
903             return nerWithModel(tokens, nerModelFile);
904         } else if (nerDictionaryFile.exists()) {
905             return nerWithDictionary(tokens, nerDictionaryFile, getPlaceholderSymbol(placeholderFileName));
906         } else {
907             logger.debug("No model or dictionary found for '{}'", placeholderFileName);
908             throw new IOException("Unable to find model or dictionary with name: " + placeholderFileName);
909         }
910     }
911
912     private Span[] nerItemLabels(String[] tokens) {
913         return nerValues(tokens, getItemsByLabelTokensMap().keySet().toArray(String[][]::new), ITEM_LABEL_PLACEHOLDER,
914                 false);
915     }
916
917     private Span[] nerWithModel(String[] tokens, File nerModelFile) throws IOException {
918         logger.debug("applying NER with model {}", nerModelFile.getAbsolutePath());
919         TokenNameFinderModel model = new TokenNameFinderModel(nerModelFile);
920         var nameFinder = new NameFinderME(model);
921         return nameFinder.find(tokens);
922     }
923
924     private Span[] nerWithDictionary(String[] tokens, File nerDictFile, String type) throws IOException {
925         logger.debug("applying NER with dictionary {}", nerDictFile);
926         var dictionary = new opennlp.tools.dictionary.Dictionary(new FileInputStream(nerDictFile));
927         return nerWithDictionary(tokens, dictionary, type);
928     }
929
930     private Span[] nerValues(String[] tokens, String[][] valueTokens, String type) {
931         return nerValues(tokens, valueTokens, type, config.caseSensitive);
932     }
933
934     private Span[] nerValues(String[] tokens, String[][] valueTokens, String type, boolean caseSensitive) {
935         var runtimeDictionary = new Dictionary(caseSensitive);
936         Arrays.stream(valueTokens).map(StringList::new).forEach(runtimeDictionary::put);
937         return nerWithDictionary(tokens, runtimeDictionary, type);
938     }
939
940     private Span[] nerWithDictionary(String[] tokens, Dictionary dictionary, String type) {
941         var nameFinder = new DictionaryNameFinder(dictionary, type);
942         return nameFinder.find(tokens);
943     }
944
945     private String[] languagePOSTagging(String[] tokens) throws IOException {
946         var posTaggingModelFile = Path.of(NLP_FOLDER, "pos.bin").toFile();
947         if (posTaggingModelFile.exists()) {
948             logger.debug("applying POSTagging with model {}", posTaggingModelFile);
949             POSModel posModel = new POSModel(posTaggingModelFile);
950             POSTaggerME posTagger = new POSTaggerME(posModel);
951             return posTagger.tag(tokens);
952         } else {
953             logger.debug("POSTagging model not found {}, disabled", posTaggingModelFile);
954             return new String[] {};
955         }
956     }
957
958     private String[] languageLemmatize(String[] tokens, String[] tags) throws IOException {
959         if (tags.length == 0) {
960             logger.debug("Tags are required for lemmatization, disabled");
961             return new String[] {};
962         }
963         var lemmatizeModelFile = Path.of(NLP_FOLDER, "lemma.bin").toFile();
964         var lemmatizeDictionaryFile = Path.of(NLP_FOLDER, "lemma.txt").toFile();
965         Lemmatizer lemmatizer;
966         if (lemmatizeModelFile.exists()) {
967             logger.debug("applying lemmatize with model {}", lemmatizeModelFile);
968             LemmatizerModel model = new LemmatizerModel(lemmatizeModelFile);
969             lemmatizer = new LemmatizerME(model);
970         } else if (lemmatizeDictionaryFile.exists()) {
971             logger.debug("applying lemmatize with dictionary {}", lemmatizeDictionaryFile);
972             lemmatizer = new DictionaryLemmatizer(lemmatizeDictionaryFile);
973         } else {
974             logger.debug("Unable to find lemmatize dictionary or model, disabled");
975             return new String[] {};
976         }
977         return lemmatizer.lemmatize(tokens, tags);
978     }
979
980     private Map<String[], String> getStringsByTokensMap(String[] values) {
981         var map = new HashMap<String[], String>();
982         for (String value : values) {
983             map.put(tokenizeText(value), value);
984         }
985         return map;
986     }
987
988     private String applyPOSTransformation(String text, ActionTemplatePlaceholder placeholderConfig) throws IOException {
989         var singleWorldText = text.replaceAll("\\s", "__");
990         String tag = null;
991         if (placeholderConfig.posFile != null) {
992             File posTaggingDictionary = Path.of(POS_FOLDER, placeholderConfig.posFile + ".xml").toFile();
993             File posTaggingModel = Path.of(POS_FOLDER, placeholderConfig.posFile + ".bin").toFile();
994             if (posTaggingModel.exists()) {
995                 POSModel posModel = new POSModel(posTaggingModel);
996                 var tags = new POSTaggerME(posModel).tag(new String[] { singleWorldText });
997                 if (tags.length > 0 && !"O".equals(tags[0])) {
998                     tag = tags[0];
999                 }
1000             } else if (posTaggingDictionary.exists()) {
1001                 POSDictionary posDictionary = POSDictionary.create(new FileInputStream(posTaggingDictionary));
1002                 var tags = posDictionary.getTags(singleWorldText);
1003                 if (tags != null && tags.length > 0 && !"O".equals(tags[0])) {
1004                     tag = tags[0];
1005                 }
1006             } else {
1007                 logger.warn("configured pos transformation file not found {}", placeholderConfig.posFile);
1008             }
1009         } else if (placeholderConfig.posStaticValues != null) {
1010             var dictionary = new POSDictionary(config.caseSensitive);
1011             for (var entry : placeholderConfig.posStaticValues.entrySet()) {
1012                 dictionary.put(entry.getKey(), entry.getValue());
1013             }
1014             var tokenTags = dictionary.getTags(singleWorldText);
1015             if (tokenTags != null && tokenTags.length > 0) {
1016                 tag = tokenTags[0];
1017             }
1018         } else {
1019             // no transformation configured
1020             return text;
1021         }
1022         if (tag == null) {
1023             return "";
1024         }
1025         return tag.replaceAll("__", " ");
1026     }
1027
1028     private Map<String[], Item> getItemsByLabelTokensMap() {
1029         return getItemsMaps().itemLabelByTokens;
1030     }
1031
1032     private Map<Item, ActionTemplateConfiguration[]> getItemsWithActionConfigs() {
1033         return getItemsMaps().itemsWithActionConfigs;
1034     }
1035
1036     private NLPItemMaps getItemsMaps() {
1037         var itemMaps = this.nlpItemMaps;
1038         if (itemMaps == null) {
1039             var itemByLabelTokens = new HashMap<String[], Item>();
1040             var itemsWithActionConfigs = new HashMap<Item, ActionTemplateConfiguration[]>();
1041             var labelList = new ArrayList<String>();
1042             for (Item item : itemRegistry.getAll()) {
1043                 var alternativeNames = new ArrayList<String>();
1044                 var label = item.getLabel();
1045                 if (label != null) {
1046                     alternativeNames.add(label);
1047                 }
1048                 MetadataKey key = new MetadataKey("synonyms", item.getName());
1049                 Metadata synonymsMetadata = metadataRegistry.get(key);
1050                 if (synonymsMetadata != null) {
1051                     String[] synonyms = synonymsMetadata.getValue().split(",");
1052                     if (synonyms.length > 0) {
1053                         alternativeNames.addAll(List.of(synonyms));
1054                     }
1055                 }
1056                 if (!alternativeNames.isEmpty()) {
1057                     for (var alternative : alternativeNames) {
1058                         var lowerLabel = alternative.toLowerCase();
1059                         if (labelList.contains(lowerLabel)) {
1060                             logger.debug("Multiple items with label '{}', this is not supported, ignoring '{}'",
1061                                     lowerLabel, item.getName());
1062                             continue;
1063                         }
1064                         labelList.add(lowerLabel);
1065                         itemByLabelTokens.put(tokenizeText(lowerLabel), item);
1066                     }
1067                 }
1068                 var metadata = metadataRegistry.get(new MetadataKey(SERVICE_ID, item.getName()));
1069                 if (metadata != null) {
1070                     try {
1071                         itemsWithActionConfigs.put(item, ActionTemplateConfiguration.fromMetadata(metadata));
1072                     } catch (IOException e) {
1073                         logger.warn("Unable to parse template action configs for item '{}': {}", item.getName(),
1074                                 e.getMessage());
1075                     }
1076                 }
1077             }
1078             itemMaps = new NLPItemMaps(itemByLabelTokens, itemsWithActionConfigs);
1079             this.nlpItemMaps = itemMaps;
1080         }
1081         return itemMaps;
1082     }
1083
1084     private NLPTokenComparisonResult compareTokens(String[] tokens, String[] tokenTags, String[] tokensTemplate) {
1085         if (tokens.length == 0 || tokensTemplate.length == 0) {
1086             return NLPTokenComparisonResult.ZERO;
1087         }
1088         int score = 0;
1089         int processedIndex = 0;
1090         // avoid use tags if not available for all tokens
1091         var tagsEnabled = tokenTags.length == tokens.length;
1092         for (int i = 0; i < tokens.length; i++) {
1093             String token = tokens[i];
1094             // Tag is used here to allow optional matching by language POS tag
1095             String tag = tagsEnabled ? tokenTags[i] : null;
1096             if (processedIndex == tokensTemplate.length) {
1097                 return NLPTokenComparisonResult.ZERO;
1098             }
1099             String tokenTemplate = tokensTemplate[processedIndex];
1100             var tokenAlternatives = splitString(tokenTemplate, "\\|");
1101             boolean isMatch = false;
1102             for (var tokenAlternative : tokenAlternatives) {
1103                 if (DYNAMIC_PLACEHOLDER_SYMBOL.equals(tokenAlternative)) {
1104                     if (tokenAlternatives.length > 1) {
1105                         logger.warn("Providing the dynamic placeholder as an optional token is not allowed");
1106                         return NLPTokenComparisonResult.ZERO;
1107                     }
1108                     if (tokensTemplate.length == 1) {
1109                         logger.warn("Providing the dynamic placeholder alone is not allowed");
1110                         return NLPTokenComparisonResult.ZERO;
1111                     }
1112                     if (processedIndex + 1 == tokensTemplate.length) {
1113                         // the dynamic placeholder is the last value in the template token array, returning score
1114                         // note that the dynamic placeholder does not count for score
1115                         return new NLPTokenComparisonResult(score, new Span(i, tokensTemplate.length));
1116                     }
1117                     // here we cut and reverse the arrays to run score backwards until the dynamic placeholder
1118                     var unprocessedTokens = Arrays.copyOfRange(tokens, i, tokens.length);
1119                     var unprocessedTags = tagsEnabled ? Arrays.copyOfRange(tokenTags, i, tokenTags.length)
1120                             : new String[] {};
1121                     var unprocessedTokensTemplate = Arrays.copyOfRange(tokensTemplate, processedIndex,
1122                             tokensTemplate.length);
1123                     Collections.reverse(Arrays.asList(unprocessedTokens));
1124                     Collections.reverse(Arrays.asList(unprocessedTags));
1125                     Collections.reverse(Arrays.asList(unprocessedTokensTemplate));
1126                     if (DYNAMIC_PLACEHOLDER_SYMBOL.equals(unprocessedTokens[0])) {
1127                         // here dynamic placeholder should be at the end, but if it's also at the beginning we should
1128                         // abort
1129                         logger.warn("Using multiple dynamic placeholders is not supported");
1130                         return NLPTokenComparisonResult.ZERO;
1131                     }
1132                     var partialScoreResult = compareTokens(unprocessedTokens, unprocessedTags,
1133                             unprocessedTokensTemplate);
1134                     if (NLPTokenComparisonResult.ZERO.equals(partialScoreResult)) {
1135                         return NLPTokenComparisonResult.ZERO;
1136                     } else {
1137                         var dynamicSpan = partialScoreResult.dynamicSpan;
1138                         if (dynamicSpan == null) {
1139                             logger.error(
1140                                     "dynamic span missed, this should never happen, please open an issue; aborting");
1141                             return NLPTokenComparisonResult.ZERO;
1142                         }
1143                         return new NLPTokenComparisonResult(score + partialScoreResult.score,
1144                                 new Span(i, tokens.length - (dynamicSpan.getStart())));
1145                     }
1146                 }
1147                 if (tokenAlternative.equals(token)) {
1148                     isMatch = true;
1149                     break;
1150                 }
1151             }
1152             if (isMatch) {
1153                 processedIndex++;
1154                 score++;
1155             } else if (tag != null && optionalLanguageTags.contains(tag)) {
1156                 logger.debug("part '{}' tagged as '{}' skipped", token, tag);
1157             } else {
1158                 return NLPTokenComparisonResult.ZERO;
1159             }
1160         }
1161         return new NLPTokenComparisonResult(score, null);
1162     }
1163
1164     private String[] splitString(String template, String regex) {
1165         return Arrays.stream(template.split(regex)).map(String::trim).toArray(String[]::new);
1166     }
1167
1168     private void invalidate() {
1169         logger.debug("Invalidate cached item data");
1170         nlpItemMaps = null;
1171     }
1172
1173     private void reloadConfigs() {
1174         optionalLanguageTags = Arrays.stream(this.config.optionalLanguageTags.split(",")).filter(i -> !i.isEmpty())
1175                 .collect(Collectors.toList());
1176         tokenizer = getTokenizer();
1177     }
1178
1179     private static class NLPInfo {
1180         public final String[] tokens;
1181         public final String[] lemmas;
1182         public final String[] tags;
1183
1184         public NLPInfo(String[] tokens, String[] lemmas, String[] tags) {
1185             this.tokens = tokens;
1186             this.lemmas = lemmas;
1187             this.tags = tags;
1188         }
1189     }
1190
1191     private static void createFolder(Logger logger, String nlpFolder) {
1192         File directory = new File(nlpFolder);
1193         if (!directory.exists()) {
1194             if (directory.mkdir()) {
1195                 logger.debug("dir created {}", nlpFolder);
1196             }
1197         }
1198     }
1199
1200     public static String getPlaceholderSymbol(String name) {
1201         return "$" + name.replaceAll("\\s", "_");
1202     }
1203
1204     private static class NLPInterpretationResult {
1205         public final Item targetItem;
1206         public final ActionTemplateConfiguration actionConfig;
1207         public final Map<String, String> placeholderValues;
1208
1209         public NLPInterpretationResult(Item targetItem, ActionTemplateConfiguration actionConfig,
1210                 Map<String, String> placeholderValues) {
1211             this.targetItem = targetItem;
1212             this.actionConfig = actionConfig;
1213             this.placeholderValues = placeholderValues;
1214         }
1215
1216         public static NLPInterpretationResult from(Item item, ActionTemplateConfiguration actionConfig,
1217                 List<NLPPlaceholderData> placeholderValues) {
1218             return new NLPInterpretationResult(item, actionConfig, placeholderValues.stream()
1219                     .collect(Collectors.toMap(i -> i.placeholderName, i -> i.placeholderValue)));
1220         }
1221     }
1222
1223     public static class NLPPlaceholderData {
1224         private final String placeholderName;
1225         private final String placeholderValue;
1226         private final Span placeholderSpan;
1227
1228         private NLPPlaceholderData(String placeholderName, String placeholderValue, Span placeholderSpan) {
1229             this.placeholderName = placeholderName;
1230             this.placeholderValue = placeholderValue;
1231             this.placeholderSpan = placeholderSpan;
1232         }
1233     }
1234
1235     private static class NLPTokenComparisonResult {
1236         public static final NLPTokenComparisonResult ZERO = new NLPTokenComparisonResult(0, null);
1237         public final int score;
1238         public final @Nullable Span dynamicSpan;
1239
1240         private NLPTokenComparisonResult(int score, @Nullable Span dynamicSpan) {
1241             this.score = score;
1242             this.dynamicSpan = dynamicSpan;
1243         }
1244     }
1245
1246     private static class NLPItemMaps {
1247         private final Map<String[], Item> itemLabelByTokens;
1248         private final Map<Item, ActionTemplateConfiguration[]> itemsWithActionConfigs;
1249
1250         private NLPItemMaps(Map<String[], Item> itemLabelByTokens,
1251                 Map<Item, ActionTemplateConfiguration[]> itemsWithActionConfigs) {
1252             this.itemLabelByTokens = itemLabelByTokens;
1253             this.itemsWithActionConfigs = itemsWithActionConfigs;
1254         }
1255     }
1256 }