mirror of
https://github.com/danieldemus/openhab-core.git
synced 2026-07-29 12:34:22 +02:00
[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:
co-authored by
Miguel Álvarez
Kai Kreuzer
parent
927f244419
commit
de3c272bc5
+33
@@ -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) {
|
||||
}
|
||||
}
|
||||
+37
@@ -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());
|
||||
}
|
||||
}
|
||||
+62
-24
@@ -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());
|
||||
|
||||
+47
@@ -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());
|
||||
}
|
||||
}
|
||||
+28
-4
@@ -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) {
|
||||
|
||||
+20
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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
|
||||
*/
|
||||
|
||||
+14
-2
@@ -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);
|
||||
|
||||
+36
-11
@@ -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);
|
||||
|
||||
+137
@@ -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);
|
||||
}
|
||||
}
|
||||
+172
-33
@@ -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");
|
||||
|
||||
+108
-87
@@ -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) //
|
||||
|
||||
+29
@@ -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) {
|
||||
}
|
||||
+171
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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());
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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;
|
||||
+82
@@ -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);
|
||||
}
|
||||
}
|
||||
+114
@@ -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.");
|
||||
}
|
||||
}
|
||||
+29
@@ -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
|
||||
*
|
||||
|
||||
+28
@@ -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) {
|
||||
}
|
||||
+31
@@ -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) {
|
||||
|
||||
}
|
||||
+222
@@ -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) {
|
||||
}
|
||||
}
|
||||
+64
@@ -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);
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
+81
@@ -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);
|
||||
}
|
||||
+34
@@ -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
|
||||
}
|
||||
+39
@@ -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;
|
||||
}
|
||||
}
|
||||
+56
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -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);
|
||||
}
|
||||
}
|
||||
+86
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -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;
|
||||
}
|
||||
}
|
||||
+80
@@ -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;
|
||||
}
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
+33
@@ -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) {
|
||||
}
|
||||
+27
@@ -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
|
||||
}
|
||||
+50
@@ -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);
|
||||
}
|
||||
+1
-1
@@ -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.
|
||||
+25
-6
@@ -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();
|
||||
+1
-1
@@ -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;
|
||||
+1
-1
@@ -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;
|
||||
+1
-1
@@ -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;
|
||||
+1
-1
@@ -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;
|
||||
+1
-1
@@ -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;
|
||||
+1
-1
@@ -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;
|
||||
+1
-1
@@ -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;
|
||||
+2
-1
@@ -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
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+230
@@ -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));
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -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));
|
||||
+68
@@ -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());
|
||||
}
|
||||
}
|
||||
+97
@@ -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());
|
||||
}
|
||||
}
|
||||
+95
@@ -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());
|
||||
}
|
||||
}
|
||||
+152
@@ -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());
|
||||
}
|
||||
}
|
||||
+63
-7
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user