diff --git a/embabel-common-chat/pom.xml b/embabel-common-chat/pom.xml new file mode 100644 index 0000000..30fa198 --- /dev/null +++ b/embabel-common-chat/pom.xml @@ -0,0 +1,29 @@ + + + 4.0.0 + + com.embabel.common + embabel-common-parent + 0.1.10-SNAPSHOT + + embabel-common-chat + jar + Embabel Common Chat + Common chat and conversation abstractions for storage-agnostic persistence + https://github.com/embabel/embabel-common + + + https://github.com/embabel/embabel-common + scm:git:https://github.com/embabel/embabel-common.git + scm:git:https://github.com/embabel/embabel-common.git + HEAD + + + + + com.embabel.common + embabel-common-core + ${project.version} + + + \ No newline at end of file diff --git a/embabel-common-chat/src/main/kotlin/com/embabel/common/chat/ConversationFactory.kt b/embabel-common-chat/src/main/kotlin/com/embabel/common/chat/ConversationFactory.kt new file mode 100644 index 0000000..67c9d62 --- /dev/null +++ b/embabel-common-chat/src/main/kotlin/com/embabel/common/chat/ConversationFactory.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.common.chat + +/** + * Type of conversation storage. + */ +enum class ConversationStoreType { + + /** + * Conversations are stored in memory only. + * Fast and simple, suitable for testing and ephemeral sessions. + */ + IN_MEMORY, + + /** + * Conversations are persisted to a backing store. + * The specific store (e.g., Neo4j) is configured at factory level. + */ + STORED +} + +/** + * Factory for creating [StorableConversation] instances. + * + * Implementations provide different storage strategies (in-memory, persistent, etc.). + * Use [ConversationFactoryProvider] to obtain factories by type. + */ +interface ConversationFactory { + + /** + * The storage type this factory provides. + */ + val storeType: ConversationStoreType + + /** + * Create a new conversation with the given ID. + * + * @param id unique identifier for the conversation + * @return a new StorableConversation instance + */ + fun create(id: String): StorableConversation + + /** + * Create a conversation for a 1-1 chat between a user and an agent. + * + * Messages can be automatically attributed based on role when participants are set. + * + * @param id the conversation/session ID + * @param user the human user participant + * @param agent the AI/system user participant (optional) + * @param title the session title (optional) + * @return a new StorableConversation instance + */ + fun createForParticipants( + id: String, + user: MessageAuthor, + agent: MessageAuthor? = null, + title: String? = null + ): StorableConversation = create(id) +} diff --git a/embabel-common-chat/src/main/kotlin/com/embabel/common/chat/ConversationFactoryProvider.kt b/embabel-common-chat/src/main/kotlin/com/embabel/common/chat/ConversationFactoryProvider.kt new file mode 100644 index 0000000..1665dd4 --- /dev/null +++ b/embabel-common-chat/src/main/kotlin/com/embabel/common/chat/ConversationFactoryProvider.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.common.chat + +/** + * Provider for [ConversationFactory] instances by type. + * + * Implementations resolve factories based on [ConversationStoreType], + * typically backed by Spring beans registered via autoconfiguration. + * + * To use conversation factories, either: + * - Inject [ConversationFactoryProvider] directly via Spring DI + * - Use the fluent API in agent frameworks: `context.ai().conversationFactory(type)` + */ +interface ConversationFactoryProvider { + + /** + * Get a conversation factory for the given store type. + * + * @param type the conversation store type + * @return the factory for that type + * @throws IllegalArgumentException if no factory is registered for the type + */ + fun getFactory(type: ConversationStoreType): ConversationFactory + + /** + * Get a conversation factory for the given store type, or null if not available. + * + * @param type the conversation store type + * @return the factory for that type, or null + */ + fun getFactoryOrNull(type: ConversationStoreType): ConversationFactory? + + /** + * Get all registered factory types. + */ + fun availableTypes(): Set +} + +/** + * Simple map-based implementation of [ConversationFactoryProvider]. + */ +class MapConversationFactoryProvider( + private val factories: Map +) : ConversationFactoryProvider { + + constructor(vararg factories: ConversationFactory) : this( + factories.associateBy { it.storeType } + ) + + constructor(factories: List) : this( + factories.associateBy { it.storeType } + ) + + override fun getFactory(type: ConversationStoreType): ConversationFactory { + return factories[type] + ?: throw IllegalArgumentException( + "No ConversationFactory registered for type $type. Available: ${factories.keys}" + ) + } + + override fun getFactoryOrNull(type: ConversationStoreType): ConversationFactory? { + return factories[type] + } + + override fun availableTypes(): Set = factories.keys +} diff --git a/embabel-common-chat/src/main/kotlin/com/embabel/common/chat/MessageAuthor.kt b/embabel-common-chat/src/main/kotlin/com/embabel/common/chat/MessageAuthor.kt new file mode 100644 index 0000000..e84166f --- /dev/null +++ b/embabel-common-chat/src/main/kotlin/com/embabel/common/chat/MessageAuthor.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.common.chat + +/** + * Represents the author of a message in a conversation. + * + * This interface provides a persistence-agnostic abstraction for user identity + * in chat contexts. Implementations can extend this interface with additional + * persistence-specific annotations. + * + * ## Usage + * + * For simple cases, use the provided [SimpleMessageAuthor] data class: + * ```kotlin + * val author = SimpleMessageAuthor(id = "user-123", displayName = "Alice") + * conversation.addMessageFrom(message, author) + * ``` + */ +interface MessageAuthor { + + /** + * Unique identifier for the author. + */ + val id: String + + /** + * Human-readable display name for the author. + */ + val displayName: String +} + +/** + * Simple implementation of [MessageAuthor] for non-persistent use cases. + */ +data class SimpleMessageAuthor( + override val id: String, + override val displayName: String +) : MessageAuthor diff --git a/embabel-common-chat/src/main/kotlin/com/embabel/common/chat/MessageRole.kt b/embabel-common-chat/src/main/kotlin/com/embabel/common/chat/MessageRole.kt new file mode 100644 index 0000000..c276ff1 --- /dev/null +++ b/embabel-common-chat/src/main/kotlin/com/embabel/common/chat/MessageRole.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.common.chat + +/** + * Role of a message sender in a conversation. + */ +enum class MessageRole { + USER, + ASSISTANT, + SYSTEM +} diff --git a/embabel-common-chat/src/main/kotlin/com/embabel/common/chat/StorableConversation.kt b/embabel-common-chat/src/main/kotlin/com/embabel/common/chat/StorableConversation.kt new file mode 100644 index 0000000..1412f93 --- /dev/null +++ b/embabel-common-chat/src/main/kotlin/com/embabel/common/chat/StorableConversation.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.common.chat + +/** + * Minimal conversation interface for storage-agnostic persistence. + * + * This interface captures the essential properties needed to persist a conversation + * without coupling to agent-specific types like PromptContributor or AssetTracker. + * + * Implementations in agent frameworks (e.g., embabel-agent) can extend this + * interface with richer functionality while still being storable. + */ +interface StorableConversation { + + /** + * Unique identifier for this conversation. + */ + val id: String + + /** + * Messages in the conversation in chronological order. + */ + val messages: List + + /** + * Whether this conversation is backed by persistent storage. + * + * Returns `true` for database-backed conversations, `false` for in-memory. + */ + fun persistent(): Boolean = false + + /** + * Add a message to the conversation. + * + * @param message the message to add + * @return the added message (may be wrapped or enhanced by the implementation) + */ + fun addMessage(message: StorableMessage): StorableMessage + + /** + * Add a message with explicit author attribution. + * + * @param message the message to add + * @param author the author of this message + * @return the added message + */ + fun addMessageFrom(message: StorableMessage, author: MessageAuthor?): StorableMessage = + addMessage(message) + + /** + * Add a message with explicit author and recipient. + * + * @param message the message to add + * @param from the author of this message + * @param to the recipient of this message + * @return the added message + */ + fun addMessageFromTo( + message: StorableMessage, + from: MessageAuthor?, + to: MessageAuthor? + ): StorableMessage = addMessage(message) + + /** + * Create a view of this conversation with only the last n messages. + * + * Implementations may optimize this (e.g., database query with LIMIT) + * or use the default in-memory slicing. + * + * @param n the number of messages to include + * @return a conversation view with the last n messages + */ + fun last(n: Int): StorableConversation = SlicedStorableConversation( + id = id, + slicedMessages = messages.takeLast(n) + ) +} + +/** + * A read-only slice of a conversation with a subset of messages. + */ +private class SlicedStorableConversation( + override val id: String, + private val slicedMessages: List +) : StorableConversation { + + override val messages: List + get() = slicedMessages + + override fun persistent(): Boolean = false + + override fun addMessage(message: StorableMessage): StorableMessage { + throw UnsupportedOperationException("Cannot add messages to a conversation slice") + } + + override fun last(n: Int): StorableConversation = SlicedStorableConversation( + id = id, + slicedMessages = slicedMessages.takeLast(n) + ) +} diff --git a/embabel-common-chat/src/main/kotlin/com/embabel/common/chat/StorableMessage.kt b/embabel-common-chat/src/main/kotlin/com/embabel/common/chat/StorableMessage.kt new file mode 100644 index 0000000..d594ce2 --- /dev/null +++ b/embabel-common-chat/src/main/kotlin/com/embabel/common/chat/StorableMessage.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2024-2026 Embabel Pty Ltd. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.embabel.common.chat + +import java.time.Instant + +/** + * Minimal message interface for storage-agnostic persistence. + * + * This interface captures the essential properties needed to persist a message + * without coupling to agent-specific types like Awaitable or ActionContext. + * + * Implementations in agent frameworks (e.g., embabel-agent) can extend this + * interface with richer functionality while still being storable. + */ +interface StorableMessage { + + /** + * The role of the message sender. + */ + val role: MessageRole + + /** + * The text content of the message. + */ + val content: String + + /** + * When the message was created. + */ + val timestamp: Instant + + /** + * Optional identifier of the message author. + * Useful for multi-user conversations. + */ + val authorId: String? + get() = null + + /** + * Optional identifier of the message recipient. + * Useful for directed messages in group chats. + */ + val recipientId: String? + get() = null +} + +/** + * Simple implementation of [StorableMessage] for basic use cases. + */ +data class SimpleStorableMessage( + override val role: MessageRole, + override val content: String, + override val timestamp: Instant = Instant.now(), + override val authorId: String? = null, + override val recipientId: String? = null +) : StorableMessage diff --git a/embabel-common-dependencies/pom.xml b/embabel-common-dependencies/pom.xml index 6c26f70..0576268 100644 --- a/embabel-common-dependencies/pom.xml +++ b/embabel-common-dependencies/pom.xml @@ -42,6 +42,12 @@ ${project.version} + + com.embabel.common + embabel-common-chat + ${project.version} + + diff --git a/pom.xml b/pom.xml index 6d43888..b30f8f0 100644 --- a/pom.xml +++ b/pom.xml @@ -21,6 +21,7 @@ embabel-common-dependencies embabel-common-core + embabel-common-chat embabel-common-util embabel-common-textio embabel-common-test