Skip to content

Commit 53ae92a

Browse files
committed
[voice] Update HLI interface for LLM implementations
Signed-off-by: Miguel Álvarez <miguelwork92@gmail.com>
1 parent 0e9ca96 commit 53ae92a

File tree

28 files changed

+1419
-60
lines changed

28 files changed

+1419
-60
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright (c) 2010-2026 Contributors to the openHAB project
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License 2.0 which is available at
9+
* http://www.eclipse.org/legal/epl-2.0
10+
*
11+
* SPDX-License-Identifier: EPL-2.0
12+
*/
13+
package org.openhab.core.io.rest.voice.internal;
14+
15+
import java.util.List;
16+
17+
import io.swagger.v3.oas.annotations.media.Schema;
18+
19+
/**
20+
* A DTO that is used on the REST API to provide infos about {@link org.openhab.core.voice.text.Conversation} to UIs.
21+
*
22+
* @author Miguel Álvarez Díez - Initial contribution
23+
*/
24+
@Schema(name = "Conversation")
25+
public class ConversationDTO {
26+
public String id;
27+
public List<MessageDTO> messages;
28+
29+
public static class MessageDTO {
30+
public String uid;
31+
public String rol;
32+
public String content;
33+
}
34+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright (c) 2010-2026 Contributors to the openHAB project
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License 2.0 which is available at
9+
* http://www.eclipse.org/legal/epl-2.0
10+
*
11+
* SPDX-License-Identifier: EPL-2.0
12+
*/
13+
package org.openhab.core.io.rest.voice.internal;
14+
15+
import org.eclipse.jdt.annotation.NonNullByDefault;
16+
import org.openhab.core.voice.text.Conversation;
17+
18+
/**
19+
* Mapper class that maps {@link org.openhab.core.voice.text.Conversation} instanced to their respective DTOs.
20+
*
21+
* @author Miguel Álvarez Díez - Initial contribution
22+
*/
23+
@NonNullByDefault
24+
public class ConversationMapper {
25+
26+
/**
27+
* Maps a {@link Conversation} to a {@link ConversationDTO}.
28+
*
29+
* @param conversation the conversation
30+
*
31+
* @return the corresponding DTO
32+
*/
33+
public static ConversationDTO map(Conversation conversation) {
34+
ConversationDTO dto = new ConversationDTO();
35+
dto.id = conversation.getId();
36+
dto.messages = conversation.getMessages().stream().map(m -> {
37+
ConversationDTO.MessageDTO messageDTO = new ConversationDTO.MessageDTO();
38+
messageDTO.uid = m.getUID();
39+
messageDTO.rol = m.getRole().name();
40+
messageDTO.content = m.getContent();
41+
return messageDTO;
42+
}).toList();
43+
return dto;
44+
}
45+
}

bundles/org.openhab.core.io.rest.voice/src/main/java/org/openhab/core/io/rest/voice/internal/VoiceResource.java

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import javax.annotation.security.RolesAllowed;
2020
import javax.ws.rs.Consumes;
21+
import javax.ws.rs.DELETE;
2122
import javax.ws.rs.GET;
2223
import javax.ws.rs.HeaderParam;
2324
import javax.ws.rs.POST;
@@ -41,13 +42,17 @@
4142
import org.openhab.core.io.rest.RESTConstants;
4243
import org.openhab.core.io.rest.RESTResource;
4344
import org.openhab.core.library.types.PercentType;
45+
import org.openhab.core.voice.InterpreterContext;
4446
import org.openhab.core.voice.KSService;
4547
import org.openhab.core.voice.STTService;
4648
import org.openhab.core.voice.TTSService;
4749
import org.openhab.core.voice.Voice;
4850
import org.openhab.core.voice.VoiceManager;
51+
import org.openhab.core.voice.text.Conversation;
52+
import org.openhab.core.voice.text.ConversationRole;
4953
import org.openhab.core.voice.text.HumanLanguageInterpreter;
5054
import org.openhab.core.voice.text.InterpretationException;
55+
import org.openhab.core.voice.text.LLMTool;
5156
import org.osgi.service.component.annotations.Activate;
5257
import org.osgi.service.component.annotations.Component;
5358
import org.osgi.service.component.annotations.Reference;
@@ -104,6 +109,43 @@ public VoiceResource( //
104109
this.voiceManager = voiceManager;
105110
}
106111

112+
@GET
113+
@Path("/conversations/{id: [a-zA-Z_0-9]+}")
114+
@Produces(MediaType.APPLICATION_JSON)
115+
@Operation(operationId = "getConversationById", summary = "Get a conversation.", responses = {
116+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = ConversationDTO.class))),
117+
@ApiResponse(responseCode = "404", description = "Conversation not found") })
118+
public Response getConversation(@PathParam("id") @Parameter(description = "conversation id") String id) {
119+
Conversation conversation = voiceManager.getConversation(id);
120+
if (conversation.getMessages().isEmpty()) {
121+
return JSONResponse.createErrorResponse(Status.NOT_FOUND, "No conversation found");
122+
}
123+
return Response.ok(ConversationMapper.map(conversation)).build();
124+
}
125+
126+
@DELETE
127+
@Path("/conversations/{id: [a-zA-Z_0-9]+}")
128+
@Produces(MediaType.APPLICATION_JSON)
129+
@Operation(operationId = "getConversationById", summary = "Deletes a conversation.", responses = {
130+
@ApiResponse(responseCode = "200", description = "OK"),
131+
@ApiResponse(responseCode = "404", description = "Conversation or message not found") })
132+
public Response deleteConversation(@PathParam("id") @Parameter(description = "conversation id") String id,
133+
@Parameter(description = "Optional message UID") @Nullable String messageUID) {
134+
Conversation conversation = voiceManager.getConversation(id);
135+
if (conversation.getMessages().isEmpty()) {
136+
return JSONResponse.createErrorResponse(Status.NOT_FOUND, "Conversation not found");
137+
}
138+
if (messageUID != null) {
139+
if (!conversation.removeSinceMessage(messageUID)) {
140+
return JSONResponse.createErrorResponse(Status.NOT_FOUND, "Message not found");
141+
}
142+
} else {
143+
conversation.removeMessages();
144+
}
145+
voiceManager.persistConversation(conversation);
146+
return Response.ok(null, MediaType.TEXT_PLAIN).build();
147+
}
148+
107149
@GET
108150
@Path("/interpreters")
109151
@Produces(MediaType.APPLICATION_JSON)
@@ -146,20 +188,31 @@ public Response getInterpreter(
146188
@ApiResponse(responseCode = "400", description = "interpretation exception occurs") })
147189
public Response interpret(
148190
@HeaderParam(HttpHeaders.ACCEPT_LANGUAGE) @Parameter(description = "language") @Nullable String language,
191+
@QueryParam("conversation") @Parameter(description = "Conversation id") String conversationId,
192+
@QueryParam("llmTools") @Parameter(description = "Comma separated list of llm-tool ids") List<String> llmToolIds,
193+
@QueryParam("locationItem") @Parameter(description = "Location item id to contextualize the command") @Nullable String locationItem,
149194
@Parameter(description = "text to interpret", required = true) String text,
150195
@PathParam("ids") @Parameter(description = "comma separated list of interpreter ids") List<String> ids) {
151196
final Locale locale = localeService.getLocale(language);
152197
List<HumanLanguageInterpreter> hlis = voiceManager.getHLIsByIds(ids);
153198
if (hlis.isEmpty()) {
154199
return JSONResponse.createErrorResponse(Status.NOT_FOUND, "No interpreter found");
155200
}
201+
List<LLMTool> llmTools = voiceManager.getLLMToolsByIds(llmToolIds);
202+
Conversation conversation = voiceManager.getConversation(conversationId);
203+
InterpreterContext interpreterContext = new InterpreterContext(conversation, llmTools, locationItem);
156204
String answer = "";
157205
String error = null;
158206
for (HumanLanguageInterpreter interpreter : hlis) {
159207
try {
160-
answer = interpreter.interpret(locale, text);
161-
logger.debug("Interpretation result: {}", answer);
208+
interpreter.interpret(locale, interpreterContext);
209+
Conversation.Message message = interpreterContext.conversation().getLastMessage();
210+
if (message != null && message.getRole() == ConversationRole.OPENHAB) {
211+
answer = message.getContent();
212+
}
162213
error = null;
214+
logger.debug("Interpretation result from interpreter '{}': {}", interpreter.getId(), answer);
215+
voiceManager.persistConversation(conversation);
163216
break;
164217
} catch (InterpretationException e) {
165218
logger.debug("Interpretation exception: {}", e.getMessage());

bundles/org.openhab.core.model.script/src/org/openhab/core/model/script/actions/Voice.java

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import org.openhab.core.model.script.engine.action.ActionDoc;
2626
import org.openhab.core.model.script.engine.action.ParamDoc;
2727
import org.openhab.core.model.script.internal.engine.action.VoiceActionService;
28+
import org.openhab.core.voice.InterpretationArguments;
2829
import org.openhab.core.voice.KSService;
2930
import org.openhab.core.voice.STTService;
3031
import org.openhab.core.voice.TTSService;
@@ -169,7 +170,7 @@ public static void say(@ParamDoc(name = "text") Object text, @ParamDoc(name = "v
169170
*/
170171
@ActionDoc(text = "interprets a given text by the default human language interpreter", returns = "human language response")
171172
public static String interpret(@ParamDoc(name = "text") Object text) {
172-
return interpret(text, null);
173+
return interpret(text, null, null, null, null);
173174
}
174175

175176
/**
@@ -182,10 +183,16 @@ public static String interpret(@ParamDoc(name = "text") Object text) {
182183
*/
183184
@ActionDoc(text = "interprets a given text by given human language interpreter(s)", returns = "human language response")
184185
public static String interpret(@ParamDoc(name = "text") Object text,
185-
@ParamDoc(name = "interpreters") @Nullable String interpreters) {
186+
@ParamDoc(name = "interpreters") @Nullable String interpreters,
187+
@ParamDoc(name = "conversation") @Nullable String conversation,
188+
@ParamDoc(name = "llm-tools") @Nullable String llmTools,
189+
@ParamDoc(name = "location") @Nullable String location) {
186190
String response;
187191
try {
188-
response = VoiceActionService.voiceManager.interpret(text.toString(), interpreters);
192+
response = VoiceActionService.voiceManager.interpret(text.toString(),
193+
new InterpretationArguments(Objects.requireNonNullElse(interpreters, ""),
194+
Objects.requireNonNullElse(conversation, ""), Objects.requireNonNullElse(llmTools, ""),
195+
Objects.requireNonNullElse(location, "")));
189196
} catch (InterpretationException e) {
190197
String message = Objects.requireNonNullElse(e.getMessage(), "");
191198
say(message);
@@ -206,10 +213,12 @@ public static String interpret(@ParamDoc(name = "text") Object text,
206213
*/
207214
@ActionDoc(text = "interprets a given text by given human language interpreter(s) and using the given sink", returns = "human language response")
208215
public static String interpret(@ParamDoc(name = "text") Object text,
209-
@ParamDoc(name = "interpreters") String interpreters, @ParamDoc(name = "sink") @Nullable String sink) {
216+
@ParamDoc(name = "interpreters") @Nullable String interpreters,
217+
@ParamDoc(name = "sink") @Nullable String sink) {
210218
String response;
211219
try {
212-
response = VoiceActionService.voiceManager.interpret(text.toString(), interpreters);
220+
response = VoiceActionService.voiceManager.interpret(text.toString(),
221+
new InterpretationArguments(interpreters != null ? interpreters : "", "", "", ""));
213222
} catch (InterpretationException e) {
214223
String message = Objects.requireNonNullElse(e.getMessage(), "");
215224
if (sink != null) {

bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DialogContext.java

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import org.openhab.core.audio.AudioSink;
2323
import org.openhab.core.audio.AudioSource;
2424
import org.openhab.core.voice.text.HumanLanguageInterpreter;
25+
import org.openhab.core.voice.text.LLMTool;
2526

2627
/**
2728
* Describes dialog configured services and options.
@@ -32,7 +33,7 @@
3233
public record DialogContext(@Nullable DTService dt, @Nullable String keyword, STTService stt, TTSService tts,
3334
@Nullable Voice voice, List<HumanLanguageInterpreter> hlis, AudioSource source, AudioSink sink, Locale locale,
3435
String dialogGroup, @Nullable String locationItem, @Nullable String listeningItem,
35-
@Nullable String listeningMelody) {
36+
@Nullable String listeningMelody, @Nullable String conversationId, List<LLMTool> llmTools) {
3637

3738
/**
3839
* Builder for {@link DialogContext}
@@ -47,7 +48,9 @@ public static class Builder {
4748
private @Nullable TTSService tts;
4849
private @Nullable Voice voice;
4950
private List<HumanLanguageInterpreter> hlis = List.of();
51+
private List<LLMTool> llmTools = List.of();
5052
// options
53+
private @Nullable String conversationId;
5154
private String dialogGroup = "default";
5255
private @Nullable String locationItem;
5356
private @Nullable String listeningItem;
@@ -130,6 +133,20 @@ public Builder withVoice(@Nullable Voice voice) {
130133
return this;
131134
}
132135

136+
public Builder withConversationId(@Nullable String conversationId) {
137+
if (conversationId != null) {
138+
this.conversationId = conversationId;
139+
}
140+
return this;
141+
}
142+
143+
public Builder withLLMTools(List<LLMTool> llmTools) {
144+
if (!llmTools.isEmpty()) {
145+
this.llmTools = llmTools;
146+
}
147+
return this;
148+
}
149+
133150
public Builder withDialogGroup(@Nullable String dialogGroup) {
134151
if (dialogGroup != null) {
135152
this.dialogGroup = dialogGroup;
@@ -199,7 +216,8 @@ public DialogContext build() throws IllegalStateException {
199216
throw new IllegalStateException("Cannot build dialog context: " + String.join(", ", errors) + ".");
200217
} else {
201218
return new DialogContext(dtService, keyword, sttService, ttsService, voice, hliServices, audioSource,
202-
audioSink, locale, dialogGroup, locationItem, listeningItem, listeningMelody);
219+
audioSink, locale, dialogGroup, locationItem, listeningItem, listeningMelody, conversationId,
220+
llmTools);
203221
}
204222
}
205223
}

bundles/org.openhab.core.voice/src/main/java/org/openhab/core/voice/DialogRegistration.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ public class DialogRegistration {
5757
* List of interpreters
5858
*/
5959
public List<String> hliIds = List.of();
60+
/**
61+
* Conversation id.
62+
*/
63+
public @Nullable String conversationId;
64+
/**
65+
* List of LLM tools
66+
*/
67+
public List<String> llmToolIds = List.of();
6068
/**
6169
* Dialog locale
6270
*/
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright (c) 2010-2026 Contributors to the openHAB project
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License 2.0 which is available at
9+
* http://www.eclipse.org/legal/epl-2.0
10+
*
11+
* SPDX-License-Identifier: EPL-2.0
12+
*/
13+
package org.openhab.core.voice;
14+
15+
import org.eclipse.jdt.annotation.NonNullByDefault;
16+
17+
/**
18+
* This service provides functionality around voice services and is the central service to be used directly by others.
19+
*
20+
* @author Miguel Álvarez Díez - Initial contribution
21+
*/
22+
@NonNullByDefault
23+
public record InterpretationArguments(String hliIdList, String conversationId, String toolIdList, String locationItem) {
24+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright (c) 2010-2026 Contributors to the openHAB project
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License 2.0 which is available at
9+
* http://www.eclipse.org/legal/epl-2.0
10+
*
11+
* SPDX-License-Identifier: EPL-2.0
12+
*/
13+
package org.openhab.core.voice;
14+
15+
import java.util.List;
16+
17+
import org.eclipse.jdt.annotation.NonNullByDefault;
18+
import org.eclipse.jdt.annotation.Nullable;
19+
import org.openhab.core.voice.text.Conversation;
20+
import org.openhab.core.voice.text.LLMTool;
21+
22+
/**
23+
* Context passed to the {@link org.openhab.core.voice.text.HumanLanguageInterpreter}
24+
* when interpreting a new input text.
25+
*
26+
* @author Miguel Álvarez Díez - Initial contribution
27+
*/
28+
@NonNullByDefault
29+
public record InterpreterContext(Conversation conversation, List<LLMTool> tools, @Nullable String locationItem) {
30+
31+
}

0 commit comments

Comments
 (0)