Skip to content

Commit 3ecf4ca

Browse files
committed
Store chat history in threads.
1 parent 9622519 commit 3ecf4ca

File tree

15 files changed

+431
-29
lines changed

15 files changed

+431
-29
lines changed

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
<dependency>
4747
<groupId>org.drivine</groupId>
4848
<artifactId>drivine4j-spring-boot-starter</artifactId>
49-
<version>0.0.18</version>
49+
<version>0.0.19</version>
5050
</dependency>
5151

5252

src/main/kotlin/com/embabel/guide/chat/controller/ChatApiController.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ import org.springframework.web.bind.annotation.RestController
1010
@RequestMapping("/api/messages")
1111
class ChatApiController(private val jesseService: JesseService) {
1212

13-
data class SendMessageRequest(val threadId: String, val fromUserId: String, val body: String)
13+
data class SendMessageRequest(val threadId: String, val fromWebUserId: String, val body: String)
1414

1515
@PostMapping("/send")
1616
fun sendMessage(@RequestBody req: SendMessageRequest) {
1717
jesseService.receiveMessage(
1818
threadId = req.threadId,
19-
fromUserId = req.fromUserId,
19+
fromWebUserId = req.fromWebUserId,
2020
message = req.body
2121
)
2222
}

src/main/kotlin/com/embabel/guide/chat/controller/ChatController.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,27 @@ package com.embabel.guide.chat.controller
22

33
import com.embabel.guide.chat.model.ChatMessage
44
import com.embabel.guide.chat.service.JesseService
5+
import org.slf4j.LoggerFactory
56
import org.springframework.messaging.handler.annotation.MessageMapping
67
import org.springframework.stereotype.Controller
78
import java.security.Principal
89

910
@Controller
1011
class ChatController(private val jesseService: JesseService) {
1112

13+
private val logger = LoggerFactory.getLogger(ChatController::class.java)
14+
1215
/**
1316
* Receive a chat message and send it to Jesse for processing.
1417
* Messages are persisted to the specified thread.
1518
*/
1619
@MessageMapping("chat.send")
1720
fun receive(principal: Principal, payload: ChatMessage) {
21+
logger.info("ChatController received message from webUser {} in thread {}: {}",
22+
principal.name, payload.threadId, payload.body)
1823
jesseService.receiveMessage(
1924
threadId = payload.threadId,
20-
fromUserId = principal.name,
25+
fromWebUserId = principal.name,
2126
message = payload.body
2227
)
2328
}

src/main/kotlin/com/embabel/guide/chat/model/ChatMessage.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ package com.embabel.guide.chat.model
22

33
/**
44
* Incoming chat message from a client.
5+
* Default values required for STOMP message converter deserialization.
56
*/
67
data class ChatMessage(
7-
val threadId: String,
8-
val body: String
8+
val threadId: String = "",
9+
val body: String = ""
910
)

src/main/kotlin/com/embabel/guide/chat/model/ThreadTimeline.kt

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import org.drivine.annotation.Root
99
/**
1010
* Thread timeline with messages.
1111
* Each message includes its current version text and author.
12-
* Messages are sorted by messageId (UUIDv7 = chronological order).
12+
* Messages are sorted by messageId (UUIDv7 = chronological order) via DSL deep sorting.
1313
*/
1414
@GraphView
1515
data class ThreadTimeline(
@@ -19,12 +19,9 @@ data class ThreadTimeline(
1919
val owner: GuideUser,
2020

2121
@GraphRelationship(type = "HAS_MESSAGE", direction = Direction.OUTGOING)
22-
private val _messages: List<MessageWithVersion>
22+
val messages: List<MessageWithVersion> = emptyList()
2323
) {
24-
/** Messages sorted by messageId (UUIDv7 = chronological order). Sorted once on first access. */
25-
val messages: List<MessageWithVersion> by lazy { _messages.sortedBy { it.message.messageId } }
26-
2724
/** Returns a copy of this timeline with an additional message. */
2825
fun withMessage(message: MessageWithVersion): ThreadTimeline =
29-
copy(_messages = _messages + message)
26+
copy(messages = messages + message)
3027
}

src/main/kotlin/com/embabel/guide/chat/repository/ThreadRepositoryImpl.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ class ThreadRepositoryImpl(
2222
where {
2323
thread.threadId eq threadId
2424
}
25+
orderBy {
26+
messages.message.messageId.asc()
27+
}
2528
}
2629
return Optional.ofNullable(results.firstOrNull())
2730
}
@@ -32,6 +35,9 @@ class ThreadRepositoryImpl(
3235
where {
3336
owner.core.id eq ownerId
3437
}
38+
orderBy {
39+
messages.message.messageId.asc()
40+
}
3541
}
3642
}
3743

@@ -68,7 +74,7 @@ class ThreadRepositoryImpl(
6874
createdAt = now
6975
),
7076
owner = owner,
71-
_messages = listOf(
77+
messages = listOf(
7278
MessageWithVersion(
7379
message = MessageData(
7480
messageId = messageId,

src/main/kotlin/com/embabel/guide/chat/service/FakeRagServiceAdapter.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,15 @@ class FakeRagServiceAdapter : RagServiceAdapter {
7474
logger.debug("Fake RAG response generated for user: {}", fromUserId)
7575
return response
7676
}
77+
78+
override suspend fun generateTitle(content: String, fromUserId: String): String {
79+
logger.debug("Generating fake title for content from user: {}", fromUserId)
80+
// Return first few words of content as title, or a default
81+
val words = content.trim().split("\\s+".toRegex())
82+
return if (words.size <= 4) {
83+
content.take(50)
84+
} else {
85+
words.take(4).joinToString(" ") + "..."
86+
}
87+
}
7788
}

src/main/kotlin/com/embabel/guide/chat/service/GuideRagServiceAdapter.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,33 @@ class GuideRagServiceAdapter(
155155
waited += POLL_INTERVAL_MS.toInt()
156156
}
157157
}
158+
159+
/**
160+
* Generates a short title from message content using a one-shot call.
161+
* This does NOT use the user's session to avoid polluting conversation history.
162+
*/
163+
override suspend fun generateTitle(content: String, fromUserId: String): String = withContext(Dispatchers.IO) {
164+
logger.debug("Generating title for content from user: {}", fromUserId)
165+
166+
val responseBuilder = StringBuilder()
167+
var isComplete = false
168+
169+
val outputChannel = createOutputChannel(responseBuilder, {}) { isComplete = true }
170+
171+
try {
172+
val guideUser = guideUserRepository.findById(fromUserId)
173+
.orElseThrow { RuntimeException("No user found with id: $fromUserId") }
174+
175+
// Create a one-shot session (not cached) for title generation
176+
val session = chatbot.createSession(guideUser, outputChannel, null)
177+
session.onUserMessage(UserMessage(RagServiceAdapter.TITLE_PROMPT + content))
178+
179+
waitForResponse { isComplete }
180+
181+
responseBuilder.toString().trim().take(100).ifBlank { "New conversation" }
182+
} catch (e: Exception) {
183+
logger.error("Error generating title for user {}: {}", fromUserId, e.message, e)
184+
"New conversation" // Fallback title on error
185+
}
186+
}
158187
}

src/main/kotlin/com/embabel/guide/chat/service/JesseService.kt

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.embabel.guide.chat.service
33
import com.embabel.guide.chat.model.DeliveredMessage
44
import com.embabel.guide.chat.model.MessageWithVersion
55
import com.embabel.guide.chat.model.StatusMessage
6+
import com.embabel.guide.domain.GuideUserService
67
import kotlinx.coroutines.CoroutineScope
78
import kotlinx.coroutines.Dispatchers
89
import kotlinx.coroutines.launch
@@ -17,7 +18,8 @@ class JesseService(
1718
private val chatService: ChatService,
1819
private val presenceService: PresenceService,
1920
private val ragAdapter: RagServiceAdapter,
20-
private val threadService: ThreadService
21+
private val threadService: ThreadService,
22+
private val guideUserService: GuideUserService
2123
) {
2224
private val logger = LoggerFactory.getLogger(JesseService::class.java)
2325
private val coroutineScope = CoroutineScope(Dispatchers.IO)
@@ -59,26 +61,33 @@ class JesseService(
5961
* Receive a message from a user, persist it, get AI response, and send back.
6062
*
6163
* @param threadId the thread to add messages to
62-
* @param fromUserId the user sending the message (GuideUser ID)
64+
* @param fromWebUserId the WebUser ID from the JWT principal
6365
* @param message the message text
6466
*/
65-
fun receiveMessage(threadId: String, fromUserId: String, message: String) {
66-
logger.info("Jesse received message from user {} in thread {}", fromUserId, threadId)
67+
fun receiveMessage(threadId: String, fromWebUserId: String, message: String) {
68+
logger.info("Jesse received message from webUser {} in thread {}", fromWebUserId, threadId)
6769

6870
coroutineScope.launch {
6971
try {
72+
// Look up the GuideUser by WebUser ID
73+
val guideUser = guideUserService.findByWebUserId(fromWebUserId).orElseThrow {
74+
IllegalArgumentException("User not found for webUserId: $fromWebUserId")
75+
}
76+
val guideUserId = guideUser.core.id
77+
7078
// Save the user's message to the thread
7179
threadService.addMessage(
7280
threadId = threadId,
7381
text = message,
7482
role = ThreadService.ROLE_USER,
75-
authorId = fromUserId
83+
authorId = guideUserId
7684
)
7785

7886
// Send status updates to the user while processing
79-
val response = ragAdapter.sendMessage(message, fromUserId) { event ->
80-
logger.debug("RAG event for user {}: {}", fromUserId, event)
81-
sendStatusToUser(fromUserId, event)
87+
// Use WebUser ID for WebSocket delivery (that's the principal in the session)
88+
val response = ragAdapter.sendMessage(message, guideUserId) { event ->
89+
logger.debug("RAG event for user {}: {}", fromWebUserId, event)
90+
sendStatusToUser(fromWebUserId, event)
8291
}
8392

8493
// Save the assistant's response to the thread
@@ -89,11 +98,11 @@ class JesseService(
8998
authorId = null // System-generated response
9099
)
91100

92-
// Send the response to the user via WebSocket
93-
sendMessageToUser(fromUserId, assistantMessage, threadId)
101+
// Send the response to the user via WebSocket (use WebUser ID)
102+
sendMessageToUser(fromWebUserId, assistantMessage, threadId)
94103
} catch (e: Exception) {
95-
logger.error("Error processing message from user {}: {}", fromUserId, e.message, e)
96-
sendStatusToUser(fromUserId, "Error processing your request")
104+
logger.error("Error processing message from webUser {}: {}", fromWebUserId, e.message, e)
105+
sendStatusToUser(fromWebUserId, "Error processing your request")
97106

98107
// Save error message to thread and send to user
99108
val errorMessage = threadService.addMessage(
@@ -102,7 +111,7 @@ class JesseService(
102111
role = ThreadService.ROLE_ASSISTANT,
103112
authorId = null
104113
)
105-
sendMessageToUser(fromUserId, errorMessage, threadId)
114+
sendMessageToUser(fromWebUserId, errorMessage, threadId)
106115
}
107116
}
108117
}

src/main/kotlin/com/embabel/guide/chat/service/RagServiceAdapter.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ package com.embabel.guide.chat.service
88
*/
99
interface RagServiceAdapter {
1010

11+
companion object {
12+
const val TITLE_PROMPT = "Generate a short title (max 6 words) for this message. " +
13+
"Reply with ONLY the title, no quotes or punctuation: "
14+
}
15+
1116
/**
1217
* Sends a message to the RAG system and returns the response.
1318
*
@@ -25,4 +30,16 @@ interface RagServiceAdapter {
2530
fromUserId: String,
2631
onEvent: (String) -> Unit = {}
2732
): String
33+
34+
/**
35+
* Generates a short title from message content.
36+
*
37+
* @param content The message content to generate a title from
38+
* @param fromUserId The ID of the user (for session context)
39+
* @return A short title (typically 3-6 words)
40+
*/
41+
suspend fun generateTitle(content: String, fromUserId: String): String {
42+
val response = sendMessage(TITLE_PROMPT + content, fromUserId)
43+
return response.trim().take(100) // Safety limit
44+
}
2845
}

0 commit comments

Comments
 (0)