[voice] Add conversations & LLM tools support (#5620)

* [voice] Update HLI interface for LLM implementations

Signed-off-by: Florian Hotze <dev@florianhotze.com>
Co-authored-by: Miguel Álvarez <miguelwork92@gmail.com>
Co-authored-by: Kai Kreuzer <kai@openhab.org>
This commit is contained in:
Florian Hotze
2026-06-04 16:46:02 +02:00
committed by GitHub
co-authored by Miguel Álvarez Kai Kreuzer
parent 927f244419
commit de3c272bc5
56 changed files with 3053 additions and 196 deletions
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.io.rest.voice.internal;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.voice.text.conversation.Conversation;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* A DTO for serialising {@link Conversation}s on the REST API.
*
* @author Miguel Álvarez Díez - Initial contribution
*/
@Schema(name = "Conversation")
@NonNullByDefault
public record ConversationDTO(String id, List<MessageDTO> messages) {
@Schema(name = "Message")
public record MessageDTO(int id, String role, String content) {
}
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.io.rest.voice.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.voice.text.conversation.Conversation;
/**
* Mapper class that maps {@link Conversation} instances to their respective DTOs.
*
* @author Miguel Álvarez Díez - Initial contribution
*/
@NonNullByDefault
public class ConversationMapper {
/**
* Maps a {@link Conversation} to a {@link ConversationDTO}.
*
* @param conversation the conversation
*
* @return the corresponding DTO
*/
public static ConversationDTO map(Conversation conversation) {
return new ConversationDTO(conversation.getId(), conversation.getMessages().stream()
.map(m -> new ConversationDTO.MessageDTO(m.id(), m.role().name(), m.content())).toList());
}
}
@@ -18,6 +18,7 @@ import java.util.Objects;
import javax.annotation.security.RolesAllowed;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.POST;
@@ -47,7 +48,10 @@ import org.openhab.core.voice.TTSService;
import org.openhab.core.voice.Voice;
import org.openhab.core.voice.VoiceManager;
import org.openhab.core.voice.text.HumanLanguageInterpreter;
import org.openhab.core.voice.text.InterpretationArguments;
import org.openhab.core.voice.text.InterpretationException;
import org.openhab.core.voice.text.conversation.Conversation;
import org.openhab.core.voice.text.conversation.ConversationManager;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
@@ -56,8 +60,6 @@ import org.osgi.service.jaxrs.whiteboard.propertytypes.JSONRequired;
import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsApplicationSelect;
import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsName;
import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -89,19 +91,56 @@ public class VoiceResource implements RESTResource {
/** The URI path to this resource */
public static final String PATH_VOICE = "voice";
private final Logger logger = LoggerFactory.getLogger(VoiceResource.class);
private final LocaleService localeService;
private final AudioManager audioManager;
private final VoiceManager voiceManager;
private final ConversationManager conversationManager;
@Activate
public VoiceResource( //
final @Reference LocaleService localeService, //
final @Reference AudioManager audioManager, //
final @Reference VoiceManager voiceManager) {
final @Reference VoiceManager voiceManager, //
final @Reference ConversationManager conversationManager) {
this.localeService = localeService;
this.audioManager = audioManager;
this.voiceManager = voiceManager;
this.conversationManager = conversationManager;
}
@GET
@Path("/conversations/{id: [a-zA-Z_0-9-]+}")
@Produces(MediaType.APPLICATION_JSON)
@Operation(operationId = "getConversationById", summary = "Get a conversation.", responses = {
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ConversationDTO.class))),
@ApiResponse(responseCode = "404", description = "Conversation not found") })
public Response getConversation(@PathParam("id") @Parameter(description = "conversation id") String id) {
Conversation conversation = conversationManager.getConversation(id, false);
if (conversation == null) {
return JSONResponse.createErrorResponse(Status.NOT_FOUND, "No conversation found");
}
return Response.ok(ConversationMapper.map(conversation)).build();
}
@DELETE
@Path("/conversations/{id: [a-zA-Z_0-9-]+}")
@Operation(operationId = "deleteConversationById", summary = "Deletes a full conversation or its messages since a given message id.", responses = {
@ApiResponse(responseCode = "200", description = "OK"),
@ApiResponse(responseCode = "404", description = "Conversation or message not found") })
public Response deleteConversation(@PathParam("id") @Parameter(description = "conversation id") String id,
@QueryParam("messageId") @Parameter(description = "Optional message ID") @Nullable Integer messageID) {
Conversation conversation = conversationManager.getConversation(id, false);
if (conversation == null) {
return JSONResponse.createErrorResponse(Status.NOT_FOUND, "Conversation not found");
}
if (messageID != null) {
if (!conversation.removeSinceMessage(messageID)) {
return JSONResponse.createErrorResponse(Status.NOT_FOUND, "Message not found");
}
} else {
conversationManager.removeConversation(id);
}
return Response.ok().build();
}
@GET
@@ -146,30 +185,24 @@ public class VoiceResource implements RESTResource {
@ApiResponse(responseCode = "400", description = "interpretation exception occurs") })
public Response interpret(
@HeaderParam(HttpHeaders.ACCEPT_LANGUAGE) @Parameter(description = "language") @Nullable String language,
@QueryParam("conversation") @Parameter(description = "Conversation id") @Nullable String conversationId,
@QueryParam("llmTools") @Parameter(description = "Comma separated list of llm-tool ids") @Nullable List<String> llmToolIds,
@QueryParam("locationItem") @Parameter(description = "Location item id to contextualize the command") @Nullable String locationItem,
@Parameter(description = "text to interpret", required = true) String text,
@PathParam("ids") @Parameter(description = "comma separated list of interpreter ids") List<String> ids) {
final Locale locale = localeService.getLocale(language);
List<HumanLanguageInterpreter> hlis = voiceManager.getHLIsByIds(ids);
if (hlis.isEmpty()) {
if (voiceManager.getHLIsByIds(ids).isEmpty()) {
return JSONResponse.createErrorResponse(Status.NOT_FOUND, "No interpreter found");
}
String answer = "";
String error = null;
for (HumanLanguageInterpreter interpreter : hlis) {
try {
answer = interpreter.interpret(locale, text);
logger.debug("Interpretation result: {}", answer);
error = null;
break;
} catch (InterpretationException e) {
logger.debug("Interpretation exception: {}", e.getMessage());
error = Objects.requireNonNullElse(e.getMessage(), "Unexpected error");
}
}
if (error != null) {
return JSONResponse.createErrorResponse(Status.BAD_REQUEST, error);
} else {
final Locale locale = localeService.getLocale(language);
InterpretationArguments args = new InterpretationArguments(String.join(",", ids),
Objects.requireNonNullElse(conversationId, ""), llmToolIds == null ? "" : String.join(",", llmToolIds),
locationItem, locale);
try {
String answer = voiceManager.interpret(text, args);
return Response.ok(answer, MediaType.TEXT_PLAIN).build();
} catch (InterpretationException e) {
return JSONResponse.createErrorResponse(Status.BAD_REQUEST, e.getMessage());
}
}
@@ -183,6 +216,9 @@ public class VoiceResource implements RESTResource {
@ApiResponse(responseCode = "400", description = "interpretation exception occurs") })
public Response interpret(
@HeaderParam(HttpHeaders.ACCEPT_LANGUAGE) @Parameter(description = "language") @Nullable String language,
@QueryParam("conversation") @Parameter(description = "Conversation id") @Nullable String conversationId,
@QueryParam("llmTools") @Parameter(description = "Comma separated list of llm-tool ids") @Nullable List<String> llmToolIds,
@QueryParam("locationItem") @Parameter(description = "Location item id to contextualize the command") @Nullable String locationItem,
@Parameter(description = "text to interpret", required = true) String text) {
final Locale locale = localeService.getLocale(language);
HumanLanguageInterpreter hli = voiceManager.getHLI();
@@ -190,8 +226,10 @@ public class VoiceResource implements RESTResource {
return JSONResponse.createErrorResponse(Status.NOT_FOUND, "No interpreter found");
}
InterpretationArguments args = new InterpretationArguments("", Objects.requireNonNullElse(conversationId, ""),
llmToolIds == null ? "" : String.join(",", llmToolIds), locationItem, locale);
try {
String answer = hli.interpret(locale, text);
String answer = voiceManager.interpret(text, args);
return Response.ok(answer, MediaType.TEXT_PLAIN).build();
} catch (InterpretationException e) {
return JSONResponse.createErrorResponse(Status.BAD_REQUEST, e.getMessage());
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.io.rest.voice.internal;
import static org.junit.jupiter.api.Assertions.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.core.voice.text.conversation.Conversation;
import org.openhab.core.voice.text.conversation.ConversationException;
import org.openhab.core.voice.text.conversation.ConversationRole;
/**
* Test class for {@link ConversationMapper}.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public class ConversationMapperTest {
@Test
public void testMap() throws ConversationException {
String id = "test-conversation";
Conversation conversation = new Conversation(id);
conversation.addMessage(ConversationRole.USER, "Hello");
conversation.addMessage(ConversationRole.OPENHAB, "Hi there!");
ConversationDTO dto = ConversationMapper.map(conversation);
assertNotNull(dto);
assertEquals(id, dto.id());
assertEquals(2, dto.messages().size());
assertEquals(ConversationRole.USER.name(), dto.messages().get(0).role());
assertEquals("Hello", dto.messages().get(0).content());
assertEquals(ConversationRole.OPENHAB.name(), dto.messages().get(1).role());
assertEquals("Hi there!", dto.messages().get(1).content());
}
}
@@ -29,6 +29,7 @@ import org.openhab.core.voice.KSService;
import org.openhab.core.voice.STTService;
import org.openhab.core.voice.TTSService;
import org.openhab.core.voice.text.HumanLanguageInterpreter;
import org.openhab.core.voice.text.InterpretationArguments;
import org.openhab.core.voice.text.InterpretationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -169,7 +170,7 @@ public class Voice {
*/
@ActionDoc(text = "interprets a given text by the default human language interpreter", returns = "human language response")
public static String interpret(@ParamDoc(name = "text") Object text) {
return interpret(text, null);
return interpret(text, null, null, null);
}
/**
@@ -183,9 +184,30 @@ public class Voice {
@ActionDoc(text = "interprets a given text by given human language interpreter(s)", returns = "human language response")
public static String interpret(@ParamDoc(name = "text") Object text,
@ParamDoc(name = "interpreters") @Nullable String interpreters) {
return interpret(text, interpreters, null, null);
}
/**
* Interprets the given text with a given Human Language Interpreter.
*
* In case of interpretation error, the error message is played using the default audio sink.
*
* @param text The text to interpret
* @param interpreters Comma separated list of human language text interpreters to use
* @param conversationId The id of the conversation to use
* @param llmTools Comma separated list of LLM tools to use
*/
@ActionDoc(text = "interprets a given text by given human language interpreter(s)", returns = "human language response")
public static String interpret(@ParamDoc(name = "text") Object text,
@ParamDoc(name = "interpreters") @Nullable String interpreters,
@ParamDoc(name = "conversationId") @Nullable String conversationId,
@ParamDoc(name = "llm-tools") @Nullable String llmTools) {
String response;
try {
response = VoiceActionService.voiceManager.interpret(text.toString(), interpreters);
response = VoiceActionService.voiceManager.interpret(text.toString(),
new InterpretationArguments(Objects.requireNonNullElse(interpreters, ""),
Objects.requireNonNullElse(conversationId, ""), Objects.requireNonNullElse(llmTools, ""),
null, null));
} catch (InterpretationException e) {
String message = Objects.requireNonNullElse(e.getMessage(), "");
say(message);
@@ -206,10 +228,12 @@ public class Voice {
*/
@ActionDoc(text = "interprets a given text by given human language interpreter(s) and using the given sink", returns = "human language response")
public static String interpret(@ParamDoc(name = "text") Object text,
@ParamDoc(name = "interpreters") String interpreters, @ParamDoc(name = "sink") @Nullable String sink) {
@ParamDoc(name = "interpreters") @Nullable String interpreters,
@ParamDoc(name = "sink") @Nullable String sink) {
String response;
try {
response = VoiceActionService.voiceManager.interpret(text.toString(), interpreters);
response = VoiceActionService.voiceManager.interpret(text.toString(),
new InterpretationArguments(interpreters != null ? interpreters : "", "", "", null, null));
} catch (InterpretationException e) {
String message = Objects.requireNonNullElse(e.getMessage(), "");
if (sink != null) {
@@ -22,6 +22,7 @@ import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.audio.AudioSink;
import org.openhab.core.audio.AudioSource;
import org.openhab.core.voice.text.HumanLanguageInterpreter;
import org.openhab.core.voice.text.interpreter.llm.LLMTool;
/**
* Describes dialog configured services and options.
@@ -32,7 +33,7 @@ import org.openhab.core.voice.text.HumanLanguageInterpreter;
public record DialogContext(@Nullable DTService dt, @Nullable String keyword, STTService stt, TTSService tts,
@Nullable Voice voice, List<HumanLanguageInterpreter> hlis, AudioSource source, AudioSink sink, Locale locale,
String dialogGroup, @Nullable String locationItem, @Nullable String listeningItem,
@Nullable String listeningMelody) {
@Nullable String listeningMelody, @Nullable String conversationId, List<LLMTool> llmTools) {
/**
* Builder for {@link DialogContext}
@@ -47,7 +48,9 @@ public record DialogContext(@Nullable DTService dt, @Nullable String keyword, ST
private @Nullable TTSService tts;
private @Nullable Voice voice;
private List<HumanLanguageInterpreter> hlis = List.of();
private List<LLMTool> llmTools = List.of();
// options
private @Nullable String conversationId;
private String dialogGroup = "default";
private @Nullable String locationItem;
private @Nullable String listeningItem;
@@ -130,6 +133,20 @@ public record DialogContext(@Nullable DTService dt, @Nullable String keyword, ST
return this;
}
public Builder withConversationId(@Nullable String conversationId) {
if (conversationId != null) {
this.conversationId = conversationId;
}
return this;
}
public Builder withLLMTools(List<LLMTool> llmTools) {
if (!llmTools.isEmpty()) {
this.llmTools = llmTools;
}
return this;
}
public Builder withDialogGroup(@Nullable String dialogGroup) {
if (dialogGroup != null) {
this.dialogGroup = dialogGroup;
@@ -199,7 +216,8 @@ public record DialogContext(@Nullable DTService dt, @Nullable String keyword, ST
throw new IllegalStateException("Cannot build dialog context: " + String.join(", ", errors) + ".");
} else {
return new DialogContext(dtService, keyword, sttService, ttsService, voice, hliServices, audioSource,
audioSink, locale, dialogGroup, locationItem, listeningItem, listeningMelody);
audioSink, locale, dialogGroup, locationItem, listeningItem, listeningMelody, conversationId,
llmTools);
}
}
}
@@ -57,6 +57,14 @@ public class DialogRegistration {
* List of interpreters
*/
public List<String> hliIds = List.of();
/**
* Conversation id.
*/
public @Nullable String conversationId;
/**
* List of LLM tools
*/
public List<String> llmToolIds = List.of();
/**
* Dialog locale
*/
@@ -23,6 +23,7 @@ import org.openhab.core.audio.AudioSource;
import org.openhab.core.audio.AudioStream;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.voice.text.HumanLanguageInterpreter;
import org.openhab.core.voice.text.InterpretationArguments;
import org.openhab.core.voice.text.InterpretationException;
/**
@@ -120,7 +121,7 @@ public interface VoiceManager {
* Interprets the passed string using the default services for HLI and locale.
*
* @param text The text to interpret
* @throws InterpretationException
* @throws InterpretationException when unable to succeed.
* @return a human language response
*/
String interpret(String text) throws InterpretationException;
@@ -133,8 +134,19 @@ public interface VoiceManager {
* @throws InterpretationException
* @return a human language response
*/
@Deprecated
String interpret(String text, @Nullable String hliIdList) throws InterpretationException;
/**
* Interprets the passed string using a particular HLI service and the default locale.
*
* @param text The text to interpret
* @param args instance of {@link InterpretationArguments} with the options for this execution.
* @throws InterpretationException when unable to succeed.
* @return a human language response
*/
String interpret(String text, @Nullable InterpretationArguments args) throws InterpretationException;
/**
* Determines the preferred voice for the currently set locale
*
@@ -332,7 +344,7 @@ public interface VoiceManager {
* Retrieves a HumanLanguageInterpreter collection.
* If no services are available returns an empty list.
*
* @param ids List of HLI service ids to use or null
* @param ids List of HLI service ids to use
* @return a List<HumanLanguageInterpreter> or empty, if none of the services is available
*/
List<HumanLanguageInterpreter> getHLIsByIds(List<String> ids);
@@ -59,6 +59,12 @@ import org.openhab.core.voice.TTSException;
import org.openhab.core.voice.Voice;
import org.openhab.core.voice.text.HumanLanguageInterpreter;
import org.openhab.core.voice.text.InterpretationException;
import org.openhab.core.voice.text.InterpreterContext;
import org.openhab.core.voice.text.conversation.Conversation;
import org.openhab.core.voice.text.conversation.ConversationException;
import org.openhab.core.voice.text.conversation.ConversationManager;
import org.openhab.core.voice.text.conversation.ConversationRole;
import org.openhab.core.voice.text.interpreter.llm.LLMTool;
import org.osgi.framework.Bundle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -83,6 +89,7 @@ public class DialogProcessor implements KSListener, STTListener {
private final Logger logger = LoggerFactory.getLogger(DialogProcessor.class);
private final WeakHashMap<String, DialogContext> activeDialogGroups;
public final DialogContext dialogContext;
private final ConversationManager conversationManager;
private @Nullable List<ToneSynthesizer.Tone> listeningMelody;
private final EventPublisher eventPublisher;
private final TranslationProvider i18nProvider;
@@ -111,11 +118,13 @@ public class DialogProcessor implements KSListener, STTListener {
private @Nullable ToneSynthesizer toneSynthesizer;
public DialogProcessor(DialogContext context, DialogEventListener eventListener, EventPublisher eventPublisher,
WeakHashMap<String, DialogContext> activeDialogGroups, TranslationProvider i18nProvider, Bundle bundle) {
WeakHashMap<String, DialogContext> activeDialogGroups, TranslationProvider i18nProvider,
ConversationManager conversationManager, Bundle bundle) {
this.dialogContext = context;
this.eventListener = eventListener;
this.eventPublisher = eventPublisher;
this.i18nProvider = i18nProvider;
this.conversationManager = conversationManager;
this.activeDialogGroups = activeDialogGroups;
this.bundle = bundle;
var dt = context.dt();
@@ -358,17 +367,33 @@ public class DialogProcessor implements KSListener, STTListener {
logger.debug("Text recognized: {}", question);
toggleProcessing(false);
eventListener.onBeforeDialogInterpretation(dialogContext);
String answer = "";
@Nullable
String conversationId = dialogContext.conversationId();
Conversation conversation = conversationManager
.getConversation(Objects.requireNonNullElse(conversationId, ""));
String error = null;
for (HumanLanguageInterpreter interpreter : dialogContext.hlis()) {
try {
answer = interpreter.interpret(dialogContext.locale(), question, dialogContext);
logger.debug("Interpretation result: {}", answer);
error = null;
break;
} catch (InterpretationException e) {
logger.debug("Interpretation exception: {}", e.getMessage());
error = Objects.requireNonNullElse(e.getMessage(), "Unexpected error");
String answer = "";
try {
conversation.addMessage(ConversationRole.USER, question);
} catch (ConversationException e) {
logger.debug("Unable to add message to conversation: {}", e.getMessage(), e);
error = "Unable to add message to conversation: " + e.getMessage();
}
if (error == null) {
List<LLMTool> tools = dialogContext.llmTools();
InterpreterContext interpreterContext = new InterpreterContext(conversation, tools,
dialogContext.locationItem());
for (HumanLanguageInterpreter interpreter : dialogContext.hlis()) {
try {
answer = interpreter.interpret(dialogContext.locale(), interpreterContext);
error = null;
logger.debug("Interpretation result from interpreter '{}': {}", interpreter.getId(),
answer);
break;
} catch (InterpretationException e) {
logger.debug("Interpretation exception: {}", e.getMessage());
error = Objects.requireNonNullElse(e.getMessage(), "Unexpected error");
}
}
}
say(error != null ? error : answer);
@@ -0,0 +1,137 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.internal;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.voice.text.conversation.Conversation;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link VoiceConfiguration} class holds the configuration for the {@link VoiceManagerImpl}.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public class VoiceConfiguration {
// the default keyword to use if no other is configured
public static final String DEFAULT_KEYWORD = "Wakeup";
// constants for the configuration properties
public static final String CONFIG_URI = "system:voice";
public static final String CONFIG_KEYWORD = "keyword";
public static final String CONFIG_LISTENING_ITEM = "listeningItem";
public static final String CONFIG_LISTENING_MELODY = "listeningMelody";
public static final String CONFIG_DEFAULT_HLI = "defaultHLI";
public static final String CONFIG_DEFAULT_KS = "defaultKS";
public static final String CONFIG_DEFAULT_STT = "defaultSTT";
public static final String CONFIG_DEFAULT_TTS = "defaultTTS";
public static final String CONFIG_DEFAULT_VOICE = "defaultVoice";
public static final String CONFIG_PREFIX_DEFAULT_VOICE = "defaultVoice.";
public static final String CONFIG_CONVERSATION_HISTORY_LIMIT = "conversationHistoryLimit";
private final Logger logger = LoggerFactory.getLogger(VoiceConfiguration.class);
private String keyword = DEFAULT_KEYWORD;
private @Nullable String listeningItem;
private @Nullable String listeningMelody;
private @Nullable String defaultTTS;
private @Nullable String defaultSTT;
private @Nullable String defaultKS;
private @Nullable String defaultHLI;
private @Nullable String defaultVoice;
private int conversationHistoryLimit = Conversation.DEFAULT_MAX_MESSAGES;
private final Map<String, String> defaultVoices = new HashMap<>();
public void update(Map<String, Object> config) {
this.keyword = config.containsKey(CONFIG_KEYWORD) ? config.get(CONFIG_KEYWORD).toString() : DEFAULT_KEYWORD;
this.listeningItem = config.containsKey(CONFIG_LISTENING_ITEM) ? config.get(CONFIG_LISTENING_ITEM).toString()
: null;
this.listeningMelody = config.containsKey(CONFIG_LISTENING_MELODY)
? config.get(CONFIG_LISTENING_MELODY).toString()
: null;
this.defaultTTS = config.containsKey(CONFIG_DEFAULT_TTS) ? config.get(CONFIG_DEFAULT_TTS).toString() : null;
this.defaultSTT = config.containsKey(CONFIG_DEFAULT_STT) ? config.get(CONFIG_DEFAULT_STT).toString() : null;
this.defaultKS = config.containsKey(CONFIG_DEFAULT_KS) ? config.get(CONFIG_DEFAULT_KS).toString() : null;
this.defaultHLI = config.containsKey(CONFIG_DEFAULT_HLI) ? config.get(CONFIG_DEFAULT_HLI).toString() : null;
this.defaultVoice = config.containsKey(CONFIG_DEFAULT_VOICE) ? config.get(CONFIG_DEFAULT_VOICE).toString()
: null;
if (config.containsKey(CONFIG_CONVERSATION_HISTORY_LIMIT)) {
try {
this.conversationHistoryLimit = Integer
.parseInt(config.get(CONFIG_CONVERSATION_HISTORY_LIMIT).toString());
} catch (NumberFormatException e) {
logger.warn("Failed to parse {}, setting to default ({}):", CONFIG_CONVERSATION_HISTORY_LIMIT,
Conversation.DEFAULT_MAX_MESSAGES, e);
this.conversationHistoryLimit = Conversation.DEFAULT_MAX_MESSAGES;
}
} else {
this.conversationHistoryLimit = Conversation.DEFAULT_MAX_MESSAGES;
}
defaultVoices.clear();
for (Entry<String, Object> entry : config.entrySet()) {
String key = entry.getKey();
if (key.startsWith(CONFIG_PREFIX_DEFAULT_VOICE)) {
String tts = key.substring(CONFIG_PREFIX_DEFAULT_VOICE.length());
defaultVoices.put(tts, entry.getValue().toString());
}
}
}
public String getKeyword() {
return keyword;
}
public @Nullable String getListeningItem() {
return listeningItem;
}
public @Nullable String getListeningMelody() {
return listeningMelody;
}
public @Nullable String getDefaultTTS() {
return defaultTTS;
}
public @Nullable String getDefaultSTT() {
return defaultSTT;
}
public @Nullable String getDefaultKS() {
return defaultKS;
}
public @Nullable String getDefaultHLI() {
return defaultHLI;
}
public @Nullable String getDefaultVoice() {
return defaultVoice;
}
public int getConversationHistoryLimit() {
return conversationHistoryLimit;
}
public Map<String, String> getDefaultVoices() {
return Map.copyOf(defaultVoices);
}
}
@@ -47,7 +47,12 @@ import org.openhab.core.voice.TTSService;
import org.openhab.core.voice.Voice;
import org.openhab.core.voice.VoiceManager;
import org.openhab.core.voice.text.HumanLanguageInterpreter;
import org.openhab.core.voice.text.InterpretationArguments;
import org.openhab.core.voice.text.InterpretationException;
import org.openhab.core.voice.text.conversation.Conversation;
import org.openhab.core.voice.text.conversation.ConversationManager;
import org.openhab.core.voice.text.interpreter.llm.LLMTool;
import org.openhab.core.voice.text.interpreter.llm.LLMToolRegistry;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
@@ -58,7 +63,8 @@ import org.osgi.service.component.annotations.Reference;
* @author Kai Kreuzer - Initial contribution
* @author Wouter Born - Sort TTS voices
* @author Laurent Garnier - Added sub-commands startdialog and stopdialog
* @author Miguel Álvarez - Add transcribe command
* @author Miguel Álvarez Díez - Add transcribe command
* @author Miguel Álvarez Díez - Add conversation command
*/
@Component(service = ConsoleCommandExtension.class)
@NonNullByDefault
@@ -79,21 +85,29 @@ public class VoiceConsoleCommandExtension extends AbstractConsoleCommandExtensio
private static final String SUBCMD_KEYWORD_SPOTTERS = "keywordspotters";
private static final String SUBCMD_STT_SERVICES = "sttservices";
private static final String SUBCMD_TTS_SERVICES = "ttsservices";
private static final String SUBCMD_LLM_TOOLS = "llmtools";
private static final String SUBCMD_CONVERSATION = "conversation";
private static final String SUBCMD_CONVERSATION_REMOVE = "conversationremove";
private final ItemRegistry itemRegistry;
private final ConversationManager conversationManager;
private final VoiceManager voiceManager;
private final AudioManager audioManager;
private final LocaleProvider localeProvider;
private final LLMToolRegistry llmToolRegistry;
@Activate
public VoiceConsoleCommandExtension(final @Reference VoiceManager voiceManager,
final @Reference AudioManager audioManager, final @Reference LocaleProvider localeProvider,
final @Reference ItemRegistry itemRegistry) {
public VoiceConsoleCommandExtension(final @Reference ConversationManager conversationManager,
final @Reference VoiceManager voiceManager, final @Reference AudioManager audioManager,
final @Reference LocaleProvider localeProvider, final @Reference ItemRegistry itemRegistry,
final @Reference LLMToolRegistry llmToolRegistry) {
super("voice", "Commands around voice enablement features.");
this.conversationManager = conversationManager;
this.voiceManager = voiceManager;
this.audioManager = audioManager;
this.localeProvider = localeProvider;
this.itemRegistry = itemRegistry;
this.llmToolRegistry = llmToolRegistry;
}
@Override
@@ -101,29 +115,35 @@ public class VoiceConsoleCommandExtension extends AbstractConsoleCommandExtensio
return List.of(buildCommandUsage(SUBCMD_SAY + " <text>", "speaks a text"), buildCommandUsage(
SUBCMD_TRANSCRIBE + " [--source <source>]|[--file <file>] [--stt <stt>] [--locale <locale>]",
"transcribe audio from default source, optionally specify a different source/file, speech-to-text service or locale"),
buildCommandUsage(SUBCMD_INTERPRET + " [--hlis <comma,separated,interpreters>] <command>",
buildCommandUsage(SUBCMD_INTERPRET
+ " [--hli <comma,separated,interpreterIds>] [--conversation <conversationId>] [--llm-tools <comma,separated,llmToolIds>] [--location <locationId>] <command>",
"interprets a human language command"),
buildCommandUsage(SUBCMD_VOICES, "lists available voices of the TTS services"),
buildCommandUsage(SUBCMD_DIALOGS, "lists the running dialog and their audio/voice services"),
buildCommandUsage(SUBCMD_DIALOG_REGS,
"lists the existing dialog registrations and their selected audio/voice services"),
buildCommandUsage(SUBCMD_REGISTER_DIALOG
+ " [--source <source>] [--sink <sink>] [--hlis <comma,separated,interpreters>] [--tts <tts> [--voice <voice>]] [--stt <stt>] [--ks ks [--keyword <ks>]] [--listening-item <listeningItem>] [--location-item <locationItem>] [--dialog-group <dialogGroup>]",
+ " [--source <source>] [--sink <sink>] [--hli <comma,separated,interpreterIds>] [--tts <tts> [--voice <voice>]] [--stt <stt>] [--ks ks [--keyword <ks>]] [--listening-item <listeningItem>] [--location-item <locationItem>] [--dialog-group <dialogGroup>]",
"register a new dialog processing using the default services or the services identified with provided arguments, it will be persisted and keep running whenever is possible."),
buildCommandUsage(SUBCMD_UNREGISTER_DIALOG + " [source]",
"unregister the dialog processing for the default audio source or the audio source identified with provided argument, stopping it if started"),
buildCommandUsage(SUBCMD_START_DIALOG
+ " [--source <source>] [--sink <sink>] [--hlis <comma,separated,interpreters>] [--tts <tts> [--voice <voice>]] [--stt <stt>] [--ks ks [--keyword <ks>]] [--listening-item <listeningItem>] [--location-item <locationItem>] [--dialog-group <dialogGroup>]",
+ " [--source <source>] [--sink <sink>] [--hli <comma,separated,interpreterIds>] [--tts <tts> [--voice <voice>]] [--stt <stt>] [--ks ks [--keyword <ks>]] [--listening-item <listeningItem>] [--location-item <locationItem>] [--dialog-group <dialogGroup>]",
"start a new dialog processing using the default services or the services identified with provided arguments"),
buildCommandUsage(SUBCMD_STOP_DIALOG + " [<source>]",
"stop the dialog processing for the default audio source or the audio source identified with provided argument"),
buildCommandUsage(SUBCMD_LISTEN_ANSWER
+ " [--source <source>] [--sink <sink>] [--hlis <comma,separated,interpreters>] [--tts <tts> [--voice <voice>]] [--stt <stt>] [--listening-item <listeningItem>] [--location-item <locationItem>] [--dialog-group <dialogGroup>]",
+ " [--source <source>] [--sink <sink>] [--hli <comma,separated,interpreterIds>] [--tts <tts> [--voice <voice>]] [--stt <stt>] [--listening-item <listeningItem>] [--location-item <locationItem>] [--dialog-group <dialogGroup>]",
"Execute a simple dialog sequence without keyword spotting using the default services or the services identified with provided arguments"),
buildCommandUsage(SUBCMD_INTERPRETERS, "lists the interpreters"),
buildCommandUsage(SUBCMD_KEYWORD_SPOTTERS, "lists the keyword spotters"),
buildCommandUsage(SUBCMD_STT_SERVICES, "lists the Speech-to-Text services"),
buildCommandUsage(SUBCMD_TTS_SERVICES, "lists the Text-to-Speech services"));
buildCommandUsage(SUBCMD_TTS_SERVICES, "lists the Text-to-Speech services"),
buildCommandUsage(SUBCMD_LLM_TOOLS, "lists the LLM tools"),
buildCommandUsage(SUBCMD_CONVERSATION + " [--uid true] <conversationId>",
"Displays conversation messages"),
buildCommandUsage(SUBCMD_CONVERSATION_REMOVE + " [--message-id <message-id>] <conversationId>",
"Remove Conversation"));
}
@Override
@@ -167,7 +187,7 @@ public class VoiceConsoleCommandExtension extends AbstractConsoleCommandExtensio
case SUBCMD_REGISTER_DIALOG -> {
DialogRegistration dialogRegistration;
try {
dialogRegistration = parseDialogRegistration(args);
dialogRegistration = parseDialogRegistration(Arrays.copyOfRange(args, 1, args.length));
} catch (IllegalStateException e) {
console.println(Objects.requireNonNullElse(e.getMessage(),
"An error occurred while parsing the dialog options"));
@@ -198,7 +218,7 @@ public class VoiceConsoleCommandExtension extends AbstractConsoleCommandExtensio
case SUBCMD_START_DIALOG -> {
DialogContext.Builder dialogContextBuilder;
try {
dialogContextBuilder = parseDialogContext(args);
dialogContextBuilder = parseDialogContext(Arrays.copyOfRange(args, 1, args.length));
} catch (IllegalStateException e) {
console.println(Objects.requireNonNullElse(e.getMessage(),
"An error occurred while parsing the dialog options"));
@@ -224,7 +244,7 @@ public class VoiceConsoleCommandExtension extends AbstractConsoleCommandExtensio
case SUBCMD_LISTEN_ANSWER -> {
DialogContext.Builder dialogContextBuilder;
try {
dialogContextBuilder = parseDialogContext(args);
dialogContextBuilder = parseDialogContext(Arrays.copyOfRange(args, 1, args.length));
} catch (IllegalStateException e) {
console.println(Objects.requireNonNullElse(e.getMessage(),
"An error occurred while parsing the dialog options"));
@@ -262,6 +282,18 @@ public class VoiceConsoleCommandExtension extends AbstractConsoleCommandExtensio
listTTSs(console);
return;
}
case SUBCMD_LLM_TOOLS -> {
listLLMTools(console);
return;
}
case SUBCMD_CONVERSATION -> {
printConversationMessages(Arrays.copyOfRange(args, 1, args.length), console);
return;
}
case SUBCMD_CONVERSATION_REMOVE -> {
removeConversationMessages(Arrays.copyOfRange(args, 1, args.length), console);
return;
}
default -> {
}
}
@@ -281,19 +313,24 @@ public class VoiceConsoleCommandExtension extends AbstractConsoleCommandExtensio
}
private void interpret(String[] args, Console console) {
@Nullable
String hliIdList = null;
String[] arguments;
if (args.length > 0 && "--hlis".equals(args[0])) {
if (args.length == 1) {
console.println("No hli id list provided.");
return;
}
hliIdList = args[1];
arguments = Arrays.copyOfRange(args, 2, args.length);
} else {
arguments = args;
HashMap<String, String> parameters;
try {
parameters = parseNamedParameters(args, true);
} catch (IllegalStateException e) {
console.println(Objects.requireNonNullElse(e.getMessage(), "An error parsing positional parameters"));
return;
}
String[] arguments = Arrays.copyOfRange(args, parameters.size() * 2, args.length);
@Nullable
String hliIdList = parameters.remove("hli");
@Nullable
String conversationId = parameters.remove("conversation");
@Nullable
String llmToolIdList = parameters.remove("llm-tools");
@Nullable
String locationItem = parameters.remove("location");
if (arguments.length == 0) {
console.println("No command provided.");
return;
@@ -304,8 +341,14 @@ public class VoiceConsoleCommandExtension extends AbstractConsoleCommandExtensio
sb.append(arguments[i]);
}
String msg = sb.toString();
var interpretationArgs = new InterpretationArguments( //
Objects.requireNonNullElse(hliIdList, ""), //
Objects.requireNonNullElse(conversationId, ""), //
Objects.requireNonNullElse(llmToolIdList, ""), //
Objects.requireNonNullElse(locationItem, ""), null //
);
try {
String result = voiceManager.interpret(msg, hliIdList);
String result = voiceManager.interpret(msg, interpretationArgs);
console.println(result);
} catch (InterpretationException ie) {
console.println(Objects.requireNonNullElse(ie.getMessage(),
@@ -340,7 +383,7 @@ public class VoiceConsoleCommandExtension extends AbstractConsoleCommandExtensio
private void transcribe(String[] args, Console console) {
HashMap<String, String> parameters;
try {
parameters = parseNamedParameters(args);
parameters = parseNamedParameters(args, false);
} catch (IllegalStateException e) {
console.println(Objects.requireNonNullElse(e.getMessage(), "An error parsing positional parameters"));
return;
@@ -476,15 +519,101 @@ public class VoiceConsoleCommandExtension extends AbstractConsoleCommandExtensio
}
}
private void listLLMTools(Console console) {
Collection<LLMTool> tools = llmToolRegistry.getAll();
if (!tools.isEmpty()) {
Locale locale = localeProvider.getLocale();
tools.stream().sorted(comparing(s -> s.getLabel(locale))).forEach(tool -> {
console.println(String.format(" %s (%s)", tool.getLabel(locale), tool.getUID()));
});
} else {
console.println("No LLM tools found.");
}
}
private void printConversationMessages(String[] args, Console console) {
HashMap<String, String> parameters;
try {
parameters = parseNamedParameters(args, true);
} catch (IllegalStateException e) {
console.println(Objects.requireNonNullElse(e.getMessage(), "An error parsing positional parameters"));
return;
}
String[] arguments = Arrays.copyOfRange(args, parameters.size() * 2, args.length);
if (arguments.length != 1) {
console.println("Incorrect number of arguments");
return;
}
boolean printUID = "true".equals(parameters.remove("uid"));
if (!parameters.isEmpty()) {
console.println("Argument " + parameters.keySet().stream().findAny().orElse("") + " is not supported");
return;
}
Conversation conversation = conversationManager.getConversation(arguments[0]);
if (conversation.getMessages().isEmpty()) {
console.println("Empty conversation");
return;
}
for (var message : conversation.getMessages()) {
if (printUID) {
console.printf("%s - %s|> %s\n", message.id(), message.role(), message.content());
} else {
console.printf("%s|> %s\n", message.role(), message.content());
}
}
}
private void removeConversationMessages(String[] args, Console console) {
HashMap<String, String> parameters;
try {
parameters = parseNamedParameters(args, true);
} catch (IllegalStateException e) {
console.println(Objects.requireNonNullElse(e.getMessage(), "An error parsing positional parameters"));
return;
}
String[] arguments = Arrays.copyOfRange(args, parameters.size() * 2, args.length);
if (arguments.length != 1) {
console.println("Incorrect number of arguments");
return;
}
@Nullable
String rawMessageID = parameters.remove("message-id");
if (!parameters.isEmpty()) {
console.println("Argument " + parameters.keySet().stream().findAny().orElse("") + " is not supported");
return;
}
Conversation conversation = conversationManager.getConversation(arguments[0]);
if (conversation.getMessages().isEmpty()) {
console.println("Empty conversation");
return;
}
if (rawMessageID != null) {
Integer messageID;
try {
messageID = Integer.parseInt(rawMessageID);
} catch (NumberFormatException e) {
console.print("Invalid message ID, must be a number: " + rawMessageID);
return;
}
if (!conversation.removeSinceMessage(messageID)) {
console.println("No messages were removed");
return;
}
console.println("Messages since " + rawMessageID + " were removed");
} else {
conversation.removeMessages();
}
}
private @Nullable Voice getVoice(@Nullable String id) {
return id == null ? null
: voiceManager.getAllVoices().stream().filter(voice -> voice.getUID().equals(id)).findAny()
.orElse(null);
}
private HashMap<String, String> parseNamedParameters(String[] args) {
private HashMap<String, String> parseNamedParameters(String[] args, boolean allowText) {
var parameters = new HashMap<String, String>();
for (int i = 1; i < args.length; i++) {
for (int i = 0; i < args.length; i++) {
var arg = args[i].trim();
if (arg.startsWith("--")) {
i++;
@@ -494,6 +623,9 @@ public class VoiceConsoleCommandExtension extends AbstractConsoleCommandExtensio
throw new IllegalStateException("Missing value for argument " + arg);
}
} else {
if (allowText) {
break;
}
throw new IllegalStateException("Argument name should start by -- " + arg);
}
}
@@ -502,10 +634,10 @@ public class VoiceConsoleCommandExtension extends AbstractConsoleCommandExtensio
private DialogContext.Builder parseDialogContext(String[] args) {
var dialogContextBuilder = voiceManager.getDialogContextBuilder();
if (args.length < 2) {
if (args.length < 1) {
return dialogContextBuilder;
}
var parameters = parseNamedParameters(args);
var parameters = parseNamedParameters(args, false);
String sourceId = parameters.remove("source");
if (sourceId != null) {
var source = audioManager.getSource(sourceId);
@@ -526,11 +658,13 @@ public class VoiceConsoleCommandExtension extends AbstractConsoleCommandExtensio
.withSTT(voiceManager.getSTT(parameters.remove("stt"))) //
.withTTS(voiceManager.getTTS(parameters.remove("tts"))) //
.withVoice(getVoice(parameters.remove("voice"))) //
.withHLIs(voiceManager.getHLIsByIds(parameters.remove("hlis"))) //
.withHLIs(voiceManager.getHLIsByIds(parameters.remove("hli"))) //
.withKS(voiceManager.getKS(parameters.remove("ks"))) //
.withListeningItem(parameters.remove("listening-item")) //
.withLocationItem(parameters.remove("location-item")) //
.withDialogGroup(parameters.remove("dialog-group")) //
.withConversationId(parameters.remove("conversation")) //
.withLLMTools(llmToolRegistry.getByIds(parameters.remove("llm-tools"))) //
.withKeyword(parameters.remove("keyword"));
if (!parameters.isEmpty()) {
throw new IllegalStateException(
@@ -540,7 +674,7 @@ public class VoiceConsoleCommandExtension extends AbstractConsoleCommandExtensio
}
private DialogRegistration parseDialogRegistration(String[] args) {
var parameters = parseNamedParameters(args);
var parameters = parseNamedParameters(args, false);
@Nullable
String sourceId = parameters.remove("source");
if (sourceId == null) {
@@ -566,11 +700,16 @@ public class VoiceConsoleCommandExtension extends AbstractConsoleCommandExtensio
dr.listeningItem = parameters.remove("listening-item");
dr.locationItem = parameters.remove("location-item");
dr.dialogGroup = parameters.remove("dialog-group");
dr.conversationId = parameters.remove("conversation");
String hliIds = parameters.remove("hlis");
String hliIds = parameters.remove("hli");
if (hliIds != null) {
dr.hliIds = Arrays.stream(hliIds.split(",")).map(String::trim).toList();
}
String llmToolIds = parameters.remove("llm-tools");
if (llmToolIds != null) {
dr.llmToolIds = Arrays.stream(llmToolIds.split(",")).map(String::trim).toList();
}
if (!parameters.isEmpty()) {
throw new IllegalStateException(
"Argument " + parameters.keySet().stream().findAny().orElse("") + " is not supported");
@@ -25,7 +25,6 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Set;
import java.util.WeakHashMap;
@@ -48,6 +47,7 @@ import org.openhab.core.audio.AudioSink;
import org.openhab.core.audio.AudioSource;
import org.openhab.core.audio.AudioStream;
import org.openhab.core.common.ThreadPoolManager;
import org.openhab.core.common.registry.RegistryChangeListener;
import org.openhab.core.config.core.ConfigOptionProvider;
import org.openhab.core.config.core.ConfigurableService;
import org.openhab.core.config.core.ParameterOption;
@@ -73,7 +73,15 @@ import org.openhab.core.voice.TTSService;
import org.openhab.core.voice.Voice;
import org.openhab.core.voice.VoiceManager;
import org.openhab.core.voice.text.HumanLanguageInterpreter;
import org.openhab.core.voice.text.InterpretationArguments;
import org.openhab.core.voice.text.InterpretationException;
import org.openhab.core.voice.text.InterpreterContext;
import org.openhab.core.voice.text.conversation.Conversation;
import org.openhab.core.voice.text.conversation.ConversationException;
import org.openhab.core.voice.text.conversation.ConversationManager;
import org.openhab.core.voice.text.conversation.ConversationRole;
import org.openhab.core.voice.text.interpreter.llm.LLMTool;
import org.openhab.core.voice.text.interpreter.llm.LLMToolRegistry;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext;
import org.osgi.framework.Constants;
@@ -101,27 +109,13 @@ import org.slf4j.LoggerFactory;
*/
@Component(immediate = true, configurationPid = VoiceManagerImpl.CONFIGURATION_PID, //
property = Constants.SERVICE_PID + "=org.openhab.voice")
@ConfigurableService(category = "system", label = "Voice", description_uri = VoiceManagerImpl.CONFIG_URI)
@ConfigurableService(category = "system", label = "Voice", description_uri = VoiceConfiguration.CONFIG_URI)
@NonNullByDefault
public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, DialogProcessor.DialogEventListener {
public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, DialogProcessor.DialogEventListener,
RegistryChangeListener<LLMTool> {
public static final String CONFIGURATION_PID = "org.openhab.voice";
// the default keyword to use if no other is configured
private static final String DEFAULT_KEYWORD = "Wakeup";
// constants for the configuration properties
protected static final String CONFIG_URI = "system:voice";
private static final String CONFIG_KEYWORD = "keyword";
private static final String CONFIG_LISTENING_ITEM = "listeningItem";
private static final String CONFIG_LISTENING_MELODY = "listeningMelody";
private static final String CONFIG_DEFAULT_HLI = "defaultHLI";
private static final String CONFIG_DEFAULT_KS = "defaultKS";
private static final String CONFIG_DEFAULT_STT = "defaultSTT";
private static final String CONFIG_DEFAULT_TTS = "defaultTTS";
private static final String CONFIG_DEFAULT_VOICE = "defaultVoice";
private static final String CONFIG_PREFIX_DEFAULT_VOICE = "defaultVoice.";
private final Logger logger = LoggerFactory.getLogger(VoiceManagerImpl.class);
private final ScheduledExecutorService scheduledExecutorService = ThreadPoolManager
.getScheduledPool(ThreadPoolManager.THREAD_POOL_NAME_COMMON);
@@ -130,6 +124,7 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
private final Map<String, STTService> sttServices = new HashMap<>();
private final Map<String, TTSService> ttsServices = new HashMap<>();
private final Map<String, HumanLanguageInterpreter> humanLanguageInterpreters = new HashMap<>();
private final LLMToolRegistry llmToolRegistry;
private final WeakHashMap<String, DialogContext> activeDialogGroups = new WeakHashMap<>();
@@ -138,21 +133,11 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
private final EventPublisher eventPublisher;
private final TranslationProvider i18nProvider;
private final Storage<DialogRegistration> dialogRegistrationStorage;
private final ConversationManager conversationManager;
private final VoiceConfiguration configuration = new VoiceConfiguration();
private @Nullable Bundle bundle;
/**
* default settings filled through the service configuration
*/
private String keyword = DEFAULT_KEYWORD;
private @Nullable String listeningItem;
private @Nullable String listeningMelody;
private @Nullable String defaultTTS;
private @Nullable String defaultSTT;
private @Nullable String defaultKS;
private @Nullable String defaultHLI;
private @Nullable String defaultVoice;
private final Map<String, String> defaultVoices = new HashMap<>();
private final Map<String, DialogProcessor> dialogProcessors = new HashMap<>();
private final Map<String, DialogProcessor> singleDialogProcessors = new ConcurrentHashMap<>();
private @Nullable DialogContext lastDialogContext;
@@ -161,23 +146,28 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
@Activate
public VoiceManagerImpl(final @Reference LocaleProvider localeProvider, final @Reference AudioManager audioManager,
final @Reference EventPublisher eventPublisher, final @Reference TranslationProvider i18nProvider,
final @Reference StorageService storageService) {
final @Reference StorageService storageService, final @Reference ConversationManager conversationManager,
final @Reference LLMToolRegistry llmToolRegistry) {
this.localeProvider = localeProvider;
this.audioManager = audioManager;
this.eventPublisher = eventPublisher;
this.i18nProvider = i18nProvider;
this.dialogRegistrationStorage = storageService.getStorage(DialogRegistration.class.getName(),
this.getClass().getClassLoader());
this.conversationManager = conversationManager;
this.llmToolRegistry = llmToolRegistry;
}
@Activate
protected void activate(BundleContext bundleContext, Map<String, Object> config) {
this.bundle = bundleContext.getBundle();
llmToolRegistry.addRegistryChangeListener(this);
modified(config);
}
@Deactivate
protected void deactivate() {
llmToolRegistry.removeRegistryChangeListener(this);
dialogProcessors.values().forEach(DialogProcessor::stop);
dialogProcessors.clear();
ScheduledFuture<?> dialogRegistrationFuture = this.dialogRegistrationFuture;
@@ -191,27 +181,8 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
@Modified
protected void modified(Map<String, Object> config) {
if (config != null) {
this.keyword = config.containsKey(CONFIG_KEYWORD) ? config.get(CONFIG_KEYWORD).toString() : DEFAULT_KEYWORD;
this.listeningItem = config.containsKey(CONFIG_LISTENING_ITEM)
? config.get(CONFIG_LISTENING_ITEM).toString()
: null;
this.listeningMelody = config.containsKey(CONFIG_LISTENING_MELODY)
? config.get(CONFIG_LISTENING_MELODY).toString()
: null;
this.defaultTTS = config.containsKey(CONFIG_DEFAULT_TTS) ? config.get(CONFIG_DEFAULT_TTS).toString() : null;
this.defaultSTT = config.containsKey(CONFIG_DEFAULT_STT) ? config.get(CONFIG_DEFAULT_STT).toString() : null;
this.defaultKS = config.containsKey(CONFIG_DEFAULT_KS) ? config.get(CONFIG_DEFAULT_KS).toString() : null;
this.defaultHLI = config.containsKey(CONFIG_DEFAULT_HLI) ? config.get(CONFIG_DEFAULT_HLI).toString() : null;
this.defaultVoice = config.containsKey(CONFIG_DEFAULT_VOICE) ? config.get(CONFIG_DEFAULT_VOICE).toString()
: null;
for (Entry<String, Object> entry : config.entrySet()) {
String key = entry.getKey();
if (key.startsWith(CONFIG_PREFIX_DEFAULT_VOICE)) {
String tts = key.substring(CONFIG_PREFIX_DEFAULT_VOICE.length());
defaultVoices.put(tts, entry.getValue().toString());
}
}
configuration.update(config);
conversationManager.setHistoryLimit(configuration.getConversationHistoryLimit());
}
}
@@ -250,7 +221,7 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
String selectedVoiceId = voiceId;
if (selectedVoiceId == null) {
// use the configured default, if set
selectedVoiceId = defaultVoice;
selectedVoiceId = configuration.getDefaultVoice();
}
if (selectedVoiceId == null) {
tts = getTTS();
@@ -388,43 +359,76 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
@Override
public String interpret(String text) throws InterpretationException {
return interpret(text, null);
return interpret(text, (InterpretationArguments) null);
}
@Override
public String interpret(String text, @Nullable String hliIdList) throws InterpretationException {
return interpret(text, hliIdList == null ? null : new InterpretationArguments(hliIdList, "", "", "", null));
}
@Override
public String interpret(String text, @Nullable InterpretationArguments args) throws InterpretationException {
if (args == null) {
args = new InterpretationArguments("", "", "", "", null);
}
List<HumanLanguageInterpreter> interpreters = new ArrayList<>();
if (hliIdList == null) {
if (args.hliIdList().isEmpty()) {
HumanLanguageInterpreter interpreter = getHLI();
if (interpreter != null) {
interpreters.add(interpreter);
}
} else {
interpreters = getHLIsByIds(hliIdList);
interpreters = getHLIsByIds(args.hliIdList());
}
if (!interpreters.isEmpty()) {
Locale locale = localeProvider.getLocale();
Locale locale = args.locale();
if (locale == null) {
locale = localeProvider.getLocale();
}
Conversation conversation = conversationManager.getConversation(args.conversationId());
try {
conversation.addMessage(ConversationRole.USER, text);
} catch (ConversationException e) {
String errMsg = e.getMessage();
throw new InterpretationException(
errMsg != null ? errMsg : "Unknown exception adding user message to conversation");
}
List<LLMTool> tools = llmToolRegistry.getByIds(args.toolIdList());
String locationItem = args.locationItem();
if (locationItem != null && locationItem.isBlank()) {
locationItem = null;
}
InterpreterContext context = new InterpreterContext(conversation, tools, locationItem);
InterpretationException exception = null;
for (var interpreter : interpreters) {
try {
String answer = interpreter.interpret(locale, text);
logger.debug("Interpretation result: {}", answer);
String answer = interpreter.interpret(locale, context);
logger.debug("Interpretation result from interpreter '{}': {}", interpreter.getId(), answer);
return answer;
} catch (InterpretationException e) {
logger.debug("Interpretation exception: {}", e.getMessage());
logger.debug("Interpretation exception from interpreter '{}': {}", interpreter.getId(),
e.getMessage());
exception = e;
}
}
if (exception != null) { // this should always be the case here
if (exception != null) {
try {
String errMsg = exception.getMessage();
context.conversation().addMessage(ConversationRole.OPENHAB,
errMsg != null ? errMsg : "Unknown interpreter error");
} catch (ConversationException e) {
logger.error("Error adding error message to conversation: {}", e.getMessage());
}
throw exception;
}
}
if (hliIdList == null) {
if (args.hliIdList().isEmpty()) {
throw new InterpretationException("No human language interpreter available!");
} else {
throw new InterpretationException("No human language interpreter can be found for " + hliIdList);
throw new InterpretationException("No human language interpreter can be found for " + args.hliIdList());
}
}
@@ -586,7 +590,7 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
@Override
public DialogContext.Builder getDialogContextBuilder() {
return new DialogContext.Builder(keyword, localeProvider.getLocale()) //
return new DialogContext.Builder(configuration.getKeyword(), localeProvider.getLocale()) //
.withSink(audioManager.getSink()) //
.withSource(audioManager.getSource()) //
.withKS(this.getKS()) //
@@ -594,8 +598,8 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
.withTTS(this.getTTS()) //
.withHLI(this.getHLI()) //
.withVoice(this.getDefaultVoice()) //
.withMelody(listeningMelody) //
.withListeningItem(listeningItem);
.withMelody(configuration.getListeningMelody()) //
.withListeningItem(configuration.getListeningItem());
}
@Override
@@ -635,7 +639,7 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
logger.debug("Starting a new dialog for source {} ({})", context.source().getLabel(null),
context.source().getId());
processor = new DialogProcessor(context, this, this.eventPublisher, this.activeDialogGroups,
this.i18nProvider, b);
this.i18nProvider, this.conversationManager, b);
dialogProcessors.put(context.source().getId(), processor);
return processor.start();
} else {
@@ -692,7 +696,7 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
activeProcessor = singleDialogProcessors.get(audioSource.getId());
}
var processor = new DialogProcessor(context, this, this.eventPublisher, this.activeDialogGroups,
this.i18nProvider, b);
this.i18nProvider, this.conversationManager, b);
if (activeProcessor == null) {
logger.debug("Executing a simple dialog for source {} ({})", audioSource.getLabel(null),
audioSource.getId());
@@ -830,13 +834,28 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
.anyMatch(hli -> hli.getId().equals(humanLanguageInterpreter.getId())));
}
@Override
public void added(LLMTool tool) {
scheduleDialogRegistrations();
}
@Override
public void removed(LLMTool tool) {
stopDialogs(dialog -> dialog.dialogContext.llmTools().stream().anyMatch(t -> t.getUID().equals(tool.getUID())));
}
@Override
public void updated(LLMTool oldElement, LLMTool element) {
// do nothing
}
@Override
public @Nullable TTSService getTTS() {
TTSService tts = null;
if (defaultTTS != null) {
tts = ttsServices.get(defaultTTS);
if (configuration.getDefaultTTS() != null) {
tts = ttsServices.get(configuration.getDefaultTTS());
if (tts == null) {
logger.warn("Default TTS service '{}' not available!", defaultTTS);
logger.warn("Default TTS service '{}' not available!", configuration.getDefaultTTS());
}
} else if (!ttsServices.isEmpty()) {
tts = ttsServices.values().iterator().next();
@@ -863,10 +882,10 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
@Override
public @Nullable STTService getSTT() {
STTService stt = null;
if (defaultSTT != null) {
stt = sttServices.get(defaultSTT);
if (configuration.getDefaultSTT() != null) {
stt = sttServices.get(configuration.getDefaultSTT());
if (stt == null) {
logger.warn("Default STT service '{}' not available!", defaultSTT);
logger.warn("Default STT service '{}' not available!", configuration.getDefaultSTT());
}
} else if (!sttServices.isEmpty()) {
stt = sttServices.values().iterator().next();
@@ -889,10 +908,10 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
@Override
public @Nullable KSService getKS() {
KSService ks = null;
if (defaultKS != null) {
ks = ksServices.get(defaultKS);
if (configuration.getDefaultKS() != null) {
ks = ksServices.get(configuration.getDefaultKS());
if (ks == null) {
logger.warn("Default KS service '{}' not available!", defaultKS);
logger.warn("Default KS service '{}' not available!", configuration.getDefaultKS());
}
} else if (!ksServices.isEmpty()) {
ks = ksServices.values().iterator().next();
@@ -915,10 +934,10 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
@Override
public @Nullable HumanLanguageInterpreter getHLI() {
HumanLanguageInterpreter hli = null;
if (defaultHLI != null) {
hli = humanLanguageInterpreters.get(defaultHLI);
if (configuration.getDefaultHLI() != null) {
hli = humanLanguageInterpreters.get(configuration.getDefaultHLI());
if (hli == null) {
logger.warn("Default HumanLanguageInterpreter '{}' not available!", defaultHLI);
logger.warn("Default HumanLanguageInterpreter '{}' not available!", configuration.getDefaultHLI());
}
} else if (!humanLanguageInterpreters.isEmpty()) {
hli = humanLanguageInterpreters.values().iterator().next();
@@ -993,32 +1012,32 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
@Override
public @Nullable Voice getDefaultVoice() {
String localDefaultVoice = defaultVoice;
String localDefaultVoice = configuration.getDefaultVoice();
return localDefaultVoice != null ? getVoice(localDefaultVoice) : null;
}
@Override
public @Nullable Collection<ParameterOption> getParameterOptions(URI uri, String param, @Nullable String context,
@Nullable Locale locale) {
if (CONFIG_URI.equals(uri.toString())) {
if (VoiceConfiguration.CONFIG_URI.equals(uri.toString())) {
switch (param) {
case CONFIG_DEFAULT_HLI:
case VoiceConfiguration.CONFIG_DEFAULT_HLI:
return humanLanguageInterpreters.values().stream()
.sorted((hli1, hli2) -> hli1.getLabel(locale).compareToIgnoreCase(hli2.getLabel(locale)))
.map(hli -> new ParameterOption(hli.getId(), hli.getLabel(locale))).toList();
case CONFIG_DEFAULT_KS:
case VoiceConfiguration.CONFIG_DEFAULT_KS:
return ksServices.values().stream()
.sorted((ks1, ks2) -> ks1.getLabel(locale).compareToIgnoreCase(ks2.getLabel(locale)))
.map(ks -> new ParameterOption(ks.getId(), ks.getLabel(locale))).toList();
case CONFIG_DEFAULT_STT:
case VoiceConfiguration.CONFIG_DEFAULT_STT:
return sttServices.values().stream()
.sorted((stt1, stt2) -> stt1.getLabel(locale).compareToIgnoreCase(stt2.getLabel(locale)))
.map(stt -> new ParameterOption(stt.getId(), stt.getLabel(locale))).toList();
case CONFIG_DEFAULT_TTS:
case VoiceConfiguration.CONFIG_DEFAULT_TTS:
return ttsServices.values().stream()
.sorted((tts1, tts2) -> tts1.getLabel(locale).compareToIgnoreCase(tts2.getLabel(locale)))
.map(tts -> new ParameterOption(tts.getId(), tts.getLabel(locale))).toList();
case CONFIG_DEFAULT_VOICE:
case VoiceConfiguration.CONFIG_DEFAULT_VOICE:
Locale nullSafeLocale = locale != null ? locale : localeProvider.getLocale();
return getAllVoicesSorted(nullSafeLocale)
.stream().filter(v -> getTTS(v) != null).map(
@@ -1076,8 +1095,10 @@ public class VoiceManagerImpl implements VoiceManager, ConfigOptionProvider, Dia
.withTTS(getTTS(dr.ttsId)) //
.withVoice(getVoice(dr.voiceId)) //
.withHLIs(getHLIsByIds(dr.hliIds)) //
.withLLMTools(llmToolRegistry.getByIds(dr.llmToolIds)) //
.withLocale(dr.locale) //
.withDialogGroup(dr.dialogGroup) //
.withConversationId(dr.conversationId) //
.withLocationItem(dr.locationItem) //
.withListeningItem(dr.listeningItem) //
.withMelody(dr.listeningMelody) //
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.internal.text.conversation;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.voice.text.conversation.Conversation;
/**
* The {@link ConversationDTO} class contains a list of messages in between the users and a LanguageInterpreter.
* It is used to store {@link Conversation}s using a
* {@link org.openhab.core.storage.StorageService}.
*
* @author Miguel Álvarez Díez - Initial contribution
*/
@NonNullByDefault
public record ConversationDTO(List<MessageDTO> messages) {
}
@@ -0,0 +1,171 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.internal.text.conversation;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.events.EventPublisher;
import org.openhab.core.storage.Storage;
import org.openhab.core.storage.StorageService;
import org.openhab.core.voice.text.conversation.Conversation;
import org.openhab.core.voice.text.conversation.ConversationListener;
import org.openhab.core.voice.text.conversation.ConversationManager;
import org.openhab.core.voice.text.conversation.events.ConversationEventFactory;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link ConversationManagerImpl} class manages active conversations.
*
* @author Miguel Álvarez Díez - Initial contribution
* @author Florian Hotze - Initial contribution
*/
@Component(service = ConversationManager.class)
@NonNullByDefault
public class ConversationManagerImpl implements ConversationManager, ConversationListener {
private final Storage<ConversationDTO> conversationStorage;
private final Map<String, Conversation> activeConversations = new ConcurrentHashMap<>();
private final EventPublisher eventPublisher;
private final Logger logger = LoggerFactory.getLogger(ConversationManagerImpl.class);
private int historyLimit = Conversation.DEFAULT_MAX_MESSAGES;
@Activate
public ConversationManagerImpl(final @Reference StorageService storageService,
final @Reference EventPublisher eventPublisher) {
this.conversationStorage = storageService.getStorage(Conversation.class.getName(),
this.getClass().getClassLoader());
this.eventPublisher = eventPublisher;
}
@Override
public @Nullable Conversation getConversation(String id, boolean createIfMissing) {
Conversation conversation;
if (id.isBlank()) {
conversation = new Conversation("");
conversation.setMaxMessages(historyLimit);
return conversation;
}
conversation = activeConversations.get(id);
if (conversation != null) {
// return same reference when possible
return conversation;
}
synchronized (this) {
// re-check whether conversation became active since last check
conversation = activeConversations.get(id);
if (conversation != null) {
conversation.setMaxMessages(historyLimit);
// return same reference when possible
return conversation;
}
// load conversation from storage or create a new one
ConversationDTO conversationDTO = conversationStorage.get(id);
if (conversationDTO != null) {
logger.debug("Conversation '{}' found", id);
conversation = new Conversation(id,
conversationDTO.messages().stream().map(MessageDTO::toMessage).toList());
} else if (createIfMissing) {
logger.debug("Creating new conversation '{}'", id);
conversation = new Conversation(id);
eventPublisher.post(ConversationEventFactory.createConversationCreatedEvent(id, null));
} else {
return null;
}
conversation.addListener(this);
conversation.setMaxMessages(historyLimit);
activeConversations.put(conversation.getId(), conversation);
return conversation;
}
}
/**
* Persists the conversation state to storage.
*
* <p>
* If the conversation is empty (no messages), it should be removed from storage.
* If the ID is blank, it should not be persisted to storage.
*
* @param conversation the conversation to save
*/
private void storeConversation(Conversation conversation) {
String id = conversation.getId();
if (id.isBlank()) {
return;
}
if (conversation.getMessages().isEmpty()) {
removeConversation(id);
} else {
logger.debug("Storing conversation '{}' with {} messages...", id, conversation.getMessages().size());
conversationStorage.put(id, new ConversationDTO(
new ArrayList<>(conversation.getMessages().stream().map(MessageDTO::fromMessage).toList())));
activeConversations.put(id, conversation);
}
}
@Override
public void removeConversation(String id) {
logger.debug("Removing conversation '{}'", id);
Conversation conversation = activeConversations.remove(id);
if (conversation != null) {
conversation.removeListener(this);
}
if (!id.isBlank()) {
conversationStorage.remove(id);
eventPublisher.post(ConversationEventFactory.createConversationRemovedEvent(id, null));
}
}
@Override
public Collection<Conversation> getConversations() {
// Ensure all stored conversations are in the active map
for (String id : conversationStorage.getKeys()) {
if (!activeConversations.containsKey(id)) {
getConversation(id);
}
}
return activeConversations.values();
}
@Override
public void setHistoryLimit(int limit) {
this.historyLimit = limit;
activeConversations.values().forEach(c -> c.setMaxMessages(limit));
}
@Override
public void onMessageAdded(Conversation conversation, Conversation.Message message) {
eventPublisher.post(ConversationEventFactory.createConversationMessageAddedEvent(conversation.getId(),
message.id(), message.role(), message.content(), null));
storeConversation(conversation);
}
@Override
public void onMessagesRemoved(Conversation conversation, int sinceRemovedMessagesId) {
if (conversation.getMessages().isEmpty()) {
removeConversation(conversation.getId());
} else {
eventPublisher.post(ConversationEventFactory.createConversationMessagesRemovedEvent(conversation.getId(),
sinceRemovedMessagesId, null));
storeConversation(conversation);
}
}
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.internal.text.conversation;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.voice.text.conversation.Conversation;
import org.openhab.core.voice.text.conversation.ConversationRole;
/**
* The {@link MessageDTO} class represents a message in between the users and a LanguageInterpreter.
* It is used to store {@link Conversation}s using a
* {@link org.openhab.core.storage.StorageService}.
*
* @author Miguel Álvarez Díez - Initial contribution
*/
@NonNullByDefault
public record MessageDTO(int id, ConversationRole role, String content) {
public Conversation.Message toMessage() {
return new Conversation.Message(id, role, content);
}
public static MessageDTO fromMessage(Conversation.Message messageRecord) {
return new MessageDTO(messageRecord.id(), messageRecord.role(), messageRecord.content());
}
}
@@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.internal.text;
package org.openhab.core.voice.internal.text.interpreter;
import java.util.ArrayList;
import java.util.Arrays;
@@ -41,10 +41,10 @@ import org.openhab.core.library.types.UpDownType;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.TypeParser;
import org.openhab.core.voice.text.AbstractRuleBasedInterpreter;
import org.openhab.core.voice.text.Expression;
import org.openhab.core.voice.text.HumanLanguageInterpreter;
import org.openhab.core.voice.text.Rule;
import org.openhab.core.voice.text.interpreter.rulebased.AbstractRuleBasedInterpreter;
import org.openhab.core.voice.text.interpreter.rulebased.Expression;
import org.openhab.core.voice.text.interpreter.rulebased.Rule;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Deactivate;
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.internal.text.interpreter.llm;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.voice.text.interpreter.llm.LLMTool;
import org.openhab.core.voice.text.interpreter.llm.LLMToolException;
import org.openhab.core.voice.text.interpreter.llm.LLMToolParam;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
/**
* The {@link DateTimeLLMTool} is an {@link LLMTool} that returns the current system date and time.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
@Component(service = LLMTool.class, immediate = true)
public class DateTimeLLMTool implements LLMTool {
public static final String ID = "get-date-time";
private final TimeZoneProvider timeZoneProvider;
@Activate
public DateTimeLLMTool(final @Reference TimeZoneProvider timeZoneProvider) {
this.timeZoneProvider = timeZoneProvider;
}
@Override
public String getUID() {
return ID;
}
@Override
public String getLabel(@Nullable Locale locale) {
return "Get Date and Time";
}
@Override
public String getShortDescription(@Nullable Locale locale) {
return "Returns the current date and time.";
}
@Override
public String getDescription(@Nullable Locale locale) {
return "This tool returns the current date and time in a human-readable format.";
}
@Override
public List<LLMToolParam> getParamDescriptions(@Nullable Locale locale) {
return List.of();
}
@Override
public String call(Map<String, Object> params, @Nullable Locale locale) throws LLMToolException {
ZonedDateTime now = ZonedDateTime.now(timeZoneProvider.getTimeZone());
Locale effectiveLocale = locale != null ? locale : Locale.getDefault();
DateTimeFormatter formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG)
.withLocale(effectiveLocale);
return now.format(formatter);
}
}
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.internal.text.interpreter.llm;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.common.registry.RegistryChangeListener;
import org.openhab.core.voice.text.interpreter.llm.LLMTool;
import org.openhab.core.voice.text.interpreter.llm.LLMToolRegistry;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.component.annotations.ReferenceCardinality;
import org.osgi.service.component.annotations.ReferencePolicy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link LLMToolRegistryImpl} is the implementation of the {@link LLMToolRegistry}.
*
* @author Florian Hotze - Initial contribution
*/
@Component(service = LLMToolRegistry.class, immediate = true)
@NonNullByDefault
public class LLMToolRegistryImpl implements LLMToolRegistry {
private final Logger logger = LoggerFactory.getLogger(LLMToolRegistryImpl.class);
private final Map<String, LLMTool> llmTools = new ConcurrentHashMap<>();
private final Set<RegistryChangeListener<LLMTool>> listeners = new CopyOnWriteArraySet<>();
@Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC)
protected void addLLMTool(LLMTool llmTool) {
this.llmTools.put(llmTool.getUID(), llmTool);
listeners.forEach(l -> l.added(llmTool));
}
protected void removeLLMTool(LLMTool llmTool) {
this.llmTools.remove(llmTool.getUID());
listeners.forEach(l -> l.removed(llmTool));
}
@Override
public List<LLMTool> getByIds(List<String> ids) {
List<LLMTool> tools = new ArrayList<>();
for (String id : ids) {
LLMTool tool = llmTools.get(id);
if (tool == null) {
logger.warn("LLMTool '{}' not available!", id);
} else {
tools.add(tool);
}
}
return tools;
}
@Override
public Collection<LLMTool> getAll() {
return new HashSet<>(llmTools.values());
}
@Override
public Stream<LLMTool> stream() {
return getAll().stream();
}
@Override
public @Nullable LLMTool get(String id) {
return llmTools.get(id);
}
@Override
public void addRegistryChangeListener(RegistryChangeListener<LLMTool> listener) {
listeners.add(listener);
}
@Override
public void removeRegistryChangeListener(RegistryChangeListener<LLMTool> listener) {
listeners.remove(listener);
}
@Override
public LLMTool add(LLMTool element) {
throw new UnsupportedOperationException("LLMToolRegistry does not support adding elements manually.");
}
@Override
public @Nullable LLMTool update(LLMTool element) {
throw new UnsupportedOperationException("LLMToolRegistry does not support updating elements manually.");
}
@Override
public @Nullable LLMTool remove(String key) {
throw new UnsupportedOperationException("LLMToolRegistry does not support removing elements manually.");
}
}
@@ -18,6 +18,9 @@ import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.voice.DialogContext;
import org.openhab.core.voice.text.conversation.Conversation;
import org.openhab.core.voice.text.conversation.ConversationException;
import org.openhab.core.voice.text.conversation.ConversationRole;
/**
* This is the interface that a human language text interpreter has to implement.
@@ -59,11 +62,37 @@ public interface HumanLanguageInterpreter {
* @param text the text to interpret
* @return a human language response
*/
@Deprecated
default String interpret(Locale locale, String text, @Nullable DialogContext dialogContext)
throws InterpretationException {
return interpret(locale, text);
}
/**
* Continues the conversation provided in the {@link InterpreterContext} argument given a {@link Locale}.
*
* @implNote Implementations must add the response to the {@link Conversation} provided in the
* {@link InterpreterContext} before returning it.
*
* @param locale language of the text (given by a {@link Locale})
* @param interpreterContext the context for the interpretation
* @return a human language response
*/
default String interpret(Locale locale, InterpreterContext interpreterContext) throws InterpretationException {
Conversation.Message message = interpreterContext.conversation().getLastMessage();
if (message == null || message.role() != ConversationRole.USER) {
throw new InterpretationException("Last conversation message is not a user message");
}
String response = interpret(locale, message.content());
try {
interpreterContext.conversation().addMessage(ConversationRole.OPENHAB, response);
} catch (ConversationException e) {
String errMsg = e.getMessage();
throw new InterpretationException(errMsg != null ? errMsg : "Unknown conversation error");
}
return response;
}
/**
* Gets the grammar of all commands of a given {@link Locale} of the interpreter
*
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text;
import java.util.Locale;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Arguments for {@link org.openhab.core.voice.VoiceManager} interpretation.
*
* @author Miguel Álvarez Díez - Initial contribution
*/
@NonNullByDefault
public record InterpretationArguments(String hliIdList, String conversationId, String toolIdList,
@Nullable String locationItem, @Nullable Locale locale) {
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.voice.text.conversation.Conversation;
import org.openhab.core.voice.text.interpreter.llm.LLMTool;
/**
* Context passed to the {@link HumanLanguageInterpreter}
* when interpreting a new input text.
*
* @author Miguel Álvarez Díez - Initial contribution
*/
@NonNullByDefault
public record InterpreterContext(Conversation conversation, List<LLMTool> tools, @Nullable String locationItem) {
}
@@ -0,0 +1,222 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text.conversation;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* The {@link Conversation} class contains a list of messages in between the user and a LanguageInterpreter.
*
* @author Miguel Álvarez Díez - Initial contribution
*/
@NonNullByDefault
public class Conversation {
public static final int DEFAULT_MAX_MESSAGES = 50;
private final List<Message> messages;
private final String id;
private final CopyOnWriteArrayList<ConversationListener> listeners = new CopyOnWriteArrayList<>();
private int maxMessages = DEFAULT_MAX_MESSAGES;
public Conversation(String id) {
this.id = id;
this.messages = new ArrayList<>();
}
public Conversation(String id, List<Message> messages) {
this.id = id;
this.messages = new ArrayList<>(messages);
}
/**
* Adds a {@link ConversationListener}.
*
* @param listener the listener
*/
public void addListener(ConversationListener listener) {
listeners.addIfAbsent(listener);
}
/**
* Removes a {@link ConversationListener}.
*
* @param listener the listener
*/
public void removeListener(ConversationListener listener) {
listeners.remove(listener);
}
private void notifyMessageAdded(Message message) {
listeners.forEach(l -> l.onMessageAdded(this, message));
}
private void notifyMessagesRemoved(int removedSinceMessagesId) {
listeners.forEach(l -> l.onMessagesRemoved(this, removedSinceMessagesId));
}
/**
* Set the maximum number of messages to keep in history.
* At least 5 messages must be kept in the history.
*
* @param maxMessages the maximum number of messages
*/
public void setMaxMessages(int maxMessages) {
this.maxMessages = Math.max(5, maxMessages);
synchronized (messages) {
while (messages.size() > this.maxMessages) {
messages.removeFirst();
}
}
}
/**
* Get a copy of the list of messages.
*
* @return list of messages
*/
public List<Message> getMessages() {
synchronized (messages) {
return List.copyOf(messages);
}
}
/**
* Get the last message (if any).
*
* @return the last message or null
*/
public @Nullable Message getLastMessage() {
synchronized (messages) {
if (messages.isEmpty()) {
return null;
}
return messages.getLast();
}
}
/**
* Adds a new message to the end of the conversation.
*
* @param role the role that authored this message
* @param content the content of the message
* @return the UID of the newly added message
* @throws ConversationException see {@link #addMessage(ConversationRole, String, Integer)}
*/
public int addMessage(ConversationRole role, String content) throws ConversationException {
return addMessage(role, content, null);
}
/**
* Adds a new message to the end of the conversation.
*
* <p>
* The following conditions are enforced:
* <ul>
* <li>If <code>prevMessageUID</code> is provided: The last message has the expected UID, so the conversation hasn't
* changed unexpectedly.</li>
* <li>A tool return message must come after a tool call message.</li>
* <li>The first message is expected to be a user message.</li>
* </ul>
*
* @param role the role that authored this message
* @param content the content of the message
* @param prevMessageID the ID of the (assumed) previous message
* @return the UID of the newly added message
* @throws ConversationException when any of the above conditions is violated
*/
public int addMessage(ConversationRole role, String content, @Nullable Integer prevMessageID)
throws ConversationException {
Message message;
synchronized (messages) {
@Nullable
Message lastMessage = messages.isEmpty() ? null : messages.getLast();
if (lastMessage != null) {
if (prevMessageID != null && prevMessageID != lastMessage.id()) {
throw new ConversationException("Conversation has changed");
}
if (role == ConversationRole.TOOL_RETURN) {
if (lastMessage.role() != ConversationRole.TOOL_CALL) {
throw new ConversationException("Tool result should be after tool call");
}
}
} else if (!role.equals(ConversationRole.USER)) {
throw new ConversationException("First message should be a user message");
}
int id = lastMessage != null ? lastMessage.id() + 1 : 0;
message = new Message(id, role, content);
while (messages.size() >= maxMessages) {
messages.removeFirst();
}
messages.add(message);
}
notifyMessageAdded(message);
return message.id();
}
/**
* Removes a specific messages and all subsequent messages from the conversation.
*
* @param id the ID of the message and its successors to remove
* @return whether an actual removal happened
*/
public boolean removeSinceMessage(int id) {
boolean removed = false;
synchronized (messages) {
var messageOptional = messages.stream().filter(m -> m.id() == id).findFirst();
if (messageOptional.isPresent()) {
int index = messages.indexOf(messageOptional.get());
messages.subList(index, messages.size()).clear();
removed = true;
}
}
if (removed) {
notifyMessagesRemoved(id);
}
return removed;
}
/**
* Remove all messages from the conversation.
*/
public void removeMessages() {
boolean removed = false;
synchronized (messages) {
if (!messages.isEmpty()) {
messages.clear();
removed = true;
}
}
if (removed) {
notifyMessagesRemoved(0);
}
}
public String getId() {
return id;
}
/**
* A message.
*
* @param id the ID of the message
* @param role the role that authored the message
* @param content the content of the message
*/
public record Message(int id, ConversationRole role, String content) {
}
}
@@ -0,0 +1,64 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text.conversation;
import java.io.Serial;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* General purpose conversation exception.
*
* @author Miguel Álvarez Díez - Initial contribution
*/
@NonNullByDefault
public class ConversationException extends Exception {
@Serial
private static final long serialVersionUID = 1L;
/**
* Constructs a new exception with null as its detail message.
*/
public ConversationException() {
super();
}
/**
* Constructs a new exception with the specified detail message and cause.
*
* @param message Detail message
* @param cause The cause
*/
public ConversationException(String message, Throwable cause) {
super(message, cause);
}
/**
* Constructs a new exception with the specified detail message.
*
* @param message Detail message
*/
public ConversationException(String message) {
super(message);
}
/**
* Constructs a new exception with the specified cause.
*
* @param cause The cause
*/
public ConversationException(Throwable cause) {
super(cause);
}
}
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text.conversation;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Listener interface for receiving events from a {@link Conversation}.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public interface ConversationListener {
/**
* Called when a new message is added to the conversation.
*
* @param conversation the conversation
* @param message the added message
*/
void onMessageAdded(Conversation conversation, Conversation.Message message);
/**
* Called when messages are removed from the conversation.
*
* @param conversation the conversation
* @param removedSinceMessagesId the id of the message that messages have been removed since
*/
void onMessagesRemoved(Conversation conversation, int removedSinceMessagesId);
}
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text.conversation;
import java.util.Collection;
import java.util.Objects;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* The {@link ConversationManager} is responsible for managing the lifecycle and persistence of {@link Conversation}s.
*
* <p>
* Implementations should:
* <ul>
* <li>Automatically persist a persistable {@link Conversation} whenever a message is added or messages are
* removed.</li>
* <li>Emit {@link org.openhab.core.voice.text.conversation.events.ConversationEvent} implementations accordingly.</li>
* <li>If a conversation has a blank ID, no events will be emitted.</li>
* </ul>
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public interface ConversationManager {
/**
* Gets a conversation by its identifier.
*
* <p>
* If no conversation with that ID exists, create a new conversation.
*
* @param id the unique identifier of the conversation
* @return the conversation
*/
default Conversation getConversation(String id) {
return Objects.requireNonNull(getConversation(id, true));
}
/**
* Gets a conversation by its unique ID.
*
* @param id the unique identifier of the conversation
* @param createIfMissing whether to create a conversation with the provided <code>id</code> if none exists
* @return the conversation
*/
@Nullable
Conversation getConversation(String id, boolean createIfMissing);
/**
* Explicitly removes a conversation storage.
*
* @param id the conversation identifier
*/
void removeConversation(String id);
/**
* Returns all currently active or stored conversations.
*
* @return a collection of all conversations
*/
Collection<Conversation> getConversations();
/**
* Sets the maximum number of messages to keep in a conversation history.
*
* @param limit the maximum number of messages
*/
void setHistoryLimit(int limit);
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text.conversation;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link ConversationRole} class defines the role/author of a message in a {@link Conversation}.
*
* @author Miguel Álvarez Díez - Initial contribution
*/
@NonNullByDefault
public enum ConversationRole {
/** For user messages **/
USER,
/** For messages generated by an interpreter **/
OPENHAB,
/** For LLM thinking **/
THINKING,
/** For LLM tool calling **/
TOOL_CALL,
/** For LLM tool call calling result **/
TOOL_RETURN
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text.conversation.events;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* The {@link ConversationCreatedEvent} defines a {@link org.openhab.core.events.Event} that notifies about a new
* conversation.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public class ConversationCreatedEvent extends ConversationEvent {
/**
* The conversation added event type.
*/
public static final String TYPE = ConversationCreatedEvent.class.getSimpleName();
public ConversationCreatedEvent(String topic, String payload, @Nullable String source, String conversationId) {
super(topic, payload, source, conversationId);
}
@Override
public String getType() {
return TYPE;
}
}
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text.conversation.events;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.events.AbstractEvent;
/**
* Abstract implementation of a conversation event which will be posted for conversation changes.
*
* @author Miguel Álvarez Díez - Initial contribution
*/
@NonNullByDefault
public abstract class ConversationEvent extends AbstractEvent {
private final String conversationId;
/**
* Must be called in subclass constructor to create a new event.
*
* @param topic the topic
* @param payload the payload
* @param source the source
* @param conversationId the unique ID of the conversation
*/
protected ConversationEvent(String topic, String payload, @Nullable String source, String conversationId) {
super(topic, payload, source);
this.conversationId = conversationId;
}
@Override
abstract public String getType();
public String getConversationId() {
return conversationId;
}
public static class ConversationDTO {
public String conversationId = "";
public ConversationDTO withConversationId(String conversationId) {
this.conversationId = conversationId;
return this;
}
}
}
@@ -0,0 +1,121 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text.conversation.events;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.events.AbstractEventFactory;
import org.openhab.core.events.Event;
import org.openhab.core.events.EventFactory;
import org.openhab.core.voice.text.conversation.ConversationRole;
import org.osgi.service.component.annotations.Component;
/**
* The {@link ConversationEventFactory} defines a {@link Event} implementation that emits conversation changes.
*
* @author Miguel Álvarez Díez - Initial contribution
* @author Florian Hotze - Differentiate between created, removed, message added and messages removed events
*/
@Component(immediate = true, service = EventFactory.class)
@NonNullByDefault
public class ConversationEventFactory extends AbstractEventFactory {
private static final String CONVERSATION_ADDED_TOPIC = "openhab/conversations/{id}/added";
private static final String CONVERSATION_REMOVED_TOPIC = "openhab/conversations/{id}/removed";
private static final String CONVERSATION_MESSAGE_ADDED_TOPIC = "openhab/conversations/{id}/messageadded";
private static final String CONVERSATION_MESSAGES_REMOVED_TOPIC = "openhab/conversations/{id}/messagesremoved";
/**
* Constructs a new ConversationEventFactory.
*/
public ConversationEventFactory() {
super(Set.of(ConversationCreatedEvent.TYPE, ConversationRemovedEvent.TYPE, ConversationMessageAddedEvent.TYPE,
ConversationMessagesRemovedEvent.TYPE));
}
@Override
protected Event createEventByType(String eventType, String topic, String payload, @Nullable String source) {
if (ConversationCreatedEvent.TYPE.equals(eventType)) {
return createConversationCreatedEvent(topic, payload, source);
} else if (ConversationRemovedEvent.TYPE.equals(eventType)) {
return createConversationRemovedEvent(topic, payload, source);
} else if (ConversationMessageAddedEvent.TYPE.equals(eventType)) {
return createConversationMessageAddedEvent(topic, payload, source);
} else if (ConversationMessagesRemovedEvent.TYPE.equals(eventType)) {
return createConversationMessagesRemovedEvent(topic, payload, source);
}
throw new IllegalArgumentException("The event type '" + eventType + "' is not supported by this factory.");
}
private Event createConversationCreatedEvent(String topic, String payload, @Nullable String source) {
ConversationEvent.ConversationDTO conversationDTO = deserializePayload(payload,
ConversationEvent.ConversationDTO.class);
return new ConversationCreatedEvent(topic, payload, source, conversationDTO.conversationId);
}
private Event createConversationRemovedEvent(String topic, String payload, @Nullable String source) {
ConversationEvent.ConversationDTO conversationDTO = deserializePayload(payload,
ConversationEvent.ConversationDTO.class);
return new ConversationRemovedEvent(topic, payload, source, conversationDTO.conversationId);
}
private Event createConversationMessageAddedEvent(String topic, String payload, @Nullable String source) {
ConversationMessageAddedEvent.ConversationMessageAddedDTO messageDTO = deserializePayload(payload,
ConversationMessageAddedEvent.ConversationMessageAddedDTO.class);
return new ConversationMessageAddedEvent(topic, payload, source, messageDTO.conversationId,
messageDTO.messageId, messageDTO.role, messageDTO.text);
}
private Event createConversationMessagesRemovedEvent(String topic, String payload, @Nullable String source) {
ConversationMessagesRemovedEvent.ConversationMessagesRemovedDTO messageDTO = deserializePayload(payload,
ConversationMessagesRemovedEvent.ConversationMessagesRemovedDTO.class);
return new ConversationMessagesRemovedEvent(topic, payload, source, messageDTO.conversationId,
messageDTO.removedSinceMessagesId);
}
private static String buildTopic(String template, String id) {
return template.replace("{id}", id);
}
public static ConversationCreatedEvent createConversationCreatedEvent(String conversationId,
@Nullable String source) {
String payload = serializePayload(new ConversationEvent.ConversationDTO().withConversationId(conversationId));
return new ConversationCreatedEvent(buildTopic(CONVERSATION_ADDED_TOPIC, conversationId), payload, source,
conversationId);
}
public static ConversationRemovedEvent createConversationRemovedEvent(String conversationId,
@Nullable String source) {
String payload = serializePayload(new ConversationEvent.ConversationDTO().withConversationId(conversationId));
return new ConversationRemovedEvent(buildTopic(CONVERSATION_REMOVED_TOPIC, conversationId), payload, source,
conversationId);
}
public static ConversationMessageAddedEvent createConversationMessageAddedEvent(String conversationId,
int messageId, ConversationRole role, String text, @Nullable String source) {
String payload = serializePayload(new ConversationMessageAddedEvent.ConversationMessageAddedDTO()
.withConversationId(conversationId).withId(messageId).withParticipant(role).withText(text));
return new ConversationMessageAddedEvent(buildTopic(CONVERSATION_MESSAGE_ADDED_TOPIC, conversationId), payload,
source, conversationId, messageId, role, text);
}
public static ConversationMessagesRemovedEvent createConversationMessagesRemovedEvent(String conversationId,
int removedSinceMessagesId, @Nullable String source) {
String payload = serializePayload(new ConversationMessagesRemovedEvent.ConversationMessagesRemovedDTO()
.withConversationId(conversationId).withRemovedSinceMessagesId(removedSinceMessagesId));
return new ConversationMessagesRemovedEvent(buildTopic(CONVERSATION_MESSAGES_REMOVED_TOPIC, conversationId),
payload, source, conversationId, removedSinceMessagesId);
}
}
@@ -0,0 +1,86 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text.conversation.events;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.voice.text.conversation.ConversationRole;
/**
* The {@link ConversationMessageAddedEvent} defines a {@link org.openhab.core.events.Event} implementation that emits
* on addition of a message to a conversation.
*
* @author Miguel Álvarez Díez - Initial contribution
*/
@NonNullByDefault
public class ConversationMessageAddedEvent extends ConversationEvent {
/**
* The conversation message added event type.
*/
public static final String TYPE = ConversationMessageAddedEvent.class.getSimpleName();
private final int messageId;
private final ConversationRole role;
private final String text;
public ConversationMessageAddedEvent(String topic, String payload, @Nullable String source, String conversationId,
int messageId, ConversationRole role, String text) {
super(topic, payload, source, conversationId);
this.messageId = messageId;
this.role = role;
this.text = text;
}
@Override
public String getType() {
return TYPE;
}
public int getMessageId() {
return messageId;
}
public ConversationRole getRole() {
return role;
}
public String getText() {
return text;
}
public static class ConversationMessageAddedDTO extends ConversationEvent.ConversationDTO {
public int messageId = 0;
public ConversationRole role = ConversationRole.USER;
public String text = "";
@Override
public ConversationMessageAddedDTO withConversationId(String conversationId) {
return (ConversationMessageAddedDTO) super.withConversationId(conversationId);
}
public ConversationMessageAddedDTO withId(int id) {
this.messageId = id;
return this;
}
public ConversationMessageAddedDTO withParticipant(ConversationRole role) {
this.role = role;
return this;
}
public ConversationMessageAddedDTO withText(String text) {
this.text = text;
return this;
}
}
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text.conversation.events;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* The {@link ConversationMessagesRemovedEvent} defines a {@link org.openhab.core.events.Event} implementation that
* emits on removal of messages from a conversation.
*/
@NonNullByDefault
public class ConversationMessagesRemovedEvent extends ConversationEvent {
/**
* The conversation messages removed event type.
*/
public static final String TYPE = ConversationMessagesRemovedEvent.class.getSimpleName();
private final int removedSinceMessagesId;
public ConversationMessagesRemovedEvent(String topic, String payload, @Nullable String source,
String conversationId, int removedSinceMessagesId) {
super(topic, payload, source, conversationId);
this.removedSinceMessagesId = removedSinceMessagesId;
}
@Override
public String getType() {
return TYPE;
}
public int getRemovedSinceMessagesId() {
return removedSinceMessagesId;
}
public static class ConversationMessagesRemovedDTO extends ConversationEvent.ConversationDTO {
public int removedSinceMessagesId = 0;
@Override
public ConversationMessagesRemovedDTO withConversationId(String conversationId) {
return (ConversationMessagesRemovedDTO) super.withConversationId(conversationId);
}
public ConversationMessagesRemovedDTO withRemovedSinceMessagesId(int removedSinceMessagesId) {
this.removedSinceMessagesId = removedSinceMessagesId;
return this;
}
}
}
@@ -0,0 +1,39 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text.conversation.events;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* The {@link ConversationRemovedEvent} defines a {@link org.openhab.core.events.Event} that notifies about conversation
* removal.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public class ConversationRemovedEvent extends ConversationEvent {
/**
* The conversation removed event type.
*/
public static final String TYPE = ConversationRemovedEvent.class.getSimpleName();
public ConversationRemovedEvent(String topic, String payload, @Nullable String source, String conversationId) {
super(topic, payload, source, conversationId);
}
@Override
public String getType() {
return TYPE;
}
}
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text.interpreter.llm;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.common.registry.Identifiable;
/**
* The {@link LLMTool} interface represents a programmatic tool
* designed to be consumed by an LLM.
*
* @author Miguel Álvarez Díez - Initial contribution
*/
@NonNullByDefault
public interface LLMTool extends Identifiable<String> {
/**
* Returns a simple string that uniquely identifies this service.
*
* @return an id that identifies this service
*/
@Override
String getUID();
/**
* Returns a localized human-readable label that can be used within UIs.
*
* @param locale the locale to provide the label for
* @return a localized string to be used in UIs
*/
String getLabel(@Nullable Locale locale);
/**
* Returns a short localized human-readable description of the tool.
*
* @param locale the locale to provide the label for
* @return a localized string that shortly describes the tool purpose
*/
String getShortDescription(@Nullable Locale locale);
/**
* Returns a localized human-readable description of the tool.
*
* @param locale the locale to provide the label for
* @return a localized string to be feed to an LLM
*/
String getDescription(@Nullable Locale locale);
/**
* Returns a localized {@link LLMToolParam} list with human-readable descriptions of the tool parameters.
*
* @param locale the locale to provide the label for
* @return localized descriptions of the tool params
*/
List<LLMToolParam> getParamDescriptions(@Nullable Locale locale);
/**
* Invoke the tool implementation.
*
* @param params a map of parameter names and values
* @return the tool result
* @throws LLMToolException when invocation of the tool implementation failed
*/
String call(Map<String, Object> params, @Nullable Locale locale) throws LLMToolException;
}
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text.interpreter.llm;
import java.io.Serial;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* General purpose {@link LLMTool} exception.
*
* @author Miguel Álvarez Díez - Initial contribution
*/
@NonNullByDefault
public class LLMToolException extends Exception {
@Serial
private static final long serialVersionUID = 1L;
public LLMToolException() {
super();
}
public LLMToolException(String message, Throwable cause) {
super(message, cause);
}
public LLMToolException(String message) {
super(message);
}
public LLMToolException(Throwable cause) {
super(cause);
}
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text.interpreter.llm;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link LLMToolParam} class describe each of the parameters of a {@link LLMTool}.
*
* @author Miguel Álvarez Díez - Initial contribution
*
* @param name unique name of the parameter
* @param type type of the parameter
* @param description parameter description
* @param options if not empty, defines a list of allowed values for the parameter
* @param required whether the parameter is required
*/
@NonNullByDefault
public record LLMToolParam(String name, LLMToolParamType type, String description, List<String> options,
boolean required) {
}
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text.interpreter.llm;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link LLMToolParamType} enum describe the type of the parameters of {@link LLMTool} .
*
* @author Miguel Álvarez Díez - Initial contribution
*/
@NonNullByDefault
public enum LLMToolParamType {
BOOLEAN,
STRING,
NUMBER
}
@@ -0,0 +1,50 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text.interpreter.llm;
import java.util.Arrays;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.common.registry.Registry;
/**
* The {@link LLMToolRegistry} interface provides access to the available {@link LLMTool}s.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public interface LLMToolRegistry extends Registry<LLMTool, String> {
/**
* Retrieves a {@link LLMTool} collection.
* If no services match the provided ids returns an empty list.
*
* @param ids Comma separated list of LLM tool ids to use
* @return a list of {@link LLMTool} or an empty list if none of them is available
*/
default List<LLMTool> getByIds(@Nullable String ids) {
return ids == null || ids.isBlank() ? List.of()
: getByIds(Arrays.stream(ids.split(",")).map(String::trim).filter(s -> !s.isEmpty()).toList());
}
/**
* Retrieves a {@link LLMTool} collection.
* If no services match the provided ids returns an empty list.
*
* @param ids List of LLM tool ids to use
* @return a list of {@link LLMTool} or an empty list if none of them is available
*/
List<LLMTool> getByIds(List<String> ids);
}
@@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text;
package org.openhab.core.voice.text.interpreter.rulebased;
/**
* Abstract syntax tree node. Result of parsing an expression.
@@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text;
package org.openhab.core.voice.text.interpreter.rulebased;
import java.text.ParseException;
import java.util.ArrayList;
@@ -46,7 +46,13 @@ import org.openhab.core.library.types.StringType;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.TypeParser;
import org.openhab.core.voice.DialogContext;
import org.openhab.core.voice.text.HumanLanguageInterpreter;
import org.openhab.core.voice.text.InterpretationException;
import org.openhab.core.voice.text.InterpretationResult;
import org.openhab.core.voice.text.InterpreterContext;
import org.openhab.core.voice.text.conversation.Conversation;
import org.openhab.core.voice.text.conversation.ConversationException;
import org.openhab.core.voice.text.conversation.ConversationRole;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -181,12 +187,26 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
@Override
public String interpret(Locale locale, String text) throws InterpretationException {
return interpret(locale, text, null);
return interpret(locale, text, (String) null);
}
@Override
public String interpret(Locale locale, String text, @Nullable DialogContext dialogContext)
throws InterpretationException {
public String interpret(Locale locale, InterpreterContext interpreterContext) throws InterpretationException {
Conversation.Message message = interpreterContext.conversation().getLastMessage();
if (message == null || message.role() != ConversationRole.USER) {
throw new InterpretationException("Last conversation message is not an user message");
}
String response = interpret(locale, message.content(), interpreterContext.locationItem());
try {
interpreterContext.conversation().addMessage(ConversationRole.OPENHAB, response);
} catch (ConversationException e) {
String errMsg = e.getMessage();
throw new InterpretationException(errMsg != null ? errMsg : "Unknown conversation error");
}
return response;
}
private String interpret(Locale locale, String text, @Nullable String locationItem) throws InterpretationException {
ResourceBundle language = ResourceBundle.getBundle(LANGUAGE_SUPPORT, locale);
Rule[] rules = getRules(locale);
if (rules.length == 0) {
@@ -200,7 +220,6 @@ public abstract class AbstractRuleBasedInterpreter implements HumanLanguageInter
InterpretationResult result;
InterpretationResult lastResult = null;
String locationItem = dialogContext != null ? dialogContext.locationItem() : null;
for (Rule rule : rules) {
if ((result = rule.execute(language, tokens, locationItem)).isSuccess()) {
return result.getResponse();
@@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text;
package org.openhab.core.voice.text.interpreter.rulebased;
import java.util.HashSet;
import java.util.List;
@@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text;
package org.openhab.core.voice.text.interpreter.rulebased;
import java.util.Arrays;
import java.util.List;
@@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text;
package org.openhab.core.voice.text.interpreter.rulebased;
import java.util.ArrayList;
import java.util.List;
@@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text;
package org.openhab.core.voice.text.interpreter.rulebased;
import java.util.HashSet;
import java.util.ResourceBundle;
@@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text;
package org.openhab.core.voice.text.interpreter.rulebased;
import java.util.List;
import java.util.ResourceBundle;
@@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text;
package org.openhab.core.voice.text.interpreter.rulebased;
import java.util.ResourceBundle;
import java.util.Set;
@@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text;
package org.openhab.core.voice.text.interpreter.rulebased;
import java.util.Arrays;
import java.util.List;
@@ -10,12 +10,13 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text;
package org.openhab.core.voice.text.interpreter.rulebased;
import java.util.ResourceBundle;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.voice.text.InterpretationResult;
/**
* Represents an expression plus action code that will be executed after successful parsing. This class is immutable and
@@ -10,7 +10,7 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text;
package org.openhab.core.voice.text.interpreter.rulebased;
import java.util.List;
@@ -65,6 +65,11 @@
the TTS without storing it. 0 for no limit.</description>
<default>150</default>
</parameter>
<parameter name="conversationHistoryLimit" type="integer" min="5" step="5">
<label>Conversation History Limit</label>
<description>The maximum number of messages to keep in a conversation history.</description>
<default>50</default>
</parameter>
</config-description>
</config-description:config-descriptions>
@@ -0,0 +1,230 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.internal.text.conversation;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openhab.core.events.EventPublisher;
import org.openhab.core.storage.Storage;
import org.openhab.core.storage.StorageService;
import org.openhab.core.voice.text.conversation.Conversation;
import org.openhab.core.voice.text.conversation.ConversationException;
import org.openhab.core.voice.text.conversation.ConversationRole;
import org.openhab.core.voice.text.conversation.events.ConversationCreatedEvent;
import org.openhab.core.voice.text.conversation.events.ConversationMessageAddedEvent;
import org.openhab.core.voice.text.conversation.events.ConversationMessagesRemovedEvent;
import org.openhab.core.voice.text.conversation.events.ConversationRemovedEvent;
/**
* Test class for {@link ConversationManagerImpl}.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public class ConversationManagerImplTest {
private final StorageService storageService = mock(StorageService.class);
private final EventPublisher eventPublisher = mock(EventPublisher.class);
@SuppressWarnings("unchecked")
private final Storage<ConversationDTO> storage = mock(Storage.class);
private @NonNullByDefault({}) ConversationManagerImpl conversationManager;
@BeforeEach
public void setUp() {
doReturn(storage).when(storageService).getStorage(eq(Conversation.class.getName()), any());
conversationManager = new ConversationManagerImpl(storageService, eventPublisher);
}
@AfterEach
public void tearDown() {
clearInvocations(storageService, eventPublisher, storage);
}
@Test
public void getConversationCreatesNewConversationIfCreateIfMissingIsTrue() {
String id = "test-conv";
when(storage.get(id)).thenReturn(null);
Conversation conversation = conversationManager.getConversation(id, true);
assertNotNull(conversation);
assertEquals(id, conversation.getId());
assertTrue(conversation.getMessages().isEmpty());
}
@Test
public void getConversationDoesNotCreateNewConversationIfCreateIfMissingIsFalse() {
String id = "test-conv";
when(storage.get(id)).thenReturn(null);
Conversation conversation = conversationManager.getConversation(id, false);
assertNull(conversation);
}
@Test
public void getConversationEmitsEventOnCreatingNewConversation() {
String id = "test-conv";
when(storage.get(id)).thenReturn(null);
conversationManager.getConversation(id);
verify(eventPublisher).post(any(ConversationCreatedEvent.class));
}
@Test
public void getConversationDoesNotEmitEventOnCreatingConversationWithBlankId() {
String id = "";
conversationManager.getConversation(id);
verify(eventPublisher, never()).post(any(ConversationCreatedEvent.class));
}
@Test
public void getConversationLoadsConversationFromStorage() {
String id = "stored-conv";
MessageDTO messageDTO = new MessageDTO(1, ConversationRole.USER, "Hello");
ArrayList<MessageDTO> messages = new ArrayList<>(List.of(messageDTO));
ConversationDTO conversationDTO = new ConversationDTO(messages);
when(storage.get(id)).thenReturn(conversationDTO);
Conversation conversation = conversationManager.getConversation(id);
assertNotNull(conversation);
assertEquals(id, conversation.getId());
assertEquals(1, conversation.getMessages().size());
assertEquals("Hello", conversation.getMessages().getFirst().content());
// Verify it was put into active conversations map by checking that subsequent call returns the same instance
assertSame(conversation, conversationManager.getConversation(id));
}
@Test
public void removeConversationRemovesFromStorage() {
String id = "to-remove";
conversationManager.removeConversation(id);
verify(storage).remove(id);
verify(eventPublisher).post(any(ConversationRemovedEvent.class));
}
@Test
public void removeConversationEmitsEvent() {
String id = "to-remove";
conversationManager.removeConversation(id);
verify(eventPublisher).post(any(ConversationRemovedEvent.class));
}
@Test
public void getConversationsReturnsAllFromStorage() {
when(storage.getKeys()).thenReturn(Set.of("conv1", "conv2"));
when(storage.get("conv1")).thenReturn(new ConversationDTO(new ArrayList<>()));
when(storage.get("conv2")).thenReturn(new ConversationDTO(new ArrayList<>()));
Collection<Conversation> conversations = conversationManager.getConversations();
assertEquals(2, conversations.size());
}
@Test
public void addMessageToAConversationEmitsEvent() throws ConversationException {
String id = "event-test";
Conversation conversation = conversationManager.getConversation(id);
clearInvocations(eventPublisher); // clear events from creating conversation
conversation.addMessage(ConversationRole.USER, "Hello");
verify(eventPublisher).post(any(ConversationMessageAddedEvent.class));
}
@Test
public void addMessageToAConversationPersistsToStorage() throws ConversationException {
String id = "event-test";
Conversation conversation = conversationManager.getConversation(id);
conversation.addMessage(ConversationRole.USER, "Hello");
verify(storage).put(eq(id), any(ConversationDTO.class));
}
@Test
public void addMessageToAConversationWithBlankIdDoesNotPersistToStorage() throws ConversationException {
String id = "";
Conversation conversation = conversationManager.getConversation(id);
conversation.addMessage(ConversationRole.USER, "Hello");
verify(storage, never()).put(eq(id), any(ConversationDTO.class));
}
@Test
public void removeMessagesFromAConversationEmitsEvent() throws ConversationException {
String id = "event-test";
Conversation conversation = conversationManager.getConversation(id);
conversation.addMessage(ConversationRole.USER, "1");
conversation.addMessage(ConversationRole.USER, "2");
clearInvocations(eventPublisher); // clear events from creating conversation
conversation.removeSinceMessage(1);
verify(eventPublisher).post(any(ConversationMessagesRemovedEvent.class));
conversation.removeSinceMessage(0);
verify(eventPublisher).post(any(ConversationRemovedEvent.class));
}
@Test
public void removeMessagesFromAConversationPersistsToStorage() throws ConversationException {
String id = "event-test";
Conversation conversation = conversationManager.getConversation(id);
conversation.addMessage(ConversationRole.USER, "1");
conversation.addMessage(ConversationRole.USER, "2");
clearInvocations(storage, storageService); // clear stores from setting up conversation
conversation.removeSinceMessage(1);
verify(storage).put(eq(id), any(ConversationDTO.class));
clearInvocations(storage, storageService);
conversation.removeSinceMessage(0);
verify(storage).remove(eq(id));
}
@Test
public void removeMessagesFromAConversationWithBlankIdDoesNotPersistToStorage() throws ConversationException {
String id = "";
Conversation conversation = conversationManager.getConversation(id);
conversation.addMessage(ConversationRole.USER, "1");
conversation.addMessage(ConversationRole.USER, "2");
conversation.addMessage(ConversationRole.USER, "3");
conversation.addMessage(ConversationRole.USER, "4");
conversation.addMessage(ConversationRole.USER, "5");
clearInvocations(storage, storageService); // clear stores from setting up conversation
conversation.removeSinceMessage(3);
conversation.removeSinceMessage(0);
verify(storage, never()).put(eq(id), any(ConversationDTO.class));
}
}
@@ -10,17 +10,17 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.internal.text;
package org.openhab.core.voice.internal.text.interpreter;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
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_FORCED_CONFIGURATION;
import static org.openhab.core.voice.text.AbstractRuleBasedInterpreter.IS_SILENT_CONFIGURATION;
import static org.openhab.core.voice.text.AbstractRuleBasedInterpreter.IS_TEMPLATE_CONFIGURATION;
import static org.openhab.core.voice.internal.text.interpreter.StandardInterpreter.VOICE_SYSTEM_NAMESPACE;
import static org.openhab.core.voice.text.interpreter.rulebased.AbstractRuleBasedInterpreter.IS_FORCED_CONFIGURATION;
import static org.openhab.core.voice.text.interpreter.rulebased.AbstractRuleBasedInterpreter.IS_SILENT_CONFIGURATION;
import static org.openhab.core.voice.text.interpreter.rulebased.AbstractRuleBasedInterpreter.IS_TEMPLATE_CONFIGURATION;
import java.util.HashMap;
import java.util.List;
@@ -127,7 +127,7 @@ public class StandardInterpreterTest {
computerItem2.setLabel("Computer");
when(locationItem.getMembers()).thenReturn(Set.of(computerItem));
var dialogContext = new DialogContext(null, null, sttService, ttsService, null, List.of(), audioSource,
audioSink, Locale.ENGLISH, "", locationItem.getName(), null, null);
audioSink, Locale.ENGLISH, "", locationItem.getName(), null, null, null, List.of());
List<Item> items = List.of(computerItem2, locationItem, computerItem);
when(itemRegistryMock.getItems()).thenReturn(items);
assertEquals(OK_RESPONSE, standardInterpreter.interpret(Locale.ENGLISH, "turn off computer", dialogContext));
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.internal.text.interpreter.llm;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.time.ZoneId;
import java.util.Locale;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.voice.text.interpreter.llm.LLMToolException;
/**
* Test class for {@link DateTimeLLMTool}.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public class DateTimeLLMToolTest {
private final TimeZoneProvider timeZoneProvider = mock(TimeZoneProvider.class);
private @NonNullByDefault({}) DateTimeLLMTool tool;
@BeforeEach
public void setUp() {
when(timeZoneProvider.getTimeZone()).thenReturn(ZoneId.of("UTC"));
tool = new DateTimeLLMTool(timeZoneProvider);
}
@Test
public void testGetUID() {
assertEquals(DateTimeLLMTool.ID, tool.getUID());
}
@Test
public void testGetLabel() {
assertNotNull(tool.getLabel(Locale.ENGLISH));
}
@Test
public void testGetDescription() {
assertNotNull(tool.getDescription(Locale.ENGLISH));
}
@Test
public void testCall() throws LLMToolException {
String result = tool.call(Map.of(), Locale.ENGLISH);
assertNotNull(result);
assertFalse(result.isEmpty());
}
}
@@ -0,0 +1,97 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.internal.text.interpreter.llm;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openhab.core.common.registry.RegistryChangeListener;
import org.openhab.core.voice.text.interpreter.llm.LLMTool;
/**
* Test class for {@link LLMToolRegistryImpl}.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public class LLMToolRegistryImplTest {
private LLMTool tool1 = mock(LLMTool.class);
private LLMTool tool2 = mock(LLMTool.class);
private @NonNullByDefault({}) LLMToolRegistryImpl registry;
@BeforeEach
public void setUp() {
registry = new LLMToolRegistryImpl();
when(tool1.getUID()).thenReturn("tool1");
when(tool2.getUID()).thenReturn("tool2");
}
@AfterEach
public void tearDown() {
clearInvocations(tool1, tool2);
}
@SuppressWarnings("unchecked")
@Test
public void testAddAndRemoveLLMTool() {
RegistryChangeListener<LLMTool> listener = mock(RegistryChangeListener.class);
registry.addRegistryChangeListener(listener);
registry.addLLMTool(tool1);
assertEquals(tool1, registry.get("tool1"));
verify(listener).added(tool1);
registry.removeLLMTool(tool1);
assertNull(registry.get("tool1"));
verify(listener).removed(tool1);
}
@Test
public void getByIdsListReturnsAvailableLLMTools() {
registry.addLLMTool(tool1);
registry.addLLMTool(tool2);
List<LLMTool> result = registry.getByIds(List.of("tool1", "tool2", "tool3"));
assertEquals(2, result.size());
assertTrue(result.contains(tool1));
assertTrue(result.contains(tool2));
}
@Test
public void getByIdsStringReturnsAvailableLLMTools() {
registry.addLLMTool(tool1);
registry.addLLMTool(tool2);
List<LLMTool> result = registry.getByIds("tool1,tool2,tool3");
assertEquals(2, result.size());
assertTrue(result.contains(tool1));
assertTrue(result.contains(tool2));
}
@Test
public void getByIdsStringHandlesBlankStringOrNull() {
registry.addLLMTool(tool1);
registry.addLLMTool(tool2);
assertTrue(registry.getByIds("").isEmpty());
assertTrue(registry.getByIds((String) null).isEmpty());
}
}
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text.conversation;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
/**
* Test class for {@link org.openhab.core.voice.text.conversation.Conversation}.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public class ConversationTest {
@Test
public void addMessageRespectsHistoryLimit() throws ConversationException {
Conversation conversation = new Conversation("conversation");
conversation.setMaxMessages(5);
conversation.addMessage(ConversationRole.USER, "1");
conversation.addMessage(ConversationRole.OPENHAB, "2");
conversation.addMessage(ConversationRole.USER, "3");
conversation.addMessage(ConversationRole.OPENHAB, "4");
conversation.addMessage(ConversationRole.USER, "5");
conversation.addMessage(ConversationRole.OPENHAB, "6");
conversation.addMessage(ConversationRole.USER, "7");
assertEquals(5, conversation.getMessages().size());
assertEquals("3", conversation.getMessages().get(0).content());
assertEquals("4", conversation.getMessages().get(1).content());
}
@Test
public void concurrentAddsWithSamePreviousMessageIdAllowOnlyOneAppend() throws Exception {
Conversation conversation = new Conversation("conversation");
int previousMessageId = conversation.addMessage(ConversationRole.USER, "question");
int attempts = 20;
CountDownLatch ready = new CountDownLatch(attempts);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(attempts);
AtomicInteger successes = new AtomicInteger();
ExecutorService executor = Executors.newFixedThreadPool(attempts);
try {
for (int i = 0; i < attempts; i++) {
executor.submit(() -> {
ready.countDown();
try {
if (start.await(5, TimeUnit.SECONDS)) {
try {
conversation.addMessage(ConversationRole.OPENHAB, "answer", previousMessageId);
successes.incrementAndGet();
} catch (ConversationException ignored) {
// Expected for stale concurrent appends.
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
});
}
assertTrue(ready.await(5, TimeUnit.SECONDS));
start.countDown();
assertTrue(done.await(5, TimeUnit.SECONDS));
} finally {
executor.shutdownNow();
}
assertEquals(1, successes.get());
assertEquals(2, conversation.getMessages().size());
assertEquals(1, conversation.getMessages().get(1).id());
}
}
@@ -0,0 +1,152 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.core.voice.text.conversation.events;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.core.events.Event;
import org.openhab.core.voice.text.conversation.ConversationRole;
/**
* Test class for {@link ConversationEventFactory}.
*
* @author Florian Hotze - Initial contribution
*/
@NonNullByDefault
public class ConversationEventFactoryTest {
ConversationEventFactory factory = new ConversationEventFactory();
@Test
public void createConversationCreatedEvent() {
String id = "conv-1";
ConversationCreatedEvent event = ConversationEventFactory.createConversationCreatedEvent(id, null);
assertEquals(ConversationCreatedEvent.TYPE, event.getType());
assertEquals("openhab/conversations/conv-1/added", event.getTopic());
assertEquals(id, event.getConversationId());
}
@Test
public void serializeDeserializeConversationCreatedEvent() {
String id = "conv-1";
ConversationCreatedEvent event = ConversationEventFactory.createConversationCreatedEvent(id, null);
Event deserialized = factory.createEventByType(event.getType(), event.getTopic(), event.getPayload(),
event.getSource());
assertInstanceOf(ConversationCreatedEvent.class, deserialized);
assertEquals(ConversationCreatedEvent.TYPE, deserialized.getType());
assertEquals(event.getTopic(), deserialized.getTopic());
assertEquals(event.getPayload(), deserialized.getPayload());
assertEquals(event.getSource(), deserialized.getSource());
assertEquals(id, ((ConversationCreatedEvent) deserialized).getConversationId());
}
@Test
public void createConversationRemovedEvent() {
String id = "conv-1";
ConversationRemovedEvent event = ConversationEventFactory.createConversationRemovedEvent(id, null);
assertEquals(ConversationRemovedEvent.TYPE, event.getType());
assertEquals("openhab/conversations/conv-1/removed", event.getTopic());
assertEquals(id, event.getConversationId());
}
@Test
public void serializeDeserializeConversationRemovedEvent() {
String id = "conv-1";
ConversationRemovedEvent event = ConversationEventFactory.createConversationRemovedEvent(id, null);
Event deserialized = factory.createEventByType(event.getType(), event.getTopic(), event.getPayload(),
event.getSource());
assertInstanceOf(ConversationRemovedEvent.class, deserialized);
assertEquals(ConversationRemovedEvent.TYPE, deserialized.getType());
assertEquals(event.getTopic(), deserialized.getTopic());
assertEquals(event.getPayload(), deserialized.getPayload());
assertEquals(event.getSource(), deserialized.getSource());
assertEquals(id, ((ConversationRemovedEvent) deserialized).getConversationId());
}
@Test
public void createConversationMessageAddedEvent() {
String convId = "conv-1";
int msgId = 1;
ConversationRole role = ConversationRole.USER;
String text = "Hello";
ConversationMessageAddedEvent event = ConversationEventFactory.createConversationMessageAddedEvent(convId,
msgId, role, text, null);
assertEquals(ConversationMessageAddedEvent.TYPE, event.getType());
assertEquals("openhab/conversations/conv-1/messageadded", event.getTopic());
assertEquals(convId, event.getConversationId());
assertEquals(msgId, event.getMessageId());
assertEquals(role, event.getRole());
assertEquals(text, event.getText());
}
@Test
public void serializeDeserializeConversationMessageAddedEvent() {
String convId = "conv-1";
int msgId = 1;
ConversationRole role = ConversationRole.USER;
String text = "Hello";
ConversationMessageAddedEvent event = ConversationEventFactory.createConversationMessageAddedEvent(convId,
msgId, role, text, null);
Event deserialized = factory.createEventByType(event.getType(), event.getTopic(), event.getPayload(),
event.getSource());
assertInstanceOf(ConversationMessageAddedEvent.class, deserialized);
assertEquals(ConversationMessageAddedEvent.TYPE, deserialized.getType());
assertEquals(event.getTopic(), deserialized.getTopic());
assertEquals(event.getPayload(), deserialized.getPayload());
assertEquals(event.getSource(), deserialized.getSource());
assertEquals(convId, ((ConversationMessageAddedEvent) deserialized).getConversationId());
assertEquals(msgId, ((ConversationMessageAddedEvent) deserialized).getMessageId());
assertEquals(role, ((ConversationMessageAddedEvent) deserialized).getRole());
assertEquals(text, ((ConversationMessageAddedEvent) deserialized).getText());
}
@Test
public void createConversationMessagesRemovedEvent() {
String convId = "conv-1";
int removedSinceMessagesId = 1;
ConversationMessagesRemovedEvent event = ConversationEventFactory.createConversationMessagesRemovedEvent(convId,
removedSinceMessagesId, null);
assertEquals(ConversationMessagesRemovedEvent.TYPE, event.getType());
assertEquals("openhab/conversations/conv-1/messagesremoved", event.getTopic());
assertEquals(convId, event.getConversationId());
assertEquals(removedSinceMessagesId, event.getRemovedSinceMessagesId());
}
@Test
public void serializeDeserializeConversationMessagesRemovedEvent() {
String convId = "conv-1";
int removedSinceMessagesId = 1;
ConversationMessagesRemovedEvent event = ConversationEventFactory.createConversationMessagesRemovedEvent(convId,
removedSinceMessagesId, null);
Event deserialized = factory.createEventByType(event.getType(), event.getTopic(), event.getPayload(),
event.getSource());
assertInstanceOf(ConversationMessagesRemovedEvent.class, deserialized);
assertEquals(ConversationMessagesRemovedEvent.TYPE, deserialized.getType());
assertEquals(event.getTopic(), deserialized.getTopic());
assertEquals(event.getPayload(), deserialized.getPayload());
assertEquals(event.getSource(), deserialized.getSource());
assertEquals(convId, ((ConversationMessagesRemovedEvent) deserialized).getConversationId());
assertEquals(removedSinceMessagesId,
((ConversationMessagesRemovedEvent) deserialized).getRemovedSinceMessagesId());
}
}
@@ -42,7 +42,9 @@ import org.openhab.core.voice.DialogContext;
import org.openhab.core.voice.DialogRegistration;
import org.openhab.core.voice.Voice;
import org.openhab.core.voice.VoiceManager;
import org.openhab.core.voice.text.InterpretationArguments;
import org.openhab.core.voice.text.InterpretationException;
import org.openhab.core.voice.text.conversation.ConversationRole;
import org.osgi.framework.BundleContext;
import org.osgi.service.cm.Configuration;
import org.osgi.service.cm.ConfigurationAdmin;
@@ -153,16 +155,16 @@ public class VoiceManagerImplTest extends JavaOSGiTest {
public void interpretSomethingWithGivenHliIdWhenTheHliIsARegisteredService() throws InterpretationException {
hliStub = new HumanLanguageInterpreterStub();
registerService(hliStub);
String result = voiceManager.interpret("something", hliStub.getId());
String result = voiceManager.interpret("something",
new InterpretationArguments(hliStub.getId(), "", "", "", null));
assertThat(result, is("Interpreted text"));
}
@Test
public void interpretSomethingWithGivenHliIdEhenTheHliIsNotARegisteredService() throws InterpretationException {
hliStub = new HumanLanguageInterpreterStub();
assertThrows(InterpretationException.class, () -> voiceManager.interpret("something", hliStub.getId()));
assertThrows(InterpretationException.class, () -> voiceManager.interpret("something",
new InterpretationArguments(hliStub.getId(), "", "", "", null)));
}
@Test
@@ -179,7 +181,7 @@ public class VoiceManagerImplTest extends JavaOSGiTest {
// Wait some time to be sure that the configuration will be updated
Thread.sleep(2000);
String result = voiceManager.interpret("something", null);
String result = voiceManager.interpret("something");
assertThat(result, is("Interpreted text"));
}
@@ -190,11 +192,56 @@ public class VoiceManagerImplTest extends JavaOSGiTest {
hliStub.setExceptionExpected(true);
var anotherHLIStub = new HumanLanguageInterpreterStub();
registerService(anotherHLIStub);
String result = voiceManager.interpret("something",
String.join(",", List.of(hliStub.getId(), anotherHLIStub.getId())));
String result = voiceManager.interpret("something", new InterpretationArguments(
String.join(",", List.of(hliStub.getId(), anotherHLIStub.getId())), "", "", "", null));
assertThat(result, is("Interpreted text"));
}
@Test
public void interpretWithConversation() throws InterpretationException {
hliStub = new HumanLanguageInterpreterStub();
registerService(hliStub);
String convId = "conv-1";
String result = voiceManager.interpret("hello",
new InterpretationArguments(hliStub.getId(), convId, "", "", null));
assertThat(result, is("Interpreted text"));
// Verify conversation was updated
var conversationManager = getService(org.openhab.core.voice.text.conversation.ConversationManager.class);
assertNotNull(conversationManager, "ConversationManager service should be available");
var conversation = conversationManager.getConversation(convId);
assertThat(conversation.getMessages().size(), is(2));
assertThat(conversation.getMessages().get(0).role(), is(ConversationRole.USER));
assertThat(conversation.getMessages().get(0).content(), is("hello"));
assertThat(conversation.getMessages().get(1).role(), is(ConversationRole.OPENHAB));
assertThat(conversation.getMessages().get(1).content(), is("Interpreted text"));
}
@Test
public void testMultiTurnConversation() throws InterpretationException {
hliStub = new HumanLanguageInterpreterStub();
registerService(hliStub);
String convId = "multi-turn";
voiceManager.interpret("turn on light", new InterpretationArguments(hliStub.getId(), convId, "", "", null));
voiceManager.interpret("and close shutters",
new InterpretationArguments(hliStub.getId(), convId, "", "", null));
var conversationManager = getService(org.openhab.core.voice.text.conversation.ConversationManager.class);
assertNotNull(conversationManager, "ConversationManager service should be available");
var conversation = conversationManager.getConversation(convId);
assertThat(conversation.getMessages().size(), is(4));
assertThat(conversation.getMessages().get(0).role(), is(ConversationRole.USER));
assertThat(conversation.getMessages().get(0).content(), is("turn on light"));
assertThat(conversation.getMessages().get(1).role(), is(ConversationRole.OPENHAB));
assertThat(conversation.getMessages().get(1).content(), is("Interpreted text"));
assertThat(conversation.getMessages().get(2).role(), is(ConversationRole.USER));
assertThat(conversation.getMessages().get(2).content(), is("and close shutters"));
assertThat(conversation.getMessages().get(3).role(), is(ConversationRole.OPENHAB));
assertThat(conversation.getMessages().get(3).content(), is("Interpreted text"));
}
@Test
public void verifyThatADialogIsNotStartedWhenAnyOfTheRequiredServiceIsNull() {
sttService = new STTServiceStub();
@@ -627,6 +674,7 @@ public class VoiceManagerImplTest extends JavaOSGiTest {
Thread.sleep(2000);
// Add a dialog registration
var dialogRegistration = new DialogRegistration(source.getId(), sink.getId());
dialogRegistration.conversationId = "registered-dialog-conversation";
voiceManager.registerDialog(dialogRegistration);
// Wait some time to be sure dialog build has been fired and check dialog has been started
Thread.sleep(6000);
@@ -641,6 +689,14 @@ public class VoiceManagerImplTest extends JavaOSGiTest {
assertThat(hliStub.getAnswer(), is("Interpreted text"));
assertThat(ttsService.getSynthesized(), is("Interpreted text"));
assertTrue(sink.getIsStreamProcessed());
var conversationManager = getService(org.openhab.core.voice.text.conversation.ConversationManager.class);
assertNotNull(conversationManager, "ConversationManager service should be available");
var conversation = conversationManager.getConversation("registered-dialog-conversation");
assertThat(conversation.getMessages().size(), is(2));
assertThat(conversation.getMessages().get(0).role(), is(ConversationRole.USER));
assertThat(conversation.getMessages().get(0).content(), is("Recognized text"));
assertThat(conversation.getMessages().get(1).role(), is(ConversationRole.OPENHAB));
assertThat(conversation.getMessages().get(1).content(), is("Interpreted text"));
// Remove the dialog registration
voiceManager.unregisterDialog(dialogRegistration);
// Assert dialog has been stopped